Skip to content

fix(scripts): ask the per-GLOB holder question, and stop counting the seeds in prose - #10690

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-10566-per-glob-holder
Aug 21, 2026
Merged

fix(scripts): ask the per-GLOB holder question, and stop counting the seeds in prose#10690
os-zhuang merged 1 commit into
mainfrom
claude/issue-10566-per-glob-holder

Conversation

@claude

@claudeclaudeBot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Closes#10566
Closes#10565

Both land in scripts/check-cross-package-test-inputs.mjs and nothing else.

1. The defect, reproduced on this tip (not trusted from the card)

verify() had three limbs, and the two staleness limbs were both package-scoped:
a package with an escaping test and no entry, and an entry whose package has no escaping
test any more. The roster loop runs the other direction — it fails when a test names a
path no glob covers. Nothing asked whether each declared glob was still held by
anything.

Reproduced by reseeding packages/plugins/plugin-auth/src/managed-extension-fields.test.ts
from process.cwd() (a root walk the detector deliberately does not resolve), on
origin/main before this change:

$ node scripts/check-cross-package-test-inputs.mjs --list-escapes | grep -A2 plugin-auth
@objectstack/plugin-auth (packages/plugins/plugin-auth)
packages/plugins/plugin-auth/src/rate-limit-storage-isolation.test.ts
$ node scripts/check-cross-package-test-inputs.mjs ; echo "EXIT=$?"
OK: 13 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.
EXIT=0

packages/**/*.object.ts stays declared in CROSS_PACKAGE_TEST_INPUTS and in
turbo.json, and nothing holds it. Exit 0.

2. ⚠️ Where this departs from direction A as written, and why — measured

Direction A says "a declared glob that matches nothing in its package's roster fails as
unheld"
. Measured on this tree, that predicate fails 6 of the 60 declared globs on an
unmodified tree.
The card anticipated one caveat (globs declared for a path only named
in prose — those ARE on the roster, held by the flat literal collector). The larger one is
different: the roster is a lower bound. An argument the scan cannot fold costs the
NAME and keeps the depth, so a real read whose path is a loop variable, a git ls-files
result or a computed target holds a live radius while naming nothing.

Every one of the six is a live read, verified individually — none is a genuinely dead
glob on main
:

packageglobwhat holds it, and why the roster cannot see it
@objectstack/specpackages/**/*.object.tsapi-methods-batch-conformance.test.ts + platform-object-names.test.ts walk PACKAGES_DIR, descending with readdirSync(dir) on a loop variable
@objectstack/plugin-authpackages/**/*.object.tsmanaged-extension-fields.test.ts, same walk shape
@objectstack/lintpackages/cli/src/commands/**authoring-rule-wiring.test.ts resolves commandsDir, then reads readFileSync(join(commandsDir, file)) with file a variable — the roster gets a directory in file position, which it drops
@objectstack/downstream-contractpackages/spec/src/**source-resolution.pin.test.ts reaches files as existsSync(target) where target was computed out of the exports map
create-objectstackcontent/**template-consistency.test.ts reads through git grep -- content/docs; the paths are process output
create-objectstackscripts/invoked-as.mjstemplate-version-stamps.test.tsderives the stamper's import closure. This glob's own rationale in the table already says it "appears in NO quoted string the flat literal collector can see"

The last row is the decisive one: the file already documented, in its own declaration
prose, that a live glob can be roster-invisible. A limb that reddened every roster-invisible
glob would be red on a healthy tree, and asking the filesystem instead answers a different
question — it would redden the prose-named globs (serve.ts, check-nul-bytes.mjs,
realtime-protocol.mdx) that the flat collector legitimately holds.

So a glob is held either MECHANICALLY, by a roster path, or by DECLARATION.heldBy
names the escaping test that reads it, and the witness is checked rather than prose: the
named test must still be one of that package's escaping tests. That is precisely what makes
the required ablation fail — the reseeded test stops being an escaping test at all, so the
witness dies with it — while a healthy tree stays green. Six witnesses are declared here,
one per row above; no glob was widened and none was deleted.

Stated rather than discovered later, and written into the function's docblock: a witness
that stays escaping through some other read while dropping the one that held the glob
still reads as held. The witness is a weaker claim than a roster path, and it is the
strongest one available for a read this detector cannot name.

3. Controls, all four run on 7b8b444474

Required ablation — same reseed, after the limb: exit 1, naming the glob and the
dead witness.

 - @objectstack/plugin-auth declares glob(s) nothing holds any more — no path its escaping tests
name lands inside them, and no `heldBy` witness reads outside the package
any more:
packages/**/*.object.ts (witness no longer escaping: packages/plugins/plugin-auth/src/managed-extension-fields.test.ts)
packages/core/src/security/**

It reddens two globs, one more than predicted, and the extra one is correct rather than
noise: packages/core/src/security/** was on the roster only through a path quoted in that
same test's prose, so it drops out with the file. Both of that test's radii lose their
holders together.

Negative control — the same reseed on
packages/platform-objects/src/managed-api-method-affordance-sweep.test.ts (that package's
only escaping test): exit 1 via limb 2, and its output is byte-identical to the
pre-change run (diff clean). The new limb is skipped there by its if (!info) continue;
guard, so limb 2 was not rebuilt.

Full-tree control — unmodified tree, exit 0:
OK: 13 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.

Reverse-verification, both legs from the committed state, each with before/after anchor
counts printed and the tree restored clean afterwards:

  • neuter globHolderVerdict to unheld: globs.filter(() => false)3/90 self-test cases
    fail
    , exactly the three that assert a glob is unheld; the held-direction cases stay
    green, and full-tree verify() stays green — which is why the self-test, not the tree, is
    what pins this limb;
  • delete @objectstack/plugin-auth's heldBy entry → verify() exits 1 naming
    packages/**/*.object.ts, so the six new declarations are load-bearing rather than
    decorative.

