diff --git a/.changeset/9641-registry-namespace-by-reference.md b/.changeset/9641-registry-namespace-by-reference.md new file mode 100644 index 0000000000..0277066022 --- /dev/null +++ b/.changeset/9641-registry-namespace-by-reference.md @@ -0,0 +1,26 @@ +--- +"@object-ui/cli": patch +--- + +Stop `objectui check` reporting five real page types as unknown. + +`ui:page`, `ui:app`, `ui:utility`, `ui:home` and `ui:record` are registry keys the platform stores and the renderer paints, and `objectui check` called every document that spelled one of them an unknown schema type. The list the check judges against is generated from this repository's registration calls, and the generator could not see a `namespace` that arrives through a reference: the page kinds declare their options once and register from that object — one call passing it whole, four spreading it to vary a label — so there is no `namespace:` inside any of those call spans. The derivation read the bare half of each registration, produced no finding, and shipped a list short by exactly the namespaced half. + +The derivation now reads an options object passed by identifier or by top-level spread. It does so from an allowlist of ARGUMENT shapes: an object literal whose top-level entries are all key-value pairs or plain-identifier spreads, or an identifier resolving to one such literal. Every other options ARGUMENT — a cast, a member expression, a call, a spread of any of those, a conditional spread, a computed `namespace` or `skipFallback` — is now reported instead of read as namespace-free, and so is an identifier the derivation declines to follow: one it counts as bound more than once or with `let`, an imported name, or one whose `namespace` or `skipFallback` the file assigns, deletes or `Object.assign`-es in a spelling the guard matches. A validator that refuses what the platform renders is the expensive direction — it teaches authors to stop reading the validator, which costs the opposite direction (a type the check blesses and the runtime rejects) its only reader. + +Within a literal the derivation reads, entries are applied in source order and an entry that sets `namespace` or `skipFallback` replaces what an earlier one set — including a base spread twice around an intervening entry, and including an explicit `skipFallback: false` arriving by spread after an explicit `true`. Both of those were read wrongly, in silence, by the first draft of this change. + +Refusing a name whose options this file is SEEN to write is the sharper half of that, because its failure direction is a phantom rather than a miss. A `let` may hold a different object by the time the call runs, and a `const` cannot be rebound but its `namespace` can be deleted after declaration — either way the derivation would publish a key the runtime never stores, and the check would bless a spelling that renders nothing. A miss refuses something that renders; a phantom green-lights a spelling that renders nothing. + +⚠️ What the instrument does NOT see is stated rather than implied, because a comment claiming more than the code does is the defect this card was filed about. Two rules bound it, and both are narrower than "the object is not rebound or written": + +- A name counts as a binding only where it IMMEDIATELY follows `const` / `let` / `var`, or sits in an import clause. A function parameter, a destructuring pattern, a later declarator of the same statement and a `catch` binding are invisible to the count, so a module-level object answers while the call passes a different one. +- A write counts only in three spellings: an assignment or a `delete` whose target is the name spelled exactly followed by `namespace` / `skipFallback` written out, dotted or in a quoted bracket; or an `Object.assign` whose first argument is that name. A write through an alias, inside a callee, with a computed key, as a destructuring-assignment target, or through `Reflect.set` / `Reflect.deleteProperty` / `Object.defineProperty` / `Object.setPrototypeOf` is invisible. + +Closing either needs scope and aliasing analysis this regex-level derivation does not do, and a regex approximation of those semantics has no finishing line — so the end state chosen for this card is an accurate declaration instead. Every shape above is pinned as a KNOWN GAP reading that asserts today's silent answer, so closing one later fails a test instead of passing unnoticed. + +⛔ Nothing in this repository is KNOWN to hit one of them, and that is a weaker statement than "none is hit" — the whole point of a silent reading is that a clean run does not rule it out. What is re-derived every run is the size of the population a gap could reach: `counters.metaViaReference`, the number of call sites whose options arrive by reference at all. Today that is five sites in one file, all reading the same declaration, the derivation reports zero findings, and the generated list moved by exactly the five namespaced halves and nothing else. + +The registration calls themselves are unchanged; so is every key that was already derived. Five keys are added to the generated list and none is removed or renamed. + +⚠️ Read against a census, not a guess: every `ComponentRegistry.register` / `registerLazy` call in the tree was classified by how its options argument arrives, because "are these five all of them?" was explicitly unmeasured when this was filed. ⚠️ That census is about CALL SITES and their arguments; it says nothing about the 132 keys whose namespace comes from a hand-kept indirect table rather than from a call, which is a different population with its own failure mode. The five page kinds were the only registrations losing a namespace this way; the one other site reaching its options by reference registers a third-party plugin's own key and is already declared unresolvable-by-design. `deriveRegistryKeys`' `metaViaReference` counter re-derives that population on every run — the number is not written down anywhere, here included. diff --git a/packages/cli/src/utils/known-schema-types.ts b/packages/cli/src/utils/known-schema-types.ts index 453628e466..45bf0e990f 100644 --- a/packages/cli/src/utils/known-schema-types.ts +++ b/packages/cli/src/utils/known-schema-types.ts @@ -511,6 +511,7 @@ export const KNOWN_SCHEMA_TYPES: readonly string[] = [ 'ui:address', 'ui:alert', 'ui:alert-dialog', + 'ui:app', 'ui:article', 'ui:aside', 'ui:aspect-ratio', @@ -561,6 +562,7 @@ export const KNOWN_SCHEMA_TYPES: readonly string[] = [ 'ui:h6', 'ui:header', 'ui:header-bar', + 'ui:home', 'ui:hover-card', 'ui:hr', 'ui:html', @@ -583,6 +585,7 @@ export const KNOWN_SCHEMA_TYPES: readonly string[] = [ 'ui:navigation-menu', 'ui:ol', 'ui:p', + 'ui:page', 'ui:pagination', 'ui:password', 'ui:popover', @@ -590,6 +593,7 @@ export const KNOWN_SCHEMA_TYPES: readonly string[] = [ 'ui:progress', 'ui:q', 'ui:radio-group', + 'ui:record', 'ui:resizable', 'ui:scroll-area', 'ui:section', @@ -632,6 +636,7 @@ export const KNOWN_SCHEMA_TYPES: readonly string[] = [ 'ui:tree-view', 'ui:u', 'ui:ul', + 'ui:utility', 'ul', 'url', 'user', diff --git a/scripts/__tests__/check-doc-component-types.test.ts b/scripts/__tests__/check-doc-component-types.test.ts index 35f9f22e26..e266eeacf9 100644 --- a/scripts/__tests__/check-doc-component-types.test.ts +++ b/scripts/__tests__/check-doc-component-types.test.ts @@ -276,6 +276,692 @@ describe('the registered-key universe is derived from the registration calls', ( expect(findings.map((f) => f.reason)).toEqual(['unresolved-registration']); }); + // ── objectui#9641: options that arrive by REFERENCE ──────────────────────── + // + // A registration may hand `register()` an options object it does not spell + // out at the call — a bare identifier, or an object literal that spreads one. + // The namespace is then nowhere inside the call's own span, and a derivation + // that reads only that span produces the BARE half alone and says nothing. + // + // These pins are written against the MECHANISM, over a fixture tree, for the + // reason objectui#9641 exists at all: a pin that asserted the five key + // strings the live tree lost would pass just as happily against a + // hand-edited generated file, which is the failure mode to exclude. Each of + // the four pins that assert what the derivation READS — this one and the + // three titled `carries skipFallback`, `lets a literal namespace` and + // `reports options imported from another module` — fails on the derivation as + // it stood before objectui#9641 and passes after it. They are no longer + // adjacent: the tables of REFUSED shapes sit between them, and citing them by + // position rather than by name is what made this sentence wrong once already. + + it('⭐ resolves a namespace passed by SPREAD, not only one spelled out at the call (objectui#9641)', () => { + // The live shape this was filed for: one options object, one registration + // taking it whole, four more spreading it to vary a label. Every one of + // them is a namespaced registration at runtime. + const keys = withTree((write) => { + write( + 'packages/demo/src/page.tsx', + [ + 'const pageMeta: any = {', + " namespace: 'ui',", + " label: 'Page',", + " inputs: [{ name: 'title', type: 'string' }],", + '};', + "ComponentRegistry.register('page', PageRenderer, pageMeta);", + "ComponentRegistry.register('app', PageRenderer, { ...pageMeta, label: 'App Page' });", + // The firing control, in the same fixture: a namespaced registration + // whose options ARE spelled out at the call. It read correctly before + // this repair and must keep reading correctly after it — that + // asymmetry is what made the spread case a defect and not a design + // choice, so the pin keeps both halves of it in one tree. + "ComponentRegistry.register('header', HeaderRenderer, { namespace: 'page', label: 'Page Header' });", + ].join('\n'), + ); + }, keysOf); + expect(keys).toEqual(['app', 'header', 'page', 'page:header', 'ui:app', 'ui:page']); + }); + + it('carries `skipFallback` through the reference too, so a bare key is not invented', () => { + // The other direction of the same read: options reached by reference decide + // whether the BARE key exists at all. Missing the flag here would put a key + // into the universe that the registry never stores — a phantom, the + // direction objectui#5115 was filed for. + const keys = withTree((write) => { + write( + 'packages/demo/src/index.tsx', + [ + "const barMeta = { namespace: 'action', skipFallback: true, label: 'Action Bar' };", + "ComponentRegistry.register('action-bar', Bar, barMeta);", + "ComponentRegistry.register('toolbar', Bar, { ...barMeta, label: 'Toolbar' });", + ].join('\n'), + ); + }, keysOf); + expect(keys).toEqual(['action:action-bar', 'action:toolbar']); + }); + + it('lets a literal `namespace:` after the spread win, as the runtime does', () => { + // `{ ...base, namespace: 'x' }` is `'x'` and `{ namespace: 'x', ...base }` + // is whatever `base` carries. Reading the first `namespace:` in the span + // would get the second case backwards, so the entries are read in order. + const keys = withTree((write) => { + write( + 'packages/demo/src/index.tsx', + [ + "const base = { namespace: 'ui', label: 'Base' };", + "ComponentRegistry.register('after', C, { ...base, namespace: 'view' });", + "ComponentRegistry.register('before', C, { namespace: 'view', ...base });", + ].join('\n'), + ); + }, keysOf); + expect(keys).toEqual(['after', 'before', 'ui:before', 'view:after']); + }); + + /** + * ⛔ Every options shape that is not READ is REPORTED — the whole class, not + * the two idioms this tree happens to use. + * + * The first cut of objectui#9641 taught the two shapes `page.tsx` uses and + * left the siblings falling through to the whole-span regex, which finds no + * `namespace:` and yields a bare-only reading with no finding. That is the + * defect the card was filed for, wearing a different spelling — and the + * headers were meanwhile re-asserting that a form the derivation cannot + * resolve fails here rather than shrinking the universe quietly. Measured at + * the time: zero of the resolved call sites in this tree use any of the + * shapes below, so refusing them reds nothing and makes that sentence true. + * + * ⭐ Each row is SILENT-BARE on the derivation before this table existed: the + * namespaced key is dropped and no finding is raised. The assertion pairs the + * two halves deliberately — a finding AND the absence of the namespaced key — + * because a reading that merely lost the key would satisfy half of it. + */ + const UNREADABLE_OPTIONS: [name: string, lines: string[]][] = [ + [ + 'a cast, which hides an object the derivation would otherwise read', + ["const meta = { namespace: 'ui', label: 'W' };", "ComponentRegistry.register('widget', C, meta as any);"], + ], + [ + 'a member expression, whose object lives in another module', + ["import { shared } from './shared';", "ComponentRegistry.register('widget', C, shared.meta);"], + ], + [ + 'a call expression, whose result nothing static can know', + ['const buildMeta = () => ({});', "ComponentRegistry.register('widget', C, buildMeta());"], + ], + [ + 'a spread of a call expression', + ['const buildMeta = () => ({});', "ComponentRegistry.register('widget', C, { ...buildMeta(), label: 'W' });"], + ], + [ + 'a spread of a member expression', + ["import { shared } from './shared';", "ComponentRegistry.register('widget', C, { ...shared.meta, label: 'W' });"], + ], + [ + 'a conditional spread, where the two arms may not agree', + [ + "const compact = { namespace: 'ui' };", + "const roomy = { namespace: 'view' };", + 'const flag = true;', + "ComponentRegistry.register('widget', C, { ...(flag ? compact : roomy), label: 'W' });", + ], + ], + [ + 'a `namespace` that is not a string literal', + ["const NS = 'ui';", "ComponentRegistry.register('widget', C, { namespace: NS, label: 'W' });"], + ], + ]; + + for (const [shape, lines] of UNREADABLE_OPTIONS) { + it(`⭐ reports options it cannot read — ${shape}`, () => { + const { keys, findings } = withTree((write) => { + write('packages/demo/src/index.tsx', `${lines.join('\n')}\n`); + }, (dir) => deriveRegistryKeys(dir, BARE)); + expect((findings as Finding[]).map((f) => f.reason)).toEqual(['unresolved-registration-meta']); + // The KEY is still collected. What could not be read is the namespace, and + // dropping the bare half too would shrink the universe further than the + // defect being reported. + expect([...keys.keys()].sort()).toEqual(['widget']); + }); + } + + it('⭐ reports options imported from another module rather than reading the registration as bare', () => { + // The shape that motivated the finding reason: an options object another + // module owns may carry a namespace, and assuming it does not is the + // objectui#9641 defect with a new address. + const { keys, findings } = withTree((write) => { + write( + 'packages/demo/src/index.tsx', + [ + "import { sharedMeta } from './shared';", + "ComponentRegistry.register('widget', C, { ...sharedMeta, label: 'Widget' });", + ].join('\n'), + ); + }, (dir) => deriveRegistryKeys(dir, BARE)); + expect((findings as Finding[]).map((f) => f.reason)).toEqual(['unresolved-registration-meta']); + expect([...keys.keys()].sort()).toEqual(['widget']); + }); + + it('⭐ reports a computed `skipFallback`, which decides whether the bare key exists at all', () => { + // The other half of an options object, and the other failure direction. The + // namespace here IS readable, so the namespaced key is derived; what cannot + // be known is whether the registry also publishes the bare fallback. Both + // are still collected — the generous reading — and the finding is what says + // one of them is a guess, instead of the run looking certain. + const { keys, findings } = withTree((write) => { + write( + 'packages/demo/src/index.tsx', + [ + 'const SKIP = new Set([]);', + "ComponentRegistry.register('widget', C, { namespace: 'ui', skipFallback: SKIP.has('widget') });", + ].join('\n'), + ); + }, (dir) => deriveRegistryKeys(dir, BARE)); + expect((findings as Finding[]).map((f) => f.reason)).toEqual(['unresolved-registration-meta']); + expect([...keys.keys()].sort()).toEqual(['ui:widget', 'widget']); + }); + + it.each(['let', 'var'])('⭐ refuses a reassignable `%s` binding rather than minting a PHANTOM key', (keyword) => { + // ⚠️ The worse direction of the two, and the reason the name is followed + // only through a single `const`. The runtime stores the BARE key here and + // nothing else; deriving `ui:widget` from the initialiser would put a key + // into the universe that the registry never has, so `objectui check` would + // bless a document that renders an OBJUI-001 panel. A miss refuses + // something that renders; a phantom green-lights something that renders + // nothing. + const { keys, findings } = withTree((write) => { + write( + 'packages/demo/src/index.tsx', + [ + `${keyword} meta = { namespace: 'ui', label: 'W' };`, + "meta = { label: 'W' };", + "ComponentRegistry.register('widget', C, meta);", + ].join('\n'), + ); + }, (dir) => deriveRegistryKeys(dir, BARE)); + expect((findings as Finding[]).map((f) => f.reason)).toEqual(['unresolved-registration-meta']); + expect([...keys.keys()].sort()).toEqual(['widget']); + }); + + it('⭐ refuses a name declared more than once, because scope decides which one the call reads', () => { + // A function-scoped declaration earlier in the file is not the binding this + // call resolves to, but it is the first one a whole-file search finds. The + // derivation cannot do scope analysis, so the honest answer is to say so — + // the alternative is a namespace read off the wrong object, silently. + const { keys, findings } = withTree((write) => { + write( + 'packages/demo/src/index.tsx', + [ + 'function local() {', + " const meta = { namespace: 'view', label: 'Local' };", + ' return meta;', + '}', + "const meta = { namespace: 'ui', label: 'W' };", + "ComponentRegistry.register('widget', C, meta);", + ].join('\n'), + ); + }, (dir) => deriveRegistryKeys(dir, BARE)); + expect((findings as Finding[]).map((f) => f.reason)).toEqual(['unresolved-registration-meta']); + expect([...keys.keys()].sort()).toEqual(['widget']); + }); + + /** + * ⛔ A `const` cannot be REASSIGNED, but its contents can be WRITTEN. + * + * Following a name is a premise: that the literal at the declaration is the + * object the call passes. The refusals above enforce that premise against + * rebinding; these enforce it against mutation, which the first two rounds + * left unread. Every row below derived with no finding at all before this + * block existed — five shapes were measured on the round that added them and + * two spellings were added beside those, seven rows in all — and two of them + * are in the direction that matters most. + * + * ⭐ The `namespace` and `skipFallback` properties are the only two that move + * which keys a registration publishes, so they are the only two watched. A + * write to any other property leaves the derivation's answer correct and is + * deliberately still READ — the pin for that is below, because a guard that + * refused every mutated object would red correct registrations. + */ + const MUTATED_AFTER_DECLARATION: [name: string, lines: string[]][] = [ + [ + 'assigning a `namespace` the declaration does not carry (a MISS: the runtime publishes a namespaced key the derivation would not)', + ["const meta = { label: 'W' };", "meta.namespace = 'ui';", "ComponentRegistry.register('widget', C, meta);"], + ], + [ + 'the same write through `Object.assign`, which can carry any option', + [ + "const meta = { label: 'W' };", + "Object.assign(meta, { namespace: 'ui' });", + "ComponentRegistry.register('widget', C, meta);", + ], + ], + [ + '⭐ DELETING a declared `namespace` (a PHANTOM: the runtime stores the bare key alone)', + [ + "const meta = { namespace: 'ui', label: 'W' };", + 'delete meta.namespace;', + "ComponentRegistry.register('widget', C, meta);", + ], + ], + [ + '⭐ assigning `skipFallback` after the fact (a PHANTOM in the bare half: the runtime stops publishing it)', + [ + "const meta = { namespace: 'ui', label: 'W' };", + 'meta.skipFallback = true;', + "ComponentRegistry.register('widget', C, meta);", + ], + ], + [ + 'the same write reached through a top-level spread rather than the identifier', + [ + "const meta = { label: 'W' };", + "meta.namespace = 'ui';", + "ComponentRegistry.register('widget', C, { ...meta, label: 'X' });", + ], + ], + [ + 'the bracket spelling of the same write', + ["const meta = { label: 'W' };", "meta['namespace'] = 'ui';", "ComponentRegistry.register('widget', C, meta);"], + ], + [ + 'a logical assignment, which writes only sometimes and is therefore no more knowable', + ["const meta = { label: 'W' };", "meta.namespace ??= 'ui';", "ComponentRegistry.register('widget', C, meta);"], + ], + ]; + + for (const [shape, lines] of MUTATED_AFTER_DECLARATION) { + it(`⭐ refuses a name whose options are written after it is declared — ${shape}`, () => { + const { keys, findings } = withTree((write) => { + write('packages/demo/src/index.tsx', `${lines.join('\n')}\n`); + }, (dir) => deriveRegistryKeys(dir, BARE)); + expect((findings as Finding[]).map((f) => f.reason)).toEqual(['unresolved-registration-meta']); + expect([...keys.keys()].sort()).toEqual(['widget']); + }); + } + + it('⛔ keeps READING a name that is only read — a member access is not a write', () => { + // The control for the block above, and the reason the guard looks for an + // assignment operator, a `delete` or an `Object.assign` target rather than + // for the property name. A file that logs or compares `meta.namespace` + // still hands the declared object to `register()`, so refusing it would red + // a correct registration. ⚠️ This pin passes both before and after the + // mutation guard — it is a control, not a mechanism pin: what it excludes + // is a guard written too broadly, which no ablation can show. + const { keys, findings } = withTree((write) => { + write( + 'packages/demo/src/index.tsx', + [ + "const meta = { namespace: 'ui', label: 'W' };", + 'console.log(meta.namespace);', + "const isUi = meta.namespace === 'ui';", + "meta.label = 'X';", + "ComponentRegistry.register('widget', C, meta);", + ].join('\n'), + ); + }, (dir) => deriveRegistryKeys(dir, BARE)); + expect(findings).toEqual([]); + expect([...keys.keys()].sort()).toEqual(['ui:widget', 'widget']); + }); + + it('refuses a plain imported name — the object it names is in another file', () => { + // ⚠️ A control, not a mechanism pin: this shape was already refused before + // imports were counted, because no `const|let|var` declared the name at + // all. It is here so the pin below cannot be read as the whole claim. + const imported = withTree((write) => { + write('packages/demo/src/index.tsx', "import { meta } from './shared';\nComponentRegistry.register('widget', C, meta);\n"); + }, (dir) => deriveRegistryKeys(dir, BARE)); + expect((imported.findings as Finding[]).map((f) => f.reason)).toEqual(['unresolved-registration-meta']); + expect([...imported.keys.keys()].sort()).toEqual(['widget']); + }); + + it('⭐ counts an IMPORT as a binding, so a function-local declaration cannot answer for it', () => { + // The case counting imports actually closes, and it is silent without it. + // The call sits at module level and reads the IMPORT; the only + // `const|let|var` in the file is function-scoped and invisible to the call. + // Counting just the declarations sees exactly one and reads the wrong + // object — here it would publish a namespace the imported options may not + // carry at all. + const { keys, findings } = withTree((write) => { + write( + 'packages/demo/src/index.tsx', + [ + "import { meta } from './shared';", + 'export function unrelated() {', + " const meta = { namespace: 'ui', label: 'Local' };", + ' return meta;', + '}', + "ComponentRegistry.register('widget', C, meta);", + ].join('\n'), + ); + }, (dir) => deriveRegistryKeys(dir, BARE)); + expect((findings as Finding[]).map((f) => f.reason)).toEqual(['unresolved-registration-meta']); + expect([...keys.keys()].sort()).toEqual(['widget']); + }); + + it('⚠️ KNOWN GAP — a function parameter of the same name is not counted, and is read against the module literal', () => { + // ⛔ This pin records what the derivation DOES, not what it should do. The + // declaration count sees `const`, `let`, `var` and imports; a parameter + // binding is invisible to it, so the module-level literal answers while the + // object the call passes is the argument. Closing it needs scope analysis + // this regex-level derivation does not do. + // + // It is pinned rather than left in a comment for the reason the whole card + // exists: a gap stated only in prose is a claim no instrument re-derives. + // ⭐ If someone closes it, THIS TEST FAILS — and that failure is the signal + // to delete the pin and the paragraph in `declaredObjectBody`'s header that + // declares the gap, not to restore the old reading. + const { keys, findings } = withTree((write) => { + write( + 'packages/demo/src/index.tsx', + [ + "const meta = { namespace: 'ui', label: 'W' };", + 'export function reg(meta) {', + " ComponentRegistry.register('widget', C, meta);", + '}', + ].join('\n'), + ); + }, (dir) => deriveRegistryKeys(dir, BARE)); + expect(findings).toEqual([]); + expect([...keys.keys()].sort()).toEqual(['ui:widget', 'widget']); + }); + + /** + * ⭐ The two `readMetaBody` bugs this PR's own new code introduced, fixed + * here and pinned so the fix cannot silently regress. Both were measured on + * the head that carried them (objectui#9641 round 3); neither is reachable + * from this tree, and both are asserted as the RUNTIME answer rather than as + * the answer the old code gave. + */ + it('⭐ re-applies a base spread the same literal spreads TWICE — the visited set is a recursion stack', () => { + // Runtime: `{ ...base, ...mid, ...base }` copies `base` again last, so the + // namespace is `ui`. A visited SET marks `base` used up at its first + // spread and skips the second, leaving `mid`'s namespace standing — a + // PHANTOM `view:widget` and a MISS of `ui:widget` at the same time. A + // recursion STACK releases the name once its body has been read, so only a + // spread still being read is a cycle. + const { keys, findings } = withTree((write) => { + write( + 'packages/demo/src/index.tsx', + [ + "const base = { namespace: 'ui' };", + "const mid = { namespace: 'view' };", + "ComponentRegistry.register('widget', C, { ...base, ...mid, ...base });", + ].join('\n'), + ); + }, (dir) => deriveRegistryKeys(dir, BARE)); + expect(findings).toEqual([]); + expect([...keys.keys()].sort()).toEqual(['ui:widget', 'widget']); + }); + + it('⭐ lets an explicit `skipFallback: false` arriving by spread override an explicit `true`', () => { + // Runtime: the later spread wins, so `skipFallback` is `false` and the + // registry publishes the bare key too. A truthiness test cannot tell + // "spread carries false" from "spread never mentions it", so the earlier + // `true` survived and the bare key went MISSING. Set-ness is tracked. + const { keys, findings } = withTree((write) => { + write( + 'packages/demo/src/index.tsx', + [ + "const base = { namespace: 'ui', skipFallback: false };", + "ComponentRegistry.register('widget', C, { skipFallback: true, ...base });", + ].join('\n'), + ); + }, (dir) => deriveRegistryKeys(dir, BARE)); + expect(findings).toEqual([]); + expect([...keys.keys()].sort()).toEqual(['ui:widget', 'widget']); + }); + + it('⛔ still terminates on a self-referential and on a mutually-spreading declaration', () => { + // ⚠️ A control, not a mechanism pin: it passes on both sides of the change + // above. It is what says the recursion stack did not trade a wrong reading + // for a hang — the reason the set existed in the first place. + const selfRef = withTree((write) => { + write('packages/demo/src/index.tsx', "const a = { ...a, namespace: 'ui' };\nComponentRegistry.register('w', C, a);\n"); + }, (dir) => deriveRegistryKeys(dir, BARE)); + expect([...selfRef.keys.keys()].sort()).toEqual(['ui:w', 'w']); + const mutual = withTree((write) => { + write( + 'packages/demo/src/index.tsx', + ['const a = { ...b };', "const b = { ...a, namespace: 'ui' };", "ComponentRegistry.register('w', C, a);"].join('\n'), + ); + }, (dir) => deriveRegistryKeys(dir, BARE)); + expect([...mutual.keys.keys()].sort()).toEqual(['ui:w', 'w']); + }); + + /** + * ⚠️ KNOWN GAPS — every silent reading objectui#9641's round-3 review + * measured, pinned as the reading the derivation GIVES TODAY. + * + * ⛔ These are not statements about what the derivation should do. They exist + * because the ruling on this card (batch #150 item 2, letter B) is that a + * regex approximation of JavaScript scope and mutation semantics has no + * finishing line, so the reachable end state is an ACCURATE DECLARATION of + * what the instrument cannot see — and a gap stated only in prose is a claim + * no instrument re-derives. Each row below is a shape the runtime and the + * derivation disagree about, WITHOUT a finding: + * + * MISS the runtime publishes a namespaced key the derivation does not. + * `objectui check` calls a document unknown while it renders. + * PHANTOM the derivation publishes a key the runtime never stores. The + * check blesses a spelling that paints an OBJUI-001 panel — the + * worse direction, and the one this card was filed about. + * + * ⭐ If someone closes one of these, ITS ROW FAILS — and that failure is the + * signal to delete the row and the paragraph that declares the gap, not to + * restore the old reading. That is the same treatment the function-parameter + * gap above already gets. + * + * ⛔ NOTHING here says the live tree is free of these shapes; what is + * re-derived every run is that the tree produces zero findings and that + * `counters.metaViaReference` is non-zero, which bounds how many sites the + * mutation and scope gaps could reach without saying any of them is hit. + */ + const KNOWN_GAPS: [route: string, shape: string, keys: string[], lines: string[]][] = [ + // ── the mutation route: `optionsMutatedAfterDeclaration` sees a write only + // when it is spelled with this exact name and a literal property. + [ + 'mutation', + 'a write through an ALIAS of the name (MISS)', + ['widget'], + ["const meta = { label: 'W' };", 'const other = meta;', "other.namespace = 'ui';", "ComponentRegistry.register('widget', C, meta);"], + ], + [ + 'mutation', + '⭐ a DELETE through an alias (PHANTOM)', + ['ui:widget', 'widget'], + [ + "const meta = { namespace: 'ui', label: 'W' };", + 'const other = meta;', + 'delete other.namespace;', + "ComponentRegistry.register('widget', C, meta);", + ], + ], + [ + 'mutation', + 'an alias write reached through a top-level spread (MISS)', + ['widget'], + [ + "const meta = { label: 'W' };", + 'const other = meta;', + "other.namespace = 'ui';", + "ComponentRegistry.register('widget', C, { ...meta, label: 'X' });", + ], + ], + [ + 'mutation', + 'a write inside a CALLEE the object is passed to (MISS)', + ['widget'], + [ + "const meta = { label: 'W' };", + 'function tag(o) {', + " o.namespace = 'ui';", + '}', + 'tag(meta);', + "ComponentRegistry.register('widget', C, meta);", + ], + ], + [ + 'mutation', + '⭐ a `skipFallback` write inside a callee (PHANTOM in the bare half)', + ['ui:widget', 'widget'], + [ + "const meta = { namespace: 'ui', label: 'W' };", + 'function hide(o) {', + ' o.skipFallback = true;', + '}', + 'hide(meta);', + "ComponentRegistry.register('widget', C, meta);", + ], + ], + [ + 'mutation', + 'a computed-key write, whose property name is not in the source (MISS)', + ['widget'], + ["const meta = { label: 'W' };", "const prop = 'namespace';", "meta[prop] = 'ui';", "ComponentRegistry.register('widget', C, meta);"], + ], + [ + 'mutation', + 'a destructuring ASSIGNMENT whose target is the property (MISS)', + ['widget'], + ["const meta = { label: 'W' };", "({ namespace: meta.namespace } = { namespace: 'ui' });", "ComponentRegistry.register('widget', C, meta);"], + ], + [ + 'mutation', + '`Object.defineProperty` (MISS)', + ['widget'], + ["const meta = { label: 'W' };", "Object.defineProperty(meta, 'namespace', { value: 'ui' });", "ComponentRegistry.register('widget', C, meta);"], + ], + [ + 'mutation', + '`Reflect.set` (MISS)', + ['widget'], + ["const meta = { label: 'W' };", "Reflect.set(meta, 'namespace', 'ui');", "ComponentRegistry.register('widget', C, meta);"], + ], + [ + 'mutation', + '⭐ `Reflect.deleteProperty` (PHANTOM)', + ['ui:widget', 'widget'], + ["const meta = { namespace: 'ui', label: 'W' };", "Reflect.deleteProperty(meta, 'namespace');", "ComponentRegistry.register('widget', C, meta);"], + ], + [ + 'mutation', + '`Object.setPrototypeOf`, whose INHERITED `namespace` the registry still reads through `meta?.namespace` (MISS)', + ['widget'], + ["const meta = { label: 'W' };", "Object.setPrototypeOf(meta, { namespace: 'ui' });", "ComponentRegistry.register('widget', C, meta);"], + ], + // ── the name-resolution route: `declaredObjectBody` counts a name only + // where it IMMEDIATELY follows `const` / `let` / `var`, or sits in an + // import clause. Every binding below is invisible to that count, so the + // module-level literal answers for an object the call never passes — + // all four are PHANTOMs, the same family as the parameter gap above. + [ + 'scope', + '⭐ a DESTRUCTURED `const` shadowing the module-level name (PHANTOM)', + ['ui:widget', 'widget'], + [ + "const meta = { namespace: 'ui', label: 'W' };", + 'export function reg(input) {', + ' const { meta } = input;', + " ComponentRegistry.register('widget', C, meta);", + '}', + ], + ], + [ + 'scope', + '⭐ an ARRAY-destructured binding of the same name (PHANTOM)', + ['ui:widget', 'widget'], + [ + "const meta = { namespace: 'ui', label: 'W' };", + 'export function reg(input) {', + ' const [meta] = input;', + " ComponentRegistry.register('widget', C, meta);", + '}', + ], + ], + [ + 'scope', + '⭐ a LATER DECLARATOR of the same `const` statement (PHANTOM)', + ['ui:widget', 'widget'], + [ + "const meta = { namespace: 'ui', label: 'W' };", + 'export function reg(input) {', + ' const first = 1, meta = input.meta;', + " ComponentRegistry.register('widget', C, meta);", + '}', + ], + ], + [ + 'scope', + '⭐ a `catch` binding of the same name (PHANTOM)', + ['ui:widget', 'widget'], + [ + "const meta = { namespace: 'ui', label: 'W' };", + 'export function reg(run) {', + ' try {', + ' run();', + ' } catch (meta) {', + " ComponentRegistry.register('widget', C, meta);", + ' }', + '}', + ], + ], + ]; + + for (const [route, shape, expected, lines] of KNOWN_GAPS) { + it(`⚠️ KNOWN GAP (${route}) — ${shape}`, () => { + const { keys, findings } = withTree((write) => { + write('packages/demo/src/index.tsx', `${lines.join('\n')}\n`); + }, (dir) => deriveRegistryKeys(dir, BARE)); + // ⛔ The silence is half the reading and the half that matters: the + // derivation does not merely get these wrong, it gets them wrong without + // raising `unresolved-registration-meta`, so no run reports them. + expect(findings).toEqual([]); + expect([...keys.keys()].sort()).toEqual(expected); + }); + } + + it('⛔ the spelled forms of those same writes are still REFUSED — the gaps are about spelling, not about the guard', () => { + // The firing control for the whole KNOWN GAP block. Without it, a block of + // green "the derivation reads this silently" pins is indistinguishable from + // a mutation guard that stopped working altogether. + const { keys, findings } = withTree((write) => { + write( + 'packages/demo/src/index.tsx', + ["const meta = { label: 'W' };", "meta.namespace = 'ui';", "ComponentRegistry.register('widget', C, meta);"].join('\n'), + ); + }, (dir) => deriveRegistryKeys(dir, BARE)); + expect((findings as Finding[]).map((f) => f.reason)).toEqual(['unresolved-registration-meta']); + expect([...keys.keys()].sort()).toEqual(['widget']); + }); + + it('reads an options argument spelled `undefined` as ABSENT options, not as an unreadable name', () => { + // `register(key, C, undefined)` is what `register(key, C)` means, and the + // registry publishes the bare key for both. `undefined` matches the + // identifier pattern, so without a keyword check it was refused as "not + // declared in this file" — a red on a correct registration. + const { keys, findings } = withTree((write) => { + write( + 'packages/demo/src/index.tsx', + ["ComponentRegistry.register('widget', C, undefined);", "ComponentRegistry.register('gadget', C, null);"].join('\n'), + ); + }, (dir) => deriveRegistryKeys(dir, BARE)); + expect(findings).toEqual([]); + expect([...keys.keys()].sort()).toEqual(['gadget', 'widget']); + }); + + it('counts EVERY site whose options arrived by reference, not only the namespaced ones', () => { + // `counters.metaViaReference` is the live instrument the run summary prints + // and the 5115 suite asserts is non-zero, so what it counts has to be what + // the header says it counts. It used to increment only when a namespace was + // also resolved, which made a referenced options object without one + // invisible to the very counter that reports this route is in use. + const { counters } = withTree((write) => { + write( + 'packages/demo/src/index.tsx', + ["const meta = { label: 'W' };", "ComponentRegistry.register('widget', C, meta);"].join('\n'), + ); + }, (dir) => deriveRegistryKeys(dir, BARE)); + expect((counters as { metaViaReference: number }).metaViaReference).toBe(1); + }); + it('ignores registrations that live in test files', () => { // `probe`, `crashing-widget`, `test-widget` and friends are registered by // suites all over this repo. Letting them into the universe would let a doc diff --git a/scripts/__tests__/known-schema-types-derivation-5115.test.ts b/scripts/__tests__/known-schema-types-derivation-5115.test.ts index 752d19231a..7380131479 100644 --- a/scripts/__tests__/known-schema-types-derivation-5115.test.ts +++ b/scripts/__tests__/known-schema-types-derivation-5115.test.ts @@ -106,6 +106,59 @@ describe('the two drifted types objectui#5115 was filed for', () => { }); }); +describe('registrations whose options arrive by reference (objectui#9641)', () => { + /** + * `page.tsx` declares one options object and registers five page kinds from + * it — one taking it whole, four spreading it to vary the label. All five are + * namespaced (`ui`) at runtime, so the registry stores ten keys; the + * derivation resolved only the five bare ones and said nothing, and the list + * shipped to `objectui check` was short by exactly the namespaced half. + * + * ⚠️ What made that expensive is not the size. Three instruments on + * objectui#9263 — that card's census, the dev's independent re-take, and this + * repository's own `deriveRegistryKeys` — agreed that a page registration was + * unused. They agreed because two of them were reading through this blind + * spot, not because they were independent. + * + * The MECHANISM is pinned over fixture trees in + * `check-doc-component-types.test.ts` — `resolves a namespace passed by + * SPREAD` and its neighbours for what the derivation READS, and the + * `reports options it cannot read` and `refuses a name` families for what it + * refuses — which is where a pin can fail on the old derivation and pass on + * the new one. + * What belongs HERE is the live-tree consequence: these keys reach the + * shipped list. The two halves are + * deliberately separate — an assertion about key strings alone would also be + * satisfied by a hand-edited generated file, and it is only the + * byte-for-byte regenerability pin below that rules that out. + */ + it('⭐ ships the namespaced half of every page kind, not just the bare fallback', () => { + for (const type of ['ui:page', 'ui:app', 'ui:utility', 'ui:home', 'ui:record']) { + expect(derived.keys.has(type), `${type} is a real runtime registry key`).toBe(true); + expect(isKnownSchemaType(type), `${type} must not be reported as an unknown schema type`).toBe(true); + } + }); + + it('keeps the bare fallbacks it always had — this repair adds a half, it does not move one', () => { + // ⚠️ A REGRESSION GUARD, not a mechanism pin: it passes on both sides of + // the objectui#9641 change, because these five bare keys were derived + // before it too. What it excludes is a repair that traded the bare half for + // the namespaced one, which no ablation of the repair can show. + for (const type of ['page', 'app', 'utility', 'home', 'record']) { + expect(isKnownSchemaType(type)).toBe(true); + } + }); + + it('is not vacuous: the tree still contains registrations read through a reference', () => { + // The counter the derivation prints, never a number copied here (AGENTS #9): + // if `page.tsx` is rewritten to spell its options out at each call, this + // drops to zero and the pin above starts passing for a reason that has + // nothing to do with the repair. That is the day to re-read this block, not + // to delete it — some other registration will reach for the same shape. + expect(derived.counters.metaViaReference).toBeGreaterThan(0); + }); +}); + describe('the generated module is regenerable', () => { it('matches what the regeneration script would write, byte for byte', () => { // Keeps `node scripts/regenerate-known-schema-types.mjs` honest: if the diff --git a/scripts/check-doc-component-types.mjs b/scripts/check-doc-component-types.mjs index 47a9397b40..33d9fd37d2 100644 --- a/scripts/check-doc-component-types.mjs +++ b/scripts/check-doc-component-types.mjs @@ -60,6 +60,56 @@ * dropped the bare `object-grid` key — which 13 doc sites teach correctly. * A window bug in a derivation this gate trusts shows up as a false RED on * correct documentation, so the span is matched, not guessed. + * - OPTIONS: `namespace` and `skipFallback` decide which keys a registration + * publishes, so `resolveRegistrationOptions` reads an ALLOWLIST of exactly + * two ARGUMENT shapes and REPORTS every other argument shape. Read: an + * object literal whose top-level entries are all key-value pairs or spreads + * of a plain identifier, and a bare identifier that resolves to one such + * literal which the same file declares exactly once — counting only names + * immediately after `const` / `let` / `var` or in an import clause — with + * `const`, and whose `namespace` or `skipFallback` this file does not + * assign, `delete` or `Object.assign` onto IN ONE OF THE THREE SPELLINGS + * `optionsMutatedAfterDeclaration` matches. ⚠️ Those two qualifications are + * the whole of it: the allowlist is structural about the ARGUMENT, while + * following a NAME rests on premises this derivation enforces only against + * the spellings it can see. `declaredObjectBody` and + * `optionsMutatedAfterDeclaration` carry the exact conditions and the + * measured list of shapes that slip through them SILENTLY, each pinned as a + * KNOWN GAP reading in `check-doc-component-types.test.ts` — + * `register('page', R, pageMeta)` and + * `register('app', R, { ...pageMeta, label: 'App Page' })` are both read, + * and `counters.metaViaReference` counts every site whose options arrived + * that way and were read — namespaced or not — which the run summary + * prints and a pin in the objectui#5115 suite asserts is non-zero. + * + * ⛔ The allowlist is the point, and it is structural rather than a list of + * idioms to keep up with. Reading only the call span found no `namespace:` + * in either line above, so both were read as bare-only and their + * `namespace:key` halves were lost SILENTLY — objectui#9641, five real + * runtime keys absent from the universe while the firing control (the same + * registration shape with its options spelled out at the call) was present, + * which is what made it a defect rather than a choice. A best-effort repair + * reproduces it: a cast, a member expression, a call, a spread of any of + * those, a conditional spread, a computed `namespace` and a computed + * `skipFallback` were each measured falling through to the same silent + * bare-only reading. Any ARGUMENT shape not on the allowlist is therefore an + * `unresolved-registration-meta` finding, one fixture pin per shape in + * `check-doc-component-types.test.ts`. ⛔ That sentence is about the + * argument only: a name the allowlist accepts can still be read against the + * wrong object, and those readings are silent rather than reported. They + * are enumerated where they live, and pinned. + * + * ⚠️ The same correction had to be made twice, one layer down: the + * allowlist is structural about the ARGUMENT, and the identifier route + * rests on a premise about the NAME. A `const` cannot be reassigned but its + * contents can be written, and a `namespace` deleted after declaration used + * to mint a phantom through the very binding this derivation treats as + * safe. Those conditions now live in `declaredObjectBody`, which is where + * to read them — + * restating them here would be a second copy to keep honest. Whether that refusal costs this tree + * anything is a question for the gate, not for this comment: it reds + * nothing today, and the day a registration reaches for one of these shapes + * it is told so instead of losing half its keys. * - LOOP: `for (const v of ['a','b'])`, `for (const v of ARR)` and * `ARR.forEach(v => …)` where `ARR` is a literal array in the same file. * Five registration sites use this form (`html-elements.tsx`'s `TAGS`, @@ -933,6 +983,382 @@ function resolveKeyArgument(source, callOpen) { return names.length ? names : null; } +/** + * Split a balanced `(…)` / `{…}` / `[…]` span into its top-level, + * comma-separated parts, with the outer delimiters dropped. Depth and quotes + * are tracked, so a comma inside `inputs: [ … ]` or inside a string is not + * read as a separator. + */ +function topLevelParts(span) { + const inner = span.slice(1, -1); + const parts = []; + let depth = 0; + let start = 0; + let i = 0; + const n = inner.length; + while (i < n) { + const ch = inner[i]; + if (ch === '"' || ch === "'" || ch === '`') { + const quote = ch; + i++; + while (i < n && inner[i] !== quote) { + if (inner[i] === '\\') i++; + i++; + } + i++; + continue; + } + if (ch === '(' || ch === '[' || ch === '{') depth++; + else if (ch === ')' || ch === ']' || ch === '}') depth--; + else if (ch === ',' && depth === 0) { + parts.push(inner.slice(start, i)); + start = i + 1; + } + i++; + } + parts.push(inner.slice(start)); + return parts.map((part) => part.trim()).filter((part) => part.length > 0); +} + +const IDENTIFIER = '[A-Za-z_$][\\w$]*'; +const META_SPREAD = new RegExp(`^\\.\\.\\.\\s*(${IDENTIFIER})$`); +const META_ENTRY = new RegExp(`^(?:(${IDENTIFIER})|'([^']*)'|"([^"]*)")\\s*:\\s*([\\s\\S]+)$`); +const STRING_LITERAL = /^(['"])([^'"]*)\1$/; + +const escapeForRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +/** + * Properties of a registration's options that decide which keys it publishes. + * A write to either of these AFTER the declaration changes the answer, so + * `declaredObjectBody` refuses a name whose file writes one. + */ +const KEY_BEARING_OPTIONS = 'namespace|skipFallback'; + +/** Assignment operators, including the logical and compound forms. */ +const ASSIGN_OP = '(?:\\?\\?|\\|\\||&&|\\*\\*|<<|>>>|>>|[-+*/%&^|])?=(?!=)'; + +/** + * Does this file contain one of THREE SPELLINGS of a write to `name`'s + * key-bearing options? Returns the reason, or null. + * + * ⚠️ Stated as three spellings rather than as "is it written to", because that + * is all this is (objectui#9641 round 3 measured the difference). What is seen: + * + * 1. an assignment whose target is `name` spelled EXACTLY, followed by a + * LITERAL `namespace` / `skipFallback` — dotted, or bracketed with a + * quoted string. Compound and logical assignment operators included. + * 2. a `delete` of that same exact-name-plus-literal-property shape. + * 3. an `Object.assign` whose FIRST argument is `name` spelled exactly. + * + * ⛔ NOTHING ELSE IS SEEN, and the difference is silent — no finding is raised, + * the declaration is read as written, and the run looks certain. A write + * through an ALIAS of the name, inside a CALLEE the object is passed to, with a + * COMPUTED property key, as a DESTRUCTURING-assignment target, through + * `Reflect.set` / `Reflect.deleteProperty` / `Object.defineProperty` / + * `Object.setPrototypeOf`, or from another module, is invisible here. Each of + * those is pinned as a KNOWN GAP reading in `check-doc-component-types.test.ts` + * so that closing one later fails a test rather than passing unnoticed; ⛔ this + * paragraph is not a to-do list — the ruling on objectui#9641 (batch #150 item + * 2, letter B) is that the reachable end state for a regex instrument is an + * accurate declaration of what it cannot see, not a closed set of shapes. + * + * ⛔ READ-ONLY member access is not a write and must keep reading — a file that + * logs or compares `name.namespace` still passes the declared object to + * `register()`. That is why this looks for an assignment operator, a `delete` + * or an `Object.assign` TARGET rather than for the property name. + * + * ⚠️ It is also POSITION-AGNOSTIC: a write spelled anywhere in the file + * refuses the name, including one placed after the `register()` call, which the + * runtime never reached before it read the namespace. That direction is LOUD + * (a finding on a correct registration), which is why it is left as it is. + */ +function optionsMutatedAfterDeclaration(source, name) { + const n = escapeForRegExp(name); + // Spelled out per quote rather than with a backreference: this fragment is + // embedded in regexes with different group numbering, and a backreference + // that outruns the group count is read as an OCTAL ESCAPE in JavaScript — + // it matches a control character, so the bracket form silently never fired. + const quoted = `(?:'(?:${KEY_BEARING_OPTIONS})'|"(?:${KEY_BEARING_OPTIONS})")`; + const property = `(?:\\.\\s*(?:${KEY_BEARING_OPTIONS})|\\[\\s*${quoted}\\s*\\])`; + if (new RegExp(`(? 1) { + return { + body: null, + reason: `\`${name}\` is bound ${bindings} times in this file, so which binding this call reads depends on scope`, + }; + } + if (imported) { + return { body: null, reason: `\`${name}\` is imported, so the object it names is not in this file` }; + } + const [declaration] = declarations; + if (declaration[1] !== 'const') { + return { + body: null, + reason: `\`${name}\` is declared with \`${declaration[1]}\`, so it may hold a different object by the time this call runs`, + }; + } + const mutated = optionsMutatedAfterDeclaration(source, name); + if (mutated) return { body: null, reason: mutated }; + const opener = new RegExp(`^const\\s+${escaped}(?![\\w$])\\s*(?::[^=]*)?=\\s*\\{`).exec( + source.slice(declaration.index), + ); + if (!opener) { + return { body: null, reason: `\`${name}\` is not initialised with an object literal` }; + } + const open = declaration.index + opener[0].length - 1; + const end = spanEnd(source, open); + if (end < 0) return { body: null, reason: `the object literal \`${name}\` is initialised with is unbalanced` }; + return { body: source.slice(open, end), reason: null }; +} + +/** + * Read `namespace` / `skipFallback` out of a meta OBJECT BODY, following + * top-level spreads into the object they spread (objectui#9641). + * + * Entries are read in source order, and an entry that SETS one of the two + * properties replaces whatever an earlier entry set. An entry that does not + * mention a property leaves the earlier reading standing, which is what a + * spread of an object without that OWN property does at runtime. So an object + * that spreads a base and then writes `namespace` carries the written one, and + * one that writes `namespace` and then spreads a base that has its own + * `namespace` carries the base's. + * + * ⚠️ "Sets" is tracked rather than inferred from the value: `skipFallback: + * false` arriving by spread must override an earlier explicit `true`, and a + * truthiness test cannot tell it apart from a spread that never mentions + * `skipFallback` at all. That was a real MISS of the bare key (objectui#9641 + * round 3), and `skipFallbackSet` is what distinguishes the two. + * + * ⚠️ The order claim is about THESE TWO PROPERTIES only, and only for bodies + * where every entry was recognised — an unrecognised entry raises `unresolved` + * and the reading it produces is not asserted to be the runtime's. + * + * ⚠️ Every entry must be RECOGNISED, not merely searched for a `namespace:`. + * An entry this cannot read may be the one carrying the namespace, and the + * whole point of objectui#9641 is that assuming otherwise is silent. + * + * `seen` is a RECURSION STACK, not a visited set: a name is released once its + * body has been read, so a literal that spreads the same base TWICE re-applies + * it the second time as the runtime does, while a spread still on the stack is + * a cycle and is skipped. A visited set got that wrong in both directions at + * once, and silently (objectui#9641 round 3). + */ +function readMetaBody(source, body, seen) { + let namespace = null; + let skipFallback = false; + // Explicit `false` is not the same reading as "never mentioned": a spread + // carrying `skipFallback: false` must OVERRIDE an earlier explicit `true`, + // and a truthiness test cannot tell those apart. Tracked, not inferred. + let skipFallbackSet = false; + let unresolved = null; + const refuse = (reason) => { + unresolved ??= reason; + }; + for (const entry of topLevelParts(body)) { + if (entry.startsWith('...')) { + const spread = META_SPREAD.exec(entry); + if (!spread) { + refuse('it spreads something that is not a plain identifier, so the object it spreads cannot be read here'); + continue; + } + const name = spread[1]; + // `seen` is a RECURSION STACK, not a visited set: the name is released + // once its body has been read, so the same base spread twice re-applies + // the second time, exactly as the runtime re-copies it. Only a spread + // that is still on the stack is a cycle, and that is what is skipped. + if (seen.has(name)) continue; + seen.add(name); + const { body: nested, reason } = declaredObjectBody(source, name); + if (!nested) { + seen.delete(name); + refuse(reason); + continue; + } + const inherited = readMetaBody(source, nested, seen); + seen.delete(name); + if (inherited.namespace) namespace = inherited.namespace; + if (inherited.skipFallbackSet) { + skipFallback = inherited.skipFallback; + skipFallbackSet = true; + } + refuse(inherited.unresolved); + continue; + } + const pair = META_ENTRY.exec(entry); + if (!pair) { + refuse('it holds an entry that is neither a key-value pair nor a spread of a plain identifier'); + continue; + } + const key = pair[1] ?? pair[2] ?? pair[3]; + const value = pair[4].trim(); + if (key === 'namespace') { + const literal = STRING_LITERAL.exec(value); + if (!literal) { + refuse('its `namespace` is not a string literal, so the key it publishes cannot be known here'); + continue; + } + namespace = literal[2]; + continue; + } + if (key === 'skipFallback') { + if (value === 'true') { + skipFallback = true; + skipFallbackSet = true; + } else if (value === 'false') { + skipFallback = false; + skipFallbackSet = true; + } else { + refuse('its `skipFallback` is neither `true` nor `false`, so whether the bare key exists cannot be known here'); + } + } + } + return { namespace, skipFallback, skipFallbackSet, unresolved }; +} + +/** + * Resolve the registration options (`namespace`, `skipFallback`) of one + * `register()` / `registerLazy()` call from its argument span. + * + * Two shapes are READ: an object literal whose top-level entries are all + * key-value pairs or spreads of a plain identifier, and a bare identifier that + * resolves to such a literal in the same file. `pageMeta.namespace` is `'ui'`, + * so both of these store `ui:page` / `ui:app` alongside the bare fallbacks: + * + * ComponentRegistry.register('page', PageRenderer, pageMeta) + * ComponentRegistry.register('app', PageRenderer, { ...pageMeta, label: 'App Page' }) + * + * ⛔ EVERY OTHER ARGUMENT SHAPE IS REPORTED, and that is the whole design. + * ⚠️ Argument shape, not registration shape: once a name is accepted here, + * whether it names the object the call passes is a premise `declaredObjectBody` + * enforces only against the spellings it can see, and the readings that slip + * through are SILENT, not reported. That half is declared and pinned there. + * + * Reading only + * the call span found no `namespace:` in either line above and produced the + * bare halves ALONE — five real runtime keys missing from a universe whose + * whole job is to say which keys are real, with no finding raised, so both + * consumers agreed on a universe neither had measured (objectui#9641). The + * repair for that cannot itself be a best effort: a cast, a member expression, + * a call, a spread of any of those, a conditional spread and a computed + * `namespace` were each measured resolving to a silent bare-only reading, which + * is the same defect with a different spelling. None of them appears in this + * tree today, so refusing them costs nothing here and is what lets the + * regeneration script's header say, truthfully, that a form this cannot resolve + * fails HERE rather than shrinking the universe there. + * + * `viaReference` marks a site whose options were reached through an identifier + * rather than spelled out at the call, and feeds `counters.metaViaReference`. + */ +function resolveRegistrationOptions(source, span) { + const parts = topLevelParts(span); + const bare = { namespace: null, skipFallback: false, unresolved: null, viaReference: false }; + if (parts.length < 3) return bare; + const meta = parts[2]; + + // An options argument spelled `undefined`, `null` or `void 0` is the ABSENCE + // of options, which `register()` reads exactly as a missing third argument. + // They match the identifier pattern, so without this they were refused as + // "not declared in this file" — a red on a correct bare-only registration. + if (/^(?:undefined|null|void\s+0)$/.test(meta)) return bare; + + if (new RegExp(`^${IDENTIFIER}$`).test(meta)) { + const { body, reason } = declaredObjectBody(source, meta); + if (!body) return { ...bare, unresolved: reason, viaReference: true }; + return { ...readMetaBody(source, body, new Set([meta])), viaReference: true }; + } + + if (meta.startsWith('{') && meta.endsWith('}')) { + const viaReference = topLevelParts(meta).some((entry) => META_SPREAD.test(entry)); + return { ...readMetaBody(source, meta, new Set()), viaReference }; + } + + return { + ...bare, + unresolved: 'its options argument is neither an object literal nor a plain identifier', + }; +} + function literalArray(source, name) { const m = new RegExp(`(?:const|let|var)\\s+${name}\\s*(?::[^=]*)?=\\s*\\[([\\s\\S]*?)\\n\\];`, 'm').exec(source); if (!m) return null; @@ -962,7 +1388,7 @@ export function deriveRegistryKeys(root, options = {}) { const openRegistrations = options.openRegistrationSites ?? OPEN_REGISTRATION_SITES; const keys = new Map(); const findings = []; - const counters = { sourceFiles: 0, callSites: 0, resolved: 0, open: 0, indirect: 0 }; + const counters = { sourceFiles: 0, callSites: 0, resolved: 0, open: 0, indirect: 0, metaViaReference: 0 }; const openSeen = new Set(); const indirectSeen = new Set(); const indirectSites = new Set(indirect.map((entry) => entry.site)); @@ -1025,9 +1451,20 @@ export function deriveRegistryKeys(root, options = {}) { counters.resolved++; const end = spanEnd(source, callOpen); const span = end < 0 ? source.slice(callOpen, callOpen + 2000) : source.slice(callOpen, end); - const nsMatch = /namespace\s*:\s*(['"])([^'"]+)\1/.exec(span); - const namespace = nsMatch && !nsMatch[2].includes('${') ? nsMatch[2] : null; - const skipFallback = /skipFallback\s*:\s*true/.test(span); + const { namespace, skipFallback, unresolved, viaReference } = resolveRegistrationOptions(source, span); + if (viaReference && !unresolved) counters.metaViaReference++; + if (unresolved) { + findings.push({ + reason: 'unresolved-registration-meta', + site, + detail: + `the options of this ${match[1]}() call cannot be read here: ${unresolved}. A namespaced ` + + 'registration read as bare loses its `namespace:key` half from the universe SILENTLY, which is ' + + 'the objectui#9641 defect — so an options shape this cannot resolve is reported rather than ' + + 'assumed namespace-free. Spell the options out at the call as key-value pairs, declare them in ' + + 'this file as a single `const` object literal, or teach `readMetaBody` this form.', + }); + } for (const name of names) { if (namespace) { add(`${namespace}:${name}`, site); @@ -1384,6 +1821,14 @@ const HINTS = { 'A ComponentRegistry registration takes a key this derivation cannot resolve to literals. Left ' + 'unhandled it shrinks the universe, which turns CORRECT documentation red. Teach ' + '`resolveKeyArgument` the form, or declare the site in OPEN_REGISTRATION_SITES.', + 'unresolved-registration-meta': + 'A ComponentRegistry registration takes OPTIONS this derivation cannot read — a shape other than ' + + 'an object literal of key-value pairs and plain-identifier spreads, or an identifier that is not a ' + + 'single `const` object literal in the same file. The `namespace` such options may carry decides ' + + 'whether the `namespace:key` half of the registration exists, so reading them as absent drops a ' + + 'real key SILENTLY and `objectui check` then calls a document that renders perfectly unknown. ' + + 'Spell the options out at the call, declare them as one `const` object literal in this file, or ' + + 'teach `readMetaBody` the form. See objectui#9641.', 'stale-open-site': 'OPEN_REGISTRATION_SITES names a file that no longer has an unresolvable registration.', 'stale-indirect-registration': @@ -1459,7 +1904,8 @@ if (invokedDirectly) { `${counters.codeBlocks} code block(s), ` + `${counters.typeSites} \`type\` literal(s) against ${counters.registryKeys} registered key(s) ` + `derived from ${counters.sourceFiles} source file(s) (${counters.resolved} resolved call site(s), ` + - `${counters.indirect} indirect, ${counters.open} open): ` + + `${counters.metaViaReference} via referenced options, ${counters.indirect} indirect, ` + + `${counters.open} open): ` + `${counters.registered} registered, ${counters.exempted} exempted; ` + `${counters.keyTables} key table(s), ${counters.keyTableRows} row(s), ` + `${counters.keyTableKeys} table key(s) judged (namespaced + bare), ` + diff --git a/scripts/regenerate-known-schema-types.mjs b/scripts/regenerate-known-schema-types.mjs index d3b1725dd5..6f4c9542ae 100644 --- a/scripts/regenerate-known-schema-types.mjs +++ b/scripts/regenerate-known-schema-types.mjs @@ -59,10 +59,53 @@ * The key universe comes from `deriveRegistryKeys` in * `scripts/check-doc-component-types.mjs` — deliberately the SAME derivation * that judges documentation snippets (objectui#4823), not a second scanner - * with its own bugs. It already handles the forms this repo actually uses - * (balanced-span `namespace` / `skipFallback` reads, loop and indirect - * registrations, declared open sites), and any registration form it cannot - * resolve fails there rather than silently shrinking the universe here. + * with its own bugs. It handles the forms this repo actually uses (registration + * keys as literals, loops and declared indirect helpers; registration OPTIONS + * as an object literal of key-value pairs and plain-identifier spreads, or an + * identifier resolving to one such literal), and an options ARGUMENT whose + * shape it does not recognise fails there rather than silently shrinking the + * universe here. + * + * ⚠️ That last clause is a PROPERTY OF THAT MODULE, not of this file, and this + * header asserted it while it was false — which is why objectui#9641 exists at + * all. A registration whose options arrive by reference, as + * `register('app', PageRenderer, { ...pageMeta, label: 'App Page' })` with + * `pageMeta.namespace` of `'ui'`, has no `namespace:` inside its own call span. + * The derivation produced the bare half alone and reported NOTHING, so five + * real runtime keys (`ui:page` `ui:app` `ui:utility` `ui:home` `ui:record`) + * were missing from a generated list whose entire job is to say which keys are + * real, and `objectui check` called documents spelling them unknown while the + * renderer painted them. + * + * ⛔ The first repair for that fixed the two shapes this tree uses and left + * seven siblings — a cast, a member expression, a call, three spread variants + * and a computed `namespace` — falling through to the same silent reading, + * while this paragraph re-asserted the clause. Restating a false invariant + * beside a partial fix is the original defect one layer up, and it is the + * reason the derivation now works from an ALLOWLIST: two options ARGUMENT + * shapes are read and every other argument shape is an + * `unresolved-registration-meta` finding. That is what makes the clause above + * a description of the code rather than a hope about it; `deriveRegistryKeys` + * is where to check it, and its own header and fixture pins are what hold it. + * + * ⚠️ AND THE CLAUSE IS NARROWER THAN IT READS, which is the third layer of the + * same mistake and is stated here rather than discovered again. "A form it + * cannot resolve" means a form it does not RECOGNISE. A form it recognises but + * reads under a premise that does not hold — an options object reached by a + * name that some spelling this derivation cannot see rebinds or writes to — is + * read SILENTLY and shrinks or inflates the universe here with no finding + * anywhere. Those readings are measured, enumerated in `declaredObjectBody`'s + * and `optionsMutatedAfterDeclaration`'s headers, and pinned one per shape in + * `scripts/__tests__/check-doc-component-types.test.ts` as KNOWN GAP readings. + * ⛔ Closing them is not owed: a regex approximation of JavaScript scope and + * mutation semantics has no finishing line, and the ruling on objectui#9641 + * (batch #150 item 2, letter B) is that the reachable end state is an accurate + * declaration of what the instrument cannot see. This paragraph is that + * declaration for this file; the pins are what re-derive it. + * + * The repair belongs there and not here for the reason this section already + * gives: a second resolver in this file would be the second scanner it exists + * to refuse. * * The universe is taken WHOLE, with no filtering — bare keys, namespaced keys * and the `protocol-placeholder:` spellings alike. A filter would be a second