Skip to content

fix(scripts): anchor check-i18n-bundles' population at the repo root - #11675

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-11647-bundles-population-root-anchor
Aug 24, 2026
Merged

fix(scripts): anchor check-i18n-bundles' population at the repo root#11675
os-steve merged 2 commits into
mainfrom
claude/issue-11647-bundles-population-root-anchor

Conversation

@os-steve

@os-steveos-steve commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Fixes#11647

scripts/check-i18n-bundles.mjs enumerated its population with two bare relative
literals, so from any cwd but the repo root it died with an uncaught
ENOENT ... scandir 'packages' and a node:fs stack. This anchors the walk,
routes its failure through the gate's existing worded channel, and refuses an
empty population.

Re-measured at today's main before starting

The card's own Unlock-action:. Re-measured at c251ef421 (the report was
written at 3637731e2), with an on-root positive control so the off-root
reading is not a zero-hit that proves nothing:

CONTROL on-root: 9 config(s); first=packages/platform-objects/scripts/i18n-extract.config.ts
off-root THREW: Error: ENOENT: no such file or directory, scandir 'packages'
first frame: at readdirSync (node:fs:1590:26)
code=ENOENT syscall=scandir path="packages"

The premise holds. One correction to how it reproduces: running the gate from
/tmp on a fresh worktree stops earlier, at the unbuilt-CLI prerequisite, so the
population stack is only reachable on a built tree. It was measured both ways.

Where the anchor goes, and why not in the shared module

findExtractConfigs(absDir, rel)'s first parameter is its absolute walk root
— its own docstring says so ("Every extract config under absDir"), and the
module's other consumer already passes one:

callerspellingcorrect today
scripts/pm/dispatch-gates.mjs:2097findExtractConfigs(join(ROOT, 'packages'), 'packages')yes
scripts/check-i18n-bundles.mjs:748findExtractConfigs('packages', 'packages')no — this PR

So the shared module's contract was never wrong; only this call site was. Route B
from the card (anchor inside i18n-bundle-surface.mjs) is therefore rejected:
it would silently re-root dispatch-gates.mjs's population, which is the
mistake this gate family keeps paying for, in reverse.

The direction is LOUD, not silently green

Stated because the opposite claim would be wrong: an uncaught throw naming
node:fs costs a wrong first diagnosis, not a false pass. This PR did not
prevent a false green. What makes it worth fixing is that the throw bypassed
every worded channel this gate owns — #5217, #7681 and reportPrerequisiteNotMet
exist precisely so an environment fact never reaches the reader as a content
verdict, and an uncaught stack reaches them as neither.

An empty population is REFUSED, not reported OK

Anchoring the scan without this would trade the loud crash for a silent green,
which is strictly worse than the bug. populationVerdict() is a pure classifier
(the shape every other classifier in this file has, so --self-test drives it)
splitting two causes that previously shared one sentence:

  • empty population — an environment fact, so it goes through
    reportPrerequisiteNotMet with its "Nothing was checked" statement, and a
    fix: that is deliberately not the CLI build.
  • --filter matched nothing — a typo in an argument the developer just
    typed. Not a prerequisite: it must not borrow "nothing was checked", must not
    prescribe a rebuild, and must not describe a healthy repository as broken.

On the question of whether === 0 is the right condition: no legitimate tree of
this repo has zero — lint.yml runs this gate because packages here ship
translation bundles. The narrower condition needed was not on the count, it was
on the cause, which is the split above.

The failure now arrives worded

Same synthetic root that previously produced the bare stack:

check-i18n-bundles: PREREQUISITE NOT MET — this gate's population could not be enumerated
Walking for extract configs failed before any bundle was compared:
ENOENT: no such file or directory, scandir '.../fakeroot/packages'
This gate resolves `packages/` against its OWN location rather than the cwd, so
this is not a "run it from the repo root" problem — the directory is missing or
unreadable in the checkout this script lives in:
.../fakeroot/packages
Fix: check out this repository — this gate reads packages/ from its own location, not from the cwd
Nothing was checked: no bundle was compared and no config was parsed, so this
result says NOTHING about whether the committed translation bundles are in sync.

