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` and
1323/// `project/tests_emit.rs` also import `bynk_syntax::ast` today (`EmitProjectCtx`
1324/// holding `ActorDecl`/`AgentDecl` fields directly; test/suite emission reading
1325/// `TypeRef`/`HandlerKind`), and that *is* exactly the still-open R6.13 defect this
1326/// probe tracks (P6.6: "closes the emitter reading AST declarations directly"). A
1327/// path-prefix rule scoped to `emitter/**` would exclude those two files right along
1328/// with `ir/`'s legitimate ones, silently undercounting real remaining work — see
1329/// [`is_named_ast_importer`].
1330///
1331/// #1184 review: this exclusion is necessary but not sufficient for R6.13. `ir.rs`
1332/// itself still holds several AST types directly in `IrItem`-adjacent struct fields
1333/// (`Arc<TypeDecl>`, `Arc<FnDecl>`, `HandlerKind`, `Refinement`, `SchemaVersionPattern`)
1334/// rather than IR-native equivalents — an emitter reading e.g. `IrHandler::kind`, which
1335/// *is* `ast::HandlerKind`, touches the AST without ever spelling `bynk_syntax::ast`
1336/// itself, so it is invisible to this probe by construction. `ast_importers` = 0 proves
1337/// no *remaining* file outside these two imports the AST module directly; it does not
1338/// by itself prove every `IrItem` field is AST-free (`the-ir.md` §5's own added note).
1339const AST_IMPORTER_EXCEPTIONS: &[&str] = &["ir.rs", "ir/lower.rs"];
1340
1341/// Is `rel_path` (relative to `bynk-emit/src`) one of [`AST_IMPORTER_EXCEPTIONS`]?
1342fn is_named_ast_importer(rel_path: &Path) -> bool {
1343 let rel = rel_path.to_string_lossy().replace('\\', "/");
1344 AST_IMPORTER_EXCEPTIONS.contains(&rel.as_str())
1345}
1346
1347/// The files [`ast_importers`] counts: `bynk-emit/src` files whose contents match
1348/// `bynk_syntax::ast`, excluding [`AST_IMPORTER_EXCEPTIONS`]. Split out from
1349/// [`ast_importers`] so a test can assert on the actual survivor set, not just its
1350/// length (#1184 review).
1351fn ast_importer_files(root: &Path) -> Vec<PathBuf> {
1352 let dir = root.join("bynk-emit/src");
1353 rust_files(&dir)
1354 .into_iter()
1355 .filter(|(_, contents)| contents.contains("bynk_syntax::ast"))
1356 .filter(|(path, _)| !is_named_ast_importer(path.strip_prefix(&dir).unwrap_or(path)))
1357 .map(|(path, _)| path)
1358 .collect()
1359}
1360
1361/// R6.13. Files in `bynk-emit/src` that import `bynk_syntax::ast`, excluding
1362/// [`AST_IMPORTER_EXCEPTIONS`] — phase 6 (the AST import surface `bynk-emit` still
1363/// depends on directly). #1176: the unexcluded, crate-wide count could never reach 0
1364/// while `bynk-emit::ir`'s lowering pass exists at all; this exclusion is what lets the
1365/// probe track the track's real completion criterion (`the-ir.md` §5) instead of a
1366/// floor this track's own IR module structurally cannot clear.
1367fn ast_importers(root: &Path) -> Probe {
1368 Probe {
1369 name: "ast_importers",
1370 gated: true,
1371 reads: ast_importer_files(root).len().to_string(),
1372 }
1373}
1374
1375// --- Gated probe 9: emit_abi_shapes ---------------------------------------
1376
1377/// ADR 0310 D1's four emit-ABI shapes, as they surface as import names in the vendored
1378/// bindings — the `Result`/`Option` tag layout plus `JsonError`, `Uuid`, `FetchError`.
1379const EMIT_ABI: &[&str] = &[
1380 "Result",
1381 "Option",
1382 "Ok",
1383 "Err",
1384 "Some",
1385 "None",
1386 "JsonError",
1387 "Uuid",
1388 "FetchError",
1389];
1390
1391/// The capability interfaces a vendored binding legitimately imports to implement what
1392/// it declares — governed by language-stability rules, not ADR 0310's codegen-freeze
1393/// concern. See [`emit_abi_shapes`] and #999 Decision E for the two-list rationale.
1394const CAPABILITY_SURFACE: &[&str] = &[
1395 "Clock",
1396 "Fetch",
1397 "Idempotency",
1398 "Locale",
1399 "Logger",
1400 "Random",
1401 "Secrets",
1402 "Request",
1403 "Response",
1404 "LocaleTag",
1405 "Kv",
1406 "KVNamespace",
1407];
1408
1409/// Is `ident` one of ADR 0310's enumerated emit-ABI shapes, or part of the capability
1410/// surface a binding is required to import? If neither, it's a leak `emit_abi_shapes`
1411/// flags — this is the single predicate both the probe and its tests use, so a test
1412/// asserting "no leak" can't silently pass against a list the test itself redefined.
1413fn is_enumerated_emit_abi_or_capability_surface(ident: &str) -> bool {
1414 EMIT_ABI.contains(&ident) || CAPABILITY_SURFACE.contains(&ident)
1415}
1416
1417/// ADR 0310's probe (#999 Decision E). The vendored first-party bindings under
1418/// `bynk-check/src/firstparty/bindings/` must reference only [`EMIT_ABI`]'s nine names.
1419///
1420/// This does NOT count every non-enumerated import: a binding legitimately imports the
1421/// [`CAPABILITY_SURFACE`] interfaces it implements — that surface is governed by
1422/// language-stability rules, not ADR 0310's codegen-freeze concern, and a probe that
1423/// flagged it would read non-zero on every binding by construction. See #999 Decision
1424/// E for the two-list rationale and its falsifier.
1425fn emit_abi_shapes(root: &Path) -> Probe {
1426 let dir = root.join("bynk-check/src/firstparty/bindings");
1427 let mut leaks: Vec<String> = Vec::new();
1428 let Ok(entries) = std::fs::read_dir(&dir) else {
1429 return Probe {
1430 name: "emit_abi_shapes",
1431 gated: true,
1432 reads: "bindings directory not found".to_string(),
1433 };
1434 };
1435 let mut files: Vec<_> = entries.flatten().map(|e| e.path()).collect();
1436 files.sort();
1437 for path in files {
1438 if path.extension().is_none_or(|e| e != "ts") {
1439 continue;
1440 }
1441 let Ok(contents) = std::fs::read_to_string(&path) else {
1442 continue;
1443 };
1444 let name = path.file_name().unwrap().to_string_lossy().to_string();
1445 for ident in ts_named_imports_from_runtime_modules(&contents) {
1446 if !is_enumerated_emit_abi_or_capability_surface(&ident) {
1447 leaks.push(format!("{name}:{ident}"));
1448 }
1449 }
1450 }
1451 Probe {
1452 name: "emit_abi_shapes",
1453 gated: true,
1454 reads: format!("{} ({})", leaks.len(), leaks.join(", ")),
1455 }
1456}
1457
1458/// Named identifiers imported from the compiler-generated firstparty/runtime relative
1459/// modules (`./bynk.js`, `./runtime.js`, `./bynk/locale/types.js`, `./cloudflare.js`,
1460/// or their `../` forms) — `import type { A, B }`/`import { A, B }` braces, stripping
1461/// `type ` markers and `X as Y` aliases (keeping the imported name, not the local one,
1462/// since the allowlists are about what's referenced, not what it's called locally).
1463fn ts_named_imports_from_runtime_modules(src: &str) -> Vec<String> {
1464 let mut out = Vec::new();
1465 for line in src.lines() {
1466 let line = line.trim();
1467 if !line.starts_with("import") {
1468 continue;
1469 }
1470 let is_runtime_module = ["\"./bynk.js\"", "\"./runtime.js\"", "\"../runtime.js\""]
1471 .iter()
1472 .any(|m| line.ends_with(&format!("from {m};")))
1473 || line.contains("bynk/locale/types.js")
1474 || line.contains("cloudflare.js");
1475 if !is_runtime_module {
1476 continue;
1477 }
1478 let Some(open) = line.find('{') else { continue };
1479 let Some(close) = line.find('}') else {
1480 continue;
1481 };
1482 for part in line[open + 1..close].split(',') {
1483 let part = part.trim().trim_start_matches("type ").trim();
1484 if part.is_empty() {
1485 continue;
1486 }
1487 let imported = part.split(" as ").next().unwrap_or(part).trim();
1488 out.push(imported.to_string());
1489 }
1490 }
1491 out
1492}
1493
1494// --- Reported probe 1: wildcard_arms --------------------------------------
1495
1496/// R2.12. `clippy::wildcard_enum_match_arm` diagnostics, forced on via `-W` so the
1497/// count is real from day one and doesn't wait on `workspace_lints`/T0.3 (#999 Decision
1498/// C — delegating to clippy's own type-aware pass, rather than a hand-rolled scan for
1499/// "compiler-owned enum", so the probe and the enforcement mechanism can never
1500/// disagree). A count, not a boolean — moves on nearly every match statement anyone
1501/// writes, so it is reported, not gated (#999 Decision D).
1502fn wildcard_arms(root: &Path) -> Probe {
1503 let reads = match run_clippy_wildcard_scan(root) {
1504 Ok(n) => n.to_string(),
1505 Err(e) => format!("error running clippy: {e}"),
1506 };
1507 Probe {
1508 name: "wildcard_arms",
1509 gated: false,
1510 reads,
1511 }
1512}
1513
1514/// Runs clippy with the lint forced on and parses the NDJSON output properly —
1515/// **not** a substring count. A single `wildcard_enum_match_arm` diagnostic's JSON
1516/// repeats the lint name several times (the `code` field, the human-readable message,
1517/// the `#[warn(...)]` note, and the `rendered` field duplicating the whole thing as
1518/// text), so `stdout.matches("wildcard_enum_match_arm").count()` overcounts by roughly
1519/// 3x — caught by cross-checking this probe's own first run against a real JSON parse
1520/// (296 real diagnostics, not the naive scan's 888).
1521///
1522/// Checks the process exit status: a forced `-W` (not `-D`) never fails the build on
1523/// account of the lint itself, so a non-zero exit means clippy genuinely could not run
1524/// (a compile error elsewhere, a missing toolchain component, offline with no cached
1525/// index) — in which case stdout carries no `compiler-message` lines and a silent
1526/// success would report a false, and indistinguishable, `0`. This probe is reported,
1527/// not gated, precisely so an honest "couldn't measure" surfaces loudly here rather
1528/// than being read as "closed."
1529fn run_clippy_wildcard_scan(root: &Path) -> std::io::Result<usize> {
1530 let output = Command::new("cargo")
1531 .args([
1532 "clippy",
1533 "--workspace",
1534 "--message-format=json",
1535 "--",
1536 "-W",
1537 "clippy::wildcard_enum_match_arm",
1538 ])
1539 .current_dir(root)
1540 .output()?;
1541 if !output.status.success() {
1542 return Err(std::io::Error::other(format!(
1543 "cargo clippy exited with {}: {}",
1544 output.status,
1545 String::from_utf8_lossy(&output.stderr).trim()
1546 )));
1547 }
1548 let stdout = String::from_utf8_lossy(&output.stdout);
1549 let mut count = 0usize;
1550 for line in stdout.lines() {
1551 let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
1552 continue;
1553 };
1554 if value.get("reason").and_then(|r| r.as_str()) != Some("compiler-message") {
1555 continue;
1556 }
1557 let code = value.pointer("/message/code/code").and_then(|c| c.as_str());
1558 if code == Some("clippy::wildcard_enum_match_arm") {
1559 count += 1;
1560 }
1561 }
1562 Ok(count)
1563}
1564
1565// --- Reported probe 2: keep_in_sync ---------------------------------------
1566
1567/// P2 (trend only). Comments across the workspace containing "in sync", "mirrors",
1568/// "parity", or "must match" — each one names a rule the compiler cannot teach itself
1569/// and must be taught in review, every time.
1570fn keep_in_sync(root: &Path) -> Probe {
1571 let phrases = ["in sync", "mirrors", "parity", "must match"];
1572 let mut count = 0usize;
1573 for dir in top_level_crate_dirs(root) {
1574 for (_, contents) in rust_files(&dir.join("src")) {
1575 for line in contents.lines() {
1576 if is_line_comment(line) {
1577 let lower = line.to_lowercase();
1578 if phrases.iter().any(|p| lower.contains(p)) {
1579 count += 1;
1580 }
1581 }
1582 }
1583 }
1584 }
1585 Probe {
1586 name: "keep_in_sync",
1587 gated: false,
1588 reads: count.to_string(),
1589 }
1590}
1591
1592// --- Reported probe 3: test_density ---------------------------------------
1593
1594/// R11.1, and §3.4's phase-3 trigger. Per crate: (lines inside `#[test]` fn bodies,
1595/// plus lines inside `#[cfg(test)] mod` blocks outside those fns) ÷ (non-blank,
1596/// non-comment lines under that crate's `src/`) — #999 Decision F's definition,
1597/// written down precisely because an undefined "ratio" is exactly the ambiguity that
1598/// produced the track doc §9's four-row ambiguity.
1599fn test_density(root: &Path) -> Probe {
1600 let mut parts = Vec::new();
1601 for dir in top_level_crate_dirs(root) {
1602 let name = dir.file_name().unwrap().to_string_lossy().to_string();
1603 let src_dir = dir.join("src");
1604 let mut test_lines = 0usize;
1605 let mut code_lines = 0usize;
1606 for (_, contents) in rust_files(&src_dir) {
1607 let lines: Vec<&str> = contents.lines().collect();
1608 let ranges = test_mod_ranges(&lines);
1609 for (i, line) in lines.iter().enumerate() {
1610 let is_blank_or_comment = line.trim().is_empty() || is_line_comment(line);
1611 if !is_blank_or_comment {
1612 code_lines += 1;
1613 }
1614 if in_test_range(i, &ranges) && !is_blank_or_comment {
1615 test_lines += 1;
1616 }
1617 }
1618 }
1619 if code_lines > 0 {
1620 let ratio = 100.0 * test_lines as f64 / code_lines as f64;
1621 parts.push(format!("{name}={ratio:.1}%"));
1622 }
1623 }
1624 Probe {
1625 name: "test_density",
1626 gated: false,
1627 reads: parts.join(", "),
1628 }
1629}
1630
1631// --- Reported probe 4: fixture_kinds --------------------------------------
1632
1633/// R11.2. Fixture directories under `bynkc/tests` using each assertion granularity —
1634/// `expected_contains.txt` / `expected_absent.txt` / `expected_diagnostics.txt` — set
1635/// against the older, coarser `expected_error.txt` (category-string) convention.
1636fn fixture_kinds(root: &Path) -> Probe {
1637 let tests_dir = root.join("bynkc/tests");
1638 let contains = count_files_named(&tests_dir, "expected_contains.txt");
1639 let absent = count_files_named(&tests_dir, "expected_absent.txt");
1640 let diagnostics = count_files_named(&tests_dir, "expected_diagnostics.txt");
1641 let error = count_files_named(&tests_dir, "expected_error.txt");
1642 Probe {
1643 name: "fixture_kinds",
1644 gated: false,
1645 reads: format!(
1646 "contains={contains}, absent={absent}, diagnostics={diagnostics}, error={error}"
1647 ),
1648 }
1649}
1650
1651fn count_files_named(dir: &Path, filename: &str) -> usize {
1652 let mut count = 0usize;
1653 count_files_named_walk(dir, filename, &mut count);
1654 count
1655}
1656
1657fn count_files_named_walk(dir: &Path, filename: &str, count: &mut usize) {
1658 let Ok(entries) = std::fs::read_dir(dir) else {
1659 return;
1660 };
1661 for entry in entries.flatten() {
1662 let path = entry.path();
1663 if path.is_dir() {
1664 count_files_named_walk(&path, filename, count);
1665 } else if path.file_name().is_some_and(|n| n == filename) {
1666 *count += 1;
1667 }
1668 }
1669}
1670
1671// --- Rendering + diffing ---------------------------------------------------
1672
1673/// The committed table: a plain Markdown table, probe name → gated?/reads, plus a
1674/// pointer to the rule ledger `stamp::apply` writes (#1001).
1675pub fn render_table(report: &Report) -> String {
1676 let mut out = String::new();
1677 out.push_str("<!-- GENERATED FILE — do not edit by hand.\n");
1678 out.push_str(" Source: cargo xtask greenfield-status (xtask/src/greenfield_status.rs).\n");
1679 out.push_str(" Regenerate with: cargo xtask greenfield-status --apply -->\n\n");
1680 out.push_str("# Greenfield status\n\n");
1681 out.push_str(
1682 "Track slice T0.0 (#999). Nine probes are gated — a disagreement between this \
1683 file and a fresh run fails `greenfield_status_table_is_current` \
1684 (`xtask/tests/greenfield_status.rs`). Four are trend probes, reported only.\n\n",
1685 );
1686 out.push_str("| Probe | Gated | Reads |\n|---|---|---|\n");
1687 for probe in &report.probes {
1688 let _ = writeln!(
1689 out,
1690 "| `{}` | {} | {} |",
1691 probe.name,
1692 if probe.gated { "yes" } else { "no (trend)" },
1693 probe.reads
1694 );
1695 }
1696
1697 out.push_str("\n## Rules closed\n\n");
1698 // A static, unconditional link — not a count, and not even an existence
1699 // check. A first draft read `design/greenfield-status-rules.md` here to
1700 // report a row count, but nothing regenerates *this* file when `stamp`
1701 // writes the ledger (`stamp.yml` never runs `greenfield-status --apply`,
1702 // and the gating test only diffs the nine probes) — so a count or an
1703 // exists/doesn't-exist message would silently go stale the moment the
1704 // first `closes_rule` landed, which is exactly the drift this section
1705 // exists to avoid, not invite (#1001 review). Static text can't go stale;
1706 // the ledger is one click away either way.
1707 out.push_str(
1708 "See [`design/greenfield-status-rules.md`](greenfield-status-rules.md) for rule ids \
1709 closed so far (written by `cargo xtask stamp --apply` at merge; may not exist yet if \
1710 no increment has cited `closes_rule`).\n",
1711 );
1712 out
1713}
1714
1715/// Every gated probe whose live reading disagrees with the committed table's, as
1716/// `(probe name, committed, live)`. Trend probes are never compared, and never
1717/// computed here — this only runs the nine gated probes, so checking currency never
1718/// pays for `wildcard_arms`'s workspace-wide clippy pass. For a caller that has already
1719/// run the full report (e.g. to print it), use [`gated_disagreements_in`] instead so the
1720/// nine gated probes aren't computed a second time.
1721pub fn gated_disagreements(root: &Path) -> Vec<(String, String, String)> {
1722 gated_disagreements_in(&run_gated(root), root)
1723}
1724
1725/// Like [`gated_disagreements`], but diffs `probes` (typically a [`Report`]'s
1726/// `.probes`, already computed) instead of re-running the gated probes.
1727pub fn gated_disagreements_in(probes: &[Probe], root: &Path) -> Vec<(String, String, String)> {
1728 let committed = std::fs::read_to_string(table_path(root)).unwrap_or_default();
1729 let mut out = Vec::new();
1730 for probe in probes.iter().filter(|p| p.gated) {
1731 let row_prefix = format!("| `{}` | yes | ", probe.name);
1732 let committed_reads = committed
1733 .lines()
1734 .find(|l| l.starts_with(&row_prefix))
1735 .and_then(|l| l.strip_prefix(&row_prefix))
1736 .and_then(|l| l.strip_suffix(" |"))
1737 .unwrap_or("<row missing>");
1738 if committed_reads != probe.reads {
1739 out.push((
1740 probe.name.to_string(),
1741 committed_reads.to_string(),
1742 probe.reads.clone(),
1743 ));
1744 }
1745 }
1746 out
1747}
1748
1749#[cfg(test)]
1750mod tests {
1751 use super::*;
1752
1753 // --- emit_diagnostics (#999 Decision A) ---------------------------------
1754
1755 /// A standalone `"bynk.foo"` literal is found — the ordinary case.
1756 #[test]
1757 fn bynk_dotted_literals_finds_standalone_literal() {
1758 let src = r#"code("bynk.check.something", "a message")"#;
1759 assert_eq!(bynk_dotted_literals(src), vec!["bynk.check.something"]);
1760 }
1761
1762 /// The bug this slice found in its own first draft: a longer message that merely
1763 /// *starts* with "bynk." must not be truncated into a fake code literal. Regression
1764 /// test for `bynk.map itself uses bynk.list, so list must be injected too: {paths:?}`
1765 /// (`bynk-emit/src/project.rs`), which an earlier, less careful version of this scan
1766 /// wrongly counted as the literal `"bynk.map"`.
1767 #[test]
1768 fn bynk_dotted_literals_ignores_prefix_of_a_longer_message() {
1769 let src = r#"assert!(cond, "bynk.map itself uses bynk.list, so list must be injected too: {paths:?}");"#;
1770 assert!(bynk_dotted_literals(src).is_empty());
1771 }
1772
1773 /// Regression test for the other half of the same bug: a `\`-continued string
1774 /// literal (`"bynk.emit.unresolved_cross_context_signature: no signature for \`,
1775 /// continued on the next source line) is one string, not a diagnostic-code literal,
1776 /// even though its first segment matches the identifier charset — because the
1777 /// character after the run is `:`, never a closing quote, on either line.
1778 #[test]
1779 fn bynk_dotted_literals_ignores_a_line_continued_message() {
1780 let src =
1781 "\"bynk.emit.unresolved_cross_context_signature: no signature for \\\n the rest\"";
1782 assert!(bynk_dotted_literals(src).is_empty());
1783 }
1784
1785 /// The whole point of Decision A: cross-referencing the real registry, not a
1786 /// hand-maintained exclusion list, correctly separates a real diagnostic code from
1787 /// a commons/namespace path that merely looks like one.
1788 #[test]
1789 fn emit_diagnostics_cross_references_the_real_registry() {
1790 let registry: BTreeSet<&str> = bynk_syntax::diagnostics::REGISTRY
1791 .iter()
1792 .map(|d| d.code)
1793 .collect();
1794 // A code this registry is known to carry (bynk-syntax/src/diagnostics.rs).
1795 assert!(registry.contains("bynk.parse.expected_expression"));
1796 // A commons/namespace path, not a diagnostic code — #999's own verified survey.
1797 assert!(!registry.contains("bynk.locale"));
1798 }
1799
1800 // --- ast_importers (#1176) ------------------------------------------------
1801
1802 /// The exclusion is named, not prefixed: `ir.rs`/`ir/lower.rs` are the lowering
1803 /// pass's own legitimate `Ast → Ir` import, but `project.rs`/`project/tests_emit.rs`
1804 /// (which also import `bynk_syntax::ast`, via `EmitProjectCtx` and test/suite
1805 /// emission) must stay counted — that's exactly the still-open R6.13 defect this
1806 /// probe tracks. A path-prefix rule (e.g. "only `emitter/**` counts") would have
1807 /// excluded those two right along with `ir/`'s, silently undercounting real work.
1808 #[test]
1809 fn ast_importer_exclusion_is_named_not_prefixed() {
1810 assert!(is_named_ast_importer(Path::new("ir.rs")));
1811 assert!(is_named_ast_importer(Path::new("ir/lower.rs")));
1812 assert!(!is_named_ast_importer(Path::new("project.rs")));
1813 assert!(!is_named_ast_importer(Path::new("project/tests_emit.rs")));
1814 assert!(!is_named_ast_importer(Path::new("emitter/lower.rs")));
1815 assert!(!is_named_ast_importer(Path::new("ir/other.rs")));
1816 }
1817
1818 /// #1184 review: an `AST_IMPORTER_EXCEPTIONS` entry going stale (renamed or split,
1819 /// e.g. `ir/lower.rs` becoming `ir/lower/mod.rs`) must fail loud here, not surface
1820 /// as a silent `ast_importers` regression in `greenfield_status_table_is_current` —
1821 /// mirrors [`file_is_named_fs_floor`]'s own "fail loud, not quiet" discipline.
1822 #[test]
1823 fn ast_importer_exceptions_still_exist_and_still_import_the_ast() {
1824 let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1825 .join("..")
1826 .join("bynk-emit/src");
1827 for rel in AST_IMPORTER_EXCEPTIONS {
1828 let contents = std::fs::read_to_string(dir.join(rel)).unwrap_or_else(|e| {
1829 panic!("AST_IMPORTER_EXCEPTIONS entry {rel:?} does not exist: {e}")
1830 });
1831 assert!(
1832 contents.contains("bynk_syntax::ast"),
1833 "AST_IMPORTER_EXCEPTIONS entry {rel:?} no longer imports bynk_syntax::ast \
1834 — it excludes nothing and should be removed"
1835 );
1836 }
1837 }
1838
1839 /// #1184 review: exercises the real filter over the live tree, not just the pure
1840 /// predicate — the survivor set the PR's own named-vs-prefix argument depends on:
1841 /// `ir/`'s two files drop out, `project.rs`/`project/tests_emit.rs` (R6.13's
1842 /// still-open AST-declaration reads) do not.
1843 #[test]
1844 fn ast_importers_excludes_the_ir_lowering_pass_but_counts_project_rs() {
1845 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..");
1846 let dir = root.join("bynk-emit/src");
1847 let counted: BTreeSet<String> = ast_importer_files(&root)
1848 .into_iter()
1849 .map(|path| {
1850 path.strip_prefix(&dir)
1851 .unwrap_or(&path)
1852 .to_string_lossy()
1853 .replace('\\', "/")
1854 })
1855 .collect();
1856 assert!(!counted.contains("ir.rs"));
1857 assert!(!counted.contains("ir/lower.rs"));
1858 assert!(counted.contains("project.rs"));
1859 assert!(counted.contains("project/tests_emit.rs"));
1860 }
1861
1862 // --- fs_below_driver / test_density (trailing `#[cfg(test)] mod tests {}`) ---
1863
1864 #[test]
1865 fn production_std_fs_usage_is_detected() {
1866 let src = "fn load(p: &Path) -> String {\n std::fs::read_to_string(p).unwrap()\n}\n";
1867 assert!(has_production_std_fs(src));
1868 }
1869
1870 #[test]
1871 fn std_fs_inside_a_trailing_test_mod_is_not_production() {
1872 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";
1873 assert!(!has_production_std_fs(src));
1874 }
1875
1876 /// Regression test for the other real bug this slice found: `bynk-emit/src/lib.rs`
1877 /// has `#[cfg(test)] pub(crate) mod testkit;` — an external-file module
1878 /// *declaration* (ends in `;`), not an inline block. It must not be mistaken for a
1879 /// scope-opening `mod tests { ... }`, or the (genuinely production) code after it in
1880 /// the same file would be wrongly excluded.
1881 #[test]
1882 fn cfg_test_external_mod_declaration_does_not_open_a_test_region() {
1883 let src = "#[cfg(test)]\npub(crate) mod testkit;\n\nfn load(p: &Path) -> String {\n std::fs::read_to_string(p).unwrap()\n}\n";
1884 assert!(has_production_std_fs(src));
1885 }
1886
1887 /// Regression test for the bug caught in review: a file with **two** scattered
1888 /// `#[cfg(test)] mod ... { ... }` blocks, with real production code between them —
1889 /// exactly `bynk-emit/src/emitter/lower.rs`'s shape (two test modules, 1031
1890 /// production lines apart). A single "everything from the first/last `#[cfg(test)]`
1891 /// onward" cutoff would misclassify `lower_lambda` here as test-scope; the fix must
1892 /// close each block at its own boundary and resume production scanning after it.
1893 #[test]
1894 fn production_code_between_two_scattered_test_mods_is_detected() {
1895 let src = "\
1896#[cfg(test)]
1897mod decode_map_key_tests {
1898 #[test]
1899 fn t() {
1900 assert_eq!(1, 1);
1901 }
1902}
1903
1904fn lower_lambda(p: &Path) -> String {
1905 std::fs::read_to_string(p).unwrap()
1906}
1907
1908#[cfg(test)]
1909mod idempotency_scoping_tests {
1910 #[test]
1911 fn t2() {
1912 assert_eq!(2, 2);
1913 }
1914}
1915";
1916 assert!(has_production_std_fs(src));
1917 }
1918
1919 /// The same fixture's `test_mod_ranges` shape, checked directly: two disjoint
1920 /// ranges, not one span from the first block to the last.
1921 #[test]
1922 fn test_mod_ranges_finds_each_block_separately() {
1923 let src = "\
1924#[cfg(test)]
1925mod a {
1926 fn x() {}
1927}
1928
1929fn production() {}
1930
1931#[cfg(test)]
1932mod b {
1933 fn y() {}
1934}
1935";
1936 let lines: Vec<&str> = src.lines().collect();
1937 let ranges = test_mod_ranges(&lines);
1938 assert_eq!(
1939 ranges.len(),
1940 2,
1941 "expected two disjoint test-mod ranges: {ranges:?}"
1942 );
1943 // Line 5 (0-indexed) is `fn production() {}`, between the two blocks.
1944 assert!(
1945 !in_test_range(5, &ranges),
1946 "production() must not read as test-scope"
1947 );
1948 }
1949
1950 /// Regression test for the bug in the *fix* for the above: a column-0-`}`
1951 /// shortcut (tried and reverted during review) truncates a test module the moment
1952 /// its body embeds a multi-line fixture string containing a `}` flush against the
1953 /// left margin — exactly `bynk-ide/src/sequence.rs`'s shape, whose test mod embeds
1954 /// `.bynk` source fixtures. The real brace-depth scanner must see through the
1955 /// string and find the module's *actual* closing brace, hundreds of lines later.
1956 /// Uses a raw string for the outer fixture so the embedded `"..."` doesn't need
1957 /// escaping, and locates the real end by content rather than a hand-counted index
1958 /// — a hand-counted line number is exactly the kind of easy-to-miscount detail
1959 /// this codebase's own convention (verify, don't assume) warns against.
1960 #[test]
1961 fn test_mod_ranges_is_not_fooled_by_a_column_zero_brace_inside_a_string() {
1962 let src = r#"#[cfg(test)]
1963mod tests {
1964 const FIXTURE: &str = "
1965commons app.demo {
1966}
1967";
1968
1969 fn real_end_of_module() {}
1970}
1971"#;
1972 let lines: Vec<&str> = src.lines().collect();
1973 let ranges = test_mod_ranges(&lines);
1974 assert_eq!(ranges.len(), 1, "expected exactly one range: {ranges:?}");
1975 let (_, end) = ranges[0];
1976 // `str::lines()` drops the trailing newline, so the module's real closing
1977 // brace — the fixture's last line — is at `lines.len() - 1`. The string's
1978 // embedded `}` (an earlier line) must not be mistaken for it.
1979 assert_eq!(
1980 end,
1981 lines.len() - 1,
1982 "closed too early — mistook the string's `}}` for the module's: {ranges:?}"
1983 );
1984 }
1985
1986 // --- fs_below_driver: import resolution through `use super::*;` (#1013) ---
1987
1988 /// Run [`production_std_fs_files`] over an in-memory crate layout and name the
1989 /// flagged files, so each case reads as "these files, and only these".
1990 fn flagged(files: &[(&str, &str)]) -> Vec<String> {
1991 let owned: Vec<(PathBuf, String)> = files
1992 .iter()
1993 .map(|(p, s)| (PathBuf::from(p), (*s).to_string()))
1994 .collect();
1995 production_std_fs_files(&owned)
1996 .into_iter()
1997 .map(|i| files[i].0.to_string())
1998 .collect()
1999 }
2000
2001 /// The concrete #1013 instance, in miniature: `project.rs` has a module-level
2002 /// `use std::fs;` (ancestor-scoped, so visible to descendants), `discovery.rs`
2003 /// glob-imports it via `use super::*;` and calls bare `fs::read_to_string` —
2004 /// touching `std::fs` in production while never spelling it. The text scan alone
2005 /// reads only `project.rs`; the resolved probe must read both.
2006 #[test]
2007 fn bare_fs_reached_through_a_glob_imported_parent_is_flagged() {
2008 let files = [
2009 ("lib.rs", "mod project;\n"),
2010 ("project.rs", "use std::fs;\n\nmod discovery;\n"),
2011 (
2012 "project/discovery.rs",
2013 "use super::*;\n\nfn read_source(path: &std::path::Path) -> String {\n fs::read_to_string(path).unwrap()\n}\n",
2014 ),
2015 ];
2016 assert!(
2017 !has_production_std_fs(files[2].1),
2018 "the text scan alone must miss it"
2019 );
2020 assert_eq!(flagged(&files), vec!["project.rs", "project/discovery.rs"]);
2021 }
2022
2023 /// Without `use super::*;` there is no path from the bare `fs::` to the parent's
2024 /// binding — the probe must not guess one into existence.
2025 #[test]
2026 fn bare_fs_without_a_glob_super_import_is_not_flagged() {
2027 let files = [
2028 ("lib.rs", "mod project;\n"),
2029 ("project.rs", "use std::fs;\n\nmod discovery;\n"),
2030 (
2031 "project/discovery.rs",
2032 "fn read_source(path: &std::path::Path) -> String {\n fs::read_to_string(path).unwrap()\n}\n",
2033 ),
2034 ];
2035 assert_eq!(flagged(&files), vec!["project.rs"]);
2036 }
2037
2038 /// Glob chains re-reach ancestors transitively — grandparent binds `fs`, both
2039 /// hops glob-import `super::*` — and the `mod.rs` layout maps to the same module
2040 /// tree as the `name.rs` one. The middle file sees `fs` but never uses it, so
2041 /// only the leaf joins the (text-flagged) root.
2042 #[test]
2043 fn glob_super_resolution_is_transitive_across_mod_rs_parents() {
2044 let files = [
2045 ("lib.rs", "use std::fs;\n\nmod a;\n"),
2046 ("a/mod.rs", "use super::*;\n\nmod b;\n"),
2047 (
2048 "a/b.rs",
2049 "use super::*;\n\nfn walk() {\n let _ = fs::read_dir(\".\");\n}\n",
2050 ),
2051 ];
2052 assert_eq!(flagged(&files), vec!["lib.rs", "a/b.rs"]);
2053 }
2054
2055 /// A break anywhere in the chain stops resolution: the middle module does not
2056 /// glob-import `super::*`, so the leaf's `use super::*;` reaches a module with no
2057 /// `fs` binding to offer.
2058 #[test]
2059 fn a_break_in_the_glob_chain_stops_resolution() {
2060 let files = [
2061 ("lib.rs", "use std::fs;\n\nmod a;\n"),
2062 ("a/mod.rs", "mod b;\n"),
2063 (
2064 "a/b.rs",
2065 "use super::*;\n\nfn walk() {\n let _ = fs::read_dir(\".\");\n}\n",
2066 ),
2067 ];
2068 assert_eq!(flagged(&files), vec!["lib.rs"]);
2069 }
2070
2071 /// Nearest binding wins, as in Rust: the child re-binds `fs` to something that is
2072 /// not `std::fs`, so its bare `fs::` calls are that something's, not std's.
2073 #[test]
2074 fn a_local_non_std_binding_shadows_the_ancestors_std_fs() {
2075 let files = [
2076 ("lib.rs", "mod project;\n"),
2077 ("project.rs", "use std::fs;\n\nmod overlay;\nmod d;\n"),
2078 ("project/overlay.rs", "pub fn read(_p: &str) {}\n"),
2079 (
2080 "project/d.rs",
2081 "use super::*;\nuse crate::project::overlay as fs;\n\nfn f() {\n let _ = fs::read(\"x\");\n}\n",
2082 ),
2083 ];
2084 assert_eq!(flagged(&files), vec!["project.rs"]);
2085 }
2086
2087 /// An aliased module binding resolves under its alias — the call site never
2088 /// contains the substring `fs::` at all.
2089 #[test]
2090 fn an_aliased_std_fs_binding_resolves_through_the_glob() {
2091 let files = [
2092 ("lib.rs", "mod p;\n"),
2093 ("p.rs", "use std::fs as stdfs;\n\nmod c;\n"),
2094 (
2095 "p/c.rs",
2096 "use super::*;\n\nfn f() {\n stdfs::write(\"a\", \"b\").unwrap();\n}\n",
2097 ),
2098 ];
2099 assert_eq!(flagged(&files), vec!["p.rs", "p/c.rs"]);
2100 }
2101
2102 /// `use std::{fs, io};` binds `fs` without ever containing the substring
2103 /// `std::fs` — the same blind spot as #1013's, one file deep. Resolution applies
2104 /// in the file's own scope, no glob import required.
2105 #[test]
2106 fn a_group_imported_fs_binding_is_resolved_in_its_own_file() {
2107 let src = "use std::{fs, io};\n\nfn f() -> io::Result<()> {\n fs::metadata(\"x\").map(|_| ())\n}\n";
2108 assert!(
2109 !has_production_std_fs(src),
2110 "the text scan alone must miss it"
2111 );
2112 let files = [("thing.rs", src)];
2113 assert_eq!(flagged(&files), vec!["thing.rs"]);
2114 }
2115
2116 /// The item-import shape #1013 scope-checked (zero current instances), at the
2117 /// granularity this probe can reach: an ancestor's `use std::fs::File;` used as a
2118 /// bare path root `File::open` in a glob-importing child resolves and flags. (A
2119 /// bare *call* of an imported fn — `read_to_string(p)`, no `::` — presents no
2120 /// path root and remains out of a text-level scanner's reach, per the doc.)
2121 #[test]
2122 fn an_item_import_under_std_fs_resolves_as_a_path_root() {
2123 let files = [
2124 ("lib.rs", "mod p;\n"),
2125 ("p.rs", "use std::fs::File;\n\nmod c;\n"),
2126 (
2127 "p/c.rs",
2128 "use super::*;\n\nfn f() {\n let _ = File::open(\"x\");\n}\n",
2129 ),
2130 ];
2131 assert_eq!(flagged(&files), vec!["p.rs", "p/c.rs"]);
2132 }
2133
2134 /// A test module's `use super::*;` and tempdir `fs::` calls are test-scope — the
2135 /// `bynk-ide` files' shape (`architecture.rs`, `sequence.rs`), which must stay
2136 /// unflagged exactly as they were under the text-only scan.
2137 #[test]
2138 fn glob_and_bare_fs_inside_a_test_mod_stay_test_scope() {
2139 let files = [
2140 ("lib.rs", "use std::fs;\n\nmod w;\n"),
2141 (
2142 "w.rs",
2143 "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",
2144 ),
2145 ];
2146 assert_eq!(flagged(&files), vec!["lib.rs"]);
2147 }
2148
2149 /// The module-tree mapping behind the resolution, checked directly: `name.rs` and
2150 /// `mod.rs` layouts, a preferred `a.rs` over `a/mod.rs`, and rootless roots.
2151 #[test]
2152 fn module_parent_maps_both_file_layouts() {
2153 let files: Vec<(PathBuf, String)> = ["lib.rs", "a.rs", "a/b.rs", "c/mod.rs", "c/d.rs"]
2154 .iter()
2155 .map(|p| (PathBuf::from(p), String::new()))
2156 .collect();
2157 let idx = |name: &str| {
2158 files
2159 .iter()
2160 .position(|(p, _)| p == Path::new(name))
2161 .unwrap()
2162 };
2163 assert_eq!(module_parent(Path::new("lib.rs"), &files), None);
2164 assert_eq!(
2165 module_parent(Path::new("a.rs"), &files),
2166 Some(idx("lib.rs"))
2167 );
2168 assert_eq!(
2169 module_parent(Path::new("a/b.rs"), &files),
2170 Some(idx("a.rs"))
2171 );
2172 assert_eq!(
2173 module_parent(Path::new("c/mod.rs"), &files),
2174 Some(idx("lib.rs"))
2175 );
2176 assert_eq!(
2177 module_parent(Path::new("c/d.rs"), &files),
2178 Some(idx("c/mod.rs"))
2179 );
2180 }
2181
2182 // --- fs_below_driver: #1016 review findings ------------------------------
2183
2184 /// Finding 1: a `super::`-qualified path needs no glob import — module privacy is
2185 /// ancestor-scoped, so `super::fs` names the parent's private `use std::fs;` from
2186 /// any child. One disambiguating edit away from `discovery.rs:39`'s bare call,
2187 /// and it must not drop the file out of the count.
2188 #[test]
2189 fn a_super_qualified_path_resolves_without_a_glob_import() {
2190 let files = [
2191 ("lib.rs", "mod project;\n"),
2192 ("project.rs", "use std::fs;\n\nmod discovery;\n"),
2193 (
2194 "project/discovery.rs",
2195 "fn read_source(path: &std::path::Path) -> String {\n super::fs::read_to_string(path).unwrap()\n}\n",
2196 ),
2197 ];
2198 assert_eq!(flagged(&files), vec!["project.rs", "project/discovery.rs"]);
2199 }
2200
2201 /// Finding 1, the `crate::`-rooted form: the walk descends the module tree from
2202 /// the crate root file by file, then resolves the leaf against that module's
2203 /// bindings — from anywhere in the crate, glob import or not.
2204 #[test]
2205 fn a_crate_qualified_path_resolves_through_the_module_tree() {
2206 let files = [
2207 ("lib.rs", "mod other;\nmod project;\n"),
2208 (
2209 "other.rs",
2210 "fn f() {\n let _ = crate::project::fs::read_dir(\".\");\n}\n",
2211 ),
2212 ("project.rs", "use std::fs;\n"),
2213 ];
2214 assert_eq!(flagged(&files), vec!["other.rs", "project.rs"]);
2215 }
2216
2217 /// Finding 1, stacked hops: `super::super::` climbs two parents (through a
2218 /// glob-free middle module — qualified paths don't need the glob chain).
2219 #[test]
2220 fn stacked_super_hops_climb_the_parent_chain() {
2221 let files = [
2222 ("lib.rs", "use std::fs;\n\nmod a;\n"),
2223 ("a/mod.rs", "mod b;\n"),
2224 (
2225 "a/b.rs",
2226 "fn f() {\n let _ = super::super::fs::read_dir(\".\");\n}\n",
2227 ),
2228 ];
2229 assert_eq!(flagged(&files), vec!["lib.rs", "a/b.rs"]);
2230 }
2231
2232 /// Finding 1, `self::` composed with the glob chain: `self::fs` resolves in the
2233 /// file's own namespace, which includes what its `use super::*;` pulled in.
2234 #[test]
2235 fn a_self_qualified_path_resolves_through_the_files_own_glob_chain() {
2236 let files = [
2237 ("lib.rs", "mod p;\n"),
2238 ("p.rs", "use std::fs;\n\nmod c;\n"),
2239 (
2240 "p/c.rs",
2241 "use super::*;\n\nfn f() {\n let _ = self::fs::read_dir(\".\");\n}\n",
2242 ),
2243 ];
2244 assert_eq!(flagged(&files), vec!["p.rs", "p/c.rs"]);
2245 }
2246
2247 /// Finding 1's negatives: a qualified path to a name the parent binds to
2248 /// something other than `std::fs` stops at that binding, and a path through a
2249 /// module that doesn't exist resolves nowhere.
2250 #[test]
2251 fn a_qualified_path_to_a_non_std_binding_or_missing_module_is_not_flagged() {
2252 let files = [
2253 ("lib.rs", "mod overlay;\nmod p;\n"),
2254 ("overlay.rs", "pub fn read_dir(_p: &str) {}\n"),
2255 ("p.rs", "use crate::overlay as fs;\n\nmod d;\n"),
2256 (
2257 "p/d.rs",
2258 "fn f() {\n let _ = super::fs::read_dir(\".\");\n let _ = crate::missing::fs::read_dir(\".\");\n}\n",
2259 ),
2260 ];
2261 assert_eq!(flagged(&files), Vec::<String>::new());
2262 }
2263
2264 /// Finding 2: a locally-declared type-namespace item beats a glob-imported name
2265 /// in real Rust — a child with its own `mod fs;` calling `fs::…` is calling its
2266 /// own submodule, not the ancestor's `std::fs`.
2267 #[test]
2268 fn a_locally_declared_module_shadows_the_ancestors_std_fs() {
2269 let files = [
2270 ("lib.rs", "mod p;\n"),
2271 ("p.rs", "use std::fs;\n\nmod c;\n"),
2272 (
2273 "p/c.rs",
2274 "use super::*;\n\nmod fs;\n\nfn f() {\n let _ = fs::read_dir(\".\");\n}\n",
2275 ),
2276 ("p/c/fs.rs", "pub fn read_dir(_p: &str) {}\n"),
2277 ];
2278 assert_eq!(flagged(&files), vec!["p.rs"]);
2279 }
2280
2281 /// Finding 3: a trailing `//` comment on a `use` line must not sever the edge —
2282 /// neither the glob (`use super::*; // …`) nor the binding (`use std::fs; // …`).
2283 #[test]
2284 fn a_trailing_comment_on_a_use_line_does_not_sever_resolution() {
2285 let files = [
2286 ("lib.rs", "mod p;\n"),
2287 (
2288 "p.rs",
2289 "use std::fs; // read_source's disk fallback\n\nmod c;\n",
2290 ),
2291 (
2292 "p/c.rs",
2293 "use super::*; // parent's fs, PathBuf\n\nfn f() {\n let _ = fs::read_dir(\".\");\n}\n",
2294 ),
2295 ];
2296 assert_eq!(flagged(&files), vec!["p.rs", "p/c.rs"]);
2297 }
2298
2299 /// Finding 4: the nested-group + `::self` normalisation branches, pinned
2300 /// directly — `use std::{fs::{self, File}, io};` binds `fs` *and* `File` to
2301 /// `std::fs`, and `io` only to the shadow set. Getting `::self` wrong would
2302 /// silently under-count, which is exactly this probe's failure mode.
2303 #[test]
2304 fn a_nested_group_with_self_binds_the_module_and_its_items() {
2305 let facts = fs_import_facts("use std::{fs::{self, File}, io};\n");
2306 let bound: Vec<&str> = facts.std_fs_bindings.iter().map(String::as_str).collect();
2307 assert_eq!(bound, vec!["File", "fs"]);
2308 assert!(facts.use_bound_names.contains("io"));
2309 assert!(!facts.std_fs_bindings.contains("io"));
2310 }
2311
2312 /// Finding 4, the children half of [`FsImportFacts`]' contract: a parent whose
2313 /// *only* `use std::fs;` lives in its `#[cfg(test)] mod` hands no binding to a
2314 /// glob-importing child — `bynk-ide/src/symbols.rs`' shape, latent until it
2315 /// grows a submodule.
2316 #[test]
2317 fn a_parents_test_mod_use_std_fs_does_not_reach_its_children() {
2318 let files = [
2319 ("lib.rs", "mod p;\n"),
2320 (
2321 "p.rs",
2322 "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",
2323 ),
2324 (
2325 "p/c.rs",
2326 "use super::*;\n\nfn f() {\n let _ = fs::read_dir(\".\");\n}\n",
2327 ),
2328 ];
2329 assert_eq!(flagged(&files), Vec::<String>::new());
2330 }
2331
2332 // --- fs_below_driver: named-floor classification (#1104) -----------------
2333
2334 #[test]
2335 fn fn_name_on_line_strips_modifiers() {
2336 assert_eq!(fn_name_on_line("fn foo() {"), Some("foo".to_string()));
2337 assert_eq!(
2338 fn_name_on_line("pub(crate) fn read_adapter_binding("),
2339 Some("read_adapter_binding".to_string())
2340 );
2341 assert_eq!(
2342 fn_name_on_line("pub async unsafe fn go() {"),
2343 Some("go".to_string())
2344 );
2345 }
2346
2347 #[test]
2348 fn fn_name_on_line_ignores_non_fn_lines() {
2349 assert_eq!(fn_name_on_line(" let f = foo();"), None);
2350 assert_eq!(fn_name_on_line("/// calls fn bar somewhere"), None);
2351 }
2352
2353 /// A signature whose `{` arrives lines after the `fn` line — `read_adapter_binding`'s
2354 /// own real shape — must still resolve to the correct body range: `started` can't
2355 /// flip true on the parameter list, which has no braces of its own.
2356 #[test]
2357 fn production_fn_ranges_handles_a_wrapped_signature() {
2358 let src = "pub(crate) fn read_adapter_binding(\n path: &Path,\n) -> std::io::Result<String> {\n fs::read_to_string(path)\n}\n";
2359 let lines: Vec<&str> = src.lines().collect();
2360 let ranges = production_fn_ranges(&lines, &[]);
2361 assert_eq!(ranges.len(), 1);
2362 let (name, start, end) = &ranges[0];
2363 assert_eq!(name, "read_adapter_binding");
2364 assert_eq!(*start, 0);
2365 assert_eq!(*end, lines.len() - 1);
2366 assert_eq!(
2367 enclosing_fn(3, &ranges),
2368 Some("read_adapter_binding".to_string())
2369 );
2370 }
2371
2372 /// Build the `facts`/`parents` vectors [`file_is_named_fs_floor`] now takes as
2373 /// caller-supplied arguments, the same way [`fs_below_driver`] does, so each test
2374 /// below reads as "classify this file" rather than repeating the setup.
2375 fn classify(krate: &str, files: &[(PathBuf, String)], i: usize) -> bool {
2376 let facts: Vec<FsImportFacts> = files.iter().map(|(_, s)| fs_import_facts(s)).collect();
2377 let parents: Vec<Option<usize>> =
2378 files.iter().map(|(p, _)| module_parent(p, files)).collect();
2379 file_is_named_fs_floor(krate, files, &facts, &parents, i)
2380 }
2381
2382 /// The concrete #1104 shape, in miniature: `project.rs`'s bare `use std::fs;` (no
2383 /// enclosing fn — never itself a violation) plus `discovery.rs`'s two named-exception
2384 /// functions. The whole file must read as a named floor, not residual.
2385 #[test]
2386 fn file_is_named_fs_floor_true_for_the_real_discovery_rs_shape() {
2387 let files = [
2388 (
2389 PathBuf::from("project.rs"),
2390 "use std::fs;\n\nmod discovery;\n".to_string(),
2391 ),
2392 (
2393 PathBuf::from("project/discovery.rs"),
2394 "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(),
2395 ),
2396 ];
2397 assert!(classify("bynk-emit", &files, 1));
2398 }
2399
2400 /// A new, unlisted fn touching `std::fs` in the *same file* as two named exceptions
2401 /// must flip the whole file to residual — no partial credit, since "named floor"
2402 /// must mean every touch is accounted for, not most of them.
2403 #[test]
2404 fn file_is_named_fs_floor_false_when_an_unnamed_fn_also_touches_fs() {
2405 let files = [
2406 (
2407 PathBuf::from("project.rs"),
2408 "use std::fs;\n\nmod discovery;\n".to_string(),
2409 ),
2410 (
2411 PathBuf::from("project/discovery.rs"),
2412 "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(),
2413 ),
2414 ];
2415 assert!(!classify("bynk-emit", &files, 1));
2416 }
2417
2418 /// A file whose only production-scope touch is a bare `use std::fs;` import — no
2419 /// enclosing fn at all — is trivially a named floor: the import performs no I/O by
2420 /// itself, and the descendant it enables is checked (and named) separately.
2421 #[test]
2422 fn file_is_named_fs_floor_true_for_an_import_only_file() {
2423 let files = [(
2424 PathBuf::from("project.rs"),
2425 "use std::fs;\n\nmod discovery;\n".to_string(),
2426 )];
2427 assert!(classify("bynk-emit", &files, 0));
2428 }
2429
2430 /// The same `discovery.rs` shape under the wrong crate label must not read as a
2431 /// floor — [`NAMED_FS_EXCEPTIONS`] is keyed on `(crate, file, fn)`, not `(file, fn)`
2432 /// alone, so a same-named file/fn pair in a different crate isn't accidentally
2433 /// covered.
2434 #[test]
2435 fn file_is_named_fs_floor_false_under_the_wrong_crate() {
2436 let files = [
2437 (
2438 PathBuf::from("project.rs"),
2439 "use std::fs;\n\nmod discovery;\n".to_string(),
2440 ),
2441 (
2442 PathBuf::from("project/discovery.rs"),
2443 "use super::*;\n\npub(crate) fn discover_bynk_files() {\n let _ = fs::read_dir(\".\");\n}\n".to_string(),
2444 ),
2445 ];
2446 assert!(!classify("bynk-ide", &files, 1));
2447 }
2448
2449 /// Review finding (#1106): a module-scope `std::fs` touch that isn't an import
2450 /// declaration — a `static` initialiser doing real I/O — has no enclosing fn either,
2451 /// but is a genuine R2.3 violation and must not be waved through as a floor just
2452 /// because it sits outside every known fn range.
2453 #[test]
2454 fn file_is_named_fs_floor_false_for_a_module_scope_static_that_reads() {
2455 let files = [(
2456 PathBuf::from("project.rs"),
2457 "use std::fs;\n\nstatic ROOT: once_cell::sync::Lazy<String> = once_cell::sync::Lazy::new(|| fs::read_to_string(\"x\").unwrap());\n"
2458 .to_string(),
2459 )];
2460 assert!(!classify("bynk-emit", &files, 0));
2461 }
2462
2463 /// Same review finding, the [`fn_name_on_line`] half: an `extern "C" fn` (a modifier
2464 /// combination the parser doesn't strip) produces no [`production_fn_ranges`] entry
2465 /// at all, so its whole body would fall into the "no enclosing fn" branch. It must
2466 /// still read as residual, not floor, once it touches `std::fs`.
2467 #[test]
2468 fn file_is_named_fs_floor_false_for_an_unparsed_extern_fn_body() {
2469 let files = [(
2470 PathBuf::from("project.rs"),
2471 "use std::fs;\n\nextern \"C\" fn callback() {\n let _ = fs::read_dir(\".\");\n}\n"
2472 .to_string(),
2473 )];
2474 assert!(!classify("bynk-emit", &files, 0));
2475 }
2476
2477 // --- emit_abi_shapes (#999 Decision E) ----------------------------------
2478
2479 /// A binding's ordinary capability-interface imports, and the emit-ABI tag-layout
2480 /// names, must not be flagged — the exact failure mode Decision E rebuilt the probe
2481 /// to avoid (the original single-allowlist definition read 29-33 here, not 1).
2482 ///
2483 /// Exercises the real production allowlists via [`is_enumerated_emit_abi_or_capability_surface`]
2484 /// — not a local re-declaration. A test with its own copy of `EMIT_ABI` would still
2485 /// pass if the real one lost an entry (e.g. deleting `Uuid` from the production
2486 /// list), proving nothing about the probe it claims to cover.
2487 #[test]
2488 fn emit_abi_shapes_does_not_flag_capability_or_tag_layout_imports() {
2489 let src = "import type { Clock, Fetch, Locale } from \"./bynk.js\";\n\
2490 import { FetchError, Uuid } from \"./bynk.js\";\n\
2491 import { Err, None, Ok, Some, type Option, type Result } from \"./runtime.js\";\n";
2492 let imports = ts_named_imports_from_runtime_modules(src);
2493 let leaks: Vec<&String> = imports
2494 .iter()
2495 .filter(|i| !is_enumerated_emit_abi_or_capability_surface(i))
2496 .collect();
2497 assert!(leaks.is_empty(), "unexpected leaks: {leaks:?}");
2498 }
2499
2500 /// The falsifier from #999 Decision E, checked directly: deleting an entry from the
2501 /// real production allowlist must be detectable by *some* test — this one flags
2502 /// `Uuid` as a leak the moment it's removed from [`EMIT_ABI`], which the test above
2503 /// (using the real const) would also start failing on.
2504 #[test]
2505 fn is_enumerated_checks_the_real_production_allowlist() {
2506 assert!(is_enumerated_emit_abi_or_capability_surface("Uuid"));
2507 assert!(is_enumerated_emit_abi_or_capability_surface("LocaleTag"));
2508 assert!(!is_enumerated_emit_abi_or_capability_surface(
2509 "negotiateLocale"
2510 ));
2511 }
2512
2513 /// The real, current-tree finding this probe exists to surface: `negotiateLocale`,
2514 /// a plain value helper from `./runtime.js` alongside the tag-layout constructors,
2515 /// is neither an enumerated emit-ABI shape nor a capability-interface import.
2516 #[test]
2517 fn emit_abi_shapes_flags_a_non_enumerated_runtime_helper() {
2518 let src = "import { Err, None, Ok, Some, negotiateLocale, type Option, type Result } from \"./runtime.js\";\n";
2519 let imports = ts_named_imports_from_runtime_modules(src);
2520 assert!(imports.contains(&"negotiateLocale".to_string()));
2521 }
2522
2523 /// `FetchError` is `import type` in one binding and a plain value import in
2524 /// another (`FetchError.Timeout`) — Decision E's rejected type-vs-value
2525 /// discriminator. Confirms the extractor treats both forms as the same identifier,
2526 /// so the allowlist check doesn't depend on which form a given file happens to use.
2527 #[test]
2528 fn ts_import_extraction_ignores_type_only_vs_value_distinction() {
2529 let type_only = "import type { FetchError } from \"./bynk.js\";\n";
2530 let value = "import { FetchError, Uuid } from \"./bynk.js\";\n";
2531 assert_eq!(
2532 ts_named_imports_from_runtime_modules(type_only),
2533 vec!["FetchError".to_string()]
2534 );
2535 assert!(ts_named_imports_from_runtime_modules(value).contains(&"FetchError".to_string()));
2536 }
2537
2538 // --- options_sources -----------------------------------------------------
2539
2540 #[test]
2541 fn struct_body_finds_a_field_by_name() {
2542 let src = "struct Foo {\n pub sources: Option<HashMap<PathBuf, String>>,\n pub other: bool,\n}\n";
2543 let body = struct_body(src, "Foo").expect("struct body found");
2544 assert!(body.contains("sources"));
2545 }
2546
2547 #[test]
2548 fn struct_body_does_not_match_an_unrelated_struct() {
2549 let src =
2550 "struct Bar {\n pub sources: bool,\n}\n\nstruct Foo {\n pub other: bool,\n}\n";
2551 let body = struct_body(src, "Foo").expect("struct body found");
2552 assert!(!body.contains("sources"));
2553 }
2554
2555 // --- render_table's "Rules closed" section (#1001) ------------------------
2556
2557 fn empty_report() -> Report {
2558 Report { probes: Vec::new() }
2559 }
2560
2561 /// The section is static text — no count, no existence check — precisely
2562 /// because nothing regenerates `design/greenfield-status.md` when `stamp`
2563 /// writes the ledger, so a computed count would silently go stale the
2564 /// moment the first `closes_rule` landed (the drift a first draft of this
2565 /// section introduced, caught in #1001's review). This test pins "static"
2566 /// as the actual behaviour, not just the intent in a comment.
2567 #[test]
2568 fn render_table_rules_closed_section_is_static_regardless_of_the_tree() {
2569 let out = render_table(&empty_report());
2570 assert!(out.contains("greenfield-status-rules.md"), "{out}");
2571 assert!(
2572 out.contains("may not exist yet"),
2573 "the wording must not claim to know whether the ledger exists: {out}"
2574 );
2575 }
2576}