Skip to content

Commit c8dcde8

Browse files
committed
Merge remote-tracking branch 'origin/main' into docs-pipeline-p5.1-cli-snapshots
2 parents 59fe843 + e6ff7a9 commit c8dcde8

18 files changed

Lines changed: 413 additions & 50 deletions

Makefile

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Forge developer convenience targets.
22
# CI enforces fmt, clippy (-D warnings), tests, and the documentation drift gate.
33

4-
.PHONY: docs-check docs-report docs-api docs-counts docs-crates docs-baseline install-hooks fmt clippy test check ci
4+
.PHONY: docs-check docs-report docs-api docs-counts docs-crates docs-examples docs-baseline install-hooks fmt clippy test check ci
55

66
# Documentation drift gate (baseline-aware): fails only on NEW drift beyond the
77
# recorded baseline. Same logic as CI and the pre-commit hook.
@@ -23,6 +23,11 @@ docs-counts:
2323
docs-crates:
2424
cargo run -q -p forge-docs-check -- --write-crate-pages
2525

26+
# Regenerate every <!-- forge:example --> block from each example app's
27+
# runtime:* imports.
28+
docs-examples:
29+
cargo run -q -p forge-docs-check -- --write-example-blocks
30+
2631
# Full drift report: list ALL current drift (fails if any exists at all).
2732
# Useful for seeing the whole backlog; not the burn-down gate.
2833
docs-report:

crates/forge-docs-check/src/apiblock.rs

Lines changed: 11 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
2222
use crate::checks::{read_optional, HOOK_PLUMBING};
2323
use crate::discovery::Workspace;
24+
use crate::markers;
2425
use crate::Finding;
2526
use regex::Regex;
2627
use std::collections::{HashMap, HashSet};
@@ -129,25 +130,6 @@ pub fn render_block_body(module: &str, signatures: &[String]) -> String {
129130
out
130131
}
131132

