Skip to content

Commit 4301f78

Browse files
refactor(lint): derive the runtime gate's name-keyed collection set (#13769)
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`. Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8ab4ace commit 4301f78

3 files changed

Lines changed: 301 additions & 22 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
refactor(lint): derive the runtime gate's name-keyed collection set instead of hand-listing it (#13390)
6+
7+
The set of collections the runtime publish gate carries in a per-write snapshot was
8+
written down in five places, and `NAME_KEYED_STACK_KEYS` was the one with no guard of
9+
any kind — `CONTEXT_STACK_KEYS` carries a `satisfies` clause, which is validity rather
10+
than completeness, and the compiler held nothing else.
11+
12+
That list carries a real invariant: a collection the CONTEXT fills **and** that some
13+
write type maps into must be name-keyed, or a finding's `path` is a positional index
14+
into an in-memory snapshot the caller has never seen and cannot enumerate — the defect
15+
#10064 fixed for `objects` / `permissions` / `books`. Omitting a member did not fail to
16+
build, fail a test, or fail a gate; it produced correct-LOOKING findings with paths the
17+
receiver cannot resolve. Adding the `pages` collection had to touch all five spellings
18+
and only one of them announced itself.
19+
20+
`NAME_KEYED_STACK_KEYS` and the `TOP_LEVEL_INDEX` pattern built from it are now derived
21+
from the two inputs that already state the answer: `CONTEXT_STACK_KEYS` intersected with
22+
the values of `TYPE_TO_STACK_KEY`. The intersection was measured against the list it
23+
replaces before anything changed — same four members (`objects`, `permissions`, `books`,
24+
`pages`) in the same order, and `datasets` excluded on its own because no write type maps
25+
into it, so no member needed a hand-written exception and none is kept.
26+
27+
Constructive preservation, not a tightening or a loosening: the derived pattern's `source`
28+
is byte-identical to the literal it replaces, and the gate returns the same findings for
29+
the same inputs. No published entry point changed — `@objectstack/lint` and
30+
`@objectstack/lint/runtime` export exactly the names they did before.
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#13390] The name-keyed collection set is DERIVED, and this is what makes the
5+
* derivation load-bearing rather than decorative.
6+
*
7+
* ## What was wrong with the list
8+
*
9+
* The set of collections a per-write snapshot carries was written down in five
10+
* places. `NAME_KEYED_STACK_KEYS` was the one with no guard of any kind, and it
11+
* carries a real invariant: a collection the CONTEXT fills **and** that some
12+
* write type maps into must be name-keyed, or a finding's `path` is a positional
13+
* index into an in-memory snapshot the caller has never seen and cannot
14+
* enumerate — the #10064 defect. Adding `pages` (#13216) had to touch all five
15+
* and only one announced itself. Omitting this one produced correct-LOOKING
16+
* findings and no test, type or gate went red.
17+
*
18+
* ## What this file pins, and why in this shape
19+
*
20+
* The four keys that exist today agree with the list they replaced. That shows
21+
* the answer is right, and cannot show the derivation is the REASON — the whole
22+
* value of this card is about the NEXT widening. So every claim here is made
23+
* one of two ways:
24+
*
25+
* - against the module's own inputs (`WRITTEN_STACK_KEYS` and the context set
26+
* read back out of a real snapshot), never against a restated list — a test
27+
* that hand-listed the members would be a sixth spelling of the same set;
28+
* - on SYNTHETIC inputs through the exported pure builders, which is the only
29+
* way to exercise a widening that has not happened, and the only way to
30+
* exercise the two hazards a derived regex has and a literal did not.
31+
*/
32+
33+
import { describe, expect, it } from 'vitest';
34+
35+
import {
36+
WRITTEN_STACK_KEYS,
37+
buildRuntimeWriteSnapshots,
38+
buildTopLevelIndexPattern,
39+
deriveNameKeyedStackKeys,
40+
nameKeyFindingPath,
41+
} from './runtime-gate.js';
42+
43+
/**
44+
* The context set, read back from a REAL snapshot rather than imported as a
45+
* constant: `buildRuntimeWriteSnapshots` gives the baseline one key per context
46+
* collection, so this is the set the gate actually carries, not a claim about it.
47+
*/
48+
const contextStackKeys = Object.keys(
49+
buildRuntimeWriteSnapshots({ type: 'object', item: { name: 'probe_object' } })!.baseline,
50+
);
51+
52+
describe('the derived name-keyed set (#13390)', () => {
53+
it('reproduces exactly the membership the hand list carried — four, in the same order', () => {
54+
expect(deriveNameKeyedStackKeys(contextStackKeys, WRITTEN_STACK_KEYS)).toEqual([
55+
'objects',
56+
'permissions',
57+
'books',
58+
'pages',
59+
]);
60+
});
61+
62+
it('excludes `datasets` by derivation, not by a written-down exception', () => {
63+
// The old comment had to STATE this. Now it falls out: the context fills
64+
// `datasets`, and no write type lands an item in it, so a `datasets[0]`
65+
// path is not a position in anything the caller cannot enumerate.
66+
expect(contextStackKeys).toContain('datasets');
67+
expect(WRITTEN_STACK_KEYS.has('datasets')).toBe(false);
68+
});
69+
70+
it.each(contextStackKeys)(
71+
'%s is name-keyed exactly when a write type maps into it',
72+
(key) => {
73+
// The invariant end to end, asked per context collection against the same
74+
// table the gate consults. A hand-kept list that forgot a member — the
75+
// #13216 near-miss — fails here; so does one that name-keys a
76+
// context-only collection.
77+
const candidate = { [key]: [{ name: 'acme_thing' }] };
78+
const rewritten = nameKeyFindingPath(`${key}[0].sharingModel`, candidate);
79+
80+
expect(rewritten).toBe(
81+
WRITTEN_STACK_KEYS.has(key)
82+
? `${key}.acme_thing.sharingModel`
83+
: `${key}[0].sharingModel`,
84+
);
85+
},
86+
);
87+
88+
it('takes the context order, so a write-table reordering cannot move it', () => {
89+
expect(deriveNameKeyedStackKeys(['c', 'b', 'a'], ['a', 'b'])).toEqual(['b', 'a']);
90+
});
91+
92+
it('a write type mapping onto a NON-context key contributes nothing', () => {
93+
// `flow` -> `flows` is a real mapping onto a collection the context never
94+
// fills; `flows[0]` IS the write, trivially stable, and must stay positional.
95+
expect(deriveNameKeyedStackKeys(['objects'], ['objects', 'flows'])).toEqual(['objects']);
96+
expect(nameKeyFindingPath('flows[0].name', { flows: [{ name: 'acme_flow' }] })).toBe(
97+
'flows[0].name',
98+
);
99+
});
100+
101+
it('the next widening cannot half-land', () => {
102+
// The acceptance criterion, stated synthetically because the real widening
103+
// has not happened yet: adding a context collection that a write type maps
104+
// into name-keys it and joins the pattern, with no second edit to forget.
105+
const widenedContext = [...contextStackKeys, 'reports'];
106+
const widenedWrites = new Set([...WRITTEN_STACK_KEYS, 'reports']);
107+
108+
const derived = deriveNameKeyedStackKeys(widenedContext, widenedWrites);
109+
expect(derived).toContain('reports');
110+
expect(buildTopLevelIndexPattern(derived).exec('reports[7].title')?.[1]).toBe('reports');
111+
});
112+
});
113+
114+
describe('the derived top-level index pattern (#13390)', () => {
115+
it('rebuilds the literal it replaced, source for source', () => {
116+
// Constructive preservation, byte for byte: same members, same order, so
117+
// the derived pattern and the hand-written one are the same regex.
118+
expect(buildTopLevelIndexPattern(['objects', 'permissions', 'books', 'pages']).source).toBe(
119+
/^(objects|permissions|books|pages)\[(\d+)\](.*)$/.source,
120+
);
121+
});
122+
123+
it('escapes members instead of trusting them to be `[a-z]+`', () => {
124+
const re = buildTopLevelIndexPattern(['a.c']);
125+
expect(re.test('a.c[0].x')).toBe(true);
126+
// An unescaped `.` matches any character — the silent-widening direction.
127+
expect(re.test('abc[0].x')).toBe(false);
128+
});
129+
130+
it.each([
131+
['short branch first', ['page', 'pages']],
132+
['long branch first', ['pages', 'page']],
133+
])('resolves a prefix pair regardless of order (%s)', (_label, keys) => {
134+
// Alternation IS ordered, so `page|pages` reads as though it shadows
135+
// `pages`. The `\[` anchor fails the short branch and forces a backtrack
136+
// into the long one — measured here in both orders, which is why the
137+
// builder does not carry a longest-first sort it would never exercise.
138+
const re = buildTopLevelIndexPattern(keys);
139+
expect(re.exec('pages[3].x')?.[1]).toBe('pages');
140+
expect(re.exec('page[3].x')?.[1]).toBe('page');
141+
});
142+
143+
it('an empty set matches nothing, rather than every top-level index', () => {
144+
const re = buildTopLevelIndexPattern([]);
145+
expect(re.test('objects[0].x')).toBe(false);
146+
expect(re.test('[0].x')).toBe(false);
147+
});
148+
});

packages/lint/src/runtime-gate.ts

Lines changed: 123 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ type AnyRec = Record<string, unknown>;
6565
* Only the types some rule declares in `runtimeTypes` need an entry; the guard
6666
* in `authoring-rule-wiring.test.ts` fails if a declared type is missing one,
6767
* so widening the gate cannot half-land.
68+
*
69+
* [#13390] The VALUES here are also one of the two inputs
70+
* {@link NAME_KEYED_STACK_KEYS} is derived from — a stack key that some write
71+
* type maps into is a key whose top-level index the caller cannot resolve. Adding
72+
* a mapping onto a context collection therefore name-keys it by construction; it
73+
* is no longer a second edit that nothing checks.
6874
*/
6975
const TYPE_TO_STACK_KEY: Readonly<Record<string, string>> = {
7076
flow: 'flows',
@@ -413,30 +419,118 @@ export function buildRuntimeWriteSnapshots(args: {
413419
return { baseline, candidate };
414420
}
415421

422+
/**
423+
* The name-keyed stack keys implied by a context shape and a write-type table:
424+
* the context collections that some write type ALSO lands an item inside.
425+
*
426+
* Exported (#13390) as a pure function of its two inputs so the derivation can
427+
* be exercised on SYNTHETIC sets. The real inputs are four keys that agree with
428+
* the list they replaced, which shows the answer is right today and cannot show
429+
* that the DERIVATION is the reason — the property this card buys is about the
430+
* next widening, so it has to be measured on inputs that widen.
431+
*
432+
* Order follows `contextStackKeys`, deliberately: it keeps the derived value
433+
* comparable to the hand list it replaced position for position, and it keeps
434+
* the pattern built from it byte-identical to the literal it replaced.
435+
*/
436+
export function deriveNameKeyedStackKeys(
437+
contextStackKeys: readonly string[],
438+
writtenStackKeys: Iterable<string>,
439+
): readonly string[] {
440+
const written = new Set(writtenStackKeys);
441+
return contextStackKeys.filter((key) => written.has(key));
442+
}
443+
444+
/**
445+
* `['objects', 'pages']` → `/^(objects|pages)\[(\d+)\](.*)$/` — the top-level
446+
* index matcher, BUILT from the name-keyed set instead of restating it (#13390).
447+
*
448+
* A derived alternation has two hazards a hand-written literal did not, and both
449+
* are decided here rather than left implicit:
450+
*
451+
* - **Escaping.** Every member today is `[a-z]+`, so nothing needs escaping and
452+
* nothing would notice if it were skipped. But a stack key is a
453+
* {@link RuntimeStackContext} property name, and a quoted one may hold a `.`
454+
* or a `-`; an unescaped `.` matches ANY character, which is the silent-failure
455+
* direction. Members are escaped rather than trusted — one `replace`.
456+
* - **Prefix ordering.** Alternation is ordered, so `page|pages` reads as though
457+
* the short branch shadows the long one. It does not in THIS pattern: the group
458+
* is anchored by `\[`, which fails the short branch and forces the engine to
459+
* backtrack into the long one. That is a property of the anchor, not of
460+
* alternation — so it is pinned by test with a synthetic `page` / `pages` pair
461+
* in BOTH orders, rather than papered over with a longest-first sort that would
462+
* silently stop being exercised and would leave the claim untested either way.
463+
*
464+
* An empty set yields a pattern matching nothing. Interpolating it would produce
465+
* `^()\[(\d+)\](.*)$`, which name-keys EVERY top-level index — the failure
466+
* direction that widens the rewrite instead of narrowing it.
467+
*/
468+
export function buildTopLevelIndexPattern(stackKeys: readonly string[]): RegExp {
469+
if (stackKeys.length === 0) return /(?!)/;
470+
const alternation = stackKeys.map((key) => key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
471+
return new RegExp(`^(${alternation})\\[(\\d+)\\](.*)$`);
472+
}
473+
474+
/**
475+
* The stack keys some write type lands an item INSIDE — the VALUES of
476+
* {@link TYPE_TO_STACK_KEY}, read off the table rather than restated, so a new
477+
* `type → key` mapping cannot arrive without this set seeing it.
478+
*
479+
* Exported for the pin in `runtime-gate.derived-name-keys.test.ts` and for that
480+
* only — it is not on either package entry. The pin asks, per context
481+
* collection, whether a top-level index is name-keyed, and it must ask that
482+
* against the SAME table the gate uses; a test that restated the answer would
483+
* be a sixth hand-written spelling of the very set this card removed.
484+
*/
485+
export const WRITTEN_STACK_KEYS: ReadonlySet<string> = new Set(Object.values(TYPE_TO_STACK_KEY));
486+
416487
/**
417488
* The collection-resident stack keys whose TOP-LEVEL index the gate rewrites
418-
* to a name key before findings leave it (#10064).
489+
* to a name key before findings leave it (#10064) — DERIVED, not listed (#13390).
419490
*
420491
* These are the collections a written item lands INSIDE **and** that the
421-
* context also fills (`TYPE_TO_STACK_KEY` routes `object` / `permission` /
422-
* `book` / `page` writes into them) — so a finding's `objects[417]` is an
423-
* offset into this gate's per-write snapshot, an in-memory array the caller has
424-
* never seen and cannot enumerate. Every other write type is the sole member of
425-
* its own collection (`flows[0]` IS this write, trivially stable), and
426-
* `datasets` is context-only — no write type maps into it — so both keep their
427-
* positional spelling.
428-
*
429-
* [#13216] `pages` JOINED this list in the same change that made `pages` a
430-
* context collection, and the pairing is the rule rather than a coincidence:
431-
* before that, a `page` write's snapshot held exactly one page, so `pages[0]`
432-
* was this write, trivially stable, and name-keying it would have been
433-
* pointless. The moment the live universe joins the snapshot, the index stops
434-
* meaning anything to the caller — `validatePresetComparands` already runs on
435-
* `page` writes and emits paths into this collection. So: adding a key to
436-
* {@link CONTEXT_STACK_KEYS} that some write type ALSO maps into means adding
437-
* it here too.
492+
* context also fills — so a finding's `objects[417]` is an offset into this
493+
* gate's per-write snapshot, an in-memory array the caller has never seen and
494+
* cannot enumerate. Every other write type is the sole member of its own
495+
* collection (`flows[0]` IS this write, trivially stable), and a context-only
496+
* collection holds no write at all — so both keep their positional spelling.
497+
*
498+
* ## Why it is derived
499+
*
500+
* That paragraph is not a judgement call, it is two conditions intersected, and
501+
* both are already written down: the context fills the collection
502+
* ({@link CONTEXT_STACK_KEYS}) and some write type maps into it
503+
* ({@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.
509+
*
510+
* [#13216] `pages` is the measurement that made the case: adding it touched
511+
* FIVE spellings of this one set and only the fifth announced itself — the one
512+
* the compiler could see, and only after that accumulator was retyped as a
513+
* mapped type. The pairing is the rule rather than a coincidence. Before the
514+
* live page universe joined the snapshot, a `page` write's snapshot held exactly
515+
* one page, so `pages[0]` WAS this write and name-keying it would have been
516+
* pointless; the moment the universe joins, the index stops meaning anything to
517+
* the caller (`validatePresetComparands` already runs on `page` writes and emits
518+
* paths into this collection). Derived, the two move together by construction
519+
* and the next widening is a one-key edit again.
520+
*
521+
* ## Measured against the list it replaces (#13390)
522+
*
523+
* Same four members in the same order — `objects`, `permissions`, `books`,
524+
* `pages`. `datasets` falls out on its own, for exactly the reason the old
525+
* comment had to state by hand: it is context-only, no write type maps into it.
526+
* So **no member needed a hand-written exception** and none is kept. If a future
527+
* member ever does need one, state it here WITH its reason — quietly
528+
* re-introducing a literal is the thing this constant now exists to prevent.
438529
*/
439-
const NAME_KEYED_STACK_KEYS = ['objects', 'permissions', 'books', 'pages'] as const;
530+
const NAME_KEYED_STACK_KEYS: readonly string[] = deriveNameKeyedStackKeys(
531+
CONTEXT_STACK_KEYS,
532+
WRITTEN_STACK_KEYS,
533+
);
440534

441535
/**
442536
* Machine names safe to splice into a dotted path. Matches the spec's
@@ -446,7 +540,7 @@ const NAME_KEYED_STACK_KEYS = ['objects', 'permissions', 'books', 'pages'] as co
446540
*/
447541
const PATH_SAFE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
448542

449-
const TOP_LEVEL_INDEX = /^(objects|permissions|books|pages)\[(\d+)\](.*)$/;
543+
const TOP_LEVEL_INDEX = buildTopLevelIndexPattern(NAME_KEYED_STACK_KEYS);
450544

451545
/**
452546
* `objects[417].sharingModel` → `objects.acme_invoice.sharingModel` (#10064).
@@ -458,6 +552,13 @@ const TOP_LEVEL_INDEX = /^(objects|permissions|books|pages)\[(\d+)\](.*)$/;
458552
* purpose — within one named item they index the author's own document, which
459553
* the receiver holds and can resolve.
460554
*
555+
* [#13390] Exported for the pin, not for callers (it is on neither package
556+
* entry). It is the one place the derived set and the derived pattern MEET, so
557+
* it is where the invariant is observable end to end: for each context
558+
* collection, is the top-level index rewritten exactly when a write type maps
559+
* into that collection? Asked here, the answer cannot be produced by a list
560+
* that agrees with the derivation by luck.
561+
*
461562
* Fallback is the positional spelling, never a hole: an entry that is missing,
462563
* unnamed, or whose name will not splice into a dotted path keeps the index.
463564
*
@@ -466,11 +567,11 @@ const TOP_LEVEL_INDEX = /^(objects|permissions|books|pages)\[(\d+)\](.*)$/;
466567
* stored items that (illegitimately) share a name must not have their distinct
467568
* findings merged or cancelled by the rewrite.
468569
*/
469-
function nameKeyFindingPath(path: string, candidate: AnyRec): string {
570+
export function nameKeyFindingPath(path: string, candidate: AnyRec): string {
470571
const m = TOP_LEVEL_INDEX.exec(path);
471572
if (!m) return path;
472573
const [, stackKey, index, rest] = m;
473-
if (!(NAME_KEYED_STACK_KEYS as readonly string[]).includes(stackKey!)) return path;
574+
if (!NAME_KEYED_STACK_KEYS.includes(stackKey!)) return path;
474575
const collection = candidate[stackKey!] as readonly unknown[] | undefined;
475576
const entry = collection?.[Number(index)];
476577
const name = entry && typeof entry === 'object' ? (entry as AnyRec).name : undefined;

0 commit comments

Comments
 (0)