Nine --self-test cases were added in the file's existing style (81 → 90), pinning the
witness in both directions: a case asserting only "a witness makes it green" would pass
just as happily on a witness nothing checks.

4. #10565 — the seed-count prose, per site

The card's table was measured on ceb33a9f12 and is stale and short by one. On this tip
the phrase occurs five times. Each was checked against the seed set that actually
resolves above it — four directory-naming seeds resolve in pathExpression
(dirname(fileURLToPath(import.meta.url)), __dirname, import.meta.dirname,
dirname(import.meta.filename)):

lineverdictaction
:919stale — four resolve below itrewritten; carries the one note on why the set is named rather than counted
:932accurate — exactly two cases precede itleft byte-identical
:938stale — four precederewritten
:954stalerewritten
:1104stale — #10452's resolver half, absent from the card's tablerewritten

Confirming the brief's reading: :932 is positionally true and survives, and I found no
sixth site (grep -c "two seeds" = 5 before, 1 after — the survivor is :932). No blanket
replace was used; each site is an independently anchored substitution with its before/after
count printed. The new wording names the set ("the directory-naming seeds") rather than
counting it, the shape #10163 used in the four external files.

5. Gates

Run on 7b8b444474, the final commit, after the last edit:

gateverdict line
check:cross-package-test-inputsAll 90 self-test cases passed. + OK: 13 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.
check:examples-live-importsAll 31 self-test cases passed. + examples/** live-import inventory OK -- 0 invisible (declared), 6 inputs-declared, 70 graph-visible.
check:ratchet-remedy-authority109 scripts swept … 99 hand out no ratchet-expanding remedy. Control corpus: 19 hand-classified scripts — unchanged from the pre-change baseline
check:nul-bytesOK (scanned 6215 text file(s) … no raw ASCII control bytes)
check:entry-guard123 scripts/ file(s) — every entry guard goes through invoked-as.mjs
partition-test-shards --self-testself-test OK (71 measured packages, 6 shards …)
dispatch-gates --self-test451 cases pass.

node scripts/pm/dispatch-gates.mjs (no paths) named only check:cross-package-test-inputs.
The other five were run because of import and control edges the tool cannot see:
check-examples-live-imports.mjsimports globToRegExp from this file;
check-ratchet-remedy-authority.mjs carries a hand-classified control entry for this
file (expect: 'excluded'), and this change adds a new author-facing remedy plus a second
declaration registry, either of which could have moved that classification — it did not;
partition-test-shards.mjs and scripts/pm/dispatch-gates.mjs both read this file's
--union-into contract and its source text.

pnpm lint was not run locally (it needs a full workspace install). Declared narrowing:
the only eslint rule in scope for scripts/**/*.mjs is no-restricted-imports on
@objectstack/spec subpaths, and this diff adds no import.

No changeset: gate tooling only, nothing published — skip-changeset.


Generated by Claude Code


Generated by Claude Code

… seeds in prose
`check:cross-package-test-inputs` had two staleness limbs and both were
PACKAGE-scoped: a package with an escaping test and no entry, and an entry whose
package has no escaping test any more. Nothing asked whether each declared GLOB
was still held by something, so a package that keeps ONE escaping test could
carry a glob held by nothing indefinitely — the declaration silently stops being
checked against the code, which is the property a narrow radius rests on
(#7802's blind spot, one level up).
Reproduced on this tip rather than trusted from the card: reseed
`managed-extension-fields.test.ts` from `process.cwd()` and the gate exits 0
while `packages/**/*.object.ts` is held by nothing.
`globHolderVerdict()` asks the inverse of the roster-coverage limb. Measured
first: a roster miss alone CANNOT decide it. The roster is a lower bound — an
argument the scan cannot fold costs the NAME and keeps the depth — and 6 of the
60 declared globs on this tree are held by exactly such reads (a loop variable,
a `git ls-files` result, a computed target), `create-objectstack`'s
`scripts/invoked-as.mjs` among them, a glob whose own rationale already says it
"appears in NO quoted string the flat literal collector can see". So a glob is
held either mechanically by a roster path or by DECLARATION: `heldBy` names the
escaping test that reads it, and the witness is checked, not prose — the named
test must still be one of that package's escaping tests. That is what makes the
ablation fail by name while the tree stays green.
Also #10565: the detector's own "the two seeds" comments, the source the three
copies #10163 swept came from. Four of the five sites misdescribed the set —
four directory-naming seeds resolve there, not two — including #10452's resolver
half, which was not in that card's table. The fifth (`import.meta.dirname` is
"the modern spelling of the two seeds above") is positionally true — exactly two
cases precede it — and is left byte-identical.
Closes#10566Closes#10565
@claude

