diff --git a/.changeset/lucky-donkeys-smile.md b/.changeset/lucky-donkeys-smile.md new file mode 100644 index 0000000..2e2eedf --- /dev/null +++ b/.changeset/lucky-donkeys-smile.md @@ -0,0 +1,10 @@ +--- +"@cosyte/cli": patch +--- + +Gate the two-file agent-guidance contract with `pnpm check:agent-notes`, run from the test suite so +it rides the required CI contexts and `prepublishOnly`. The narrative file must be tracked, no +section may be emptied down to its heading, and every pointer at it must resolve. The matcher was +derived by counting both pointer spellings on this tree rather than ported from a sibling, a bare-form +census refuses if the spelling this scope rests on ever changes, and the gate refuses at exit 2 +rather than reporting green over a corpus it never opened. diff --git a/CHANGELOG.md b/CHANGELOG.md index 24b97aa..70a7fc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,78 @@ still do. Each entry was assigned to the release whose tag first contains it, re ## [Unreleased] +### Added + +- **The two-file agent-guidance contract is now gated (`pnpm check:agent-notes`).** `CLAUDE.md` was + split from `documentation/agent-notes.md` on 2026-08-04, which made every anchor between them + load-bearing, and nothing checked them. `scripts/check-agent-notes.ts` now verifies that the + narrative file is tracked, that no section is emptied down to its heading (a container whose body is + its subsections is exempt and counted), and that every pointer at it resolves. It runs from + `test/scripts/agent-notes.test.ts`, so it rides the required `ci / verify` contexts and + `prepublishOnly` rather than adding a fourth workflow. + - **The matcher was derived by counting this tree, not by porting a sibling's.** Two pointer + spellings are live across these repositories, and porting without re-counting has produced a false + green twice: a qualified-only matcher would have covered 3 of `ncpdp`'s 38 pointers, and would + have matched nothing at all in `terminology` (42 bare, zero qualified) while still exiting 0. + **Measured here: every pointer is the qualified `documentation/agent-notes.md#` form and no + bare pointer exists**, so the gate matches that form only. No count is written into the gate's + prose as a promise; the OK line prints all of them on every run, because it measures rather than + remembers. + - **A bare-form census keeps that scope honest.** Matching one spelling is safe only while the other + stays absent, so in **every opened file** each backticked `#` span is enumerated: a + digits-only one is a pull-request reference and is counted and reported, and **any other one + refuses the run at exit 2** with an instruction to re-derive the matcher. Refusal rather than a + finding, because the tree has not necessarily broken but the evidence the scope rested on has. + A first draft scoped the census to the pair and justified that with tree files it claimed a + widening would red; **running it tree-wide proved none of them can, and that the only files that + could were the gate's own source and test**, which wrote bare spans out literally. That made the + narrow scope a self-exemption for the very files where a broken pointer would hide, so the samples + are now assembled from parts and the census covers the corpus. It closes the hole a pair-scoped + census leaves: a bare pointer in a third file was covered by neither the matcher nor the census. + - **Three claims were withdrawn after review rather than shipped.** The opening promise said the + gate catches "a rename"; it does not, because both halves match on **basename** and the directory + is never compared, so moving the file while pointers keep their prefix exits 0 with every rendered + link broken. That is now a narrowed promise and a disclosed miss rather than a bigger guard. A + **heading inside an HTML comment** mints a phantom anchor, which the list had omitted; it is now + disclosed and pinned. And a written-down count of sibling repos lacking the file was already + wrong, so **the number was dropped in favour of the class**. + - **Two further claims were withdrawn on later review passes, each arriving inside the fix for the + one before it.** The first fix asserted the HTML-comment miss is disclosed in "every sibling + copy"; that phrasing came from a review rather than from the tree, and it is false. `ncpdp`, + `terminology` and `astm` disclose it; `mllp`, `ccda`, `transform` and `docs` do not, and `mllp` + is the copy this gate's disclosed-miss block is transcribed from, so the shared ancestor never + carried the entry. The second fix then said the four that lack it are all owed it. Also false: + `mllp`, `ccda` and `transform` derive anchors by **slugging headings** and are owed it, while + `docs` resolves explicit `` anchors and cannot have the miss at all. **A claim about + another repository is not checkable from inside this one**, so it is measured against that + repository's source or not made. + - **It refuses rather than reporting green over a corpus it never opened.** There is no declared + scan root to be wrong about: the corpus is `git ls-files`, reconciled as **sets of paths**, and + zero pointers, zero tracked files, an unmerged path, a symlink, a non-regular file or two files + carrying the contract basename each refuse at exit 2. This repository has already shipped the + opposite defect once, in `phi-scan`, which printed `OK, no hits` over a root it never walked. + - **Every claim was watched to fail on a clone of the real tree** before the gate was believed: a + misspelled real anchor, an emptied real section, the narrative file deleted, a bare pointer that + resolves, a neutered matcher, and a tree with every pointer rewritten out of range. The last two + are the `terminology` scenario, and both refuse instead of going green. + - **Exit codes and corpus handling were re-derived from this repository, not inherited.** `0`, `1` + for a finding and `2` for a refusal come from `scripts/phi-scan.ts`. An unmerged path **refuses** + here even though `phi-scan` leaves that status unenumerated, because its reasoning (`git commit` + refuses an unmerged index) covers a staged route this gate does not have. + - **The NUL skip is a disclosed miss, not a pass**, and is required rather than tidy: the tree + tracks vendored `@cosyte/*` tarballs and a synthetic DICOM fixture, none readable as markdown or + editable to clear a red. The tell is the skipped count on the OK line. A draft claimed this + partition differs from `check-no-emdash.sh`'s; **that was false and was corrected by reading that + gate's own OK line.** Both key on an actual NUL byte; the wider set is git's own binary + classification, which is why neither gate may be reduced to `grep -I`. + +### Changed + +- **`CLAUDE.md` narrative was relocated into `documentation/agent-notes.md` to make room for the gate's + rules.** The branch-protection, PHI-scanner-residual and em-dash blocks were compressed to their + imperatives; every trap keeps a one-line rule and a pointer, and the reasoning each one compresses + was already in the narrative file. No trap was deleted and no ceiling was raised. + ### Fixed - **`pnpm phi-scan`'s all-mode walk was rooted at `test/__fixtures__` and `src` only, so 89 of this diff --git a/CLAUDE.md b/CLAUDE.md index 912571a..949fb12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,6 +9,18 @@ > imperative with a link to the section that proves it. **"I did not read the reason" is not a licence > to discount the rule.** Every one of these lines cost a defect to learn. +> **The pair is gated** (`pnpm check:agent-notes`, enforced by `test/scripts/agent-notes.test.ts`, so +> it rides `ci / verify` and `prepublishOnly`): the narrative file must be tracked, every section must +> have a body (a container's is its subsections), and every pointer at it **in a file it opened** must +> resolve. **A NUL-bearing file is skipped: a disclosed miss, not a pass**; the tell is the skipped +> count, and **only the BASENAME is compared, so moving the file to another directory keeps this +> green while every link 404s**. It matches the **QUALIFIED spelling only**; a **bare** backticked +> anchor anywhere **REFUSES the run**: the measurement that scoped the matcher has gone stale. +> **Never port a sibling's matcher without re-counting both spellings here.** It asserts **this +> repo's promise, not a universal**, and **refuses (exit 2) rather than reporting green over a +> corpus it never opened**. **Never clear a red by deleting the pointer or the heading.** Why: +> [agent-notes § The gate](documentation/agent-notes.md#the-two-file-contract-gate). + ## Project **`@cosyte/cli`**: the **cosyte CLI**: a **`bin` package** (the `cosyte` command), not a parser and @@ -183,15 +195,13 @@ Why: [agent-notes § The pre-commit PHI gate and git mv](documentation/agent-not - **`scripts/` IS SWEPT: name a PHI shape, never SPELL a literal** (a draft banner red the gate on itself). The SSN check reads **no** allow-list, so an `ID` in dashed shape reds `phi-allow-list.txt`: respell `MRN-`. -- **🛑 THE WIDENING BOUGHT THE SSN/EMAIL FLOOR OVER 38 MORE FILES AND NOTHING ELSE** (all hand-read: - enumeration gap, not exposure). **The recogniser was NOT widened, on measurement** - this floor is - anchor-free, so it never had the "file IS the document" defect, and an escape-decoded view finds - nothing new. **A tripwire reds if that changes**; widen **in addition to** the raw pass. -- **Other residuals:** `D` and `U` are unenumerated (`U` costs nothing that can reach a commit: - `git commit` refuses an unmerged index, exit 128); the routes now differ widely: `--staged` is - `test/__fixtures__` + `src/*.ts` only, CI sweeps the rest. -- **Give `test/scripts/phi-scan.test.ts` explicit timeouts.** Each case spawns `tsx` cold: 0.5s idle, - **3.7s under contention**, against a shared 10s default. +- **🛑 THE WIDENING BOUGHT THE SSN/EMAIL FLOOR OVER 38 MORE FILES AND NOTHING ELSE** (an + enumeration gap, not exposure). **The recogniser was NOT widened, on measurement**, and **a tripwire + reds if that changes**: widen **in addition to** the raw pass, never instead of it. +- **Other residuals:** `D` and `U` are unenumerated (`U` reaches no commit: `git commit` refuses an + unmerged index, exit 128); the two routes now differ widely. +- **Give every `test/scripts/*.test.ts` case an explicit timeout.** Each spawns `tsx` cold: **3.7s + under contention**, against a shared 10s default. - **Assert the premise, not only the remedy.** Two vacuity traps already sprang in this suite: a fixture whose `git merge` refused on an identity-less runner so every later assertion held over an empty result, and a loop that asserted only the detection-OFF side. @@ -226,14 +236,13 @@ Why: [agent-notes § The em-dash brand gate](documentation/agent-notes.md#the-em seeding each with a live em dash. A red on those echoes a whole synthetic message into a public CI log: deliberate and acceptable. - **Fix the script's shared known limits in the shared copies, not here.** A divergent copy is worse - than a shared known limit. The script is composed from three siblings' fixes; understand the - composition before editing it. -- **Re-derive every number before writing it down and never quote one from a sibling's copy.** The - first draft of these notes stated a wrong count _inside the sentence arguing for measurement rigor_. + than a shared known limit; the script is composed from three siblings' fixes. +- **Re-derive every number before writing it down and never quote one from a sibling's copy.** A draft + of these notes stated a wrong count _inside the sentence arguing for measurement rigor_. - **What lands on `main` here differs from `mllp`: all three merge methods are enabled**, so the - **branch commit messages** are the one text that lands under every method, the PR title lands under - two of three, and **the PR body lands under none** (it is scanned anyway, as deliberate - over-strictness). **Do not repeat `ncpdp`'s copy** claiming the title and body are what lands. + **branch commit messages** are the one text that lands under every method and **the PR body lands + under none** (scanned anyway, deliberate over-strictness). **Do not repeat `ncpdp`'s copy** claiming + the title and body are what lands. - **Not retroactive:** commit subjects already on `main` may carry `U+2014`. History is not rewritten. ## Tech Stack (the shared `@cosyte/*` standard) @@ -272,41 +281,33 @@ a summary. Full ruleset, the required-context table and the per-check reasoning: [agent-notes § Branch protection](documentation/agent-notes.md#branch-protection-and-the-limits-of-this-claim). -- `main` is protected by the repository ruleset **`ci-required-checks`** (id `19907924`). Before it - existed every check here was advisory, on the branch that publishes. **Seven** required contexts, - each pinned to **`integration_id: 15368`** so a same-named status from another actor cannot satisfy - it. **Do not quote that count without re-deriving it** (`gh api repos/cosyte/cli/rulesets/19907924`): - it was `6` until `ci / prepublish` was added, and it moves when the called workflow does. +- `main` is protected by the repository ruleset **`ci-required-checks`** (id `19907924`); before it + existed every check here was advisory, on the branch that publishes. Every required context is + pinned to **`integration_id: 15368`** so a same-named status from another actor cannot satisfy it. + **Never quote the context count without re-deriving it** + (`gh api repos/cosyte/cli/rulesets/19907924`): it moves when the called workflow does. - **Read a required context off a REAL check run, never off a workflow's `name:` field.** Requiring a - context nothing emits does not fail a PR: it leaves it **pending and unmergeable forever**, with no - error and no warning. -- **▶ A `ci / *` CONTEXT CAN APPEAR HERE WITH NO COMMIT IN THIS REPO, AND IT ARRIVES NOT REQUIRED.** - `ci.yml` calls `cosyte/.github/.github/workflows/ci.yml@main` unpinned, so a job added upstream - starts emitting a context here that the ruleset does not name: **a red X that does not block a - merge**, which is the failure this repo's whole protection claim exists to prevent. `ci / prepublish` - arrived that way on 2026-08-05 and was unrequired until measured and added. **Census `ci / *` against - a real check run whenever `.github` moves**, then require it or write down why not, in `ci.yml`'s - banner. -- **`no-internal-refs` and `no-emdash` are bare JOB IDS**, not ` / `: they are ordinary - jobs in this repo's own workflows. **Renaming the job silently detaches the required check.** Rename - the job and the ruleset together, or neither. -- **A required job gates all of its steps.** Splitting a step out of `ci / verify` into its own job - silently un-requires it. There is a banner on `ci.yml` where someone would trip it. -- **Never add a `paths:` filter to `ci.yml`, `codeql.yml`, `no-internal-refs.yml` or - `no-emdash.yml`.** None carries one, which is what stops a PR skipping a required check. -- **Confirm a ruleset write with the `PUT` itself, never a `GET`.** An Organization-sourced ruleset - returns `200` to a `GET` and `404` to an identical-payload `PUT`. `19907924` is - `source_type: Repository` and is the only ruleset this repo has. + context nothing emits does not fail a PR: it leaves it **pending and unmergeable forever**. +- **▶ A `ci / *` CONTEXT CAN APPEAR HERE WITH NO COMMIT IN THIS REPO, AND IT ARRIVES NOT REQUIRED** + (`ci.yml` calls the reusable workflow unpinned), so an upstream job shows **a red X that does not + block a merge**. **Census `ci / *` against a real check run whenever `.github` moves**, then require + it or write down why not. +- **`no-internal-refs` and `no-emdash` are bare JOB IDS**, not ` / `. **Renaming the job + silently detaches the required check**: rename the job and the ruleset together, or neither. +- **A required job gates all of its steps**, so splitting a step out of `ci / verify` into its own job + silently un-requires it. **Never add a `paths:` filter** to `ci.yml`, `codeql.yml`, + `no-internal-refs.yml` or `no-emdash.yml`: none carries one, which is what stops a PR skipping a + required check. +- **Confirm a ruleset write with the `PUT` itself, never a `GET`**: an Organization-sourced ruleset + answers `200` to a `GET` and `404` to an identical-payload `PUT`. - **Never require `scorecard / analysis`, `fuzz`, or `release / release`**: none runs on - `pull_request`, so each would strand every PR pending forever. **Never require the Advanced Security + `pull_request`, so each strands every PR pending forever. **Never require the Advanced Security `CodeQL` check** (app id `57789`): it reports **alert state**, not whether the analysis ran. - **▶ Scope of the claim: a ruleset makes a red check BLOCK a merge. It does not make the check - correct, and nothing inside this repository can observe its own ruleset.** Delete it and this suite - stays green while this section keeps asserting protection. Verify the only way that works: - `gh api repos/cosyte/cli/rulesets`. -- Recorded **unproven** rather than fine: no fork PR has ever run here, so neither the - first-time-contributor gate nor whether `codeql / analyze` can report on a fork token has been - observed. + correct, and nothing inside this repository can observe its own ruleset.** Verify the only way that + works: `gh api repos/cosyte/cli/rulesets`. Recorded **unproven** rather than fine: no fork PR has + ever run here, so neither the first-time-contributor gate nor whether `codeql / analyze` can report + on a fork token has been observed. ## Engineering Guardrails @@ -342,16 +343,14 @@ return 0`, so no `--profile`, `--ignore-rules` or config setting reaches that ea - **`--profile node16` is load-bearing and is forwarded, never reinterpreted.** Without it `@cosyte/cli/mcp` fails `node10` resolution. - **Only a TOTAL loss of declarations is the false green; a PARTIAL one `attw` catches itself**, so - the preflight must report both outcomes and **must not assert the exit 0**. Six packed-but-undeclared - declarations decide which silence you get, and the obvious two-line version of this is false: a - refuter falsified a first draft of it in one run. **Re-measure before you shorten it.** + the preflight must report both outcomes and **must not assert the exit 0**. The obvious two-line + version of this is false: a refuter falsified a draft in one run. **Re-measure before shortening.** - **The post-check reads a string, so what would hide that string is refused**: `--quiet`, `-q`, `--format`, `-f`, `--config-path`, and a `.attw.json` setting `quiet` or `format`. **Say "exact argv - token" of the ARGV refusal, never "wholesale"** (the stronger wording was live and was refuted); the - `.attw.json` refusal **is** wholesale, and the two messages differ on purpose. + token" of the ARGV refusal, never "wholesale"** (the stronger wording was refuted); the `.attw.json` + refusal **is** wholesale, and the two messages differ on purpose. - **Two holes are disclosed and deliberately left open**: clustered short forms `-fjson` / `-Pf json`, - and a declared path not starting with `.`. Left open on purpose; the reasoning is in the linked - section. + and a declared path not starting with `.`. The reasoning is in the linked section. - `test/scripts/attw-gate.test.ts` pins both nets, the upstream exit 0 itself, a negative control, and that a real `attw` failure still fails with `attw`'s own status. diff --git a/documentation/agent-notes.md b/documentation/agent-notes.md index afb4100..314b6c9 100644 --- a/documentation/agent-notes.md +++ b/documentation/agent-notes.md @@ -21,6 +21,200 @@ narrative; keep the cursor, the rule, and every trap."_ --- +## The two-file contract gate + +The split above made a link load-bearing. A rule in `CLAUDE.md` now reads "never do X. Why:" followed +by an anchor into this file, so if that anchor stops resolving the reader is left with an imperative +and no grounding, which is the "prose no test can check" shape this repository's own text warns +about. Three things could break it silently and none of them had a check: the narrative file stops +existing, a section is emptied down to its heading, or an anchor is edited on one side of the pair +and not the other. `scripts/check-agent-notes.ts` checks those three, on this tree, and nothing else. + +It lives in the **test suite** (`test/scripts/agent-notes.test.ts`) rather than in a fourth workflow. +That is deliberate: it therefore rides the required `ci / verify` contexts and blocks a merge, and it +rides `prepublishOnly`, which in this package is a real pre-publish gate rather than an advisory one. +A gate that catches a defect and then cannot block the merge that reintroduces it is documentation. + +### Why the matcher is the part that never ports + +**Counting first was the whole job, and skipping it has produced a false green twice in this +ecosystem.** Two spellings of a pointer are live across the cosyte repos, and which one dominates is +a property of the tree rather than of the convention: + +- **QUALIFIED**, `agent-notes.md#`, optionally prefixed with a path; and +- **BARE**, an inline code span holding a `#` and an anchor and nothing else. + +A qualified-only matcher dropped into `ncpdp` would have printed `all resolving` while covering 3 of +that tree's 38 pointers, because its dominant spelling is the bare one (35 against 3). In +`terminology` the split measured 42 bare against ZERO qualified, so the same matcher would have found +nothing at all and still exited 0. **A gate that clears a corpus it never opened is the single most +repeated defect in this class.** + +**Measured on this tree before the matcher was written: the qualified form is the only live one.** +Every pointer at this file is written `documentation/agent-notes.md#`, all of them in +`CLAUDE.md`, and there is no bare pointer anywhere. The bare-shaped spans that do exist in the pair +are pull-request references (`#` followed by digits, which is what GitHub renders them as) plus spans +that are not anchor-shaped at all, such as a quoted shebang and a quoted heading. So a bare matcher +here would have a denominator of zero, and the ecosystem rule that **a zero from either form refuses** +would then refuse on a healthy tree forever. That is why this gate matches the qualified form only. + +**No count from that paragraph is load-bearing, deliberately.** The OK line prints every figure on +every run, because it measures rather than remembers, and a figure written into a document goes stale +on the next commit with nobody touching it. + +### The bare census, which keeps the single-form scope a measurement + +Matching one form is safe exactly as long as the other stays absent, and an assumption nothing +re-checks is how a matcher silently stops covering its corpus. So the absence is observed on every +run. In **every opened file**, every backticked span of the shape `#` is enumerated. A +span whose anchor is **all digits** is a pull-request reference, not a pointer: it is counted and +reported on the OK line rather than dropped in silence. **Any other bare span refuses the run at exit +2.** + +That is a refusal rather than a violation on purpose. The tree has not necessarily broken, but the +evidence the matcher's scope was derived from has, so "all resolving" would be a claim about a corpus +the gate no longer covers. **The remedy is to re-derive the matcher, never to delete the span.** + +**The route to the whole-corpus scope is worth recording, because the first draft got it wrong and a +refuter caught it.** That draft scoped the census to the pair and justified it by claiming the bare +shape is ambiguous elsewhere on this tree, naming `CHANGELOG.md`'s pull-request references, quoted +shebangs in `tsup.config.ts` and `docs-content/`, and a quoted comment marker in +`scripts/phi-allow-list.txt`. **That justification was false**, and running the census tree-wide is +what proved it: not one of those files can refuse. A shebang, a `# synthetic` marker and a lone `#` +do not match the pattern at all (a space, a `!`, a `/` and an empty run are all outside the anchor +class), and `CHANGELOG.md`'s spans are digits-only, which the census classifies as references by +design. + +**The only files that could refuse were the gate's own source and its own test**, because both wrote +bare spans out literally while illustrating the rule. So the narrow scope was not protecting innocent +content: it was a **self-exemption for the gate's own files**, which is the one place a genuinely +broken pointer would hide, and this gate's own `CONTRACT_BASENAME` note already forbids exactly that. +**The fix was the fixture and not the exemption.** Every bare sample in both files is now assembled +from parts, as the qualified samples always were, and the census reaches every opened file. That also +closes the real hole a pair-scoped census leaves: **a bare pointer written into a third file would +have been covered by neither the matcher nor the census.** + +### What was re-derived here rather than inherited + +A sibling's exit-code table does not port, and neither does its corpus handling. Each of these was +decided against this repository: + +- **The exit codes come from `scripts/phi-scan.ts`**, this repository's own scanner: `0` clean, `1` a + finding a human acts on, `2` a refusal or a bad invocation. That split was itself paid for here, + when an unreadable allow-list, override log and scan root each threw past every handler and exited + `1`, which reads as a finding and is worse than a crash. +- **An UNMERGED path refuses.** `phi-scan` leaves the unmerged status unenumerated in its **staged** + route and records why: `git commit` refuses an unmerged index at exit 128, so nothing unmerged can + reach a commit through that gate. **That reasoning does not transfer**, because this gate has no + staged route; it runs from the test suite and from CI over whatever tree it is handed, and a + half-merged one is exactly where a working-tree copy is conflict markers. +- **The NUL skip is required here and is not removable by tidying.** This tree tracks real NUL-bearing + files: the vendored `@cosyte/*` tarballs, which are compressed streams, and a synthetic DICOM + fixture. None can be read as markdown and none can be edited to clear a red, and a gate whose red + has no fix is a gate someone disables. It is a **disclosed miss, not a pass**, and the tell is the + skipped count on the OK line. +- **A correction, kept visible because it was easy to write and was wrong.** A draft of the script's + disclosure claimed `scripts/check-no-emdash.sh` partitions on what git calls binary while this gate + partitions on a NUL byte, so the two sets differ. **That is false**, and reading that gate's own OK + line ("excluded by the NUL rule") is what caught it: **both gates key on an actual NUL byte and + exclude the same set.** The set that genuinely differs is **git's, which is wider**: + `test/__fixtures__/adt-a01.hl7` and `test/__fixtures__/minimal.astm` are `i/-text` to git (an HL7 v2 + or ASTM terminator is a lone `CR`) yet hold no NUL, so **both gates open them**. That gap is why + neither gate may be "simplified" to `grep -I` or to `git ls-files --eol`: either would drop a + readable text file from the sweep with no tell. + +### Existence is not observation, so the OK line reconciles + +The likeliest failure of a gate like this is not a wrong answer, it is a right-looking answer over a +corpus it never opened, and **this repository has already shipped exactly that**: `phi-scan` carried a +declared scan root the walk never observed and printed `OK, no hits` at exit 0 over it. + +**The property that prevents it here is structural rather than arithmetic: there is no declared root +to be wrong about.** The corpus is whatever `git ls-files` returns, every path in it is opened or +refused, and the only silent skip is the NUL one, which is counted and named. The printed arithmetic +is the weaker half and is not the remedy: it reconciles **sets of paths** rather than a pair of +counters, because a counter incremented once per iteration can only sum to the number of iterations, +so comparing that sum to the corpus size is a tautology dressed as a check. + +**Zero qualified pointers refuses**, because in this repository that cannot be a clean tree: +`CLAUDE.md` opens by linking this file and cites it by anchor throughout. + +### The positive controls, because a gate is believed only after it has been watched to fail + +Every one of these was run against a clone of the **real** tree, not a fixture, before the gate was +believed: + +| what was broken | result | +| --------------------------------------------------- | -------------------------------------- | +| one real anchor in `CLAUDE.md` misspelled | exit 1, naming the file and line | +| a real section emptied down to its heading | exit 1, naming the section and its slug | +| this file deleted | exit 1, the pair plus every pointer | +| a genuine bare pointer added (one that RESOLVES) | exit 2, "re-derive the matcher" | +| the matcher neutered so it matches nothing | exit 2, self-test refusal | +| every qualified pointer rewritten so none is matched | exit 2, "ZERO qualified pointers" | + +The last two are the ones that matter most: they are the `terminology` scenario, and a gate that went +green on either of them would be worthless while looking healthy. + +### Three claims this gate made and had to withdraw + +All three were written into the first draft, all three were refuted, and each is recorded because the +wrong version is the easy one to write again. + +- **"A rename" was in the opening promise, and the gate does not catch one.** Both halves match on + **basename** (which is deliberate: it lets a path-qualified, a `./`-relative and a bare pointer all + reach the same target), so the **directory is never compared**. Measured: moving the narrative file + to `docs/agent-notes.md` while the pointers keep their old prefix exits **0**, "all resolving", + while every rendered link 404s on GitHub. **The promise was narrowed and the miss disclosed; the + guard was not grown.** It now reads "stops being tracked at all". +- **A heading inside an HTML comment mints a phantom anchor**, and the first draft's disclosed-miss + list omitted it. `` are not tracked as a block, so a commented-out `## Section` is + counted here and renders no anchor on GitHub: a pointer at it passes green and resolves to nothing. + Now disclosed and pinned. + - **The correction inside the correction, which is the one worth reading.** The first fix for this + said "**every** sibling copy discloses it; this one forgot". That was taken from a review's + phrasing rather than from the tree, and it is **false**. Measured across this checkout: `ncpdp`, + `terminology` and `astm` disclose it; **`mllp`, `ccda`, `transform` and `docs` do not, anywhere**. + `mllp` decides it, because this gate's disclosed-miss block is transcribed from `mllp`'s shape and + that list stops at (ix) as well, so the shared ancestor never carried the entry and "this one + forgot" is backwards. The umbrella's `documentation/conventions.md` asserts the universal ("in + every copy including `ccda`'s") and the repos do not bear it out. **That is the umbrella's to + reconcile.** Repeating an unmeasured universal is exactly the overclaim this gate's banner + refuses, so it cost a second refuter pass to notice that a fix for an overclaim had shipped a new + one. + - **And a third pass to notice that the fix for THAT one did it again.** The corrected text went on + to say "four sibling repos are owed this + disclosure", counting the four that do not disclose it. **Wrong, and unmeasured in the same way: + only three are owed it.** `mllp`, `ccda` and `transform` derive an anchor by SLUGGING a heading, + so a commented-out heading mints a phantom anchor there and the disclosure applies. **`docs` does + not slug at all**: it resolves pointers against explicit `` anchors, so the + failure mode cannot arise there and writing the disclosure into it would name a failure that + repo cannot have. That is the trap `mllp`'s own miss (vi) states in one line: _a disclosure that + names the wrong failure mode sends the next reader hunting something that cannot happen._ **The + lesson is not "count more carefully". It is that a claim about another repository is not + checkable from inside this one, so either measure it against that repository's source or do not + make it.** +- **A count of sibling repos was written down and was already wrong.** The draft said seven repos + carry no `agent-notes.md`; it was eight, and the set moves whenever a submodule is added. The rule + is **keep the list or drop the number**, so the number was dropped, including from the stderr a + human reads. The same file, forty lines higher, already warned that a figure written into a comment + goes stale with nobody touching it. + +### What no mechanical check here can see + +The heading, anchor and body checks all verify that prose **moved**. **None of them notices a trap +that reached this file with no imperative following it into `CLAUDE.md`**, and the exposed class is +the trap phrased as a deliberate omission ("is deliberately left alone", "is never the default"), +which carries no identifier to grep for. Enumerate those by hand. The same limit applies to claims +about other documents: an anchor resolving proves the target exists, never that it says what the +sentence promises. A section with a body is not a section with the **right** body. + +This file is also outside every `format` glob in `package.json` (which reaches `src/`, `test/`, +`scripts/` and the top level only), so Prettier does not renormalise the list indentation the +pointers depend on. **Do not add `documentation/` to a formatter glob.** + +--- + ## Status The per-incident sections below were the bulk of `CLAUDE.md`'s `## Status` list. Each is reproduced diff --git a/package.json b/package.json index ab7f206..97b4583 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "changeset": "changeset", "typecheck": "tsc --noEmit", "phi-scan": "tsx scripts/phi-scan.ts", + "check:agent-notes": "tsx scripts/check-agent-notes.ts", "check:no-emdash": "bash scripts/check-no-emdash.sh", "check:no-internal-refs": "bash scripts/check-no-internal-refs.sh", "lint": "eslint \"src/**/*.ts\" \"scripts/**/*.ts\" \"test/**/*.ts\" --max-warnings=0 --no-error-on-unmatched-pattern", diff --git a/scripts/check-agent-notes.ts b/scripts/check-agent-notes.ts new file mode 100644 index 0000000..44b7195 --- /dev/null +++ b/scripts/check-agent-notes.ts @@ -0,0 +1,1245 @@ +#!/usr/bin/env tsx +/** + * `@cosyte/cli` two-file-contract gate. + * + * WHAT THIS REPO PROMISES, WHICH IS THE ONLY THING THIS GATE ASSERTS. On 2026-08-04 this + * repo's guidance was split in two: `CLAUDE.md` became a cursor plus rules plus traps, and + * `documentation/agent-notes.md` took the narrative, with `CLAUDE.md` pointing into it by + * anchor. Nothing was deleted; the reasoning moved behind a link. That makes the link + * load-bearing in a way it was not before. A rule in `CLAUDE.md` now reads "never do X. Why:" + * followed by an anchor into the narrative file, and if that anchor does not exist the reader + * gets an imperative with no grounding. Three things can break silently and none had a check: + * + * 1. the narrative file stops being tracked at all (a bad merge, a `git rm`); + * 2. a section is emptied down to its heading, so a pointer resolves to nothing; and + * 3. an anchor is edited on one side of the pair and not the other, so a pointer dangles. + * + * This gate checks those three, on this tree, and nothing else. + * + * ▶ SAY "STOPS BEING TRACKED", NEVER "A RENAME". An earlier draft of the list above opened + * "the narrative file stops existing (A RENAME, a bad merge, a `git rm`)" and that was an + * OVERCLAIM, measured: a tree that moves the file to `docs/agent-notes.md` while `CLAUDE.md` + * still points at `documentation/agent-notes.md#...` exits 0, "all resolving", even though + * every one of those links now 404s on GitHub. Both halves of the gate match on BASENAME + * (`contractPaths` and `pointerPattern`), which is deliberate and is what lets a qualified, a + * `./`-relative and a bare-path pointer all reach the same target, but the cost is that the + * DIRECTORY is never compared. That is disclosed as miss (xi) rather than closed, because + * closing it means a path-agreement check, and the promise is the thing that was wrong here. + * + * --------------------------------------------------------------------------- + * IT IS NAMED FOR WHAT IT CHECKS AND IS DELIBERATELY NOT A UNIVERSAL, AND THAT IS THE MOST + * IMPORTANT LINE IN THIS FILE. + * + * The two-file split was applied across the cosyte tree, so the tempting framing is "every + * repo has a `CLAUDE.md` and a `documentation/agent-notes.md`, and this gate enforces the + * contract". IT IS NOT. Re-measured across the umbrella's own checkout, a whole GROUP of + * cosyte repos carry NO `documentation/agent-notes.md` at all, `config` and `hl7` among them. + * So the ecosystem-wide contract is either not universal or is violated in every one of those + * places, and a gate written as though it were universal would be asserting something they + * disprove. That is an OVERCLAIM, and an + * overclaiming guard is worse than a narrow one: it invites a reader to trust a promise the + * tree does not keep, and the first repo that trips it deletes the gate instead of fixing + * anything. For those repos the honest outcome is a WRITTEN EXEMPTION, not an invented file. + * + * So: this gate asserts `@cosyte/cli`'s contract. This repo HAS an `agent-notes.md`, its + * `CLAUDE.md` points into it by anchor throughout, and its `CLAUDE.md` sits AT its byte + * budget with the narrative already relocated, so here the pointer relationship is real and + * paid for. Whether every OTHER repo owes the same thing is a question for whoever owns the + * convention, and it is not answered by a script inside one package. DO NOT WIDEN THIS FILE + * TO CLAIM IT. + * + * --------------------------------------------------------------------------- + * THE MATCHER IS THE PART THAT DOES NOT PORT, AND COUNTING FIRST WAS THE WHOLE JOB. + * + * Two spellings of a pointer are live across this ecosystem and which one dominates is a + * property of the tree, not of the convention: + * + * * QUALIFIED, `agent-notes.md#`, optionally path-prefixed; and + * * BARE, an inline code span holding a `#` and an anchor and nothing else. + * + * Porting a sibling's matcher without re-counting has produced a false green twice. A + * qualified-only matcher dropped into `ncpdp` would have printed `all resolving` while + * covering 3 of that tree's 38 pointers, because its dominant spelling is the bare one (35 + * against 3). In `terminology` the split measured 42 bare against ZERO qualified, so the same + * matcher would have found NOTHING AT ALL and still exited 0. + * + * MEASURED HERE, ON THIS TREE, BEFORE THIS MATCHER WAS WRITTEN: the qualified form is the + * only live one. Every pointer at the narrative file in this repository is written + * `documentation/agent-notes.md#`, and there is no bare pointer anywhere. The + * bare-shaped spans that DO exist anywhere on it are pull-request references (`#` followed by + * digits, which is what GitHub renders them as) plus spans that are not anchor-shaped at all, + * such as a quoted shebang and a quoted heading. So a bare MATCHER here would have a + * denominator of zero, and a per-form refusal keyed on it would refuse on a healthy tree + * forever. That is why this file matches the qualified form only. + * + * DO NOT TRUST THE PARAGRAPH ABOVE FOR THE TOTALS. A figure written into a comment goes stale + * on the next commit with nobody touching it, and this ecosystem has burned four separate + * slices on exactly that. THE OK LINE PRINTS EVERY COUNT ON EVERY RUN, because it measures + * rather than remembers. + * + * --------------------------------------------------------------------------- + * THE BARE CENSUS, WHICH IS HOW "THE BARE FORM IS DEAD HERE" STAYS A MEASUREMENT. + * + * Deciding to match one form only is safe exactly as long as the other form stays absent, and + * an assumption nothing re-checks is how a matcher silently stops covering its corpus. So the + * absence is not assumed, it is OBSERVED on every run: + * + * * In EVERY OPENED FILE, every backticked span of the shape `#` is + * enumerated. Not the pair alone: a bare pointer written into a third file would be + * covered by neither the matcher nor a pair-scoped census, which is precisely the + * uncovered corner this census exists to remove. + * * A span whose anchor is ALL DIGITS is a pull-request or issue reference, not a pointer. + * It is COUNTED AND REPORTED on the OK line rather than dropped in silence, because a + * matcher that quietly declines to look at some of its own hits is the defect this whole + * section exists to prevent. The disclosed cost is that a heading whose text is only + * digits would be unreachable through the bare form; the qualified form still reaches it, + * and no such heading exists here. + * * ANY OTHER bare span is a SUSPECTED BARE POINTER and REFUSES THE RUN (exit 2). It is a + * refusal and not a violation on purpose: the tree has not necessarily broken, but the + * evidence this matcher's scope was derived from has, so "all resolving" would be a claim + * about a corpus this gate no longer covers. The fix is to re-derive the matcher against + * the new spelling, exactly as this one was derived, NOT to delete the span. + * + * THE CENSUS COVERS THE WHOLE CORPUS, AND THE ROUTE TO THAT IS WORTH RECORDING BECAUSE A + * FIRST DRAFT GOT IT WRONG. That draft scoped the census to the pair and justified it by + * claiming the bare shape is ambiguous elsewhere on this tree, naming `CHANGELOG.md`'s + * pull-request references, quoted shebangs in `tsup.config.ts` and `docs-content/`, and a + * quoted comment marker in `scripts/phi-allow-list.txt`. THAT JUSTIFICATION WAS FALSE, and + * running the census tree-wide is what proved it: not one of those files can refuse. A + * shebang, `# synthetic` and a lone `#` do not match `barePattern` at all (a space, a `!`, a + * `/` and an empty run are all outside `ANCHOR_CHARS`), and `CHANGELOG.md`'s spans are + * digits-only, which the census classifies as references by design. + * + * The only files that could refuse were THIS ONE AND ITS TEST, because both wrote bare spans + * out literally while illustrating the rule. So the narrow scope was not protecting innocent + * content; it was a SELF-EXEMPTION FOR THE GATE'S OWN SOURCE, which is the one place a + * genuinely broken pointer would hide, and the rule against that is already written below at + * `CONTRACT_BASENAME`. The fix was the fixture and not the exemption: every bare sample in + * both files is now assembled from parts, exactly as the qualified samples always were, and + * the census reaches every opened file. + * + * --------------------------------------------------------------------------- + * EXISTENCE IS NOT OBSERVATION, WHICH IS WHY THE OK LINE RECONCILES. + * + * The failure this gate is most likely to have is not a wrong answer, it is a right-looking + * answer over a corpus it never opened. That is not hypothetical in this repository: its own + * PHI scanner shipped a declared scan root the walk never observed and printed `OK, no hits` + * at exit 0 over it, which is why `scripts/phi-scan.ts` now reconciles per root against + * `git ls-files` and refuses at exit 2. A DENOMINATOR DOES NOT DETECT THAT, because a count + * counts the roots that DID exist. + * + * BE PRECISE ABOUT WHICH PART DOES THE WORK. The property that prevents the defect here is + * STRUCTURAL, not arithmetic: THERE IS NO DECLARED ROOT TO BE WRONG ABOUT. The corpus is + * whatever `git ls-files` returns, every path in it is opened or refused, and the only silent + * skip is a NUL-bearing file, which is counted and named on the OK line. + * + * THE PRINTED ARITHMETIC IS A WEAKER THING AND IS NOT THE REMEDY. It is reconciled over SETS + * of paths rather than a pair of counters, which buys something a counter cannot: a counter + * incremented once per iteration can only sum to the number of iterations, so comparing that + * sum to the corpus size is a tautology. Sets catch a path enumerated twice and a path no + * branch reached. Treat the printed sum as something a reader can check by eye, above all the + * skip count, and not as evidence the scan was complete. + * + * Further refusals exist for the same reason, each a case where "no violations" would be a + * lie rather than a result: + * * zero tracked paths (not a repo, or a `--root` pointed at an empty tree); + * * a tracked path that is missing, unreadable, a symlink, or not a regular file; + * * an UNMERGED path, which `git ls-files -s` reports three times and whose working-tree + * copy is conflict markers nobody has decided the contents of; + * * two tracked files carrying the contract basename, which makes every pointer ambiguous; + * * a suspected bare pointer, per the census above; and + * * ZERO QUALIFIED POINTERS FOUND ANYWHERE. In THIS repo that cannot be a clean tree: + * `CLAUDE.md` opens by linking the narrative file and cites it by anchor throughout. Zero + * means the matcher stopped matching, so the pointer half proved nothing. This refusal is + * grounded in what THIS repo contains and is one of the things a port must re-derive. + * + * --------------------------------------------------------------------------- + * EXIT CODES. RE-DERIVED FROM THIS REPOSITORY'S OWN `scripts/phi-scan.ts`, NOT PORTED. That + * scanner's contract is the one a reader of this repo already knows, and it was itself paid + * for: an unreadable allow-list, override log or scan root each threw past every handler and + * exited 1, which read as a finding. + * 0 the contract holds. + * 1 the contract is broken: a missing file, an empty section, or a dangling pointer. + * 2 REFUSAL, or a bad invocation. The gate could not observe what it claims to check, or + * could not honestly report on what it did observe. Never reported as clean. + * + * The split matters: exit 1 is a finding a human acts on, exit 2 is "believe nothing I said". + * Collapsing them turns a broken scanner into a list of false findings, which reads as + * actionable and is worse than a crash. + * + * --------------------------------------------------------------------------- + * DISCLOSED MISSES. Stated here rather than discovered later. + * + * WHICH OF THESE A TEST PINS IS MARKED PER ITEM. A DISCLOSURE THAT NAMES A TEST MUST NAME ONE + * THAT EXISTS. [PINNED] means a case in `test/scripts/agent-notes.test.ts` exercises it IN THE + * DIRECTION IT FAILS. [SCOPE] means it is a boundary of what this gate is for, with nothing to + * execute. + * + * (i) [PINNED] A POINTER SPLIT MID-ANCHOR ACROSS A LINE WRAP is matched only when the head + * fragment runs to the end of the line and the join with the next line resolves. The + * join is attempted ONLY after the line-pass anchor has already failed, so it can turn + * a false red into a pass but never a real red into a pass, EXCEPT where the head + * fragment is ITSELF a valid anchor and the tail is garbage, which passes green. + * Closing that needs a markdown renderer, not a bigger regex. + * (ii) [PINNED] A PERCENT-ENCODED OR HTML-ENTITY ANCHOR IS NOT DECODED. `#a%20b` matches + * only up to the `%`, so it is checked as the anchor `a` alone and reds. None exists here. + * (iii) [PINNED] A POINTER AT ANY OTHER FILE'S ANCHOR IS OUT OF SCOPE, including + * `CLAUDE.md#...`. This gate is about the narrative file. A general markdown link + * checker is a different tool with a different failure surface, and writing half of one + * here would be the overclaim this file's second section refuses. + * (iv) [PINNED] A POINTER INSIDE A FENCED CODE BLOCK IS TREATED EXACTLY LIKE PROSE. + * Deliberate: a reader follows it either way. Headings are the opposite, see (vi). + * (v) [PINNED] A NUL-BEARING FILE IS SKIPPED WHOLE, SO ITS POINTERS ARE NEVER READ. This is + * the one that can print `all resolving` over a dangling pointer, and it is a disclosed + * miss rather than a pass. THE TELL IS THE SKIPPED COUNT ON THE OK LINE. + * THE EXCLUSION IS REQUIRED HERE AND IS NOT REMOVABLE BY TIDYING. This tree tracks + * real NUL-bearing files: the vendored `@cosyte/*` tarballs, which are compressed + * streams, and a synthetic DICOM fixture. None can be read as markdown and none can be + * edited to clear a red, and a gate whose red has no fix is a gate someone disables. + * THE PARTITION IS AN ACTUAL NUL BYTE, WHICH IS NARROWER THAN WHAT GIT CALLS BINARY, + * AND GETTING THAT ROUND THE WRONG WAY IS HOW A FILE LEAVES THE SWEEP IN SILENCE. A + * first draft of this note claimed `scripts/check-no-emdash.sh` partitions on git's + * classification while this gate partitions on NUL, so the two sets differ. THAT WAS + * FALSE AND WAS CAUGHT BY READING THAT GATE'S OWN OK LINE, which says "excluded by the + * NUL rule": BOTH gates partition on NUL and both exclude the same set today. The set + * that genuinely differs is GIT'S, which is WIDER: `test/__fixtures__/adt-a01.hl7` and + * `test/__fixtures__/minimal.astm` are `i/-text` to git (an HL7 v2 or ASTM terminator + * is a lone `CR`) yet hold no NUL, so BOTH gates open them. That gap is exactly why + * neither gate may be "simplified" to `grep -I` or to `git ls-files --eol`: either + * would drop a readable text file from the sweep with no tell. Each gate prints its own + * count on its own OK line; neither number is a copy of the other, and this comment + * deliberately quotes neither. + * (vi) [PINNED] AN ATX HEADING INSIDE A FENCED CODE BLOCK IS NOT AN ANCHOR, and the fence + * tracker is why. Without it a `#` comment in a shell sample mints a phantom anchor and + * masks the dangling pointer this gate exists to catch. The tracker handles ``` and ~~~ + * fences of three or more characters. It does NOT track an INDENTED code block as a + * block, but that is not reachable as a phantom anchor: `ATX_RE` bounds indentation at + * three spaces, so a four-space-indented `#` line is not a heading here either, which + * is what CommonMark does anyway. Both halves are asserted. + * (vi-b)[PINNED] AN ATX HEADING INSIDE AN HTML COMMENT IS COUNTED AS A HEADING HERE AND + * RENDERS NO ANCHOR ON GITHUB. `` are not tracked as a block, so a + * commented-out `## Section` mints a phantom anchor and a pointer at it passes green + * while resolving to nothing. This is the SAME false-green direction as (vi) and is the + * one the fence tracker does close, which is why leaving it undisclosed was worth fixing. + * Reproduced end to end before it was written down. + * ▶ DO NOT WRITE "EVERY SIBLING COPY DISCLOSES THIS". A draft of this entry did, taking + * the phrasing from a review rather than from the tree, and it is FALSE. Measured across + * this checkout: `ncpdp`, `terminology` and `astm` disclose it; `mllp`, `ccda`, + * `transform` and `docs` do not, anywhere. `mllp` is the decisive one, because the + * disclosed-miss block in this file is transcribed from its shape and ITS list stops at + * (ix) too, so the shared ancestor never carried this entry. The umbrella's + * `documentation/conventions.md` asserts the universal ("in every copy including + * `ccda`'s"), and the repos do not bear it out; that is the umbrella's to reconcile, and + * adopting the claim here would just be the overclaim this banner exists to refuse. + * (vii) [PINNED] THE SLUGGER IS A TRANSCRIPTION OF github-slugger, NOT THE MODULE, so that + * this gate adds no runtime dependency to a package whose dependency count is capped. + * It was verified against github-slugger@2.0.0 by running the real module over THIS + * tree's every real heading plus the shapes below, not by reading a sibling's table. + * `SLUG_CASES` carries the rows that would diverge from the obvious implementation. + * ONE THING THE MODULE CANNOT BE FED DIRECTLY, AND GETTING THIS BACKWARDS IS A FALSE + * RED: github-slugger slugs the RENDERED text of a heading, so a heading carrying a + * markdown link must have the link's URL removed before slugging. Feeding the raw + * source `A [linked](https://example.test/x) heading` to the module yields + * `a-linkedhttpsexampletestx-heading`, while GitHub's real anchor is `a-linked-heading`. + * `stripInline` is that rendering step. No heading here carries a link today, so the + * function is inert on this corpus and is kept as a guard for the next one. + * UNTESTED AND NOT CLAIMED: combining marks, CJK, and connector punctuation other than + * `_`. A heading needing any of those is the signal to measure it, not to assume. + * (viii)[SCOPE] A SECTION WITH A BODY IS NOT A SECTION WITH THE RIGHT BODY. This gate proves + * a pointer lands somewhere non-empty. It cannot prove the prose there grounds the rule + * that cited it. That half stays human, and saying so is the point of writing it down. + * (ix) [SCOPE] IT DOES NOT CHECK ANY BYTE BUDGET. `CLAUDE.md`'s ceiling is enforced by the + * meta-repo's `.claude/hooks/doc-budget.mjs`, which holds the budget table; a script + * inside this package cannot see it and must not keep a second copy of a number. + * (x) [SCOPE] IT DOES NOT NOTICE A TRAP THAT REACHED THE ARCHIVE WITH NO IMPERATIVE + * FOLLOWING IT INTO `CLAUDE.md`. Heading, anchor and body checks all verify prose + * MOVED. The exposed class is a trap phrased as a deliberate omission, which carries no + * identifier to grep for. Enumerate those by hand. + * (xi) [PINNED] THE NARRATIVE FILE'S DIRECTORY IS NEVER COMPARED, ONLY ITS BASENAME. Moving + * it to another directory while the pointers keep their old path prefix exits 0 with + * every rendered link broken. See the note under the opening list for the measurement + * and for why the basename match is deliberate. THE PROMISE WAS NARROWED RATHER THAN + * THE GUARD GROWN, and the miss is asserted green so that closing it later is a + * deliberate act. + * (xii) [PINNED] THE TREE-WIDE CENSUS REFUSES ON AN ANCHOR-SHAPED BACKTICKED SPAN THAT WAS + * NEVER A POINTER, AND THAT IS THE PRICE OF WIDENING IT. A hex colour or a CSS id + * written inside an inline code span with a leading hash refuses the run at exit 2 in + * any tracked file, and the refusal text says to re-derive the matcher, which is the + * wrong advice for a colour because there is no new spelling. BUT ONLY WHEN THE ANCHOR IS + * NOT ALL DECIMAL DIGITS, and two drafts of this entry got that boundary wrong in + * opposite directions. The rule is exactly `DIGITS_ONLY`: an all-decimal anchor is + * classified as a pull-request reference and exits 0, so a hex colour of decimal digits + * alone does NOT refuse, while one carrying a letter does. Do not restate that as "only + * when it carries a letter" either: an id of digits with an underscore or a hyphen + * carries no letter and still refuses. Every wrong version of this boundary is still + * fail-closed, so none of them was a false green. (Both examples are DESCRIBED rather + * than written out, for the same reason every sample in this file is assembled from + * parts: writing one refuses this gate on its own source, which is precisely what that + * same draft did.) DISCLOSED RATHER THAN NARROWED, + * deliberately, and the DIRECTION is what makes that defensible: it refuses rather than + * reporting a false green. The pair-scoped census this replaced avoided this cost only + * by carrying a much worse one, since a bare pointer in any third file was then seen by + * neither the matcher nor the census. If such a span ever lands legitimately, the answer + * is a per-path carve-out argued on its own merits, NOT a retreat to the pair, and NOT + * deleting the span if it turns out to be a real pointer. + * + * Run it locally with `pnpm check:agent-notes`. `pnpm test` runs it against this tree too + * (`test/scripts/agent-notes.test.ts`), which is what puts it on the meta-repo's + * `scripts/verify.sh cli` ladder without that ladder needing to name it, and what puts it + * inside `prepublishOnly`. + */ + +import { execFileSync } from "node:child_process"; +import { closeSync, constants, fstatSync, openSync, readFileSync } from "node:fs"; +import { isAbsolute, join, resolve } from "node:path"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface Heading { + /** 1-based line number of the heading text (the underline line, for setext). */ + readonly line: number; + readonly text: string; + readonly slug: string; + /** 1-based line the section body may start on. */ + readonly bodyFrom: number; + /** + * Depth: 1 to 6 for `#` through `######`, and for setext 1 for `===` and 2 for `---`. + * Carried ONLY so `emptySections` can tell a CONTAINER from an emptied leaf; nothing else + * reads it, and it is deliberately not part of the slug, which depends on the text alone. + */ + readonly level: number; +} + +interface Violation { + readonly where: string; + readonly what: string; +} + +/** A refusal: the gate could not observe what it claims to check. Always exit 2. */ +class RefusalError extends Error {} + +/** A bad invocation. Also exit 2: the run proves nothing. */ +class InvocationError extends Error {} + +// --------------------------------------------------------------------------- +// The contract file, named once +// --------------------------------------------------------------------------- + +/** + * The basename this gate is about. Matched on BASENAME rather than on the full path, so a + * pointer qualified with `documentation/`, one prefixed `./`, and a bare one all reach the + * same target. Exactly one tracked file may carry this name: two would make every pointer + * ambiguous, and the gate refuses rather than guessing. + * + * NOTE FOR ANYONE EDITING THE PROSE IN THIS FILE OR IN `test/scripts/agent-notes.test.ts`: + * this gate scans EVERY tracked text file and carves out no exemption for its own source or + * its own tests, so a literally-written pointer here is a pointer into this repo's narrative + * file and is checked as one. That is deliberate. An exemption for the gate's own files is + * precisely where a genuinely broken pointer would hide, and this repository's PHI scanner has + * already paid for a blanket exemption once. Sample pointers are therefore assembled from a + * constant rather than written out, in both files. + */ +const CONTRACT_BASENAME = "agent-notes.md"; + +/** The cursor half of the pair. Its absence is a contract violation, not a refusal. */ +const CURSOR_PATH = "CLAUDE.md"; + +// --------------------------------------------------------------------------- +// Slugging: a transcription of github-slugger, pinned by SLUG_CASES below +// --------------------------------------------------------------------------- + +/** + * Strip the one inline construct that changes a slug: a markdown link, whose URL must not + * reach the slug while its text must. This is the RENDERING STEP, and it is why the module + * cannot be fed raw heading source. Nothing else needs stripping, and that is measured rather + * than a shortcut: backticks, asterisks and underscores-as-emphasis are all removed (or kept) + * by the punctuation filter below in exactly the way github-slugger removes (or keeps) them. + * `_` in particular is KEPT by the filter, which is what makes an anchor minted from a heading + * naming a `CLI_*` diagnostic code resolve. + */ +function stripInline(text: string): string { + return text.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1"); +} + +/** + * github-slugger's transformation: lowercase, drop everything that is not a letter, a number, + * a space separator, a hyphen or an underscore, then replace each remaining space with a + * hyphen. + * + * THREE THINGS HERE ARE NOT COSMETIC, and all three are reachable on THIS tree. + * + * PER-SPACE, NOT PER-RUN: `a b` becomes `a--b` on GitHub and must here too. This is not a + * hypothetical shape here. The real heading "`ci / prepublish`, and the hazard that a context + * can arrive with no commit here" slugs with a DOUBLE hyphen, because the backticks and the + * slash are deleted and the two spaces around them both survive as separators. Measured + * against github-slugger@2.0.0; a per-run collapse reds the live pointer at it. + * + * NO `.trim()`. github-slugger does not trim: it deletes the disallowed character and leaves + * the space behind, so a heading led by a marker glyph slugs with a LEADING HYPHEN. A trim + * makes a pointer written without that hyphen pass this gate and resolve to nothing on GitHub, + * which is the exact shape this file exists to catch. It is reachable rather than exotic here: + * this repository's own documents lead load-bearing rules with marker glyphs throughout, so a + * glyph-led heading is the likeliest next one anybody writes. Pinned by SLUG_CASES for both + * a text marker and an emoji one. + * + * THE KEPT SPACE IS THE ASCII SPACE ALONE, NOT `\p{Zs}`. Same rule, same direction of care: a + * disallowed character is DELETED, and every space separator other than U+0020 is disallowed, + * so a heading holding U+00A0 between two letters slugs them together upstream. A `\p{Zs}` + * keep-class leaves the separator in the slug, which reds a pointer that works. + * + * A SOFTBREAK IS DELETED, NOT HYPHENATED, WHICH IS WHY THE SETEXT JOIN USES `\n`. A wrapped + * setext heading is ONE heading whose text contains a newline, and `\n` is not a space + * separator, so upstream removes it and the two halves RUN TOGETHER. + */ +function slugify(text: string): string { + return stripInline(text) + .toLowerCase() + .replace(/[^\p{L}\p{N} \-_]/gu, "") + .replace(/ /g, "-"); +} + +/** + * github-slugger's DEDUPLICATION, transcribed as the loop it actually is rather than as the + * counter it looks like. + * + * The obvious implementation (count occurrences of the base slug, suffix `-N`) is wrong on one + * input and the difference is a false red: headings `Same`, `Same`, `Same-1` yield `same`, + * `same-1`, `same-1-1` upstream, because the third heading's OWN slug collides with the second + * heading's GENERATED one and the suffix is applied again. A counter yields `same-1` twice, so + * a pointer at the anchor `same-1-1` reds against a link GitHub resolves. Measured against + * github-slugger@2.0.0 and pinned by a self-test. + */ +function makeSlugger(): (text: string) => string { + const occurrences = new Map(); + return (text: string): string => { + const original = slugify(text); + let result = original; + while (occurrences.has(result)) { + occurrences.set(original, (occurrences.get(original) ?? 0) + 1); + result = `${original}-${String(occurrences.get(original))}`; + } + occurrences.set(result, 0); + return result; + }; +} + +/** + * The anchor character class, kept in lockstep with what `slugify` can EMIT. If this were the + * ASCII `[A-Za-z0-9_-]` the obvious way, a pointer at a heading containing an accented letter + * would be truncated mid-anchor by the matcher and reported as dangling, a false red against a + * link that works. Aligning the two is what makes the matcher's silence meaningful. + */ +const ANCHOR_CHARS = "[\\p{L}\\p{N}_-]"; + +/** The qualified form, the only live spelling here. Matched in every opened file. */ +function pointerPattern(): RegExp { + return new RegExp(`agent-notes\\.md#(${ANCHOR_CHARS}+)`, "gu"); +} + +/** + * The bare shape: an inline code span holding nothing but `#` and an anchor run. This is NOT a + * matcher for a form this gate checks. It is the CENSUS that keeps "the bare form is absent + * here" a measurement instead of an assumption, and it runs over every opened file. See the + * header for why it is not scoped to the pair, and for the false justification that was. + */ +function barePattern(): RegExp { + return new RegExp(`\`#(${ANCHOR_CHARS}+)\``, "gu"); +} + +/** A bare span whose anchor is all digits is a pull-request reference, not a pointer. */ +const DIGITS_ONLY = /^\p{Nd}+$/u; + +// --------------------------------------------------------------------------- +// Heading extraction +// --------------------------------------------------------------------------- + +const FENCE_RE = /^ {0,3}(`{3,}|~{3,})/; +/** + * ATX. Up to three leading spaces (CommonMark), one to six hashes, and then EITHER whitespace + * or end of line: a hash run with no space after it is not a heading. Trailing hashes are stripped. + * + * A NAIVE `/^#{1,6} /` HAS TWO MEASURED BYPASSES, both handled here and both asserted in the + * test file: a single leading space, and a setext underline. A missed heading is a missing + * anchor and a missing anchor is a FALSE RED on a pointer that works. + */ +const ATX_RE = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?[ \t]*$/; +/** Setext: a `=` or `-` run under a non-blank paragraph line. `=` is h1, `-` is h2. */ +const SETEXT_RE = /^ {0,3}(=+|-+)[ \t]*$/; + +function stripTrailingHashes(text: string): string { + return text.replace(/[ \t]+#+[ \t]*$/, ""); +} + +/** + * Extract every heading GitHub would give an anchor, in document order, deduplicated by + * `makeSlugger`. + * + * THREE BLOCK CONSTRUCTS ARE TRACKED, each because leaving it out is a measured divergence: + * + * * FENCED CODE. An ATX line inside ``` or ~~~ is a comment in a sample, not a heading. + * Without this a shell snippet mints a phantom anchor and a dangling pointer passes, which + * is the one direction this gate must never fail in. Not hypothetical in this file's + * target: the narrative file embeds shell and `gh api` reproductions. + * * YAML FRONT MATTER. A `---` fence at the very start of the file is front matter, and its + * CLOSING `---` sits directly under a non-blank line, so a setext reader mints an anchor + * from `title: x`. + * * THE SETEXT PARAGRAPH. An underline belongs to the WHOLE paragraph above it, not to its + * last line. The lines are joined with `\n`, NOT a space, because a softbreak is DELETED + * by the slug rule rather than hyphenated. + */ +function extractHeadings(lines: readonly string[]): Heading[] { + const headings: Heading[] = []; + const slugger = makeSlugger(); + let inFence = false; + let fenceMarker = ""; + + const push = (line: number, rawText: string, bodyFrom: number, level: number): void => { + const text = rawText.trim(); + headings.push({ line, text, slug: slugger(text), bodyFrom, level }); + }; + + // Front matter, if any: a `---` on the very first line opens it and the next `---` or `...` + // closes it. Everything between is metadata, never a heading and never a pointer surface. + let start = 0; + if ((lines[0] ?? "").trimEnd() === "---") { + for (let i = 1; i < lines.length; i += 1) { + const t = (lines[i] ?? "").trimEnd(); + if (t === "---" || t === "...") { + start = i + 1; + break; + } + } + } + + for (let i = start; i < lines.length; i += 1) { + const line = lines[i] ?? ""; + + const fence = FENCE_RE.exec(line); + if (fence) { + const marker = (fence[1] ?? "")[0] ?? ""; + if (!inFence) { + inFence = true; + fenceMarker = marker; + } else if (marker === fenceMarker) { + inFence = false; + fenceMarker = ""; + } + continue; + } + if (inFence) continue; + + const atx = ATX_RE.exec(line); + if (atx) { + push(i + 1, stripTrailingHashes(atx[2] ?? ""), i + 2, (atx[1] ?? "#").length); + continue; + } + + // Setext. The underline is only a heading when it sits under a non-blank paragraph that is + // not itself a heading and not a list item. A `---` after a blank line is a thematic break. + const setext = SETEXT_RE.exec(line); + if (setext && i > start) { + const prev = lines[i - 1] ?? ""; + const prevIsText = prev.trim() !== "" && !ATX_RE.test(prev) && !/^ {0,3}[-*+>] /.test(prev); + if (prevIsText) { + // THE UNDERLINE BELONGS TO THE WHOLE PARAGRAPH, so walk back to its first line and + // join. Reading only `lines[i - 1]` slugs a wrapped heading from its last line alone, + // which is a false red on the pointer GitHub resolves. + let first = i - 1; + while (first > start) { + const above = lines[first - 1] ?? ""; + if (above.trim() === "" || ATX_RE.test(above) || /^ {0,3}[-*+>] /.test(above)) break; + first -= 1; + } + const paragraph = lines + .slice(first, i) + .map((l) => l.trim()) + .join("\n"); + // The anchor belongs to the paragraph; the body starts after the underline. + // `===` is a level-1 heading and `---` a level-2 one, exactly as CommonMark reads them. + push(first + 1, paragraph, i + 2, (setext[1] ?? "-").startsWith("=") ? 1 : 2); + } + } + } + + return headings; +} + +/** + * A section is EMPTY when nothing but blank lines separates its heading from the next heading + * or from the end of the file. That is the check the item asks for and it is deliberately the + * weak form: see disclosed miss (viii). A heading whose only body is a fence or a single word + * counts as non-empty, because judging sufficiency is not something a script can do honestly. + * + * A CONTAINER IS NOT AN EMPTIED SECTION, AND CONFLATING THEM IS A FALSE RED. A heading + * immediately followed by a DEEPER one (`## Group` then `### Sub` with no prose between) is a + * container whose body IS its subsections. A pointer at the anchor `group` resolves on GitHub and the + * reader lands on real content, so reporting it is a red against a link that works. That shape + * is live in this repository's narrative file, where a top-level heading is followed straight + * away by its first subsection. + * + * IT OPENS NO FALSE-GREEN HOLE, which is the only direction that would matter. The exemption + * moves the obligation DOWN rather than removing it: the deeper heading is still checked, so + * an emptied leaf still reds, and a container can only be exempt when something deeper exists + * to carry the body. A trailing heading has no `next` at all and is therefore never a + * container. Both directions are pinned in `test/scripts/agent-notes.test.ts`. + */ +function emptySections( + lines: readonly string[], + headings: readonly Heading[], +): { readonly empty: Heading[]; readonly containers: number } { + const empty: Heading[] = []; + let containers = 0; + for (let h = 0; h < headings.length; h += 1) { + const here = headings[h]; + if (!here) continue; + const next = headings[h + 1]; + // A container: its body is the subsections beneath it, and the obligation moves to them. + // COUNTED, NOT SILENTLY SKIPPED, because the OK line must not claim every section has a + // body when a container was never asked. + if (next && next.level > here.level) { + containers += 1; + continue; + } + const end = next ? next.line - 1 : lines.length; + let hasBody = false; + for (let i = here.bodyFrom; i <= end; i += 1) { + if ((lines[i - 1] ?? "").trim() !== "") { + hasBody = true; + break; + } + } + if (!hasBody) empty.push(here); + } + return { empty, containers }; +} + +// --------------------------------------------------------------------------- +// Self-tests. A gate is believed only after it has shown it can still see. +// --------------------------------------------------------------------------- + +/** + * Slug transcription cases. Every row is either a REAL heading from this repository's + * `agent-notes.md` or a shape a future one is likely to take here, and every row was produced + * by running github-slugger@2.0.0 rather than copied from a sibling's table. If someone + * "simplifies" `slugify`, this table reds here rather than turning every working pointer on + * the tree into a false red. + * + * DO NOT DESCRIBE THIS TABLE BY POSITION. A positional reference is a claim that goes stale on + * the next append. Each row carries its own reason where it needs one. + */ +const SLUG_CASES: ReadonlyArray = [ + // Real headings. The first is the file's own H1, and proves `@` and `/` are deleted while + // the letters either side run together. + ["@cosyte/cli: agent notes", "cosytecli-agent-notes"], + // Real, and the PER-SPACE rule in its live form: backticks and a slash are deleted and both + // surrounding spaces survive, giving a DOUBLE hyphen. A per-run collapse reds this pointer. + [ + "`ci / prepublish`, and the hazard that a context can arrive with no commit here", + "ci--prepublish-and-the-hazard-that-a-context-can-arrive-with-no-commit-here", + ], + // Real. An apostrophe is deleted with no separator left behind, and a parenthesised date + // survives as digits and hyphens. + [ + "Widening the walk to this repository's whole authored corpus (2026-08-07)", + "widening-the-walk-to-this-repositorys-whole-authored-corpus-2026-08-07", + ], + [ + "Branch protection (and the limits of this claim)", + "branch-protection-and-the-limits-of-this-claim", + ], + ["Standing disciplines (every change)", "standing-disciplines-every-change"], + ["The pre-commit PHI gate and git mv", "the-pre-commit-phi-gate-and-git-mv"], + // NO TRIM, with a leading marker. Both spellings this repository actually uses. + ["▶ The section", "-the-section"], + ["🛑 THE STOP", "-the-stop"], + // An underscore is KEPT, which is what lets a heading naming a diagnostic code resolve. + ["CLI_NOT_IMPLEMENTED and 69", "cli_not_implemented-and-69"], + ["A `code` heading with **bold**", "a-code-heading-with-bold"], + // The rendering step: the URL must not reach the slug while the link text must. + ["A [linked](https://example.test/x) heading", "a-linked-heading"], + ["A double space", "a--double--space"], + // A SOFTBREAK IS DELETED, NOT HYPHENATED. The two halves run together. This is the wrapped + // setext heading, and getting it wrong is a false green on a link that resolves to nothing. + ["The long\nsection name", "the-longsection-name"], + // Every space separator other than U+0020 is deleted too, for the same reason. Written as + // ESCAPES, not literals: a bare U+00A0 in a table is invisible to a reader and to a diff. + ["a\u00a0b", "ab"], + ["a\u2009b", "ab"], +]; + +function selfTest(): void { + for (const [text, want] of SLUG_CASES) { + const got = slugify(text); + if (got !== want) { + throw new RefusalError( + `SELF-TEST FAILED: slugify(${JSON.stringify(text)}) produced ${JSON.stringify(got)}, ` + + `expected ${JSON.stringify(want)}. The slug transcription no longer matches ` + + `github-slugger, so every anchor this gate computes is suspect and no result from ` + + `it can be believed.`, + ); + } + } + + // The heading detector must see every shape that mints an anchor and NONE of the shapes that + // do not. The second half matters most: a phantom anchor lets a dangling pointer pass, which + // is the single outcome this gate exists to prevent. Blank lines separate the blocks, because + // that is what makes each shape unambiguous markdown. Keep them. + const sample = [ + "---", + "title: front matter", + "---", + "", + "# Top", + "body", + "", + " ## Indented by two", + "body", + "", + " ### Indented by four", + "", + "Setext one", + "==========", + "body", + "", + "A wrapped setext", + "heading over two lines", + "----------------------", + "body", + "", + "#hashtag", + "", + "```sh", + "# not a heading", + "```", + "body", + ]; + const got = extractHeadings(sample).map((h) => h.slug); + const want = ["top", "indented-by-two", "setext-one", "a-wrapped-setextheading-over-two-lines"]; + if (got.length !== want.length || got.some((s, i) => s !== want[i])) { + throw new RefusalError( + `SELF-TEST FAILED: the heading detector produced [${got.join(", ")}], expected ` + + `[${want.join(", ")}]. A missed heading is a false red on a working pointer; a ` + + `phantom one lets a dangling pointer through. Refusing to report on the tree.`, + ); + } + + // Deduplication is a LOOP, not a counter: the third heading's own slug collides with the + // second heading's GENERATED one, so the suffix applies again. + const dedup = extractHeadings(["## Same", "a", "## Same", "b", "## Same-1", "c"]).map( + (h) => h.slug, + ); + const wantDedup = ["same", "same-1", "same-1-1"]; + if (dedup.length !== wantDedup.length || dedup.some((s, i) => s !== wantDedup[i])) { + throw new RefusalError( + `SELF-TEST FAILED: duplicate headings slugged as [${dedup.join(", ")}], expected ` + + `[${wantDedup.join(", ")}]. GitHub disambiguates by re-suffixing until the slug is ` + + `free, and a pointer at a repeated heading depends on it.`, + ); + } + + const flat = ["## A", "## B", "body"]; + const { empty } = emptySections(flat, extractHeadings(flat)); + if (empty.length !== 1 || empty[0]?.slug !== "a") { + throw new RefusalError( + `SELF-TEST FAILED: the empty-section detector found ${String(empty.length)} empty ` + + `section(s) in a sample with exactly one. Refusing to report on the tree.`, + ); + } + + // THE CONTAINER EXEMPTION, SELF-TESTED IN BOTH DIRECTIONS, because it is the one rule here + // that makes the gate report LESS. + const nested = ["## A", "### B"]; + const nestedResult = emptySections(nested, extractHeadings(nested)); + if ( + nestedResult.containers !== 1 || + nestedResult.empty.length !== 1 || + nestedResult.empty[0]?.slug !== "b" + ) { + throw new RefusalError( + `SELF-TEST FAILED: the container exemption found ${String(nestedResult.containers)} ` + + `container(s) and ${String(nestedResult.empty.length)} empty section(s) in a sample ` + + `holding exactly one of each. Either a container is being reported as emptied, which ` + + `is a false red, or an emptied leaf beneath one is being skipped, which is a false ` + + `green. Refusing to report on the tree.`, + ); + } + + const re = pointerPattern(); + const hits = [ + ...`see documentation/${CONTRACT_BASENAME}#a-b, ./${CONTRACT_BASENAME}#c_d.`.matchAll(re), + ].map((m) => m[1]); + if (hits.length !== 2 || hits[0] !== "a-b" || hits[1] !== "c_d") { + throw new RefusalError( + `SELF-TEST FAILED: the qualified pointer matcher found [${hits.join(", ")}] in a sample ` + + `holding exactly two pointers, one path-qualified and one relative. A matcher that ` + + `stopped matching reports a clean tree it never read.`, + ); + } + + // THE CENSUS MUST SEPARATE A REFERENCE FROM A SUSPECTED POINTER, or it either refuses on + // every pull-request number in the narrative file or never fires at all. + // + // ASSEMBLED FROM PARTS, EXACTLY AS THE QUALIFIED SAMPLE ABOVE IS, AND FOR THE SAME REASON. + // The census now runs over the WHOLE corpus, which includes this file, so a literally + // written bare span here would be a suspected bare pointer in this repository's own source + // and would refuse every run. Writing the samples out was what forced the earlier, narrower + // scope; assembling them is what let the scope widen. + const span = (anchor: string): string => `\`${"#"}${anchor}\``; + const bare = [...`${span("36")} and ${span("a-real-anchor")}`.matchAll(barePattern())].map( + (m) => m[1] ?? "", + ); + if (bare.length !== 2 || !DIGITS_ONLY.test(bare[0] ?? "") || DIGITS_ONLY.test(bare[1] ?? "")) { + throw new RefusalError( + `SELF-TEST FAILED: the bare census classified [${bare.join(", ")}] wrongly in a sample ` + + `holding one reference and one suspected pointer. The census is the only thing ` + + `keeping this gate's single-form scope a measurement rather than an assumption.`, + ); + } +} + +// --------------------------------------------------------------------------- +// The corpus: enumerate with git, account for every path +// --------------------------------------------------------------------------- + +interface Corpus { + readonly tracked: readonly string[]; + readonly gitlinks: readonly string[]; +} + +function gitCorpus(root: string): Corpus { + let raw: string; + try { + raw = execFileSync("git", ["ls-files", "-s", "-z"], { + cwd: root, + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }); + } catch (err) { + throw new RefusalError( + `could not enumerate tracked files under ${root} with \`git ls-files\`: ` + + `${err instanceof Error ? err.message : String(err)}. A gate that cannot list its ` + + `corpus has not observed it.`, + ); + } + + const tracked: string[] = []; + const gitlinks: string[] = []; + for (const record of raw.split("\0")) { + if (record === "") continue; + // ` \t` + const tab = record.indexOf("\t"); + if (tab < 0) { + throw new RefusalError( + `unparseable \`git ls-files -s\` record: ${JSON.stringify(record)}. Refusing rather ` + + `than dropping a path from the corpus silently.`, + ); + } + const mode = record.slice(0, 6); + const path = record.slice(tab + 1); + // AN UNMERGED PATH IS REFUSED, NOT COUNTED. `git ls-files -s` emits stages 1, 2 and 3 for + // a conflicted path, so the same path arrives three times. Reading the working-tree copy + // of a conflicted file means scanning conflict markers and reporting on a tree nobody has + // yet decided the contents of, and counting it three times is what would make the + // reconciliation below balance while the SET of paths did not. + // + // RE-DERIVED FOR THIS REPOSITORY RATHER THAN INHERITED. `scripts/phi-scan.ts` leaves the + // unmerged status UNENUMERATED in its staged route, and records why: `git commit` refuses + // an unmerged index outright at exit 128, so nothing unmerged can reach a commit through + // that gate. THAT REASONING DOES NOT TRANSFER HERE, because this gate has no staged route. + // It runs from the test suite and from CI on whatever tree it is handed, and a half-merged + // one is exactly the tree where a working-tree copy is conflict markers. + const stage = record.slice(tab - 1, tab); + if (stage !== "0") { + throw new RefusalError( + `tracked path is unmerged (stage ${stage}): ${path}. Resolve the conflict before ` + + `running this gate; a scan of a half-merged tree reports on nothing anyone has ` + + `decided yet.`, + ); + } + // A gitlink (mode 160000) is a submodule pointer with no bytes here to read. Counted and + // reported, never silently skipped: the OK line's arithmetic has to account for it. This + // repository tracks none today, which is why the count exists rather than an assumption. + if (mode === "160000") gitlinks.push(path); + else tracked.push(path); + } + + if (tracked.length === 0) { + throw new RefusalError( + `\`git ls-files\` under ${root} listed no readable tracked file. There is nothing here ` + + `to observe, so "the contract holds" would be a statement about an empty set. This ` + + `is the control case: a gate pointed at nothing must refuse, never report OK.`, + ); + } + + return { tracked, gitlinks }; +} + +// --------------------------------------------------------------------------- +// main +// --------------------------------------------------------------------------- + +interface Args { + readonly root: string; +} + +function parseArgs(argv: readonly string[]): Args { + let root = process.cwd(); + let i = 0; + while (i < argv.length) { + const a = argv[i]; + if (a === "--root") { + const next = argv[i + 1]; + if (next === undefined) throw new InvocationError("--root requires a directory argument"); + root = isAbsolute(next) ? next : resolve(process.cwd(), next); + i += 2; + } else if (a !== undefined && a.startsWith("--")) { + throw new InvocationError(`Unknown flag: ${a}`); + } else if (a !== undefined) { + throw new InvocationError(`Unexpected positional argument: ${a}`); + } else { + i += 1; + } + } + return { root }; +} + +/** + * ONE OPEN, THEN `fstat` AND READ THROUGH THAT SAME DESCRIPTOR, NOT `lstat`-then-read-by-path. + * + * The obvious shape (`lstatSync(abs)`, check, then `readFileSync(abs)`) is a TIME-OF-CHECK / + * TIME-OF-USE RACE, and this repository runs a required `codeql / analyze` context that flags + * it as `js/file-system-race` at high severity. The two calls resolve the path INDEPENDENTLY, + * so what was checked and what was read need not be the same object: anything that can replace + * the path between them (a concurrent `git checkout`, a rebase, an editor's atomic save, a + * hostile symlink swap) gets its bytes read under a path this gate already decided was a safe + * regular file. The symlink refusal is the one that matters, because defeating it is how bytes + * from OUTSIDE the tree get scanned and reported on as though they were tracked content. + * + * The fix is structural rather than a re-check, because a re-check is the same race again. The + * path is resolved EXACTLY ONCE, by `openSync`, and every question after that is asked of the + * resulting descriptor, which is bound to one inode for its lifetime: + * + * * `O_NOFOLLOW` makes the SYMLINK REFUSAL PART OF THE OPEN. The kernel fails with `ELOOP` + * rather than handing back a descriptor on the target, so there is no window between + * "is it a link" and "read it". + * * `O_NONBLOCK` is not decoration: opening a FIFO for reading BLOCKS until a writer + * appears, so a tracked FIFO would hang the gate forever instead of refusing it. + * * `fstatSync(fd)` asks about the OPENED OBJECT, not about a path that may since have + * moved, which is what makes the regular-file check meaningful rather than advisory. + * + * STATED LIMIT: `O_NOFOLLOW` only refuses a symlink as the FINAL path component. A symlinked + * PARENT DIRECTORY is still traversed. Closing that needs `openat2(RESOLVE_BENEATH)`, which + * Node does not expose. This repository's PHI scanner has the same boundary and discloses it + * the same way; do not restate either as closed. + * + * If `O_NOFOLLOW` is unavailable (it is not defined on Windows), the gate REFUSES rather than + * quietly dropping the symlink guarantee. A gate that cannot keep a promise says so. + */ +function readTracked(root: string, path: string): Buffer { + const abs = join(root, path); + + if (typeof constants.O_NOFOLLOW !== "number") { + throw new RefusalError( + `this platform does not provide O_NOFOLLOW, so a tracked path cannot be opened with ` + + `the symlink refusal applied atomically. Refusing rather than scanning with the ` + + `guarantee silently dropped.`, + ); + } + + let fd: number; + try { + fd = openSync(abs, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + // ELOOP is Linux's answer to O_NOFOLLOW on a symlink; some BSDs answer EMLINK. + if (code === "ELOOP" || code === "EMLINK") { + throw new RefusalError( + `tracked path is a symbolic link: ${path}. Reading through it would scan bytes from ` + + `somewhere else under this path's name. Refused by name rather than skipped, so ` + + `the reconciliation below stays honest. The link target is deliberately not ` + + `printed: a target path is itself a surface that can carry PHI, which is the rule ` + + `this repository's PHI scanner already refuses under.`, + ); + } + if (code === "ENOENT" || code === "ENOTDIR") { + throw new RefusalError( + `tracked path is missing from the working tree: ${path} ` + + `(${err instanceof Error ? err.message : String(err)}). A scan that could not open ` + + `one of its inputs has not observed the corpus it is about to report on.`, + ); + } + throw new RefusalError( + `tracked path is not readable: ${path} ` + + `(${err instanceof Error ? err.message : String(err)}).`, + ); + } + + try { + if (!fstatSync(fd).isFile()) { + throw new RefusalError( + `tracked path is not a regular file: ${path}. Refusing to report green from a scan ` + + `that skipped one of its inputs.`, + ); + } + return readFileSync(fd); + } catch (err) { + if (err instanceof RefusalError) throw err; + throw new RefusalError( + `tracked path is not readable: ${path} ` + + `(${err instanceof Error ? err.message : String(err)}).`, + ); + } finally { + closeSync(fd); + } +} + +function main(argv: readonly string[]): number { + selfTest(); + + const { root } = parseArgs(argv); + const { tracked, gitlinks } = gitCorpus(root); + + const violations: Violation[] = []; + + // ---- 1. The pair exists ------------------------------------------------ + const contractPaths = tracked.filter( + (p) => p === CONTRACT_BASENAME || p.endsWith(`/${CONTRACT_BASENAME}`), + ); + if (contractPaths.length > 1) { + throw new RefusalError( + `${String(contractPaths.length)} tracked files are named ${CONTRACT_BASENAME} ` + + `(${contractPaths.join(", ")}). Every pointer would be ambiguous, so no verdict on ` + + `them is meaningful. Refusing rather than guessing which one a pointer meant.`, + ); + } + + const cursorTracked = tracked.includes(CURSOR_PATH); + if (!cursorTracked) { + violations.push({ + where: CURSOR_PATH, + what: `the cursor half of the pair is not tracked. The contract is two files; one of them is gone.`, + }); + } + + const contractPath = contractPaths[0]; + if (contractPath === undefined) { + violations.push({ + where: `documentation/${CONTRACT_BASENAME}`, + what: + `the narrative half of the pair is not tracked. Every rule in ${CURSOR_PATH} that ` + + `cites it is now an imperative with no grounding. Restore the file or move the ` + + `narrative back; do not delete the pointers.`, + }); + } + + // ---- 2. Anchors and sections ------------------------------------------ + let anchors = new Set(); + let sectionCount = 0; + let containerCount = 0; + if (contractPath !== undefined) { + const buf = readTracked(root, contractPath); + if (buf.includes(0)) { + throw new RefusalError( + `${contractPath} contains a NUL byte, so it is not the markdown this gate parses. ` + + `Refusing rather than reporting on bytes it cannot read as text.`, + ); + } + const text = buf.toString("utf8"); + if (text.trim() === "") { + violations.push({ + where: contractPath, + what: `the narrative file is empty. Its existence is not the contract; its content is.`, + }); + } + const lines = text.split("\n"); + const headings = extractHeadings(lines); + sectionCount = headings.length; + anchors = new Set(headings.map((h) => h.slug)); + + if (headings.length === 0 && text.trim() !== "") { + throw new RefusalError( + `extracted no headings from ${contractPath}, which is ${String(lines.length)} line(s) ` + + `long and not empty. Every anchor this gate resolves comes from that extraction, so ` + + `an empty one means the extractor broke, not that the file has no sections.`, + ); + } + + const sections = emptySections(lines, headings); + containerCount = sections.containers; + for (const h of sections.empty) { + violations.push({ + where: `${contractPath}:${String(h.line)}`, + what: + `section "${h.text}" (#${h.slug}) has no body. A pointer at it resolves to nothing, ` + + `which is the same defect as a dangling anchor with a friendlier error message. ` + + `Restore the narrative; do not delete the heading to clear this.`, + }); + } + } + + // ---- 3. Every qualified pointer resolves, and the bare form stays absent ----------------- + // THE TWO SETS BELOW ARE SETS, NOT COUNTERS, AND THAT IS THE WHOLE POINT OF THE + // RECONCILIATION. A pair of counters incremented one per loop iteration can only ever sum to + // the number of iterations, so comparing that sum against the corpus size is a tautology + // dressed as a check. Sets of PATHS cannot: they catch a path enumerated twice, a path + // visited twice, and a path in the corpus that no branch ever reached. + const openedPaths = new Set(); + const skippedPaths = new Set(); + let pointerCount = 0; + const pointerFiles = new Set(); + let bareReferences = 0; + + for (const path of tracked) { + const buf = readTracked(root, path); + if (buf.includes(0)) { + // A NUL-bearing file is not markdown and cannot be edited to clear a red, so it is + // skipped. THIS IS DISCLOSED MISS (v), NOT A PASS: a pointer inside such a file is never + // read. The tell is the skipped count on the OK line. + skippedPaths.add(path); + continue; + } + openedPaths.add(path); + const text = buf.toString("utf8"); + const lines = text.split("\n"); + + // THE BARE CENSUS, OVER EVERY OPENED FILE. It runs before the early return below, because a + // bare span is not accompanied by the qualified basename and would otherwise never be seen. + // It is deliberately NOT scoped to the pair: a bare pointer in a third file would then be + // covered by neither the matcher nor the census. See the header for the measurement. + { + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i] ?? ""; + const re = barePattern(); + let bm: RegExpExecArray | null; + while ((bm = re.exec(line)) !== null) { + const anchor = bm[1] ?? ""; + if (DIGITS_ONLY.test(anchor)) { + bareReferences += 1; + continue; + } + throw new RefusalError( + `suspected BARE pointer \`#${anchor}\` at ${path}:${String(i + 1)}. This gate ` + + `matches the QUALIFIED spelling only, and that scope was derived from a ` + + `measurement of this tree in which no bare pointer existed. A bare-shaped span ` + + `that is not a digits-only pull-request reference means that measurement is ` + + `now stale, so "all resolving" would be a claim about a corpus this gate no ` + + `longer covers. RE-DERIVE THE MATCHER against the new spelling, or write the ` + + `pointer in the qualified form. Do not delete the span to clear this.`, + ); + } + } + } + + if (!text.includes(`${CONTRACT_BASENAME}#`)) continue; + + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i] ?? ""; + const re = pointerPattern(); + let m: RegExpExecArray | null; + while ((m = re.exec(line)) !== null) { + const anchor = m[1] ?? ""; + pointerCount += 1; + pointerFiles.add(path); + if (anchors.has(anchor)) continue; + + // Disclosed miss (i): the wrap join, attempted ONLY on an anchor that already failed + // to resolve and only when it ran to the end of the line. It can rescue a false red; + // it cannot manufacture a pass for a pointer the line pass already resolved. + if (m.index + m[0].length === line.length) { + const tail = (lines[i + 1] ?? "").replace(/^[ \t>*-]*/, ""); + const joined = new RegExp(`^(${ANCHOR_CHARS}+)`, "u").exec(tail); + if (joined && anchors.has(anchor + (joined[1] ?? ""))) continue; + } + + violations.push({ + where: `${path}:${String(i + 1)}`, + what: + `pointer #${anchor} does not resolve to a heading in ` + + `${contractPath ?? `documentation/${CONTRACT_BASENAME}`}. Fix the anchor or ` + + `restore the section. Deleting the pointer to clear this deletes the grounding ` + + `for the rule that cited it.`, + }); + } + } + } + + // ---- 4. Reconcile, then report ---------------------------------------- + const opened = openedPaths.size; + const skippedBinary = skippedPaths.size; + const unaccounted = tracked.filter((p) => !openedPaths.has(p) && !skippedPaths.has(p)); + if (opened + skippedBinary !== tracked.length || unaccounted.length > 0) { + throw new RefusalError( + `reconciliation failed: ${String(tracked.length)} tracked non-gitlink path(s) ` + + `enumerated, ${String(opened)} opened and ${String(skippedBinary)} skipped as binary, ` + + `${String(unaccounted.length)} reached by no branch` + + `${unaccounted.length > 0 ? ` (first: ${String(unaccounted[0])})` : ""}. Every path ` + + `must be opened or skipped for a named reason, and no path twice. A corpus that does ` + + `not reconcile means the scan is reporting on something it did not read.`, + ); + } + + if (pointerCount === 0) { + throw new RefusalError( + `found ZERO qualified pointers at ${CONTRACT_BASENAME} across ${String(opened)} opened ` + + `file(s). In this repo that is not a clean tree: ${CURSOR_PATH} opens by linking the ` + + `narrative file and cites it by anchor throughout. Zero means the matcher stopped ` + + `matching, so the pointer half of this gate observed nothing and proved nothing. ` + + `EXISTENCE IS NOT OBSERVATION, and a denominator would not have caught this either.`, + ); + } + + if (violations.length > 0) { + process.stderr.write( + `ERROR: check-agent-notes - the two-file contract is broken in this repo ` + + `(${String(violations.length)} finding(s)).\n\n`, + ); + for (const v of violations) { + process.stderr.write(` ${v.where}\n ${v.what}\n\n`); + } + process.stderr.write( + ` This gate asserts THIS repo's contract only. It says nothing about any sibling: ` + + `several cosyte repos carry no ${CONTRACT_BASENAME} at all, and ` + + `for those the honest outcome is a written exemption rather than an invented file.\n`, + ); + return 1; + } + + process.stdout.write( + // NOT "all with a body". A container is exempt and is never asked, so that phrasing became + // false the moment the exemption landed. + `check-agent-notes: OK (${contractPath ?? "?"}: ${String(sectionCount)} section(s), ` + + `${String(containerCount)} of them container(s) whose body is their subsections and the ` + + `rest with a body of their own; ${String(pointerCount)} qualified pointer(s) from ` + + `${String(pointerFiles.size)} file(s), all resolving; ${String(bareReferences)} ` + + `bare-shaped span(s) across every opened file, each a digits-only reference and none a ` + + `pointer; ` + + `${String(tracked.length)} tracked path(s) reconciled = ${String(opened)} opened + ` + + `${String(skippedBinary)} skipped as binary, plus ${String(gitlinks.length)} gitlink(s) ` + + `with no bytes here)\n`, + ); + return 0; +} + +function run(): number { + try { + return main(process.argv.slice(2)); + } catch (err) { + if (err instanceof RefusalError) { + process.stderr.write(`[check-agent-notes] refusing: ${err.message}\n`); + return 2; + } + if (err instanceof InvocationError) { + process.stderr.write(`[check-agent-notes] bad invocation: ${err.message}\n`); + return 2; + } + process.stderr.write( + `[check-agent-notes] refusing: the check failed before it could finish: ` + + `${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`, + ); + return 2; + } +} + +process.exit(run()); diff --git a/test/scripts/agent-notes.test.ts b/test/scripts/agent-notes.test.ts new file mode 100644 index 0000000..d302bd8 --- /dev/null +++ b/test/scripts/agent-notes.test.ts @@ -0,0 +1,872 @@ +/** + * Unit tests for `scripts/check-agent-notes.ts`, the two-file-contract gate. + * + * WHAT IS BEING PROVED, in the order it matters: + * + * 1. THE GATE SEES. Each of the three things it claims to check is seeded into a throwaway + * repo and shown RED, then repaired and shown GREEN in the same tree. A gate is only + * worth its exit code once it has been watched to fail. + * 2. THE GATE REFUSES RATHER THAN REPORTING CLEAN OVER A CORPUS IT NEVER OPENED. This is the + * control, and it is the whole reason the exit codes are split 1/2. A gate pointed at an + * empty tree must exit 2, never 0. So must a tree with no pointers in it at all. + * 3. THE BARE CENSUS FIRES IN BOTH DIRECTIONS. A digits-only reference must NOT refuse (or + * the gate reds on every pull-request number in the narrative file), and a genuine bare + * pointer MUST refuse (or this gate's single-form scope silently stops covering the + * corpus, which is the most repeated defect in this whole class of gate). + * 4. THE BYPASS CLASSES ARE REPRODUCED END TO END, not asserted in the abstract. + * 5. THE REAL TREE IS GREEN, run through the real script. That case is what puts this gate + * on the meta-repo's `scripts/verify.sh cli` ladder without the ladder having to name it, + * and it is what reds if a future edit breaks an anchor on either side of the pair. + * + * WHAT IS DELIBERATELY NOT PROVED HERE: that any sibling repo satisfies the same contract. + * A group of cosyte repos carry no `documentation/agent-notes.md` at all, so a + * universal assertion would be an overclaim and the honest outcome for those repos is a + * written exemption. The script's banner says so; this file does not restate the argument, it + * declines to test the universal. + * + * ASSERT THE PREMISE, NOT ONLY THE REMEDY. This repository has already sprung two vacuity + * traps in `test/scripts/phi-scan.test.ts`: a fixture whose `git merge` refused on an + * identity-less runner, so every later assertion held over an empty result, and a loop that + * asserted only the detection-OFF side. So every repair case here asserts the RED first and + * the GREEN second in the same tree, and the fixture builder below never needs a commit (and + * therefore never needs a committer identity): `git ls-files` reads the INDEX, so `git init` + * plus `git add` is the whole setup and there is no identity-dependent step to fail silently. + * + * RUNNER: `tsx`, matching `test/scripts/phi-scan.test.ts` and `pnpm check:agent-notes`. Node + * 22 is this package's floor and its native type stripping is behind a flag there, so spawning + * `node` directly on the `.ts` gate would test something the commit gate does not run. The + * cost is a cold `tsx` start per case, which this repository has measured at 3.7s under + * contention against a shared 10s default, so every describe below sets its own budget. + * + * The throwaway repos are created under the OS temp dir, never under `test/`. `test/` is a + * `phi-scan` walk root and that scanner refuses a root it did not observe, so putting churn + * there would couple two gates that have nothing to do with each other. + */ + +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const REPO_ROOT = process.cwd(); +const GATE_PATH = join(REPO_ROOT, "scripts", "check-agent-notes.ts"); +const TSX_BIN = join(REPO_ROOT, "node_modules", ".bin", "tsx"); + +/** Each case spawns `tsx` cold. Measured here at 3.7s under contention; budget generously. */ +const SLOW_MS = 60_000; + +interface RunResult { + code: number; + stdout: string; + stderr: string; +} + +function runGate(args: string[]): RunResult { + const r = spawnSync(TSX_BIN, [GATE_PATH, ...args], { + cwd: REPO_ROOT, + encoding: "utf8", + shell: false, + }); + return { code: r.status ?? -1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" }; +} + +let scratch: string; + +/** + * Build a throwaway git repo and return its path. Only the INDEX is populated (`git add`), not + * a commit: `git ls-files` reads the index, so no commit and therefore no committer identity + * is needed, which keeps these cases independent of whatever git config the box carries. + */ +function repo(files: Record): string { + const dir = mkdtempSync(join(scratch, "tree-")); + git(dir, ["init", "-q"]); + write(dir, files); + return dir; +} + +function write(dir: string, files: Record): void { + for (const [rel, content] of Object.entries(files)) { + const abs = join(dir, rel); + const slash = rel.lastIndexOf("/"); + if (slash > 0) mkdirSync(join(dir, rel.slice(0, slash)), { recursive: true }); + writeFileSync(abs, content); + } + git(dir, ["add", "-A"]); +} + +function git(dir: string, args: string[]): void { + const r = spawnSync("git", args, { cwd: dir, encoding: "utf8", shell: false }); + if ((r.status ?? -1) !== 0) throw new Error(`git ${args.join(" ")} failed: ${r.stderr}`); +} + +/** + * A sample pointer, ASSEMBLED rather than written out, and that is load-bearing rather than a + * style choice. + * + * The gate scans every tracked text file in the repo and carves out NO exemption for its own + * source or for this file. So a pointer written literally here would be read as a pointer into + * THIS repo's `documentation/agent-notes.md` and checked against its anchors, and every fixture + * below names a section that exists only inside a throwaway repo. + * + * The fix is deliberately the fixture and NOT an exemption. This repository's PHI scanner + * already shows what an exemption costs when it is drawn wider than one path. A gate's own + * tests are exactly where a genuinely broken pointer would hide, so they stay in scope. + */ +function ptr(anchor: string): string { + return `documentation/agent-notes.md${"#"}${anchor}`; +} + +/** A bare-form span, assembled for the same reason as `ptr`. */ +function bare(anchor: string): string { + return `\`${"#"}${anchor}\``; +} + +/** The narrative half, with one real section. */ +const NOTES = "# notes\n\nPreamble.\n\n## The section\n\nBody.\n"; + +beforeAll(() => { + scratch = mkdtempSync(join(tmpdir(), "cli-agent-notes-")); +}); + +afterAll(() => { + rmSync(scratch, { recursive: true, force: true }); +}); + +describe("check-agent-notes: the contract it asserts", { timeout: SLOW_MS }, () => { + it("is green on a tree that keeps the contract", () => { + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": NOTES, + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(0); + expect(r.stdout).toContain("check-agent-notes: OK"); + // The OK line must show its arithmetic, not just its verdict. + expect(r.stdout).toContain("2 tracked path(s) reconciled = 2 opened"); + }); + + it("reds when a pointer dangles, and goes green when the anchor is repaired", () => { + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-wrong-anchor")}\n`, + "documentation/agent-notes.md": NOTES, + }); + + const before = runGate(["--root", dir]); + expect(before.code).toBe(1); + expect(before.stderr).toContain("#the-wrong-anchor does not resolve"); + expect(before.stderr).toContain("CLAUDE.md:3"); + + write(dir, { "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n` }); + const after = runGate(["--root", dir]); + expect(after.code).toBe(0); + }); + + it("reds on a section that is nothing but its heading, and goes green when the body returns", () => { + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": + "# notes\n\nPreamble.\n\n## The section\n\n## Next\n\nBody.\n", + }); + + const before = runGate(["--root", dir]); + expect(before.code).toBe(1); + expect(before.stderr).toContain('section "The section" (#the-section) has no body'); + + write(dir, { "documentation/agent-notes.md": NOTES }); + expect(runGate(["--root", dir]).code).toBe(0); + }); + + it("does NOT red a container heading, whose body is its subsections", () => { + // `## Group` immediately followed by `### Sub` has no prose of its own, but the anchor `group` + // resolves on GitHub and the reader lands on real content, so reporting it is a red + // against a link that works. This shape is live in this repository's narrative file. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("group")}\n`, + "documentation/agent-notes.md": + "# notes\n\nPreamble.\n\n## Group\n\n### Sub\n\nReal body here.\n", + }); + const r = runGate(["--root", dir]); + expect(r.stderr).toBe(""); + expect(r.code).toBe(0); + }); + + it("still reds an emptied LEAF beneath a container, so the exemption opens no false green", () => { + // The only direction that would matter. The exemption moves the obligation DOWN to the + // deeper heading rather than removing it, so an emptied leaf is still a finding. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("sub")}\n`, + "documentation/agent-notes.md": + "# notes\n\nPreamble.\n\n## Group\n\n### Sub\n\n## Other\n\nBody.\n", + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(1); + expect(r.stderr).toContain('section "Sub" (#sub) has no body'); + }); + + it("treats a TRAILING heading as a leaf, never a container, since nothing deeper follows it", () => { + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": `${NOTES}\n### Trailing\n`, + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(1); + expect(r.stderr).toContain('section "Trailing" (#trailing) has no body'); + }); + + it("reds when the narrative half is not tracked at all, and calls it a finding rather than a refusal", () => { + const dir = repo({ "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n` }); + const r = runGate(["--root", dir]); + // Exit 1, not 2: this is a broken contract a human acts on, not a scan that failed. + expect(r.code).toBe(1); + expect(r.stderr).toContain("the narrative half of the pair is not tracked"); + }); + + it("reds when the cursor half is not tracked", () => { + const dir = repo({ + "documentation/agent-notes.md": NOTES, + "README.md": `See ${ptr("the-section")}.\n`, + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(1); + expect(r.stderr).toContain("the cursor half of the pair is not tracked"); + }); + + it("finds a qualified pointer wherever it is written, not only in CLAUDE.md", () => { + // The qualified form carries its own filename, so it is unambiguous anywhere. A + // markdown-only sweep would not open a source file at all. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": NOTES, + "scripts/thing.ts": `// see ${ptr("not-a-section")}\n`, + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(1); + expect(r.stderr).toContain("scripts/thing.ts:1"); + }); +}); + +/** + * THE BARE CENSUS. This repository's pointers are 100% the QUALIFIED spelling, measured before + * the matcher was written, so the bare form is matched by no checker here. That scope is only + * safe while the bare form stays absent, and these cases are what keep the absence a + * measurement. Both directions are asserted, because a census that refuses on a pull-request + * reference is deleted within a day and a census that never fires proves nothing. + */ +describe("check-agent-notes: the bare census", { timeout: SLOW_MS }, () => { + it("does NOT refuse on a digits-only reference, and reports it on the OK line", () => { + // GitHub renders a hash followed by digits as a pull-request reference, and the narrative file + // uses it that way. Refusing on these would red a healthy tree. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n\nSee ${bare("36")} and ${bare("27")}.\n`, + "documentation/agent-notes.md": NOTES, + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(0); + expect(r.stdout).toContain("2 bare-shaped span(s)"); + }); + + it("REFUSES on a genuine bare pointer, even one whose anchor RESOLVES", () => { + // THE DIRECTION THAT MATTERS. The tree is not necessarily broken; the evidence this gate's + // single-form scope was derived from is. Green here would be a claim about a corpus the + // gate no longer covers, which is exactly how a ported matcher printed `all resolving` + // over 3 of 38 pointers in a sibling repository. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n\nAlso: ${bare("the-section")}\n`, + "documentation/agent-notes.md": NOTES, + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(2); + expect(r.stdout).not.toContain("OK"); + expect(r.stderr).toContain("suspected BARE pointer"); + expect(r.stderr).toContain("RE-DERIVE THE MATCHER"); + }); + + it("REFUSES on a bare pointer written into the NARRATIVE half too, not only the cursor", () => { + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": `${NOTES}\nSee ${bare("the-section")}.\n`, + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(2); + expect(r.stderr).toContain("suspected BARE pointer"); + }); + + it("censuses a bare span in a THIRD file too, not only in the pair", () => { + // THE HOLE A PAIR-SCOPED CENSUS LEAVES, AND WHY THE SCOPE IS THE WHOLE CORPUS. A bare + // pointer written into any file that is neither half of the pair would be covered by + // neither the matcher nor a pair-scoped census, which is exactly the uncovered corner the + // census exists to remove. A first draft scoped it to the pair and justified that with + // files on this tree that supposedly could not survive a widening; running it tree-wide + // proved none of them can refuse, and that the only files that could were the gate's own. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": NOTES, + "README.md": `See ${bare("some-anchor")}.\n`, + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(2); + expect(r.stderr).toContain("suspected BARE pointer"); + expect(r.stderr).toContain("README.md"); + }); + + it("still treats a digits-only span in a third file as a reference, not a refusal", () => { + // The other direction of the widening. `CHANGELOG.md` on this tree carries backticked + // pull-request references, and refusing on those would red a healthy repository. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": NOTES, + "CHANGELOG.md": `Fixed in ${bare("34")} and ${bare("27")}.\n`, + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(0); + expect(r.stdout).toContain("2 bare-shaped span(s)"); + }); + + it("does not match a bare-shaped span that is not anchor-shaped, so a shebang is inert", () => { + // Measured on this tree: a quoted shebang, a quoted `# synthetic` comment and a lone `#` + // are all outside the anchor class, so they never reach the reference/pointer decision. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": NOTES, + "tsup.config.ts": "// a shebang is written `#!/usr/bin/env node` and a marker `# on`.\n", + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(0); + expect(r.stdout).toContain("0 bare-shaped span(s)"); + }); +}); + +describe("check-agent-notes: the control, and every other refusal", { timeout: SLOW_MS }, () => { + it("REFUSES when pointed at a tree with nothing tracked in it", () => { + const dir = mkdtempSync(join(scratch, "empty-")); + git(dir, ["init", "-q"]); + const r = runGate(["--root", dir]); + expect(r.code).toBe(2); + expect(r.code).not.toBe(0); + expect(r.stderr).toContain("listed no readable tracked file"); + expect(r.stdout).not.toContain("OK"); + }); + + it("REFUSES when pointed at something that is not a git repository", () => { + const dir = mkdtempSync(join(scratch, "norepo-")); + const r = runGate(["--root", dir]); + expect(r.code).toBe(2); + expect(r.stdout).not.toContain("OK"); + }); + + it("REFUSES a tree that holds the pair but zero pointers, instead of calling it clean", () => { + // The `observed nothing` shape, in this gate's own terms: the corpus exists, the files + // open, and the answer is still meaningless. EXISTENCE IS NOT OBSERVATION, and a + // denominator would read healthy here. This is the case that would have caught a matcher + // ported into a tree whose pointers are spelled the other way. + const dir = repo({ + "CLAUDE.md": "# cursor\n\nNo link here.\n", + "documentation/agent-notes.md": NOTES, + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(2); + expect(r.stderr).toContain("ZERO qualified pointers"); + }); + + it("REFUSES when two tracked files carry the contract basename, rather than guessing", () => { + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": NOTES, + "docs-content/agent-notes.md": NOTES, + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(2); + expect(r.stderr).toContain("named agent-notes.md"); + }); + + it("REFUSES an unknown flag rather than scanning with it ignored", () => { + expect(runGate(["--everything"]).code).toBe(2); + }); + + it("REFUSES a tracked path that is a SYMLINK, rather than scanning bytes from outside the tree", () => { + // Enforced by O_NOFOLLOW on the open itself, so there is no window between the check and + // the read. Defeating this refusal is how untracked bytes get read under a tracked name. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": NOTES, + }); + writeFileSync(join(dir, "outside.txt"), "not tracked\n"); + symlinkSync(join(dir, "outside.txt"), join(dir, "link.md")); + git(dir, ["add", "-A"]); + + const r = runGate(["--root", dir]); + expect(r.code).toBe(2); + expect(r.stderr).toContain("tracked path is a symbolic link"); + // The target is deliberately not printed: a target path is itself a PHI surface. + expect(r.stderr).not.toContain("outside.txt"); + }); + + it("REFUSES a tracked path that is missing from the working tree", () => { + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": NOTES, + "gone.md": "here for now\n", + }); + rmSync(join(dir, "gone.md")); + + const r = runGate(["--root", dir]); + expect(r.code).toBe(2); + expect(r.stderr).toContain("tracked path is missing from the working tree"); + }); + + it("REFUSES a tracked path replaced on disk by a DIRECTORY", () => { + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": NOTES, + "swapped.md": "a regular file, for now\n", + }); + rmSync(join(dir, "swapped.md")); + mkdirSync(join(dir, "swapped.md")); + + const r = runGate(["--root", dir]); + expect(r.code).toBe(2); + expect(r.stderr).toContain("tracked path is not a regular file"); + }); + + it("REFUSES a tracked path replaced by a FIFO instead of hanging on it forever", () => { + // THIS IS WHAT MAKES O_NONBLOCK LOAD-BEARING RATHER THAN DECORATION. Opening a FIFO for + // reading blocks until a writer appears, so without the flag the gate would hang here + // indefinitely rather than refuse, and a hung gate reports nothing at all. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": NOTES, + "pipe.md": "a regular file, for now\n", + }); + const fifo = join(dir, "pipe.md"); + rmSync(fifo); + const mk = spawnSync("mkfifo", [fifo], { encoding: "utf8", shell: false }); + if ((mk.status ?? -1) !== 0) return; // no mkfifo here; the directory case still covers the class + + const r = runGate(["--root", dir]); + expect(r.code).toBe(2); + expect(r.stderr).toContain("tracked path is not a regular file"); + }); + + it("REFUSES an UNMERGED path rather than scanning conflict markers", () => { + // This repository's PHI scanner leaves the unmerged status unenumerated in its STAGED + // route, and records why: `git commit` refuses an unmerged index at exit 128, so nothing + // unmerged can reach a commit through that gate. THAT REASONING DOES NOT TRANSFER HERE, + // because this gate has no staged route: it runs from the test suite and from CI over + // whatever tree it is handed. So the refusal is re-derived rather than inherited, and this + // case is what proves it fires. + // + // An unmerged index is built directly with `git update-index`, which needs no commit and + // therefore no committer identity. A `git merge` fixture is what sprang a vacuity trap in + // this repository's other script suite: it refused on an identity-less runner and every + // later assertion then held over an empty result. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": NOTES, + "conflicted.md": "ours\n", + }); + const blob = spawnSync("git", ["hash-object", "-w", "--stdin"], { + cwd: dir, + input: "theirs\n", + encoding: "utf8", + shell: false, + }); + expect(blob.status).toBe(0); + const sha = blob.stdout.trim(); + git(dir, ["rm", "--cached", "-q", "conflicted.md"]); + const upd = spawnSync( + "git", + ["update-index", "--index-info"], + // Stages 1, 2 and 3 for one path: exactly what a conflicted index holds. + { + cwd: dir, + input: `100644 ${sha} 1\tconflicted.md\n100644 ${sha} 2\tconflicted.md\n100644 ${sha} 3\tconflicted.md\n`, + encoding: "utf8", + shell: false, + }, + ); + expect(upd.status).toBe(0); + + // ASSERT THE PREMISE: the index really is unmerged before the gate is asked about it. + const staged = spawnSync("git", ["ls-files", "-u"], { cwd: dir, encoding: "utf8" }); + expect(staged.stdout).toContain("conflicted.md"); + + const r = runGate(["--root", dir]); + expect(r.code).toBe(2); + expect(r.stderr).toContain("is unmerged"); + expect(r.stdout).not.toContain("OK"); + }); +}); + +describe( + "check-agent-notes: the bypass classes, reproduced end to end", + { timeout: SLOW_MS }, + () => { + it("sees a heading indented by one space (a /^#{1,6} / guard misses it)", () => { + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": "# notes\n\nPreamble.\n\n ## The section\n\nBody.\n", + }); + // A guard that missed this would report a FALSE RED against a link GitHub resolves. + expect(runGate(["--root", dir]).code).toBe(0); + }); + + it("sees a setext heading (an underline, not a hash)", () => { + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": + "# notes\n\nPreamble.\n\nThe section\n-----------\n\nBody.\n", + }); + expect(runGate(["--root", dir]).code).toBe(0); + }); + + it("does NOT mint an anchor from an ATX line inside a code fence", () => { + // The opposite direction from the two above, and the one that would let a dangling pointer + // through. A shell sample containing `# The section` is a comment, not a section, and this + // repository's narrative file embeds shell and `gh api` reproductions. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": + "# notes\n\nPreamble.\n\n## Real\n\n```sh\n# The section\n```\n", + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(1); + expect(r.stderr).toContain("#the-section does not resolve"); + }); + + it("resolves a pointer whose anchor is split across a line wrap", () => { + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-long-")}\nsection-name\n`, + "documentation/agent-notes.md": + "# notes\n\nPreamble.\n\n## The long section name\n\nBody.\n", + }); + expect(runGate(["--root", dir]).code).toBe(0); + }); + + it("still reds a wrapped pointer whose join does not resolve either", () => { + // The join can rescue a false red; it must not manufacture a pass. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-long-")}\nwrong-name\n`, + "documentation/agent-notes.md": + "# notes\n\nPreamble.\n\n## The long section name\n\nBody.\n", + }); + expect(runGate(["--root", dir]).code).toBe(1); + }); + + it("disambiguates two identical headings the way GitHub does", () => { + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("same-1")}\n`, + "documentation/agent-notes.md": "# notes\n\nP.\n\n## Same\n\nA.\n\n## Same\n\nB.\n", + }); + expect(runGate(["--root", dir]).code).toBe(0); + }); + + it("re-suffixes a slug that collides with an already-generated one", () => { + // github-slugger loops rather than counting: `Same`, `Same`, `Same-1` yields + // `same`, `same-1`, `same-1-1`. A counter yields `same-1` twice and reds the last of those. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("same-1-1")}\n`, + "documentation/agent-notes.md": + "# notes\n\nP.\n\n## Same\n\nA.\n\n## Same\n\nB.\n\n## Same-1\n\nC.\n", + }); + expect(runGate(["--root", dir]).code).toBe(0); + }); + + it("keeps the leading hyphen a dropped leading character leaves behind", () => { + // github-slugger does NOT trim, so a heading led by a marker glyph slugs with a LEADING + // hyphen. A trim makes a pointer written without it pass here and resolve to nothing on + // GitHub. This repository's documents lead load-bearing rules with marker glyphs + // throughout, so the shape is reachable rather than exotic. + const notes = "# notes\n\nP.\n\n## ▶ The section\n\nBody.\n"; + const green = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("-the-section")}\n`, + "documentation/agent-notes.md": notes, + }); + expect(runGate(["--root", green]).code).toBe(0); + + const red = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": notes, + }); + expect(runGate(["--root", red]).code).toBe(1); + }); + + it("does the same for an EMOJI marker, which this repository also uses", () => { + const notes = "# notes\n\nP.\n\n## \u{1f6d1} The section\n\nBody.\n"; + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("-the-section")}\n`, + "documentation/agent-notes.md": notes, + }); + expect(runGate(["--root", dir]).code).toBe(0); + }); + + it("does NOT mint a setext anchor from YAML front matter", () => { + // The closing `---` of front matter sits directly under a non-blank line, so a naive + // setext reader mints an anchor from `title: phantom`. That is the false-green direction. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("title-phantom")}\n`, + "documentation/agent-notes.md": + "---\ntitle: phantom\n---\n\n# notes\n\nP.\n\n## Real\n\nBody.\n", + }); + expect(runGate(["--root", dir]).code).toBe(1); + }); + + it("gives a wrapped setext heading the anchor of the whole paragraph, with the softbreak DELETED", () => { + // A wrapped setext heading is ONE heading whose text carries a newline, and the slug rule + // DELETES a newline rather than hyphenating it, so the two halves run together. + const notes = "# notes\n\nP.\n\nThe long\nsection name\n------------\n\nBody.\n"; + + const green = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-longsection-name")}\n`, + "documentation/agent-notes.md": notes, + }); + expect(runGate(["--root", green]).code).toBe(0); + + const red = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-long-section-name")}\n`, + "documentation/agent-notes.md": notes, + }); + expect(runGate(["--root", red]).code).toBe(1); + }); + + it("deletes a non-ASCII space separator from a slug, as the upstream rule does", () => { + // The separator is written as an escape, not a literal: a bare U+00A0 in a fixture is + // invisible to a reader and to a diff. + const notes = "# notes\n\nP.\n\n## A\u00a0B\n\nBody.\n"; + const green = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("ab")}\n`, + "documentation/agent-notes.md": notes, + }); + expect(runGate(["--root", green]).code).toBe(0); + }); + + it("does not read a four-space-indented hash line as a heading, matching CommonMark", () => { + // Disclosed miss (vi) says the fence tracker does not model indented code blocks. It also + // says that is not reachable as a phantom anchor, because ATX indentation is bounded at + // three spaces. This pins the second half. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": "# notes\n\nP.\n\n## Real\n\n ## The section\n\nBody.\n", + }); + expect(runGate(["--root", dir]).code).toBe(1); + }); + + it("keeps an underscore in a slug, which is what makes a diagnostic-code heading resolve", () => { + // A slugger that treated `_` as emphasis, or stripped it as punctuation, would red a + // pointer at a heading naming one of this package's `CLI_*` codes. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("cli_not_implemented-and-69")}\n`, + "documentation/agent-notes.md": "# notes\n\nP.\n\n## CLI_NOT_IMPLEMENTED and 69\n\nBody.\n", + }); + expect(runGate(["--root", dir]).code).toBe(0); + }); + }, +); + +/** + * One case per DISCLOSED MISS in the script header that is marked [PINNED], each written in the + * direction the miss actually fails. If a miss is added to the header, a case belongs here or + * the marking must say [SCOPE]. A disclosure that names a test must name one that exists. + */ +describe( + "check-agent-notes: the disclosed misses, each in the direction it fails", + { timeout: SLOW_MS }, + () => { + it("(i) passes a wrapped pointer whose head fragment is itself a valid anchor", () => { + // The join is attempted only after the unwrapped anchor fails, so a head that resolves + // ends the check there and the garbage tail is never seen. Asserted as green so that + // closing it later is a deliberate change and not a surprise. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\nzzz-not-an-anchor\n`, + "documentation/agent-notes.md": NOTES, + }); + expect(runGate(["--root", dir]).code).toBe(0); + }); + + it("(ii) does not decode a percent-encoded anchor, so it reds", () => { + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the%20section")}\n`, + "documentation/agent-notes.md": NOTES, + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(1); + // Matched only up to the `%`, so the reported anchor is `the`, not `the section`. + expect(r.stderr).toContain("pointer #the does not resolve"); + }); + + it("(iii) ignores an anchor on any other file, including the cursor half", () => { + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n\nAlso CLAUDE.md#no-such-anchor.\n`, + "documentation/agent-notes.md": NOTES, + }); + expect(runGate(["--root", dir]).code).toBe(0); + }); + + it("(iv) checks a pointer inside a fenced code block exactly like prose", () => { + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n\n\`\`\`sh\n# ${ptr("bogus")}\n\`\`\`\n`, + "documentation/agent-notes.md": NOTES, + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(1); + expect(r.stderr).toContain("pointer #bogus does not resolve"); + }); + + it("(v) skips a NUL-bearing file whole, so a pointer inside one is never read", () => { + // THE MISS THAT CAN PRINT `all resolving` OVER A DANGLING POINTER. Asserted green on + // purpose: it is disclosed in the script header and in CLAUDE.md, and the tell is the + // skipped count, which is asserted here too so a silent widening of the skip reds. + // + // The exclusion is REQUIRED in this repository rather than a tidy-up: the tree tracks + // vendored `@cosyte/*` tarballs and a synthetic DICOM fixture, none of which can be read + // as markdown or edited to clear a red. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": NOTES, + "blob.bin": `binary \0 payload ${ptr("totally-bogus-anchor")}\n`, + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(0); + expect(r.stdout).toContain("3 tracked path(s) reconciled = 2 opened + 1 skipped as binary"); + }); + + it("(v) opens a file GIT would call binary but that holds no NUL, so the skip stays narrow", () => { + // The skip is keyed on an ACTUAL NUL byte, which is NARROWER than git's own binary + // classification. A lone-CR file (an HL7 v2 or ASTM terminator is a lone `CR`) is + // `i/-text` to git and holds no NUL, so this gate OPENS it and reads the pointers inside. + // Pinned so that nobody "simplifies" the skip to `grep -I` or `git ls-files --eol`, either + // of which would drop a readable text file from the sweep with no tell. + // `scripts/check-no-emdash.sh` keys on NUL for the same reason and agrees here. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": NOTES, + // Lone CR terminators, no NUL, and a dangling pointer inside. + "loneCr.txt": `first record\rsecond record, see ${ptr("not-a-section")}\r`, + }); + const r = runGate(["--root", dir]); + // Opened, therefore the pointer inside it is READ and reported. + expect(r.code).toBe(1); + expect(r.stderr).toContain("loneCr.txt"); + expect(r.stderr).toContain("#not-a-section does not resolve"); + }); + + it("(vi) does NOT mint an anchor from an ATX line inside a code fence", () => { + // (vi) was marked [PINNED] while this block held no case for it, which broke this + // block's own stated invariant even though the shape was exercised further up the file. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("fenced")}\n`, + "documentation/agent-notes.md": "# notes\n\nP.\n\n## Real\n\n```sh\n## Fenced\n```\n", + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(1); + expect(r.stderr).toContain("#fenced does not resolve"); + }); + + it("(vi-b) DOES mint a phantom anchor from a heading inside an HTML comment", () => { + // THE MISS EVERY SIBLING COPY DISCLOSES AND THIS ONE FIRST FORGOT TO. `` + // are not tracked as a block, so a commented-out heading is counted here and renders no + // anchor on GitHub: a pointer at it passes green and resolves to nothing. Asserted GREEN + // on purpose, because it is a disclosed miss rather than a promise, and asserting it is + // what makes closing it later a deliberate act instead of a surprise. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("commented-out")}\n`, + "documentation/agent-notes.md": + "# notes\n\nP.\n\n## Real\n\nBody.\n\n\n", + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(0); + }); + + it("(vii) transcribes the slugger rather than importing it, and reds if that drifts", () => { + // (vii) was marked [PINNED] with no case in this block. The transcription is exercised + // by `SLUG_CASES` inside the gate's own self-test, so the direction it fails in is a + // REFUSAL before any tree is read: proved here by the fact that every other case in this + // file gets a verdict at all, and directly by a heading whose slug only a faithful + // transcription produces (no trim, and an underscore kept). + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("-cli_not_implemented")}\n`, + "documentation/agent-notes.md": "# notes\n\nP.\n\n## ▶ CLI_NOT_IMPLEMENTED\n\nBody.\n", + }); + expect(runGate(["--root", dir]).code).toBe(0); + }); + + it("(xii) refuses on an anchor-shaped span that was never a pointer, the price of widening", () => { + // THE COST OF THE TREE-WIDE CENSUS, DISCLOSED RATHER THAN NARROWED. A hex colour or a CSS + // id written as a backticked anchor-shaped span refuses, and the refusal's advice to + // re-derive the matcher is wrong for a colour. Asserted so the cost is a known, deliberate + // trade rather than a surprise: the direction is conservative (refuse, never false green), + // and the pair-scoped census it replaced avoided this only by leaving every third file + // uncovered by both the matcher and the census. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "documentation/agent-notes.md": NOTES, + "docs-content/theme.md": `The brand ink is ${bare("fff")}.\n`, + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(2); + expect(r.stderr).toContain("suspected BARE pointer"); + expect(r.stderr).toContain("docs-content/theme.md"); + }); + + it("(xi) compares the narrative file's BASENAME only, so a move to another directory passes", () => { + // MEASURED, AND IT IS WHY THE OPENING PROMISE NO LONGER SAYS "a rename". The file moved + // out of `documentation/` while every pointer keeps the old path prefix: every rendered + // link 404s on GitHub and this gate still exits 0. Asserted green as a disclosed miss. + const dir = repo({ + "CLAUDE.md": `# cursor\n\nWhy: ${ptr("the-section")}\n`, + "docs/agent-notes.md": NOTES, + }); + const r = runGate(["--root", dir]); + expect(r.code).toBe(0); + // The gate reports the path it actually found, which is the only tell a reader gets. + expect(r.stdout).toContain("docs/agent-notes.md"); + }); + }, +); + +describe("check-agent-notes: against this repo", { timeout: SLOW_MS }, () => { + it("is green on this tree, with every pointer resolving", () => { + const r = runGate([]); + expect(r.stderr).toBe(""); + expect(r.code).toBe(0); + expect(r.stdout).toContain("documentation/agent-notes.md"); + expect(r.stdout).toContain("all resolving"); + }); + + it("accounts for every tracked path on the OK line", () => { + // The reconciliation is the anti-`observed nothing` property, so it is asserted as a + // property of the OUTPUT, not just of the exit code: `tracked == opened + skipped`. + const r = runGate([]); + const m = /(\d+) tracked path\(s\) reconciled = (\d+) opened \+ (\d+) skipped as binary/.exec( + r.stdout, + ); + expect(m).not.toBeNull(); + const tracked = Number(m?.[1]); + const opened = Number(m?.[2]); + const skipped = Number(m?.[3]); + expect(opened).toBeGreaterThan(0); + expect(tracked).toBe(opened + skipped); + }); + + it("reads a real, non-zero population of qualified pointers on this tree", () => { + // ASSERT THE PREMISE. The gate refuses at zero, so this cannot be vacuous, but the count + // is asserted as positive here anyway so that a future change which drops the corpus to a + // single pointer is visible as a deliberate act rather than a silent narrowing. + const r = runGate([]); + const m = /(\d+) qualified pointer\(s\) from (\d+) file\(s\), all resolving/.exec(r.stdout); + expect(m).not.toBeNull(); + expect(Number(m?.[1])).toBeGreaterThan(1); + expect(Number(m?.[2])).toBeGreaterThan(0); + }); + + it("reports the bare census over this whole tree, every span a digits-only reference", () => { + // The census is what keeps the single-form scope honest, so its result is asserted rather + // than merely produced. A non-digits bare span ANYWHERE on this tree, this file and the + // gate's own source included, would have refused the run above. + const r = runGate([]); + expect(r.code).toBe(0); + expect(r.stdout).toMatch(/\d+ bare-shaped span\(s\) across every opened file/); + expect(r.stdout).toContain("none a pointer"); + }); +});