chore(#3059): close the ESLint glob-coverage escape and guard it (#3277)

* chore(#3059): close the ESLint glob-coverage escape and guard it

62 tracked source files matched no `files:` glob in eslint.config.mjs, so
ESLint skipped them entirely while `eslint .` still exited 0 — including all
26 files under hooks/, the enforcement machinery itself.

Covers 56 of them (eslint-rules/, hooks/, bin/lib/, pi/, examples/, vscode/,
the plugin shims, root *.mjs) and allowlists the 6 deliberate
must-not-compile brand-typing fixtures with a recorded reason each.

hooks/** is covered with n/no-process-exit deliberately off: a hook's whole
contract is its exit code, several exits are load-bearing stdin-timeout
guards where nothing else terminates the process, and ADR-0012/0174 scope the
no-process-exit convention to the Command Routing Hub. bin/lib/ui-safety-gate.cjs
is dual-mode, so it keeps the rule live and takes two targeted disables in its
require.main===module tail instead.

Adds scripts/lint-eslint-glob-coverage.cjs + a node:test drift guard so the
class cannot regrow: allowlist entries require a non-empty reason, the list
ratchets down only (a stale entry fails), and a tracked-count floor means a
broken `git ls-files` fails rather than reporting clean.

Closes #3059

* chore(#3059): apply review findings — correct the changeset count, add parser properties

Isolated adversarial review, confirmed by rebuilding a byte-for-byte replica
of the pre-change eslint.config.mjs: the changeset claimed 44 previously-
unlinted files. The real figure is 56 (56 covered + 6 allowlisted = 62).
That was user-facing CHANGELOG text and was wrong; corrected, along with three
consequential figures in the design record.

CLAUDE.md requires a fast-check property test for parsers and budget limits,
and listTrackedSourceFiles is a parser. The standards review called this
"satisfied in spirit"; it is not. Adds three properties driving the real
exported parser through an injected execFile: extension totality/soundness
including a trailing terminator, backslash-normalization totality, and
CRLF/LF equivalence — the invariant the repo's recurring CRLF defect class
breaks.

Also de-duplicates the anchor rows onto one shared resolver, kept deliberately
independent of the guard's own resolveFileCoverage so an anchor still fails if
that resolution regresses, and records in the guard's header why the
bin/install.js family is NOT allowlisted: it resolves to 2 rules under
ADR-1703, so an entry would trip the allowlist_stale ratchet.

* fix(#3059): make the coverage guard's git call container-safe

The remote runner reported the guard degrading to `git_failed` on both Node
lanes:

  fatal: detected dubious ownership in repository at '/work'

The runner executes in a container where the repo is owned by a different UID,
so git refuses to operate on it. The guard's degraded-verdict path worked
exactly as designed — it reported the failure instead of throwing or falsely
reporting clean — but a guard that cannot run in CI is not a gate.

`git ls-files` is now invoked as `git -c safe.directory=* ls-files`. `-c`
scopes the override to the single invocation and mutates no config file, and
the wildcard is appropriate because this command only enumerates tracked paths
in the repository it is already executing inside.

Adds a regression test that captures the argv through the injected execFile
seam and asserts `-c safe.directory=*` precedes `ls-files`, so the container
case is pinned behaviorally rather than by reading the script's source.

* chore(#3059): backfill changeset PR number

pr:0 placeholder replaced with the real number now that #3277 exists.

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-09 19:35:34 -04:00
committed by GitHub
parent 653f95e39f
commit 0c413bbc9c
15 changed files with 980 additions and 14 deletions

View File

@@ -0,0 +1,93 @@
# How to resolve an ESLint glob-coverage finding
**Goal:** Turn a `lint-eslint-glob-coverage` failure into either real lint coverage or a recorded, reasoned exemption — so a source file can never again sit in the tree matching no rule at all, linted by nothing, while `eslint .` exits 0.
**Prerequisites:** A failing `tests/eslint-glob-coverage.test.cjs`, or a non-zero `node scripts/lint-eslint-glob-coverage.cjs`. You do not invoke the guard separately in CI — it runs as part of the test suite.
---
## Why this guard exists
ESLint assigns rules by `files:` glob. A file matching **no** glob is not "linted and clean" — it is **not linted at all**, and ESLint reports nothing about it. There is no warning, no summary line, no exit code. The failure is perfectly silent.
That is not hypothetical. Before this guard, 62 tracked source files resolved to zero rules, including all 26 files under `hooks/` — the enforcement machinery itself. One earlier escape hid a real Windows portability defect (a bare `npm` invocation without `{ shell: true }`) in a shared test helper: a rule the repo bans as an **error** in sibling files, surviving purely because the helper was unreachable by the glob.
The guard checks rule **reachability**, not rule **severity**. A file covered entirely by `warn` rules passes here; that is a different gate (`#1885` F17, `--max-warnings 0`).
---
## Read a finding
Each violation names one path and one `kind`:
| `kind` | What it means |
|---|---|
| `uncovered` | The file matches no `files:` glob. Nothing lints it. |
| `allowlist_missing_reason` | An allowlist entry has no `reason` key. |
| `allowlist_empty_reason` | An allowlist entry's `reason` is empty or whitespace. |
| `allowlist_stale` | An allowlisted file **now resolves to rules** — the exemption is obsolete. |
| `allowlist_missing_path` | An allowlist entry names a path that is no longer tracked. |
| `allowlist_duplicate` | The same path is listed twice (usually a merge artifact). |
| `tracked_count_below_floor` | Fewer than 500 tracked source files were found — the guard refuses to report "clean" from an obviously broken file list. |
| `git_failed` | `git ls-files` failed or timed out. The guard degrades to a failure rather than a vacuous pass. |
An `uncovered` finding has exactly two legitimate resolutions. Pick the first one unless you can write down a permanent reason for the second.
---
## Cover it — bring the file under a rule block
**Choose this whenever the file is first-party source that should be linted.** This is the correct resolution in almost every case, and it is what closed 56 of the original 62.
Open `eslint.config.mjs` and add the path to the `files:` array of the block whose ruleset fits:
- CommonJS Node code (`scripts/`, `eslint-rules/`, `bin/lib/`, `pi/`, `examples/`, `vscode/`) → the **CommonJS Node** block.
- Test code → the `tests/**/*.cjs` block.
- TypeScript sources → the `src/**/*.cts` block.
Then run `npx eslint .` and **fix whatever it surfaces**. Do not downgrade a rule's severity to make a new file pass — that converts a real finding into a silent one, which is the failure this guard exists to end.
If the file needs a genuinely different ruleset, add a new block with a comment saying why. `hooks/**` is the worked example: it is covered like other CommonJS Node code, but with `n/no-process-exit` deliberately `off`, because a hook's entire contract is its exit code and several of its exits are load-bearing (a `setTimeout` stdin guard where nothing else would terminate the process). That comment names the evidence, so the next reader does not have to re-derive it.
---
## Allowlist it — record a permanent reason
**Choose this only when the file must never be linted**, and say why. Add an entry to `scripts/lint-eslint-glob-coverage.allowlist.json`:
```json
{
"path": "tests/fixtures/brand-typing/bad-calibrated-as-sample-basis.cts",
"reason": "Deliberate MUST-NOT-COMPILE type-error fixture (#3059): type-aware linting would fail by design; it exists to prove the compiler rejects it."
}
```
The `reason` is **structurally required** — an entry with a missing, empty, or whitespace-only reason fails the guard. That is deliberate. The easy way to make a coverage metric look good is to add an exemption, so an exemption has to cost you a sentence you are willing to sign.
⚠️ **"Not linted yet" is not a reason.** An allowlist entry meaning *we will get to it* is a TODO wearing an exemption's badge, and it will outlive everyone who remembers it. If the file should eventually be linted, lint it now.
A file that is **explicitly ignored** — the `gsd-core/bin/lib/**` tsc artifacts under ADR-457 — needs no allowlist entry. Ignored is a recorded decision; unmatched is an accident. The guard already tells them apart.
Note that `bin/install.js` also needs no entry: it resolves to two rules under ADR-1703's deliberately minimal coverage, which is non-empty and therefore passes.
---
## Delete a stale entry
`allowlist_stale` means an allowlisted file now resolves to rules — someone widened a glob and the exemption became dead weight. **Delete the entry.** The allowlist only ratchets down; it can never quietly accumulate.
---
## Verify
```bash
node scripts/lint-eslint-glob-coverage.cjs
```
A pass prints the count it actually checked:
```
ok lint-eslint-glob-coverage: 1175 tracked source file(s), 0 escapes
```
Read that number. If it is implausibly small, the guard's floor should have caught it — but a count that merely *shrank* is worth a second look before you trust the "0 escapes".