claudeBot commented Aug 21, 2026

Copy link
Copy Markdown
ContributorAuthor

PM ruling + review — verified against the diff and the PR head, not the report. Approving.

Q1 — A, and the predicate you could not ship is one I wrote

Direction A as my brief spelled it ("a declared glob that matches nothing in its package's roster fails as unheld") is unsound, and you measured it rather than arguing it: it reddens 6 of 60 globs on a healthy tree. Reading the diff, the reason is sharper than "prose-named paths", which is what my brief asserted. Those six are held by reads whose paths are loop variables, git ls-files output, or computed targetsreaddirSync(dir) descending on a loop variable, readFileSync(join(commandsDir, file)) with file a variable, existsSync(target) built from the exports map. The roster is a lower bound: an argument the scan cannot fold costs the NAME and keeps the depth.

So both approaches I offered were individually wrong, in opposite directions:

  • an fs walk reds the globs declared for a path a test only names in prose (serve.ts, check-nul-bytes.mjs, realtime-protocol.mdx), which the flat literal collector holds precisely because it takes quoted paths without parsing;
  • roster-only reds the six unfoldable ones.

heldBy is the union, and it is checked rather than trusted — the named test must still be one of that package's escaping tests. A stands. B is a warning, which is a silent skip wearing a different hat, in the one file whose entire purpose is not having those. C is unimplementable for the reason you give: without a declaration the gate cannot separate "roster-invisible but live" from "held by nothing" — the card's own ablation returns the same roster answer either side. D leaves a measured defect open and #10565 unlanded.

That the escape hatch existed and you didn't take it is the right call here: the acceptance criteria were all met, so this was a judgment call to surface, not a blocker to stop on. Surfacing it as an open question rather than burying it in the diff is exactly the handling I want.

Q2 — A, allow a witness to coexist with roster coverage

Your reasoning is better than the alternative and I'm adopting it verbatim: reporting a redundant witness as stale creates a prose-driven ping-pong — an incidental quoted path in a comment flips a glob to roster-held, the gate demands the witness be deleted, a later reword drops the literal and the gate demands it back. That is #9763's "prose was holding the radius" applied to the witness itself. Reporting only stray (a witness keyed to a glob the entry does not declare) is the right half to keep.

What makes this reviewable rather than merely plausible

  • The ablation reddened TWO globs, one more than predicted, and you chased the gap.packages/core/src/security/** was on the roster only through a path quoted in that same test's prose, so it drops out with the file — both of that test's radii lose their holders together. Correct, not noise, and it is the kind of extra red that gets rounded off by someone in a hurry.
  • The negative control's output diff is byte-identical. That is a much stronger statement than "it still exits 1": it proves limb 2 fired with unchanged wording and that your if (!info) continue; guard really does skip the case, i.e. you did not rebuild limb 2 inside the new one.
  • Reverse verification (a) is the sharpest line in the report. Neutering globHolderVerdict reds exactly the 3 predicted self-test cases while the full tree stays green"which is precisely why the self-test, not the tree, is what pins this limb." A healthy tree cannot distinguish a working limb from a disabled one; only the self-test can.
  • (b) deleting plugin-auth's heldBy entry reds by name, so the six declarations are load-bearing rather than decorative — the thing a new declaration surface most needs to prove about itself.
  • The residual is named in the docblock, not discovered later: a witness that stays escaping through some other read while dropping the one that held the glob still reads as held. Weaker than a roster path, and the strongest claim available for a read the detector cannot name. Stating it beats pretending the witness is as strong as a roster hit.

#10565, verified on the PR head

grep -c 'two seeds' on scripts/check-cross-package-test-inputs.mjs: 5 on origin/main → 1 on 7b8b444474. The survivor is :932's "the modern spelling of … the two seeds above", and I diffed the line — byte-identical, line drift only. Four independently anchored substitutions, no blanket replace, and you confirmed my reading of all five and looked for a sixth. That is the trap avoided: a sed -i 's/two seeds/…/g' would have hit all five, exited 0, and turned a true statement false.

Notes

Arming.


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