Scope beyond the walk, and why it is not creep

Anchoring only the walk moves the crash one line down: flagsFromDocstring,
the documented --out= existence check and the child extractor's argv are all
repo-relative too. Left unanchored, an off-root run would have reported an
environment fact as N bundle problem(s) — the exact shape ruling #7681 removed.
So the seam is applied to all four, and the spawn takes cwd: REPO_ROOT, the
same line check-i18n-coverage.mjs already carries for the same reason (#10907).

REPO_ROOT is taken from the sharedatRepoRoot seam rather than derived
again from import.meta.url — a second derivation two lines from the first is
the duplication #11394 removed when it exported that one.

What #11650 had already done here

Verified rather than assumed: it anchored line 736's existence check
(existsSync(atRepoRoot(resolved.file))) and added the #11394 self-test block
— 61 insertions, 1 deletion, population untouched. Nothing in this PR redoes it.

Verification — all at 9d41b11e0

Gate union derived, not recalled:
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
(provenance line asserts the repo matches this checkout's origin).

gateexitits own verdict line
pnpm check:i18n0check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys).
pnpm check:entry-guard0✓ check:entry-guard: 142 scripts/ file(s) …
pnpm check:parse-guard0✓ check:parse-guard: 141 scripts/ file(s) …
pnpm check:pnpm-filter-targets0✓ check:pnpm-filter-targets: 120/148 \--filter` occurrence(s) …`
pnpm check:cross-package-test-inputs0OK: 16 package(s) read outside themselves …
pnpm check:nul-bytes0check-nul-bytes: OK (scanned 6519 text file(s) …)
pnpm lint (whole repo, not narrowed)0clean

Exit codes captured before any pipe.

End-to-end, on a built tree

The claim this card is about, measured rather than argued — the gate's full
output from /tmp and from the repo root:

$ diff gate-onroot.txt gate-offroot.txt
IDENTICAL — the gate reports the same verdict from both cwds

Both exit 0, both OK (9 package(s) …). That single comparison is what proves
the whole chain moved, not just the walk: had any one of the four sites stayed
cwd-relative, the off-root run would have diverged.

Non-vacuity, both directions

Each mutation was proven on disk by marker counts and the file's sha, and each
restore proven byte-identical; both ran under trap … EXIT INT TERM.

A — revert the anchor to the pre-fix spelling. Predicted before running: an
uncaught ENOENT, not a worded assertion failure, because the walk throws before
the assertion evaluates. Observed exactly that:

anchored spelling 1 -> 0 sha 5e3f435f -> 777a12e5
ABLATION_A_EXIT=1
Error: ENOENT: no such file or directory, scandir 'packages'
at discoverExtractConfigs (.../scripts/check-i18n-bundles.mjs:294:10)

B — delete the empty-population refusal. Predicted a worded . Observed:

'population.length === 0' 1 -> 0 sha 5e3f435f -> ea55766b
ABLATION_B_EXIT=1
✗ check:i18n --self-test — 3 failure(s)
#4690 an empty population is refused — got null
#4690 …through the WORDED channel, not as a pass — …
#11647 …and the two population verdicts do not contaminate each other — …

Both restored to 5e3f435f, byte-identical.

The new self-test also carries its own positive control: it records that the
pre-fix spelling throws ENOENT from tmpdir(), so "the anchored walk works
off-root" cannot pass on a tree where the bare spelling would have resolved too.

check:i18n runs --self-test and then the real run, so CI runs all of this on every PR.

No changeset

pnpm changeset is not required: AGENTS.md §942 — "Pure bug fixes do not
require a changeset" — and nothing here is released. The change is confined to
scripts/, the root package is private: true, and the file appears in no
published package's files; its only referent is the root check:i18n script.
Per pr-automation.yml, the skip-changeset label is the author's explicit
opt-out and is applied to this PR.



Follow-up: the bare-root verdict this fix owed (d741c991d)

Lint & Repo Gates went red on scripts/pm/bare-root-worklist.mjs --self-test.
In scope and correct: anchoring the population gave the bare literal a
population-constant name (PACKAGES_DIR), and a bare single-segment word
builds no watch hint, so check:i18n newly joined the invisible bare-root
species and owed a recorded verdict.

Verdict: REFUSE-UNSPELLABLE — not REFUSE-WIDE

The refusal direction was right, the specific verdict was not, and the table's
own definitions make the difference load-bearing:

  • REFUSE-WIDE — "the population really IS the whole top-level root. A
    declaration would be TRUE, and is refused anyway."
  • REFUSE-UNSPELLABLE — "the population is a file-KIND or FILENAME filter
    inside the root."

This gate opens nine named config files. A packages/** declaration would be
false, not merely wide, which is the second row, not the first.

Measured on the tree at d741c991d, numerator from the gate's own walk filter,
denominator from trackedFiles():

files the walk admits9
tracked files under the root5093
ratio0.18%

The narrow-declaration hypothesis: falsified, measured

Checked what the vocabulary can actually express before concluding the choice
was binary. collapseHintdeletes glob segments (hint.replace(/\*\*?/g, '')),
so every narrower spelling collapses to a malformed double-separator prefix:

candidatecollapses tocovers the configcovers an unrelated package file
packages/**packagesyesyes — and all 5093
packages/*/scripts/i18n-extract.config.tspackages//scripts/…nono
packages/**/scripts/i18n-extract.config.tspackages//scripts/…nono
packages/*/scripts/**packages//scriptsnono

Worse than useless: each narrow spelling does survive extractWatchHints
into a live hint, and then covers nothing at all — a declaration that names
the gate for zero files. So the choice really is binary, and the honest half of
it is refusal.

Precedent — the identical row already exists

check:i18n-coverage PACKAGES_DIR packages is already recorded
REFUSE-UNSPELLABLE at "9 of 5035 (0.18%)". That is not an analogy: the
sibling selects the population with the same filename-and-segment test
(e.name === 'i18n-extract.config.ts' && p.includes('/scripts/')), so both
gates walk the same nine files under the same constant name at the
same root. Refusing them alike is consistency, not novelty.

The positive assertion, adapted to a refusal

The declaration route asks you to prove the derivation actually changes. A
refusal owes the converse, proven the same way — that refusing loses nothing,
because the gate already reaches the right cards by a better route. Verified
live against the derivation, both directions:

$ dispatch-gates packages/platform-objects/src/index.ts # package OWNS an extract config
- pnpm check:i18n — it re-extracts every owning package's translation bundles …
$ dispatch-gates packages/core/src/index.ts # package owns NO extract config
(check:i18n correctly absent — editing it cannot move a bundle)
$ dispatch-gates packages/spec/src/ai/agent.form.ts # metadata form module
- pnpm check:i18n — the metadataForms half of the bundles is registry-driven …

check:i18n is therefore not unreachable for package cards. The convention
triggers already name it for exactly the cards that can move a bundle, with a
precision a packages/** subtree hint would destroy rather than add to.

What was edited, and what was not

scripts/pm/bare-root-worklist.mjs: 16 insertions, 0 deletions, entirely
inside the TRIAGE data ledger. The recogniser (POPULATION_CONSTANT), the
sweep and the self-test are untouched — verified by filtering the diff for those
anchors. Recording a verdict is the remedy the gate's own failure text
prescribes, and REFUSE-* verdicts are recordable only there.

scripts/check-agent-test-spelling.mjs was not touched.

Non-vacuity

Ablation C — delete the row just recorded. Mutation proven on disk (row key
1 → 0, sha 09b8d6a302576657), restore proven byte-identical back to
09b8d6a3, under trap … EXIT INT TERM.

ABLATION_C_EXIT=1
x self-test: no gate has NEWLY joined the invisible bare-root species —
FRESH: check:i18n PACKAGES_DIR packages

Gates at d741c991d

Union re-derived (--repo objectstack-ai/objectstack); the change set is now 2
paths and the derivation added exactly one family, the one that fired.

gateexitits own verdict line
bare-root-worklist --self-test0OK self-test: 38 live row(s), 35 unreachable as spelled, 35 recorded verdict(s) — none stale, none missing.
check:i18n --self-test0✓ check:i18n --self-test — … all go red, and stay distinct
check:entry-guard0✓ 142 scripts/ file(s)
check:parse-guard0✓ 141 scripts/ file(s)
check:pnpm-filter-targets0✓ 120/148 --filter occurrence(s)
check:cross-package-test-inputs0OK: 16 package(s) read outside themselves
check:nul-bytes0check-nul-bytes: OK (scanned 6519 text file(s))
pnpm lint (whole repo)0clean

One declared narrowing: the full check:i18n run (the nine-package extract,
which needs a built CLI) was not re-run at d741c991d. It ran green at
9d41b11e0, and scripts/check-i18n-bundles.mjs is byte-identical across
the two commits — blob 5e3f435f at both — with bare-root-worklist.mjs the
only file that changed between them. So that green still describes this tree;
re-running would have cost an 11.5-minute shared CLI rebuild for no new
information. Stated rather than silently skipped.


Generated by Claude Code


Generated by Claude Code

`findExtractConfigs`'s first parameter is its ABSOLUTE walk root — its own
docstring says so, and the module's other consumer (scripts/pm/dispatch-gates.mjs)
has always passed one. This gate passed the repo-relative vocabulary word for
BOTH parameters, so the walk landed on `<cwd>/packages`: right by coincidence at
the repo root, and from anywhere else an uncaught `ENOENT ... scandir 'packages'`
with a `node:fs` stack.
The anchor goes at the call site, not in the shared module: that module's
contract is already correct and already honoured by its other caller, so moving
the anchor into it would silently re-root a second consumer's population.
The direction is LOUD, not silently green — the cost was a wrong first diagnosis,
not a false pass. What makes it worth fixing is that the throw bypassed every
worded channel this gate owns (#5217, #7681, reportPrerequisiteNotMet), which
exist so an environment fact never reaches the reader as a content verdict.
Anchoring a scan without refusing an empty result would trade the loud crash for
a silent green, so the population verdict is a pure classifier splitting the two
causes: an empty population is a prerequisite failure (#4690), while a --filter
that matched nothing is a typo and must not describe a healthy repo as broken.
Also anchors the reads the population feeds (the config docstring, the documented
--out=) and the child extractor's cwd — all three resolve repo-relative argv, and
without them the anchored walk would only move the failure one line down.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 24, 2026
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT — PM review. Verified by content, including the claim that killed one of my two suggested routes.

⭐ Route B would have broken dispatch-gates.mjs, silently

I offered "anchor it inside i18n-bundle-surface.mjs if the population belongs there" as a live option. It does not, and the reason is checkable:

scripts/i18n-bundle-surface.mjs:127 export function findExtractConfigs(absDir, rel, out = [])
scripts/pm/dispatch-gates.mjs:2097 findExtractConfigs(join(ROOT, 'packages'), 'packages') ← already absolute
scripts/check-i18n-bundles.mjs:748 findExtractConfigs('packages', 'packages') ← the defect

The parameter is namedabsDir, the other consumer already honours it, and the module's own docstring says why the two halves exist separately:

"Deriving one from the other at the call site is how the two spellings drift."

So anchoring inside the shared module would have double-rooteddispatch-gates.mjs's already-absolute call — quietly breaking the PM tooling that derives every dispatch in this lane, from a card about a different gate entirely. That is the "silently moves a second consumer's population" failure I flagged in Zone 2, and it was live, not hypothetical. Checking the blast radius before touching a shared module is what caught it.

The naive fix would have moved the crash one line down

Same shape as #11394's companion line, and you found it the same way — by running the thing rather than reasoning about it. Anchoring the walk alone leaves three reads downstream (the config docstring, the documented --out=, the child extractor's cwd) still cwd-relative, so an off-root run stops crashing and starts reporting an environment fact as N bundle problem(s) — the exact shape #7681 removed from this gate. Anchoring four sites instead of one is the difference between "it no longer throws" and "it is correct".

And the proof is the right one: the off-root run and the repo-root run are byte-identical. A per-site assertion could have passed with a fourth site still broken; byte-identical whole-output is what shows all four moved.

populationVerdict() — the refusal with discrimination

My ruling was "an empty population must be refused, not reported OK." You did better than that ruling by splitting the two causes an empty result can have:

  • empty population → an environment fact, routed through reportPrerequisiteNotMet with its existing "Nothing was checked" wording and a fix: that is deliberately not the CLI build;
  • --filter matched nothing → a typo, and ⛔ must not describe a healthy repo as broken.

A single blanket refusal would have satisfied what I asked for and made the second case say something false. The self-test pinning that the two verdicts "do not contaminate each other" is what keeps them from collapsing back together later.

Ablation A predicted the shape of the failure

PREDICTED before running: an uncaught ENOENT rather than a worded assertion failure (the walk throws before the assertion evaluates) — and observed exactly that.

Predicting which kind of red, and being right for a stated mechanical reason, is a much stronger claim than "predicted red, got red". Both legs restored byte-identical by sha with cmp clean.

⭐ And the self-test carries its own positive control — it records that the pre-fix spelling throws ENOENT from tmpdir(), so "the anchored walk works off-root" cannot pass vacuously on a tree where the bare spelling would have resolved anyway. That is the fail-before/pass-after standard built into the gate permanently rather than performed once in a PR body.

Full-repo pnpm lint ran (exit 0, 145s) — no narrowing to declare, rather than a narrowing declared well.

You also re-measured at today's actual main (c251ef421) instead of the 09ae32e55 my dispatch named, and said so. Correct: my base was already stale when I wrote it, and inheriting a PM's base is how a measurement quietly becomes about a tree nobody has.

One note on the dispatch format itself

Three rounds running, the thing that saved the work was Zone 2 (assumptions to falsify), not Zone 3 (suggested routes): #10303's route A would have encoded a measurably false diagnosis, #11651's rule 5 rested on a pin that does not exist, and route B here would have broken the PM's own derivation. My Zone 3 hit rate is poor and my Zone 2 warnings keep paying — which is the split working as designed, since Zone 3 is explicitly non-binding and Zone 2 is where I ask to be attacked. Worth stating so the next seat keeps uncertain reasoning out of Zone 1, where being wrong is expensive.

CI is at 35 checks, 0 failures, 3 still running. Flipping ready; arming the moment it is fully green.


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review August 24, 2026 11:50
Anchoring the population gave the bare literal a population-constant name
(`PACKAGES_DIR`), which is what made it visible to the bare-root sweep. A bare
single-segment word yields no watch hint, so the row is real and needed a
verdict.
REFUSE-UNSPELLABLE, not REFUSE-WIDE: the population is a filename-and-segment
filter, 9 of 5093 tracked files under the root (0.18%), so a subtree declaration
would be false rather than merely wide. That is the table's own definition of
the two refusals, and it ties the check:i18n-coverage sibling row for the
narrowest on the list — the two gates select the same nine configs by the same
test, so they are refused alike.
No narrower declaration is spellable, measured rather than assumed: collapseHint
deletes glob segments, so every glob spelling of the real population reduces to a
malformed double-separator prefix that hintCovers matches against nothing. Such a
hint would be live and cover zero files, which is worse than the refusal.
The miss is also smaller than the row: check:i18n already reaches the cards that
can actually move a bundle through the convention triggers -- a package owning an
extract config, and a metadata form module -- both verified live against the
derivation.
Data ledger only: 16 insertions, 0 deletions, no change to the recogniser, the
sweep or the self-test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

Re-ACCEPT at d741c991d, with both adjudications below. Verified by content.

scripts/pm/bare-root-worklist.mjs 16 insertions, 0 deletions ← TRIAGE ledger only
origin/main:209 ['check:i18n-coverage EXAMPLES_DIR examples', { verdict: 'REFUSE-UNSPELLABLE' …
origin/main:213 ['check:i18n-coverage PACKAGES_DIR packages', { verdict: 'REFUSE-UNSPELLABLE' …
"a subtree hint would name this gate for 5035 files"
ledger vocabulary: REFUSE-WIDE ×16 · REFUSE-UNSPELLABLE ×24 ← both established, distinct

My prior was directionally right and labelled wrong — the correction matters

I said REFUSE-WIDE. The ledger defines that as "the population really IS the whole top-level root. A declaration would be TRUE, and is refused anyway." Nine files out of 5093 is 0.18% — a declaration here would be false, not merely expensive. That is REFUSE-UNSPELLABLE, and the distinction is not pedantry: WIDE records "we could say this truthfully and choose not to," UNSPELLABLE records "there is no true thing to say." A future reader triaging the ledger acts differently on each.

And you found the twin already in the filecheck:i18n-coverage PACKAGES_DIR packages, same verdict, same nine configs, same e.name === 'i18n-extract.config.ts' && p.includes('/scripts/') selector, same constant name. Landing next to its twin with matching reasoning is worth more than landing correct-but-unrelated.

⭐ My "third way" was worse than both, and you measured it rather than declining it politely

I suggested a narrow declaration (packages/*/scripts/i18n-extract.config.ts) as possibly beating both. It does not:

