diff --git a/.changeset/plenty-donuts-attack.md b/.changeset/plenty-donuts-attack.md new file mode 100644 index 0000000..a14efc1 --- /dev/null +++ b/.changeset/plenty-donuts-attack.md @@ -0,0 +1,31 @@ +--- +"@cosyte/ccda": patch +--- + +phi-scan: a target enumerated and never read refuses the scan + +`--allow-fixture` withdrew a path from the target list and the run reported on +whatever remained, so the withdrawal never showed up in the verdict: four argv +shapes printed `OK, no hits` at exit 0 over a corpus holding a live, detectable +violator, including a whole-run sweep whose only violator was the withdrawn file. +CI could print a clean verdict over a corpus it never opened. + +The scanner now refuses (exit 2), in every mode, over any target this run +enumerated and never read, naming the paths. The comparison is a set difference +rather than a count, because a count counts the targets that did get read. A +bypass naming a path the run does not enumerate refuses too, under its own +message, since such a flag subtracts nothing (in `paths` mode a nonexistent path +is refused earlier still, by `File not found`). `--allow-fixture` no longer +selects the mode and is unioned into the target list in `paths` mode, so it means +the same thing in every argv. + +What it costs, stated rather than left to be discovered: `--allow-fixture` can no +longer reach exit 0 in any mode. The flag, `phi-scan-overrides.md` and the log +gate all stay, so an attempt is recorded and then refused rather than silently +honored, and `scripts/phi-allow-list.txt` is now the only mechanism that reaches +a clean run. The hit footer no longer advertises the flag as a remedy. + +No published API, warning code or parser behaviour changes; this is the commit +gate only. There was no real unread corpus in this repo: the all-mode sweep +enumerates and reads the same set, and the four states were reproduced with +planted fixtures rather than found. diff --git a/CLAUDE.md b/CLAUDE.md index a84df17..7ae2780 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,9 +24,8 @@ immutability + explicit mutation, and the profile system. - **Published on npm, public, MIT.** **This line names no version on purpose: it was stale every time anyone checked, so `npm view @cosyte/ccda version` is the only source of truth**, and a - `@cosyte/ccda` version quoted elsewhere in this file is historical, not - current; the `@cosyte/test-utils`, TypeScript and `pnpm` pins below are live - and mean what they say. Pre-alpha, `0.0.x` ladder (ADR 0001). A published version never moves + `@cosyte/ccda` version quoted elsewhere here is historical; every toolchain pin below is live and + means what it says. Pre-alpha, `0.0.x` ladder (ADR 0001). A published version never moves backwards. Why: `documentation/agent-notes.md#the-published-version-line-names-no-version` - **There are no stubs left.** `src/index.ts` exports a working parser (`parseCcda`), serializer @@ -268,23 +267,22 @@ immutability + explicit mutation, and the profile system. exclusions, phase guards and self-test samples are re-derived for C-CDA and must not be inherited wholesale.** `CHANGELOG.md` is exempt org-wide (founder, 2026-07-29): do not re-litigate it. Why: `documentation/agent-notes.md#the-public-surface-gate` -- **Em-dash gate present AND BLOCKING.** `U+2014` is banned outright by - founder directive, and **when it goes red the fix is never to re-encode the character**: rewrite - with a period, colon, comma or parentheses. `no-emdash` is a required status check via the - repository-level `emdash-required-check` ruleset; **re-read the rulesets rather than this line.** - It scans every tracked file **except the script itself**, **and** the - PR title, body and commit messages, so **keep the script free of the literal character.** **It is - the text-only variant, and dropping `grep -I` is the load-bearing part**: `src/profiles/merge.ts` - carries raw NULs and would otherwise be **silently exempt** from a ban with no exceptions, which - is not theoretical. **Do not swap in `website`'s variant**, and do not reach for `pathways`' +- **Em-dash gate present AND BLOCKING.** `U+2014` is banned outright by founder directive, and + **when it goes red the fix is never to re-encode the character**: rewrite with a period, colon, + comma or parentheses. `no-emdash` is required via the repository-level `emdash-required-check` + ruleset; **re-read the rulesets rather than this line.** It scans every + tracked file **except the script itself**, **and** the PR title, body and commit messages, so + **keep the script free of the literal character.** **It is the text-only variant, and dropping + `grep -I` is the load-bearing part** (`src/profiles/merge.ts` carries raw NULs and would otherwise + be **silently exempt**). **Do not swap in `website`'s variant**, and do not reach for `pathways`' `git check-attr binary` without first adding a `.gitattributes`. **Do not trust a copy count written down anywhere, including here** - enumerate at carry-back time. **The gate covers new text only and does not rewrite history.** Why: `documentation/agent-notes.md#the-em-dash-gate` - **`phi-scan` scans EVERY tracked file now, markdown included; the two exemptions are literal - paths, and writing docs is inside the gate.** **THERE ARE THREE ROUTES, not two** - (`paths` is the third; miscounting them shipped an `INTRODUCED`, twice). **Never widen - `isSourceCode`: it also SUBTRACTS the structured scan in `looksLikeCda`.** A refusal exits **2**. + paths, and writing docs is inside the gate.** **THERE ARE THREE ROUTES, not two** (`paths` is the + third; miscounting them shipped an `INTRODUCED`, twice). **Never widen `isSourceCode`: it also + SUBTRACTS the structured scan in `looksLikeCda`.** A refusal exits **2**. **Write no count in this area; three drafts wrote one and were wrong.** Why: `documentation/agent-notes.md#the-corpus-every-phi-scan-route-read-past` - **`all` mode UNIONS the bytes git carries with the walk, deduped by CONTENT not path (the EOL @@ -292,13 +290,16 @@ immutability + explicit mutation, and the profile system. not decorate `Target.path`. A non-blob index mode or an EMPTY index refuses. `ccda` had NO real unscanned corpus: the four states were reproduced, not found.** Why: `documentation/agent-notes.md#the-all-mode-sweep-reads-the-bytes-git-carries` +- **A target ENUMERATED and never READ refuses (exit 2), any mode; `--allow-fixture` cannot reach + exit 0 anywhere. Assert an exact code AND the tier's message: `not.toBe(0)` accepts a crash.** + Why: `documentation/agent-notes.md#the-completeness-rule` - **The `CLAUDE.md` / `agent-notes.md` contract is gated, and unlike the public-surface gate above it BLOCKS** (it runs in the test suite, inside `parser-ci-required-checks`). **It asserts what THIS repo promises, never a fleet universal**: `config`, `hl7` and `workflow` carry no `agent-notes.md` at all. **Do not promote it to an umbrella script.** It scans **EVERY tracked file, no exclusion list: do not re-add a binary/NUL skip** - the first cut had one and silently - exempted `src/profiles/merge.ts`, the file the em-dash trap above names. The bare `` `#anchor` `` - form is confined to `CLAUDE.md` by shape and scope and **must not be widened** (`#id`/`#62` are + exempted `src/profiles/merge.ts`. The bare `` `#anchor` `` form is + confined to `CLAUDE.md` by shape and scope and **must not be widened** (`#id`/`#62` are XML and C-CDA narrative references). **Never delete an imperative or a section to get green.** Why: `documentation/agent-notes.md#the-agent-notes-contract-gate` @@ -312,8 +313,7 @@ a summary. `@cosyte/tsconfig`. **Target ES2023**, `NodeNext`. TypeScript 5.9.x, exact-pinned. - **Build:** dual ESM + CJS + `.d.ts` via `tsup` (`@cosyte/tsup-config`); `attw` is a publish gate (per-condition types: `.d.ts` for `import`, `.d.cts` for `require`). The `attw` script is - **`node scripts/attw.mjs`, not the bare CLI**: see the guardrail below. The CLI reports a missing - `dist/` as "does not contain types" and **exits 0**. + **`node scripts/attw.mjs`, not the bare CLI**: see the guardrail below. - **Node:** **>= 22** (CI matrix 22 + 24). - **Package manager:** `pnpm@10`. - **Lint/format:** **ESLint 10** + unified `typescript-eslint` (type-checked) via @@ -351,11 +351,11 @@ a summary. token.** **`.npmignore` versus `files` is about the file's DEPTH, not its existence.** `test/scripts/attw-gate.test.ts` pins two of the three, the upstream exit-0 itself, a real failure and a negative control; **the printed-nothing backstop is pinned by NO test, a stated gap rather - than an oversight. Do not carry the test file's "16 of 21" figure forward, re-measure it.** **This is a - per-repo script** and a sibling still invoking the CLI directly still has the defect; do not write - a repo count down here, derive it. `scripts/verify.sh` in the meta-repo **must not be touched** for - this. **The guard is described in four committed files and three corrections have landed in some - copies and not others: prefer CUTTING a copy to adding a more careful one.** + than an oversight. Do not carry the test file's "16 of 21" figure forward, re-measure it.** + **Per-repo script**; a sibling still on the bare CLI still has the defect. Write no repo count, + derive it. The meta-repo's `scripts/verify.sh` **must not be touched** for this. **The guard is + described in four committed files and three corrections have landed in some copies and not + others: prefer CUTTING a copy to adding a more careful one.** Why: `documentation/agent-notes.md#the-attw-wrapper-script` ## Standing disciplines (every change) diff --git a/documentation/agent-notes.md b/documentation/agent-notes.md index 4a8048a..a635cef 100644 --- a/documentation/agent-notes.md +++ b/documentation/agent-notes.md @@ -2118,6 +2118,98 @@ non-regular branch, not on `isFile()`). It is harmless - the file holds a `gitdi direction is more scanning than claimed, never less. Reproduced on this repo's checkout under the meta-repo; a standalone clone has a real `.git` directory and never reaches it. +## The completeness rule + +`PHI-SCAN`'s second half, closed here 2026-08-11. It is independent of the union half above and was +still live after it: **CI could print a clean verdict over a corpus it never opened.** **The rule +itself, the four argv shapes it closes, what it costs and why the comparison is a set difference are +stated ONCE, in `scripts/phi-scan.ts`'s docblock.** This section carries what a docblock cannot: what +was measured here, and which claims were checked rather than inherited. + +**THE DEFECT WAS REPRODUCED IN THIS FILE, NOT PORTED AS A STORY.** `--allow-fixture` withdrew a path +from the target list and the run reported on whatever remained, so the withdrawal was invisible in +the verdict. Four argv shapes reached `OK, no hits` at exit 0 over a corpus holding a live, detectable +violator, and the subtlest of them is the one a reader will miss: the seed read +`paths.length > 0 ? paths : [...allowFixtures]`, so with any positional path present the flag was a +silent no-op and the named file was never ADMITTED to the run rather than withdrawn from it. All four +are enumerated in the docblock and all four are pinned by test. + +**AND AGAIN, THE HONEST ANSWER IS A NULL RESULT: THERE WAS NO REAL UNREAD CORPUS HERE.** Measured at +`1978bf7` with the rule in place: `all` mode enumerates 141 paths and reads all 141, nothing +tolerated and nothing unread, so the sweep exits 0 on its own corpus. **The figures are DATED TO THAT +COMMIT rather than asserted as current**, for the reason the section above gives: the `.changeset/` +entry that ships this raises the tracked figure by one until a release consumes it. The arithmetic is +written out because 141 looks like the tracked count wearing a different hat and is not: 141 tracked, +minus the one declared literal exclusion, is 140 in-scope tracked paths reached by both the walk and +the union, plus the `.git` **gitfile** this submodule checkout carries in place of a directory (the +pre-existing, filed observation in the section above). The tracked figure was taken with three tools +(`wc -l`, `rg -c` and a stage-0 filter over `ls-files -s`), because `grep -c` has mis-reported a zero +on this class three times. **`ncpdp` reproduced the same null result on the union half, so the fleet +premise that "every repo is hiding something" is now broken twice. Do not manufacture a gap to match +a sibling.** + +**WHAT IT COSTS THIS REPO, STATED RATHER THAN LEFT TO BE DISCOVERED. `--allow-fixture` CAN NO LONGER +REACH EXIT 0 IN ANY MODE.** The flag, `phi-scan-overrides.md` and the log gate all stay, so an attempt +is RECORDED AND REFUSED rather than silently honored, and `scripts/phi-allow-list.txt` is now the only +mechanism that reaches a clean run. The hit footer stopped advertising the flag as a remedy for the +same reason: a printed remedy that walks a developer from exit 1 into exit 2 is the same defect as one +that reaches a false green, with the sign flipped. **The log gate did not become decorative and the +test that used to assert "honors a logged bypass, exit 0" now asserts it reaches a DIFFERENT refusal**, +which is the boundary between the two tiers. + +**THE VANISH TOLERANCE IS THE ONE ACCOUNTED-FOR NON-READ, AND SUBTRACTING IT IS A DECISION.** A target +skipped under `Target.tolerateVanish` is excluded from the unread difference, or the `ccda@0.0.5` +publish-time refusal would come straight back. It cannot launder a bypass: an `--allow-fixture` path is +skipped BEFORE `scanTarget` is ever called, so it can never enter the vanished set, and the tolerance +is bounded to an untracked file the walk enumerated itself that failed with `ENOENT`. **If you widen +that tolerance, you widen this hole in the same edit.** + +**⚖️ THE POSITIVE CONTROL IS THE POINT, AND IT IS BUILT AGAINST THIS REPO'S OWN CAUTIONARY LINEAGE.** +`#110`'s final fix shipped ungraded and `#111` found that its branch-and-merge fixture had gone red on +a **committer identity** (`git merge` dying at exit 128 before it touched the index) while an +`expect(...).not.toBe(0)` waved it through. **A premise assertion that accepts any non-zero exit +accepts a crash**, and this scanner has three distinct exit-2 tiers, so every assertion in the new +block pins an exact code AND the message that names the tier. The control itself runs the same argv +against a copy of the scanner with the rule's one line mutated out, asserts the mutation landed (so it +cannot go vacuous when the rule is reworded), and asserts the mutant reproduces both pre-fix results +exactly: the HITS code where the corpus still holds a readable violator, and `OK, no hits` at exit 0 +where it does not. Re-run against the pre-change scanner, seven of the nine new cases red and the two +that pass are the anti-vacuity premise and the no-false-refusal case, which must hold on both trees. + +**🛑 THE GRADE FOUND THREE PROSE DEFECTS AND NO CODE DEFECT, WHICH IS THIS LINEAGE'S SIGNATURE, SO +THEY ARE RECORDED RATHER THAN QUIETLY FIXED.** All three were `INTRODUCED` by this slice and all three +are corrected on it: + +1. **A draft docblock listed the WALK's filters as if they bounded the whole sweep.** Only + `EXCLUDED_PATHS` binds both halves of `all` mode. A **tracked** path under a `WALK_SKIP_DIRS` name + IS enumerated, by the union half, and read from the index: a committed `dist/leaked.txt` with a + clean working copy reports `HIT: dist/leaked.txt (as git carries it)` at exit 1. Measured twice, + independently. The direction is more scanning than the sentence claimed, never less, and it cannot + produce a false refusal because the union both enumerates AND reads it. **Do not read the walk's + filter list as the sweep's.** The gitignore half of that sentence is only VACUOUSLY true of the + union, and `unionCandidatePaths` says so where it declines to apply it. +2. **The `CLAUDE.md` byte budget forced five compressions and one of them widened a scoped sentence + into a false universal:** "a `@cosyte/ccda` version quoted elsewhere is historical" became "any + version", which declares the live enforced toolchain baseline historical. **Compressing to fit a + budget is an edit like any other and can introduce a claim defect.** No imperative was deleted by + any of the five; that was checked one by one. +3. **The positive control's first mutant assertion accepted a crash.** It pinned exit 1, and node + exits 1 on an uncaught throw, so a mutant that failed to RUN satisfied it; only the second mutant + run (exit 0 plus `OK, no hits`) was crash-proof. It now asserts the payload marker too. **This is + the `#111` shape again, in the very block written to avoid it.** + +**A BOUND WORTH KNOWING: the rule is PATH-LEVEL.** "Read" means the bytes went through `scanTarget`, +not that every detector ran over them, so `CHANGELOG.md` counts as read while +`STRUCTURED_EXEMPT_PATHS` keeps the five structured detectors off it. That exemption is pre-existing +and argued where it is declared; this rule does not re-litigate it, and what it adds is that a file +nothing opened at all can no longer pass. + +**THE EXTERNAL DEFINITION OF DONE.** `config`'s `pnpm drift` runs a capability PROBE, not a regex over +this file's prose, and it named this repo's drift as "reported only its HITS code (1) over a run that +withdrew `test/fixtures/phi-scan-probe-decoy.txt` after enumerating it". It is `ok` here now. **The +probe grades behaviour on purpose: this class has produced defects that lived entirely in a prose +carrier while the code was right, so matching prose would grade the comment.** + ## The agent-notes contract gate `CLAUDE-MD-AUDIT` (2026-08-04) split this file out of `CLAUDE.md` and nothing checked the result. diff --git a/phi-scan-overrides.md b/phi-scan-overrides.md index fd3c502..7fec916 100644 --- a/phi-scan-overrides.md +++ b/phi-scan-overrides.md @@ -1,11 +1,20 @@ # phi-scan bypass log This file logs every `--allow-fixture ` bypass invocation of -`scripts/phi-scan.ts`. The scanner refuses to honor a `--allow-fixture ` -flag UNLESS this file contains an entry referencing the same path. The committed -log is intentionally annoying: it discourages bypass and creates an audit -trail. Prefer extending `scripts/phi-allow-list.txt` (a token-level, reviewed -declaration) over a whole-file bypass. +`scripts/phi-scan.ts`. The scanner refuses a `--allow-fixture ` flag +UNLESS this file contains an entry referencing the same path. The committed log +is intentionally annoying: it discourages bypass and creates an audit trail. + +**A LOGGED BYPASS IS NOW RECORDED AND THEN REFUSED, NEVER HONORED, so +`--allow-fixture` CANNOT REACH EXIT 0 IN ANY MODE.** A bypass withdraws a file +from the read set, and a scan that did not open a file has no clean verdict to +give about it, so the completeness rule refuses (exit 2) over any target this run +enumerated and never read. The two tiers are separate and stay separate: this +log is what admits the flag to the run at all, and the completeness rule is what +then refuses the run. **`scripts/phi-allow-list.txt` (a token-level, reviewed +declaration) is the ONLY mechanism that reaches a clean run.** The rule, the four +argv shapes it closes, and what the vanish tolerance is allowed to subtract from +it are stated once, in the docblock of `scripts/phi-scan.ts`; read them there. ## How the scanner detects PHI diff --git a/scripts/phi-scan.ts b/scripts/phi-scan.ts index 9ff9f4a..3b41928 100644 --- a/scripts/phi-scan.ts +++ b/scripts/phi-scan.ts @@ -128,17 +128,129 @@ * * Modes: * --staged - scan only files staged in `git diff --cached` - * --allow-fixture - bypass one path; rejected unless logged in - * phi-scan-overrides.md * [...] - scan specific paths * (no args) - scan all in-scope working-tree files * + * `--allow-fixture ` IS A MODIFIER, NOT A MODE, and that is load-bearing + * rather than cosmetic. A bypass is SUBTRACTIVE, so it must not also be the + * thing that decides what gets scanned: this file used to let a lone + * `--allow-fixture X` select `paths` mode over exactly `X`, which then withdrew + * `X` and reported a clean whole run having opened nothing at all. The flag is + * still rejected unless `phi-scan-overrides.md` logs the same path, and in + * `paths` mode it is now UNCONDITIONALLY UNIONED INTO THE TARGET LIST, deduped + * by repo-relative path, so it means the same thing in every argv. + * * Exit codes: 0 (clean), 1 (hits found), 2 (invocation error, OR a scan that * refused, OR one that could not run at all). Read the 2 as the whole class, * not as `InvocationError`: an `EACCES` off `readdirSync` is a plain system * error and exits 2 as well, deliberately. See the net at the bottom. * * --------------------------------------------------------------------------- + * THE COMPLETENESS RULE: A TARGET THIS RUN ENUMERATED AND NEVER READ REFUSES + * (exit 2), IN EVERY MODE, NAMING THE PATHS. + * + * THE DEFECT IT CLOSES, MEASURED IN THIS FILE AND NOT INHERITED AS A STORY. All + * four of these argv shapes reported `[phi-scan] OK, no hits` and exit 0 over a + * corpus carrying a live, detectable hit: + * + * phi-scan --allow-fixture the violator was never + * ADMITTED to the run rather than withdrawn from it: the seed read + * `paths.length > 0 ? paths : [...allowFixtures]`, so the flag seeded the + * target list ONLY when no positional path was given, and was a silent + * no-op the moment one was; + * phi-scan --allow-fixture a floor of one at whole-run + * scope: the run's entire target list was withdrawn and the empty result + * reported clean; + * phi-scan --allow-fixture the same floor with no + * positional at all, which is the worst of the four because it reads to a + * caller like a full-corpus sweep; + * phi-scan --staged --allow-fixture the identical floor on the + * route a commit is actually blocked on. + * + * In every one of them the file was validated, checked against the override log, + * and OPENED NEVER. A scan that did not open a file has no clean verdict to give + * about it, so the only true thing left to say is that the scan is incomplete. + * + * THE COMPARISON IS A SET DIFFERENCE, NEVER A SIZE. Counting reads against + * targets and comparing two numbers is a different and weaker test, because a + * count counts the targets that DID get read: a plausible-looking total hides + * exactly the paths that did not. The refusal names the paths because no number + * can. + * + * ENUMERATION IS THIS RUN'S OWN DECLARATION OF WHAT IT WILL READ, so the filters + * UPSTREAM of it do not violate the rule and are not weakened by it. What the + * rule catches is a path that BECAME a target and then did not get opened. In + * `all` mode the enumerated set is the walk's targets UNION + * `unionCandidatePaths(index)`, computed before the first byte is read, so the + * union half's tracked paths are covered too. + * + * 🛑 WHICH FILTERS THOSE ARE IS PER-ROUTE, AND A DRAFT OF THIS PARAGRAPH LISTED + * THE WALK'S AS IF THEY BOUNDED THE WHOLE SWEEP. Only `EXCLUDED_PATHS` is + * honored by both halves of `all` mode. The walk's other two are NOT: + * + * - A TRACKED PATH UNDER A `WALK_SKIP_DIRS` NAME IS ENUMERATED, by the union + * half, and read from the index. Measured, not reasoned about: a committed + * `dist/leaked.txt` whose working copy is clean reports + * `HIT: dist/leaked.txt (as git carries it)` at exit 1. Nothing is tracked + * under such a name here today, so it is latent, and the direction is MORE + * scanning than the walk alone, never less. It cannot produce a false + * refusal either, because the union both enumerates AND reads it. + * - The gitignore filter is only VACUOUSLY true of the union, and saying so is + * the point: `git check-ignore` never reports a TRACKED path as ignored, so + * the union has no gitignored candidate to drop. See `unionCandidatePaths`. + * + * A BYPASS NAMING A PATH THIS RUN DOES NOT ENUMERATE ALSO REFUSES. It is the + * other half of the same claim: such a flag subtracts nothing, so honoring it + * silently would let a developer believe a file was acknowledged when the run + * never had it in scope. BOUND, so the tier is not read as wider than it is: in + * `paths` mode a bypass naming a path that does not exist never reaches this + * tier, because the flag is unioned into the target list and + * `buildTargetsForPaths` throws `File not found` first. Same exit 2, earlier and + * more specific message; the tier is reached in `all` and `staged` mode. + * + * WHAT THIS COSTS, STATED RATHER THAN LEFT TO BE DISCOVERED: `--allow-fixture` + * CAN NO LONGER REACH EXIT 0 IN ANY MODE. The flag, the override log and the + * rejection gate are all kept, so an attempt is RECORDED AND REFUSED rather than + * silently honored, and `scripts/phi-allow-list.txt` is the mechanism that + * reaches a clean run. THE HIT FOOTER THEREFORE NO LONGER ADVERTISES + * `--allow-fixture` AS A REMEDY: a printed remedy that leads to exit 2 is the + * same defect as one that leads to a false green, with the sign flipped. + * + * THE RULE IS PATH-LEVEL, AND "READ" MEANS THE BYTES WENT THROUGH `scanTarget`, + * NOT THAT EVERY DETECTOR RAN OVER THEM. Stated because the headline sentence + * ("a scan that did not open a file has no clean verdict to give about it") is + * otherwise easy to read as the stronger claim: `STRUCTURED_EXEMPT_PATHS` names + * `CHANGELOG.md`, which is read and shape-scanned but never structurally + * scanned, and it counts as READ here. That exemption is pre-existing, argued + * where it is declared, and deliberately NOT re-litigated by this rule; what + * this rule adds is that a file nothing opened at all can no longer pass. + * + * THE VANISH TOLERANCE IS THE ONE ACCOUNTED-FOR NON-READ, AND ITS BOUNDS ARE + * WHY IT IS NOT A HOLE IN THIS RULE. A target skipped under + * `Target.tolerateVanish` is recorded and excluded from the unread difference, + * because it is already reported on stderr by its own branch and re-checked for + * reappearance there. It cannot launder a bypass: an `--allow-fixture` path is + * skipped BEFORE `scanTarget` is called, so it never enters the vanished set; + * and the tolerance is bounded to an UNTRACKED file the walk enumerated itself + * that failed with `ENOENT`. Every other non-read is either an in-loop refusal + * or lands in the unread difference below. + * + * A HIT IS NEVER SWALLOWED BY A POST-SWEEP REFUSAL. Hits are printed as soon as + * both sweeps finish and BEFORE the vanish, floor and unread refusals, so a run + * that is both incomplete AND carrying hits prints both; the code is 2, because + * the incompleteness is the larger claim and the hits are already on stderr + * where a human reads them. THE GUARANTEE IS ABOUT THE POST-SWEEP REFUSALS AND + * NOT ABOUT REFUSALS IN GENERAL, and the others are named here rather than left + * to be assumed: the unmatched-bypass refusal fires BEFORE any target is read, + * so no hit exists for it to swallow; and a target whose bytes cannot be read + * refuses from INSIDE the loop, which does discard the hits found before it. + * That last one is pre-existing and is left alone deliberately: it exits 2, so + * it is loud rather than green, and re-ordering the loop to salvage a partial + * hit list would be a claim about a corpus the scan just said it could not + * account for. + * --------------------------------------------------------------------------- + * + * --------------------------------------------------------------------------- * AN IN-SCOPE ENTRY THAT IS NOT A REGULAR FILE REFUSES THE SCAN (exit 2). This * paragraph is the ONE authoritative statement of that rule; everything else * (changeset, changelog, phi-scan-overrides.md) points here rather than @@ -636,23 +748,50 @@ function parseArgs(argv: string[]): Args { throw new InvocationError("--staged cannot be combined with positional paths"); } - // An `--allow-fixture` path is a *subtractive* acknowledgement on a broader - // scan, never a scan target on its own, so it also seeds the positional path - // set. That makes `--allow-fixture X` mean "scan X, but allow it" (proving the - // override gate actually subtracts a scanned target) instead of a silent no-op. - const scanPaths = paths.length > 0 ? paths : [...allowFixtures]; - + // THE MODE IS CHOSEN BY POSITIONAL PATHS ALONE. A bypass is subtractive, so + // letting one select the mode made `--allow-fixture X` scan exactly `X`, then + // withdraw it, then report a clean whole run over a corpus it never touched. + // With the mode decided here, a lone bypass leaves the run in `all` mode and + // the two refusal tiers in `main` account for the flag. let mode: Args["mode"]; if (staged) { mode = "staged"; - } else if (scanPaths.length > 0) { + } else if (paths.length > 0) { mode = "paths"; } else { mode = "all"; } + + // UNCONDITIONAL, DEDUPED SEEDING, so the flag has ONE meaning in every argv. + // The old form was `paths.length > 0 ? paths : [...allowFixtures]`, which + // seeded the target list ONLY when no positional path was given: with one + // present the bypass was a silent no-op and the named file was never ADMITTED + // to the run rather than withdrawn from it. Unioning admits it in every case, + // so the withdrawal is always a withdrawal of something enumerated and is + // therefore always caught by the completeness rule. Dedupe is by repo-relative + // path, so `X --allow-fixture ./X` is one target, not two. + const scanPaths = mode === "paths" ? dedupeByRepoPath([...paths, ...allowFixtures]) : paths; + return { mode, paths: scanPaths, allowFixtures }; } +/** + * Dedupe argument paths by the repo-relative path each one resolves to, keeping + * the caller's original spelling for the first occurrence: that spelling is what + * `buildTargetsForPaths` resolves and what a diagnostic echoes back. + */ +function dedupeByRepoPath(paths: string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const p of paths) { + const key = normalizePath(p); + if (seen.has(key)) continue; + seen.add(key); + out.push(p); + } + return out; +} + /** * The `errno` string of a Node system error (`ENOENT`, `EACCES`, ...), or * `undefined` for anything else. Narrowed with `in` rather than cast, so a @@ -1119,12 +1258,42 @@ function buildTargetsForAll(): { targets: Target[]; index: Map): string[] { + return ( + [...index] + // Refused already in `buildTargetsForAll`, except where EXCLUDED_PATHS + // names it; either way there is nothing here to read. `index` holds + // stage-0 records only, so an unmerged path never reaches here at all. + .filter(([, e]) => REGULAR_BLOB_MODES.has(e.mode)) + // The scanner's own test carries adversarial literals by design and is out + // of scope for the two sweeping routes. The union is the same route as the + // walk, so it inherits the same literal exclusion rather than reopening it. + .filter(([p]) => !EXCLUDED_PATHS.has(p)) + .map(([p]) => p) + ); +} + /** * The union half of `all` mode: the bytes git carries at every tracked path the * walk did not already read verbatim. See the docblock for why this exists, what * it costs, and why deduplication is by content rather than by path. * - * `readBytes` maps a path the walk actually READ to the object id of what it + * `readOids` maps a path the walk actually READ to the object id of what it * read. A path absent from it was never opened, whatever the reason, so its blob * is scanned. */ @@ -1133,15 +1302,9 @@ function buildTargetsForGitIndex( readOids: Map, ): Target[] { const targets: Target[] = []; - for (const [path, entry] of index) { - // Refused already in `buildTargetsForAll`, except where EXCLUDED_PATHS names - // it; either way there is nothing here to read. `index` holds stage-0 - // records only, so an unmerged path never reaches this loop at all. - if (!REGULAR_BLOB_MODES.has(entry.mode)) continue; - // The scanner's own test carries adversarial literals by design and is out - // of scope for the two sweeping routes. The union is the same route as the - // walk, so it inherits the same literal exclusion rather than reopening it. - if (EXCLUDED_PATHS.has(path)) continue; + for (const path of unionCandidatePaths(index)) { + const entry = index.get(path); + if (entry === undefined) continue; if (readOids.get(path) === entry.oid) continue; targets.push({ path, @@ -1722,11 +1885,15 @@ function scanTarget(target: Target, allow: AllowList, hits: Hit[]): Buffer | nul // Reporting // --------------------------------------------------------------------------- -function report(hits: Hit[]): void { - if (hits.length === 0) { - process.stdout.write("[phi-scan] OK, no hits\n"); - return; - } +/** + * Print the hits. SPLIT FROM THE CLEAN LINE ON PURPOSE: `main` reports hits + * BEFORE it can refuse for incompleteness, so a run that is both incomplete and + * carrying hits prints both rather than swallowing one. The clean line is + * written by `main` only once every refusal tier has passed, so `OK, no hits` + * can never appear beside a refusal. + */ +function reportHits(hits: Hit[]): void { + if (hits.length === 0) return; const byPath = new Map(); for (const h of hits) { const arr = byPath.get(h.path); @@ -1739,10 +1906,18 @@ function report(hits: Hit[]): void { process.stderr.write(` locus=${h.locus} value=${JSON.stringify(h.value)} (${h.reason})\n`); } } + // THE FOOTER NO LONGER ADVERTISES `--allow-fixture`, AND THAT IS A DECISION, + // NOT AN OMISSION. A bypass withdraws a file from the read set, and the + // completeness rule refuses (exit 2) over a target enumerated and never read, + // so a developer following that printed remedy would be walked from exit 1 + // into exit 2. A printed remedy that cannot reach the state it promises is the + // same defect as one that reaches a false green, with the sign flipped. process.stderr.write( `[phi-scan] ${String(hits.length)} hit(s) across ${String(byPath.size)} file(s). ` + - `If a value is genuinely synthetic, declare it in scripts/phi-allow-list.txt OR ` + - `run with --allow-fixture AND log it in phi-scan-overrides.md.\n`, + `If a value is genuinely synthetic, declare it in scripts/phi-allow-list.txt: a ` + + `token-level, reviewed declaration is the only remedy that reaches a clean run. A ` + + `whole-file --allow-fixture bypass is recorded and then REFUSED (exit 2), because a scan ` + + `that never opened a file has no clean verdict to give about it.\n`, ); } @@ -1792,11 +1967,43 @@ function main(): number { throw err; } - targets = targets.filter((t) => !allowed.has(t.path)); + // ENUMERATED: the set of paths this run DECLARED it would read, captured + // BEFORE the bypass subtracts anything and before the first byte is read. + // Whatever the filters upstream of it dropped never became a target and is not + // in here, which is why the completeness rule below does not fire on it. WHICH + // FILTERS THOSE ARE IS PER-ROUTE and the docblock spells it out: only + // `EXCLUDED_PATHS` binds BOTH halves of `all` mode, and a TRACKED path under a + // `WALK_SKIP_DIRS` name is enumerated by the union half even though the walk + // drops it. Do not read the walk's filter list as the sweep's. + // + // IN `all` MODE IT IS THE WALK'S TARGETS UNION THE IN-SCOPE TRACKED PATHS. The + // union half reads that second set minus whatever the walk already read + // verbatim, and the dedupe collapses on the SAME path key, so a path the union + // skips is a path the walk already put in `read` and the difference below + // stays exact either way. + const enumerated = new Set(targets.map((t) => t.path)); + if (index !== null) for (const p of unionCandidatePaths(index)) enumerated.add(p); + + // TIER: A BYPASS MUST NAME A PATH THIS RUN ENUMERATES. Otherwise it subtracts + // nothing, and a flag that subtracts nothing lets a developer believe a file + // was acknowledged when the run never had it in scope. Compared by DIFFERENCE + // against the enumerated set, and every offender is named. It fires before any + // target is read, so there is no hit here for it to swallow. + const unmatched = [...allowed].filter((p) => !enumerated.has(p)); + if (unmatched.length > 0) { + process.stderr.write( + `[phi-scan] --allow-fixture names ${String(unmatched.length)} path(s) this run does not ` + + `enumerate, so the flag subtracts nothing:\n${unmatched.map((p) => ` - ${p}`).join("\n")}\n` + + `Scan a corpus that contains the path, or drop the flag.\n`, + ); + return 2; + } const hits: Hit[] = []; const vanished: Target[] = []; - let observed = 0; + // READ: filled in only once a target's bytes have actually been through + // `scanTarget`. This is evidence of observation, never a plan to observe. + const read = new Set(); // Path -> object id of the bytes the walk actually read, so the union below can // skip a path whose content it would otherwise scan a second time. const readOids = new Map(); @@ -1804,13 +2011,17 @@ function main(): number { const sweep = (batch: Target[]): number | null => { for (const t of batch) { + // The bypass is applied HERE rather than by filtering `targets`, so the + // withdrawn path is still in `enumerated` above and the completeness rule + // below sees it as what it is: a target this run declared and never read. + if (allowed.has(t.path)) continue; try { const bytes = scanTarget(t, allow, hits); if (bytes === null) { vanished.push(t); continue; } - observed += 1; + read.add(t.path); if (objectHash !== null && t.origin === undefined) { const oid = blobOid(objectHash, bytes); if (oid !== null) readOids.set(t.path, oid); @@ -1832,13 +2043,15 @@ function main(): number { // THE UNION. It runs after the walk, not instead of it, and only for the paths // the walk did not already read verbatim. if (index !== null) { - const unionTargets = buildTargetsForGitIndex(index, readOids).filter( - (t) => !allowed.has(t.path), - ); - const unionFailure = sweep(unionTargets); + const unionFailure = sweep(buildTargetsForGitIndex(index, readOids)); if (unionFailure !== null) return unionFailure; } + // HITS FIRST, so none of the three refusals below can swallow one. See the + // docblock: the guarantee is about the POST-SWEEP refusals, not about the + // in-loop read failure, which still discards what it found before it. + reportHits(hits); + // A tolerated file is never silent, and the tolerance is only good while the // file is still gone: if it is back on disk the sweep skipped something that // exists, which is an incomplete scan and refuses like any other. @@ -1871,15 +2084,44 @@ function main(): number { // literally excluded AND the walk to find nothing else. It is kept because a // floor that only fires in a state nobody expects is exactly the floor worth // keeping, and deleting it would trade a cheap standing guard for nothing. - if (args.mode === "all" && observed === 0) { + if (args.mode === "all" && read.size === 0) { process.stderr.write( "[phi-scan] refusing: the all-mode sweep observed no files, so it proves nothing.\n", ); return 2; } - report(hits); - return hits.length === 0 ? 0 : 1; + // THE COMPLETENESS RULE. A SET DIFFERENCE, NEVER A SIZE COMPARISON: a count + // counts the targets that DID get read, so `n read of n targets` is exactly + // the arithmetic that hides which ones did not. Names every offender. + // + // IT IS LAST AMONG THE REFUSALS BY CHOICE, not by accident of ordering. The + // two above name a MORE SPECIFIC cause for the same class of incompleteness (a + // file that came back, a sweep that observed nothing at all), and a more + // specific diagnosis is the more useful one to print. All three exit 2, so the + // order decides only which message a developer reads. + // + // A VANISHED TARGET IS SUBTRACTED, and the docblock states why that is not a + // hole: it is already reported and re-checked by its own branch above, an + // `--allow-fixture` path is skipped before `scanTarget` and so can never enter + // that set, and the tolerance is bounded to an untracked file the walk + // enumerated itself that failed with `ENOENT`. + const tolerated = new Set(vanished.map((t) => t.path)); + const unread = [...enumerated].filter((p) => !read.has(p) && !tolerated.has(p)); + if (unread.length > 0) { + process.stderr.write( + `[phi-scan] refusing the scan: ${String(unread.length)} target(s) were enumerated and ` + + `never read:\n${unread.map((p) => ` - ${p}`).join("\n")}\n` + + `A scan that did not open a file has no clean verdict to give about it. If the file is ` + + `genuinely synthetic, declare its identifiers in scripts/phi-allow-list.txt rather than ` + + `withdrawing the file from the scan.\n`, + ); + return 2; + } + + if (hits.length > 0) return 1; + process.stdout.write("[phi-scan] OK, no hits\n"); + return 0; } /** diff --git a/test/scripts/phi-scan.test.ts b/test/scripts/phi-scan.test.ts index c6c002d..78313a9 100644 --- a/test/scripts/phi-scan.test.ts +++ b/test/scripts/phi-scan.test.ts @@ -448,22 +448,33 @@ describe("phi-scan: --allow-fixture override gate", () => { expect(r2.stderr).toMatch(/phi-scan-overrides\.md/); }); - it("honors --allow-fixture WITH an override-log entry (exit 0)", () => { + it("a LOGGED --allow-fixture gets past the log gate and is then REFUSED (exit 2)", () => { + // The gate and the completeness rule are two different tiers, and this pins + // the boundary between them: the log entry is what the previous case is + // missing, so this run reaches a DIFFERENT refusal. It must not be read as + // "the log no longer matters". const path = join(dir, "override-me.xml"); writeFileSync(path, doc(`
Anderson
`)); const rel = relative(REPO_ROOT, path).split(sep).join("/"); - // Sanity: scanned on its own it is a genuine violator, so the override, not - // an empty target set, is what flips the next run to clean. + // Premise: scanned on its own it is a genuine violator, so a later clean + // verdict could only ever come from an empty target set. expect(runScanner([path]).code).toBe(1); const original = readFileSync(OVERRIDES_PATH, "utf8"); try { appendFileSync( OVERRIDES_PATH, - `\n### ${rel}\n\n- **Date:** 2026-07-18\n- **Reason:** unit test\n- **Approved by:** vitest\n- **Expires:** permanent\n`, + `\n### ${rel}\n\n- **Date:** 2026-08-11\n- **Reason:** unit test\n- **Approved by:** vitest\n- **Expires:** permanent\n`, ); const r = runScanner(["--allow-fixture", path]); - expect(r.code, `stderr: ${r.stderr}`).toBe(0); + expect(r.code, `stderr: ${r.stderr}`).toBe(2); + // EXACT MESSAGE, not merely a non-zero exit. The temp fixture lives + // outside REPO_ROOT, so the lone bypass leaves the run in `all` mode and + // the path is not enumerated: this is the unmatched-bypass tier, and + // asserting which one fired is what stops a crash from passing as a pass. + expect(r.stderr).toMatch(/does not enumerate, so the flag subtracts nothing/); + expect(r.stderr).not.toMatch(/phi-scan-overrides\.md/); + expect(r.stdout).not.toMatch(/OK, no hits/); } finally { writeFileSync(OVERRIDES_PATH, original); } @@ -1741,3 +1752,264 @@ describe("phi-scan: positive control over the corpus this repo claims to clear", expect(fired.stderr).toMatch(/Ashgrove/); }); }); + +// --------------------------------------------------------------------------- +// THE COMPLETENESS RULE (PHI-SCAN, half 2) +// --------------------------------------------------------------------------- + +/** + * A target this run ENUMERATED and never READ refuses (exit 2), in every mode. + * + * THE DEFECT, REPRODUCED HERE RATHER THAN DESCRIBED. `--allow-fixture` withdrew + * a path from the target list and the run reported on the remainder, so a + * corpus whose only violator was withdrawn came back `OK, no hits` at exit 0. + * The withdrawal was invisible in the verdict: a scan that never opened a file + * has no clean verdict to give about it. + * + * 🛑 EVERY ASSERTION HERE IS AN EXACT CODE PLUS THE MESSAGE THAT NAMES THE TIER, + * NEVER `not.toBe(0)`. This repo already shipped a premise assertion of that + * shape: `expect(merged.status).not.toBe(0)` passed on a `git merge` that died + * at exit 128 on "Committer identity unknown", which made a fixture look like it + * exercised an unmerged index when it had never touched one (`#111`). An + * assertion that accepts any non-zero exit accepts a crash, and this scanner has + * three distinct exit-2 tiers, so accepting the code alone would let the wrong + * one pass for the right one. + * + * THE POSITIVE CONTROL IS THE LAST CASE IN THIS BLOCK AND IT IS THE POINT. A + * test that has never been seen to red is indistinguishable from one that + * cannot: it runs the SAME argv against a copy of the scanner with the + * completeness rule mutated out, asserts the mutation actually landed, and + * asserts the mutant reproduces both pre-fix results exactly (the HITS code + * where the corpus still holds a readable violator, and `OK, no hits` at exit 0 + * where it does not). + */ +describe("phi-scan: the completeness rule", () => { + const VIOLATOR = "test/fixtures/completeness-violator.txt"; + const DECOY = "test/fixtures/completeness-decoy.txt"; + /** Logged in the override log and deliberately never written to disk. */ + const ABSENT = "test/fixtures/completeness-absent.txt"; + /** A dashed SSN: the shape pass finds it at any path, under any extension. */ + const PAYLOAD = "patient ssn 123-45-6789 on file\n"; + const MARKER = "123-45-6789"; + const CLEAN = "nothing to see here\n"; + + /** + * A throwaway git repo the scanner can treat as REPO_ROOT, carrying exactly + * one violator, one clean decoy, and an override log that admits a bypass of + * EITHER of them. Committed, so both are tracked and the index-backed half of + * `all` mode has something real to enumerate. + */ + function makeCorpus(): string { + const d = tempDir("ccda-phi-completeness-"); + const write = (rel: string, text: string): void => { + const abs = join(d, ...rel.split("/")); + mkdirSync(join(abs, ".."), { recursive: true }); + writeFileSync(abs, text); + }; + mkdirSync(join(d, "scripts"), { recursive: true }); + copyFileSync( + join(REPO_ROOT, "scripts", "phi-allow-list.txt"), + join(d, "scripts", "phi-allow-list.txt"), + ); + write(VIOLATOR, PAYLOAD); + write(DECOY, CLEAN); + write( + "phi-scan-overrides.md", + `# phi-scan bypass log\n\n## Entries\n\n### ${VIOLATOR}\n\n- **Date:** 2026-08-11\n` + + `- **Reason:** unit test\n- **Approved by:** vitest\n- **Expires:** permanent\n\n` + + `### ${DECOY}\n\n- **Date:** 2026-08-11\n- **Reason:** unit test\n` + + `- **Approved by:** vitest\n- **Expires:** permanent\n\n` + + `### ${ABSENT}\n\n- **Date:** 2026-08-11\n- **Reason:** unit test\n` + + `- **Approved by:** vitest\n- **Expires:** permanent\n`, + ); + gitIn(d, ["init", "-q"]); + gitIn(d, ["add", "-A"]); + gitIn(d, [ + "-c", + "user.name=vitest", + "-c", + "user.email=vitest@example.invalid", + "commit", + "-qm", + "corpus", + "--no-verify", + ]); + return d; + } + + /** Run an arbitrary scanner file with `cwd` as its REPO_ROOT. */ + function runIn(cwd: string, scanner: string, args: string[]): RunResult { + const r = spawnSync(NODE_BIN, [scanner, ...args], { cwd, encoding: "utf8", shell: false }); + return { code: r.status ?? -1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" }; + } + + // ANTI-VACUITY, and it is a premise for every case below rather than a nicety: + // if the planted payload is not something this scanner finds at this path, a + // clean report over it proves nothing and the whole block is decorative. + it("premise: the violator is caught and the decoy is clean, with no flag in play", () => { + const corpus = makeCorpus(); + const hit = runIn(corpus, SCANNER_PATH, [VIOLATOR]); + expect(hit.code, `stderr: ${hit.stderr}`).toBe(1); + expect(hit.stderr).toContain(MARKER); + + const clean = runIn(corpus, SCANNER_PATH, [DECOY]); + expect(clean.code, `stderr: ${clean.stderr}`).toBe(0); + expect(clean.stdout).toMatch(/OK, no hits/); + }); + + it("REFUSES a paths run that withdrew one of two enumerated targets", () => { + const corpus = makeCorpus(); + const r = runIn(corpus, SCANNER_PATH, [VIOLATOR, DECOY, "--allow-fixture", DECOY]); + expect(r.code, `stderr: ${r.stderr}`).toBe(2); + expect(r.stderr).toMatch(/were enumerated and never read/); + expect(r.stderr).toContain(DECOY); + // The bypass got PAST the override-log gate and the run DID open the other + // target: without this the refusal above could be any earlier failure. + expect(r.stderr).toContain(MARKER); + // A hit is never swallowed by the refusal that follows it. + expect(r.stderr).toMatch(/HIT: /); + expect(r.stdout).not.toMatch(/OK, no hits/); + }); + + it("REFUSES the headline case: the run's ONLY violator is withdrawn", () => { + // This exact argv reported `OK, no hits` at exit 0 before the rule: a floor + // of one at whole-run scope withdrew the entire target list and the empty + // result reported clean. + // + // THE EXTERNALLY-GRADED SHAPE IS THE CASE ABOVE, NOT THIS ONE. `config`'s + // capability probe runs `[violator, decoy, --allow-fixture decoy]` and named + // this repo's drift as the HITS code (1), which is that case's pre-rule + // result; this case's is exit 0. One run cannot have produced both, so + // do not re-attach the probe to this argv. + const corpus = makeCorpus(); + const r = runIn(corpus, SCANNER_PATH, [VIOLATOR, "--allow-fixture", VIOLATOR]); + expect(r.code, `stderr: ${r.stderr}`).toBe(2); + expect(r.stderr).toMatch(/were enumerated and never read/); + expect(r.stderr).toContain(VIOLATOR); + expect(r.stdout).not.toMatch(/OK, no hits/); + }); + + it("REFUSES an all-mode sweep whose bypass withdraws a tracked path", () => { + // A LONE bypass no longer selects `paths` mode over exactly the path it + // withdraws, which is the shape that read to a caller like a full sweep. The + // run is `all` mode, the path is enumerated by the walk AND by the index, and + // it is unread either way. + const corpus = makeCorpus(); + const r = runIn(corpus, SCANNER_PATH, ["--allow-fixture", VIOLATOR]); + expect(r.code, `stderr: ${r.stderr}`).toBe(2); + expect(r.stderr).toMatch(/were enumerated and never read/); + expect(r.stderr).toContain(VIOLATOR); + expect(r.stdout).not.toMatch(/OK, no hits/); + }); + + it("REFUSES a --staged run whose bypass withdraws a staged path", () => { + // The route a commit is actually blocked on. Same floor, same refusal. + const corpus = makeCorpus(); + writeFileSync(join(corpus, ...VIOLATOR.split("/")), `${PAYLOAD}second line\n`); + gitIn(corpus, ["add", VIOLATOR]); + const r = runIn(corpus, SCANNER_PATH, ["--staged", "--allow-fixture", VIOLATOR]); + expect(r.code, `stderr: ${r.stderr}`).toBe(2); + expect(r.stderr).toMatch(/were enumerated and never read/); + expect(r.stderr).toContain(VIOLATOR); + expect(r.stdout).not.toMatch(/OK, no hits/); + }); + + it("ADMITS a bypass beside an unrelated positional, then refuses it", () => { + // The FIRST of the four pre-fix shapes, and the subtlest: the seed read + // `paths.length > 0 ? paths : [...allowFixtures]`, so with a positional + // present the bypass was a silent no-op and the violator was never ADMITTED + // to the run rather than withdrawn from it. It reported `OK, no hits` at + // exit 0 while naming a file it had never opened. The flag is unioned into + // the target list now, so the withdrawal is always a withdrawal of something + // enumerated and the completeness rule always sees it. + const corpus = makeCorpus(); + const r = runIn(corpus, SCANNER_PATH, [DECOY, "--allow-fixture", VIOLATOR]); + expect(r.code, `stderr: ${r.stderr}`).toBe(2); + expect(r.stderr).toMatch(/were enumerated and never read/); + expect(r.stderr).toContain(VIOLATOR); + expect(r.stdout).not.toMatch(/OK, no hits/); + }); + + it("REFUSES a bypass naming a path this run does not enumerate, under its OWN message", () => { + // The other half of the same claim, and a DIFFERENT tier: such a flag + // subtracts nothing, so honoring it silently would let a developer believe a + // file was acknowledged when the run never had it in scope. Asserting WHICH + // message fired is what keeps the two exit-2 tiers apart; the code alone + // cannot tell them from each other or from a crash. + const corpus = makeCorpus(); + + // `--staged` does not union a bypass into its targets (its scope is the + // index, not argv), and nothing is staged in a freshly committed corpus. + const staged = runIn(corpus, SCANNER_PATH, ["--staged", "--allow-fixture", VIOLATOR]); + expect(staged.code, `stderr: ${staged.stderr}`).toBe(2); + expect(staged.stderr).toMatch(/does not enumerate, so the flag subtracts nothing/); + expect(staged.stderr).toContain(VIOLATOR); + expect(staged.stderr).not.toMatch(/were enumerated and never read/); + expect(staged.stdout).not.toMatch(/OK, no hits/); + + // And in `all` mode, over a logged path that is in neither the walk nor the + // index. Premise first, so this cannot pass because the file simply exists. + expect(existsSync(join(corpus, ...ABSENT.split("/")))).toBe(false); + const sweep = runIn(corpus, SCANNER_PATH, ["--allow-fixture", ABSENT]); + expect(sweep.code, `stderr: ${sweep.stderr}`).toBe(2); + expect(sweep.stderr).toMatch(/does not enumerate, so the flag subtracts nothing/); + expect(sweep.stderr).toContain(ABSENT); + expect(sweep.stdout).not.toMatch(/OK, no hits/); + }); + + it("leaves an ordinary sweep of the same corpus alone (no false refusal)", () => { + // The rule must not fire on the filters UPSTREAM of enumeration. A whole + // `all`-mode sweep of this corpus reports the violator it can see, at the + // HITS code, not at a refusal. + const corpus = makeCorpus(); + const r = runIn(corpus, SCANNER_PATH, []); + expect(r.code, `stderr: ${r.stderr}`).toBe(1); + expect(r.stderr).toContain(MARKER); + expect(r.stderr).not.toMatch(/were enumerated and never read/); + }); + + // ------------------------------------------------------------------------- + // POSITIVE CONTROL: mutate the rule out and prove these assertions RED. + // ------------------------------------------------------------------------- + + /** The one line whose removal removes the completeness rule, and nothing else. */ + const RULE_LINE = + "const unread = [...enumerated].filter((p) => !read.has(p) && !tolerated.has(p));"; + + it("the assertions above RED when the completeness rule is mutated out", () => { + const source = readFileSync(SCANNER_PATH, "utf8"); + // THE MUTATION IS ASSERTED TO HAVE LANDED. Without this the control goes + // vacuous the moment the rule is reworded: a "mutant" identical to the + // shipped scanner would pass by agreeing with it. + expect(source, `the rule line is gone; re-derive this control: ${RULE_LINE}`).toContain( + RULE_LINE, + ); + const mutated = source.replace(RULE_LINE, "const unread: string[] = [];"); + expect(mutated).not.toBe(source); + + const mutantDir = tempDir("ccda-phi-mutant-"); + const mutant = join(mutantDir, "phi-scan-no-completeness-rule.ts"); + writeFileSync(mutant, mutated); + + const corpus = makeCorpus(); + + // The mutant reports only its HITS code over the withdrawn decoy: exactly + // the drift `config`'s probe named on this repo. The shipped scanner exits 2 + // here, so the assertion two cases up genuinely discriminates. + const withdrawnDecoy = runIn(corpus, mutant, [VIOLATOR, DECOY, "--allow-fixture", DECOY]); + expect(withdrawnDecoy.code, `stderr: ${withdrawnDecoy.stderr}`).toBe(1); + // NODE ALSO EXITS 1 ON AN UNCAUGHT THROW, which is this gate's code for HITS + // FOUND, so the code alone cannot tell a mutant that scanned from a mutant + // that failed to run. The marker is the evidence it actually opened the + // violator. Without this line the control could pass on a broken mutant, + // which is the shape (`#111`) that let a crash stand in for a premise here. + expect(withdrawnDecoy.stderr).toContain(MARKER); + expect(withdrawnDecoy.stderr).not.toMatch(/were enumerated and never read/); + + // And the headline false green, reproduced rather than described: the run's + // only violator withdrawn, and a CLEAN verdict over a corpus it never opened. + const withdrawnViolator = runIn(corpus, mutant, [VIOLATOR, "--allow-fixture", VIOLATOR]); + expect(withdrawnViolator.code, `stderr: ${withdrawnViolator.stderr}`).toBe(0); + expect(withdrawnViolator.stdout).toMatch(/OK, no hits/); + }); +});