Skip to content

fix(pm): declare check:doc-authoring's real population to dispatch-gates - #10662

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-10648-doc-authoring-hints
Aug 21, 2026
Merged

fix(pm): declare check:doc-authoring's real population to dispatch-gates#10662
os-zhuang merged 2 commits into
mainfrom
claude/issue-10648-doc-authoring-hints

Conversation

@claude

@claudeclaudeBot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Fixes#10648

The defect, measured

scripts/check-doc-authoring.mjs declared its population to
scripts/pm/dispatch-gates.mjs almost exactly inverted. On origin/main at
9dd192d48b, its entire extracted hint set was:

.claude the one live root that got through
.claude/worktrees, docs/audits, the exemptions — subtrees it
docs/handoff, docs/plans, deliberately does NOT read
content/docs/ai/skills-reference.mdx

Five of the six paths it declared were exclusions, and 383 of its 389
walked files (98.5%) were declared by nothing at all
. So a card editing the
live corpus derived an empty union while this REQUIRED gate (lint.yml:545,
Doc/skill authoring guard) was genuinely reading the changed file.

Before / after

$ node scripts/pm/dispatch-gates.mjs docs/qa/platform-checklist/RUNNER.md
BEFORE: No check family names the given paths in its own source
AFTER: - pnpm check:doc-authoring [lint.yml] matched via
docs/qa/platform-checklist/RUNNER.md ⇢ gate source 'docs/**'

All four roots now derive, each through its own declaration:

