Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/tidy-eels-tickle.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
'@objectstack/lint': patch
---

Derive the runtime publish gate's context-collection set from `RuntimeStackContext` instead of hand-listing it.

`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.

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.
104 changes: 104 additions & 0 deletions packages/lint/src/runtime-gate.derived-context-keys.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13977] `CONTEXT_STACK_KEYS` is derived from `RuntimeStackContext`, and the
* ORDER it derives is load-bearing. This file pins the half a type cannot.
*
* ## The split, stated once
*
* COMPLETENESS — every context collection has an entry — is held by the
* compiler and belongs there: the keyed record the set derives from is typed
* `{ [K in keyof RuntimeStackContext]-?: true }`, so a collection added to the
* interface without a row is a type error naming that collection, in
* `runtime-gate.ts`, at `tsc --noEmit` and at the DTS build. ⛔ It is
* deliberately NOT restated here as a runtime assertion: this package's
* `tsconfig.json` excludes `**\/*.test.ts`, so no tsc program compiles this
* file and a type-level witness written here would evaluate never — a phantom
* check that deletes clean. The guard's own failure was measured instead, on
* the card, by adding a collection and reading the build.
*
* ORDER is what a test can hold, and the derivation had to be chosen so as not
* to break it — a mapped type does not guarantee declaration order. The order
* reaches two consumers, one of which is measured here and one of which
* `runtime-gate.derived-name-keys.test.ts` already covers:
*
* - the snapshot's own key order (`Object.keys(baseline)`), which that file
* reads back as a VALUE and asserts through an ordered `toEqual`;
* - `TOP_LEVEL_INDEX`'s alternation, whose `source` #13390 keeps byte-identical
* to the literal it replaced.
*
* ## Why the pre-existing ordered pin is not enough
*
* It asserts the order of the NAME-KEYED subset, which drops `datasets` (no
* write type maps into it). So swapping `datasets` with either neighbour left
* it green while genuinely reordering the set the snapshot is built from. The
* first test below closes exactly that gap, and is the reason ordering could be
* reported as load-bearing rather than assumed either way.
*/

import { describe, expect, it } from 'vitest';

import { buildRuntimeWriteSnapshots } from './runtime-gate.js';

/**
* The set as the GATE carries it, never as a constant re-imported or restated:
* `buildRuntimeWriteSnapshots` gives the baseline one key per context
* collection, in the order the derivation yields.
*/
const carriedStackKeys = () =>
Object.keys(buildRuntimeWriteSnapshots({ type: 'object', item: { name: 'probe_object' } })!.baseline);

