Skip to content

Commit 8e03393

Browse files
fix(lint): derive the runtime gate's context-collection set from RuntimeStackContext (#14285)
CONTEXT_STACK_KEYS carried a satisfies clause -- validity, not completeness -- while the RuntimeStackContext docblock claimed it was derived from that shape. A collection declared on the interface and missing from the list was silently never carried into the per-write snapshot, so every rule resolving into it judged an empty universe and emitted findings that look correct. Derive it from a keyed record typed { [K in keyof RuntimeStackContext]-?: true } -- the -? mechanism proven at metadata-protocol's protocol.ts -- so a new context collection without its row is a type error naming that collection at tsc --noEmit and at the DTS build. Declaration order is preserved, since it feeds both the snapshot key order and the derived top-level-index alternation, and is now pinned end to end. Co-authored-by: Claude <trymqms@gmail.com>
1 parent 4fac7a0 commit 8e03393

3 files changed

Lines changed: 225 additions & 8 deletions

File tree

‎.changeset/tidy-eels-tickle.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/lint': patch
3+
---
4+
5+
Derive the runtime publish gate's context-collection set from `RuntimeStackContext` instead of hand-listing it.
6+
7+
`CONTEXT_STACK_KEYS` carried `as const satisfies readonly (keyof RuntimeStackContext)[]`, which asks that every entry it names is a real context key (validity) and never that every context key has an entry (completeness) — while the docblock on `RuntimeStackContext` claimed it was "derived from this shape and keeps the two from drifting". A collection declared on the interface but missing from the list was never carried into the per-write snapshot: the host passed it in, the gate dropped it, and every rule resolving references into that collection judged an empty universe and emitted findings that look correct. Measured, adding a collection to the interface left the package building at exit 0 with nothing red inside it.
8+
9+
The set is now derived from a keyed record typed `{ [K in keyof RuntimeStackContext]-?: true }` — the `-?` mechanism already proven at `@objectstack/metadata-protocol`'s `protocol.ts` — so a new context collection without its row is a type error naming that collection at `tsc --noEmit` and at the build. Declaration order is preserved and pinned, since it feeds both the snapshot's key order and the derived top-level-index alternation. No behaviour change: the same five collections are carried, in the same order.
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#13977] `CONTEXT_STACK_KEYS` is derived from `RuntimeStackContext`, and the
5+
* ORDER it derives is load-bearing. This file pins the half a type cannot.
6+
*
7+
* ## The split, stated once
8+
*
9+
* COMPLETENESS — every context collection has an entry — is held by the
10+
* compiler and belongs there: the keyed record the set derives from is typed
11+
* `{ [K in keyof RuntimeStackContext]-?: true }`, so a collection added to the
12+
* interface without a row is a type error naming that collection, in
13+
* `runtime-gate.ts`, at `tsc --noEmit` and at the DTS build. ⛔ It is
14+
* deliberately NOT restated here as a runtime assertion: this package's
15+
* `tsconfig.json` excludes `**\/*.test.ts`, so no tsc program compiles this
16+
* file and a type-level witness written here would evaluate never — a phantom
17+
* check that deletes clean. The guard's own failure was measured instead, on
18+
* the card, by adding a collection and reading the build.
19+
*
20+
* ORDER is what a test can hold, and the derivation had to be chosen so as not
21+
* to break it — a mapped type does not guarantee declaration order. The order
22+
* reaches two consumers, one of which is measured here and one of which
23+
* `runtime-gate.derived-name-keys.test.ts` already covers:
24+
*
25+
* - the snapshot's own key order (`Object.keys(baseline)`), which that file
26+
* reads back as a VALUE and asserts through an ordered `toEqual`;
27+
* - `TOP_LEVEL_INDEX`'s alternation, whose `source` #13390 keeps byte-identical
28+
* to the literal it replaced.
29+
*
30+
* ## Why the pre-existing ordered pin is not enough
31+
*
32+
* It asserts the order of the NAME-KEYED subset, which drops `datasets` (no
33+
* write type maps into it). So swapping `datasets` with either neighbour left
34+
* it green while genuinely reordering the set the snapshot is built from. The
35+
* first test below closes exactly that gap, and is the reason ordering could be
36+
* reported as load-bearing rather than assumed either way.
37+
*/
38+
39+
import { describe, expect, it } from 'vitest';
40+
41+
import { buildRuntimeWriteSnapshots } from './runtime-gate.js';
42+
43+
/**
44+
* The set as the GATE carries it, never as a constant re-imported or restated:
45+
* `buildRuntimeWriteSnapshots` gives the baseline one key per context
46+
* collection, in the order the derivation yields.
47+
*/
48+
const carriedStackKeys = () =>
49+
Object.keys(buildRuntimeWriteSnapshots({ type: 'object', item: { name: 'probe_object' } })!.baseline);
50+
51+
describe('the derived context-collection set (#13977)', () => {
52+
it('carries every context collection, in the declared stack-key order', () => {
53+
// The whole set, in order — including `datasets`, which the name-keyed pin
54+
// filters out and therefore cannot hold in position. A reordering of the
55+
// record `CONTEXT_STACK_KEYS` derives from lands here first.
56+
expect(carriedStackKeys()).toEqual(['objects', 'permissions', 'books', 'datasets', 'pages']);
57+
});
58+
59+
it('derives the same set whatever the write is, since the write does not choose it', () => {
60+
// A write into a context collection replaces its stored self and a write
61+
// outside them adds its own collection — neither may change WHICH context
62+
// collections are carried, or the gate would judge different universes for
63+
// different write types.
64+
const forNonContextWrite = Object.keys(
65+
buildRuntimeWriteSnapshots({ type: 'flow', item: { name: 'probe_flow' } })!.baseline,
66+
);
67+
68+
expect(forNonContextWrite).toEqual(carriedStackKeys());
69+
});
70+
71+
it('carries a collection the host never passed, rather than omitting the key', () => {
72+
// Completeness has a runtime half after all: the loop writes every derived
73+
// key unconditionally, so an absent context yields empty collections and a
74+
// rule resolving into one reads "empty universe" from a key that EXISTS.
75+
// (`runtime-gate.test.ts` pins the same shape against the whole set; this
76+
// states why the loop may not skip a missing collection.)
77+
const baseline = buildRuntimeWriteSnapshots({ type: 'book', item: { name: 'b1' } })!.baseline;
78+
79+
for (const key of carriedStackKeys()) {
80+
expect(baseline).toHaveProperty(key);
81+
expect(baseline[key]).toEqual([]);
82+
}
83+
});
84+
85+
it('every carried collection is one the interface declares — validity, still', () => {
86+
// The half the old `satisfies` clause DID hold, kept measurable now that the
87+
// clause is gone: the derived keys are the record's keys, and the record is
88+
// pinned to `keyof RuntimeStackContext`. Asserted against the context the
89+
// gate accepts, so an entry naming a collection the host cannot pass fails.
90+
const context = {
91+
objects: [{ name: 'acme_thing' }],
92+
permissions: [{ name: 'sales' }],
93+
books: [{ name: 'handbook' }],
94+
datasets: [{ name: 'revenue' }],
95+
pages: [{ name: 'home' }],
96+
};
97+
const baseline = buildRuntimeWriteSnapshots({ type: 'flow', item: { name: 'f1' }, context })!.baseline;
98+
99+
for (const key of carriedStackKeys()) {
100+
expect(context).toHaveProperty(key);
101+
expect(baseline[key]).toEqual(context[key as keyof typeof context]);
102+
}
103+
});
104+
});