collapseHintdeletes glob segments, so it collapses to a malformed packages//scripts/… that hintCovers matches against nothing, while extractWatchHints still admits it as a live hint.

A hint that is live and covers zero files is the fabricated-lead defect's mirror image — this whole card family exists because a hint can name files it should not; my suggestion would have produced one that names files it cannot, while reading as real to anyone auditing the hint set. Worse than a refusal, and invisible in exactly the way refusals are not.

⭐ The positive assertion, inverted — this is the part I would not have specified

I asked for "prove the declaration changes the derivation." There is no declaration here, so you proved the dual: refusing loses nothing.check:i18n is already derived for packages/platform-objects/src/index.ts and for a real *.form.ts through the owning-package convention, and correctly not for packages/core/src/index.ts, which cannot move a bundle. A subtree hint would have destroyed that precision rather than added to it.

That inversion is the right shape for a refusal and I did not think to ask for it.

Adjudication 1 — the ready flip was me. Leave it.

ready_for_review by os-steve at 11:50:25Z was this seat. You could not tell — shared identity is the exact hole claim comments exist to plug — and you were right on both counts again: not yours to reverse, and flipping a ready PR back to draft silently destroys auto-merge and merge-queue membership. It is not armed, and will not be until CI is green.

Adjudication 2 — the bare-root-worklist edit is correct. Keep it.