132-
/// Locate a marker region in `page`. Returns `(open_start, close_end, body)`
133-
/// where `body` is the exact text strictly between the open and close markers
134-
/// (trimmed of the single surrounding newlines).
135-
fn find_block(page: &str) -> Option<(usize, usize, String)> {
136-
let open = page.find(BLOCK_OPEN)?;
137-
let after_open = open + BLOCK_OPEN.len();
138-
let close_rel = page[after_open..].find(BLOCK_CLOSE)?;
139-
let close = after_open + close_rel;
140-
let close_end = close + BLOCK_CLOSE.len();
141-
// Normalize CRLF -> LF so the comparison is line-ending-agnostic: on Windows
142-
// git may check the docs out with CRLF, while the generated `expected` body
143-
// uses LF. Without this the gate reports a phantom "stale" block on Windows.
144-
let body = page[after_open..close]
145-
.replace("\r\n", "\n")
146-
.trim_matches('\n')
147-
.to_string();
148-
Some((open, close_end, body))
149-
}
150-
151133
/// Rule `api-block`: every page that opts in (contains `<!-- forge:api -->`) has
152134
/// a block whose body matches the current SDK signatures.
153135
pub fn check(ws: &Workspace) -> Vec<Finding> {
@@ -167,7 +149,7 @@ pub fn check(ws: &Workspace) -> Vec<Finding> {
167149
None => continue,
168150
};
169151
let expected = render_block_body(&module.name, &public_signatures(&sdk));
170-
match find_block(&page) {
152+
match markers::find_region(&page, BLOCK_OPEN, BLOCK_CLOSE) {
171153
Some((_, _, body)) if body == expected => {}
172154
Some((_, _, _)) => findings.push(Finding::new(
173155
"api-block",
@@ -209,20 +191,16 @@ pub fn write_all(ws: &Workspace) -> std::io::Result<Vec<std::path::PathBuf>> {
209191
None => continue,
210192
};
211193
let expected = render_block_body(&module.name, &public_signatures(&sdk));
212-
if let Some((open, close_end, body)) = find_block(&page) {
194+
if let Some((_, _, body)) = markers::find_region(&page, BLOCK_OPEN, BLOCK_CLOSE) {
213195
if body == expected {
214196
continue;
215197
}
216-
let mut updated = String::with_capacity(page.len());
217-
updated.push_str(&page[..open]);
218-
updated.push_str(BLOCK_OPEN);
219-
updated.push('\n');
220-
updated.push_str(&expected);
221-
updated.push('\n');
222-
updated.push_str(BLOCK_CLOSE);
223-
updated.push_str(&page[close_end..]);
224-
std::fs::write(&page_path, updated)?;
225-
written.push(page_path);
198+
if let Some(updated) =
199+
markers::replace_region(&page, BLOCK_OPEN, BLOCK_CLOSE, &expected)
200+
{
201+
std::fs::write(&page_path, updated)?;
202+
written.push(page_path);
203+
}
226204
}
227205
}
228206
Ok(written)
@@ -269,23 +247,6 @@ export { execute as exec };
269247
);
270248
}
271249

272-
#[test]
273-
fn find_block_extracts_body() {
274-
let page = "intro\n<!-- forge:api -->\nBODY\nLINE2\n<!-- /forge:api -->\noutro\n";
275-
let (_, _, body) = find_block(page).expect("block present");
276-
assert_eq!(body, "BODY\nLINE2");
277-
}
278-
279-
#[test]
280-
fn find_block_normalizes_crlf() {
281-
// Windows git checkout may use CRLF; the extracted body must match the
282-
// LF-generated `expected`, so the gate is line-ending-agnostic.
283-
let page =
284-
"intro\r\n<!-- forge:api -->\r\nBODY\r\nLINE2\r\n<!-- /forge:api -->\r\nouttro\r\n";
285-
let (_, _, body) = find_block(page).expect("block present");
286-
assert_eq!(body, "BODY\nLINE2");
287-
}
288-
289250
#[test]
290251
fn public_signatures_is_line_ending_agnostic() {
291252
// Same source, LF vs CRLF, must yield identical signatures — otherwise a
@@ -310,7 +271,8 @@ export { execute as exec };
310271
"# page\r\n\r\n{BLOCK_OPEN}\r\n{}\r\n{BLOCK_CLOSE}\r\n\r\n## next\r\n",
311272
expected.replace('\n', "\r\n")
312273
);
313-
let (_, _, body) = find_block(&on_disk).expect("block present");
274+
let (_, _, body) =
275+
markers::find_region(&on_disk, BLOCK_OPEN, BLOCK_CLOSE).expect("block present");
314276
assert_eq!(body, expected, "CRLF on-disk block must match LF expected");
315277
}
316278

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
//! Marker-hybrid example blocks (`<!-- forge:example -->`).
2+
//!
3+
//! Each example app under `examples/<name>/` imports a set of `runtime:*`
4+
//! modules in its `src/`. The matching docs page (`docs/examples/<name>.md`)
5+
//! can opt in with a `<!-- forge:example -->` block listing those modules; the
6+
//! `example-block` rule fails CI when the block is stale and `make docs-examples`
7+
//! regenerates it from the app source. Prose outside the markers is untouched.
8+
//!
9+
//! This extends drift protection to a real, easily-missed category: an example
10+
//! app gains/loses a `runtime:*` dependency but its doc page is not updated.
11+
12+
use crate::checks::read_optional;
13+
use crate::discovery::Workspace;
14+
use crate::{markers, Finding};
15+
use regex::Regex;
16+
use std::path::{Path, PathBuf};
17+
18+
pub const BLOCK_OPEN: &str = "<!-- forge:example -->";
19+
pub const BLOCK_CLOSE: &str = "<!-- /forge:example -->";
20+
21+
/// Discover example apps: immediate subdirectories of `examples/` that contain a
22+
/// `manifest.app.toml`. Returns `(name, dir)` sorted by name.
23+
pub fn example_apps(ws: &Workspace) -> Vec<(String, PathBuf)> {
24+
let examples_dir = ws.root.join("examples");
25+
let mut apps = Vec::new();
26+
let entries = match std::fs::read_dir(&examples_dir) {
27+
Ok(e) => e,
28+
Err(_) => return apps,
29+
};
30+
for entry in entries.flatten() {
31+
let dir = entry.path();
32+
if dir.is_dir() && dir.join("manifest.app.toml").exists() {
33+
if let Some(name) = dir.file_name().and_then(|n| n.to_str()) {
34+
apps.push((name.to_string(), dir.clone()));
35+
}
36+
}
37+
}
38+
apps.sort_by(|a, b| a.0.cmp(&b.0));
39+
apps
40+
}
41+
42+
/// The sorted, unique `runtime:*` modules imported anywhere under `<app>/src`.
43+
pub fn runtime_modules(app_dir: &Path) -> Vec<String> {
44+
let re = Regex::new(r"runtime:([a-z_]+)").expect("valid runtime-module regex");
45+
let mut mods: Vec<String> = Vec::new();
46+
for ts in ts_sources(&app_dir.join("src")) {
47+
if let Some(src) = read_optional(&ts) {
48+
for cap in re.captures_iter(&src) {
49+
mods.push(format!("runtime:{}", &cap[1]));
50+
}
51+
}
52+
}
53+
mods.sort();
54+
mods.dedup();
55+
mods
56+
}
57+
58+
/// Render the body between the markers.
59+
pub fn render_block_body(name: &str, modules: &[String]) -> String {
60+
let list = if modules.is_empty() {
61+
"_none_".to_string()
62+
} else {
63+
modules
64+
.iter()
65+
.map(|m| format!("`{m}`"))
66+
.collect::<Vec<_>>()
67+
.join(", ")
68+
};
69+
format!(
70+
"<!-- generated from examples/{name}/src — run `make docs-examples` to refresh -->\n**Runtime modules used:** {list}",
71+
)
72+
}
73+
74+
/// The docs page path for an example app.
75+
fn page_path(ws: &Workspace, name: &str) -> PathBuf {
76+
ws.docs_dir().join("examples").join(format!("{name}.md"))
77+
}
78+
79+
/// Rule `example-block`: every opted-in example page's block matches the modules
80+
/// its app actually imports.
81+
pub fn check(ws: &Workspace) -> Vec<Finding> {
82+
let mut findings = Vec::new();
83+
for (name, dir) in example_apps(ws) {
84+
let page = match read_optional(&page_path(ws, &name)) {
85+
Some(p) => p,
86+
None => continue,
87+
};
88+
if !page.contains(BLOCK_OPEN) {
89+
continue; // not opted in
90+
}
91+
let expected = render_block_body(&name, &runtime_modules(&dir));
92+
match markers::find_region(&page, BLOCK_OPEN, BLOCK_CLOSE) {
93+
Some((_, _, body)) if body == expected => {}
94+
Some(_) => findings.push(Finding::new(
95+
"example-block",
96+
format!(
97+
"examples/{name}: the <!-- forge:example --> block in {name}.md is stale; run `make docs-examples` to refresh it"
98+
),
99+
)),
100+
None => findings.push(Finding::new(
101+
"example-block",
102+
format!("examples/{name}: {name}.md has an opening <!-- forge:example --> with no closing marker"),
103+
)),
104+
}
105+
}
106+
findings
107+
}
108+
109+
/// Regenerate every opted-in example block in place. Returns rewritten paths.
110+
pub fn write_all(ws: &Workspace) -> std::io::Result<Vec<PathBuf>> {
111+
let mut written = Vec::new();
112+
for (name, dir) in example_apps(ws) {
113+
let path = page_path(ws, &name);
114+
let page = match read_optional(&path) {
115+
Some(p) => p,
116+
None => continue,
117+
};
118+
if !page.contains(BLOCK_OPEN) {
119+
continue;
120+
}
121+
let expected = render_block_body(&name, &runtime_modules(&dir));
122+
if let Some((_, _, body)) = markers::find_region(&page, BLOCK_OPEN, BLOCK_CLOSE) {
123+
if body == expected {
124+
continue;
125+
}
126+
if let Some(updated) =
127+
markers::replace_region(&page, BLOCK_OPEN, BLOCK_CLOSE, &expected)
128+
{
129+
std::fs::write(&path, updated)?;
130+
written.push(path);
131+
}
132+
}
133+
}
134+
Ok(written)
135+
}
136+
137+
/// Recursively collect `.ts` files under `dir`.
138+
fn ts_sources(dir: &Path) -> Vec<PathBuf> {
139+
let mut out = Vec::new();
140+
let mut stack = vec![dir.to_path_buf()];
141+
while let Some(d) = stack.pop() {
142+
let entries = match std::fs::read_dir(&d) {
143+
Ok(e) => e,
144+
Err(_) => continue,
145+
};
146+
for entry in entries.flatten() {
147+
let path = entry.path();
148+
if path.is_dir() {
149+
stack.push(path);
150+
} else if path.extension().and_then(|e| e.to_str()) == Some("ts") {
151+
out.push(path);
152+
}
153+
}
154+
}
155+
out.sort();
156+
out
157+
}
158+
159+
#[cfg(test)]
160+
mod tests {
161+
use super::*;
162+
163+
#[test]
164+
fn render_block_body_lists_modules() {
165+
let body = render_block_body(
166+
"react-app",
167+
&["runtime:ipc".to_string(), "runtime:window".to_string()],
168+
);
169+
assert!(body.contains("**Runtime modules used:** `runtime:ipc`, `runtime:window`"));
170+
assert!(body.contains("examples/react-app/src"));
171+
}
172+
173+
#[test]
174+
fn render_block_body_handles_no_modules() {
175+
assert!(render_block_body("x", &[]).contains("_none_"));
176+
}
177+
}

crates/forge-docs-check/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ pub mod apiblock;
1717
pub mod checks;
1818
pub mod cratepage;
1919
pub mod discovery;
20+
pub mod exampleblock;
21+
pub mod markers;
2022

2123
use discovery::Workspace;
2224

@@ -90,5 +92,6 @@ pub fn run_all_checks(ws: &Workspace) -> Report {
9092
report.extend(checks::cli_commands::check(ws));
9193
report.extend(checks::forge_docs::check(ws));
9294
report.extend(apiblock::check(ws));
95+
report.extend(exampleblock::check(ws));
9396
report
9497
}

crates/forge-docs-check/src/main.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,14 @@ fn main() -> Result<ExitCode> {
4444
return Ok(ExitCode::SUCCESS);
4545
}
4646

47+
// `--write-example-blocks`: regenerate every `<!-- forge:example -->` block
48+
// in place from each example app's runtime:* imports, then exit.
49+
if std::env::args().any(|a| a == "--write-example-blocks") {
50+
let written = forge_docs_check::exampleblock::write_all(&ws)?;
51+
report_written("example block", &written, &ws.root);
52+
return Ok(ExitCode::SUCCESS);
53+
}
54+
4755
let report = run_all_checks(&ws);
4856

4957
println!("{}", report.render());

0 commit comments

Comments
 (0)