xtask/greenfield_status.rs
1//! `cargo xtask greenfield-status` — the probe harness (track doc §8, proposal #999).
2//!
3//! Thirteen probes measuring the tree against `design/bynk-greenfield-compiler.md`:
4//! the twelve in track doc §8, plus `emit_abi_shapes` (ADR 0310's probe, #999 Decision
5//! E — this slice measures the emit-ABI enumeration guard but does not wire it; wiring
6//! is packaging-track work).
7//!
8//! **Nine are zero/closure probes**, committed and diffed: `workspace_lints`,
9//! `fs_below_driver`, `options_sources`, `hoist_sinks`, `span_keyed_maps`,
10//! `emit_diagnostics`, `ide_emit_edge`, `ast_importers`, `emit_abi_shapes`. A
11//! disagreement between a fresh run and the committed table fails
12//! `greenfield_status_table_is_current` (`xtask/tests/greenfield_status.rs`), which
13//! rides both the `test` job (`cargo test --workspace`, any Rust-touching PR) and the
14//! `drift` job's existing `cargo test -p xtask` (pending/decisions-only PRs) — no new
15//! CI wiring (#999 Decision D, which also explains why a `drift`-job *step* would have
16//! been silently skipped on the PRs that move these probes most).
17//!
18//! **Four are count/ratio trend probes**, recomputed and printed but never diffed:
19//! `wildcard_arms`, `keep_in_sync`, `test_density`, `fixture_kinds`. These move on
20//! nearly any ordinary Rust PR (§8 calls two of them "trends, not gates"); hard-gating
21//! them would make the committed table churn, and conflict, on routine work.
22//!
23//! `Closes-Rule:` rule-id provenance (#999 Decision B) is deferred to a follow-on
24//! slice — the committed table below carries no rule-citation column yet.
25
26use std::collections::BTreeSet;
27use std::fmt::Write as _;
28use std::path::{Path, PathBuf};
29use std::process::Command;
30
31/// One probe's result. `gated` probes are diffed against the committed table by
32/// [`crate::greenfield_status::gated_disagreements`]; the rest are reported only.
33pub struct Probe {
34 pub name: &'static str,
35 pub gated: bool,
36 pub reads: String,
37}
38
39pub struct Report {
40 pub probes: Vec<Probe>,
41}
42
43impl Report {
44 pub fn get(&self, name: &str) -> &str {
45 self.probes
46 .iter()
47 .find(|p| p.name == name)
48 .map(|p| p.reads.as_str())
49 .unwrap_or_else(|| panic!("no probe named {name:?}"))
50 }
51}
52
53/// Run every probe against the tree rooted at `root` (the repo root). Used by the CLI's
54/// full report; the gating test uses the nine gated probes alone
55/// ([`gated_disagreements`]) so it never pays for a workspace-wide clippy pass
56/// (`wildcard_arms`) just to check the probes that are actually diffed.
57pub fn run(root: &Path) -> Report {
58 let mut probes = run_gated(root);
59 probes.extend(run_trend(root));
60 Report { probes }
61}
62
63/// The nine gated (zero/closure) probes only — what [`gated_disagreements`] diffs.
64fn run_gated(root: &Path) -> Vec<Probe> {
65 vec![
66 workspace_lints(root),
67 fs_below_driver(root),
68 options_sources(root),
69 hoist_sinks(root),
70 span_keyed_maps(root),
71 emit_diagnostics(root),
72 ide_emit_edge(root),
73 ast_importers(root),
74 emit_abi_shapes(root),
75 ]
76}
77
78/// The four reported-only trend probes — never diffed, and notably including the one
79/// (`wildcard_arms`) that shells out to a full `cargo clippy --workspace` pass, which
80/// the gating test must not pay for on every run.
81fn run_trend(root: &Path) -> Vec<Probe> {
82 vec![
83 wildcard_arms(root),
84 keep_in_sync(root),
85 test_density(root),
86 fixture_kinds(root),
87 ]
88}
89
90/// `design/greenfield-status.md` — the committed table this probe set regenerates.
91pub fn table_path(root: &Path) -> PathBuf {
92 root.join("design/greenfield-status.md")
93}
94
95// --- Filesystem helpers --------------------------------------------------
96
97/// Every `.rs` file under `dir`, recursively, as `(path, contents)`. Unreadable files
98/// (permissions, non-UTF-8) are skipped rather than failing the whole walk — this is a
99/// measurement tool, not a build step.
100fn rust_files(dir: &Path) -> Vec<(PathBuf, String)> {
101 let mut out = Vec::new();
102 walk(dir, &mut out);
103 out
104}
105
106fn walk(dir: &Path, out: &mut Vec<(PathBuf, String)>) {
107 let Ok(entries) = std::fs::read_dir(dir) else {
108 return;
109 };
110 let mut entries: Vec<_> = entries.flatten().collect();
111 entries.sort_by_key(|e| e.file_name());
112 for entry in entries {
113 let path = entry.path();
114 if path.is_dir() {
115 walk(&path, out);
116 } else if path.extension().is_some_and(|e| e == "rs")
117 && let Ok(contents) = std::fs::read_to_string(&path)
118 {
119 out.push((path, contents));
120 }
121 }
122}
123
124/// The inner text of every **standalone** `"bynk.<ident>"` string literal (the
125/// `bynk.*` convention used for diagnostic codes and commons/namespace paths alike).
126///
127/// Standalone, not merely prefix-matching: the identifier run must be immediately
128/// followed by the closing quote, matching the naive `rg -o '"bynk\.[a-zA-Z0-9_.]*"'`
129/// this probe is deliberately more careful than (#999 Decision A). Without that
130/// requirement this would also match the *start* of an unrelated, longer message that
131/// merely happens to begin with "bynk." — e.g. a panic string
132/// `"bynk.map itself uses bynk.list, so list must be injected too: {paths:?}"` is prose
133/// beginning with a namespace-shaped word, not a `"bynk.map"` code literal, and a
134/// dev-only compile-time error message split across lines with a `\`-continuation
135/// (`"bynk.emit.unresolved_cross_context_signature: no signature for \` ...) is one
136/// string, not a diagnostic-code literal, even though its first segment matches the
137/// identifier charset. Both were found — and wrongly counted — by an earlier,
138/// less careful version of this scan; the fix is requiring the closing quote.
139fn bynk_dotted_literals(src: &str) -> Vec<&str> {
140 let mut out = Vec::new();
141 let bytes = src.as_bytes();
142 let mut i = 0;
143 while let Some(rel) = src[i..].find("\"bynk.") {
144 let start = i + rel + 1; // skip the opening quote
145 let mut end = start;
146 while end < bytes.len()
147 && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_' || bytes[end] == b'.')
148 {
149 end += 1;
150 }
151 if end < bytes.len() && bytes[end] == b'"' {
152 out.push(&src[start..end]);
153 }
154 i = end.max(start + 1);
155 }
156 out
157}
158
159/// True if `line`, trimmed, is a `//` or `///` or `//!` line comment. Doesn't attempt
160/// block comments (`/* */`) — none of this codebase's `bynk.*`/dead-identifier
161/// mentions live in one.
162fn is_line_comment(line: &str) -> bool {
163 line.trim_start().starts_with("//")
164}
165
166// --- Gated probe 1: workspace_lints --------------------------------------
167
168/// R2.12. `[workspace.lints]` presence and `clippy::wildcard_enum_match_arm`'s level in
169/// the root `Cargo.toml`. A boolean-shaped probe (not a count) — gated because it only
170/// ever changes once, when T0.3 lands it.
171fn workspace_lints(root: &Path) -> Probe {
172 let cargo_toml = std::fs::read_to_string(root.join("Cargo.toml")).unwrap_or_default();
173 let has_section = cargo_toml
174 .lines()
175 .any(|l| l.trim() == "[workspace.lints.clippy]" || l.trim() == "[workspace.lints]");
176 let level = cargo_toml
177 .lines()
178 .find(|l| l.contains("wildcard_enum_match_arm"))
179 .map(|l| l.trim().to_string());
180 let reads = match (has_section, level) {
181 (true, Some(l)) => format!("present — {l}"),
182 (true, None) => "present, wildcard_enum_match_arm not set".to_string(),
183 (false, _) => "absent".to_string(),
184 };
185 Probe {
186 name: "workspace_lints",
187 gated: true,
188 reads,
189 }
190}
191
192// --- Gated probe 2: fs_below_driver --------------------------------------
193
194/// R2.3. Files under `bynk-emit/src`, `bynk-ide/src`, `bynk-fmt/src` (the crates below
195/// the `bynk` driver, which owns disk I/O) that touch `std::fs` in **production** code.
196///
197/// Excludes usage inside a trailing `#[cfg(test)] mod tests { ... }` block — the
198/// convention every file in this codebase uses, always the last item in the file. A
199/// line is production-scope unless it falls at or after the line following a
200/// `#[cfg(test)]` attribute whose very next non-empty line opens a `mod ... {` block
201/// (as opposed to a `mod name;` external-file declaration, which is not a scope at
202/// all). This mirrors the comment-exclusion discipline elsewhere in this probe set:
203/// tests writing fixtures to a tempdir are not "the driver's job" bypassed, and
204/// counting them would report a rule open that the production code has already closed.
205///
206/// A file counts if its own text names `std::fs` ([`has_production_std_fs`]), **or** if
207/// a bare `fs::`-style call site in it resolves to `std::fs` through its imports
208/// ([`production_std_fs_files`]) — a module-level `use std::fs;` in a parent module is
209/// visible to a child through `use super::*;` (module privacy is ancestor-scoped), so
210/// `bynk-emit/src/project/discovery.rs` reads and walks the filesystem while never
211/// spelling `std::fs` itself. The literal text scan alone missed exactly that file,
212/// so a probe reading `bynk-emit=0` would have asserted R2.3 closed on a false
213/// premise (#1013).
214///
215/// #1104 (a content-ownership (#1086) probe-precision follow-on): a flagged *count*
216/// alone can't tell a residual R2.3 violation from a documented, permanent exception —
217/// `bynk-emit`'s 3 have read that way since the track's retirement (`design/archive/
218/// retired-tracks.md`'s closing summary), each named in [`NAMED_FS_EXCEPTIONS`]. So
219/// each flagged file is additionally classified as a **named floor** file — every
220/// production-scope touch it has is either inside one of those named functions, or is
221/// a bare import declaration (no fn encloses it — [`enclosing_fn`] returns `None`) that
222/// performs no I/O of its own, existing only so a *descendant* module's bare `fs::`
223/// call can resolve (exactly `project.rs`'s `use std::fs;`, which `discovery.rs` and
224/// `paths.rs` glob-import via `use super::*;`) — or a **residual** file: any other file
225/// touching `std::fs` in production scope, which still reads as a real R2.3 violation
226/// ([`file_is_named_fs_floor`]).
227fn fs_below_driver(root: &Path) -> Probe {
228 let crates = ["bynk-emit", "bynk-ide", "bynk-fmt"];
229 let mut per_crate = Vec::new();
230 let mut total = 0usize;
231 let mut total_floor = 0usize;
232 for krate in crates {
233 let dir = root.join(krate).join("src");
234 let files: Vec<(PathBuf, String)> = rust_files(&dir)
235 .into_iter()
236 .map(|(path, contents)| {
237 let rel = path.strip_prefix(&dir).unwrap_or(&path).to_path_buf();
238 (rel, contents)
239 })
240 .collect();
241 let flagged = production_std_fs_files(&files);
242 let count = flagged.len();
243 total += count;
244 let facts: Vec<FsImportFacts> = files.iter().map(|(_, s)| fs_import_facts(s)).collect();
245 let parents: Vec<Option<usize>> = files
246 .iter()
247 .map(|(p, _)| module_parent(p, &files))
248 .collect();
249 let floor = flagged
250 .iter()
251 .filter(|&&i| file_is_named_fs_floor(krate, &files, &facts, &parents, i))
252 .count();
253 total_floor += floor;
254 let residual = count - floor;
255 per_crate.push(if floor > 0 {
256 format!("{krate}={count} ({floor} named floor, {residual} residual)")
257 } else {
258 format!("{krate}={count}")
259 });
260 }
261 Probe {
262 name: "fs_below_driver",
263 gated: true,
264 reads: format!(
265 "{total} files ({}) — {total_floor} named floor, {} residual total",
266 per_crate.join(", "),
267 total - total_floor
268 ),
269 }
270}
271
272/// #1104: the specific, permanently-carved-out production functions whose
273/// `std::fs` touch is a *named* exception, not evidence of unfinished R2.3
274/// migration — settled in `design/tracks/content-ownership.md` §3.2 (retired) and
275/// its closing summary in `design/archive/retired-tracks.md`. `(crate, file path
276/// relative to that crate's `src/`, enclosing production fn name)`. A future
277/// carve-out decided the same deliberate way joins this list; anything touching
278/// `std::fs` in production scope that isn't listed here reads as a residual R2.3
279/// violation, per [`file_is_named_fs_floor`].
280const NAMED_FS_EXCEPTIONS: &[(&str, &str, &str)] = &[
281 // The bare enumeration walk — no content read, no overlay parameter at all.
282 ("bynk-emit", "project/discovery.rs", "discover_bynk_files"),
283 // An adapter's `.binding.ts` path is only known post-parse, so no discovery walk
284 // can pre-populate it into a caller-supplied overlay the way `.bynk` files are.
285 ("bynk-emit", "project/discovery.rs", "read_adapter_binding"),
286 // The plain, no-overlay manifest reader's contract has always been "read the real
287 // file"; nothing above it in the call chain can supply this for a caller that
288 // doesn't build its own overlay.
289 ("bynk-emit", "project/paths.rs", "try_read_project_paths"),
290];
291
292/// Is flagged file `files[i]` (already known, by [`production_std_fs_files`], to touch
293/// `std::fs` in production scope) a **named floor** file — every production-scope touch
294/// it has is either inside a [`NAMED_FS_EXCEPTIONS`] function for this exact
295/// `(krate, file)`, or a bare `use` import declaration (which reads but performs no
296/// filesystem operation by itself, unlike a module-scope `static`/`const` initialiser or
297/// macro invocation that might)? `facts`/`parents` are the caller's already-computed
298/// [`fs_import_facts`]/[`module_parent`] vectors for `files`, threaded through rather
299/// than recomputed per flagged file.
300///
301/// A single disallowed touch — inside an unlisted fn, inside a listed fn's *file* but
302/// wrong *name*, or outside every fn and not a plain import — makes the whole file
303/// residual: partial credit isn't meaningful here, since the point is "can a reader stop
304/// cross-referencing track docs for this file," not a ratio. Likewise, a file this
305/// function attributes *no* touch line to at all (despite the caller already knowing it's
306/// flagged — [`line_touches_std_fs`]'s re-implementation of the file-level detection
307/// disagreeing with it) reads as residual, not floor: an unattributable touch means this
308/// classifier doesn't understand the file, which must fail loud, not quiet.
309fn file_is_named_fs_floor(
310 krate: &str,
311 files: &[(PathBuf, String)],
312 facts: &[FsImportFacts],
313 parents: &[Option<usize>],
314 i: usize,
315) -> bool {
316 let (path, _) = &files[i];
317 let rel = path.to_string_lossy().replace('\\', "/");
318 let lines: Vec<&str> = files[i].1.lines().collect();
319 let ranges = test_mod_ranges(&lines);
320 let fn_ranges = production_fn_ranges(&lines, &ranges);
321
322 let mut saw_touch = false;
323 for (li, line) in lines.iter().enumerate() {
324 if in_test_range(li, &ranges) {
325 continue;
326 }
327 if !line_touches_std_fs(i, line, facts, parents, files) {
328 continue;
329 }
330 saw_touch = true;
331 let Some(fn_name) = enclosing_fn(li, &fn_ranges) else {
332 // No enclosing fn is harmless only when the line is literally an import
333 // declaration. A module-scope `static`/`const` initialiser, a macro
334 // invocation, or a fn shape `fn_name_on_line` can't parse (`extern "C" fn`)
335 // does real I/O outside every known range and must read as residual.
336 if use_declaration(line).is_some() {
337 continue;
338 }
339 return false;
340 };
341 let named = NAMED_FS_EXCEPTIONS
342 .iter()
343 .any(|&(c, f, func)| c == krate && f == rel && func == fn_name);
344 if !named {
345 return false;
346 }
347 }
348 saw_touch
349}
350
351/// Does `line` (already known to be production-scope) itself touch `std::fs` — by the
352/// same two means [`production_std_fs_files`] checks at file granularity, applied here
353/// to one line: a literal `std::fs` substring, or a bare/qualified path this line spells
354/// that resolves to `std::fs` through file `i`'s visible import bindings.
355fn line_touches_std_fs(
356 i: usize,
357 line: &str,
358 facts: &[FsImportFacts],
359 parents: &[Option<usize>],
360 files: &[(PathBuf, String)],
361) -> bool {
362 if line.contains("std::fs") {
363 return true;
364 }
365 let mut roots = BTreeSet::new();
366 collect_bare_path_roots(line, &mut roots);
367 if roots.iter().any(|name| {
368 matches!(
369 resolve_name_in_module(i, name, facts, parents),
370 NameResolution::StdFs
371 )
372 }) {
373 return true;
374 }
375 let mut chains = BTreeSet::new();
376 collect_qualified_paths(line, &mut chains);
377 chains
378 .iter()
379 .any(|chain| qualified_chain_reaches_std_fs(chain, i, facts, parents, files))
380}
381
382/// The name and inclusive body line-range of every production-scope `fn` in `lines`
383/// (`test_ranges` excluded, same as everywhere else in this probe) — used by
384/// [`file_is_named_fs_floor`] to attribute a flagged touch line to its enclosing
385/// function. A wrapped signature (the `{` arriving lines after the `fn` line, past a
386/// multi-line parameter list) is handled the same way [`test_mod_ranges`] handles a
387/// `mod` line: brace depth is tracked starting at the `fn` line itself, but a parameter
388/// list has no `{`/`}` in it, so `started` only flips true once the real body-opening
389/// brace arrives, however many lines later.
390fn production_fn_ranges(
391 lines: &[&str],
392 test_ranges: &[(usize, usize)],
393) -> Vec<(String, usize, usize)> {
394 let mut out = Vec::new();
395 for (i, line) in lines.iter().enumerate() {
396 if in_test_range(i, test_ranges) {
397 continue;
398 }
399 let Some(name) = fn_name_on_line(line) else {
400 continue;
401 };
402 let mut state = BraceScanState::Normal;
403 let mut depth = 0i32;
404 let mut started = false;
405 let mut end = lines.len() - 1;
406 for (j, l) in lines[i..].iter().enumerate() {
407 let (delta, new_state) = brace_delta(l, state);
408 state = new_state;
409 depth += delta;
410 if depth != 0 {
411 started = true;
412 }
413 if started && depth == 0 {
414 end = i + j;
415 break;
416 }
417 }
418 out.push((name, i, end));
419 }
420 out
421}
422
423/// The leading `fn NAME` on `line`, past an optional `pub`/`pub(...)`, `async`,
424/// `unsafe`, `const` modifier run (in any order/repetition, mirroring
425/// [`collect_declared_type_name`]'s `pub`-stripping) — `None` if `line` doesn't open a
426/// function at all (a call site, a doc comment mentioning "fn", a closure). Doesn't
427/// require a trailing `{` or even `(` on this same line — a wrapped signature's `fn`
428/// line can end right at the name.
429fn fn_name_on_line(line: &str) -> Option<String> {
430 let mut t = line.trim();
431 loop {
432 if let Some(rest) = t.strip_prefix("pub") {
433 let rest = rest.trim_start();
434 t = if let Some(after_paren) = rest.strip_prefix('(') {
435 after_paren.split_once(')')?.1.trim_start()
436 } else {
437 rest
438 };
439 continue;
440 }
441 let mut advanced = false;
442 for kw in ["async ", "unsafe ", "const "] {
443 if let Some(rest) = t.strip_prefix(kw) {
444 t = rest.trim_start();
445 advanced = true;
446 break;
447 }
448 }
449 if !advanced {
450 break;
451 }
452 }
453 let rest = t.strip_prefix("fn ")?;
454 let end = rest
455 .find(|c: char| !c.is_ascii_alphanumeric() && c != '_')
456 .unwrap_or(rest.len());
457 if end == 0 {
458 return None;
459 }
460 Some(rest[..end].to_string())
461}
462
463/// The innermost [`production_fn_ranges`] entry containing `line_idx`, by name — `None`
464/// if `line_idx` sits outside every production fn (module scope: a `use` declaration,
465/// a `const`/`static`, or a `struct`/`enum` body).
466fn enclosing_fn(line_idx: usize, fn_ranges: &[(String, usize, usize)]) -> Option<String> {
467 fn_ranges
468 .iter()
469 .filter(|(_, start, end)| line_idx >= *start && line_idx <= *end)
470 .min_by_key(|(_, start, end)| end - start)
471 .map(|(name, _, _)| name.clone())
472}
473
474/// The literal text component of [`fs_below_driver`]: some production-scope line names
475/// `std::fs`. Necessary but not sufficient (#1013) — a file can touch `std::fs`
476/// through a glob-imported parent binding without ever spelling it; that resolution
477/// lives in [`production_std_fs_files`], which layers on top of this scan.
478fn has_production_std_fs(src: &str) -> bool {
479 let lines: Vec<&str> = src.lines().collect();
480 let ranges = test_mod_ranges(&lines);
481 for (i, line) in lines.iter().enumerate() {
482 if in_test_range(i, &ranges) {
483 continue;
484 }
485 if line.contains("std::fs") {
486 return true;
487 }
488 }
489 false
490}
491
492/// Indices (into `files`, whose paths are relative to the crate's `src/` root) of the
493/// files that touch `std::fs` in production code — the union of the literal text scan
494/// ([`has_production_std_fs`]) and import resolution: a path whose leading module
495/// segment a production `use` declaration binds to `std::fs` (or an item under it),
496/// either a bare `NAME::` root resolved in the file itself (`use std::{fs, io};` — a
497/// form the substring scan can't see) or in an ancestor module reached through
498/// `use super::*;`, transitively (#1013), or a `super::`/`self::`/`crate::`-qualified
499/// path walked through the module tree to the same bindings (#1016 review — a
500/// descendant may spell `super::fs::read_to_string(p)` with no glob import at all,
501/// one disambiguating edit away from a currently-flagged bare call).
502///
503/// Resolution is Rust-shaped, not hand-tracked (#1013 rejects a special-case list):
504/// a private `use std::fs;` in a parent is visible to descendants because module
505/// privacy is ancestor-scoped, a chain of `use super::*;` globs re-reaches it from
506/// any depth, and a nearer binding of the same name shadows a farther one — whether
507/// that binding is another `use` or a locally-declared type-namespace item (`mod fs;`,
508/// `struct File`, …; value-namespace items like `fn` can't head a `NAME::` path, so
509/// they don't shadow one) — so a child that binds `fs` to something else keeps its
510/// bare `fs::` calls unflagged. Visibility is *not* modelled: a path that names a
511/// too-private binding wouldn't compile anyway, so over-approximating is safe.
512///
513/// Known remaining gaps, accepted as out of reach for a text-level scanner: an
514/// ancestor's `use std::fs::read_to_string;` item import called bare (`read_to_string(p)`)
515/// presents no `::` path segment to resolve — the same import used as a path root
516/// (`File::open`) **is** caught, since item bindings under `std::fs` participate in
517/// the same resolution — and a `use` declaration rustfmt has split across lines is
518/// not parsed. #1013 grepped the three scanned crates for the item-import form, and
519/// the #1016 review for the qualified-path and split-declaration forms — zero hits.
520fn production_std_fs_files(files: &[(PathBuf, String)]) -> Vec<usize> {
521 let facts: Vec<FsImportFacts> = files.iter().map(|(_, src)| fs_import_facts(src)).collect();
522 let parents: Vec<Option<usize>> = files
523 .iter()
524 .map(|(path, _)| module_parent(path, files))
525 .collect();
526 (0..files.len())
527 .filter(|&i| {
528 has_production_std_fs(&files[i].1)
529 || resolves_bare_std_fs(i, &facts, &parents)
530 || resolves_qualified_std_fs(i, &facts, &parents, files)
531 })
532 .collect()
533}
534
535/// Per-file production-scope import facts for [`production_std_fs_files`]'s
536/// resolution. All fields exclude `#[cfg(test)] mod` ranges — a test module's
537/// `use super::*;` or tempdir `fs::write` must not make the file, or its children,
538/// read as production `std::fs` (the `bynk-ide` files' shape).
539#[derive(Default)]
540struct FsImportFacts {
541 /// A production `use super::*;` (optionally `pub`-qualified) — the edge that lets
542 /// this file see its parent module's `use` bindings, and (chained) its ancestors'.
543 glob_imports_super: bool,
544 /// Names production `use` declarations bind to `std::fs` or an item under it:
545 /// `use std::fs;` → `fs`, `use std::fs as x;` → `x`, `use std::{fs, io};` → `fs`,
546 /// `use std::fs::File;` → `File`.
547 std_fs_bindings: BTreeSet<String>,
548 /// Every name a production `use` declaration binds, whatever the target — the
549 /// shadow set: a nearer non-`std::fs` binding of a candidate name stops resolution.
550 use_bound_names: BTreeSet<String>,
551 /// Type-namespace items the file declares (`mod fs;`, `struct File`, `enum`,
552 /// `trait`, `type`, `union`) — these beat a glob-imported name in real Rust, so
553 /// they join [`Self::use_bound_names`] on the shadow side of resolution (#1016
554 /// review). Value-namespace items (`fn`, `const`, `static`) can't head a `NAME::`
555 /// module path and are deliberately not collected.
556 declared_type_names: BTreeSet<String>,
557 /// Identifiers appearing as a bare path root `NAME::` (not preceded by another
558 /// path segment) on a production line — the call-site side of the resolution.
559 bare_path_roots: BTreeSet<String>,
560 /// Segment chains of `super::`/`self::`/`crate::`-qualified paths on production
561 /// lines — `super::fs::read_to_string` records `["super", "fs", "read_to_string"]`.
562 /// These need no glob import to reach an ancestor's binding (#1016 review).
563 qualified_paths: BTreeSet<Vec<String>>,
564}
565
566fn fs_import_facts(src: &str) -> FsImportFacts {
567 let lines: Vec<&str> = src.lines().collect();
568 let ranges = test_mod_ranges(&lines);
569 let mut facts = FsImportFacts::default();
570 for (i, line) in lines.iter().enumerate() {
571 if in_test_range(i, &ranges) {
572 continue;
573 }
574 if let Some(decl) = use_declaration(line) {
575 if decl == "super::*" {
576 facts.glob_imports_super = true;
577 }
578 collect_use_bindings("", decl, &mut facts);
579 }
580 collect_declared_type_name(line, &mut facts.declared_type_names);
581 collect_bare_path_roots(line, &mut facts.bare_path_roots);
582 collect_qualified_paths(line, &mut facts.qualified_paths);
583 }
584 facts
585}
586
587/// The path text of a single-line `use` declaration — `use std::fs;` → `std::fs`,
588/// with an optional `pub`/`pub(crate)`/`pub(in …)` prefix stripped and a trailing
589/// `//` comment tolerated (`use super::*; // parent's fs` must not silently sever
590/// the glob edge for a whole subtree — #1016 review; safe to split on `//` because
591/// a `use` path can contain neither a comment marker nor a string). A declaration
592/// rustfmt has split across lines has no trailing `;` here and is not recognised —
593/// none of the `std::fs` forms in the scanned crates are long enough to split.
594fn use_declaration(line: &str) -> Option<&str> {
595 let mut t = line.trim();
596 if let Some(rest) = t.strip_prefix("pub") {
597 let rest = rest.trim_start();
598 t = if let Some(after_paren) = rest.strip_prefix('(') {
599 after_paren.split_once(')')?.1.trim_start()
600 } else {
601 rest
602 };
603 }
604 let body = t.strip_prefix("use ")?;
605 let body = body.split("//").next().unwrap_or(body);
606 body.trim().strip_suffix(';').map(str::trim)
607}
608
609/// If `line` declares a type-namespace item — `mod`/`struct`/`enum`/`trait`/`type`/
610/// `union`, optionally `pub`-qualified, optionally `unsafe` (traits) — record its
611/// name. Field/variable positions can't start a trimmed line with these keywords, so
612/// a leading-keyword scan is enough for rustfmt-shaped code.
613fn collect_declared_type_name(line: &str, out: &mut BTreeSet<String>) {
614 let mut t = line.trim();
615 if let Some(rest) = t.strip_prefix("pub") {
616 let rest = rest.trim_start();
617 t = if let Some(after_paren) = rest.strip_prefix('(') {
618 match after_paren.split_once(')') {
619 Some((_, after)) => after.trim_start(),
620 None => return,
621 }
622 } else {
623 rest
624 };
625 }
626 if let Some(rest) = t.strip_prefix("unsafe ") {
627 t = rest.trim_start();
628 }
629 for kw in ["mod ", "struct ", "enum ", "trait ", "type ", "union "] {
630 if let Some(rest) = t.strip_prefix(kw) {
631 let rest = rest.trim_start();
632 let end = rest
633 .find(|c: char| !c.is_ascii_alphanumeric() && c != '_')
634 .unwrap_or(rest.len());
635 if end > 0 {
636 out.insert(rest[..end].to_string());
637 }
638 return;
639 }
640 }
641}
642
643/// Record the name(s) a `use` path binds into `facts` — `path::to::name`,
644/// `path as alias`, and brace groups (`std::{fs, path::PathBuf}`, nested one level
645/// per recursion). `prefix` is the already-consumed leading path (empty at the top).
646fn collect_use_bindings(prefix: &str, entry: &str, facts: &mut FsImportFacts) {
647 let entry = entry.trim();
648 if entry.is_empty() {
649 return;
650 }
651 if let Some((path_part, group)) = entry.split_once('{') {
652 let inner_prefix = join_use_path(prefix, path_part.trim().trim_end_matches("::"));
653 let group = group.strip_suffix('}').unwrap_or(group);
654 for part in split_group_entries(group) {
655 collect_use_bindings(&inner_prefix, part, facts);
656 }
657 return;
658 }
659 let (path_part, alias) = match entry.split_once(" as ") {
660 Some((p, a)) => (p.trim(), Some(a.trim())),
661 None => (entry, None),
662 };
663 let full = join_use_path(prefix, path_part);
664 // `use std::fs::{self};` binds `fs` — normalise the `self` leaf away.
665 let full = full.strip_suffix("::self").unwrap_or(&full);
666 let last = full.rsplit("::").next().unwrap_or(full);
667 let name = alias.unwrap_or(last);
668 if name.is_empty() || name == "*" {
669 return; // globs bind no single name; `super::*` is tracked separately
670 }
671 facts.use_bound_names.insert(name.to_string());
672 if full == "std::fs" || full.starts_with("std::fs::") {
673 facts.std_fs_bindings.insert(name.to_string());
674 }
675}
676
677fn join_use_path(prefix: &str, part: &str) -> String {
678 if prefix.is_empty() {
679 part.to_string()
680 } else {
681 format!("{prefix}::{part}")
682 }
683}
684
685/// Split a brace group's contents on top-level commas only — `fs::{self, File}, io`
686/// is two entries, not three.
687fn split_group_entries(s: &str) -> Vec<&str> {
688 let mut out = Vec::new();
689 let mut depth = 0i32;
690 let mut start = 0;
691 for (i, c) in s.char_indices() {
692 match c {
693 '{' => depth += 1,
694 '}' => depth -= 1,
695 ',' if depth == 0 => {
696 out.push(&s[start..i]);
697 start = i + 1;
698 }
699 _ => {}
700 }
701 }
702 out.push(&s[start..]);
703 out
704}
705
706/// Every identifier `NAME` occurring as `NAME::` where the character before `NAME` is
707/// not `:` — i.e. a path *root*, so `std::fs::read` contributes `std`, never `fs`.
708/// Same line discipline as the text scan: comments included, production scope only
709/// (the caller has already excluded test ranges).
710fn collect_bare_path_roots(line: &str, out: &mut BTreeSet<String>) {
711 let bytes = line.as_bytes();
712 let mut search_from = 0;
713 while let Some(rel) = line[search_from..].find("::") {
714 let pos = search_from + rel;
715 let mut start = pos;
716 while start > 0 && (bytes[start - 1].is_ascii_alphanumeric() || bytes[start - 1] == b'_') {
717 start -= 1;
718 }
719 if start < pos && (start == 0 || bytes[start - 1] != b':') {
720 out.insert(line[start..pos].to_string());
721 }
722 search_from = pos + 2;
723 }
724}
725
726/// Every `super::`/`self::`/`crate::`-rooted path on `line`, as its segment chain —
727/// `super::fs::read_to_string(p)` yields `["super", "fs", "read_to_string"]`. The
728/// root must sit at a bare word boundary (not `a_super::` or `a::super::`), so only
729/// genuine path roots are collected; `Self::` (capital) never matches, and `self.x`
730/// has no `::` to match.
731fn collect_qualified_paths(line: &str, out: &mut BTreeSet<Vec<String>>) {
732 let bytes = line.as_bytes();
733 for root in ["super", "self", "crate"] {
734 let mut from = 0;
735 while let Some(rel) = line[from..].find(root) {
736 let start = from + rel;
737 let root_end = start + root.len();
738 from = root_end;
739 let boundary_ok = start == 0 || {
740 let c = bytes[start - 1];
741 !(c.is_ascii_alphanumeric() || c == b'_' || c == b':')
742 };
743 if !boundary_ok || !line[root_end..].starts_with("::") {
744 continue;
745 }
746 let mut segments = vec![root.to_string()];
747 let mut pos = root_end;
748 while line[pos..].starts_with("::") {
749 let seg_start = pos + 2;
750 let mut seg_end = seg_start;
751 while seg_end < bytes.len()
752 && (bytes[seg_end].is_ascii_alphanumeric() || bytes[seg_end] == b'_')
753 {
754 seg_end += 1;
755 }
756 if seg_end == seg_start {
757 break; // `super::*` and friends — no further identifier
758 }
759 segments.push(line[seg_start..seg_end].to_string());
760 pos = seg_end;
761 }
762 if segments.len() >= 2 {
763 out.insert(segments);
764 }
765 }
766 }
767}
768
769/// The file defining `path`'s parent module, by the standard layout: `a/b.rs`'s parent
770/// is `a.rs` (or `a/mod.rs`), `a/mod.rs`'s parent is the crate root, and the roots
771/// (`lib.rs`/`main.rs`) have none. `#[path]`-remapped modules are not handled — none
772/// exist below the driver, and a text-level probe can't chase them anyway.
773fn module_parent(path: &Path, files: &[(PathBuf, String)]) -> Option<usize> {
774 let stem = path.file_stem()?.to_str()?;
775 let dir = path.parent().filter(|d| !d.as_os_str().is_empty());
776 let parent_module: PathBuf = if stem == "mod" {
777 dir?.parent().map(Path::to_path_buf).unwrap_or_default()
778 } else if let Some(dir) = dir {
779 dir.to_path_buf()
780 } else {
781 if stem == "lib" || stem == "main" {
782 return None;
783 }
784 PathBuf::new()
785 };
786 let candidates = if parent_module.as_os_str().is_empty() {
787 vec![PathBuf::from("lib.rs"), PathBuf::from("main.rs")]
788 } else {
789 vec![
790 parent_module.with_extension("rs"),
791 parent_module.join("mod.rs"),
792 ]
793 };
794 candidates
795 .iter()
796 .find_map(|c| files.iter().position(|(p, _)| p == c))
797}
798
799/// The scopes whose bindings a name used in module `i` can see: the module itself,
800/// then each ancestor reachable while every module below it glob-imports `super::*`.
801fn visible_scopes(i: usize, facts: &[FsImportFacts], parents: &[Option<usize>]) -> Vec<usize> {
802 let mut scopes = vec![i];
803 let mut cur = i;
804 loop {
805 if !facts[cur].glob_imports_super {
806 break;
807 }
808 let Some(parent) = parents[cur] else { break };
809 scopes.push(parent);
810 cur = parent;
811 }
812 scopes
813}
814
815/// How `name` resolves in module `m`'s namespace, walking [`visible_scopes`] with
816/// nearest binding winning — a closer non-`std::fs` `use` binding *or* locally
817/// declared type-namespace item shadows a farther `std::fs` binding, as in Rust.
818enum NameResolution {
819 StdFs,
820 Other,
821 Unbound,
822}
823
824fn resolve_name_in_module(
825 m: usize,
826 name: &str,
827 facts: &[FsImportFacts],
828 parents: &[Option<usize>],
829) -> NameResolution {
830 for s in visible_scopes(m, facts, parents) {
831 if facts[s].std_fs_bindings.contains(name) {
832 return NameResolution::StdFs;
833 }
834 if facts[s].use_bound_names.contains(name) || facts[s].declared_type_names.contains(name) {
835 return NameResolution::Other;
836 }
837 }
838 NameResolution::Unbound
839}
840
841/// Does a bare path root in file `i` resolve to `std::fs` through the bindings it
842/// can see? Candidates are the names any visible scope binds to `std::fs`; each is
843/// then resolved from `i` with nearest-binding-wins shadowing.
844fn resolves_bare_std_fs(i: usize, facts: &[FsImportFacts], parents: &[Option<usize>]) -> bool {
845 let scopes = visible_scopes(i, facts, parents);
846 let mut candidates: BTreeSet<&str> = BTreeSet::new();
847 for &s in &scopes {
848 candidates.extend(facts[s].std_fs_bindings.iter().map(String::as_str));
849 }
850 candidates.into_iter().any(|name| {
851 facts[i].bare_path_roots.contains(name)
852 && matches!(
853 resolve_name_in_module(i, name, facts, parents),
854 NameResolution::StdFs
855 )
856 })
857}
858
859/// Does a `super::`/`self::`/`crate::`-qualified path in file `i` reach a `std::fs`
860/// binding (#1016 review)? Unlike the bare-root case these need no glob import: the
861/// root picks the starting module directly (`super`-hops up the parent chain, `self`
862/// the file itself, `crate` the crate root), then each further segment either
863/// resolves in that module's namespace — `std::fs` flags, anything else stops — or
864/// descends into a child module file and continues. Inline `mod name { … }` blocks
865/// are not modelled (their `use` bindings live in the same file, which the text scan
866/// and bare-root resolution already cover).
867fn resolves_qualified_std_fs(
868 i: usize,
869 facts: &[FsImportFacts],
870 parents: &[Option<usize>],
871 files: &[(PathBuf, String)],
872) -> bool {
873 facts[i]
874 .qualified_paths
875 .iter()
876 .any(|chain| qualified_chain_reaches_std_fs(chain, i, facts, parents, files))
877}
878
879fn qualified_chain_reaches_std_fs(
880 chain: &[String],
881 i: usize,
882 facts: &[FsImportFacts],
883 parents: &[Option<usize>],
884 files: &[(PathBuf, String)],
885) -> bool {
886 let mut idx = 1;
887 let mut m = match chain[0].as_str() {
888 "self" => i,
889 "crate" => {
890 let root = files
891 .iter()
892 .position(|(p, _)| p == Path::new("lib.rs") || p == Path::new("main.rs"));
893 match root {
894 Some(root) => root,
895 None => return false,
896 }
897 }
898 "super" => {
899 let mut m = i;
900 idx = 0;
901 while idx < chain.len() && chain[idx] == "super" {
902 let Some(parent) = parents[m] else {
903 return false;
904 };
905 m = parent;
906 idx += 1;
907 }
908 m
909 }
910 _ => return false,
911 };
912 while idx < chain.len() {
913 let seg = chain[idx].as_str();
914 // Resolve `seg` in `m`, nearest scope first. Within a scope, a child module
915 // file for `seg` is checked *before* the shadow set: a declared `mod seg;`
916 // lands `seg` in `declared_type_names`, but that declaration IS the child
917 // module — it's the path's next hop, not a shadow over it. (In valid Rust a
918 // module and another same-name type-namespace item can't coexist in one
919 // scope, so the ordering costs nothing.)
920 let mut next = None;
921 for s in visible_scopes(m, facts, parents) {
922 if facts[s].std_fs_bindings.contains(seg) {
923 return true;
924 }
925 if let Some(child) = child_module_file(s, seg, files) {
926 next = Some(child);
927 break;
928 }
929 if facts[s].use_bound_names.contains(seg) || facts[s].declared_type_names.contains(seg)
930 {
931 return false; // bound to something that is neither std::fs nor a module
932 }
933 }
934 let Some(child) = next else {
935 return false;
936 };
937 m = child;
938 idx += 1;
939 }
940 false
941}
942
943/// The file defining module `m`'s child module `seg`, if it exists as a file:
944/// `lib.rs` + `a` → `a.rs`/`a/mod.rs`, `a.rs` + `b` → `a/b.rs`/`a/b/mod.rs`,
945/// `a/mod.rs` + `b` → `a/b.rs`/`a/b/mod.rs`.
946fn child_module_file(m: usize, seg: &str, files: &[(PathBuf, String)]) -> Option<usize> {
947 let m_path = &files[m].0;
948 let module_dir: PathBuf = match m_path.file_stem().and_then(|s| s.to_str()) {
949 Some("mod") => m_path.parent().unwrap_or(Path::new("")).to_path_buf(),
950 Some("lib") | Some("main") if m_path.parent().is_none_or(|p| p.as_os_str().is_empty()) => {
951 PathBuf::new()
952 }
953 _ => m_path.with_extension(""),
954 };
955 let candidates = [
956 module_dir.join(format!("{seg}.rs")),
957 module_dir.join(seg).join("mod.rs"),
958 ];
959 candidates
960 .iter()
961 .find_map(|c| files.iter().position(|(p, _)| p == c))
962}
963
964/// Every `#[cfg(test)] mod <ident> { ... }` block in `lines`, as inclusive
965/// `(start_line, end_line)` line-index ranges — every occurrence, not just a single
966/// trailing block. A file in this codebase can carry several test modules scattered
967/// through it with production code between them — `bynk-emit/src/emitter/lower.rs` has
968/// two, 1031 lines apart, and treating "everything after the first (or last)
969/// `#[cfg(test)]`" as one cutoff silently misclassifies that intervening production
970/// code as test-scope (caught in review: it made `fs_below_driver`, a *gated* probe,
971/// blind over that span, and inflated `test_density`'s ratio by up to 39%).
972///
973/// A block's end is found by real brace-depth counting via [`brace_delta`], not a
974/// "first column-0 `}`" shortcut: an earlier version of this fix tried exactly that
975/// shortcut (reasoning that rustfmt always dedents a closing brace back to column 0)
976/// and it broke on files like `bynk-ide/src/sequence.rs`, whose test module embeds
977/// multi-line `.bynk`/TypeScript fixture source as string literals — source that
978/// itself contains a column-0 `}` closing a top-level construct *inside the string*,
979/// which the shortcut mistook for the end of the Rust `mod` block, truncating it by
980/// hundreds of lines. `brace_delta` skips characters inside Rust string/char literals
981/// and comments, so embedded fixture text can't be mistaken for real Rust braces.
982///
983/// Only matches a brace-opening `mod` line — `#[cfg(test)] mod foo;` (an external-file
984/// declaration, not an inline scope) does not open a range.
985fn test_mod_ranges(lines: &[&str]) -> Vec<(usize, usize)> {
986 let mut ranges = Vec::new();
987 let mut i = 0;
988 while i < lines.len() {
989 if lines[i].trim() == "#[cfg(test)]"
990 && let Some(off) = lines[i + 1..].iter().position(|l| !l.trim().is_empty())
991 {
992 let mod_line = i + 1 + off;
993 let t = lines[mod_line].trim();
994 if t.starts_with("mod ") && t.ends_with('{') {
995 let mut depth = 0i32;
996 let mut state = BraceScanState::Normal;
997 let mut started = false;
998 let mut end = lines.len() - 1;
999 for (j, line) in lines[mod_line..].iter().enumerate() {
1000 let (delta, new_state) = brace_delta(line, state);
1001 state = new_state;
1002 depth += delta;
1003 if depth != 0 {
1004 started = true;
1005 }
1006 if started && depth == 0 {
1007 end = mod_line + j;
1008 break;
1009 }
1010 }
1011 ranges.push((mod_line, end));
1012 i = end + 1;
1013 continue;
1014 }
1015 }
1016 i += 1;
1017 }
1018 ranges
1019}
1020
1021fn in_test_range(line_idx: usize, ranges: &[(usize, usize)]) -> bool {
1022 ranges
1023 .iter()
1024 .any(|(start, end)| line_idx >= *start && line_idx <= *end)
1025}
1026
1027/// Scanner state carried across lines for [`brace_delta`]: whether the cursor is
1028/// inside a string literal, a raw string (with its `#`-count), or a block comment
1029/// (with nesting depth — Rust block comments nest).
1030#[derive(Clone, Copy, PartialEq)]
1031enum BraceScanState {
1032 Normal,
1033 InString,
1034 InRawString(u8),
1035 InBlockComment(u32),
1036}
1037
1038/// The net `{`/`}` depth change in `line`, skipping characters inside Rust string/char
1039/// literals, raw strings, and line/block comments — a naive per-character brace count
1040/// breaks the moment a line contains a fixture string like `"fn f() { \"{\" }"` or a
1041/// doc comment mentioning a brace. Returns the depth delta and the state to carry into
1042/// the next line (a string or block comment can span line boundaries).
1043fn brace_delta(line: &str, mut state: BraceScanState) -> (i32, BraceScanState) {
1044 let mut delta = 0i32;
1045 let chars: Vec<char> = line.chars().collect();
1046 let mut i = 0;
1047 while i < chars.len() {
1048 match state {
1049 BraceScanState::Normal => {
1050 if chars[i] == '/' && chars.get(i + 1) == Some(&'/') {
1051 break; // rest of the line is a line comment
1052 }
1053 if chars[i] == '/' && chars.get(i + 1) == Some(&'*') {
1054 state = BraceScanState::InBlockComment(1);
1055 i += 2;
1056 continue;
1057 }
1058 if chars[i] == '"' {
1059 state = BraceScanState::InString;
1060 i += 1;
1061 continue;
1062 }
1063 if chars[i] == 'r' && matches!(chars.get(i + 1), Some('"') | Some('#')) {
1064 let mut j = i + 1;
1065 let mut hashes = 0u8;
1066 while chars.get(j) == Some(&'#') {
1067 hashes += 1;
1068 j += 1;
1069 }
1070 if chars.get(j) == Some(&'"') {
1071 state = BraceScanState::InRawString(hashes);
1072 i = j + 1;
1073 continue;
1074 }
1075 }
1076 if chars[i] == '\'' {
1077 // A `'\x'`/`'\\'`-style escaped char literal, or a plain `'x'` —
1078 // skip past it so its contents can't be mistaken for braces.
1079 // Anything else (no closing `'` within a couple of chars) is a
1080 // lifetime, which owns no closing quote to skip.
1081 if chars.get(i + 1) == Some(&'\\') {
1082 let mut j = i + 2;
1083 while j < chars.len() && chars[j] != '\'' {
1084 j += 1;
1085 }
1086 i = (j + 1).min(chars.len());
1087 continue;
1088 } else if chars.get(i + 2) == Some(&'\'') {
1089 i += 3;
1090 continue;
1091 }
1092 }
1093 match chars[i] {
1094 '{' => delta += 1,
1095 '}' => delta -= 1,
1096 _ => {}
1097 }
1098 i += 1;
1099 }
1100 BraceScanState::InString => {
1101 if chars[i] == '\\' {
1102 i += 2;
1103 continue;
1104 }
1105 if chars[i] == '"' {
1106 state = BraceScanState::Normal;
1107 }
1108 i += 1;
1109 }
1110 BraceScanState::InRawString(hashes) => {
1111 if chars[i] == '"' {
1112 let mut j = i + 1;
1113 let mut h = 0u8;
1114 while chars.get(j) == Some(&'#') && h < hashes {
1115 h += 1;
1116 j += 1;
1117 }
1118 if h == hashes {
1119 state = BraceScanState::Normal;
1120 i = j;
1121 continue;
1122 }
1123 }
1124 i += 1;
1125 }
1126 BraceScanState::InBlockComment(depth) => {
1127 if chars[i] == '/' && chars.get(i + 1) == Some(&'*') {
1128 state = BraceScanState::InBlockComment(depth + 1);
1129 i += 2;
1130 continue;
1131 }
1132 if chars[i] == '*' && chars.get(i + 1) == Some(&'/') {
1133 state = if depth <= 1 {
1134 BraceScanState::Normal
1135 } else {
1136 BraceScanState::InBlockComment(depth - 1)
1137 };
1138 i += 2;
1139 continue;
1140 }
1141 i += 1;
1142 }
1143 }
1144 }
1145 (delta, state)
1146}
1147
1148// --- Gated probe 3: options_sources --------------------------------------
1149
1150/// R2.3. `CompileOptions` (in `bynk-emit/src/project.rs`) has a `sources` field.
1151fn options_sources(root: &Path) -> Probe {
1152 let src = std::fs::read_to_string(root.join("bynk-emit/src/project.rs")).unwrap_or_default();
1153 let present = struct_body(&src, "CompileOptions").is_some_and(|body| body.contains("sources"));
1154 Probe {
1155 name: "options_sources",
1156 gated: true,
1157 reads: if present {
1158 "present".to_string()
1159 } else {
1160 "absent".to_string()
1161 },
1162 }
1163}
1164
1165/// The `{ ... }` body text of `struct <name>` in `src`, brace-matched from the struct's
1166/// own opening brace to its close.
1167fn struct_body<'a>(src: &'a str, name: &str) -> Option<&'a str> {
1168 let needle = format!("struct {name}");
1169 let start = src.find(&needle)?;
1170 let open = start + src[start..].find('{')?;
1171 let mut depth = 0i32;
1172 for (offset, ch) in src[open..].char_indices() {
1173 match ch {
1174 '{' => depth += 1,
1175 '}' => {
1176 depth -= 1;
1177 if depth == 0 {
1178 return Some(&src[open..open + offset + 1]);
1179 }
1180 }
1181 _ => {}
1182 }
1183 }
1184 None
1185}
1186
1187// --- Gated probe 4: hoist_sinks -------------------------------------------
1188
1189/// R6.2. Live (non-comment) occurrences of the sink-passing signature
1190/// `stmts: &mut Vec<String>` in `bynk-emit`. Tier B (T2.1) deletes it entirely.
1191fn hoist_sinks(root: &Path) -> Probe {
1192 let dir = root.join("bynk-emit/src");
1193 let needle = "stmts: &mut Vec<String>";
1194 let mut count = 0usize;
1195 for (_, contents) in rust_files(&dir) {
1196 for line in contents.lines() {
1197 if !is_line_comment(line) && line.contains(needle) {
1198 count += 1;
1199 }
1200 }
1201 }
1202 Probe {
1203 name: "hoist_sinks",
1204 gated: true,
1205 reads: count.to_string(),
1206 }
1207}
1208
1209// --- Gated probe 5: span_keyed_maps ---------------------------------------
1210
1211/// R2.4. Whole-repo occurrences of `HashMap<Span` (comments included — the phase-3
1212/// migration target is every mention, not just live call sites), **excluding
1213/// `xtask` itself**: this probe's own doc comment and source both name the search
1214/// string, which would otherwise self-count every time this file is touched — the
1215/// same self-reference hazard flagged for the dead-identifier probes below, caught
1216/// here by running the probe against itself before committing the first table.
1217fn span_keyed_maps(root: &Path) -> Probe {
1218 let count = count_repo_wide(root, "HashMap<Span", &["xtask"]);
1219 Probe {
1220 name: "span_keyed_maps",
1221 gated: true,
1222 reads: count.to_string(),
1223 }
1224}
1225
1226fn count_repo_wide(root: &Path, needle: &str, exclude_crates: &[&str]) -> usize {
1227 let mut total = 0usize;
1228 for entry in top_level_crate_dirs(root) {
1229 if exclude_crates
1230 .iter()
1231 .any(|c| entry.file_name().is_some_and(|n| n == *c))
1232 {
1233 continue;
1234 }
1235 for (_, contents) in rust_files(&entry.join("src")) {
1236 total += contents.matches(needle).count();
1237 }
1238 }
1239 total
1240}
1241
1242/// Every workspace member crate directory (anything at the repo root with a
1243/// `Cargo.toml` and a `src/` dir), excluding `target` and non-crate directories.
1244fn top_level_crate_dirs(root: &Path) -> Vec<PathBuf> {
1245 let mut out = Vec::new();
1246 let Ok(entries) = std::fs::read_dir(root) else {
1247 return out;
1248 };
1249 for entry in entries.flatten() {
1250 let path = entry.path();
1251 if path.is_dir() && path.join("Cargo.toml").is_file() && path.join("src").is_dir() {
1252 out.push(path);
1253 }
1254 }
1255 out.sort();
1256 out
1257}
1258
1259// --- Gated probe 6: emit_diagnostics --------------------------------------
1260
1261/// R3.5. `bynk.*` string literals in `bynk-emit`/`bynk-check` source, cross-referenced
1262/// against `bynk_syntax::diagnostics::REGISTRY` — not pattern-matched. A literal not in
1263/// `REGISTRY` is a commons/namespace path (e.g. `bynk.locale`, the compiled first-party
1264/// source module name), not a diagnostic code, and must not inflate the count (#999
1265/// Decision A: this cross-reference is what makes the exclusion correct by
1266/// construction rather than a second hand-maintained list).
1267fn emit_diagnostics(root: &Path) -> Probe {
1268 let registry: BTreeSet<&str> = bynk_syntax::diagnostics::REGISTRY
1269 .iter()
1270 .map(|d| d.code)
1271 .collect();
1272 let mut parts = Vec::new();
1273 for (label, dir) in [
1274 ("bynk-emit", "bynk-emit/src"),
1275 ("bynk-check", "bynk-check/src"),
1276 ] {
1277 let mut naive: BTreeSet<String> = BTreeSet::new();
1278 for (_, contents) in rust_files(&root.join(dir)) {
1279 for lit in bynk_dotted_literals(&contents) {
1280 naive.insert(lit.to_string());
1281 }
1282 }
1283 let true_count = naive
1284 .iter()
1285 .filter(|l| registry.contains(l.as_str()))
1286 .count();
1287 parts.push(format!("{label}={true_count}/{}", naive.len()));
1288 }
1289 Probe {
1290 name: "emit_diagnostics",
1291 gated: true,
1292 reads: format!("{} (true/naive)", parts.join(", ")),
1293 }
1294}
1295
1296// --- Gated probe 7: ide_emit_edge -----------------------------------------
1297
1298/// R10.2. `bynk-ide` → `bynk-emit` in the manifest (`bynk-emit.workspace = true` or an
1299/// equivalent path/version dependency line).
1300fn ide_emit_edge(root: &Path) -> Probe {
1301 let manifest = std::fs::read_to_string(root.join("bynk-ide/Cargo.toml")).unwrap_or_default();
1302 let present = manifest
1303 .lines()
1304 .any(|l| l.trim_start().starts_with("bynk-emit"));
1305 Probe {
1306 name: "ide_emit_edge",
1307 gated: true,
1308 reads: if present {
1309 "present".to_string()
1310 } else {
1311 "absent".to_string()
1312 },
1313 }
1314}
1315
1316// --- Gated probe 8: ast_importers -----------------------------------------
1317
1318/// #1176: `bynk-emit::ir`'s own two files — named exactly, not by path prefix, the same
1319/// permanent-carve-out discipline [`NAMED_FS_EXCEPTIONS`] and [`emit_diagnostics`]'s
1320/// registry cross-reference already use. An `Ast → Ir` lowering pass importing
1321/// `bynk_syntax::ast` is that pass's entire job, not the AST-walking this track is
1322/// closing (`the-ir.md` §5's own P6.9 correction, #1167) — but `project.rs` also
1323/// imports `bynk_syntax::ast` today (`EmitProjectCtx` holding `ActorDecl`/`AgentDecl`
1324/// fields directly), and that *is* exactly the still-open R6.13 defect this probe
1325/// tracks (P6.6: "closes the emitter reading AST declarations directly"). A
1326/// path-prefix rule scoped to `emitter/**` would exclude that file right along with
1327/// `ir/`'s legitimate ones, silently undercounting real remaining work — see
1328/// [`is_named_ast_importer`].
1329///
1330/// #1184 review: this exclusion is necessary but not sufficient for R6.13. `ir.rs`
1331/// itself still holds several AST types directly in `IrItem`-adjacent struct fields
1332/// (`Arc<TypeDecl>`, `Arc<FnDecl>`, `HandlerKind`, `Refinement`, `SchemaVersionPattern`)
1333/// rather than IR-native equivalents — an emitter reading e.g. `IrHandler::kind`, which
1334/// *is* `ast::HandlerKind`, touches the AST without ever spelling `bynk_syntax::ast`
1335/// itself, so it is invisible to this probe by construction. `ast_importers` = 0 proves
1336/// no *remaining* file outside these two imports the AST module directly; it does not
1337/// by itself prove every `IrItem` field is AST-free (`the-ir.md` §5's own added note).
1338///
1339/// #1187's own closing scoping pass adds one more, on different grounds than the
1340/// `ir.rs`/`ir/lower.rs` pair above: `project/tests_emit.rs` was deliberately *not*
1341/// added alongside `project.rs` when this list was first cut (the
1342/// `ast_importer_exclusion_is_named_not_prefixed` test below used to assert exactly
1343/// that) — #1187's own scoping pass found new evidence changing that: its test/suite
1344/// case bodies call `emitter::lower_block_to_async_body`/`lower_test_case_body`/
1345/// `lower_integration_case_body` directly (the Q7-settled body-rendering pass,
1346/// `the-ir.md` §3.7 — `emitter/lower.rs` keeps hand-writing TypeScript source text
1347/// after this track's cutover, the printer that would change that is phase 7's), and
1348/// its own `driver_param_ty`/`strip_effect_httpresult` read a handler's *declared*
1349/// param/return `TypeRef` with no corresponding `TyId` available at that call site
1350/// (the same caller-reads-callee's-raw-declared-shape pattern #661 established for
1351/// cross-context codec generation). Both are the Q7/printer kind of unreachable, not
1352/// the "still open, real work" kind the original exclusion list deliberately left this
1353/// file out of — the correction is new evidence, not a reversal of that reasoning.
1354///
1355/// Review of #1210: `emitter.rs`/`emitter/lower.rs` themselves were considered for
1356/// this same exclusion and **rejected** — Q7 settles that these files' *body-rendering*
1357/// surface stays AST-parameter-driven, but both files also hold live, currently
1358/// untouched AST-*declaration* reads with no such gate: `emitter.rs`'s own
1359/// `CommonsItem::Service`/`svc.protocol` walk (consumed-event-root collection) and
1360/// `emitter/lower.rs`'s own `cap_op_param_names` (`CommonsItem::Capability`/`c.ops`)
1361/// are exactly the P6.2/P6.6-class conversions this track's own §6 table still lists
1362/// as in scope, not body-rendering. Excluding either file would have hidden that real,
1363/// fixable surface from this probe the same way a path-prefix rule would — the harm
1364/// the named-not-prefixed discipline above exists to prevent, just at file granularity
1365/// instead of directory granularity.
1366const AST_IMPORTER_EXCEPTIONS: &[&str] = &["ir.rs", "ir/lower.rs", "project/tests_emit.rs"];
1367
1368/// Is `rel_path` (relative to `bynk-emit/src`) one of [`AST_IMPORTER_EXCEPTIONS`]?
1369fn is_named_ast_importer(rel_path: &Path) -> bool {
1370 let rel = rel_path.to_string_lossy().replace('\\', "/");
1371 AST_IMPORTER_EXCEPTIONS.contains(&rel.as_str())
1372}
1373
1374/// The files [`ast_importers`] counts: `bynk-emit/src` files whose contents match
1375/// `bynk_syntax::ast`, excluding [`AST_IMPORTER_EXCEPTIONS`]. Split out from
1376/// [`ast_importers`] so a test can assert on the actual survivor set, not just its
1377/// length (#1184 review).
1378fn ast_importer_files(root: &Path) -> Vec<PathBuf> {
1379 let dir = root.join("bynk-emit/src");
1380 rust_files(&dir)
1381 .into_iter()
1382 .filter(|(_, contents)| contents.contains("bynk_syntax::ast"))
1383 .filter(|(path, _)| !is_named_ast_importer(path.strip_prefix(&dir).unwrap_or(path)))
1384 .map(|(path, _)| path)
1385 .collect()
1386}
1387
1388/// R6.13. Files in `bynk-emit/src` that import `bynk_syntax::ast`, excluding
1389/// [`AST_IMPORTER_EXCEPTIONS`] — phase 6 (the AST import surface `bynk-emit` still
1390/// depends on directly). #1176: the unexcluded, crate-wide count could never reach 0
1391/// while `bynk-emit::ir`'s lowering pass exists at all; this exclusion is what lets the
1392/// probe track the track's real completion criterion (`the-ir.md` §5) instead of a
1393/// floor this track's own IR module structurally cannot clear.
1394fn ast_importers(root: &Path) -> Probe {
1395 Probe {
1396 name: "ast_importers",
1397 gated: true,
1398 reads: ast_importer_files(root).len().to_string(),
1399 }
1400}
1401
1402// --- Gated probe 9: emit_abi_shapes ---------------------------------------
1403
1404/// ADR 0310 D1's four emit-ABI shapes, as they surface as import names in the vendored
1405/// bindings — the `Result`/`Option` tag layout plus `JsonError`, `Uuid`, `FetchError`.
1406const EMIT_ABI: &[&str] = &[
1407 "Result",
1408 "Option",
1409 "Ok",
1410 "Err",
1411 "Some",
1412 "None",
1413 "JsonError",
1414 "Uuid",
1415 "FetchError",
1416];
1417
1418/// The capability interfaces a vendored binding legitimately imports to implement what
1419/// it declares — governed by language-stability rules, not ADR 0310's codegen-freeze
1420/// concern. See [`emit_abi_shapes`] and #999 Decision E for the two-list rationale.
1421const CAPABILITY_SURFACE: &[&str] = &[
1422 "Clock",
1423 "Fetch",
1424 "Idempotency",
1425 "Locale",
1426 "Logger",
1427 "Random",
1428 "Secrets",
1429 "Request",
1430 "Response",
1431 "LocaleTag",
1432 "Kv",
1433 "KVNamespace",
1434];
1435
1436/// Is `ident` one of ADR 0310's enumerated emit-ABI shapes, or part of the capability
1437/// surface a binding is required to import? If neither, it's a leak `emit_abi_shapes`
1438/// flags — this is the single predicate both the probe and its tests use, so a test
1439/// asserting "no leak" can't silently pass against a list the test itself redefined.
1440fn is_enumerated_emit_abi_or_capability_surface(ident: &str) -> bool {
1441 EMIT_ABI.contains(&ident) || CAPABILITY_SURFACE.contains(&ident)
1442}
1443
1444/// ADR 0310's probe (#999 Decision E). The vendored first-party bindings under
1445/// `bynk-check/src/firstparty/bindings/` must reference only [`EMIT_ABI`]'s nine names.
1446///
1447/// This does NOT count every non-enumerated import: a binding legitimately imports the
1448/// [`CAPABILITY_SURFACE`] interfaces it implements — that surface is governed by
1449/// language-stability rules, not ADR 0310's codegen-freeze concern, and a probe that
1450/// flagged it would read non-zero on every binding by construction. See #999 Decision
1451/// E for the two-list rationale and its falsifier.
1452fn emit_abi_shapes(root: &Path) -> Probe {
1453 let dir = root.join("bynk-check/src/firstparty/bindings");
1454 let mut leaks: Vec<String> = Vec::new();
1455 let Ok(entries) = std::fs::read_dir(&dir) else {
1456 return Probe {
1457 name: "emit_abi_shapes",
1458 gated: true,
1459 reads: "bindings directory not found".to_string(),
1460 };
1461 };
1462 let mut files: Vec<_> = entries.flatten().map(|e| e.path()).collect();
1463 files.sort();
1464 for path in files {
1465 if path.extension().is_none_or(|e| e != "ts") {
1466 continue;
1467 }
1468 let Ok(contents) = std::fs::read_to_string(&path) else {
1469 continue;
1470 };
1471 let name = path.file_name().unwrap().to_string_lossy().to_string();
1472 for ident in ts_named_imports_from_runtime_modules(&contents) {
1473 if !is_enumerated_emit_abi_or_capability_surface(&ident) {
1474 leaks.push(format!("{name}:{ident}"));
1475 }
1476 }
1477 }
1478 Probe {
1479 name: "emit_abi_shapes",
1480 gated: true,
1481 reads: format!("{} ({})", leaks.len(), leaks.join(", ")),
1482 }
1483}
1484
1485/// Named identifiers imported from the compiler-generated firstparty/runtime relative
1486/// modules (`./bynk.js`, `./runtime.js`, `./bynk/locale/types.js`, `./cloudflare.js`,
1487/// or their `../` forms) — `import type { A, B }`/`import { A, B }` braces, stripping
1488/// `type ` markers and `X as Y` aliases (keeping the imported name, not the local one,
1489/// since the allowlists are about what's referenced, not what it's called locally).
1490fn ts_named_imports_from_runtime_modules(src: &str) -> Vec<String> {
1491 let mut out = Vec::new();
1492 for line in src.lines() {
1493 let line = line.trim();
1494 if !line.starts_with("import") {
1495 continue;
1496 }
1497 let is_runtime_module = ["\"./bynk.js\"", "\"./runtime.js\"", "\"../runtime.js\""]
1498 .iter()
1499 .any(|m| line.ends_with(&format!("from {m};")))
1500 || line.contains("bynk/locale/types.js")
1501 || line.contains("cloudflare.js");
1502 if !is_runtime_module {
1503 continue;
1504 }
1505 let Some(open) = line.find('{') else { continue };
1506 let Some(close) = line.find('}') else {
1507 continue;
1508 };
1509 for part in line[open + 1..close].split(',') {
1510 let part = part.trim().trim_start_matches("type ").trim();
1511 if part.is_empty() {
1512 continue;
1513 }
1514 let imported = part.split(" as ").next().unwrap_or(part).trim();
1515 out.push(imported.to_string());
1516 }
1517 }
1518 out
1519}
1520
1521// --- Reported probe 1: wildcard_arms --------------------------------------
1522
1523/// R2.12. `clippy::wildcard_enum_match_arm` diagnostics, forced on via `-W` so the
1524/// count is real from day one and doesn't wait on `workspace_lints`/T0.3 (#999 Decision
1525/// C — delegating to clippy's own type-aware pass, rather than a hand-rolled scan for
1526/// "compiler-owned enum", so the probe and the enforcement mechanism can never
1527/// disagree). A count, not a boolean — moves on nearly every match statement anyone
1528/// writes, so it is reported, not gated (#999 Decision D).
1529fn wildcard_arms(root: &Path) -> Probe {
1530 let reads = match run_clippy_wildcard_scan(root) {
1531 Ok(n) => n.to_string(),
1532 Err(e) => format!("error running clippy: {e}"),
1533 };
1534 Probe {
1535 name: "wildcard_arms",
1536 gated: false,
1537 reads,
1538 }
1539}
1540
1541/// Runs clippy with the lint forced on and parses the NDJSON output properly —
1542/// **not** a substring count. A single `wildcard_enum_match_arm` diagnostic's JSON
1543/// repeats the lint name several times (the `code` field, the human-readable message,
1544/// the `#[warn(...)]` note, and the `rendered` field duplicating the whole thing as
1545/// text), so `stdout.matches("wildcard_enum_match_arm").count()` overcounts by roughly
1546/// 3x — caught by cross-checking this probe's own first run against a real JSON parse
1547/// (296 real diagnostics, not the naive scan's 888).
1548///
1549/// Checks the process exit status: a forced `-W` (not `-D`) never fails the build on
1550/// account of the lint itself, so a non-zero exit means clippy genuinely could not run
1551/// (a compile error elsewhere, a missing toolchain component, offline with no cached
1552/// index) — in which case stdout carries no `compiler-message` lines and a silent
1553/// success would report a false, and indistinguishable, `0`. This probe is reported,
1554/// not gated, precisely so an honest "couldn't measure" surfaces loudly here rather
1555/// than being read as "closed."
1556fn run_clippy_wildcard_scan(root: &Path) -> std::io::Result<usize> {
1557 let output = Command::new("cargo")
1558 .args([
1559 "clippy",
1560 "--workspace",
1561 "--message-format=json",
1562 "--",
1563 "-W",
1564 "clippy::wildcard_enum_match_arm",
1565 ])
1566 .current_dir(root)
1567 .output()?;
1568 if !output.status.success() {
1569 return Err(std::io::Error::other(format!(
1570 "cargo clippy exited with {}: {}",
1571 output.status,
1572 String::from_utf8_lossy(&output.stderr).trim()
1573 )));
1574 }
1575 let stdout = String::from_utf8_lossy(&output.stdout);
1576 let mut count = 0usize;
1577 for line in stdout.lines() {
1578 let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
1579 continue;
1580 };
1581 if value.get("reason").and_then(|r| r.as_str()) != Some("compiler-message") {
1582 continue;
1583 }
1584 let code = value.pointer("/message/code/code").and_then(|c| c.as_str());
1585 if code == Some("clippy::wildcard_enum_match_arm") {
1586 count += 1;
1587 }
1588 }
1589 Ok(count)
1590}
1591
1592// --- Reported probe 2: keep_in_sync ---------------------------------------
1593
1594/// P2 (trend only). Comments across the workspace containing "in sync", "mirrors",
1595/// "parity", or "must match" — each one names a rule the compiler cannot teach itself
1596/// and must be taught in review, every time.
1597fn keep_in_sync(root: &Path) -> Probe {
1598 let phrases = ["in sync", "mirrors", "parity", "must match"];
1599 let mut count = 0usize;
1600 for dir in top_level_crate_dirs(root) {
1601 for (_, contents) in rust_files(&dir.join("src")) {
1602 for line in contents.lines() {
1603 if is_line_comment(line) {
1604 let lower = line.to_lowercase();
1605 if phrases.iter().any(|p| lower.contains(p)) {
1606 count += 1;
1607 }
1608 }
1609 }
1610 }
1611 }
1612 Probe {
1613 name: "keep_in_sync",
1614 gated: false,
1615 reads: count.to_string(),
1616 }
1617}
1618
1619// --- Reported probe 3: test_density ---------------------------------------
1620
1621/// R11.1, and §3.4's phase-3 trigger. Per crate: (lines inside `#[test]` fn bodies,
1622/// plus lines inside `#[cfg(test)] mod` blocks outside those fns) ÷ (non-blank,
1623/// non-comment lines under that crate's `src/`) — #999 Decision F's definition,
1624/// written down precisely because an undefined "ratio" is exactly the ambiguity that
1625/// produced the track doc §9's four-row ambiguity.
1626fn test_density(root: &Path) -> Probe {
1627 let mut parts = Vec::new();
1628 for dir in top_level_crate_dirs(root) {
1629 let name = dir.file_name().unwrap().to_string_lossy().to_string();
1630 let src_dir = dir.join("src");
1631 let mut test_lines = 0usize;
1632 let mut code_lines = 0usize;
1633 for (_, contents) in rust_files(&src_dir) {
1634 let lines: Vec<&str> = contents.lines().collect();
1635 let ranges = test_mod_ranges(&lines);
1636 for (i, line) in lines.iter().enumerate() {
1637 let is_blank_or_comment = line.trim().is_empty() || is_line_comment(line);
1638 if !is_blank_or_comment {
1639 code_lines += 1;
1640 }
1641 if in_test_range(i, &ranges) && !is_blank_or_comment {
1642 test_lines += 1;
1643 }
1644 }
1645 }
1646 if code_lines > 0 {
1647 let ratio = 100.0 * test_lines as f64 / code_lines as f64;
1648 parts.push(format!("{name}={ratio:.1}%"));
1649 }
1650 }
1651 Probe {
1652 name: "test_density",
1653 gated: false,
1654 reads: parts.join(", "),
1655 }
1656}
1657
1658// --- Reported probe 4: fixture_kinds --------------------------------------
1659
1660/// R11.2. Fixture directories under `bynkc/tests` using each assertion granularity —
1661/// `expected_contains.txt` / `expected_absent.txt` / `expected_diagnostics.txt` — set
1662/// against the older, coarser `expected_error.txt` (category-string) convention.
1663fn fixture_kinds(root: &Path) -> Probe {
1664 let tests_dir = root.join("bynkc/tests");
1665 let contains = count_files_named(&tests_dir, "expected_contains.txt");
1666 let absent = count_files_named(&tests_dir, "expected_absent.txt");
1667 let diagnostics = count_files_named(&tests_dir, "expected_diagnostics.txt");
1668 let error = count_files_named(&tests_dir, "expected_error.txt");
1669 Probe {
1670 name: "fixture_kinds",
1671 gated: false,
1672 reads: format!(
1673 "contains={contains}, absent={absent}, diagnostics={diagnostics}, error={error}"
1674 ),
1675 }
1676}
1677
1678fn count_files_named(dir: &Path, filename: &str) -> usize {
1679 let mut count = 0usize;
1680 count_files_named_walk(dir, filename, &mut count);
1681 count
1682}
1683
1684fn count_files_named_walk(dir: &Path, filename: &str, count: &mut usize) {
1685 let Ok(entries) = std::fs::read_dir(dir) else {
1686 return;
1687 };
1688 for entry in entries.flatten() {
1689 let path = entry.path();
1690 if path.is_dir() {
1691 count_files_named_walk(&path, filename, count);
1692 } else if path.file_name().is_some_and(|n| n == filename) {
1693 *count += 1;
1694 }
1695 }
1696}
1697
1698// --- Rendering + diffing ---------------------------------------------------
1699
1700/// The committed table: a plain Markdown table, probe name → gated?/reads, plus a
1701/// pointer to the rule ledger `stamp::apply` writes (#1001).
1702pub fn render_table(report: &Report) -> String {
1703 let mut out = String::new();
1704 out.push_str("<!-- GENERATED FILE — do not edit by hand.\n");
1705 out.push_str(" Source: cargo xtask greenfield-status (xtask/src/greenfield_status.rs).\n");
1706 out.push_str(" Regenerate with: cargo xtask greenfield-status --apply -->\n\n");
1707 out.push_str("# Greenfield status\n\n");
1708 out.push_str(
1709 "Track slice T0.0 (#999). Nine probes are gated — a disagreement between this \
1710 file and a fresh run fails `greenfield_status_table_is_current` \
1711 (`xtask/tests/greenfield_status.rs`). Four are trend probes, reported only.\n\n",
1712 );
1713 out.push_str("| Probe | Gated | Reads |\n|---|---|---|\n");
1714 for probe in &report.probes {
1715 let _ = writeln!(
1716 out,
1717 "| `{}` | {} | {} |",
1718 probe.name,
1719 if probe.gated { "yes" } else { "no (trend)" },
1720 probe.reads
1721 );
1722 }
1723
1724 out.push_str("\n## Rules closed\n\n");
1725 // A static, unconditional link — not a count, and not even an existence
1726 // check. A first draft read `design/greenfield-status-rules.md` here to
1727 // report a row count, but nothing regenerates *this* file when `stamp`
1728 // writes the ledger (`stamp.yml` never runs `greenfield-status --apply`,
1729 // and the gating test only diffs the nine probes) — so a count or an
1730 // exists/doesn't-exist message would silently go stale the moment the
1731 // first `closes_rule` landed, which is exactly the drift this section
1732 // exists to avoid, not invite (#1001 review). Static text can't go stale;
1733 // the ledger is one click away either way.
1734 out.push_str(
1735 "See [`design/greenfield-status-rules.md`](greenfield-status-rules.md) for rule ids \
1736 closed so far (written by `cargo xtask stamp --apply` at merge; may not exist yet if \
1737 no increment has cited `closes_rule`).\n",
1738 );
1739 out
1740}
1741
1742/// Every gated probe whose live reading disagrees with the committed table's, as
1743/// `(probe name, committed, live)`. Trend probes are never compared, and never
1744/// computed here — this only runs the nine gated probes, so checking currency never
1745/// pays for `wildcard_arms`'s workspace-wide clippy pass. For a caller that has already
1746/// run the full report (e.g. to print it), use [`gated_disagreements_in`] instead so the
1747/// nine gated probes aren't computed a second time.
1748pub fn gated_disagreements(root: &Path) -> Vec<(String, String, String)> {
1749 gated_disagreements_in(&run_gated(root), root)
1750}
1751
1752/// Like [`gated_disagreements`], but diffs `probes` (typically a [`Report`]'s
1753/// `.probes`, already computed) instead of re-running the gated probes.
1754pub fn gated_disagreements_in(probes: &[Probe], root: &Path) -> Vec<(String, String, String)> {
1755 let committed = std::fs::read_to_string(table_path(root)).unwrap_or_default();
1756 let mut out = Vec::new();
1757 for probe in probes.iter().filter(|p| p.gated) {
1758 let row_prefix = format!("| `{}` | yes | ", probe.name);
1759 let committed_reads = committed
1760 .lines()
1761 .find(|l| l.starts_with(&row_prefix))
1762 .and_then(|l| l.strip_prefix(&row_prefix))
1763 .and_then(|l| l.strip_suffix(" |"))
1764 .unwrap_or("<row missing>");
1765 if committed_reads != probe.reads {
1766 out.push((
1767 probe.name.to_string(),
1768 committed_reads.to_string(),
1769 probe.reads.clone(),
1770 ));
1771 }
1772 }
1773 out
1774}
1775
1776#[cfg(test)]
1777mod tests {
1778 use super::*;
1779
1780 // --- emit_diagnostics (#999 Decision A) ---------------------------------
1781
1782 /// A standalone `"bynk.foo"` literal is found — the ordinary case.
1783 #[test]
1784 fn bynk_dotted_literals_finds_standalone_literal() {
1785 let src = r#"code("bynk.check.something", "a message")"#;
1786 assert_eq!(bynk_dotted_literals(src), vec!["bynk.check.something"]);
1787 }
1788
1789 /// The bug this slice found in its own first draft: a longer message that merely
1790 /// *starts* with "bynk." must not be truncated into a fake code literal. Regression
1791 /// test for `bynk.map itself uses bynk.list, so list must be injected too: {paths:?}`
1792 /// (`bynk-emit/src/project.rs`), which an earlier, less careful version of this scan
1793 /// wrongly counted as the literal `"bynk.map"`.
1794 #[test]
1795 fn bynk_dotted_literals_ignores_prefix_of_a_longer_message() {
1796 let src = r#"assert!(cond, "bynk.map itself uses bynk.list, so list must be injected too: {paths:?}");"#;
1797 assert!(bynk_dotted_literals(src).is_empty());
1798 }
1799
1800 /// Regression test for the other half of the same bug: a `\`-continued string
1801 /// literal (`"bynk.emit.unresolved_cross_context_signature: no signature for \`,
1802 /// continued on the next source line) is one string, not a diagnostic-code literal,
1803 /// even though its first segment matches the identifier charset — because the
1804 /// character after the run is `:`, never a closing quote, on either line.
1805 #[test]
1806 fn bynk_dotted_literals_ignores_a_line_continued_message() {
1807 let src =
1808 "\"bynk.emit.unresolved_cross_context_signature: no signature for \\\n the rest\"";
1809 assert!(bynk_dotted_literals(src).is_empty());
1810 }
1811
1812 /// The whole point of Decision A: cross-referencing the real registry, not a
1813 /// hand-maintained exclusion list, correctly separates a real diagnostic code from
1814 /// a commons/namespace path that merely looks like one.
1815 #[test]
1816 fn emit_diagnostics_cross_references_the_real_registry() {
1817 let registry: BTreeSet<&str> = bynk_syntax::diagnostics::REGISTRY
1818 .iter()
1819 .map(|d| d.code)
1820 .collect();
1821 // A code this registry is known to carry (bynk-syntax/src/diagnostics.rs).
1822 assert!(registry.contains("bynk.parse.expected_expression"));
1823 // A commons/namespace path, not a diagnostic code — #999's own verified survey.
1824 assert!(!registry.contains("bynk.locale"));
1825 }
1826
1827 // --- ast_importers (#1176) ------------------------------------------------
1828
1829 /// The exclusion is named, not prefixed: `ir.rs`/`ir/lower.rs` are the lowering
1830 /// pass's own legitimate `Ast → Ir` import; `project/tests_emit.rs` is the
1831 /// Q7-settled (`the-ir.md` §3.7) `Ir → String` half that keeps hand-writing
1832 /// TypeScript by calling straight into `emitter.rs`'s own body-rendering, and
1833 /// keeps reading a handler's declared param/return `TypeRef` with no `TyId`
1834 /// available at that call site — but `project.rs` (which also imports
1835 /// `bynk_syntax::ast`, via `EmitProjectCtx`) must stay counted, and so, per
1836 /// review of #1210, must `emitter.rs`/`emitter/lower.rs` themselves: both still
1837 /// hold live AST-*declaration* reads (`emitter.rs`'s `CommonsItem::Service`/
1838 /// `svc.protocol` walk, `emitter/lower.rs`'s `cap_op_param_names`) that are the
1839 /// still-open R6.13 defect this probe tracks, not the Q7 kind — excluding either
1840 /// file would hide that real work the same way a path-prefix rule would. A
1841 /// path-prefix rule (e.g. "only `emitter/**` counts") would have excluded
1842 /// `project.rs` right along with the legitimate three, silently undercounting
1843 /// real work.
1844 #[test]
1845 fn ast_importer_exclusion_is_named_not_prefixed() {
1846 assert!(is_named_ast_importer(Path::new("ir.rs")));
1847 assert!(is_named_ast_importer(Path::new("ir/lower.rs")));
1848 assert!(is_named_ast_importer(Path::new("project/tests_emit.rs")));
1849 assert!(!is_named_ast_importer(Path::new("project.rs")));
1850 assert!(!is_named_ast_importer(Path::new("emitter.rs")));
1851 assert!(!is_named_ast_importer(Path::new("emitter/lower.rs")));
1852 assert!(!is_named_ast_importer(Path::new("emitter/workers.rs")));
1853 assert!(!is_named_ast_importer(Path::new("ir/other.rs")));
1854 }
1855
1856 /// #1184 review: an `AST_IMPORTER_EXCEPTIONS` entry going stale (renamed or split,
1857 /// e.g. `ir/lower.rs` becoming `ir/lower/mod.rs`) must fail loud here, not surface
1858 /// as a silent `ast_importers` regression in `greenfield_status_table_is_current` —
1859 /// mirrors [`file_is_named_fs_floor`]'s own "fail loud, not quiet" discipline.
1860 #[test]
1861 fn ast_importer_exceptions_still_exist_and_still_import_the_ast() {
1862 let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1863 .join("..")
1864 .join("bynk-emit/src");
1865 for rel in AST_IMPORTER_EXCEPTIONS {
1866 let contents = std::fs::read_to_string(dir.join(rel)).unwrap_or_else(|e| {
1867 panic!("AST_IMPORTER_EXCEPTIONS entry {rel:?} does not exist: {e}")
1868 });
1869 assert!(
1870 contents.contains("bynk_syntax::ast"),
1871 "AST_IMPORTER_EXCEPTIONS entry {rel:?} no longer imports bynk_syntax::ast \
1872 — it excludes nothing and should be removed"
1873 );
1874 }
1875 }
1876
1877 /// #1184 review, extended by #1187's own closing scoping pass (and narrowed by
1878 /// review of #1210, which found `emitter.rs`/`emitter/lower.rs` still hold live,
1879 /// in-scope AST-declaration reads and must stay counted): exercises the real
1880 /// filter over the live tree, not just the pure predicate — the survivor set the
1881 /// PR's own named-vs-prefix argument depends on: the three named exclusions drop
1882 /// out (`ir/`'s legitimate `Ast → Ir` pair, plus `project/tests_emit.rs`'s
1883 /// Q7-settled `Ir → String` case), `project.rs` and `emitter/workers.rs` (R6.13's
1884 /// still-open AST-declaration reads, the latter standing in for
1885 /// `emitter.rs`/`emitter/lower.rs` themselves) do not.
1886 #[test]
1887 fn ast_importers_excludes_the_named_pairs_but_counts_project_rs() {
1888 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..");
1889 let dir = root.join("bynk-emit/src");
1890 let counted: BTreeSet<String> = ast_importer_files(&root)
1891 .into_iter()
1892 .map(|path| {
1893 path.strip_prefix(&dir)
1894 .unwrap_or(&path)
1895 .to_string_lossy()
1896 .replace('\\', "/")
1897 })
1898 .collect();
1899 assert!(!counted.contains("ir.rs"));
1900 assert!(!counted.contains("ir/lower.rs"));
1901 assert!(!counted.contains("project/tests_emit.rs"));
1902 assert!(counted.contains("project.rs"));
1903 assert!(counted.contains("emitter.rs"));
1904 assert!(counted.contains("emitter/lower.rs"));
1905 assert!(counted.contains("emitter/workers.rs"));
1906 }
1907
1908 // --- fs_below_driver / test_density (trailing `#[cfg(test)] mod tests {}`) ---
1909
1910 #[test]
1911 fn production_std_fs_usage_is_detected() {
1912 let src = "fn load(p: &Path) -> String {\n std::fs::read_to_string(p).unwrap()\n}\n";
1913 assert!(has_production_std_fs(src));
1914 }
1915
1916 #[test]
1917 fn std_fs_inside_a_trailing_test_mod_is_not_production() {
1918 let src = "fn load(p: &Path) -> String {\n String::new()\n}\n\n#[cfg(test)]\nmod tests {\n #[test]\n fn t() {\n std::fs::write(\"x\", \"y\").unwrap();\n }\n}\n";
1919 assert!(!has_production_std_fs(src));
1920 }
1921
1922 /// Regression test for the other real bug this slice found: `bynk-emit/src/lib.rs`
1923 /// has `#[cfg(test)] pub(crate) mod testkit;` — an external-file module
1924 /// *declaration* (ends in `;`), not an inline block. It must not be mistaken for a
1925 /// scope-opening `mod tests { ... }`, or the (genuinely production) code after it in
1926 /// the same file would be wrongly excluded.
1927 #[test]
1928 fn cfg_test_external_mod_declaration_does_not_open_a_test_region() {
1929 let src = "#[cfg(test)]\npub(crate) mod testkit;\n\nfn load(p: &Path) -> String {\n std::fs::read_to_string(p).unwrap()\n}\n";
1930 assert!(has_production_std_fs(src));
1931 }
1932
1933 /// Regression test for the bug caught in review: a file with **two** scattered
1934 /// `#[cfg(test)] mod ... { ... }` blocks, with real production code between them —
1935 /// exactly `bynk-emit/src/emitter/lower.rs`'s shape (two test modules, 1031
1936 /// production lines apart). A single "everything from the first/last `#[cfg(test)]`
1937 /// onward" cutoff would misclassify `lower_lambda` here as test-scope; the fix must
1938 /// close each block at its own boundary and resume production scanning after it.
1939 #[test]
1940 fn production_code_between_two_scattered_test_mods_is_detected() {
1941 let src = "\
1942#[cfg(test)]
1943mod decode_map_key_tests {
1944 #[test]
1945 fn t() {
1946 assert_eq!(1, 1);
1947 }
1948}
1949
1950fn lower_lambda(p: &Path) -> String {
1951 std::fs::read_to_string(p).unwrap()
1952}
1953
1954#[cfg(test)]
1955mod idempotency_scoping_tests {
1956 #[test]
1957 fn t2() {
1958 assert_eq!(2, 2);
1959 }
1960}
1961";
1962 assert!(has_production_std_fs(src));
1963 }
1964
1965 /// The same fixture's `test_mod_ranges` shape, checked directly: two disjoint
1966 /// ranges, not one span from the first block to the last.
1967 #[test]
1968 fn test_mod_ranges_finds_each_block_separately() {
1969 let src = "\
1970#[cfg(test)]
1971mod a {
1972 fn x() {}
1973}
1974
1975fn production() {}
1976
1977#[cfg(test)]
1978mod b {
1979 fn y() {}
1980}
1981";
1982 let lines: Vec<&str> = src.lines().collect();
1983 let ranges = test_mod_ranges(&lines);
1984 assert_eq!(
1985 ranges.len(),
1986 2,
1987 "expected two disjoint test-mod ranges: {ranges:?}"
1988 );
1989 // Line 5 (0-indexed) is `fn production() {}`, between the two blocks.
1990 assert!(
1991 !in_test_range(5, &ranges),
1992 "production() must not read as test-scope"
1993 );
1994 }
1995
1996 /// Regression test for the bug in the *fix* for the above: a column-0-`}`
1997 /// shortcut (tried and reverted during review) truncates a test module the moment
1998 /// its body embeds a multi-line fixture string containing a `}` flush against the
1999 /// left margin — exactly `bynk-ide/src/sequence.rs`'s shape, whose test mod embeds
2000 /// `.bynk` source fixtures. The real brace-depth scanner must see through the
2001 /// string and find the module's *actual* closing brace, hundreds of lines later.
2002 /// Uses a raw string for the outer fixture so the embedded `"..."` doesn't need
2003 /// escaping, and locates the real end by content rather than a hand-counted index
2004 /// — a hand-counted line number is exactly the kind of easy-to-miscount detail
2005 /// this codebase's own convention (verify, don't assume) warns against.
2006 #[test]
2007 fn test_mod_ranges_is_not_fooled_by_a_column_zero_brace_inside_a_string() {
2008 let src = r#"#[cfg(test)]
2009mod tests {
2010 const FIXTURE: &str = "
2011commons app.demo {
2012}
2013";
2014
2015 fn real_end_of_module() {}
2016}
2017"#;
2018 let lines: Vec<&str> = src.lines().collect();
2019 let ranges = test_mod_ranges(&lines);
2020 assert_eq!(ranges.len(), 1, "expected exactly one range: {ranges:?}");
2021 let (_, end) = ranges[0];
2022 // `str::lines()` drops the trailing newline, so the module's real closing
2023 // brace — the fixture's last line — is at `lines.len() - 1`. The string's
2024 // embedded `}` (an earlier line) must not be mistaken for it.
2025 assert_eq!(
2026 end,
2027 lines.len() - 1,
2028 "closed too early — mistook the string's `}}` for the module's: {ranges:?}"
2029 );
2030 }
2031
2032 // --- fs_below_driver: import resolution through `use super::*;` (#1013) ---
2033
2034 /// Run [`production_std_fs_files`] over an in-memory crate layout and name the
2035 /// flagged files, so each case reads as "these files, and only these".
2036 fn flagged(files: &[(&str, &str)]) -> Vec<String> {
2037 let owned: Vec<(PathBuf, String)> = files
2038 .iter()
2039 .map(|(p, s)| (PathBuf::from(p), (*s).to_string()))
2040 .collect();
2041 production_std_fs_files(&owned)
2042 .into_iter()
2043 .map(|i| files[i].0.to_string())
2044 .collect()
2045 }
2046
2047 /// The concrete #1013 instance, in miniature: `project.rs` has a module-level
2048 /// `use std::fs;` (ancestor-scoped, so visible to descendants), `discovery.rs`
2049 /// glob-imports it via `use super::*;` and calls bare `fs::read_to_string` —
2050 /// touching `std::fs` in production while never spelling it. The text scan alone
2051 /// reads only `project.rs`; the resolved probe must read both.
2052 #[test]
2053 fn bare_fs_reached_through_a_glob_imported_parent_is_flagged() {
2054 let files = [
2055 ("lib.rs", "mod project;\n"),
2056 ("project.rs", "use std::fs;\n\nmod discovery;\n"),
2057 (
2058 "project/discovery.rs",
2059 "use super::*;\n\nfn read_source(path: &std::path::Path) -> String {\n fs::read_to_string(path).unwrap()\n}\n",
2060 ),
2061 ];
2062 assert!(
2063 !has_production_std_fs(files[2].1),
2064 "the text scan alone must miss it"
2065 );
2066 assert_eq!(flagged(&files), vec!["project.rs", "project/discovery.rs"]);
2067 }
2068
2069 /// Without `use super::*;` there is no path from the bare `fs::` to the parent's
2070 /// binding — the probe must not guess one into existence.
2071 #[test]
2072 fn bare_fs_without_a_glob_super_import_is_not_flagged() {
2073 let files = [
2074 ("lib.rs", "mod project;\n"),
2075 ("project.rs", "use std::fs;\n\nmod discovery;\n"),
2076 (
2077 "project/discovery.rs",
2078 "fn read_source(path: &std::path::Path) -> String {\n fs::read_to_string(path).unwrap()\n}\n",
2079 ),
2080 ];
2081 assert_eq!(flagged(&files), vec!["project.rs"]);
2082 }
2083
2084 /// Glob chains re-reach ancestors transitively — grandparent binds `fs`, both
2085 /// hops glob-import `super::*` — and the `mod.rs` layout maps to the same module
2086 /// tree as the `name.rs` one. The middle file sees `fs` but never uses it, so
2087 /// only the leaf joins the (text-flagged) root.
2088 #[test]
2089 fn glob_super_resolution_is_transitive_across_mod_rs_parents() {
2090 let files = [
2091 ("lib.rs", "use std::fs;\n\nmod a;\n"),
2092 ("a/mod.rs", "use super::*;\n\nmod b;\n"),
2093 (
2094 "a/b.rs",
2095 "use super::*;\n\nfn walk() {\n let _ = fs::read_dir(\".\");\n}\n",
2096 ),
2097 ];
2098 assert_eq!(flagged(&files), vec!["lib.rs", "a/b.rs"]);
2099 }
2100
2101 /// A break anywhere in the chain stops resolution: the middle module does not
2102 /// glob-import `super::*`, so the leaf's `use super::*;` reaches a module with no
2103 /// `fs` binding to offer.
2104 #[test]
2105 fn a_break_in_the_glob_chain_stops_resolution() {
2106 let files = [
2107 ("lib.rs", "use std::fs;\n\nmod a;\n"),
2108 ("a/mod.rs", "mod b;\n"),
2109 (
2110 "a/b.rs",
2111 "use super::*;\n\nfn walk() {\n let _ = fs::read_dir(\".\");\n}\n",
2112 ),
2113 ];
2114 assert_eq!(flagged(&files), vec!["lib.rs"]);
2115 }
2116
2117 /// Nearest binding wins, as in Rust: the child re-binds `fs` to something that is
2118 /// not `std::fs`, so its bare `fs::` calls are that something's, not std's.
2119 #[test]
2120 fn a_local_non_std_binding_shadows_the_ancestors_std_fs() {
2121 let files = [
2122 ("lib.rs", "mod project;\n"),
2123 ("project.rs", "use std::fs;\n\nmod overlay;\nmod d;\n"),
2124 ("project/overlay.rs", "pub fn read(_p: &str) {}\n"),
2125 (
2126 "project/d.rs",
2127 "use super::*;\nuse crate::project::overlay as fs;\n\nfn f() {\n let _ = fs::read(\"x\");\n}\n",
2128 ),
2129 ];
2130 assert_eq!(flagged(&files), vec!["project.rs"]);
2131 }
2132
2133 /// An aliased module binding resolves under its alias — the call site never
2134 /// contains the substring `fs::` at all.
2135 #[test]
2136 fn an_aliased_std_fs_binding_resolves_through_the_glob() {
2137 let files = [
2138 ("lib.rs", "mod p;\n"),
2139 ("p.rs", "use std::fs as stdfs;\n\nmod c;\n"),
2140 (
2141 "p/c.rs",
2142 "use super::*;\n\nfn f() {\n stdfs::write(\"a\", \"b\").unwrap();\n}\n",
2143 ),
2144 ];
2145 assert_eq!(flagged(&files), vec!["p.rs", "p/c.rs"]);
2146 }
2147
2148 /// `use std::{fs, io};` binds `fs` without ever containing the substring
2149 /// `std::fs` — the same blind spot as #1013's, one file deep. Resolution applies
2150 /// in the file's own scope, no glob import required.
2151 #[test]
2152 fn a_group_imported_fs_binding_is_resolved_in_its_own_file() {
2153 let src = "use std::{fs, io};\n\nfn f() -> io::Result<()> {\n fs::metadata(\"x\").map(|_| ())\n}\n";
2154 assert!(
2155 !has_production_std_fs(src),
2156 "the text scan alone must miss it"
2157 );
2158 let files = [("thing.rs", src)];
2159 assert_eq!(flagged(&files), vec!["thing.rs"]);
2160 }
2161
2162 /// The item-import shape #1013 scope-checked (zero current instances), at the
2163 /// granularity this probe can reach: an ancestor's `use std::fs::File;` used as a
2164 /// bare path root `File::open` in a glob-importing child resolves and flags. (A
2165 /// bare *call* of an imported fn — `read_to_string(p)`, no `::` — presents no
2166 /// path root and remains out of a text-level scanner's reach, per the doc.)
2167 #[test]
2168 fn an_item_import_under_std_fs_resolves_as_a_path_root() {
2169 let files = [
2170 ("lib.rs", "mod p;\n"),
2171 ("p.rs", "use std::fs::File;\n\nmod c;\n"),
2172 (
2173 "p/c.rs",
2174 "use super::*;\n\nfn f() {\n let _ = File::open(\"x\");\n}\n",
2175 ),
2176 ];
2177 assert_eq!(flagged(&files), vec!["p.rs", "p/c.rs"]);
2178 }
2179
2180 /// A test module's `use super::*;` and tempdir `fs::` calls are test-scope — the
2181 /// `bynk-ide` files' shape (`architecture.rs`, `sequence.rs`), which must stay
2182 /// unflagged exactly as they were under the text-only scan.
2183 #[test]
2184 fn glob_and_bare_fs_inside_a_test_mod_stay_test_scope() {
2185 let files = [
2186 ("lib.rs", "use std::fs;\n\nmod w;\n"),
2187 (
2188 "w.rs",
2189 "fn production() {}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use std::fs;\n\n #[test]\n fn t() {\n let _ = fs::read_dir(\".\");\n }\n}\n",
2190 ),
2191 ];
2192 assert_eq!(flagged(&files), vec!["lib.rs"]);
2193 }
2194
2195 /// The module-tree mapping behind the resolution, checked directly: `name.rs` and
2196 /// `mod.rs` layouts, a preferred `a.rs` over `a/mod.rs`, and rootless roots.
2197 #[test]
2198 fn module_parent_maps_both_file_layouts() {
2199 let files: Vec<(PathBuf, String)> = ["lib.rs", "a.rs", "a/b.rs", "c/mod.rs", "c/d.rs"]
2200 .iter()
2201 .map(|p| (PathBuf::from(p), String::new()))
2202 .collect();
2203 let idx = |name: &str| {
2204 files
2205 .iter()
2206 .position(|(p, _)| p == Path::new(name))
2207 .unwrap()
2208 };
2209 assert_eq!(module_parent(Path::new("lib.rs"), &files), None);
2210 assert_eq!(
2211 module_parent(Path::new("a.rs"), &files),
2212 Some(idx("lib.rs"))
2213 );
2214 assert_eq!(
2215 module_parent(Path::new("a/b.rs"), &files),
2216 Some(idx("a.rs"))
2217 );
2218 assert_eq!(
2219 module_parent(Path::new("c/mod.rs"), &files),
2220 Some(idx("lib.rs"))
2221 );
2222 assert_eq!(
2223 module_parent(Path::new("c/d.rs"), &files),
2224 Some(idx("c/mod.rs"))
2225 );
2226 }
2227
2228 // --- fs_below_driver: #1016 review findings ------------------------------
2229
2230 /// Finding 1: a `super::`-qualified path needs no glob import — module privacy is
2231 /// ancestor-scoped, so `super::fs` names the parent's private `use std::fs;` from
2232 /// any child. One disambiguating edit away from `discovery.rs:39`'s bare call,
2233 /// and it must not drop the file out of the count.
2234 #[test]
2235 fn a_super_qualified_path_resolves_without_a_glob_import() {
2236 let files = [
2237 ("lib.rs", "mod project;\n"),
2238 ("project.rs", "use std::fs;\n\nmod discovery;\n"),
2239 (
2240 "project/discovery.rs",
2241 "fn read_source(path: &std::path::Path) -> String {\n super::fs::read_to_string(path).unwrap()\n}\n",
2242 ),
2243 ];
2244 assert_eq!(flagged(&files), vec!["project.rs", "project/discovery.rs"]);
2245 }
2246
2247 /// Finding 1, the `crate::`-rooted form: the walk descends the module tree from
2248 /// the crate root file by file, then resolves the leaf against that module's
2249 /// bindings — from anywhere in the crate, glob import or not.
2250 #[test]
2251 fn a_crate_qualified_path_resolves_through_the_module_tree() {
2252 let files = [
2253 ("lib.rs", "mod other;\nmod project;\n"),
2254 (
2255 "other.rs",
2256 "fn f() {\n let _ = crate::project::fs::read_dir(\".\");\n}\n",
2257 ),
2258 ("project.rs", "use std::fs;\n"),
2259 ];
2260 assert_eq!(flagged(&files), vec!["other.rs", "project.rs"]);
2261 }
2262
2263 /// Finding 1, stacked hops: `super::super::` climbs two parents (through a
2264 /// glob-free middle module — qualified paths don't need the glob chain).
2265 #[test]
2266 fn stacked_super_hops_climb_the_parent_chain() {
2267 let files = [
2268 ("lib.rs", "use std::fs;\n\nmod a;\n"),
2269 ("a/mod.rs", "mod b;\n"),
2270 (
2271 "a/b.rs",
2272 "fn f() {\n let _ = super::super::fs::read_dir(\".\");\n}\n",
2273 ),
2274 ];
2275 assert_eq!(flagged(&files), vec!["lib.rs", "a/b.rs"]);
2276 }
2277
2278 /// Finding 1, `self::` composed with the glob chain: `self::fs` resolves in the
2279 /// file's own namespace, which includes what its `use super::*;` pulled in.
2280 #[test]
2281 fn a_self_qualified_path_resolves_through_the_files_own_glob_chain() {
2282 let files = [
2283 ("lib.rs", "mod p;\n"),
2284 ("p.rs", "use std::fs;\n\nmod c;\n"),
2285 (
2286 "p/c.rs",
2287 "use super::*;\n\nfn f() {\n let _ = self::fs::read_dir(\".\");\n}\n",
2288 ),
2289 ];
2290 assert_eq!(flagged(&files), vec!["p.rs", "p/c.rs"]);
2291 }
2292
2293 /// Finding 1's negatives: a qualified path to a name the parent binds to
2294 /// something other than `std::fs` stops at that binding, and a path through a
2295 /// module that doesn't exist resolves nowhere.
2296 #[test]
2297 fn a_qualified_path_to_a_non_std_binding_or_missing_module_is_not_flagged() {
2298 let files = [
2299 ("lib.rs", "mod overlay;\nmod p;\n"),
2300 ("overlay.rs", "pub fn read_dir(_p: &str) {}\n"),
2301 ("p.rs", "use crate::overlay as fs;\n\nmod d;\n"),
2302 (
2303 "p/d.rs",
2304 "fn f() {\n let _ = super::fs::read_dir(\".\");\n let _ = crate::missing::fs::read_dir(\".\");\n}\n",
2305 ),
2306 ];
2307 assert_eq!(flagged(&files), Vec::<String>::new());
2308 }
2309
2310 /// Finding 2: a locally-declared type-namespace item beats a glob-imported name
2311 /// in real Rust — a child with its own `mod fs;` calling `fs::…` is calling its
2312 /// own submodule, not the ancestor's `std::fs`.
2313 #[test]
2314 fn a_locally_declared_module_shadows_the_ancestors_std_fs() {
2315 let files = [
2316 ("lib.rs", "mod p;\n"),
2317 ("p.rs", "use std::fs;\n\nmod c;\n"),
2318 (
2319 "p/c.rs",
2320 "use super::*;\n\nmod fs;\n\nfn f() {\n let _ = fs::read_dir(\".\");\n}\n",
2321 ),
2322 ("p/c/fs.rs", "pub fn read_dir(_p: &str) {}\n"),
2323 ];
2324 assert_eq!(flagged(&files), vec!["p.rs"]);
2325 }
2326
2327 /// Finding 3: a trailing `//` comment on a `use` line must not sever the edge —
2328 /// neither the glob (`use super::*; // …`) nor the binding (`use std::fs; // …`).
2329 #[test]
2330 fn a_trailing_comment_on_a_use_line_does_not_sever_resolution() {
2331 let files = [
2332 ("lib.rs", "mod p;\n"),
2333 (
2334 "p.rs",
2335 "use std::fs; // read_source's disk fallback\n\nmod c;\n",
2336 ),
2337 (
2338 "p/c.rs",
2339 "use super::*; // parent's fs, PathBuf\n\nfn f() {\n let _ = fs::read_dir(\".\");\n}\n",
2340 ),
2341 ];
2342 assert_eq!(flagged(&files), vec!["p.rs", "p/c.rs"]);
2343 }
2344
2345 /// Finding 4: the nested-group + `::self` normalisation branches, pinned
2346 /// directly — `use std::{fs::{self, File}, io};` binds `fs` *and* `File` to
2347 /// `std::fs`, and `io` only to the shadow set. Getting `::self` wrong would
2348 /// silently under-count, which is exactly this probe's failure mode.
2349 #[test]
2350 fn a_nested_group_with_self_binds_the_module_and_its_items() {
2351 let facts = fs_import_facts("use std::{fs::{self, File}, io};\n");
2352 let bound: Vec<&str> = facts.std_fs_bindings.iter().map(String::as_str).collect();
2353 assert_eq!(bound, vec!["File", "fs"]);
2354 assert!(facts.use_bound_names.contains("io"));
2355 assert!(!facts.std_fs_bindings.contains("io"));
2356 }
2357
2358 /// Finding 4, the children half of [`FsImportFacts`]' contract: a parent whose
2359 /// *only* `use std::fs;` lives in its `#[cfg(test)] mod` hands no binding to a
2360 /// glob-importing child — `bynk-ide/src/symbols.rs`' shape, latent until it
2361 /// grows a submodule.
2362 #[test]
2363 fn a_parents_test_mod_use_std_fs_does_not_reach_its_children() {
2364 let files = [
2365 ("lib.rs", "mod p;\n"),
2366 (
2367 "p.rs",
2368 "mod c;\n\nfn production() {}\n\n#[cfg(test)]\nmod tests {\n use std::fs;\n\n #[test]\n fn t() {\n let _ = fs::read_dir(\".\");\n }\n}\n",
2369 ),
2370 (
2371 "p/c.rs",
2372 "use super::*;\n\nfn f() {\n let _ = fs::read_dir(\".\");\n}\n",
2373 ),
2374 ];
2375 assert_eq!(flagged(&files), Vec::<String>::new());
2376 }
2377
2378 // --- fs_below_driver: named-floor classification (#1104) -----------------
2379
2380 #[test]
2381 fn fn_name_on_line_strips_modifiers() {
2382 assert_eq!(fn_name_on_line("fn foo() {"), Some("foo".to_string()));
2383 assert_eq!(
2384 fn_name_on_line("pub(crate) fn read_adapter_binding("),
2385 Some("read_adapter_binding".to_string())
2386 );
2387 assert_eq!(
2388 fn_name_on_line("pub async unsafe fn go() {"),
2389 Some("go".to_string())
2390 );
2391 }
2392
2393 #[test]
2394 fn fn_name_on_line_ignores_non_fn_lines() {
2395 assert_eq!(fn_name_on_line(" let f = foo();"), None);
2396 assert_eq!(fn_name_on_line("/// calls fn bar somewhere"), None);
2397 }
2398
2399 /// A signature whose `{` arrives lines after the `fn` line — `read_adapter_binding`'s
2400 /// own real shape — must still resolve to the correct body range: `started` can't
2401 /// flip true on the parameter list, which has no braces of its own.
2402 #[test]
2403 fn production_fn_ranges_handles_a_wrapped_signature() {
2404 let src = "pub(crate) fn read_adapter_binding(\n path: &Path,\n) -> std::io::Result<String> {\n fs::read_to_string(path)\n}\n";
2405 let lines: Vec<&str> = src.lines().collect();
2406 let ranges = production_fn_ranges(&lines, &[]);
2407 assert_eq!(ranges.len(), 1);
2408 let (name, start, end) = &ranges[0];
2409 assert_eq!(name, "read_adapter_binding");
2410 assert_eq!(*start, 0);
2411 assert_eq!(*end, lines.len() - 1);
2412 assert_eq!(
2413 enclosing_fn(3, &ranges),
2414 Some("read_adapter_binding".to_string())
2415 );
2416 }
2417
2418 /// Build the `facts`/`parents` vectors [`file_is_named_fs_floor`] now takes as
2419 /// caller-supplied arguments, the same way [`fs_below_driver`] does, so each test
2420 /// below reads as "classify this file" rather than repeating the setup.
2421 fn classify(krate: &str, files: &[(PathBuf, String)], i: usize) -> bool {
2422 let facts: Vec<FsImportFacts> = files.iter().map(|(_, s)| fs_import_facts(s)).collect();
2423 let parents: Vec<Option<usize>> =
2424 files.iter().map(|(p, _)| module_parent(p, files)).collect();
2425 file_is_named_fs_floor(krate, files, &facts, &parents, i)
2426 }
2427
2428 /// The concrete #1104 shape, in miniature: `project.rs`'s bare `use std::fs;` (no
2429 /// enclosing fn — never itself a violation) plus `discovery.rs`'s two named-exception
2430 /// functions. The whole file must read as a named floor, not residual.
2431 #[test]
2432 fn file_is_named_fs_floor_true_for_the_real_discovery_rs_shape() {
2433 let files = [
2434 (
2435 PathBuf::from("project.rs"),
2436 "use std::fs;\n\nmod discovery;\n".to_string(),
2437 ),
2438 (
2439 PathBuf::from("project/discovery.rs"),
2440 "use super::*;\n\npub(crate) fn discover_bynk_files() {\n let _ = fs::read_dir(\".\");\n}\n\npub(crate) fn read_adapter_binding(path: &Path) -> std::io::Result<String> {\n fs::read_to_string(path)\n}\n".to_string(),
2441 ),
2442 ];
2443 assert!(classify("bynk-emit", &files, 1));
2444 }
2445
2446 /// A new, unlisted fn touching `std::fs` in the *same file* as two named exceptions
2447 /// must flip the whole file to residual — no partial credit, since "named floor"
2448 /// must mean every touch is accounted for, not most of them.
2449 #[test]
2450 fn file_is_named_fs_floor_false_when_an_unnamed_fn_also_touches_fs() {
2451 let files = [
2452 (
2453 PathBuf::from("project.rs"),
2454 "use std::fs;\n\nmod discovery;\n".to_string(),
2455 ),
2456 (
2457 PathBuf::from("project/discovery.rs"),
2458 "use super::*;\n\npub(crate) fn discover_bynk_files() {\n let _ = fs::read_dir(\".\");\n}\n\nfn some_new_helper() {\n let _ = fs::write(\"x\", \"y\");\n}\n".to_string(),
2459 ),
2460 ];
2461 assert!(!classify("bynk-emit", &files, 1));
2462 }
2463
2464 /// A file whose only production-scope touch is a bare `use std::fs;` import — no
2465 /// enclosing fn at all — is trivially a named floor: the import performs no I/O by
2466 /// itself, and the descendant it enables is checked (and named) separately.
2467 #[test]
2468 fn file_is_named_fs_floor_true_for_an_import_only_file() {
2469 let files = [(
2470 PathBuf::from("project.rs"),
2471 "use std::fs;\n\nmod discovery;\n".to_string(),
2472 )];
2473 assert!(classify("bynk-emit", &files, 0));
2474 }
2475
2476 /// The same `discovery.rs` shape under the wrong crate label must not read as a
2477 /// floor — [`NAMED_FS_EXCEPTIONS`] is keyed on `(crate, file, fn)`, not `(file, fn)`
2478 /// alone, so a same-named file/fn pair in a different crate isn't accidentally
2479 /// covered.
2480 #[test]
2481 fn file_is_named_fs_floor_false_under_the_wrong_crate() {
2482 let files = [
2483 (
2484 PathBuf::from("project.rs"),
2485 "use std::fs;\n\nmod discovery;\n".to_string(),
2486 ),
2487 (
2488 PathBuf::from("project/discovery.rs"),
2489 "use super::*;\n\npub(crate) fn discover_bynk_files() {\n let _ = fs::read_dir(\".\");\n}\n".to_string(),
2490 ),
2491 ];
2492 assert!(!classify("bynk-ide", &files, 1));
2493 }
2494
2495 /// Review finding (#1106): a module-scope `std::fs` touch that isn't an import
2496 /// declaration — a `static` initialiser doing real I/O — has no enclosing fn either,
2497 /// but is a genuine R2.3 violation and must not be waved through as a floor just
2498 /// because it sits outside every known fn range.
2499 #[test]
2500 fn file_is_named_fs_floor_false_for_a_module_scope_static_that_reads() {
2501 let files = [(
2502 PathBuf::from("project.rs"),
2503 "use std::fs;\n\nstatic ROOT: once_cell::sync::Lazy<String> = once_cell::sync::Lazy::new(|| fs::read_to_string(\"x\").unwrap());\n"
2504 .to_string(),
2505 )];
2506 assert!(!classify("bynk-emit", &files, 0));
2507 }
2508
2509 /// Same review finding, the [`fn_name_on_line`] half: an `extern "C" fn` (a modifier
2510 /// combination the parser doesn't strip) produces no [`production_fn_ranges`] entry
2511 /// at all, so its whole body would fall into the "no enclosing fn" branch. It must
2512 /// still read as residual, not floor, once it touches `std::fs`.
2513 #[test]
2514 fn file_is_named_fs_floor_false_for_an_unparsed_extern_fn_body() {
2515 let files = [(
2516 PathBuf::from("project.rs"),
2517 "use std::fs;\n\nextern \"C\" fn callback() {\n let _ = fs::read_dir(\".\");\n}\n"
2518 .to_string(),
2519 )];
2520 assert!(!classify("bynk-emit", &files, 0));
2521 }
2522
2523 // --- emit_abi_shapes (#999 Decision E) ----------------------------------
2524
2525 /// A binding's ordinary capability-interface imports, and the emit-ABI tag-layout
2526 /// names, must not be flagged — the exact failure mode Decision E rebuilt the probe
2527 /// to avoid (the original single-allowlist definition read 29-33 here, not 1).
2528 ///
2529 /// Exercises the real production allowlists via [`is_enumerated_emit_abi_or_capability_surface`]
2530 /// — not a local re-declaration. A test with its own copy of `EMIT_ABI` would still
2531 /// pass if the real one lost an entry (e.g. deleting `Uuid` from the production
2532 /// list), proving nothing about the probe it claims to cover.
2533 #[test]
2534 fn emit_abi_shapes_does_not_flag_capability_or_tag_layout_imports() {
2535 let src = "import type { Clock, Fetch, Locale } from \"./bynk.js\";\n\
2536 import { FetchError, Uuid } from \"./bynk.js\";\n\
2537 import { Err, None, Ok, Some, type Option, type Result } from \"./runtime.js\";\n";
2538 let imports = ts_named_imports_from_runtime_modules(src);
2539 let leaks: Vec<&String> = imports
2540 .iter()
2541 .filter(|i| !is_enumerated_emit_abi_or_capability_surface(i))
2542 .collect();
2543 assert!(leaks.is_empty(), "unexpected leaks: {leaks:?}");
2544 }
2545
2546 /// The falsifier from #999 Decision E, checked directly: deleting an entry from the
2547 /// real production allowlist must be detectable by *some* test — this one flags
2548 /// `Uuid` as a leak the moment it's removed from [`EMIT_ABI`], which the test above
2549 /// (using the real const) would also start failing on.
2550 #[test]
2551 fn is_enumerated_checks_the_real_production_allowlist() {
2552 assert!(is_enumerated_emit_abi_or_capability_surface("Uuid"));
2553 assert!(is_enumerated_emit_abi_or_capability_surface("LocaleTag"));
2554 assert!(!is_enumerated_emit_abi_or_capability_surface(
2555 "negotiateLocale"
2556 ));
2557 }
2558
2559 /// The real, current-tree finding this probe exists to surface: `negotiateLocale`,
2560 /// a plain value helper from `./runtime.js` alongside the tag-layout constructors,
2561 /// is neither an enumerated emit-ABI shape nor a capability-interface import.
2562 #[test]
2563 fn emit_abi_shapes_flags_a_non_enumerated_runtime_helper() {
2564 let src = "import { Err, None, Ok, Some, negotiateLocale, type Option, type Result } from \"./runtime.js\";\n";
2565 let imports = ts_named_imports_from_runtime_modules(src);
2566 assert!(imports.contains(&"negotiateLocale".to_string()));
2567 }
2568
2569 /// `FetchError` is `import type` in one binding and a plain value import in
2570 /// another (`FetchError.Timeout`) — Decision E's rejected type-vs-value
2571 /// discriminator. Confirms the extractor treats both forms as the same identifier,
2572 /// so the allowlist check doesn't depend on which form a given file happens to use.
2573 #[test]
2574 fn ts_import_extraction_ignores_type_only_vs_value_distinction() {
2575 let type_only = "import type { FetchError } from \"./bynk.js\";\n";
2576 let value = "import { FetchError, Uuid } from \"./bynk.js\";\n";
2577 assert_eq!(
2578 ts_named_imports_from_runtime_modules(type_only),
2579 vec!["FetchError".to_string()]
2580 );
2581 assert!(ts_named_imports_from_runtime_modules(value).contains(&"FetchError".to_string()));
2582 }
2583
2584 // --- options_sources -----------------------------------------------------
2585
2586 #[test]
2587 fn struct_body_finds_a_field_by_name() {
2588 let src = "struct Foo {\n pub sources: Option<HashMap<PathBuf, String>>,\n pub other: bool,\n}\n";
2589 let body = struct_body(src, "Foo").expect("struct body found");
2590 assert!(body.contains("sources"));
2591 }
2592
2593 #[test]
2594 fn struct_body_does_not_match_an_unrelated_struct() {
2595 let src =
2596 "struct Bar {\n pub sources: bool,\n}\n\nstruct Foo {\n pub other: bool,\n}\n";
2597 let body = struct_body(src, "Foo").expect("struct body found");
2598 assert!(!body.contains("sources"));
2599 }
2600
2601 // --- render_table's "Rules closed" section (#1001) ------------------------
2602
2603 fn empty_report() -> Report {
2604 Report { probes: Vec::new() }
2605 }
2606
2607 /// The section is static text — no count, no existence check — precisely
2608 /// because nothing regenerates `design/greenfield-status.md` when `stamp`
2609 /// writes the ledger, so a computed count would silently go stale the
2610 /// moment the first `closes_rule` landed (the drift a first draft of this
2611 /// section introduced, caught in #1001's review). This test pins "static"
2612 /// as the actual behaviour, not just the intent in a comment.
2613 #[test]
2614 fn render_table_rules_closed_section_is_static_regardless_of_the_tree() {
2615 let out = render_table(&empty_report());
2616 assert!(out.contains("greenfield-status-rules.md"), "{out}");
2617 assert!(
2618 out.contains("may not exist yet"),
2619 "the wording must not claim to know whether the ledger exists: {out}"
2620 );
2621 }
2622}