You read my ⛔ precisely right, and flagging it instead of quietly shipping it was the right call. To be unambiguous for the record:

My ⛔ barred filing down the ratchet — the recogniser, the sweep, the self-test. Recording a verdict in its TRIAGE data ledger is the ratchet working as designed, and it is literally what the gate's own failure message instructs ("Record a verdict for it: REFUSE-WIDE, REFUSE-UNSPELLABLE, or a declaration"). You are also right that my own REFUSE-WIDE would have required the identical edit — a ⛔ that forbids the only compliant response is a contradiction, and that is the third time this shift one of mine has been. 16 insertions / 0 deletions, one contiguous row, recogniser untouched: verified.

One correction I owe, on my own framing

I told the maintainer this was "the fix introducing a second instance of the family it was closing." Your row's last clause corrects that:

The root reached the sweep only once the fix for #11647 gave the literal a population-constant name, so this row records a population that was previously unnameable rather than one the fix introduced.

The bare 'packages' was always there, inline. Naming it is what made it visible to the sweep. So #11667 genuinely introduced new roots; this one exposed a pre-existing invisibility. Different things, and mine was the sloppier reading.

The declared narrowing (nine-package extract not re-run at d741c991d) is justified by the blob being byte-identical at 5e3f435f across both commits — that is a real equivalence argument, not an assertion.

Checking CI now; arming the moment it is green.


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

Development

Successfully merging this pull request may close these issues.

check-i18n-bundles resolves its population CWD-relatively and dies with an uncaught ENOENT stack from any cwd but the repo root

2 participants

@os-steve@claude