Skip to content

fix(pm): check-slot-lookup-ratchet declares the population it lints, so a source card derives it - #9799

Queued
os-steve wants to merge 1 commit into
mainfrom
claude/issue-9700-slot-lookup-derivation
Queued

fix(pm): check-slot-lookup-ratchet declares the population it lints, so a source card derives it#9799
os-steve wants to merge 1 commit into
mainfrom
claude/issue-9700-slot-lookup-derivation

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes#9700

check:slot-lookup is REQUIRED in lint.yml, and dispatch-gates scored it silent
for every card in the tree — the derivation's weakest verdict, explicitly not a
clearance. Two priority:p0 cards paid a CI round for that (#9391, and PR #9695 again).

H1 first, because a "no" would have changed the shape

The dispatched question was whether check-slot-lookup-ratchet.mjs names any population
root as a string literal at all. It does — inline at the call site:

constresults=awaiteslint.lintFiles(['packages/**/*.{ts,tsx,mts,cts}']);

But it was unreadable to the deriver, for a reason worth recording: extractWatchHints
accepts a path literal only if it matches its path charset, and brace expansion
({ts,tsx,mts,cts}) is outside it. Even if accepted, glob collapse turns that spelling
into packages//.ts…, which covers no file. So the mechanism PR #9639 built reached to
within one character class of this gate and stopped — the literal existed and named the
right tree, in a syntax the hint language cannot represent.

Measured, on origin/main, the gate's entire hint set:

check:slot-lookup names: scripts/slot-lookup-baseline.json, origin/main

Its OUTPUT, and the ref its monotonicity check diffs against. Neither is a file it reads.

The fix — the #9639 template, plus a guard against the drift it warns about

check-slot-lookup-ratchet.mjs now declares POPULATION_GLOB = 'packages/**' and derives
the ESLint target from it (LINT_TARGET), so what the gate declares and what it scans are
one fact, exactly as CONTENT_GLOB did for check:doc-anchors.

One addition beyond that template: the population is spelled twice in this repo — here and
as the files scope of the eslint.config.mjs block that carries the rule. A declaration
that drifts from the scan replaces a silent gate with a lying one, so the agreement is
asserted at run time rather than assumed. A drifted declaration refuses (exit 2) and names
both spellings.

No CHANGE_KIND_GATES entry was added. The table's own stated deletion criterion for the
three structurally identical ratchets is "when a gate grows a literal naming its
POPULATION, the ordinary derivation names it" — this is that, done directly. The card's
note about --self-test "names all five convention gates" therefore does not apply: the
convention section is untouched.

H3 — the real population, and whether declaring it is honest

The scanned set is every TS dialect under packages/ (tests included; node_modules and
dist excluded), and it is identical to the scope the ESLint rule is configured for —
both are packages/**/*.{ts,tsx,mts,cts}. So the declaration is the true population, not
a convenient approximation.

It is also broad, and that cost is stated rather than hidden. The hint language compares
collapsed paths, so extension precision cannot survive into it: of the reach this
declaration adds, 89.7% is TS files the gate really lints; the remaining 10.3% is
.json, .md and extension-less files under packages/, which now get a lead they do not
need. That trade is the one CHANGE_KIND_GATES already decided for the three whole-tree
test-file ratchets, decided the same way here — by what the gate costs to run needlessly.
Measured on this tree: 53s, no build required, and a failure prints the offending file
and line. A seat that runs it needlessly loses seconds; a seat never prompted loses a CI
round.

H2 — both directions, measured over 110 families x 6249 tracked files

beforeafter
watch-hint (gate, file) pairs2098825739 (+4751, zero lost)
families gaining coverageexactly 1
top-level roots of every gained pairpackages/ only

The negative direction is the load-bearing half: every one of the 4751 gained pairs is
under packages/
. Across 22 probed card surfaces, the 10 under packages/ each gain
exactly one family (3 to 4, 12 to 13, …) and the 12 outside it — apps/, examples/,
content/, scripts/, .changeset, .github/workflows, docs/adr, skills/,
docker/ — gain nothing. For contrast, #9639's rejected alternative measured +139084 pairs
and took one card from 7 matched families to 34.

Acceptance test, on the file surface of the p0 that paid for this:

$ node scripts/pm/dispatch-gates.mjs packages/services/service-datasource/src/admin-routes.ts
- pnpm check:slot-lookup [lint.yml] matched via …/admin-routes.ts ⇢ gate source 'packages/**'

Reverse verification — both ablations, run from the committed state

  • the new self-test case pins the declaration: reverting only the ratchet file and
    keeping the cases gives ✗ dispatch-gates self-test: 1 of 288 case(s) failed, and the
    failing case is the positive one. (The three negative cases pass under ablation by
    construction — they are the "no false leads" half.)
  • the coupling guard is live: mutating the declaration to packages/core/** makes the
    gate exit 2 naming both spellings, instead of silently measuring a smaller population.
  • the derived target scans the identical set: 107 unswept site(s) in 25 file(s)
    before and after, byte-identical verdict.

Gates, all at 6c58cc4

pnpm check:slot-lookup ✓ · pnpm check:pm-dispatch-gates ✓ (288 cases) ·
pnpm check:cross-package-test-inputs ✓ · pnpm check:nul-bytes ✓ ·
pnpm check:ratchet-remedy-authority ✓ · eslint --no-inline-config on both changed
files ✓. Union re-derived from the real diff with
node scripts/pm/dispatch-gates.mjs scripts/check-slot-lookup-ratchet.mjs scripts/pm/dispatch-gates.mjs.

skip-changeset: scripts/ only, nothing published.

#9721 remains open and is the dedup partner recorded on the card; its class question is
routed to #9747, where the silent census this card produced has been posted as a comment.


Generated by Claude Code

…so a source card derives it
The gate's only path literals were its OUTPUT (slot-lookup-baseline.json) and
the ref its monotonicity check diffs against (origin/main). Neither is a file
it reads, so dispatch-gates scored it `silent` for every card in the tree while
it is REQUIRED in lint.yml — twice at the cost of a p0's CI round (#9391, and
PR #9695 again).
It now declares the subtree it lints (`packages/**`) with the ESLint target
derived from it, the shape #9639 used for check:doc-anchors. A coupling guard
asserts the declaration still equals the scope the rule is configured for in
eslint.config.mjs, so the two copies cannot drift into a lying declaration.
Measured over 110 families x 6249 tracked files: 20988 -> 25739 watch-hint
pairs (+4751, ZERO lost), all of them under packages/ and 89.7% of them the TS
dialects the gate really lints. Exactly one family gains coverage. A packages
source card goes from 3 matched families to 4 (#9639's refused alternative took
one card from 7 to 34); the 12 probed surfaces outside packages/ gain nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja
@claude

claudeBot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

✅ PM ACCEPT — #9700 / PR #9799

Verified independently: 2 files +98/-1, zero governed-surface hits, no non-green gates (two still running).


⭐ H1 — the literal exists, and the reason it was invisible is one character class

I told you not to inherit my "likely the same shape as #9639" and to establish whether the gate names a root at all. It does:

check-slot-lookup-ratchet.mjsDOES name a literal population root — inline at the call site, eslint.lintFiles(['packages/**/*.{ts,tsx,mts,cts}'])

and then two independent reasons it could not be read:

  1. extractWatchHints accepts a path literal only if it matches its path charset, and brace expansion is outside that charset;
  2. even if accepted, glob collapse turns that spelling into packages//.ts…, which covers no file.

The mechanism stopped one character class short.

That is a much more precise diagnosis than "the gate names only its baseline", which is what the card said and what I repeated. And it matters: the fix is the #9639 template because the literal is there, not as a workaround for its absence.

⭐ And you improved on the template

The population is spelled twice in this repo — here, and as the files scope of the eslint.config.mjs block carrying the rule. So you added a run-time guard asserting they still agree, refusing with exit 2 if they drift:

a drifted declaration is a lying gate, which is worse than a silent one

#9639's template made a declaration and a scan one fact within a single file. Yours could not, because the second spelling lives in another file it does not own — so you made the disagreement loud instead of pretending it cannot happen. That is the right generalisation, and exit 2 is the right code: an environment/config verdict, not a tree finding, matching the posture where-matcher and check-governed-merges already use.

The CHANGE_KIND_GATES restraint — from the table's own rule, not from mine

Ruling 1 said no hand-written entry. You did not add one and cited the table's own deletion criterion: "when a gate grows a literal naming its POPULATION, the ordinary derivation names it." So the decision rests on the file's stated policy rather than on my instruction happening to agree with it. And the card's note about --self-test naming five convention gates correctly does not apply, because the convention section is untouched.

⭐ H2 — both directions, at full scale, and the no-false-leads half is the harder one

110 families × 6249 tracked files, rebuilt from dispatch-gates' own exports:

  • watch-hint pairs 20,988 → 25,739 (+4,751, ZERO lost);
  • exactly ONE family gains coverage;
  • every one of the 4,751 gained pairs is under packages/ — none on apps/, examples/, content/, scripts/, .changeset, .github/workflows, docs/adr, skills/, docker/;
  • per-surface across 22 probed card surfaces: the 10 under packages/ each gain exactly one family; the 12 outside gain nothing.

Against #9639's refused alternative — +139,084 pairs and 7→34 families on one card — that is the difference between a declaration and a heuristic, quantified.

And the honest cost is volunteered, not extracted:

89.7% of the added reach is TS files the gate really lints; the other 10.3% is .json/.md/extension-less files under packages/extension precision cannot survive glob collapse in the hint language

A dev reporting the imprecision in their own fix, with the mechanism that makes it unavoidable, is worth more than a clean number.

The reverse verification refuses to over-claim

the failing case is the positive pin — the three negative cases pass under ablation by construction; they are the no-false-leads half, and I state that rather than claiming four pins

Reverting the ratchet file gives 1 of 288 failed, and you explained why it is one and not four instead of reporting four pins as if all had fired. That is the same discipline as this lane's "a mutation that never applied is not a green" — applied to a mutation that legitimately cannot move three of its assertions.

And the second direction: mutating the declaration to packages/core/**exit 2 naming both spellings, rather than silently measuring a smaller set.

Plus the control that proves the refactor is behaviour-preserving: check:slot-lookup prints 107 unswept site(s) in 25 file(s), none new identically before and after — the derived target scans the same set. Changing how a target is spelled without changing what it scans is exactly the thing to prove.

⭐ H4 — the census, and it is the most actionable thing on #9747

15 of 110 families are structurally silent for EVERY card in the tree. Five reach zero tracked files, and two of those are almost comic:

Ten more reach only their own artifact or script; three are covered by a CHANGE_KIND_GATES entry, one was this card, leaving six uncovered.

And the recommended shape is the first cheap, concrete implementation anyone has proposed for #9747's third verdict:

a distinct unreachable verdict (hint set covers zero tracked files) is one hintCovers sweep over git ls-files and needs no per-gate knowledge

with the boundary correctly drawn: the per-gate fix stays a judgement, because a broad declaration is only justified when the gate is cheap to run — and you priced this one (53s, no build) to show it qualifies. A gate that takes 20 minutes should not declare packages/**.

Posting it to #9747 as evidence rather than filing a fifteenth card was the right call, and I have amended that card with it.

Verdict: ACCEPT. Arming once the two running gates converge.


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review August 19, 2026 05:16
@os-steve
os-steve added this pull request to the merge queueAug 19, 2026
Any commits made after this event will not be merged.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/sskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

1 participant

@os-steve