refactor(lint): derive the runtime gate's name-keyed collection set instead of hand-listing it - #13769
Conversation
The set of collections the runtime publish gate carries in a per-write snapshot was written down in five places, and `NAME_KEYED_STACK_KEYS` was the one with no guard of any kind: `CONTEXT_STACK_KEYS` carries a `satisfies` clause (validity, not completeness) and the compiler held nothing else. That list carries a real invariant. A collection the CONTEXT fills AND that some write type maps into must be name-keyed, or a finding's `path` is a positional index into an in-memory snapshot the caller has never seen and cannot enumerate. Omitting a member did not fail to build, fail a test, or fail a gate; it emitted correct-LOOKING findings the receiver cannot resolve. Adding the `pages` collection had to touch all five spellings and only one of them announced itself. `NAME_KEYED_STACK_KEYS` and the `TOP_LEVEL_INDEX` pattern built from it are now derived from the two inputs that already state the answer: `CONTEXT_STACK_KEYS` intersected with the values of `TYPE_TO_STACK_KEY`. The intersection was measured against the list it replaces before anything changed: same four members in the same order, `datasets` excluded on its own because no write type maps into it. No member needed a hand-written exception and none is kept. Constructive preservation, not a tightening or a loosening: the derived pattern's `source` is byte-identical to the literal it replaces. The two hazards a derived alternation has and a literal did not - member escaping and prefix ordering - are decided in the builder's docblock and pinned on synthetic inputs, because the four real keys cannot exercise either. No published entry point changed: the new exports are module-level, for the pin, and are on neither `@objectstack/lint` nor `@objectstack/lint/runtime`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
📓 Docs Drift Check6 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 9a6e3d459d35b74d34755cb334c5c2896d2e24d6 && git checkout 9a6e3d459d35b74d34755cb334c5c2896d2e24d6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c42bc8ee68833297f0273f14b0f6f6e9357ab293 485ce7d1c2cfd1bc48257b363b2b5348f198e728 && git checkout -B drift-repro c42bc8ee68833297f0273f14b0f6f6e9357ab293 && git merge --no-ff 485ce7d1c2cfd1bc48257b363b2b5348f198e728
node scripts/docs-audit/affected-docs.mjs --json c42bc8ee68833297f0273f14b0f6f6e9357ab293 |
Fixes #13390
Derives the runtime publish gate's name-keyed collection set instead of hand-listing it.
The hole
The set of collections the gate carries in a per-write snapshot was written down in five
places.
NAME_KEYED_STACK_KEYSwas the one with no guard of any kind —CONTEXT_STACK_KEYScarries a
satisfiesclause, which is validity rather than completeness, and the compiler heldnothing else.
It carries a real invariant: a collection the CONTEXT fills and that some write type maps
into must be name-keyed, or a finding's
pathis a positional index into an in-memory snapshotthe caller has never seen and cannot enumerate — the defect #10064 fixed for
objects/permissions/books. Omitting a member did not fail to build, fail a test, or fail a gate; itemitted correct-LOOKING findings the receiver cannot resolve. Adding the
pagescollection hadto touch all five spellings and only one announced itself — the one the compiler could see,
and only after that accumulator was retyped as a mapped type.
The change
NAME_KEYED_STACK_KEYSand theTOP_LEVEL_INDEXpattern built from it are now derived from thetwo inputs that already state the answer:
CONTEXT_STACK_KEYSintersected with the values ofTYPE_TO_STACK_KEY. Both inputs live in the same file, so the derivation is entirely insidepackages/lint.Files touched, complete:
packages/lint/src/runtime-gate.ts,packages/lint/src/runtime-gate.derived-name-keys.test.ts(new), one changeset.packages/metadata-protocol/src/runtime-authoring-gate.ts— the fifth spelling,CLOSURE_CONTEXT_KEY_BY_TYPE— is deliberately not touched; it is another lane's surface andis filed separately as #13768.
The derivation reproduces the hand list exactly — measured BEFORE anything changed
A read-only TypeScript AST extraction of all four literals on
origin/main:Four members, not five and not three.
datasetsfalls out on its own for exactly the reason theold comment had to state by hand — it is context-only, no write type maps into it — so no
member needed a hand-written exception and none is kept. If a future member ever does need one,
the docblock says to state it there with its reason.
Constructive preservation, not a tightening or a loosening
sourceis byte-identical to the literal it replaces. Members keepCONTEXT_STACK_KEYSorder, sobuildTopLevelIndexPattern([...]).sourceequals/^(objects|permissions|books|pages)\[(\d+)\](.*)$/.source. Pinned as a test.(156 tests) pass unchanged, and the full package suite is 86 files / 2370 passed.
neither
@objectstack/lintnor@objectstack/lint/runtime.A2.3 — the two hazards a derived alternation has, decided rather than left implicit
[a-z]+, so nothing needs escaping and nothing wouldnotice if it were skipped. A stack key is a
RuntimeStackContextproperty name and a quoted onemay hold a
.; an unescaped.matches any character, which is the silent-widening direction.Members are escaped. Pinned with a synthetic
a.ckey, which must matcha.c[0].xand must NOTmatch
abc[0].x.page|pagesreads as though the short branchshadows the long one. It does not in this pattern, and that was measured rather than assumed:
the group is anchored by
\[, which fails the short branch and forces the engine to backtrackinto the long one. Pinned with a synthetic
page/pagespair in BOTH orders. Because theclaim is measured, the builder deliberately carries no longest-first sort — a sort here would
be a defence that never gets exercised, and it would also break the byte-identical preservation
above.
^()\[(\d+)\](.*)$, which wouldname-key every top-level index.
The acceptance criterion is mechanical, and the pin is not tautological
The four real keys agree with the list they replaced, which shows the answer is right today and
cannot show that the derivation is the reason. So the pin asks the invariant per context
collection, against the same table the gate consults (
WRITTEN_STACK_KEYS), through the realnameKeyFindingPath: is a top-level index rewritten exactly when a write type maps into thatcollection? The next widening is exercised on synthetic inputs, since the real one has not
happened.
Reverse-verification
Predicted before running: withholding the derivation must red the behavioural pin only, while
the synthetic-input tests stay green — that is what distinguishes "the pin catches a withheld
derivation" from "any edit reds everything".
Withheld by replacing the derived constant with the hand list that forgot
pages— the #13216near-miss exactly. Mutation proven on disk before the run, not by an exit code: derived-form
occurrences 1 → 0, withheld-form 0 → 1, blob
3f5d0486→835fe498. No build step is involved —the test imports
./runtime-gate.js, a relative same-package specifier vitest resolves tosrc/,which the ablation itself confirms empirically by reddening at all.
Exactly the predicted direction and exactly the predicted test. Restore proven by observed state:
git diff HEADempty,git statusclean, working-tree blob3f5d04860164f069d1d71f98cb77ce408007ec65equal to HEAD's, derived form back on disk and the withheld form gone.
Gates
Family derived AFTER the final commit with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, which read the change set from git itself (3 paths vs merge baseadf4bf4d7). 33 commands, run at485ce7d1c. Exit codes captured before any pipe.30 GREEN. 0 RED. 3 NOT MEASURED, each declared by the gate itself rather than by me:
check-test-completenessturbo run testlog; the gate prints "It is not a red, and there is nothing here to fix"check:dual-build-cjs-loadsdist/)check:type-check-debtThe narrowing on
check:type-check-debtis declared with a measurement, not an assertion, becauseit is the one gate this diff could genuinely move (a new test file in a TEST_DEBT-ledgered
package). An ad-hoc tsc program over
packages/lint's test layer with the package's closurebuilt reproduces the ledger entry exactly — 16 errors,
TS7006 x11+TS2835 x5, acrossvalidate-semantic-rolesx5,validate-dashboard-action-refsx4,validate-filter-tokensx3,validate-capability-referencesx3,validate-managed-api-methodsx1, matching the recordedcomposition file for file — and 0 of them are in either file this PR touches, so the
shrink-only count cannot drift up.
check:type-check-coverage, the structural half, ran GREENwith the new test file present (exit 0).
Related:
packages/lint/tsconfig.jsonexcludes**/*.test.ts, sopnpm --filter @objectstack/lint typecheckreads 0 test files (--listFiles: 78 files, 0 matching*.test.ts). That ispre-existing, ledgered state, not a regression — but it means the package
typechecksays nothingabout the new test file, so the ad-hoc program above is where that file was actually type-checked.
It is clean there.
Generated by Claude Code
Generated by Claude Code