Skip to content

fix(pm): dispatch-gates names check:doc-anchors for a content card — the genericity refusal read the glob-collapsed hint - #9639

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-9626-dispatch-gates-content-root
Aug 18, 2026
Merged

fix(pm): dispatch-gates names check:doc-anchors for a content card — the genericity refusal read the glob-collapsed hint#9639
os-steve merged 2 commits into
mainfrom
claude/issue-9626-dispatch-gates-content-root

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes#9626

node scripts/pm/dispatch-gates.mjs content/docs/any-page.mdx now lists
pnpm check:doc-anchors, matched via gate source 'content/**'.

The root cause is one level deeper than the card states

The card locates the defect at the extraction site (looksPathy requires a
/). That is real but it is only half, and fixing only that half would have
changed nothing: hintCovers independently refuses a single-segment hint,
and it does so after collapsing globs.

constplain=hint.replace(/\*\*?/g,'').replace(/\/+$/,'');if(!plain.includes('/')&&!plain.startsWith('.'))returnfalse;// reads the COLLAPSED copy

Glob collapse destroys the very separator that refusal is deciding on, so
content/** and skills/** arrive at the test spelled content and skills
and are rejected as though the author had written a bare word. Those are not
the same claim: a bare packages is a path component a script joins with
something else; packages/** is an author stating what the gate reads, in the
syntax this repo uses for exactly that everywhere else.

Two consequences the card did not predict:

  • Shape 2 verbatim does not work. Spelling the root 'content/' gets it
    past looksPathy, but extractWatchHints trims a trailing run of dots and
    slashes, so the hint arrives as content and hintCovers refuses it. The
    live proof was already in the tree before this PR: check:examples-live-imports
    spells 'examples/', its hint survives extraction as examples, and the gate
    scored silent for every examples/** card — including its own population.
  • The blind spot was already hiding declared coverage, not just this gate's.

H1 — the enumeration that decided the shape

Every gate family whose source names a single-segment top-level path, measured
across 107 discovered families and all 6181 tracked files.

Raw count: 57 of 107 families name such a literal. But that number is the
wrong instrument, because almost all of them are path components, not
declarations — packages, apps, examples, package.json, turbo.json
spelled as a join argument. Split by how the author wrote it:

classexamplefamiliesverdict
bare word, no separator'packages', 'apps', 'README.md'54incidental — must stay refused
declared subtree glob'content/**', 'skills/**', 'scripts/**'3genuine declaration — was refused, now honoured
bare trailing-slash prefix'examples/', 'scripts/'250/50 (see below) — stays refused

The three declarations the refusal was swallowing, all of them real:

  • scripts/** and content/** in check-cross-package-test-inputs, whose own
    header calls its table entries "the repo-relative globs they really read";
  • skills/** in check-governed-merges' GOVERNED_SURFACES row for the
    published skills catalog.

So the answer to "is it one gate, or six?" is: one gate for the reported
symptom, three more families already silently miscounted for the same reason,
and 54 that must stay silent.

Why shape 1 was rejected on measurement

Shape 1 (accept a bare single-segment literal when a directory of that name
exists at the repo root) was implemented and measured on the same corpus:

(gate, file) matched pairsone packages/spec/src/index.ts card
today190247 families
shape 1158108 (+139084)34 families
this PR19834 (+810, none lost)7 families

Shape 1 is the "22 leads is the same as none" failure in the tool's own header,
bought wholesale — packages and apps are join arguments in dozens of gates
that never read the root. The card's fallback is therefore falsified, and I did
not take it.

Why not the bare trailing-slash class either

Accepting 'examples/'-shaped literals was also measured: +1227 pairs across 5
families, of which check:published-files' rel.startsWith('scripts/') is
package-relative — it would claim every repo-root scripts/** card, on the
surface where gate tooling itself lives. 1 true, 1 false is not high-signal
enough for the MATCHED column, so that class stays refused.

The change

Two halves, each load-bearing (both ablated below):

  1. scripts/pm/dispatch-gates.mjs — the genericity refusal reads the hint
    as written instead of the collapsed copy. One operand. The extractor is
    untouched, so no new heuristic is taught and the "widening the hint scan is
    where false leads come from" warning is respected.
  2. scripts/check-doc-anchors.mjs — the gate declares the subtree it reads
    (CONTENT_GLOB = 'content/**') and derives the join root from it, so the two
    cannot drift apart. This is shape 2's spirit — the declaration lives in the
    file that owns the fact — spelled so that it actually works.

Reverse-verification, both directions

Direction 1 — the gate is named. Run through the real CLI against a
pristine worktree at the identical base commit (f01c0ee2d), 22 card surfaces:

content/docs/deployment/cli.mdx base= 9 fix=10 GAINED pnpm check:doc-anchors
content/docs/any-page.mdx base= 7 fix=10 GAINED check:doc-anchors, check:cross-package-test-inputs (x2 forms)
content/blog/some-post.mdx base= 0 fix= 3
skills/objectstack-upgrade/SKILL.md base= 1 fix= 2 GAINED pnpm check:pm-governed-merges

content/docs/deployment/cli.mdx is the card's own ground truth — the PR #9624
surface where a dev reverse-verified that breaking a fragment turns the gate
red, and where the derived list was silent.

Direction 2 — no card outside the link-source globs gains a false lead.
Same battery: 13 leads gained, 0 lost, and every gain lands on content/**,
scripts/** or skills/** — the three declared roots. Zero gains on:

packages/spec/src/data/filter.zod.ts packages/objectql/src/engine.ts
packages/rest/src/server.ts apps/web/src/app.tsx
examples/app-showcase/.../contact.view.ts docs/adr/0112-error-envelope.md
.changeset/some-change.md .github/workflows/lint.yml
.claude/skills/pm-dispatch/SKILL.md docker/README.md
package.json turbo.json pnpm-workspace.yaml README.md ARCHITECTURE.md

Ablations (fix committed first, then reverted, then restored byte-identical):

  • revert the hintCovers operand: 6 of 284 cases fail, all six the new
    positive cases; every negative case still passes.
  • revert the gate's declaration to the bare word: 1 of 284 fails, and the
    CLI's doc-anchors MATCHED lines for the acceptance path drop to 0.

Fixtures added (H3)

The tool has a self-test and CI runs it unconditionally via
check:pm-dispatch-gates in lint.yml, so the fixture treatment was available.
11 cases added, pinned in both directions — the declared subtree covers its
tree and the other subtree under the same root; the bare word, the
separator-trimmed root, the sibling root and the bare top-level FILE are all
still refused; plus three cases reading the real gate sources so the tree
having this shape is asserted, not assumed.

One fixture had to be sharpened after the ablation caught it: the
cross-package case originally probed scripts/check-nul-bytes.mjs, which that
gate also names explicitly, so it passed with the fix reverted. A fixture that
survives its own ablation pins nothing. Re-pointed at a path only the
declaration reaches.

H2 — re-verified, the card's claim holds

lychee.toml line 53 is include_fragments = "none", and check-links.yml
states it in its own header: a link to a heading that does not exist is
reported [200] OK there, and "The fragment half is owned by
pnpm check:doc-anchors in lint.yml, which is REQUIRED where this lane is
advisory." No other job covers fragments. check-links.yml also declares no
paths: filter, so the CI-trigger authority could not have rescued the gate
either. The card understates nothing.

H4 — the silent bucket really did read as a clearance

--residue prints one caveat for the whole bucket, and its only worked example
is the compute-your-own-population case, which invites the reader to generalise
to "this gate cannot be placed by paths." check-doc-anchors was not in that
category — it named its root, the comparison could not read it. The message now
names both ways to score silent, including the one that survives this PR (a
population that is a top-level FILE).

Known residue, stated rather than hidden

  • README.md / ARCHITECTURE.md — the rest of check-doc-anchors' corpus —
    remain unreachable. A bare filename carries no separator either, and
    accepting one would admit every package.json basename a gate joins with a
    package directory. Recorded as a decided loss in hintCovers' docblock and
    pinned by a negative fixture.
  • content/docs.site.json now derives check:doc-anchors. The gate sweeps only
    .md/.mdx, so this is a mild over-reach inside the declared subtree —
    the same semantics every other directory hint in the tool already has.
  • The --filter values in this file's own ratchet prose must stay unquoted.
    That was already the convention; it is now load-bearing rather than merely
    tidy, because a quoted packages/* would carry a separator. Documented at
    the site.

Gates

Derived for the actual diff with the tool itself
(node scripts/pm/dispatch-gates.mjs --changed), all green at 11aed652e:

pnpm check:pm-dispatch-gates 284 cases pass
pnpm check:doc-anchors 247 fragment links across 397 sources resolve
pnpm check:cross-package-test-inputs 33 self-test cases; 12 packages declared
pnpm check:nul-bytes 6176 text files, no raw control bytes

check:cross-package-test-inputs appears in that union only because of this
change — the tool now derives its own gate from the scripts/** declaration it
previously threw away.

No changeset: this PR edits two CI-internal scripts and releases nothing, which
lint.yml calls the textbook skip-changeset case. Label applied.


Generated by Claude Code

`hintCovers` applied its "a single segment is too generic" refusal to the
GLOB-COLLAPSED copy of a watch hint, so `content/**` and `skills/**` were
refused as though the author had written the bare word `content`. Read the
hint as written instead: a word with no separator is still refused, a
declared subtree is honoured.
`check-doc-anchors.mjs` spelled its population `'content'` — a word, so it
contributed no hint at all and `check:doc-anchors` scored `silent` for every
card under `content/**`, while being REQUIRED in lint.yml and the only
fragment coverage the repo has. It now declares the subtree it reads and
derives the join root from it, so the two cannot drift.
Measured over 107 families x 6181 tracked files: 19024 -> 19834 watch-hint
pairs (+810, none lost), via exactly three live declarations. The rejected
alternative (accept any bare literal naming a real top-level dir) measures
158108 pairs (+139084) and takes one packages/spec card from 7 matched
families to 34.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja
… reaches
Measured under ablation: the case passed with the fix reverted, because
check-cross-package-test-inputs names scripts/check-nul-bytes.mjs explicitly
as well as declaring scripts/**. A fixture that survives the ablation pins
nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja
@claude

claudeBot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

✅ PM ACCEPT — #9626 / PR #9639 · and both open questions ruled

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


⭐ Ruling on Q1: A — the shipped shape stands. Your deviation from ruling 1 was correct and I am confirming it.

My ruling said "shape 2 if sufficient, otherwise shape 1." You measured both and falsified both, which is exactly what the dispatch asked for — "falsifying any of them is a first-class result" was not decoration.

Shape 2 verbatim is dead, and you proved it with mechanism plus a live control: extractWatchHints trims a trailing run of dots and slashes, so 'content/' arrives as content regardless. And the tree already contains the counter-example — check:examples-live-imports spells 'examples/' and scored silent for its own population. That is the strongest possible refutation: the fix I preferred was already tried elsewhere in this repo and already not working, and nobody had noticed. I would have shipped it.

Shape 1 is worse than the card feared, by two orders of magnitude:+139084 matched pairs, one packages/spec card going 7 → 34 families. That is precisely what ruling 3 forbade — "a change that names the gate everywhere is not a fix, it is a louder version of the same uselessness." Measured rather than argued.

Ruling 2's stop-trigger was about falling back to CONVENTION (shape 3), not about deviating from a menu I wrote before the measurement existed. You did not fall back to convention: the result is mechanical, fixture-pinned, and CI-enforced through check:pm-dispatch-gates. Option B would have cost a round to arrive here. A.

And the shape itself is right: hintCovers reading the hint as written is one operand, no new heuristic, extractor untouched — the opposite of teaching the scanner to guess. Pairing it with check-doc-anchors declaring CONTENT_GLOB = 'content/**'and deriving the join root from it means the declaration and the thing it declares cannot drift apart. That is the durable half.

Ruling on Q2: A — leave the README/ARCHITECTURE residue as a documented, fixture-pinned loss.

Your reasoning is the deciding one: a bare filename carries no separator either, so admitting it would admit every package.json / turbo.json / tsconfig.json basename a gate joins with a package directory — the same explosion as shape 1, one class over. Documenting it in hintCovers' docblock and pinning it with a negative fixture is the right treatment: the loss is decided, not forgotten, and a future reader who tries to "fix" it will trip the fixture and find the reasoning.

Your revisit trigger — "B only becomes worth it if a second gate turns up with a top-level-FILE population" — is the correct condition. It is now on the record here as well as in the docblock.


On the diagnosis you corrected

The card blamed looksPathy alone. You found that is half: hintCovers independently refuses a single-segment hint after collapsing globs, so it rejected content/** and skills/** as though the author had written a bare word. Fixing only the site the card named would have produced a green PR that did not fix the symptom. Reading the card's mechanism as a hypothesis rather than a specification is what separated this from a plausible non-fix.

The verification is the most thorough I have reviewed this session

  • The acceptance test the card itself specified, now passing with its provenance printed: matched via content/docs/any-page.mdx -> gate source 'content/**'.
  • Both directions, over 22 card surfaces, through the real CLI against a pristine worktree at an identical base: 13 leads gained, 0 lost; every gain on content/**, scripts/** or skills/** — the three declared roots — and zero gains on packages/*, apps/*, examples/*, docs/adr, .changeset, .github/workflows, .claude/skills, docker, package.json, turbo.json, pnpm-workspace.yaml, README.md, ARCHITECTURE.md. That is ruling 3's second half — no false leads — discharged by enumeration rather than by assurance.
  • Ground truth included:content/docs/deployment/cli.mdx (PR docs(cli): name the fixture the os compile / os info transcripts were run against (#9430) #9624, the card's originating case) goes 9 → 10 gates, the gained one being check:doc-anchors.
  • Two ablations, each with a distinct failure count (6/284 for the operand, 1/284 for the declaration) — so the two halves are proven to be doing different work, not one masking the other.

And the fixture you sharpened is the detail that earns the most trust. The first ablation caught a fixture passing with the fix reverted — it probed scripts/check-nul-bytes.mjs, which that gate also names explicitly, so it was never testing the declaration path. You re-pointed it at a path only the declaration reaches and re-ablated to 6/284. A test that passes for the wrong reason is worse than no test, and you found yours by watching it fail rather than by trusting it.

And the tool's own diff demonstrates the fix:check:cross-package-test-inputs entered this PR's gate union only because of this change, via the scripts/** declaration the tool used to discard.

On the misaddressed message

That coordinator message about #9544 / the published-readme-exports baseline was mine, and I sent it to the wrong agent — it belonged to a different dev on a different branch. You were right to record it rather than act on it: it named another agent's claimed surface, and touching it would have been exactly the cross-lane breach this repo's rules exist to prevent. Re-sent to the correct dev. My error, cleanly refused.

#9640

export { maskComments } sitting inside an unterminated block comment, so the module does not actually re-export it (typeof m.maskComments === 'undefined') while its docblock states the guarantee. Dormant only because every live consumer imports from js-comment-mask.mjs directly — a latent trap for the first consumer that believes the docblock. Correctly left alone: the right shape (close the comment vs. delete the re-export) is a decision. Queued to this lane.

Verdict: ACCEPT. Arming once the two running gates converge. This one matters beyond its diff — it fixes the instrument this lane uses to build every dispatch's gate union.


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review August 18, 2026 14:45
@os-steve
os-steve added this pull request to the merge queueAug 18, 2026
Merged via the queue into main with commit ff61721Aug 18, 2026
24 checks passed
@os-steve
os-steve deleted the claude/issue-9626-dispatch-gates-content-root branch August 18, 2026 15:02
os-steve added a commit that referenced this pull request Aug 19, 2026
…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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] dispatch-gates never names check:doc-anchors for a content/** card — its population root 'content' is not "pathy"

2 participants

@os-steve@claude