‎packages/lint/src/runtime-gate.ts‎

Lines changed: 112 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,13 @@ const TYPE_TO_STACK_KEY: Readonly<Record<string, string>> = {
122122
* `positions` / `apps` — so those are NOT carried. Widening the snapshot is a
123123
* one-key edit here plus a `CONTEXT_STACK_KEYS` entry, made when a rule that
124124
* reads the collection actually crosses the wall, never in advance.
125+
*
126+
* [#13977] "Derived from this shape" is now the mechanism and not only the
127+
* intent: the second half of that edit is DEMANDED by the compiler rather than
128+
* remembered. Add a key here and this package stops building until the
129+
* collection has its row below — see {@link CONTEXT_STACK_KEYS} for what the
130+
* old `satisfies` clause could not ask, and what silently happened when the
131+
* row was forgotten.
125132
*/
126133
export interface RuntimeStackContext {
127134
/**
@@ -285,13 +292,105 @@ const PACKAGE_PROVENANCE_KEY = '_packageId';
285292
*/
286293
const OVERLAY_PROVENANCE_SENTINEL = 'sys_metadata';
287294

295+
/**
296+
* The context collections the snapshot carries, in stack-key order — DERIVED
297+
* from {@link RuntimeStackContext}, not listed (#13977).
298+
*
299+
* ## What the spelling this replaces could not ask
300+
*
301+
* It was a hand-written literal carrying `as const satisfies readonly (keyof
302+
* RuntimeStackContext)[]`. That clause asks that every entry it NAMES is a real
303+
* context key — validity. It does not ask that every context key HAS an entry —
304+
* completeness, which the docblock on {@link RuntimeStackContext} nevertheless
305+
* claimed ("derived from this shape and keeps the two from drifting"). Declared,
306+
* not enforced.
307+
*
308+
* The cost was not a missing member, it was a WRONG VERDICT.
309+
* {@link buildRuntimeWriteSnapshots} fills the snapshot by iterating this set, so
310+
* a collection declared on the interface and absent here is never carried: the
311+
* host passes it in, the gate drops it, and every rule resolving references into
312+
* that collection judges a universe that is empty — findings that look correct
313+
* against something that is not there (the `shyx_customer_ds` shape). Measured
314+
* before this card: adding `widgets?: readonly unknown[]` to the interface and
315+
* rebuilding left `pnpm --filter @objectstack/lint build` at **exit 0** with
316+
* nothing in this package red. The only red was second-order and one package
317+
* over — `protocol.ts`'s `-?` accumulator in `@objectstack/metadata-protocol`,
318+
* about a different constant, naming this one nowhere. The same asymmetry #13390
319+
* removed from `NAME_KEYED_STACK_KEYS` and #13768 from
320+
* `CLOSURE_CONTEXT_KEY_BY_TYPE`.
321+
*
322+
* ## Why a keyed record, and why that is a derivation rather than an assertion
323+
*
324+
* A type's keys cannot be materialised as values, so the derivation needs
325+
* exactly one runtime spelling to derive FROM, and the job is to make that
326+
* spelling impossible to leave incomplete. {@link CONTEXT_STACK_KEY_ORDER} is
327+
* that spelling and the mapped type pins it in BOTH directions: `-?` over `keyof
328+
* RuntimeStackContext` demands a row per collection (a missing one is a type
329+
* error naming the collection, in this file, at `tsc --noEmit` and at the DTS
330+
* build), and the object-literal excess-property check refuses a row for a
331+
* collection the interface no longer has. The array is then computed, so it
332+
* cannot disagree with the record.
333+
*
334+
* That is `protocol.ts`'s `-?` accumulator, the mechanism this repo already
335+
* proves. #13768 had to settle for a completeness ASSERTION beside its constant
336+
* only because the type lived one package away and the set was not readable
337+
* there as a value; here both inputs are in this file, so the stronger shape is
338+
* available and is what ships.
339+
*
340+
* ## Order is load-bearing, so the derivation preserves it (measured, #13977)
341+
*
342+
* The order this encodes reaches two places, and it was worth measuring before
343+
* choosing a spelling — a mapped type does not guarantee declaration order:
344+
*
345+
* - **The snapshot's own key order.** The loop in
346+
* {@link buildRuntimeWriteSnapshots} inserts in this order, so it is what
347+
* `Object.keys(baseline)` yields — read as a VALUE by
348+
* `runtime-gate.derived-name-keys.test.ts`, which feeds it to
349+
* {@link deriveNameKeyedStackKeys} and asserts an ORDERED result.
350+
* - **The derived alternation.** {@link deriveNameKeyedStackKeys} filters in
351+
* context order by contract ("Order follows `contextStackKeys`, deliberately"),
352+
* and {@link buildTopLevelIndexPattern} interpolates that order into
353+
* {@link TOP_LEVEL_INDEX}. Reordering does not change what the pattern MATCHES
354+
* — the `\[` anchor defeats prefix shadowing, pinned in both orders — but it
355+
* does change the pattern's `source`, which #13390 keeps byte-identical to the
356+
* literal it replaced on purpose.
357+
*
358+
* So the requirement is: preserve the author's order. This spelling does, and
359+
* that is the reason it is a keyed record rather than any union-to-tuple trick:
360+
* `Object.keys` returns own enumerable string keys in declaration order
361+
* (ECMAScript `OrdinaryOwnPropertyKeys`), so the order below IS the stack-key
362+
* order, chosen here and readable here. Integer-like keys would sort ahead of
363+
* insertion order, which is why that rule is stated rather than assumed — a
364+
* stack key is an interface property name and never one of those.
365+
*
366+
* Ordering is pinned by `runtime-gate.derived-context-keys.test.ts` end to end,
367+
* because the pre-existing ordered pin could not see all of it: it filters
368+
* `datasets` out (no write type maps into it), so swapping `datasets` with a
369+
* neighbour left that assertion green.
370+
*/
371+
const CONTEXT_STACK_KEY_ORDER = {
372+
objects: true,
373+
permissions: true,
374+
books: true,
375+
datasets: true,
376+
pages: true,
377+
} as const satisfies { [K in keyof RuntimeStackContext]-?: true };
378+
288379
/**
289380
* The context collections the snapshot carries, in stack-key order. Derived
290381
* facts: every entry is a key of {@link RuntimeStackContext} AND a stack key
291-
* some runtime-wired rule reads (`runtime-gate.test.ts` pins membership).
382+
* some runtime-wired rule reads (`runtime-gate.test.ts` pins membership);
383+
* every key of {@link RuntimeStackContext} has an entry (#13977 — the record
384+
* above, held by the compiler).
385+
*
386+
* `Object.keys` types as `string[]`, so the read-back is asserted. It is the one
387+
* assertion in the derivation and it is sound by construction: the record is
388+
* compiler-pinned to exactly `keyof RuntimeStackContext`, and `Object.keys`
389+
* returns exactly that record's own enumerable string keys. ⛔ Do not widen this
390+
* back into a literal — the list would stop being derived and the interface
391+
* would stop being the single source it says it is.
292392
*/
293-
const CONTEXT_STACK_KEYS = ['objects', 'permissions', 'books', 'datasets', 'pages'] as const satisfies
294-
readonly (keyof RuntimeStackContext)[];
393+
const CONTEXT_STACK_KEYS = Object.keys(CONTEXT_STACK_KEY_ORDER) as readonly (keyof RuntimeStackContext)[];
295394

296395
/** One rule's verdict at the runtime surface, carrying which rule produced it. */
297396
export interface RuntimeGateResult {
@@ -501,11 +600,16 @@ export const WRITTEN_STACK_KEYS: ReadonlySet<string> = new Set(Object.values(TYP
501600
* both are already written down: the context fills the collection
502601
* ({@link CONTEXT_STACK_KEYS}) and some write type maps into it
503602
* ({@link TYPE_TO_STACK_KEY}). Kept as a literal it was the one spelling of that
504-
* set with NO guard — `CONTEXT_STACK_KEYS` carries a `satisfies` clause, which
505-
* is validity, not completeness, and the compiler holds nothing else. Omitting a
506-
* member here did not fail to build, fail a test, or fail a gate; it emitted
507-
* findings that LOOK correct whose `path` the caller cannot resolve, which is
508-
* the #10064 defect re-created silently.
603+
* set with NO guard — at the time, `CONTEXT_STACK_KEYS` carried a `satisfies`
604+
* clause, which is validity, not completeness, and the compiler held nothing
605+
* else. Omitting a member here did not fail to build, fail a test, or fail a
606+
* gate; it emitted findings that LOOK correct whose `path` the caller cannot
607+
* resolve, which is the #10064 defect re-created silently.
608+
*
609+
* [#13977] That reading of `CONTEXT_STACK_KEYS` is now history rather than
610+
* description: it is derived from `RuntimeStackContext` and complete by
611+
* construction. This derivation is unchanged — it always rested on the
612+
* MEMBERSHIP of that set, and it now inherits a set the compiler keeps whole.
509613
*
510614
* [#13216] `pages` is the measurement that made the case: adding it touched
511615
* FIVE spellings of this one set and only the fifth announced itself — the one

0 commit comments

Comments
 (0)