diff --git a/cli/tri/src/main.rs b/cli/tri/src/main.rs index e1469dcf51..8b459ce448 100644 --- a/cli/tri/src/main.rs +++ b/cli/tri/src/main.rs @@ -10,6 +10,7 @@ use std::process::Command; mod depin; mod discard; mod abandoned; +mod orphaned; mod cibase; mod fleet; mod fpga; @@ -147,6 +148,11 @@ enum Commands { #[command(subcommand)] action: quant::QuantCmd, }, + /// Checks whose input is not in the tree. + Orphaned { + #[command(subcommand)] + action: orphaned::OrphanedCmd, + }, /// What the parser reads and throws away, ranked against its pinned bound. Discard { #[command(subcommand)] @@ -784,6 +790,7 @@ fn main() -> Result<()> { Commands::Abandoned { action } => abandoned::run(action)?, Commands::Types { action } => types_dup::run(action)?, Commands::Quantifiers { action } => quant::run(action)?, + Commands::Orphaned { action } => orphaned::run(action)?, Commands::Discard { action } => discard::run(action)?, Commands::Seals { action } => seals::run(action)?, Commands::Hooks { action } => hooks::run(action)?, diff --git a/cli/tri/src/orphaned.rs b/cli/tri/src/orphaned.rs new file mode 100644 index 0000000000..957ef23c32 --- /dev/null +++ b/cli/tri/src/orphaned.rs @@ -0,0 +1,352 @@ +//! Checks whose input is not there. +//! +//! WHY THIS EXISTS +//! --------------- +//! The catalog gate's field-by-field comparison had never run. `gen/` is +//! gitignored and a cleanup commit untracked the artifacts it read, so the check +//! was left with nothing -- and it reported that into a variable the master gate +//! does not print. Zero occurrences of the word in the output of the command +//! that gates master (W702). +//! +//! **Removing a check's input does not make it fail. It makes it quiet.** That +//! is a shape, not an incident, and this looks for it. +//! +//! WHAT IT LOOKS FOR +//! ----------------- +//! Path-shaped string literals in gate and tool sources whose path does not +//! exist in the tree, EXCLUDING the ones that are supposed to be absent: +//! +//! * fixtures inside a self-check or a test module -- `specs/a.t27`, +//! `docs/planted.md` and friends are planted into temp directories on +//! purpose, and a first version that did not exclude them reported 126 hits +//! of which almost none were real +//! * outputs the code WRITES rather than reads +//! * anything under `target/`, `/tmp`, or carrying a format placeholder +//! +//! THE CONTROL FAILED, AND THAT IS IN THE DOCUMENTATION RATHER THAN FIXED AWAY +//! --------------------------------------------------------------------------- +//! Run against the commit before W702's fix, this command does **not** find the +//! case it was written for. The pre-fix code built the path as +//! +//! let json = emitted.join("formats_catalog.json"); // no slash +//! ... .unwrap_or_else(|| Path::new("gen/numeric")) // no extension +//! +//! Neither literal is a path by this command's test, and the assembled one never +//! appears as a literal at all. **Zero of one on its founding case.** +//! +//! The window is not being widened until it says one. Following `join` chains +//! through variables is a dataflow problem, and a heuristic stretched until it +//! hits its own motivating example has stopped being evidence (skill 177). +//! +//! What it does find is a different and real class: an input named outright in +//! production code that is not in the tree. That is how `public/index.html` was +//! found -- the Railway HTTP server's static fallback and its 404 page both point +//! at a directory that has never existed in any commit. +//! +//! WHAT IT CANNOT DO +//! ----------------- +//! It cannot tell a check that is silent about a missing input from one that +//! reports it properly -- that needs the control flow, not the literal. It marks +//! a line `quiet?` when the nearest handling looks like a bare early return with +//! no report, and that mark is a HINT for a human, never a verdict. +//! +//! It also cannot see a path built by concatenation. Everything here is a lower +//! bound. +use anyhow::{Context, Result}; +use clap::Subcommand; +use std::path::{Path, PathBuf}; + +#[derive(Subcommand)] +pub enum OrphanedCmd { + /// List gate and tool sources that name an input which is not in the tree. + List { + /// Also print the fixture paths that were filtered out, to check the filter. + #[arg(long)] + show_filtered: bool, + }, +} + +/// Directories whose sources are gates, tools or the CLI itself. +const ROOTS: [&str; 3] = ["tools", "bootstrap/src", "cli/tri/src"]; + +/// A line is fixture context when it sits inside a self-check or a test module. +/// Tracked by a simple depth counter rather than a parser: these files put +/// `mod tests` and `def self_check` at column 0, and a counter that is wrong +/// would show up as fixtures leaking back into the report. +fn fixture_regions(text: &str) -> Vec { + let mut out = Vec::with_capacity(text.lines().count()); + // Rust: a test module is a brace-delimited region. An indent rule cannot do + // this -- a first version set the region's indent from `#[test]` and then let + // the `fn` on the next line close it, so 41 fixture paths leaked back in. + let mut rust_depth: Option = None; + let mut depth: i32 = 0; + // Python: indentation is the structure, and a `def` at or shallower than the + // one that opened the region ends it. + let mut py_indent: Option = None; + + for line in text.lines() { + let trimmed = line.trim_start(); + let indent = line.len() - trimmed.len(); + + let opens_rust = trimmed.starts_with("#[cfg(test)]") + || trimmed.starts_with("mod tests") + || trimmed.starts_with("pub mod tests"); + let opens_py = trimmed.starts_with("def self_check") + || trimmed.starts_with("def _self_check") + || trimmed.starts_with("def test_"); + + if opens_rust && rust_depth.is_none() { + rust_depth = Some(depth); + } + if opens_py && py_indent.is_none() { + py_indent = Some(indent); + } + if let Some(pi) = py_indent { + if !trimmed.is_empty() && indent <= pi && !opens_py && trimmed.starts_with("def ") { + py_indent = None; + } + } + + let inside = rust_depth.is_some() || py_indent.is_some(); + out.push(inside); + + // Count braces AFTER recording, so the line that opens the module is + // itself inside it. + for c in line.chars() { + match c { + '{' => depth += 1, + '}' => depth -= 1, + _ => {} + } + } + if let Some(entry) = rust_depth { + // Back to the depth we entered at, and past the module body. + if depth <= entry && !opens_rust && trimmed.contains('}') { + rust_depth = None; + } + } + } + out +} + +/// Extensions a repository file actually has. Without this, `v0.1` in a schema +/// identifier and `github.io` in a repository name both read as paths -- they +/// contain a slash and a dot, which is all the shape test asks for. +const FILE_EXT: [&str; 22] = [ + "py", "rs", "json", "md", "t27", "txt", "yml", "yaml", "toml", "sh", "html", "inc", "bin", "v", + "sv", "lean", "tex", "csv", "tsv", "log", "zig", "c", +]; + +fn looks_like_path(s: &str) -> bool { + let Some(file) = s.rsplit('/').next() else { + return false; + }; + let Some((_, ext)) = file.rsplit_once('.') else { + return false; + }; + s.contains('/') + && FILE_EXT.contains(&ext) + && !s.starts_with('/') + && !s.starts_with("target/") + && !s.starts_with("http") + && !s.contains('{') + && !s.contains('*') + && !s.contains(' ') + // Regex source is full of slashes and dots and is not a path. + && !s.contains('^') + && !s.contains('$') + && !s.contains('|') + && !s.contains('\\') + && !s.contains('(') + && !s.contains('?') + && !s.contains('+') + && s.len() > 4 +} + +/// Extract double-quoted literals from one line. +fn literals(line: &str) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut inside = false; + let mut prev = '\0'; + for c in line.chars() { + if c == '"' && prev != '\\' { + if inside { + out.push(std::mem::take(&mut cur)); + } + inside = !inside; + } else if inside { + cur.push(c); + } + prev = c; + } + out +} + +fn repo_root() -> Result { + let out = std::process::Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .output() + .context("running `git rev-parse --show-toplevel`")?; + if !out.status.success() { + anyhow::bail!("not inside a git repository"); + } + Ok(PathBuf::from(String::from_utf8(out.stdout)?.trim())) +} + +fn sources(root: &Path) -> Vec { + let mut v = Vec::new(); + for r in ROOTS { + let dir = root.join(r); + let Ok(rd) = std::fs::read_dir(&dir) else { + continue; + }; + for e in rd.flatten() { + let p = e.path(); + match p.extension().and_then(|x| x.to_str()) { + Some("py") | Some("rs") => v.push(p), + _ => {} + } + } + } + v.sort(); + v +} + +/// A hint, not a verdict: does the handling nearby look like a bare exit? +fn looks_quiet(lines: &[&str], at: usize) -> bool { + let lo = at.saturating_sub(2); + let hi = (at + 6).min(lines.len()); + let window = lines[lo..hi].join(" "); + let exits = window.contains("return") + || window.contains("continue") + || window.contains("Ok(())") + || window.contains("=> {}"); + let reports = window.contains("bail") + || window.contains("findings.push") + || window.contains("FAIL") + || window.contains("eprintln") + || window.contains("panic") + || window.contains("exit(1") + || window.contains("exit(2"); + exits && !reports +} + +pub fn run(cmd: &OrphanedCmd) -> Result<()> { + let OrphanedCmd::List { show_filtered } = cmd; + let root = repo_root()?; + let mut hits = 0usize; + let mut filtered = 0usize; + let mut scanned = 0usize; + + for src in sources(&root) { + let Ok(text) = std::fs::read_to_string(&src) else { + continue; + }; + let fixture = fixture_regions(&text); + let lines: Vec<&str> = text.lines().collect(); + let rel = src + .strip_prefix(&root) + .unwrap_or(&src) + .display() + .to_string(); + for (i, line) in lines.iter().enumerate() { + let t = line.trim_start(); + if t.starts_with("//") || t.starts_with('#') || t.starts_with('*') { + continue; + } + for lit in literals(line) { + if !looks_like_path(&lit) { + continue; + } + scanned += 1; + if root.join(&lit).exists() { + continue; + } + if fixture.get(i).copied().unwrap_or(false) { + filtered += 1; + if *show_filtered { + println!(" filtered (fixture) {}:{} {}", rel, i + 1, lit); + } + continue; + } + hits += 1; + let quiet = if looks_quiet(&lines, i) { + " quiet?" + } else { + "" + }; + println!(" {}:{}{}", rel, i + 1, quiet); + println!(" names {} -- not in the tree", lit); + println!(" {}", t.trim_end()); + } + } + } + + println!(); + println!( + " {hits} absent input(s) named in production code; {filtered} fixture(s) filtered out of \ + {scanned} path literal(s)" + ); + println!(); + println!(" `quiet?` marks a line whose nearby handling looks like a bare exit with no"); + println!(" report. It is a HINT for a reader, never a verdict -- the control flow decides,"); + println!(" and this reads literals. Paths built by concatenation are invisible here, so"); + println!(" every number above is a lower bound."); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{fixture_regions, literals, looks_like_path}; + + /// The leak that mattered: after `#[test]`, the `fn` on the next line used + /// to close the region, and 41 fixture paths came back into the report. + #[test] + fn a_test_module_stays_a_fixture_region_past_its_first_fn() { + let src = "fn real() {\n read(\"specs/live.t27\");\n}\n#[cfg(test)]\nmod tests {\n #[test]\n fn one() { read(\"specs/a.t27\"); }\n #[test]\n fn two() { read(\"specs/b.t27\"); }\n}\n"; + let f = fixture_regions(src); + assert!(!f[1], "production line"); + assert!(f[6], "first test fn"); + assert!(f[8], "SECOND test fn -- the one that used to leak"); + } + + #[test] + fn a_fixture_inside_a_test_module_is_marked() { + let src = "fn real() {\n read(\"specs/live.t27\");\n}\n#[cfg(test)]\nmod tests {\n fn t() { read(\"specs/a.t27\"); }\n}\n"; + let f = fixture_regions(src); + assert!(!f[1], "the production line must not be a fixture"); + assert!(f[5], "the line inside mod tests must be"); + } + + /// The filter that mattered: without it a first run reported 126 hits, of + /// which almost none were real. + #[test] + fn a_python_self_check_is_a_fixture_region() { + let src = "def main():\n open(\"gen/real.json\")\n\ndef self_check():\n plant(\"docs/planted.md\")\n"; + let f = fixture_regions(src); + assert!(!f[1]); + assert!(f[4]); + } + + #[test] + fn path_shapes_are_recognised_and_noise_is_not() { + assert!(looks_like_path("gen/numeric/formats_catalog.json")); + assert!(looks_like_path("tools/gen_formats_catalog.py")); + assert!(!looks_like_path("target/release/t27c")); + assert!(!looks_like_path("https://example.com/x.json")); + assert!(!looks_like_path("{}/out.json")); + assert!(!looks_like_path("plain text here")); + assert!(!looks_like_path("noslash.json")); + // The two shapes that got through the first version. + assert!(!looks_like_path("t27-conformance-index/v0.1")); + assert!(!looks_like_path("gHashTag/ghashtag.github.io")); + assert!(!looks_like_path(r"^(tbd|todo|wip|n/?a)$")); + } + + #[test] + fn escaped_quotes_do_not_split_a_literal() { + let v = literals(r#"let s = "a\"b/c.json";"#); + assert_eq!(v.len(), 1, "{v:?}"); + } +} diff --git a/docs/now/2026-08-29-a-detector-that-fails-its-own-founding-case-and-says-so.md b/docs/now/2026-08-29-a-detector-that-fails-its-own-founding-case-and-says-so.md new file mode 100644 index 0000000000..1005287618 --- /dev/null +++ b/docs/now/2026-08-29-a-detector-that-fails-its-own-founding-case-and-says-so.md @@ -0,0 +1,8 @@ +# NOW -- A detector that fails its own founding case, and says so (2026-08-29) + +## A detector that fails its own founding case, and says so (Refs #2762) + +- tri orphaned list: inputs named outright in production code that are not in the tree -- 21 findings, 110 fixtures filtered out of 241 literals +- historical control FAILED: at the commit before W702 it finds ZERO of one, because that path was assembled from a variable and a bare filename and never appears as a literal +- the window stays where it is; the miss is in the module docs, not tuned away +- it earned its place on a different case: the Railway server's static fallback and 404 page point at public/, which has never existed in any commit