Skip to content

Resolve TypeScript-authored gates in the dispatch derivation - #12247

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-12107-dispatch-gates-ts-extensions
Aug 25, 2026
Merged

Resolve TypeScript-authored gates in the dispatch derivation#12247
yinlianghui merged 2 commits into
mainfrom
claude/issue-12107-dispatch-gates-ts-extensions

Conversation

@yinlianghui

@yinlianghuiyinlianghui commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Fixes#12107

resolveCheckToFiles matched only .mjs|.cjs|.js|.sh, so every gate whose npm script named a
.ts/.mts/.cts file resolved to zero files. discoverFamilies never opened the source, so
those families contributed no watch hints, no first-party import following, no
declaredNoPathPopulation read, and no gateFiles entry — and scored undetermined for every
card in the tree, indistinguishable from a gate whose author declined to declare a population.

File surface: scripts/pm/dispatch-gates.mjs (including its --self-test), plus
scripts/pm/bare-root-worklist.mjs's TRIAGE map
— the second file added by an explicit PM
surface widening, for the landing obligation this fix creates (see "The bare-root obligation"
below). No gate script, package.json or workflow is touched.

Derived at the final commit d821f7f25c.


What changed

Three changes in the derivation (dispatch-gates.mjs):

  1. Widen the extension alternation to ts|mts|cts.
  2. Resolve a package manifest's spelling against the manifest's own directoryresolveCheckToFiles
    takes { dir } and returns tracked paths, replacing the caller's .map((f) => join(base, pkgDirGuess, f)).
  3. Anchor the alternation on its right, so an extension that is a prefix of a longer one no
    longer matches as itself. (The literal spelling is a negative lookahead; see the comment below —
    GitHub's sanitiser eats it from a PR body.)

Plus two TRIAGE rows in bare-root-worklist.mjs, recording the two bare roots that change 1
makes visible.

Premise re-verified on my own base

Base f11fc61c51. The reproducer was run, not recalled:

ZERO-FILE FAMILIES: 24 of 167

Same as the seat claim measured at 9abe4e4827. PR #11934 (a187fe612b) was checked by diff
rather than by title: its hunks land at repoIdentity/DERIVATION_SURFACE/baseDrift/driftLines,
bannerLines, and the self-test block — resolveCheckToFiles and discoverFamilies are untouched
by it, so this fix sits cleanly on top.

The measurement, in both directions

firstPartyImportTargets' docblock is the standard this is written to, and the same numbers are in
the code. Over 167 families x 6763 tracked files:

beforeafter
watch-hint (gate, file) pairs7327874481 (+1203, 0 lost)
zero-file families241
families gaining coverage19
families losing coverage0
gate files naming a path that does not exist00
existing matches re-attributed4
import-follow edges suppressed0
inherited hints lost0

The addition is real, not fabricated. Of the hints these 19 families newly contribute, 19 are
live
and 128 are inert (they cover zero tracked files, so they cannot put any gate in any
card's matched column). The +1203 is concentrated in three families that declare real corpora —
check:skill-examples +495 (packages/client, packages/client-react, content/docs, spot-checked
against that gate's own header, which names packages/client-react/src and packages/client/src),
check:docs +440, check:generated +234.

The 4 re-attributions are the one number that differs from the precedent's (which reports 0), so
they are reported rather than rounded.
check:liveness, check:empty-state, check:variant-docs
and check:strictness-ledger each already matched their own source file through
spec-liveness-check.yml's paths: filter, and now match it through identity instead — the via
label moves from CI trigger in spec-liveness-check.yml to gate script. No path enters or leaves
any family's matched list. That is coveringKey's own declared ordering (identity outranks a
trigger, because it is the stronger provenance), so these are re-attributions up.

The subtraction direction is zero for a structural reason, not by luck. Admitting these sources
makes them gateFiles, and discoverFamilies refuses to follow a module that is itself a gate
file. firstPartyImportTargets admits only relative specifiers resolving inside the root
scripts/ dir; 22 of the 23 live under packages/spec/scripts/, which no first-party follow can
reach, and the 23rd (scripts/check-exported-any-returns.mts) is imported by nothing. The mirror
question — opening 23 files also means following their imports, which is how a gate inherits a
population it never reads — is also 0: the 22 are out of reach by the same rule, and the root-dir
one imports ./check-regen-pending.mjs and ./invoked-as.mjs, both already gate files. The
live half of the self-test asserts this rather than trusting it.

#11556's fabrication surface: measured, and the assumption falsified in direction

The dispatch asked whether adding 23 sources to gateFiles makes #11556's inheritable-literal
surface worse, on the assumption it is a net subtraction. Measured on this tree,
extractWatchHints over scripts/pm/dispatch-gates.mjs:

BASE 9 hints, covering 2642 tracked files
AFTER 9 hints, covering 2642 tracked files (added: none, removed: none)

It is neither worse nor a subtraction — it is a no-op on both counts. The assumption's
conclusion (cannot fabricate, safe to land) holds; its direction does not. scripts/pm/dispatch-gates.mjs
is not a gate file before or after this change, so nothing about its followability moves.

Two corrections to the card, both measured

1. The card's table names package-relative spellings, not tracked paths. It lists e.g.
check:generated → scripts/check-generated.ts. The tracked path is
packages/spec/scripts/check-generated.ts — 22 of the 23 resolve through the package-filter branch
against packages/spec. The table is the command string's spelling, not the repo's.

2. The card's point 1 predicts the wrong mechanism, and the difference decides the fix. The
card expects packages/client/../../scripts/… — a path that resolves on disk but is not a
tracked-path spelling. That is not what happens. The old pattern was anchored on the literal
scripts/, so the match silently dropped the ../../, leaving join nothing to normalise.
Measured with the extensions widened and normalisation not yet in place:

zero-file families 24 → 1 (looks fixed)
phantom gate files 0 → 1 packages/client/scripts/check-exported-any-returns.mts
hints read by check:exported-any-returns: 0 (still silent)

The family leaves the honest undetermined bucket and stops being nameable as such, while
existsSync keeps the file closed so it still reads zero hints — the card's own trigger gate,
still silent, now silent behind a confident phantom identity key. That is strictly worse than the
bug, which is why the climb prefix is matched and normalised through join against the manifest's
own directory rather than stapled on afterwards.

The right-hand boundary — a narrowing that rides along, declared

The alternation was never anchored on its right, so an extension that is a prefix of a longer one
matched as itself:

'tsx scripts/check-x.mts scripts/fixtures/pins.json' → ['scripts/check-x.mts', 'scripts/fixtures/pins.js']
'tsx scripts/render.tsx' → ['scripts/render.ts']

The .json case was already live through the js branch; admitting ts adds the common one,
.tsx
— so closing it is part of this change's own correctness, not a tidy-up beside it. Both
produce the same output as the package-relative case above: a gate file that does not exist. It is
closed in the same edit and pinned by the same live assertion. Measured: adding the boundary
changes nothing on this tree
— pairs, gateFiles and the family map are byte-identical with and
without it — so it costs zero recall today.

What is deliberately still zero-file

check:app-nav-i18n. Its root script is pnpm --filter @objectstack/cli run check:app-nav-i18n
a composite that names a package and a script name, no path. No extension list can reach it;
resolving it means following a pnpm --filter … run … hop into another manifest, which is a
different mechanism. A result of 0 remaining would mean this fix had absorbed a family it cannot
honestly resolve.
The self-test asserts the residue is only ever composites of that shape, and
that it is non-empty — never that the count is zero.

What this does NOT buy

check:exported-any-returns, the gate the card was filed from, resolves its source now and still
contributes no watch hints
: that source declares no path literals and carries no
no-path-population marker. It scores undetermined before and after. The difference is the whole
point of the card and none of it shows in a pair count — before, the declaration was never read;
now it has been read and there is nothing there. The first is a defect in this derivation, the
second is a missing declaration on that gate, and only the second can be acted on by its author.

The bare-root obligation — surfaced, measured, and discharged here

Resolving the TypeScript sources un-voids the bare-root sweep for them. That is the card's own
prediction, not a side effect: the card records that the extension bug "silently voids the bare-root
sweep for these files"
, because that sweep walks the gate sources discoverFamilies resolves. Two
genuinely invisible bare roots come with it, both SKILLS_DIR = 'skills'.

Reverse-verified, and the evidence is kept visible rather than deleted now that it is green:

at base f11fc61c51 : node scripts/pm/bare-root-worklist.mjs --self-test EXIT=0
43 live row(s), 37 recorded verdict(s) — none stale, none missing
at 58d7643d19 : EXIT=1
x self-test: no gate has NEWLY joined the invisible bare-root species —
FRESH: check:skill-docs SKILLS_DIR skills · check:skill-refs SKILLS_DIR skills
at d821f7f25c : EXIT=0
45 live row(s), 39 unreachable as spelled, 39 recorded verdict(s) —
none stale, none missing

Both rows are recorded REFUSE-UNSPELLABLE.

The verdict is chosen on consistency with the existing sibling row, not on a readdir argument.
bare-root-worklist.mjs:301 already carries a skills row —
scripts/check-skills-token-ratchet.mjs SKILLS_DIR skills, REFUSE-UNSPELLABLE, "one named file
per child directory, 11 of 50 (22%)"
. That gate reads the same bare root the same way
(check-skills-token-ratchet.mjs:213 is readdirSync(dir, { withFileTypes: true }), and its line
205 states the same SKILL.md-less exclusion in prose) and reconciles the listing against an authored
per-file roster in both directions. So "it enumerates the root, so a new name is catchable" does not
distinguish these two gates from the one already recorded as unspellable. Both new rows measure
12 of 50 (24%) against that sibling's 22%; every REFUSE-WIDE row in the map sits at 39–90%.
One root, answered one way.

Each why is measured from its own gate's filter and cites that gate's own file:line. The map's
docblock forbids carrying a sibling's numbers into a new row by name — it records the
check-declaration-mirrors incident, where a copied why was wrong in both terms and --self-test
could not catch it (it audits verdict-set membership and why length, never what a whysays).
The two gates are not the same mechanism, and the rows say so:

  • check:skill-docsdoes enumerate the bare root (build-skill-docs.ts:221) and reconciles it
    against DISPLAY in both directions (:227-228) — but :222 admits a child only if it carries
    SKILL.md, so the population is that filename filter, not the root. Population: 11
    objectstack-*/SKILL.md + README.md = 12 of 50.
  • check:skill-refsnever reads the bare root at all. It iterates the authored SKILL_MAP
    (build-skill-references.ts:43-127,304), resolves skills/<name>/, and enumerates and prunes
    skills/<name>/references/ via manageDir/ownsReferenceEntry (:288-294). There is no
    readdirSync(SKILLS_DIR) in that file. Its true population is skills/*/references/**, which
    collapseHint reduces to skills//references/** — a double slash no tree can hold ([finding] A mid-segment * collapses to a double slash, so skills/*/references/_index.md names 9 real files and reaches none — and the family is filed as "THE LAYOUT MOVED" #12246) —
    and that is what leaves the bare root as the only spellable claim. Population: 12 of 50, 9 of
    them the _index.md it emits.

Self-test

dispatch-gates --self-test goes 595 → 614 cases, +19, covering every behaviour added:

  • each of .ts, .mts, .cts separately (an alternation regresses one branch at a time), plus a
    mixed .mjs+.ts command;
  • the negative half: a .json argument and a .tsx file are not gate scripts, while .ts still is;
  • package-relative normalisation — the in-package spelling, the climbing spelling, the wrong answer
    stated as a negative, dedupe on the normalised path, the climbs-clear-of-the-repo drop, and the
    unchanged root-manifest behaviour;
  • live half: every gate file the derivation names exists on disk (this is the assertion that
    catches the near-miss above); the climbing family resolves to the tracked root path; the tree
    really does resolve TypeScript gates; every remaining zero-file family is a pnpm composite and
    there is at least one; and the subtraction direction, written in the same shape as the existing
    self-test-family promotion assertion beside it.

bare-root-worklist --self-test needs no new case: its self-test already asserts that every live
row carries a verdict and that no verdict is stale, which is exactly what the two new rows satisfy.

Gates

Union re-derived at the final commit d821f7f25c with the committed tools:

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack # EXIT=0

This PR edits the derivation tool itself, so the union was derived both ways. At the first
commit the BASE tool and the committed tool produced a byte-identical local gate list — this
change does not move the union it is judged by. At the final commit the list grows from 9 to 10
families, and the tenth is node scripts/pm/bare-root-worklist.mjs --self-test, matched via gate
script identity
— the derivation naming the very gate whose ledger this PR edits, which is the
mechanism working rather than a change in it.

gateexitits own verdict line
check:agent-test-spelling0non-vacuity carried by --self-test, which drives the same sweep RED over a temp tree
check:cross-package-test-inputs0All 117 self-test cases passed. / OK: 16 package(s) read outside themselves, all declared
check:entry-guard0✓ check:entry-guard: 161 scripts/ file(s) — every entry guard goes through invoked-as.mjs
check:parse-guard0(green; tier read off the owning package.json)
check:pm-dispatch-gates0✓ dispatch-gates self-test: 614 cases pass.
check:pnpm-filter-targets0✓ check:pnpm-filter-targets: 136/173 --filter occurrence(s) across 28 file(s) resolve
scripts/check-ci-filter-parity.mjs0OK: all 96 declared cross-package glob(s) (81 unique) are covered
scripts/check-cross-package-test-inputs.mjs0OK: 16 package(s) read outside themselves, all declared
scripts/check-self-test-wired.mjs0✓ every one of the 132 script(s) CI runs that ship a --self-test has that self-test run by CI
scripts/pm/bare-root-worklist.mjs --self-test0OK self-test: 45 live row(s), 39 unreachable as spelled, 39 recorded verdict(s) — none stale, none missing
scripts/check-nul-bytes.mjs0OK (scanned 6757 text file(s) … no raw ASCII control bytes)

Every exit code was captured into a variable before any pipe. All eleven ran under
scripts/pm/os-verify-lock.sh at the final commit —
VERDICT command-exit 0 · held the lock 57s · waited 422s (7m02s). The wait was spent inside the
round, not by ending it.

Declared narrowing: no repo-wide pnpm lint was run — CI owns that farm run, and this diff is
two root scripts/ files with no eslint config change, so no untouched file's verdict can move.

Out-of-scope finding, filed not fixed

#12246 — a mid-segment * collapses to a double slash, so skills/*/references/_index.md
names 9 real tracked files and reaches none, and unreachableClass then files that family under
"THE LAYOUT MOVED … a real miss, worth triaging" while printing three TypeScript module
specifiers as its evidence. Only visible because of this PR (the residue listing goes 2 → 12
families). Unassigned, not fixed here — and now load-bearing for this PR, since it is what makes
skills/*/references/** unspellable in the check:skill-refs row above.

Serial queue — deliberately not folded in

Three open cards name dispatch-gates.mjs and are held behind this PR: #12074 (its option 1,
teaching the derivation to name check:bare-root-worklist for a new gate script), #11556 (this
module's own literals being inheritable — measured above as unmoved by this change), #11199
(check:cross-package-test-inputs never named for an edited test file). None is touched. #12246
joins that queue.

Changeset

None — root scripts/ only, nothing published. skip-changeset.


Generated by Claude Code

`resolveCheckToFiles` matched only `.mjs|.cjs|.js|.sh`, so every gate whose npm
script names a `.ts`/`.mts`/`.cts` file resolved to zero files. `discoverFamilies`
never opened the source, so those families contributed no watch hints, no
first-party import following, no `no-path-population` read, and no `gateFiles`
entry -- and scored `undetermined` for every card, indistinguishable from a gate
whose author declined to declare a population.
Three changes, all in the derivation:
- widen the extension alternation to `ts|mts|cts`;
- resolve a package manifest's spelling against the manifest's OWN directory
(`dir`), so a script that climbs out of its package
(`tsx ../../scripts/check-exported-any-returns.mts`) normalises to the tracked
repo path instead of being misattributed to the package;
- anchor the alternation on its right, so an extension that is a prefix of a
longer one (`.tsx`, `.json`) no longer matches as itself.
Measured on this tree, both directions, in the docblock: 73278 -> 74481
watch-hint (gate, file) pairs (+1203, 0 lost), zero-file families 24 -> 1,
19 families gaining coverage, 0 losing, 0 import-follow edges suppressed,
0 inherited hints lost, 0 gate files naming a path that does not exist.
The one family left zero-file, `check:app-nav-i18n`, is a `pnpm --filter ... run`
composite that names no path at all; no extension list can reach it, and the
self-test asserts the residue is only ever composites of that shape.
Part of #12107
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjM2ia8Av1v5NqfqQEQmC6
@yinlianghuiyinlianghui added skip-changeset PR has no user-facing published change; bypasses the changeset gate and removed size/m labels Aug 25, 2026 — with Claude
@yinlianghuiClaude

Copy link
Copy Markdown
CollaboratorAuthor

Body-byte correction (the AGENTS.md "GitHub mutates body BYTES" class, caught on read-back).

Item 3 of "What changed" renders as (?[\w-]). The ! did not survive: inside backticks or not,
[\w-] is markdown image syntax ([alt]), and the sanitizer took it.

The right-hand boundary added to the alternation is a negative lookahead for a word character or
a hyphen
— in scripts/pm/dispatch-gates.mjs it is spelled (? + ! + [\w-]), and the file
itself is the authority:

$ grep -o 'cts))(.\{0,6\}' scripts/pm/dispatch-gates.mjs
cts))(?[\w-]

Recorded rather than fixed in place: re-editing the body re-runs the same sanitizer over the same
bytes, and a comment is the channel that keeps them.


Generated by Claude Code


Generated by Claude Code

…faces
Resolving TypeScript-authored gates un-voids the bare-root sweep for their
sources, which is what the card asked for -- that sweep walks the gate sources
`discoverFamilies` resolves, so a TypeScript gate was never swept. Two genuinely
invisible bare roots come with it, both `SKILLS_DIR = 'skills'`.
Both recorded REFUSE-UNSPELLABLE, on consistency with the existing
`check-skills-token-ratchet.mjs SKILLS_DIR skills` row directly above them: the
same bare root, the same readdir-plus-named-file shape, the same scale (11 of 50
there, 12 of 50 here). One root answered one way.
Each `why` is measured from its own gate's filter and cites that gate's own
file:line -- neither borrows the other's numbers, which the map's docblock
forbids by name. They are not the same mechanism:
- check:skill-docs DOES enumerate the bare root (build-skill-docs.ts:221) and
reconciles it against DISPLAY both ways, but :222 admits a child only if it
carries SKILL.md -- so the population is a filename filter, not the root.
- check:skill-refs never reads the bare root at all: it iterates the authored
SKILL_MAP and manages skills/<name>/references/. Its true population,
skills/*/references/**, collapses to a double slash (#12246), which is what
leaves the bare root as the only spellable claim.
bare-root-worklist --self-test: 45 live row(s), 39 recorded verdict(s) -- none
stale, none missing.
Part of #12107
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjM2ia8Av1v5NqfqQEQmC6
@yinlianghui
yinlianghui marked this pull request as ready for review August 25, 2026 15:30
@yinlianghui
yinlianghui added this pull request to the merge queueAug 25, 2026
Merged via the queue into main with commit 0b478e1Aug 25, 2026
34 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-12107-dispatch-gates-ts-extensions branch August 25, 2026 16:14
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.

resolveCheckToFiles matches only .mjs|.cjs|.js|.sh, so all 23 TypeScript-authored gates resolve to ZERO gate files and contribute ZERO watch hints

2 participants

@yinlianghui@claude