describe('the derived context-collection set (#13977)', () => {
it('carries every context collection, in the declared stack-key order', () => {
// The whole set, in order — including `datasets`, which the name-keyed pin
// filters out and therefore cannot hold in position. A reordering of the
// record `CONTEXT_STACK_KEYS` derives from lands here first.
expect(carriedStackKeys()).toEqual(['objects', 'permissions', 'books', 'datasets', 'pages']);
});

it('derives the same set whatever the write is, since the write does not choose it', () => {
// A write into a context collection replaces its stored self and a write
// outside them adds its own collection — neither may change WHICH context
// collections are carried, or the gate would judge different universes for
// different write types.
const forNonContextWrite = Object.keys(
buildRuntimeWriteSnapshots({ type: 'flow', item: { name: 'probe_flow' } })!.baseline,
);

expect(forNonContextWrite).toEqual(carriedStackKeys());
});

it('carries a collection the host never passed, rather than omitting the key', () => {
// Completeness has a runtime half after all: the loop writes every derived
// key unconditionally, so an absent context yields empty collections and a
// rule resolving into one reads "empty universe" from a key that EXISTS.
// (`runtime-gate.test.ts` pins the same shape against the whole set; this
// states why the loop may not skip a missing collection.)
const baseline = buildRuntimeWriteSnapshots({ type: 'book', item: { name: 'b1' } })!.baseline;

for (const key of carriedStackKeys()) {
expect(baseline).toHaveProperty(key);
expect(baseline[key]).toEqual([]);
}
});

it('every carried collection is one the interface declares — validity, still', () => {
// The half the old `satisfies` clause DID hold, kept measurable now that the
// clause is gone: the derived keys are the record's keys, and the record is
// pinned to `keyof RuntimeStackContext`. Asserted against the context the
// gate accepts, so an entry naming a collection the host cannot pass fails.
const context = {
objects: [{ name: 'acme_thing' }],
permissions: [{ name: 'sales' }],
books: [{ name: 'handbook' }],
datasets: [{ name: 'revenue' }],
pages: [{ name: 'home' }],
};
const baseline = buildRuntimeWriteSnapshots({ type: 'flow', item: { name: 'f1' }, context })!.baseline;

for (const key of carriedStackKeys()) {
expect(context).toHaveProperty(key);
expect(baseline[key]).toEqual(context[key as keyof typeof context]);
}
});
});
120 changes: 112 additions & 8 deletions packages/lint/src/runtime-gate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,6 +122,13 @@ const TYPE_TO_STACK_KEY: Readonly<Record<string, string>> = {
* `positions` / `apps` — so those are NOT carried. Widening the snapshot is a
* one-key edit here plus a `CONTEXT_STACK_KEYS` entry, made when a rule that
* reads the collection actually crosses the wall, never in advance.
*
* [#13977] "Derived from this shape" is now the mechanism and not only the
* intent: the second half of that edit is DEMANDED by the compiler rather than
* remembered. Add a key here and this package stops building until the
* collection has its row below — see {@link CONTEXT_STACK_KEYS} for what the
* old `satisfies` clause could not ask, and what silently happened when the
* row was forgotten.
*/
export interface RuntimeStackContext {
/**
Expand DownExpand Up@@ -285,13 +292,105 @@ const PACKAGE_PROVENANCE_KEY = '_packageId';
*/
const OVERLAY_PROVENANCE_SENTINEL = 'sys_metadata';

/**
* The context collections the snapshot carries, in stack-key order — DERIVED
* from {@link RuntimeStackContext}, not listed (#13977).
*
* ## What the spelling this replaces could not ask
*
* It was a hand-written literal carrying `as const satisfies readonly (keyof
* RuntimeStackContext)[]`. That clause asks that every entry it NAMES is a real
* context key — validity. It does not ask that every context key HAS an entry —
* completeness, which the docblock on {@link RuntimeStackContext} nevertheless
* claimed ("derived from this shape and keeps the two from drifting"). Declared,
* not enforced.
*
* The cost was not a missing member, it was a WRONG VERDICT.
* {@link buildRuntimeWriteSnapshots} fills the snapshot by iterating this set, so
* a collection declared on the interface and absent here is never carried: the
* host passes it in, the gate drops it, and every rule resolving references into
* that collection judges a universe that is empty — findings that look correct
* against something that is not there (the `shyx_customer_ds` shape). Measured
* before this card: adding `widgets?: readonly unknown[]` to the interface and
* rebuilding left `pnpm --filter @objectstack/lint build` at **exit 0** with
* nothing in this package red. The only red was second-order and one package
* over — `protocol.ts`'s `-?` accumulator in `@objectstack/metadata-protocol`,
* about a different constant, naming this one nowhere. The same asymmetry #13390
* removed from `NAME_KEYED_STACK_KEYS` and #13768 from
* `CLOSURE_CONTEXT_KEY_BY_TYPE`.
*
* ## Why a keyed record, and why that is a derivation rather than an assertion
*
* A type's keys cannot be materialised as values, so the derivation needs
* exactly one runtime spelling to derive FROM, and the job is to make that
* spelling impossible to leave incomplete. {@link CONTEXT_STACK_KEY_ORDER} is
* that spelling and the mapped type pins it in BOTH directions: `-?` over `keyof
* RuntimeStackContext` demands a row per collection (a missing one is a type
* error naming the collection, in this file, at `tsc --noEmit` and at the DTS
* build), and the object-literal excess-property check refuses a row for a
* collection the interface no longer has. The array is then computed, so it
* cannot disagree with the record.
*
* That is `protocol.ts`'s `-?` accumulator, the mechanism this repo already
* proves. #13768 had to settle for a completeness ASSERTION beside its constant
* only because the type lived one package away and the set was not readable
* there as a value; here both inputs are in this file, so the stronger shape is
* available and is what ships.
*
* ## Order is load-bearing, so the derivation preserves it (measured, #13977)
*
* The order this encodes reaches two places, and it was worth measuring before
* choosing a spelling — a mapped type does not guarantee declaration order:
*
* - **The snapshot's own key order.** The loop in
* {@link buildRuntimeWriteSnapshots} inserts in this order, so it is what
* `Object.keys(baseline)` yields — read as a VALUE by
* `runtime-gate.derived-name-keys.test.ts`, which feeds it to
* {@link deriveNameKeyedStackKeys} and asserts an ORDERED result.
* - **The derived alternation.** {@link deriveNameKeyedStackKeys} filters in
* context order by contract ("Order follows `contextStackKeys`, deliberately"),
* and {@link buildTopLevelIndexPattern} interpolates that order into
* {@link TOP_LEVEL_INDEX}. Reordering does not change what the pattern MATCHES
* — the `\[` anchor defeats prefix shadowing, pinned in both orders — but it
* does change the pattern's `source`, which #13390 keeps byte-identical to the
* literal it replaced on purpose.
*
* So the requirement is: preserve the author's order. This spelling does, and
* that is the reason it is a keyed record rather than any union-to-tuple trick:
* `Object.keys` returns own enumerable string keys in declaration order
* (ECMAScript `OrdinaryOwnPropertyKeys`), so the order below IS the stack-key
* order, chosen here and readable here. Integer-like keys would sort ahead of
* insertion order, which is why that rule is stated rather than assumed — a
* stack key is an interface property name and never one of those.
*
* Ordering is pinned by `runtime-gate.derived-context-keys.test.ts` end to end,
* because the pre-existing ordered pin could not see all of it: it filters
* `datasets` out (no write type maps into it), so swapping `datasets` with a
* neighbour left that assertion green.
*/
const CONTEXT_STACK_KEY_ORDER = {
objects: true,
permissions: true,
books: true,
datasets: true,
pages: true,
} as const satisfies { [K in keyof RuntimeStackContext]-?: true };

/**
* The context collections the snapshot carries, in stack-key order. Derived
* facts: every entry is a key of {@link RuntimeStackContext} AND a stack key
* some runtime-wired rule reads (`runtime-gate.test.ts` pins membership).
* some runtime-wired rule reads (`runtime-gate.test.ts` pins membership);
* every key of {@link RuntimeStackContext} has an entry (#13977 — the record
* above, held by the compiler).
*
* `Object.keys` types as `string[]`, so the read-back is asserted. It is the one
* assertion in the derivation and it is sound by construction: the record is
* compiler-pinned to exactly `keyof RuntimeStackContext`, and `Object.keys`
* returns exactly that record's own enumerable string keys. ⛔ Do not widen this
* back into a literal — the list would stop being derived and the interface
* would stop being the single source it says it is.
*/
const CONTEXT_STACK_KEYS = ['objects', 'permissions', 'books', 'datasets', 'pages'] as const satisfies
readonly (keyof RuntimeStackContext)[];
const CONTEXT_STACK_KEYS = Object.keys(CONTEXT_STACK_KEY_ORDER) as readonly (keyof RuntimeStackContext)[];

/** One rule's verdict at the runtime surface, carrying which rule produced it. */
export interface RuntimeGateResult {
Expand DownExpand Up@@ -501,11 +600,16 @@ export const WRITTEN_STACK_KEYS: ReadonlySet<string> = new Set(Object.values(TYP
* both are already written down: the context fills the collection
* ({@link CONTEXT_STACK_KEYS}) and some write type maps into it
* ({@link TYPE_TO_STACK_KEY}). Kept as a literal it was the one spelling of that
* set with NO guard — `CONTEXT_STACK_KEYS` carries a `satisfies` clause, which
* is validity, not completeness, and the compiler holds nothing else. Omitting a
* member here did not fail to build, fail a test, or fail a gate; it emitted
* findings that LOOK correct whose `path` the caller cannot resolve, which is
* the #10064 defect re-created silently.
* set with NO guard — at the time, `CONTEXT_STACK_KEYS` carried a `satisfies`
* clause, which is validity, not completeness, and the compiler held nothing
* else. Omitting a member here did not fail to build, fail a test, or fail a
* gate; it emitted findings that LOOK correct whose `path` the caller cannot
* resolve, which is the #10064 defect re-created silently.
*
* [#13977] That reading of `CONTEXT_STACK_KEYS` is now history rather than
* description: it is derived from `RuntimeStackContext` and complete by
* construction. This derivation is unchanged — it always rested on the
* MEMBERSHIP of that set, and it now inherits a set the compiler keeps whole.
*
* [#13216] `pages` is the measurement that made the case: adding it touched
* FIVE spellings of this one set and only the fifth announced itself — the one
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/tidy-eels-tickle.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
'@objectstack/lint': patch
---

Derive the runtime publish gate's context-collection set from `RuntimeStackContext` instead of hand-listing it.

`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.

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.
104 changes: 104 additions & 0 deletions packages/lint/src/runtime-gate.derived-context-keys.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13977] `CONTEXT_STACK_KEYS` is derived from `RuntimeStackContext`, and the
* ORDER it derives is load-bearing. This file pins the half a type cannot.
*
* ## The split, stated once
*
* COMPLETENESS — every context collection has an entry — is held by the
* compiler and belongs there: the keyed record the set derives from is typed
* `{ [K in keyof RuntimeStackContext]-?: true }`, so a collection added to the
* interface without a row is a type error naming that collection, in
* `runtime-gate.ts`, at `tsc --noEmit` and at the DTS build. ⛔ It is
* deliberately NOT restated here as a runtime assertion: this package's
* `tsconfig.json` excludes `**\/*.test.ts`, so no tsc program compiles this
* file and a type-level witness written here would evaluate never — a phantom
* check that deletes clean. The guard's own failure was measured instead, on
* the card, by adding a collection and reading the build.
*
* ORDER is what a test can hold, and the derivation had to be chosen so as not
* to break it — a mapped type does not guarantee declaration order. The order
* reaches two consumers, one of which is measured here and one of which
* `runtime-gate.derived-name-keys.test.ts` already covers:
*
* - the snapshot's own key order (`Object.keys(baseline)`), which that file
* reads back as a VALUE and asserts through an ordered `toEqual`;
* - `TOP_LEVEL_INDEX`'s alternation, whose `source` #13390 keeps byte-identical
* to the literal it replaced.
*
* ## Why the pre-existing ordered pin is not enough
*
* It asserts the order of the NAME-KEYED subset, which drops `datasets` (no
* write type maps into it). So swapping `datasets` with either neighbour left
* it green while genuinely reordering the set the snapshot is built from. The
* first test below closes exactly that gap, and is the reason ordering could be
* reported as load-bearing rather than assumed either way.
*/

import { describe, expect, it } from 'vitest';

import { buildRuntimeWriteSnapshots } from './runtime-gate.js';

/**
* The set as the GATE carries it, never as a constant re-imported or restated:
* `buildRuntimeWriteSnapshots` gives the baseline one key per context
* collection, in the order the derivation yields.
*/
const carriedStackKeys = () =>
Object.keys(buildRuntimeWriteSnapshots({ type: 'object', item: { name: 'probe_object' } })!.baseline);

describe('the derived context-collection set (#13977)', () => {
it('carries every context collection, in the declared stack-key order', () => {
// The whole set, in order — including `datasets`, which the name-keyed pin
// filters out and therefore cannot hold in position. A reordering of the
// record `CONTEXT_STACK_KEYS` derives from lands here first.
expect(carriedStackKeys()).toEqual(['objects', 'permissions', 'books', 'datasets', 'pages']);
});

it('derives the same set whatever the write is, since the write does not choose it', () => {
// A write into a context collection replaces its stored self and a write
// outside them adds its own collection — neither may change WHICH context
// collections are carried, or the gate would judge different universes for
// different write types.
const forNonContextWrite = Object.keys(
buildRuntimeWriteSnapshots({ type: 'flow', item: { name: 'probe_flow' } })!.baseline,
);

expect(forNonContextWrite).toEqual(carriedStackKeys());
});

it('carries a collection the host never passed, rather than omitting the key', () => {
// Completeness has a runtime half after all: the loop writes every derived
// key unconditionally, so an absent context yields empty collections and a
// rule resolving into one reads "empty universe" from a key that EXISTS.
// (`runtime-gate.test.ts` pins the same shape against the whole set; this
// states why the loop may not skip a missing collection.)
const baseline = buildRuntimeWriteSnapshots({ type: 'book', item: { name: 'b1' } })!.baseline;

for (const key of carriedStackKeys()) {
expect(baseline).toHaveProperty(key);
expect(baseline[key]).toEqual([]);
}
});

it('every carried collection is one the interface declares — validity, still', () => {
// The half the old `satisfies` clause DID hold, kept measurable now that the
// clause is gone: the derived keys are the record's keys, and the record is
// pinned to `keyof RuntimeStackContext`. Asserted against the context the
// gate accepts, so an entry naming a collection the host cannot pass fails.
const context = {
objects: [{ name: 'acme_thing' }],
permissions: [{ name: 'sales' }],
books: [{ name: 'handbook' }],
datasets: [{ name: 'revenue' }],
pages: [{ name: 'home' }],
};
const baseline = buildRuntimeWriteSnapshots({ type: 'flow', item: { name: 'f1' }, context })!.baseline;

for (const key of carriedStackKeys()) {
expect(context).toHaveProperty(key);
expect(baseline[key]).toEqual(context[key as keyof typeof context]);
}
});
});
120 changes: 112 additions & 8 deletions packages/lint/src/runtime-gate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,6 +122,13 @@ const TYPE_TO_STACK_KEY: Readonly<Record<string, string>> = {
* `positions` / `apps` — so those are NOT carried. Widening the snapshot is a
* one-key edit here plus a `CONTEXT_STACK_KEYS` entry, made when a rule that
* reads the collection actually crosses the wall, never in advance.
*
* [#13977] "Derived from this shape" is now the mechanism and not only the
* intent: the second half of that edit is DEMANDED by the compiler rather than
* remembered. Add a key here and this package stops building until the
* collection has its row below — see {@link CONTEXT_STACK_KEYS} for what the
* old `satisfies` clause could not ask, and what silently happened when the
* row was forgotten.
*/
export interface RuntimeStackContext {
/**
Expand DownExpand Up@@ -285,13 +292,105 @@ const PACKAGE_PROVENANCE_KEY = '_packageId';
*/
const OVERLAY_PROVENANCE_SENTINEL = 'sys_metadata';

/**
* The context collections the snapshot carries, in stack-key order — DERIVED
* from {@link RuntimeStackContext}, not listed (#13977).
*
* ## What the spelling this replaces could not ask
*
* It was a hand-written literal carrying `as const satisfies readonly (keyof
* RuntimeStackContext)[]`. That clause asks that every entry it NAMES is a real
* context key — validity. It does not ask that every context key HAS an entry —
* completeness, which the docblock on {@link RuntimeStackContext} nevertheless
* claimed ("derived from this shape and keeps the two from drifting"). Declared,
* not enforced.
*
* The cost was not a missing member, it was a WRONG VERDICT.
* {@link buildRuntimeWriteSnapshots} fills the snapshot by iterating this set, so
* a collection declared on the interface and absent here is never carried: the
* host passes it in, the gate drops it, and every rule resolving references into
* that collection judges a universe that is empty — findings that look correct
* against something that is not there (the `shyx_customer_ds` shape). Measured
* before this card: adding `widgets?: readonly unknown[]` to the interface and
* rebuilding left `pnpm --filter @objectstack/lint build` at **exit 0** with
* nothing in this package red. The only red was second-order and one package
* over — `protocol.ts`'s `-?` accumulator in `@objectstack/metadata-protocol`,
* about a different constant, naming this one nowhere. The same asymmetry #13390
* removed from `NAME_KEYED_STACK_KEYS` and #13768 from
* `CLOSURE_CONTEXT_KEY_BY_TYPE`.
*
* ## Why a keyed record, and why that is a derivation rather than an assertion
*
* A type's keys cannot be materialised as values, so the derivation needs
* exactly one runtime spelling to derive FROM, and the job is to make that
* spelling impossible to leave incomplete. {@link CONTEXT_STACK_KEY_ORDER} is
* that spelling and the mapped type pins it in BOTH directions: `-?` over `keyof
* RuntimeStackContext` demands a row per collection (a missing one is a type
* error naming the collection, in this file, at `tsc --noEmit` and at the DTS
* build), and the object-literal excess-property check refuses a row for a
* collection the interface no longer has. The array is then computed, so it
* cannot disagree with the record.
*
* That is `protocol.ts`'s `-?` accumulator, the mechanism this repo already
* proves. #13768 had to settle for a completeness ASSERTION beside its constant
* only because the type lived one package away and the set was not readable
* there as a value; here both inputs are in this file, so the stronger shape is
* available and is what ships.
*
* ## Order is load-bearing, so the derivation preserves it (measured, #13977)
*
* The order this encodes reaches two places, and it was worth measuring before
* choosing a spelling — a mapped type does not guarantee declaration order:
*
* - **The snapshot's own key order.** The loop in
* {@link buildRuntimeWriteSnapshots} inserts in this order, so it is what
* `Object.keys(baseline)` yields — read as a VALUE by
* `runtime-gate.derived-name-keys.test.ts`, which feeds it to
* {@link deriveNameKeyedStackKeys} and asserts an ORDERED result.
* - **The derived alternation.** {@link deriveNameKeyedStackKeys} filters in
* context order by contract ("Order follows `contextStackKeys`, deliberately"),
* and {@link buildTopLevelIndexPattern} interpolates that order into
* {@link TOP_LEVEL_INDEX}. Reordering does not change what the pattern MATCHES
* — the `\[` anchor defeats prefix shadowing, pinned in both orders — but it
* does change the pattern's `source`, which #13390 keeps byte-identical to the
* literal it replaced on purpose.
*
* So the requirement is: preserve the author's order. This spelling does, and
* that is the reason it is a keyed record rather than any union-to-tuple trick:
* `Object.keys` returns own enumerable string keys in declaration order
* (ECMAScript `OrdinaryOwnPropertyKeys`), so the order below IS the stack-key
* order, chosen here and readable here. Integer-like keys would sort ahead of
* insertion order, which is why that rule is stated rather than assumed — a
* stack key is an interface property name and never one of those.
*
* Ordering is pinned by `runtime-gate.derived-context-keys.test.ts` end to end,
* because the pre-existing ordered pin could not see all of it: it filters
* `datasets` out (no write type maps into it), so swapping `datasets` with a
* neighbour left that assertion green.
*/
const CONTEXT_STACK_KEY_ORDER = {
objects: true,
permissions: true,
books: true,
datasets: true,
pages: true,
} as const satisfies { [K in keyof RuntimeStackContext]-?: true };

/**
* The context collections the snapshot carries, in stack-key order. Derived
* facts: every entry is a key of {@link RuntimeStackContext} AND a stack key
* some runtime-wired rule reads (`runtime-gate.test.ts` pins membership).
* some runtime-wired rule reads (`runtime-gate.test.ts` pins membership);
* every key of {@link RuntimeStackContext} has an entry (#13977 — the record
* above, held by the compiler).
*
* `Object.keys` types as `string[]`, so the read-back is asserted. It is the one
* assertion in the derivation and it is sound by construction: the record is
* compiler-pinned to exactly `keyof RuntimeStackContext`, and `Object.keys`
* returns exactly that record's own enumerable string keys. ⛔ Do not widen this
* back into a literal — the list would stop being derived and the interface
* would stop being the single source it says it is.
*/
const CONTEXT_STACK_KEYS = ['objects', 'permissions', 'books', 'datasets', 'pages'] as const satisfies
readonly (keyof RuntimeStackContext)[];
const CONTEXT_STACK_KEYS = Object.keys(CONTEXT_STACK_KEY_ORDER) as readonly (keyof RuntimeStackContext)[];

/** One rule's verdict at the runtime surface, carrying which rule produced it. */
export interface RuntimeGateResult {
Expand DownExpand Up@@ -501,11 +600,16 @@ export const WRITTEN_STACK_KEYS: ReadonlySet<string> = new Set(Object.values(TYP
* both are already written down: the context fills the collection
* ({@link CONTEXT_STACK_KEYS}) and some write type maps into it
* ({@link TYPE_TO_STACK_KEY}). Kept as a literal it was the one spelling of that
* set with NO guard — `CONTEXT_STACK_KEYS` carries a `satisfies` clause, which
* is validity, not completeness, and the compiler holds nothing else. Omitting a
* member here did not fail to build, fail a test, or fail a gate; it emitted
* findings that LOOK correct whose `path` the caller cannot resolve, which is
* the #10064 defect re-created silently.
* set with NO guard — at the time, `CONTEXT_STACK_KEYS` carried a `satisfies`
* clause, which is validity, not completeness, and the compiler held nothing
* else. Omitting a member here did not fail to build, fail a test, or fail a
* gate; it emitted findings that LOOK correct whose `path` the caller cannot
* resolve, which is the #10064 defect re-created silently.
*
* [#13977] That reading of `CONTEXT_STACK_KEYS` is now history rather than
* description: it is derived from `RuntimeStackContext` and complete by
* construction. This derivation is unchanged — it always rested on the
* MEMBERSHIP of that set, and it now inherits a set the compiler keeps whole.
*
* [#13216] `pages` is the measurement that made the case: adding it touched
* FIVE spellings of this one set and only the fifth announced itself — the one
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/tidy-eels-tickle.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
'@objectstack/lint': patch
---

Derive the runtime publish gate's context-collection set from `RuntimeStackContext` instead of hand-listing it.

`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.

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.
104 changes: 104 additions & 0 deletions packages/lint/src/runtime-gate.derived-context-keys.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13977] `CONTEXT_STACK_KEYS` is derived from `RuntimeStackContext`, and the
* ORDER it derives is load-bearing. This file pins the half a type cannot.
*
* ## The split, stated once
*
* COMPLETENESS — every context collection has an entry — is held by the
* compiler and belongs there: the keyed record the set derives from is typed
* `{ [K in keyof RuntimeStackContext]-?: true }`, so a collection added to the
* interface without a row is a type error naming that collection, in
* `runtime-gate.ts`, at `tsc --noEmit` and at the DTS build. ⛔ It is
* deliberately NOT restated here as a runtime assertion: this package's
* `tsconfig.json` excludes `**\/*.test.ts`, so no tsc program compiles this
* file and a type-level witness written here would evaluate never — a phantom
* check that deletes clean. The guard's own failure was measured instead, on
* the card, by adding a collection and reading the build.
*
* ORDER is what a test can hold, and the derivation had to be chosen so as not
* to break it — a mapped type does not guarantee declaration order. The order
* reaches two consumers, one of which is measured here and one of which
* `runtime-gate.derived-name-keys.test.ts` already covers:
*
* - the snapshot's own key order (`Object.keys(baseline)`), which that file
* reads back as a VALUE and asserts through an ordered `toEqual`;
* - `TOP_LEVEL_INDEX`'s alternation, whose `source` #13390 keeps byte-identical
* to the literal it replaced.
*
* ## Why the pre-existing ordered pin is not enough
*
* It asserts the order of the NAME-KEYED subset, which drops `datasets` (no
* write type maps into it). So swapping `datasets` with either neighbour left
* it green while genuinely reordering the set the snapshot is built from. The
* first test below closes exactly that gap, and is the reason ordering could be
* reported as load-bearing rather than assumed either way.
*/

import { describe, expect, it } from 'vitest';

import { buildRuntimeWriteSnapshots } from './runtime-gate.js';

/**
* The set as the GATE carries it, never as a constant re-imported or restated:
* `buildRuntimeWriteSnapshots` gives the baseline one key per context
* collection, in the order the derivation yields.
*/
const carriedStackKeys = () =>
Object.keys(buildRuntimeWriteSnapshots({ type: 'object', item: { name: 'probe_object' } })!.baseline);

describe('the derived context-collection set (#13977)', () => {
it('carries every context collection, in the declared stack-key order', () => {
// The whole set, in order — including `datasets`, which the name-keyed pin
// filters out and therefore cannot hold in position. A reordering of the
// record `CONTEXT_STACK_KEYS` derives from lands here first.
expect(carriedStackKeys()).toEqual(['objects', 'permissions', 'books', 'datasets', 'pages']);
});

it('derives the same set whatever the write is, since the write does not choose it', () => {
// A write into a context collection replaces its stored self and a write
// outside them adds its own collection — neither may change WHICH context
// collections are carried, or the gate would judge different universes for
// different write types.
const forNonContextWrite = Object.keys(
buildRuntimeWriteSnapshots({ type: 'flow', item: { name: 'probe_flow' } })!.baseline,
);

expect(forNonContextWrite).toEqual(carriedStackKeys());
});

it('carries a collection the host never passed, rather than omitting the key', () => {
// Completeness has a runtime half after all: the loop writes every derived
// key unconditionally, so an absent context yields empty collections and a
// rule resolving into one reads "empty universe" from a key that EXISTS.
// (`runtime-gate.test.ts` pins the same shape against the whole set; this
// states why the loop may not skip a missing collection.)
const baseline = buildRuntimeWriteSnapshots({ type: 'book', item: { name: 'b1' } })!.baseline;

for (const key of carriedStackKeys()) {
expect(baseline).toHaveProperty(key);
expect(baseline[key]).toEqual([]);
}
});

it('every carried collection is one the interface declares — validity, still', () => {
// The half the old `satisfies` clause DID hold, kept measurable now that the
// clause is gone: the derived keys are the record's keys, and the record is
// pinned to `keyof RuntimeStackContext`. Asserted against the context the
// gate accepts, so an entry naming a collection the host cannot pass fails.
const context = {
objects: [{ name: 'acme_thing' }],
permissions: [{ name: 'sales' }],
books: [{ name: 'handbook' }],
datasets: [{ name: 'revenue' }],
pages: [{ name: 'home' }],
};
const baseline = buildRuntimeWriteSnapshots({ type: 'flow', item: { name: 'f1' }, context })!.baseline;

for (const key of carriedStackKeys()) {
expect(context).toHaveProperty(key);
expect(baseline[key]).toEqual(context[key as keyof typeof context]);
}
});
});
120 changes: 112 additions & 8 deletions packages/lint/src/runtime-gate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,6 +122,13 @@ const TYPE_TO_STACK_KEY: Readonly<Record<string, string>> = {
* `positions` / `apps` — so those are NOT carried. Widening the snapshot is a
* one-key edit here plus a `CONTEXT_STACK_KEYS` entry, made when a rule that
* reads the collection actually crosses the wall, never in advance.
*
* [#13977] "Derived from this shape" is now the mechanism and not only the
* intent: the second half of that edit is DEMANDED by the compiler rather than
* remembered. Add a key here and this package stops building until the
* collection has its row below — see {@link CONTEXT_STACK_KEYS} for what the
* old `satisfies` clause could not ask, and what silently happened when the
* row was forgotten.
*/
export interface RuntimeStackContext {
/**
Expand DownExpand Up@@ -285,13 +292,105 @@ const PACKAGE_PROVENANCE_KEY = '_packageId';
*/
const OVERLAY_PROVENANCE_SENTINEL = 'sys_metadata';

/**
* The context collections the snapshot carries, in stack-key order — DERIVED
* from {@link RuntimeStackContext}, not listed (#13977).
*
* ## What the spelling this replaces could not ask
*
* It was a hand-written literal carrying `as const satisfies readonly (keyof
* RuntimeStackContext)[]`. That clause asks that every entry it NAMES is a real
* context key — validity. It does not ask that every context key HAS an entry —
* completeness, which the docblock on {@link RuntimeStackContext} nevertheless
* claimed ("derived from this shape and keeps the two from drifting"). Declared,
* not enforced.
*
* The cost was not a missing member, it was a WRONG VERDICT.
* {@link buildRuntimeWriteSnapshots} fills the snapshot by iterating this set, so
* a collection declared on the interface and absent here is never carried: the
* host passes it in, the gate drops it, and every rule resolving references into
* that collection judges a universe that is empty — findings that look correct
* against something that is not there (the `shyx_customer_ds` shape). Measured
* before this card: adding `widgets?: readonly unknown[]` to the interface and
* rebuilding left `pnpm --filter @objectstack/lint build` at **exit 0** with
* nothing in this package red. The only red was second-order and one package
* over — `protocol.ts`'s `-?` accumulator in `@objectstack/metadata-protocol`,
* about a different constant, naming this one nowhere. The same asymmetry #13390
* removed from `NAME_KEYED_STACK_KEYS` and #13768 from
* `CLOSURE_CONTEXT_KEY_BY_TYPE`.
*
* ## Why a keyed record, and why that is a derivation rather than an assertion
*
* A type's keys cannot be materialised as values, so the derivation needs
* exactly one runtime spelling to derive FROM, and the job is to make that
* spelling impossible to leave incomplete. {@link CONTEXT_STACK_KEY_ORDER} is
* that spelling and the mapped type pins it in BOTH directions: `-?` over `keyof
* RuntimeStackContext` demands a row per collection (a missing one is a type
* error naming the collection, in this file, at `tsc --noEmit` and at the DTS
* build), and the object-literal excess-property check refuses a row for a
* collection the interface no longer has. The array is then computed, so it
* cannot disagree with the record.
*
* That is `protocol.ts`'s `-?` accumulator, the mechanism this repo already
* proves. #13768 had to settle for a completeness ASSERTION beside its constant
* only because the type lived one package away and the set was not readable
* there as a value; here both inputs are in this file, so the stronger shape is
* available and is what ships.
*
* ## Order is load-bearing, so the derivation preserves it (measured, #13977)
*
* The order this encodes reaches two places, and it was worth measuring before
* choosing a spelling — a mapped type does not guarantee declaration order:
*
* - **The snapshot's own key order.** The loop in
* {@link buildRuntimeWriteSnapshots} inserts in this order, so it is what
* `Object.keys(baseline)` yields — read as a VALUE by
* `runtime-gate.derived-name-keys.test.ts`, which feeds it to
* {@link deriveNameKeyedStackKeys} and asserts an ORDERED result.
* - **The derived alternation.** {@link deriveNameKeyedStackKeys} filters in
* context order by contract ("Order follows `contextStackKeys`, deliberately"),
* and {@link buildTopLevelIndexPattern} interpolates that order into
* {@link TOP_LEVEL_INDEX}. Reordering does not change what the pattern MATCHES
* — the `\[` anchor defeats prefix shadowing, pinned in both orders — but it
* does change the pattern's `source`, which #13390 keeps byte-identical to the
* literal it replaced on purpose.
*
* So the requirement is: preserve the author's order. This spelling does, and
* that is the reason it is a keyed record rather than any union-to-tuple trick:
* `Object.keys` returns own enumerable string keys in declaration order
* (ECMAScript `OrdinaryOwnPropertyKeys`), so the order below IS the stack-key
* order, chosen here and readable here. Integer-like keys would sort ahead of
* insertion order, which is why that rule is stated rather than assumed — a
* stack key is an interface property name and never one of those.
*
* Ordering is pinned by `runtime-gate.derived-context-keys.test.ts` end to end,
* because the pre-existing ordered pin could not see all of it: it filters
* `datasets` out (no write type maps into it), so swapping `datasets` with a
* neighbour left that assertion green.
*/
const CONTEXT_STACK_KEY_ORDER = {
objects: true,
permissions: true,
books: true,
datasets: true,
pages: true,
} as const satisfies { [K in keyof RuntimeStackContext]-?: true };

/**
* The context collections the snapshot carries, in stack-key order. Derived
* facts: every entry is a key of {@link RuntimeStackContext} AND a stack key
* some runtime-wired rule reads (`runtime-gate.test.ts` pins membership).
* some runtime-wired rule reads (`runtime-gate.test.ts` pins membership);
* every key of {@link RuntimeStackContext} has an entry (#13977 — the record
* above, held by the compiler).
*
* `Object.keys` types as `string[]`, so the read-back is asserted. It is the one
* assertion in the derivation and it is sound by construction: the record is
* compiler-pinned to exactly `keyof RuntimeStackContext`, and `Object.keys`
* returns exactly that record's own enumerable string keys. ⛔ Do not widen this
* back into a literal — the list would stop being derived and the interface
* would stop being the single source it says it is.
*/
const CONTEXT_STACK_KEYS = ['objects', 'permissions', 'books', 'datasets', 'pages'] as const satisfies
readonly (keyof RuntimeStackContext)[];
const CONTEXT_STACK_KEYS = Object.keys(CONTEXT_STACK_KEY_ORDER) as readonly (keyof RuntimeStackContext)[];

/** One rule's verdict at the runtime surface, carrying which rule produced it. */
export interface RuntimeGateResult {
Expand DownExpand Up@@ -501,11 +600,16 @@ export const WRITTEN_STACK_KEYS: ReadonlySet<string> = new Set(Object.values(TYP
* both are already written down: the context fills the collection
* ({@link CONTEXT_STACK_KEYS}) and some write type maps into it
* ({@link TYPE_TO_STACK_KEY}). Kept as a literal it was the one spelling of that
* set with NO guard — `CONTEXT_STACK_KEYS` carries a `satisfies` clause, which
* is validity, not completeness, and the compiler holds nothing else. Omitting a
* member here did not fail to build, fail a test, or fail a gate; it emitted
* findings that LOOK correct whose `path` the caller cannot resolve, which is
* the #10064 defect re-created silently.
* set with NO guard — at the time, `CONTEXT_STACK_KEYS` carried a `satisfies`
* clause, which is validity, not completeness, and the compiler held nothing
* else. Omitting a member here did not fail to build, fail a test, or fail a
* gate; it emitted findings that LOOK correct whose `path` the caller cannot
* resolve, which is the #10064 defect re-created silently.
*
* [#13977] That reading of `CONTEXT_STACK_KEYS` is now history rather than
* description: it is derived from `RuntimeStackContext` and complete by
* construction. This derivation is unchanged — it always rested on the
* MEMBERSHIP of that set, and it now inherits a set the compiler keeps whole.
*
* [#13216] `pages` is the measurement that made the case: adding it touched
* FIVE spellings of this one set and only the fifth announced itself — the one
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/tidy-eels-tickle.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
'@objectstack/lint': patch
---

Derive the runtime publish gate's context-collection set from `RuntimeStackContext` instead of hand-listing it.

`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.

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.
104 changes: 104 additions & 0 deletions packages/lint/src/runtime-gate.derived-context-keys.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13977] `CONTEXT_STACK_KEYS` is derived from `RuntimeStackContext`, and the
* ORDER it derives is load-bearing. This file pins the half a type cannot.
*
* ## The split, stated once
*
* COMPLETENESS — every context collection has an entry — is held by the
* compiler and belongs there: the keyed record the set derives from is typed
* `{ [K in keyof RuntimeStackContext]-?: true }`, so a collection added to the
* interface without a row is a type error naming that collection, in
* `runtime-gate.ts`, at `tsc --noEmit` and at the DTS build. ⛔ It is
* deliberately NOT restated here as a runtime assertion: this package's
* `tsconfig.json` excludes `**\/*.test.ts`, so no tsc program compiles this
* file and a type-level witness written here would evaluate never — a phantom
* check that deletes clean. The guard's own failure was measured instead, on
* the card, by adding a collection and reading the build.
*
* ORDER is what a test can hold, and the derivation had to be chosen so as not
* to break it — a mapped type does not guarantee declaration order. The order
* reaches two consumers, one of which is measured here and one of which
* `runtime-gate.derived-name-keys.test.ts` already covers:
*
* - the snapshot's own key order (`Object.keys(baseline)`), which that file
* reads back as a VALUE and asserts through an ordered `toEqual`;
* - `TOP_LEVEL_INDEX`'s alternation, whose `source` #13390 keeps byte-identical
* to the literal it replaced.
*
* ## Why the pre-existing ordered pin is not enough
*
* It asserts the order of the NAME-KEYED subset, which drops `datasets` (no
* write type maps into it). So swapping `datasets` with either neighbour left
* it green while genuinely reordering the set the snapshot is built from. The
* first test below closes exactly that gap, and is the reason ordering could be
* reported as load-bearing rather than assumed either way.
*/

import { describe, expect, it } from 'vitest';

import { buildRuntimeWriteSnapshots } from './runtime-gate.js';

/**
* The set as the GATE carries it, never as a constant re-imported or restated:
* `buildRuntimeWriteSnapshots` gives the baseline one key per context
* collection, in the order the derivation yields.
*/
const carriedStackKeys = () =>
Object.keys(buildRuntimeWriteSnapshots({ type: 'object', item: { name: 'probe_object' } })!.baseline);

describe('the derived context-collection set (#13977)', () => {
it('carries every context collection, in the declared stack-key order', () => {
// The whole set, in order — including `datasets`, which the name-keyed pin
// filters out and therefore cannot hold in position. A reordering of the
// record `CONTEXT_STACK_KEYS` derives from lands here first.
expect(carriedStackKeys()).toEqual(['objects', 'permissions', 'books', 'datasets', 'pages']);
});

it('derives the same set whatever the write is, since the write does not choose it', () => {
// A write into a context collection replaces its stored self and a write
// outside them adds its own collection — neither may change WHICH context
// collections are carried, or the gate would judge different universes for
// different write types.
const forNonContextWrite = Object.keys(
buildRuntimeWriteSnapshots({ type: 'flow', item: { name: 'probe_flow' } })!.baseline,
);

expect(forNonContextWrite).toEqual(carriedStackKeys());
});

it('carries a collection the host never passed, rather than omitting the key', () => {
// Completeness has a runtime half after all: the loop writes every derived
// key unconditionally, so an absent context yields empty collections and a
// rule resolving into one reads "empty universe" from a key that EXISTS.
// (`runtime-gate.test.ts` pins the same shape against the whole set; this
// states why the loop may not skip a missing collection.)
const baseline = buildRuntimeWriteSnapshots({ type: 'book', item: { name: 'b1' } })!.baseline;

for (const key of carriedStackKeys()) {
expect(baseline).toHaveProperty(key);
expect(baseline[key]).toEqual([]);
}
});

it('every carried collection is one the interface declares — validity, still', () => {
// The half the old `satisfies` clause DID hold, kept measurable now that the
// clause is gone: the derived keys are the record's keys, and the record is
// pinned to `keyof RuntimeStackContext`. Asserted against the context the
// gate accepts, so an entry naming a collection the host cannot pass fails.
const context = {
objects: [{ name: 'acme_thing' }],
permissions: [{ name: 'sales' }],
books: [{ name: 'handbook' }],
datasets: [{ name: 'revenue' }],
pages: [{ name: 'home' }],
};
const baseline = buildRuntimeWriteSnapshots({ type: 'flow', item: { name: 'f1' }, context })!.baseline;

for (const key of carriedStackKeys()) {
expect(context).toHaveProperty(key);
expect(baseline[key]).toEqual(context[key as keyof typeof context]);
}
});
});
120 changes: 112 additions & 8 deletions packages/lint/src/runtime-gate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,6 +122,13 @@ const TYPE_TO_STACK_KEY: Readonly<Record<string, string>> = {
* `positions` / `apps` — so those are NOT carried. Widening the snapshot is a
* one-key edit here plus a `CONTEXT_STACK_KEYS` entry, made when a rule that
* reads the collection actually crosses the wall, never in advance.
*
* [#13977] "Derived from this shape" is now the mechanism and not only the
* intent: the second half of that edit is DEMANDED by the compiler rather than
* remembered. Add a key here and this package stops building until the
* collection has its row below — see {@link CONTEXT_STACK_KEYS} for what the
* old `satisfies` clause could not ask, and what silently happened when the
* row was forgotten.
*/
export interface RuntimeStackContext {
/**
Expand DownExpand Up@@ -285,13 +292,105 @@ const PACKAGE_PROVENANCE_KEY = '_packageId';
*/
const OVERLAY_PROVENANCE_SENTINEL = 'sys_metadata';

/**
* The context collections the snapshot carries, in stack-key order — DERIVED
* from {@link RuntimeStackContext}, not listed (#13977).
*
* ## What the spelling this replaces could not ask
*
* It was a hand-written literal carrying `as const satisfies readonly (keyof
* RuntimeStackContext)[]`. That clause asks that every entry it NAMES is a real
* context key — validity. It does not ask that every context key HAS an entry —
* completeness, which the docblock on {@link RuntimeStackContext} nevertheless
* claimed ("derived from this shape and keeps the two from drifting"). Declared,
* not enforced.
*
* The cost was not a missing member, it was a WRONG VERDICT.
* {@link buildRuntimeWriteSnapshots} fills the snapshot by iterating this set, so
* a collection declared on the interface and absent here is never carried: the
* host passes it in, the gate drops it, and every rule resolving references into
* that collection judges a universe that is empty — findings that look correct
* against something that is not there (the `shyx_customer_ds` shape). Measured
* before this card: adding `widgets?: readonly unknown[]` to the interface and
* rebuilding left `pnpm --filter @objectstack/lint build` at **exit 0** with
* nothing in this package red. The only red was second-order and one package
* over — `protocol.ts`'s `-?` accumulator in `@objectstack/metadata-protocol`,
* about a different constant, naming this one nowhere. The same asymmetry #13390
* removed from `NAME_KEYED_STACK_KEYS` and #13768 from
* `CLOSURE_CONTEXT_KEY_BY_TYPE`.
*
* ## Why a keyed record, and why that is a derivation rather than an assertion
*
* A type's keys cannot be materialised as values, so the derivation needs
* exactly one runtime spelling to derive FROM, and the job is to make that
* spelling impossible to leave incomplete. {@link CONTEXT_STACK_KEY_ORDER} is
* that spelling and the mapped type pins it in BOTH directions: `-?` over `keyof
* RuntimeStackContext` demands a row per collection (a missing one is a type
* error naming the collection, in this file, at `tsc --noEmit` and at the DTS
* build), and the object-literal excess-property check refuses a row for a
* collection the interface no longer has. The array is then computed, so it
* cannot disagree with the record.
*
* That is `protocol.ts`'s `-?` accumulator, the mechanism this repo already
* proves. #13768 had to settle for a completeness ASSERTION beside its constant
* only because the type lived one package away and the set was not readable
* there as a value; here both inputs are in this file, so the stronger shape is
* available and is what ships.
*
* ## Order is load-bearing, so the derivation preserves it (measured, #13977)
*
* The order this encodes reaches two places, and it was worth measuring before
* choosing a spelling — a mapped type does not guarantee declaration order:
*
* - **The snapshot's own key order.** The loop in
* {@link buildRuntimeWriteSnapshots} inserts in this order, so it is what
* `Object.keys(baseline)` yields — read as a VALUE by
* `runtime-gate.derived-name-keys.test.ts`, which feeds it to
* {@link deriveNameKeyedStackKeys} and asserts an ORDERED result.
* - **The derived alternation.** {@link deriveNameKeyedStackKeys} filters in
* context order by contract ("Order follows `contextStackKeys`, deliberately"),
* and {@link buildTopLevelIndexPattern} interpolates that order into
* {@link TOP_LEVEL_INDEX}. Reordering does not change what the pattern MATCHES
* — the `\[` anchor defeats prefix shadowing, pinned in both orders — but it
* does change the pattern's `source`, which #13390 keeps byte-identical to the
* literal it replaced on purpose.
*
* So the requirement is: preserve the author's order. This spelling does, and
* that is the reason it is a keyed record rather than any union-to-tuple trick:
* `Object.keys` returns own enumerable string keys in declaration order
* (ECMAScript `OrdinaryOwnPropertyKeys`), so the order below IS the stack-key
* order, chosen here and readable here. Integer-like keys would sort ahead of
* insertion order, which is why that rule is stated rather than assumed — a
* stack key is an interface property name and never one of those.
*
* Ordering is pinned by `runtime-gate.derived-context-keys.test.ts` end to end,
* because the pre-existing ordered pin could not see all of it: it filters
* `datasets` out (no write type maps into it), so swapping `datasets` with a
* neighbour left that assertion green.
*/
const CONTEXT_STACK_KEY_ORDER = {
objects: true,
permissions: true,
books: true,
datasets: true,
pages: true,
} as const satisfies { [K in keyof RuntimeStackContext]-?: true };

/**
* The context collections the snapshot carries, in stack-key order. Derived
* facts: every entry is a key of {@link RuntimeStackContext} AND a stack key
* some runtime-wired rule reads (`runtime-gate.test.ts` pins membership).
* some runtime-wired rule reads (`runtime-gate.test.ts` pins membership);
* every key of {@link RuntimeStackContext} has an entry (#13977 — the record
* above, held by the compiler).
*
* `Object.keys` types as `string[]`, so the read-back is asserted. It is the one
* assertion in the derivation and it is sound by construction: the record is
* compiler-pinned to exactly `keyof RuntimeStackContext`, and `Object.keys`
* returns exactly that record's own enumerable string keys. ⛔ Do not widen this
* back into a literal — the list would stop being derived and the interface
* would stop being the single source it says it is.
*/
const CONTEXT_STACK_KEYS = ['objects', 'permissions', 'books', 'datasets', 'pages'] as const satisfies
readonly (keyof RuntimeStackContext)[];
const CONTEXT_STACK_KEYS = Object.keys(CONTEXT_STACK_KEY_ORDER) as readonly (keyof RuntimeStackContext)[];

/** One rule's verdict at the runtime surface, carrying which rule produced it. */
export interface RuntimeGateResult {
Expand DownExpand Up@@ -501,11 +600,16 @@ export const WRITTEN_STACK_KEYS: ReadonlySet<string> = new Set(Object.values(TYP
* both are already written down: the context fills the collection
* ({@link CONTEXT_STACK_KEYS}) and some write type maps into it
* ({@link TYPE_TO_STACK_KEY}). Kept as a literal it was the one spelling of that
* set with NO guard — `CONTEXT_STACK_KEYS` carries a `satisfies` clause, which
* is validity, not completeness, and the compiler holds nothing else. Omitting a
* member here did not fail to build, fail a test, or fail a gate; it emitted
* findings that LOOK correct whose `path` the caller cannot resolve, which is
* the #10064 defect re-created silently.
* set with NO guard — at the time, `CONTEXT_STACK_KEYS` carried a `satisfies`
* clause, which is validity, not completeness, and the compiler held nothing
* else. Omitting a member here did not fail to build, fail a test, or fail a
* gate; it emitted findings that LOOK correct whose `path` the caller cannot
* resolve, which is the #10064 defect re-created silently.
*
* [#13977] That reading of `CONTEXT_STACK_KEYS` is now history rather than
* description: it is derived from `RuntimeStackContext` and complete by
* construction. This derivation is unchanged — it always rested on the
* MEMBERSHIP of that set, and it now inherits a set the compiler keeps whole.
*
* [#13216] `pages` is the measurement that made the case: adding it touched
* FIVE spellings of this one set and only the fifth announced itself — the one
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/tidy-eels-tickle.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
'@objectstack/lint': patch
---

Derive the runtime publish gate's context-collection set from `RuntimeStackContext` instead of hand-listing it.

`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.

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.
104 changes: 104 additions & 0 deletions packages/lint/src/runtime-gate.derived-context-keys.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13977] `CONTEXT_STACK_KEYS` is derived from `RuntimeStackContext`, and the
* ORDER it derives is load-bearing. This file pins the half a type cannot.
*
* ## The split, stated once
*
* COMPLETENESS — every context collection has an entry — is held by the
* compiler and belongs there: the keyed record the set derives from is typed
* `{ [K in keyof RuntimeStackContext]-?: true }`, so a collection added to the
* interface without a row is a type error naming that collection, in
* `runtime-gate.ts`, at `tsc --noEmit` and at the DTS build. ⛔ It is
* deliberately NOT restated here as a runtime assertion: this package's
* `tsconfig.json` excludes `**\/*.test.ts`, so no tsc program compiles this
* file and a type-level witness written here would evaluate never — a phantom
* check that deletes clean. The guard's own failure was measured instead, on
* the card, by adding a collection and reading the build.
*
* ORDER is what a test can hold, and the derivation had to be chosen so as not
* to break it — a mapped type does not guarantee declaration order. The order
* reaches two consumers, one of which is measured here and one of which
* `runtime-gate.derived-name-keys.test.ts` already covers:
*
* - the snapshot's own key order (`Object.keys(baseline)`), which that file
* reads back as a VALUE and asserts through an ordered `toEqual`;
* - `TOP_LEVEL_INDEX`'s alternation, whose `source` #13390 keeps byte-identical
* to the literal it replaced.
*
* ## Why the pre-existing ordered pin is not enough
*
* It asserts the order of the NAME-KEYED subset, which drops `datasets` (no
* write type maps into it). So swapping `datasets` with either neighbour left
* it green while genuinely reordering the set the snapshot is built from. The
* first test below closes exactly that gap, and is the reason ordering could be
* reported as load-bearing rather than assumed either way.
*/

import { describe, expect, it } from 'vitest';

import { buildRuntimeWriteSnapshots } from './runtime-gate.js';

/**
* The set as the GATE carries it, never as a constant re-imported or restated:
* `buildRuntimeWriteSnapshots` gives the baseline one key per context
* collection, in the order the derivation yields.
*/
const carriedStackKeys = () =>
Object.keys(buildRuntimeWriteSnapshots({ type: 'object', item: { name: 'probe_object' } })!.baseline);

describe('the derived context-collection set (#13977)', () => {
it('carries every context collection, in the declared stack-key order', () => {
// The whole set, in order — including `datasets`, which the name-keyed pin
// filters out and therefore cannot hold in position. A reordering of the
// record `CONTEXT_STACK_KEYS` derives from lands here first.
expect(carriedStackKeys()).toEqual(['objects', 'permissions', 'books', 'datasets', 'pages']);
});

it('derives the same set whatever the write is, since the write does not choose it', () => {
// A write into a context collection replaces its stored self and a write
// outside them adds its own collection — neither may change WHICH context
// collections are carried, or the gate would judge different universes for
// different write types.
const forNonContextWrite = Object.keys(
buildRuntimeWriteSnapshots({ type: 'flow', item: { name: 'probe_flow' } })!.baseline,
);

expect(forNonContextWrite).toEqual(carriedStackKeys());
});

it('carries a collection the host never passed, rather than omitting the key', () => {
// Completeness has a runtime half after all: the loop writes every derived
// key unconditionally, so an absent context yields empty collections and a
// rule resolving into one reads "empty universe" from a key that EXISTS.
// (`runtime-gate.test.ts` pins the same shape against the whole set; this
// states why the loop may not skip a missing collection.)
const baseline = buildRuntimeWriteSnapshots({ type: 'book', item: { name: 'b1' } })!.baseline;

for (const key of carriedStackKeys()) {
expect(baseline).toHaveProperty(key);
expect(baseline[key]).toEqual([]);
}
});

it('every carried collection is one the interface declares — validity, still', () => {
// The half the old `satisfies` clause DID hold, kept measurable now that the
// clause is gone: the derived keys are the record's keys, and the record is
// pinned to `keyof RuntimeStackContext`. Asserted against the context the
// gate accepts, so an entry naming a collection the host cannot pass fails.
const context = {
objects: [{ name: 'acme_thing' }],
permissions: [{ name: 'sales' }],
books: [{ name: 'handbook' }],
datasets: [{ name: 'revenue' }],
pages: [{ name: 'home' }],
};
const baseline = buildRuntimeWriteSnapshots({ type: 'flow', item: { name: 'f1' }, context })!.baseline;

for (const key of carriedStackKeys()) {
expect(context).toHaveProperty(key);
expect(baseline[key]).toEqual(context[key as keyof typeof context]);
}
});
});
120 changes: 112 additions & 8 deletions packages/lint/src/runtime-gate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,6 +122,13 @@ const TYPE_TO_STACK_KEY: Readonly<Record<string, string>> = {
* `positions` / `apps` — so those are NOT carried. Widening the snapshot is a
* one-key edit here plus a `CONTEXT_STACK_KEYS` entry, made when a rule that
* reads the collection actually crosses the wall, never in advance.
*
* [#13977] "Derived from this shape" is now the mechanism and not only the
* intent: the second half of that edit is DEMANDED by the compiler rather than
* remembered. Add a key here and this package stops building until the
* collection has its row below — see {@link CONTEXT_STACK_KEYS} for what the
* old `satisfies` clause could not ask, and what silently happened when the
* row was forgotten.
*/
export interface RuntimeStackContext {
/**
Expand DownExpand Up@@ -285,13 +292,105 @@ const PACKAGE_PROVENANCE_KEY = '_packageId';
*/
const OVERLAY_PROVENANCE_SENTINEL = 'sys_metadata';

/**
* The context collections the snapshot carries, in stack-key order — DERIVED
* from {@link RuntimeStackContext}, not listed (#13977).
*
* ## What the spelling this replaces could not ask
*
* It was a hand-written literal carrying `as const satisfies readonly (keyof
* RuntimeStackContext)[]`. That clause asks that every entry it NAMES is a real
* context key — validity. It does not ask that every context key HAS an entry —
* completeness, which the docblock on {@link RuntimeStackContext} nevertheless
* claimed ("derived from this shape and keeps the two from drifting"). Declared,
* not enforced.
*
* The cost was not a missing member, it was a WRONG VERDICT.
* {@link buildRuntimeWriteSnapshots} fills the snapshot by iterating this set, so
* a collection declared on the interface and absent here is never carried: the
* host passes it in, the gate drops it, and every rule resolving references into
* that collection judges a universe that is empty — findings that look correct
* against something that is not there (the `shyx_customer_ds` shape). Measured
* before this card: adding `widgets?: readonly unknown[]` to the interface and
* rebuilding left `pnpm --filter @objectstack/lint build` at **exit 0** with
* nothing in this package red. The only red was second-order and one package
* over — `protocol.ts`'s `-?` accumulator in `@objectstack/metadata-protocol`,
* about a different constant, naming this one nowhere. The same asymmetry #13390
* removed from `NAME_KEYED_STACK_KEYS` and #13768 from
* `CLOSURE_CONTEXT_KEY_BY_TYPE`.
*
* ## Why a keyed record, and why that is a derivation rather than an assertion
*
* A type's keys cannot be materialised as values, so the derivation needs
* exactly one runtime spelling to derive FROM, and the job is to make that
* spelling impossible to leave incomplete. {@link CONTEXT_STACK_KEY_ORDER} is
* that spelling and the mapped type pins it in BOTH directions: `-?` over `keyof
* RuntimeStackContext` demands a row per collection (a missing one is a type
* error naming the collection, in this file, at `tsc --noEmit` and at the DTS
* build), and the object-literal excess-property check refuses a row for a
* collection the interface no longer has. The array is then computed, so it
* cannot disagree with the record.
*
* That is `protocol.ts`'s `-?` accumulator, the mechanism this repo already
* proves. #13768 had to settle for a completeness ASSERTION beside its constant
* only because the type lived one package away and the set was not readable
* there as a value; here both inputs are in this file, so the stronger shape is
* available and is what ships.
*
* ## Order is load-bearing, so the derivation preserves it (measured, #13977)
*
* The order this encodes reaches two places, and it was worth measuring before
* choosing a spelling — a mapped type does not guarantee declaration order:
*
* - **The snapshot's own key order.** The loop in
* {@link buildRuntimeWriteSnapshots} inserts in this order, so it is what
* `Object.keys(baseline)` yields — read as a VALUE by
* `runtime-gate.derived-name-keys.test.ts`, which feeds it to
* {@link deriveNameKeyedStackKeys} and asserts an ORDERED result.
* - **The derived alternation.** {@link deriveNameKeyedStackKeys} filters in
* context order by contract ("Order follows `contextStackKeys`, deliberately"),
* and {@link buildTopLevelIndexPattern} interpolates that order into
* {@link TOP_LEVEL_INDEX}. Reordering does not change what the pattern MATCHES
* — the `\[` anchor defeats prefix shadowing, pinned in both orders — but it
* does change the pattern's `source`, which #13390 keeps byte-identical to the
* literal it replaced on purpose.
*
* So the requirement is: preserve the author's order. This spelling does, and
* that is the reason it is a keyed record rather than any union-to-tuple trick:
* `Object.keys` returns own enumerable string keys in declaration order
* (ECMAScript `OrdinaryOwnPropertyKeys`), so the order below IS the stack-key
* order, chosen here and readable here. Integer-like keys would sort ahead of
* insertion order, which is why that rule is stated rather than assumed — a
* stack key is an interface property name and never one of those.
*
* Ordering is pinned by `runtime-gate.derived-context-keys.test.ts` end to end,
* because the pre-existing ordered pin could not see all of it: it filters
* `datasets` out (no write type maps into it), so swapping `datasets` with a
* neighbour left that assertion green.
*/
const CONTEXT_STACK_KEY_ORDER = {
objects: true,
permissions: true,
books: true,
datasets: true,
pages: true,
} as const satisfies { [K in keyof RuntimeStackContext]-?: true };

/**
* The context collections the snapshot carries, in stack-key order. Derived
* facts: every entry is a key of {@link RuntimeStackContext} AND a stack key
* some runtime-wired rule reads (`runtime-gate.test.ts` pins membership).
* some runtime-wired rule reads (`runtime-gate.test.ts` pins membership);
* every key of {@link RuntimeStackContext} has an entry (#13977 — the record
* above, held by the compiler).
*
* `Object.keys` types as `string[]`, so the read-back is asserted. It is the one
* assertion in the derivation and it is sound by construction: the record is
* compiler-pinned to exactly `keyof RuntimeStackContext`, and `Object.keys`
* returns exactly that record's own enumerable string keys. ⛔ Do not widen this
* back into a literal — the list would stop being derived and the interface
* would stop being the single source it says it is.
*/
const CONTEXT_STACK_KEYS = ['objects', 'permissions', 'books', 'datasets', 'pages'] as const satisfies
readonly (keyof RuntimeStackContext)[];
const CONTEXT_STACK_KEYS = Object.keys(CONTEXT_STACK_KEY_ORDER) as readonly (keyof RuntimeStackContext)[];

/** One rule's verdict at the runtime surface, carrying which rule produced it. */
export interface RuntimeGateResult {
Expand DownExpand Up@@ -501,11 +600,16 @@ export const WRITTEN_STACK_KEYS: ReadonlySet<string> = new Set(Object.values(TYP
* both are already written down: the context fills the collection
* ({@link CONTEXT_STACK_KEYS}) and some write type maps into it
* ({@link TYPE_TO_STACK_KEY}). Kept as a literal it was the one spelling of that
* set with NO guard — `CONTEXT_STACK_KEYS` carries a `satisfies` clause, which
* is validity, not completeness, and the compiler holds nothing else. Omitting a
* member here did not fail to build, fail a test, or fail a gate; it emitted
* findings that LOOK correct whose `path` the caller cannot resolve, which is
* the #10064 defect re-created silently.
* set with NO guard — at the time, `CONTEXT_STACK_KEYS` carried a `satisfies`
* clause, which is validity, not completeness, and the compiler held nothing
* else. Omitting a member here did not fail to build, fail a test, or fail a
* gate; it emitted findings that LOOK correct whose `path` the caller cannot
* resolve, which is the #10064 defect re-created silently.
*
* [#13977] That reading of `CONTEXT_STACK_KEYS` is now history rather than
* description: it is derived from `RuntimeStackContext` and complete by
* construction. This derivation is unchanged — it always rested on the
* MEMBERSHIP of that set, and it now inherits a set the compiler keeps whole.
*
* [#13216] `pages` is the measurement that made the case: adding it touched
* FIVE spellings of this one set and only the fifth announced itself — the one
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/tidy-eels-tickle.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
'@objectstack/lint': patch
---

Derive the runtime publish gate's context-collection set from `RuntimeStackContext` instead of hand-listing it.

`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.

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.
104 changes: 104 additions & 0 deletions packages/lint/src/runtime-gate.derived-context-keys.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13977] `CONTEXT_STACK_KEYS` is derived from `RuntimeStackContext`, and the
* ORDER it derives is load-bearing. This file pins the half a type cannot.
*
* ## The split, stated once
*
* COMPLETENESS — every context collection has an entry — is held by the
* compiler and belongs there: the keyed record the set derives from is typed
* `{ [K in keyof RuntimeStackContext]-?: true }`, so a collection added to the
* interface without a row is a type error naming that collection, in
* `runtime-gate.ts`, at `tsc --noEmit` and at the DTS build. ⛔ It is
* deliberately NOT restated here as a runtime assertion: this package's
* `tsconfig.json` excludes `**\/*.test.ts`, so no tsc program compiles this
* file and a type-level witness written here would evaluate never — a phantom
* check that deletes clean. The guard's own failure was measured instead, on
* the card, by adding a collection and reading the build.
*
* ORDER is what a test can hold, and the derivation had to be chosen so as not
* to break it — a mapped type does not guarantee declaration order. The order
* reaches two consumers, one of which is measured here and one of which
* `runtime-gate.derived-name-keys.test.ts` already covers:
*
* - the snapshot's own key order (`Object.keys(baseline)`), which that file
* reads back as a VALUE and asserts through an ordered `toEqual`;
* - `TOP_LEVEL_INDEX`'s alternation, whose `source` #13390 keeps byte-identical
* to the literal it replaced.
*
* ## Why the pre-existing ordered pin is not enough
*
* It asserts the order of the NAME-KEYED subset, which drops `datasets` (no
* write type maps into it). So swapping `datasets` with either neighbour left
* it green while genuinely reordering the set the snapshot is built from. The
* first test below closes exactly that gap, and is the reason ordering could be
* reported as load-bearing rather than assumed either way.
*/

import { describe, expect, it } from 'vitest';

import { buildRuntimeWriteSnapshots } from './runtime-gate.js';

/**
* The set as the GATE carries it, never as a constant re-imported or restated:
* `buildRuntimeWriteSnapshots` gives the baseline one key per context
* collection, in the order the derivation yields.
*/
const carriedStackKeys = () =>
Object.keys(buildRuntimeWriteSnapshots({ type: 'object', item: { name: 'probe_object' } })!.baseline);

describe('the derived context-collection set (#13977)', () => {
it('carries every context collection, in the declared stack-key order', () => {
// The whole set, in order — including `datasets`, which the name-keyed pin
// filters out and therefore cannot hold in position. A reordering of the
// record `CONTEXT_STACK_KEYS` derives from lands here first.
expect(carriedStackKeys()).toEqual(['objects', 'permissions', 'books', 'datasets', 'pages']);
});

it('derives the same set whatever the write is, since the write does not choose it', () => {
// A write into a context collection replaces its stored self and a write
// outside them adds its own collection — neither may change WHICH context
// collections are carried, or the gate would judge different universes for
// different write types.
const forNonContextWrite = Object.keys(
buildRuntimeWriteSnapshots({ type: 'flow', item: { name: 'probe_flow' } })!.baseline,
);

expect(forNonContextWrite).toEqual(carriedStackKeys());
});

it('carries a collection the host never passed, rather than omitting the key', () => {
// Completeness has a runtime half after all: the loop writes every derived
// key unconditionally, so an absent context yields empty collections and a
// rule resolving into one reads "empty universe" from a key that EXISTS.
// (`runtime-gate.test.ts` pins the same shape against the whole set; this
// states why the loop may not skip a missing collection.)
const baseline = buildRuntimeWriteSnapshots({ type: 'book', item: { name: 'b1' } })!.baseline;

for (const key of carriedStackKeys()) {
expect(baseline).toHaveProperty(key);
expect(baseline[key]).toEqual([]);
}
});

it('every carried collection is one the interface declares — validity, still', () => {
// The half the old `satisfies` clause DID hold, kept measurable now that the
// clause is gone: the derived keys are the record's keys, and the record is
// pinned to `keyof RuntimeStackContext`. Asserted against the context the
// gate accepts, so an entry naming a collection the host cannot pass fails.
const context = {
objects: [{ name: 'acme_thing' }],
permissions: [{ name: 'sales' }],
books: [{ name: 'handbook' }],
datasets: [{ name: 'revenue' }],
pages: [{ name: 'home' }],
};
const baseline = buildRuntimeWriteSnapshots({ type: 'flow', item: { name: 'f1' }, context })!.baseline;

for (const key of carriedStackKeys()) {
expect(context).toHaveProperty(key);
expect(baseline[key]).toEqual(context[key as keyof typeof context]);
}
});
});
120 changes: 112 additions & 8 deletions packages/lint/src/runtime-gate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,6 +122,13 @@ const TYPE_TO_STACK_KEY: Readonly<Record<string, string>> = {
* `positions` / `apps` — so those are NOT carried. Widening the snapshot is a
* one-key edit here plus a `CONTEXT_STACK_KEYS` entry, made when a rule that
* reads the collection actually crosses the wall, never in advance.
*
* [#13977] "Derived from this shape" is now the mechanism and not only the
* intent: the second half of that edit is DEMANDED by the compiler rather than
* remembered. Add a key here and this package stops building until the
* collection has its row below — see {@link CONTEXT_STACK_KEYS} for what the
* old `satisfies` clause could not ask, and what silently happened when the
* row was forgotten.
*/
export interface RuntimeStackContext {
/**
Expand DownExpand Up@@ -285,13 +292,105 @@ const PACKAGE_PROVENANCE_KEY = '_packageId';
*/
const OVERLAY_PROVENANCE_SENTINEL = 'sys_metadata';

/**
* The context collections the snapshot carries, in stack-key order — DERIVED
* from {@link RuntimeStackContext}, not listed (#13977).
*
* ## What the spelling this replaces could not ask
*
* It was a hand-written literal carrying `as const satisfies readonly (keyof
* RuntimeStackContext)[]`. That clause asks that every entry it NAMES is a real
* context key — validity. It does not ask that every context key HAS an entry —
* completeness, which the docblock on {@link RuntimeStackContext} nevertheless
* claimed ("derived from this shape and keeps the two from drifting"). Declared,
* not enforced.
*
* The cost was not a missing member, it was a WRONG VERDICT.
* {@link buildRuntimeWriteSnapshots} fills the snapshot by iterating this set, so
* a collection declared on the interface and absent here is never carried: the
* host passes it in, the gate drops it, and every rule resolving references into
* that collection judges a universe that is empty — findings that look correct
* against something that is not there (the `shyx_customer_ds` shape). Measured
* before this card: adding `widgets?: readonly unknown[]` to the interface and
* rebuilding left `pnpm --filter @objectstack/lint build` at **exit 0** with
* nothing in this package red. The only red was second-order and one package
* over — `protocol.ts`'s `-?` accumulator in `@objectstack/metadata-protocol`,
* about a different constant, naming this one nowhere. The same asymmetry #13390
* removed from `NAME_KEYED_STACK_KEYS` and #13768 from
* `CLOSURE_CONTEXT_KEY_BY_TYPE`.
*
* ## Why a keyed record, and why that is a derivation rather than an assertion
*
* A type's keys cannot be materialised as values, so the derivation needs
* exactly one runtime spelling to derive FROM, and the job is to make that
* spelling impossible to leave incomplete. {@link CONTEXT_STACK_KEY_ORDER} is
* that spelling and the mapped type pins it in BOTH directions: `-?` over `keyof
* RuntimeStackContext` demands a row per collection (a missing one is a type
* error naming the collection, in this file, at `tsc --noEmit` and at the DTS
* build), and the object-literal excess-property check refuses a row for a
* collection the interface no longer has. The array is then computed, so it
* cannot disagree with the record.
*
* That is `protocol.ts`'s `-?` accumulator, the mechanism this repo already
* proves. #13768 had to settle for a completeness ASSERTION beside its constant
* only because the type lived one package away and the set was not readable
* there as a value; here both inputs are in this file, so the stronger shape is
* available and is what ships.
*
* ## Order is load-bearing, so the derivation preserves it (measured, #13977)
*
* The order this encodes reaches two places, and it was worth measuring before
* choosing a spelling — a mapped type does not guarantee declaration order:
*
* - **The snapshot's own key order.** The loop in
* {@link buildRuntimeWriteSnapshots} inserts in this order, so it is what
* `Object.keys(baseline)` yields — read as a VALUE by
* `runtime-gate.derived-name-keys.test.ts`, which feeds it to
* {@link deriveNameKeyedStackKeys} and asserts an ORDERED result.
* - **The derived alternation.** {@link deriveNameKeyedStackKeys} filters in
* context order by contract ("Order follows `contextStackKeys`, deliberately"),
* and {@link buildTopLevelIndexPattern} interpolates that order into
* {@link TOP_LEVEL_INDEX}. Reordering does not change what the pattern MATCHES
* — the `\[` anchor defeats prefix shadowing, pinned in both orders — but it
* does change the pattern's `source`, which #13390 keeps byte-identical to the
* literal it replaced on purpose.
*
* So the requirement is: preserve the author's order. This spelling does, and
* that is the reason it is a keyed record rather than any union-to-tuple trick:
* `Object.keys` returns own enumerable string keys in declaration order
* (ECMAScript `OrdinaryOwnPropertyKeys`), so the order below IS the stack-key
* order, chosen here and readable here. Integer-like keys would sort ahead of
* insertion order, which is why that rule is stated rather than assumed — a
* stack key is an interface property name and never one of those.
*
* Ordering is pinned by `runtime-gate.derived-context-keys.test.ts` end to end,
* because the pre-existing ordered pin could not see all of it: it filters
* `datasets` out (no write type maps into it), so swapping `datasets` with a
* neighbour left that assertion green.
*/
const CONTEXT_STACK_KEY_ORDER = {
objects: true,
permissions: true,
books: true,
datasets: true,
pages: true,
} as const satisfies { [K in keyof RuntimeStackContext]-?: true };

/**
* The context collections the snapshot carries, in stack-key order. Derived
* facts: every entry is a key of {@link RuntimeStackContext} AND a stack key
* some runtime-wired rule reads (`runtime-gate.test.ts` pins membership).
* some runtime-wired rule reads (`runtime-gate.test.ts` pins membership);
* every key of {@link RuntimeStackContext} has an entry (#13977 — the record
* above, held by the compiler).
*
* `Object.keys` types as `string[]`, so the read-back is asserted. It is the one
* assertion in the derivation and it is sound by construction: the record is
* compiler-pinned to exactly `keyof RuntimeStackContext`, and `Object.keys`
* returns exactly that record's own enumerable string keys. ⛔ Do not widen this
* back into a literal — the list would stop being derived and the interface
* would stop being the single source it says it is.
*/
const CONTEXT_STACK_KEYS = ['objects', 'permissions', 'books', 'datasets', 'pages'] as const satisfies
readonly (keyof RuntimeStackContext)[];
const CONTEXT_STACK_KEYS = Object.keys(CONTEXT_STACK_KEY_ORDER) as readonly (keyof RuntimeStackContext)[];

/** One rule's verdict at the runtime surface, carrying which rule produced it. */
export interface RuntimeGateResult {
Expand DownExpand Up@@ -501,11 +600,16 @@ export const WRITTEN_STACK_KEYS: ReadonlySet<string> = new Set(Object.values(TYP
* both are already written down: the context fills the collection
* ({@link CONTEXT_STACK_KEYS}) and some write type maps into it
* ({@link TYPE_TO_STACK_KEY}). Kept as a literal it was the one spelling of that
* set with NO guard — `CONTEXT_STACK_KEYS` carries a `satisfies` clause, which
* is validity, not completeness, and the compiler holds nothing else. Omitting a
* member here did not fail to build, fail a test, or fail a gate; it emitted
* findings that LOOK correct whose `path` the caller cannot resolve, which is
* the #10064 defect re-created silently.
* set with NO guard — at the time, `CONTEXT_STACK_KEYS` carried a `satisfies`
* clause, which is validity, not completeness, and the compiler held nothing
* else. Omitting a member here did not fail to build, fail a test, or fail a
* gate; it emitted findings that LOOK correct whose `path` the caller cannot
* resolve, which is the #10064 defect re-created silently.
*
* [#13977] That reading of `CONTEXT_STACK_KEYS` is now history rather than
* description: it is derived from `RuntimeStackContext` and complete by
* construction. This derivation is unchanged — it always rested on the
* MEMBERSHIP of that set, and it now inherits a set the compiler keeps whole.
*
* [#13216] `pages` is the measurement that made the case: adding it touched
* FIVE spellings of this one set and only the fifth announced itself — the one
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/tidy-eels-tickle.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
'@objectstack/lint': patch
---

Derive the runtime publish gate's context-collection set from `RuntimeStackContext` instead of hand-listing it.

`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.

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.
104 changes: 104 additions & 0 deletions packages/lint/src/runtime-gate.derived-context-keys.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13977] `CONTEXT_STACK_KEYS` is derived from `RuntimeStackContext`, and the
* ORDER it derives is load-bearing. This file pins the half a type cannot.
*
* ## The split, stated once
*
* COMPLETENESS — every context collection has an entry — is held by the
* compiler and belongs there: the keyed record the set derives from is typed
* `{ [K in keyof RuntimeStackContext]-?: true }`, so a collection added to the
* interface without a row is a type error naming that collection, in
* `runtime-gate.ts`, at `tsc --noEmit` and at the DTS build. ⛔ It is
* deliberately NOT restated here as a runtime assertion: this package's
* `tsconfig.json` excludes `**\/*.test.ts`, so no tsc program compiles this
* file and a type-level witness written here would evaluate never — a phantom
* check that deletes clean. The guard's own failure was measured instead, on
* the card, by adding a collection and reading the build.
*
* ORDER is what a test can hold, and the derivation had to be chosen so as not
* to break it — a mapped type does not guarantee declaration order. The order
* reaches two consumers, one of which is measured here and one of which
* `runtime-gate.derived-name-keys.test.ts` already covers:
*
* - the snapshot's own key order (`Object.keys(baseline)`), which that file
* reads back as a VALUE and asserts through an ordered `toEqual`;
* - `TOP_LEVEL_INDEX`'s alternation, whose `source` #13390 keeps byte-identical
* to the literal it replaced.
*
* ## Why the pre-existing ordered pin is not enough
*
* It asserts the order of the NAME-KEYED subset, which drops `datasets` (no
* write type maps into it). So swapping `datasets` with either neighbour left
* it green while genuinely reordering the set the snapshot is built from. The
* first test below closes exactly that gap, and is the reason ordering could be
* reported as load-bearing rather than assumed either way.
*/

import { describe, expect, it } from 'vitest';

import { buildRuntimeWriteSnapshots } from './runtime-gate.js';

/**
* The set as the GATE carries it, never as a constant re-imported or restated:
* `buildRuntimeWriteSnapshots` gives the baseline one key per context
* collection, in the order the derivation yields.
*/
const carriedStackKeys = () =>
Object.keys(buildRuntimeWriteSnapshots({ type: 'object', item: { name: 'probe_object' } })!.baseline);

describe('the derived context-collection set (#13977)', () => {
it('carries every context collection, in the declared stack-key order', () => {
// The whole set, in order — including `datasets`, which the name-keyed pin
// filters out and therefore cannot hold in position. A reordering of the
// record `CONTEXT_STACK_KEYS` derives from lands here first.
expect(carriedStackKeys()).toEqual(['objects', 'permissions', 'books', 'datasets', 'pages']);
});

it('derives the same set whatever the write is, since the write does not choose it', () => {
// A write into a context collection replaces its stored self and a write
// outside them adds its own collection — neither may change WHICH context
// collections are carried, or the gate would judge different universes for
// different write types.
const forNonContextWrite = Object.keys(
buildRuntimeWriteSnapshots({ type: 'flow', item: { name: 'probe_flow' } })!.baseline,
);

expect(forNonContextWrite).toEqual(carriedStackKeys());
});

it('carries a collection the host never passed, rather than omitting the key', () => {
// Completeness has a runtime half after all: the loop writes every derived
// key unconditionally, so an absent context yields empty collections and a
// rule resolving into one reads "empty universe" from a key that EXISTS.
// (`runtime-gate.test.ts` pins the same shape against the whole set; this
// states why the loop may not skip a missing collection.)
const baseline = buildRuntimeWriteSnapshots({ type: 'book', item: { name: 'b1' } })!.baseline;

for (const key of carriedStackKeys()) {
expect(baseline).toHaveProperty(key);
expect(baseline[key]).toEqual([]);
}
});

it('every carried collection is one the interface declares — validity, still', () => {
// The half the old `satisfies` clause DID hold, kept measurable now that the
// clause is gone: the derived keys are the record's keys, and the record is
// pinned to `keyof RuntimeStackContext`. Asserted against the context the
// gate accepts, so an entry naming a collection the host cannot pass fails.
const context = {
objects: [{ name: 'acme_thing' }],
permissions: [{ name: 'sales' }],
books: [{ name: 'handbook' }],
datasets: [{ name: 'revenue' }],
pages: [{ name: 'home' }],
};
const baseline = buildRuntimeWriteSnapshots({ type: 'flow', item: { name: 'f1' }, context })!.baseline;

for (const key of carriedStackKeys()) {
expect(context).toHaveProperty(key);
expect(baseline[key]).toEqual(context[key as keyof typeof context]);
}
});
});
120 changes: 112 additions & 8 deletions packages/lint/src/runtime-gate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,6 +122,13 @@ const TYPE_TO_STACK_KEY: Readonly<Record<string, string>> = {
* `positions` / `apps` — so those are NOT carried. Widening the snapshot is a
* one-key edit here plus a `CONTEXT_STACK_KEYS` entry, made when a rule that
* reads the collection actually crosses the wall, never in advance.
*
* [#13977] "Derived from this shape" is now the mechanism and not only the
* intent: the second half of that edit is DEMANDED by the compiler rather than
* remembered. Add a key here and this package stops building until the
* collection has its row below — see {@link CONTEXT_STACK_KEYS} for what the
* old `satisfies` clause could not ask, and what silently happened when the
* row was forgotten.
*/
export interface RuntimeStackContext {
/**
Expand DownExpand Up@@ -285,13 +292,105 @@ const PACKAGE_PROVENANCE_KEY = '_packageId';
*/
const OVERLAY_PROVENANCE_SENTINEL = 'sys_metadata';

/**
* The context collections the snapshot carries, in stack-key order — DERIVED
* from {@link RuntimeStackContext}, not listed (#13977).
*
* ## What the spelling this replaces could not ask
*
* It was a hand-written literal carrying `as const satisfies readonly (keyof
* RuntimeStackContext)[]`. That clause asks that every entry it NAMES is a real
* context key — validity. It does not ask that every context key HAS an entry —
* completeness, which the docblock on {@link RuntimeStackContext} nevertheless
* claimed ("derived from this shape and keeps the two from drifting"). Declared,
* not enforced.
*
* The cost was not a missing member, it was a WRONG VERDICT.
* {@link buildRuntimeWriteSnapshots} fills the snapshot by iterating this set, so
* a collection declared on the interface and absent here is never carried: the
* host passes it in, the gate drops it, and every rule resolving references into
* that collection judges a universe that is empty — findings that look correct
* against something that is not there (the `shyx_customer_ds` shape). Measured
* before this card: adding `widgets?: readonly unknown[]` to the interface and
* rebuilding left `pnpm --filter @objectstack/lint build` at **exit 0** with
* nothing in this package red. The only red was second-order and one package
* over — `protocol.ts`'s `-?` accumulator in `@objectstack/metadata-protocol`,
* about a different constant, naming this one nowhere. The same asymmetry #13390
* removed from `NAME_KEYED_STACK_KEYS` and #13768 from
* `CLOSURE_CONTEXT_KEY_BY_TYPE`.
*
* ## Why a keyed record, and why that is a derivation rather than an assertion
*
* A type's keys cannot be materialised as values, so the derivation needs
* exactly one runtime spelling to derive FROM, and the job is to make that
* spelling impossible to leave incomplete. {@link CONTEXT_STACK_KEY_ORDER} is
* that spelling and the mapped type pins it in BOTH directions: `-?` over `keyof
* RuntimeStackContext` demands a row per collection (a missing one is a type
* error naming the collection, in this file, at `tsc --noEmit` and at the DTS
* build), and the object-literal excess-property check refuses a row for a
* collection the interface no longer has. The array is then computed, so it
* cannot disagree with the record.
*
* That is `protocol.ts`'s `-?` accumulator, the mechanism this repo already
* proves. #13768 had to settle for a completeness ASSERTION beside its constant
* only because the type lived one package away and the set was not readable
* there as a value; here both inputs are in this file, so the stronger shape is
* available and is what ships.
*
* ## Order is load-bearing, so the derivation preserves it (measured, #13977)
*
* The order this encodes reaches two places, and it was worth measuring before
* choosing a spelling — a mapped type does not guarantee declaration order:
*
* - **The snapshot's own key order.** The loop in
* {@link buildRuntimeWriteSnapshots} inserts in this order, so it is what
* `Object.keys(baseline)` yields — read as a VALUE by
* `runtime-gate.derived-name-keys.test.ts`, which feeds it to
* {@link deriveNameKeyedStackKeys} and asserts an ORDERED result.
* - **The derived alternation.** {@link deriveNameKeyedStackKeys} filters in
* context order by contract ("Order follows `contextStackKeys`, deliberately"),
* and {@link buildTopLevelIndexPattern} interpolates that order into
* {@link TOP_LEVEL_INDEX}. Reordering does not change what the pattern MATCHES
* — the `\[` anchor defeats prefix shadowing, pinned in both orders — but it
* does change the pattern's `source`, which #13390 keeps byte-identical to the
* literal it replaced on purpose.
*
* So the requirement is: preserve the author's order. This spelling does, and
* that is the reason it is a keyed record rather than any union-to-tuple trick:
* `Object.keys` returns own enumerable string keys in declaration order
* (ECMAScript `OrdinaryOwnPropertyKeys`), so the order below IS the stack-key
* order, chosen here and readable here. Integer-like keys would sort ahead of
* insertion order, which is why that rule is stated rather than assumed — a
* stack key is an interface property name and never one of those.
*
* Ordering is pinned by `runtime-gate.derived-context-keys.test.ts` end to end,
* because the pre-existing ordered pin could not see all of it: it filters
* `datasets` out (no write type maps into it), so swapping `datasets` with a
* neighbour left that assertion green.
*/
const CONTEXT_STACK_KEY_ORDER = {
objects: true,
permissions: true,
books: true,
datasets: true,
pages: true,
} as const satisfies { [K in keyof RuntimeStackContext]-?: true };

/**
* The context collections the snapshot carries, in stack-key order. Derived
* facts: every entry is a key of {@link RuntimeStackContext} AND a stack key
* some runtime-wired rule reads (`runtime-gate.test.ts` pins membership).
* some runtime-wired rule reads (`runtime-gate.test.ts` pins membership);
* every key of {@link RuntimeStackContext} has an entry (#13977 — the record
* above, held by the compiler).
*
* `Object.keys` types as `string[]`, so the read-back is asserted. It is the one
* assertion in the derivation and it is sound by construction: the record is
* compiler-pinned to exactly `keyof RuntimeStackContext`, and `Object.keys`
* returns exactly that record's own enumerable string keys. ⛔ Do not widen this
* back into a literal — the list would stop being derived and the interface
* would stop being the single source it says it is.
*/
const CONTEXT_STACK_KEYS = ['objects', 'permissions', 'books', 'datasets', 'pages'] as const satisfies
readonly (keyof RuntimeStackContext)[];
const CONTEXT_STACK_KEYS = Object.keys(CONTEXT_STACK_KEY_ORDER) as readonly (keyof RuntimeStackContext)[];

/** One rule's verdict at the runtime surface, carrying which rule produced it. */
export interface RuntimeGateResult {
Expand DownExpand Up@@ -501,11 +600,16 @@ export const WRITTEN_STACK_KEYS: ReadonlySet<string> = new Set(Object.values(TYP
* both are already written down: the context fills the collection
* ({@link CONTEXT_STACK_KEYS}) and some write type maps into it
* ({@link TYPE_TO_STACK_KEY}). Kept as a literal it was the one spelling of that
* set with NO guard — `CONTEXT_STACK_KEYS` carries a `satisfies` clause, which
* is validity, not completeness, and the compiler holds nothing else. Omitting a
* member here did not fail to build, fail a test, or fail a gate; it emitted
* findings that LOOK correct whose `path` the caller cannot resolve, which is
* the #10064 defect re-created silently.
* set with NO guard — at the time, `CONTEXT_STACK_KEYS` carried a `satisfies`
* clause, which is validity, not completeness, and the compiler held nothing
* else. Omitting a member here did not fail to build, fail a test, or fail a
* gate; it emitted findings that LOOK correct whose `path` the caller cannot
* resolve, which is the #10064 defect re-created silently.
*
* [#13977] That reading of `CONTEXT_STACK_KEYS` is now history rather than
* description: it is derived from `RuntimeStackContext` and complete by
* construction. This derivation is unchanged — it always rested on the
* MEMBERSHIP of that set, and it now inherits a set the compiler keeps whole.
*
* [#13216] `pages` is the measurement that made the case: adding it touched
* FIVE spellings of this one set and only the fifth announced itself — the one
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/tidy-eels-tickle.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
'@objectstack/lint': patch
---

Derive the runtime publish gate's context-collection set from `RuntimeStackContext` instead of hand-listing it.

`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.

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.
104 changes: 104 additions & 0 deletions packages/lint/src/runtime-gate.derived-context-keys.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13977] `CONTEXT_STACK_KEYS` is derived from `RuntimeStackContext`, and the
* ORDER it derives is load-bearing. This file pins the half a type cannot.
*
* ## The split, stated once
*
* COMPLETENESS — every context collection has an entry — is held by the
* compiler and belongs there: the keyed record the set derives from is typed
* `{ [K in keyof RuntimeStackContext]-?: true }`, so a collection added to the
* interface without a row is a type error naming that collection, in
* `runtime-gate.ts`, at `tsc --noEmit` and at the DTS build. ⛔ It is
* deliberately NOT restated here as a runtime assertion: this package's
* `tsconfig.json` excludes `**\/*.test.ts`, so no tsc program compiles this
* file and a type-level witness written here would evaluate never — a phantom
* check that deletes clean. The guard's own failure was measured instead, on
* the card, by adding a collection and reading the build.
*
* ORDER is what a test can hold, and the derivation had to be chosen so as not
* to break it — a mapped type does not guarantee declaration order. The order
* reaches two consumers, one of which is measured here and one of which
* `runtime-gate.derived-name-keys.test.ts` already covers:
*
* - the snapshot's own key order (`Object.keys(baseline)`), which that file
* reads back as a VALUE and asserts through an ordered `toEqual`;
* - `TOP_LEVEL_INDEX`'s alternation, whose `source` #13390 keeps byte-identical
* to the literal it replaced.
*
* ## Why the pre-existing ordered pin is not enough
*
* It asserts the order of the NAME-KEYED subset, which drops `datasets` (no
* write type maps into it). So swapping `datasets` with either neighbour left
* it green while genuinely reordering the set the snapshot is built from. The
* first test below closes exactly that gap, and is the reason ordering could be
* reported as load-bearing rather than assumed either way.
*/

import { describe, expect, it } from 'vitest';

import { buildRuntimeWriteSnapshots } from './runtime-gate.js';

/**
* The set as the GATE carries it, never as a constant re-imported or restated:
* `buildRuntimeWriteSnapshots` gives the baseline one key per context
* collection, in the order the derivation yields.
*/
const carriedStackKeys = () =>
Object.keys(buildRuntimeWriteSnapshots({ type: 'object', item: { name: 'probe_object' } })!.baseline);

describe('the derived context-collection set (#13977)', () => {
it('carries every context collection, in the declared stack-key order', () => {
// The whole set, in order — including `datasets`, which the name-keyed pin
// filters out and therefore cannot hold in position. A reordering of the
// record `CONTEXT_STACK_KEYS` derives from lands here first.
expect(carriedStackKeys()).toEqual(['objects', 'permissions', 'books', 'datasets', 'pages']);
});

it('derives the same set whatever the write is, since the write does not choose it', () => {
// A write into a context collection replaces its stored self and a write
// outside them adds its own collection — neither may change WHICH context
// collections are carried, or the gate would judge different universes for
// different write types.
const forNonContextWrite = Object.keys(
buildRuntimeWriteSnapshots({ type: 'flow', item: { name: 'probe_flow' } })!.baseline,
);

expect(forNonContextWrite).toEqual(carriedStackKeys());
});

it('carries a collection the host never passed, rather than omitting the key', () => {
// Completeness has a runtime half after all: the loop writes every derived
// key unconditionally, so an absent context yields empty collections and a
// rule resolving into one reads "empty universe" from a key that EXISTS.
// (`runtime-gate.test.ts` pins the same shape against the whole set; this
// states why the loop may not skip a missing collection.)
const baseline = buildRuntimeWriteSnapshots({ type: 'book', item: { name: 'b1' } })!.baseline;

for (const key of carriedStackKeys()) {
expect(baseline).toHaveProperty(key);
expect(baseline[key]).toEqual([]);
}
});

it('every carried collection is one the interface declares — validity, still', () => {
// The half the old `satisfies` clause DID hold, kept measurable now that the
// clause is gone: the derived keys are the record's keys, and the record is
// pinned to `keyof RuntimeStackContext`. Asserted against the context the
// gate accepts, so an entry naming a collection the host cannot pass fails.
const context = {
objects: [{ name: 'acme_thing' }],
permissions: [{ name: 'sales' }],
books: [{ name: 'handbook' }],
datasets: [{ name: 'revenue' }],
pages: [{ name: 'home' }],
};
const baseline = buildRuntimeWriteSnapshots({ type: 'flow', item: { name: 'f1' }, context })!.baseline;

for (const key of carriedStackKeys()) {
expect(context).toHaveProperty(key);
expect(baseline[key]).toEqual(context[key as keyof typeof context]);
}
});
});
120 changes: 112 additions & 8 deletions packages/lint/src/runtime-gate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,6 +122,13 @@ const TYPE_TO_STACK_KEY: Readonly<Record<string, string>> = {
* `positions` / `apps` — so those are NOT carried. Widening the snapshot is a
* one-key edit here plus a `CONTEXT_STACK_KEYS` entry, made when a rule that
* reads the collection actually crosses the wall, never in advance.
*
* [#13977] "Derived from this shape" is now the mechanism and not only the
* intent: the second half of that edit is DEMANDED by the compiler rather than
* remembered. Add a key here and this package stops building until the
* collection has its row below — see {@link CONTEXT_STACK_KEYS} for what the
* old `satisfies` clause could not ask, and what silently happened when the
* row was forgotten.
*/
export interface RuntimeStackContext {
/**
Expand DownExpand Up@@ -285,13 +292,105 @@ const PACKAGE_PROVENANCE_KEY = '_packageId';
*/
const OVERLAY_PROVENANCE_SENTINEL = 'sys_metadata';

/**
* The context collections the snapshot carries, in stack-key order — DERIVED
* from {@link RuntimeStackContext}, not listed (#13977).
*
* ## What the spelling this replaces could not ask
*
* It was a hand-written literal carrying `as const satisfies readonly (keyof
* RuntimeStackContext)[]`. That clause asks that every entry it NAMES is a real
* context key — validity. It does not ask that every context key HAS an entry —
* completeness, which the docblock on {@link RuntimeStackContext} nevertheless
* claimed ("derived from this shape and keeps the two from drifting"). Declared,
* not enforced.
*
* The cost was not a missing member, it was a WRONG VERDICT.
* {@link buildRuntimeWriteSnapshots} fills the snapshot by iterating this set, so
* a collection declared on the interface and absent here is never carried: the
* host passes it in, the gate drops it, and every rule resolving references into
* that collection judges a universe that is empty — findings that look correct
* against something that is not there (the `shyx_customer_ds` shape). Measured
* before this card: adding `widgets?: readonly unknown[]` to the interface and
* rebuilding left `pnpm --filter @objectstack/lint build` at **exit 0** with
* nothing in this package red. The only red was second-order and one package
* over — `protocol.ts`'s `-?` accumulator in `@objectstack/metadata-protocol`,
* about a different constant, naming this one nowhere. The same asymmetry #13390
* removed from `NAME_KEYED_STACK_KEYS` and #13768 from
* `CLOSURE_CONTEXT_KEY_BY_TYPE`.
*
* ## Why a keyed record, and why that is a derivation rather than an assertion
*
* A type's keys cannot be materialised as values, so the derivation needs
* exactly one runtime spelling to derive FROM, and the job is to make that
* spelling impossible to leave incomplete. {@link CONTEXT_STACK_KEY_ORDER} is
* that spelling and the mapped type pins it in BOTH directions: `-?` over `keyof
* RuntimeStackContext` demands a row per collection (a missing one is a type
* error naming the collection, in this file, at `tsc --noEmit` and at the DTS
* build), and the object-literal excess-property check refuses a row for a
* collection the interface no longer has. The array is then computed, so it
* cannot disagree with the record.
*
* That is `protocol.ts`'s `-?` accumulator, the mechanism this repo already
* proves. #13768 had to settle for a completeness ASSERTION beside its constant
* only because the type lived one package away and the set was not readable
* there as a value; here both inputs are in this file, so the stronger shape is
* available and is what ships.
*
* ## Order is load-bearing, so the derivation preserves it (measured, #13977)
*
* The order this encodes reaches two places, and it was worth measuring before
* choosing a spelling — a mapped type does not guarantee declaration order:
*
* - **The snapshot's own key order.** The loop in
* {@link buildRuntimeWriteSnapshots} inserts in this order, so it is what
* `Object.keys(baseline)` yields — read as a VALUE by
* `runtime-gate.derived-name-keys.test.ts`, which feeds it to
* {@link deriveNameKeyedStackKeys} and asserts an ORDERED result.
* - **The derived alternation.** {@link deriveNameKeyedStackKeys} filters in
* context order by contract ("Order follows `contextStackKeys`, deliberately"),
* and {@link buildTopLevelIndexPattern} interpolates that order into
* {@link TOP_LEVEL_INDEX}. Reordering does not change what the pattern MATCHES
* — the `\[` anchor defeats prefix shadowing, pinned in both orders — but it
* does change the pattern's `source`, which #13390 keeps byte-identical to the
* literal it replaced on purpose.
*
* So the requirement is: preserve the author's order. This spelling does, and
* that is the reason it is a keyed record rather than any union-to-tuple trick:
* `Object.keys` returns own enumerable string keys in declaration order
* (ECMAScript `OrdinaryOwnPropertyKeys`), so the order below IS the stack-key
* order, chosen here and readable here. Integer-like keys would sort ahead of
* insertion order, which is why that rule is stated rather than assumed — a
* stack key is an interface property name and never one of those.
*
* Ordering is pinned by `runtime-gate.derived-context-keys.test.ts` end to end,
* because the pre-existing ordered pin could not see all of it: it filters
* `datasets` out (no write type maps into it), so swapping `datasets` with a
* neighbour left that assertion green.
*/
const CONTEXT_STACK_KEY_ORDER = {
objects: true,
permissions: true,
books: true,
datasets: true,
pages: true,
} as const satisfies { [K in keyof RuntimeStackContext]-?: true };

/**
* The context collections the snapshot carries, in stack-key order. Derived
* facts: every entry is a key of {@link RuntimeStackContext} AND a stack key
* some runtime-wired rule reads (`runtime-gate.test.ts` pins membership).
* some runtime-wired rule reads (`runtime-gate.test.ts` pins membership);
* every key of {@link RuntimeStackContext} has an entry (#13977 — the record
* above, held by the compiler).
*
* `Object.keys` types as `string[]`, so the read-back is asserted. It is the one
* assertion in the derivation and it is sound by construction: the record is
* compiler-pinned to exactly `keyof RuntimeStackContext`, and `Object.keys`
* returns exactly that record's own enumerable string keys. ⛔ Do not widen this
* back into a literal — the list would stop being derived and the interface
* would stop being the single source it says it is.
*/
const CONTEXT_STACK_KEYS = ['objects', 'permissions', 'books', 'datasets', 'pages'] as const satisfies
readonly (keyof RuntimeStackContext)[];
const CONTEXT_STACK_KEYS = Object.keys(CONTEXT_STACK_KEY_ORDER) as readonly (keyof RuntimeStackContext)[];

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