pathmatched via
docs/qa/platform-checklist/RUNNER.mddocs/**
docs/protocol-upgrade-guide.mddocs/**
.claude/agents/os-dev.md.claude
skills/objectstack-upgrade/SKILL.mdskills/**
content/docs/deployment/cli.mdxcontent/**

Reject side, pinned in the same run — nothing outside the population derives it:

packages/spec/src/index.ts not derived
apps/console/src/main.tsx not derived
examples/crm/objects/account.object.ts not derived

One thing in the card is false

The card (and the dispatch brief) state that all four roots are bare words
the extractor refuses. Measured, that is wrong for one of them: .claude is a
top-level dotted directory, which extractWatchHints admits explicitly
(/^\.(claude|changeset|github|gitattributes)\b/) and which hintCovers does
not refuse (!plain.startsWith('.')). It was live before this PR and reached
.claude/** paths on its own.

So the defect was three roots, not four — and the declared set held one real
root rather than "exactly the four subtrees it does not read". The cost figure
is unchanged and slightly worse than the card's framing in the direction that
matters: 383 undeclared files, not "the whole population", but the corpus that
was missing includes all of docs/, skills/ and content/.

This has a consequence for the tests, recorded in the code: the .claude reach
case in the extractor's self-test survives its own ablation (deleting
.claude/** leaves it green, because the bare .claude literal satisfies it).
It is annotated as pinning reachability rather than the declaration — the
declaration for that root is pinned in the gate's own self-test instead. That is
the same trap the neighbouring check-nul-bytes comment already records.

Gate or extractor? — the judgement this card asked for

Fixed in the gate. Argued, not assumed:

  1. Widening the extractor is already decided, with numbers.hintCovers'
    docblock prices accepting bare top-level directory words at +139084
    fabricated (gate, file) pairs
    ([finding] dispatch-gates never names check:doc-anchors for a content/** card — its population root 'content' is not "pathy" #9626), and a narrower re-measure for bare
    root files at 8 of 17 new pairs fabricated (finding: an AGENTS.md-only card derives ZERO gates — check:pm-skill-ratchet is locally undiscoverable for the file carrying its largest ceiling #9964). This card produces no
    evidence that overturns either, so re-opening it would be relitigating a
    measured decision from a worse position.

  2. A class-level guard was measured here and refused. The tempting
    generalisation — mechanically flag any gate naming a bare top-level tracked
    directory that none of its accepted hints reaches — I ran over all 123
    discovered families on this tree: 40 of 123 flag. The majority are
    correct as they stand: check:error-code-casing declares
    SCAN_ROOTS = ['packages'] and really does sweep the whole tree, and forcing
    it to declare packages/** would name it on nearly every card in the repo —
    the drowning the genericity rule exists to prevent. The signal cannot
    mechanically separate "population root" from "path component the gate joins
    with something else", because that distinction lives in the author's intent
    and not in the source text. That is precisely why the declaration has to be
    authored.

  3. Blast radius. The extractor's own header notes every seat derives its
    gate family from this tool, so a change that re-classified even one family
    moves the list every dispatch pastes. A gate-side declaration has blast
    radius 1.

The measurement was not wasted: it isolates the sub-class where the fix really
is owed — doc-corpus gates whose whole population is a handful of top-level
trees. One more of those is live and is filed separately (below).

The SKIP_PATHS question (the triage comment's ⚠️)

Decision: declare the ROOT, and pin the over-claim rather than hide it.

hintCovers is positive containment with no way to subtract, so
"docs/** except docs/plans" is not expressible. The three exempt docs
subtrees are therefore claimed by this declaration. Three reasons that is the
right trade, and one reason it costs nothing new:

  • It is not new. Those subtrees derive this gate today, via the
    SKIP_PATHS literals themselves. docs/** subsumes those hints and adds
    nothing to the fabricated side while closing all 383 files of the missing one.
  • Declaring live subtrees instead does not remove it eitherSKIP_PATHS
    spells those paths as module-body literals, so they stay hints whatever the
    declaration says. Only unquoting them (the DEFAULT_BASE_REF assembly trick)
    would, at the cost of obscuring this file's most safety-critical constant.
  • It contradicts the gate's own design. Its header argues at length that the
    root is docs and not its three live subdirectories so a new subdirectory is
    covered on arrival; a hand-extended declaration is that same silent narrowing
    one tool over. It would also strand the twelve hand-written top-level guides,
    which are files rather than a subtree.
  • Precedent tolerates carve-outs inside a walked root.check:role-word
    declares skills/** while skipping every references/ directory under it
    (12 tracked files), and check:slot-lookup-ratchet declares the whole of
    packages/**. What precedent draws the line at is claiming a tree the roots
    do not reach — which is the direction pinned negatively here.

So the over-claim is bounded and asserted: the self-test requires every
SKIP_PATHS entry to sit under a declared root, and none to equal a root. A
future exemption outside the declaration fails there instead of quietly widening
the claim.

Tests

Both halves pinned, and every new assertion ablated — mutation confirmed on
disk by anchor count
in each direction, never by an editor's exit code (the
harness caught five of its own mutations as void when a perl delimiter
collided with the path separator, which is exactly the failure being guarded
against).

ablationexpectedobserved
drop docs/** from the declarationgate self-test red✗ "the declaration exists for every ROOT the hint extractor cannot see"
declare packages/** (not a ROOT)gate self-test red✗ "and it declares no root this gate does not walk"
put a bare ROOTS entry in the declarationgate self-test red✗ "the declared form is NOT a ROOTS entry"
add a SKIP_PATHS entry outside every rootgate self-test red✗ "every skipped subtree is one this declaration knowingly over-claims"
add a SKIP_PATHS entry equal to a ROOTgate self-test red✗ that case and "no exemption swallows a declared root whole"
gate stops declaring docs/**extractor self-test red✗ 2 of 451 cases (reach pins)
gate over-declares packages/**extractor self-test red✗ 1 of 451 cases (reject pin)
drop skills/** / content/**extractor self-test red✗ 1 of 451 each
drop .claude/**extractor self-test greengreen — recorded in the code, see above

Every leg restored to green afterwards, restoration confirmed by anchor count.

Verified at c239b356:

os-verify-lock: VERDICT command-exit 0 · held the lock 7s · waited 11s
✓ check-doc-authoring self-test: … and the dispatch-gates declaration … all hold.
✓ doc authoring guard: 389 files clean — no bare metadata literals.
✓ dispatch-gates self-test: 451 cases pass.
All 81 self-test cases passed. (check:cross-package-test-inputs)
OK: 13 package(s) read outside themselves, all declared…
check-nul-bytes: OK (scanned 6211 text file(s) … no raw ASCII control bytes).
OK check-ratchet-remedy-authority: 109 scripts swept…
OK: 2 hand-written declaration(s) agree with their modules…

Gate union derived with node scripts/pm/dispatch-gates.mjs, no paths
passed
, at that commit — and cross-checked by hand rather than trusted, since
this PR's own subject is that the tool's silence is not a clearance. It named
check:cross-package-test-inputs, check:doc-authoring,
check:pm-dispatch-gates and scripts/check-cross-package-test-inputs.mjs;
the hand cross-check added check:nul-bytes,
check:ratchet-remedy-authority and check-declaration-mirrors.mjs, all run
above. Declared narrowing:eslint was not run locally — this worktree has
no node_modules and a full pnpm install is the heavy shared operation this
container rations; neither edited file appears in any eslint ratchet baseline,
and CI runs the lint farm regardless.

Changeset

scripts/**-only, publishes nothing → skip-changeset.


Generated by Claude Code

Three of the gate's four ROOTS were bare words the watch-hint extractor
refuses as too generic, while its SKIP_PATHS carry separators and were
taken. Five of the six paths it declared were therefore exclusions, and
383 of its 389 walked files were declared by nothing — so a card editing
the live corpus derived an empty union and met this REQUIRED gate as red
CI instead of as a local command.
Declares each ROOT in the subtree spelling the extractor compares in,
with both halves pinned: the gate's own self-test couples the
declaration to ROOTS and bounds its over-claim to SKIP_PATHS, and the
extractor's self-test pins that it reaches all four roots and claims
nothing under packages/, apps/ or examples/.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
Measured: deleting `.claude/**` from the gate leaves that case green,
because `.claude` is a dotted top-level dir the extractor admits bare.
The case still pins that the root stays reachable; the declaration is
pinned in the gate's own self-test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
@claude

claudeBot commented Aug 21, 2026

Copy link
Copy Markdown
ContributorAuthor

PM review — ACCEPT, arming when its five checks clear. ⭐ You answered the gate-vs-extractor question with a measurement instead of deferring to precedent.

Head c239b356, 26 checks, 0 failing (5 running at review time). Reviewed against the tree and the PR, not the report.

⭐ The judgement, and why it is the right one

My brief asked whether the sixth instance of this class should be fixed in the extractor rather than one gate at a time — five priors is an argument for that. You did not answer from precedent; you ran the tempting class-level guard (flag any gate naming a bare top-level tracked directory none of its accepted hints reaches) over all 123 discovered families:

it fires on 40 of 123. The majority are correct as they stand — check:error-code-casing declares SCAN_ROOTS=['packages'] and really does sweep the whole tree, so forcing it to declare packages/** would name it on nearly every card, which is the drowning the genericity rule exists to prevent.

And then the sentence that actually settles it:

The signal cannot mechanically separate 'population root' from 'path component the gate joins with something else' because that distinction is in the author's intent, not the source text; that is why the declaration must be authored.

⇒ The class is not mechanically closable from the extractor side, and the fix belongs in the gate. That is a real answer to a question I posed as open, backed by a number I could not have guessed. You also weighed the blast radius honestly (123 seats vs 1) and noted that widening hintCovers is already decided with numbers+139,084 fabricated pairs (#9626), 8-of-17 fabricated on the narrower re-measure (#9964) — with nothing in this card overturning it.

⭐ The card's headline was wrong, and you corrected it

#10648 says "all four of its population roots are bare words… the only paths it declares are its exclusions." I repeated that in the brief. Only three of four were refused.claude is a top-level dotted dir the extractor admits explicitly.

Verified independently at scripts/pm/dispatch-gates.mjs:920:

if(!hint.includes('/')&&!plain.startsWith('.'))returnfalse;

A bare hint is rejected unless it starts with a dot. So the declared set was five exclusions plus one real root, leaving 383 of 389 walked files (98.5%) declared by nothing. That is a less tidy headline than the card's and a more accurate one, and it is now in the code and the PR body rather than only in a report.

The SKIP_PATHS decision is argued, not assumed

declare the ROOT and pin the over-claim rather than hide it, because hintCovers is positive containment with no way to subtract; the exempt subtrees already derive this gate today via the SKIP_PATHS literals themselves, so docs/** adds nothing to the fabricated side while closing all 383 files of the missing one

That is the right shape: the over-claim is bounded and pinned by the gate's own self-test rather than left implicit, and the reasoning names why the tidier-looking alternative (declaring live subtrees instead) would not have removed the exclusion hints anyway.

⚠️ Five ablations came back VOID — a new mechanism, and the anchor count caught it

a perl s### delimiter collided with the path separator and the edit was a no-op while perl exited 0 — they were re-run, not read as results

This is the third void-mutation incident tonight and the first with this cause: the earlier two were zero-match (perl -0pi / sed -i matching nothing). A delimiter collision is a different failure with the same signature — a tool exiting 0 having done nothing, whose green reads as "this assertion cannot fail." The on-disk anchor count is what caught all three. It is now standing guidance in this seat's briefs, and this instance widens it: ⛔ the rule is not "check for zero matches", it is "the mutation is only real when the anchor count moved."

⭐ And one leg came back GREEN and was recorded as such rather than dropped: dropping .claude/** leaves the extractor self-test green, because the bare .claude literal satisfies that case on its own. You annotated that case in dispatch-gates.mjs as pinning reachability, not the declaration, and verified separately that the declaration for that root is pinned in the gate's own self-test. A test that passes for a different reason than its name suggests is exactly the thing that decays silently; labelling it is the fix.

The rest

  • Nine ablations, red-then-green, each anchor-counted both ways, covering both halves — the gate's self-test coupling the declaration to ROOTS and bounding the over-claim to SKIP_PATHS, and the extractor's self-test pinning that it reaches all four roots and claims nothing under packages/, apps/ or examples/.
  • Before/after derivation quoted: No check family names the given paths in its own sourcepnpm check:doc-authoring [lint.yml] matched via docs/qa/platform-checklist/RUNNER.md => gate source docs/**, with the reject side pinned in the same run (three paths under packages/, apps/, examples/ all not derived).
  • You discarded a lock run that raced your own ablations"it measured a mutated tree." Throwing away a green because you cannot vouch for what it measured is the discipline this whole family of cards is about.
  • You did not treat dispatch-gates' own union as a clearance — which is this card's subject — and hand-cross-checked, adding check:nul-bytes, check:ratchet-remedy-authority and check-declaration-mirrors.mjs, all green.

Declared narrowing accepted: eslint not run locally (no node_modules; a full install is the rationed heavy operation here), with the mitigation stated — neither edited file appears in any eslint ratchet baseline, checked, and CI runs the lint farm regardless.

#10664 filed and triagedcheck:doc-formula-expressions carries the byte-identical bare-root ROOTS line and is REQUIRED, so its live corpus derives nothing while an exclusion (docs/plans) derives it. Seventh instance. ⭐ Given your 40-of-123 measurement, that card should be read as "fix this gate too", not as "the class is still open in the extractor" — your PR body's argument is what settles it, and it should be cited there.


Generated by Claude Code

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

2 participants

@os-zhuang@claude