diff --git a/.changeset/9871-body-dialect-producer-scan.md b/.changeset/9871-body-dialect-producer-scan.md new file mode 100644 index 0000000000..7dc8496139 --- /dev/null +++ b/.changeset/9871-body-dialect-producer-scan.md @@ -0,0 +1,24 @@ +--- +--- + +Derive the `body`-dialect producer table from a SCAN under a written criterion, +instead of from an enumeration (objectui#9871). + +`scripts/body-dialect-producer-scan.mjs` states what counts as a producer — C1 +emission, C2 resolution, C3 carrier — and answers it over the tree on every run. +The criterion, the exclusions and the limits are emitted with the reading in +both output modes, so the table cannot be quoted without what made it a table. + +The scan closes the two directions `scripts/body-dialect-census.mjs` declares it +is blind in: it requires no sibling `type` (so a `body` on a non-node item is +visible) and it reads inside string and template literals (so a spelling a tool +writes into the author's document is visible). Both closures are pinned against +the census as a differential control in +`scripts/__tests__/body-dialect-producer-scan.test.ts`, on the values the two +implementations disagree about. + +The `the platform SHIPS the dialect it is being asked to refuse` block in +`scripts/__tests__/body-dialect-census.test.ts` now reads that table rather than +naming sites by hand; its claim and its handoff condition are unchanged. + +Tooling and tests only — no package is released by this change. diff --git a/package.json b/package.json index fdd1707688..79aab57139 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "check:esm-specifiers": "node scripts/check-node-esm-load.mjs --specifiers-only", "check:node-esm-load": "node scripts/check-node-esm-load.mjs", "census:body-dialect": "node scripts/body-dialect-census.mjs", + "census:body-dialect-producers": "node scripts/body-dialect-producer-scan.mjs", "census:tsconfig-test-parity": "node scripts/tsconfig-test-parity-census.mjs", "census:cross-file-line-citations": "node scripts/cross-file-line-citation-census.mjs", "census:page-key-reads": "node scripts/page-key-read-census.mjs", diff --git a/scripts/__tests__/body-dialect-census.test.ts b/scripts/__tests__/body-dialect-census.test.ts index 461df31a8f..13bff3ef3c 100644 --- a/scripts/__tests__/body-dialect-census.test.ts +++ b/scripts/__tests__/body-dialect-census.test.ts @@ -44,6 +44,7 @@ import { RULED_BUT_NOT_A_READER, BODY_ONLY_UNRULED, } from '../body-dialect-census.mjs'; +import { scan, producersOf } from '../body-dialect-producer-scan.mjs'; const REPO_ROOT = join(__dirname, '..', '..'); const read = (p: string) => readFileSync(join(REPO_ROOT, p), 'utf8'); @@ -313,35 +314,65 @@ describe('the `body` consumers the ruling does not enumerate', () => { // limits and TWO of them hide a producer: it scores a child list only on an // object that also carries a string-LITERAL `type` (a `tabs` ITEM carries // `label`/`value`/`body` and no `type`), and it never reads inside a string - // or a template literal (a VS Code completion snippet is a string). Both - // shapes are live in shipped source today, so the claim in this test's name - // is still TRUE — with a third subject, and step 4 is still not landable. + // or a template literal (a VS Code completion snippet is a string). // - // ⭐ When either assertion below reds, that is the HANDOFF, not a - // regression: re-point this block at whatever still ships the dialect — or, - // once nothing does, say so here in these terms, which is the day step 4 - // becomes landable. ⛔ Do not delete the claim to make the block green. - - // Inserted into the user's own document the moment the completion is - // accepted, which is the most direct form a producer takes: the author does - // not even type the spelling. - const completion = readCode('packages/vscode-extension/src/providers/CompletionProvider.ts'); - expect(completion, 'lit control — the snippet table is being read at all').toContain( - '"className": "$1"' - ); - expect(completion).toContain('"body": {'); - - // A shipped `defaultProps` child list, the same construct as the three - // registrations objectui#7181 moved — spelled on a tab ITEM, where the - // renderer's canonical key is `content`. ⛔ Whether it belongs to - // objectui#6771's ruled family is NOT decided here; that it still ships the - // spelling is what is asserted. - const tabs = readCode('packages/components/src/renderers/layout/tabs.tsx'); - expect(tabs, 'lit control — the defaultProps block is being read at all').toContain( - 'defaultProps:' - ); - expect(tabs).toMatch(/value:\s*'tab1',\s*body:\s*\[/); - }); + // ⭐ objectui#9871 REPLACED the enumeration that used to stand here. Two + // named sites were an enumeration with the census's blind spots written + // into it by hand — the next producer in a third blind direction would have + // needed somebody to notice it and add a line. The subject is now DERIVED: + // `scripts/body-dialect-producer-scan.mjs` states a CRITERION (C1 emission + // · C2 resolution · C3 carrier) and this block reads its table. + // + // ⭐ When the table empties, THAT is the handoff, and the message on the + // assertion says so in these terms — it is the day step 4 becomes landable. + // ⛔ Do not delete the claim to make the block green. + const producerScan = scan(REPO_ROOT); + + // Two lit controls, because an empty table is the assertion's own shape: a + // scan that walked nothing, or derived no reader, would satisfy a bare + // "non-empty" check by accident in neither direction — so both are checked + // before the table is read at all. + expect(producerScan.filesScanned, 'the producer scan walked nothing').toBeGreaterThan(0); + expect( + producerScan.readers.reads.length, + 'the producer scan derived NO reader, so C2 would fail for every site' + ).toBeGreaterThan(0); + + const producers = producersOf(producerScan.hits, producerScan.readers); + expect( + producers.length, + 'the producer table is EMPTY — under objectui#9871\'s criterion nothing in shipped source ' + + 'emits the dialect any more, which is the day objectui#6771 step 4 becomes landable. ' + + 'Re-point this block and say so in those terms; ⛔ do not delete the claim.' + ).toBeGreaterThan(0); + + // ⭐ The two SHAPES the census structurally cannot reach, asserted as + // shapes rather than as paths: a producer arriving in a file nobody has + // named joins `producers` with no list to extend. + expect( + producers.filter((hit: { carrier: string }) => hit.carrier === 'item').length, + 'no item-carried producer — the B1 shape (a `body` on an object with no `type`)' + ).toBeGreaterThan(0); + expect( + producers.filter((hit: { source: string }) => hit.source !== 'code-key').length, + 'no string-carried producer — the B2 shape (the key spelled inside a literal)' + ).toBeGreaterThan(0); + + // ⚠️ And the family question stays OPEN in the instrument rather than being + // settled by it: an item-carried `body` is filed `unruled` and is never + // folded into objectui#6771's ruled total. objectui#9871 handed that + // question back rather than extending a ruled family from a dev seat. + for (const hit of producers.filter((h: { carrier: string }) => h.carrier === 'item')) { + expect(hit.disposition).toBe('unruled:item-carrier'); + } + // ⏱ Explicit, because this block runs TWO tree-wide instruments (the census + // over 7,855 files and the producer scan over 5,136) and the default 15s is + // not enough on a loaded shard: measured 7.9s here after objectui#9871 made + // the scan single-pass, and the CI runner took the SAME block past 15s when + // it measured 10.1s locally — so that runner is at least 1.9x slower. 60s is + // ~3x the observed CI-scale cost: ordinary contention cannot flake it, and a + // scan that stops terminating still fails. ⛔ Not a global `testTimeout` bump. + }, 60_000); }); /** diff --git a/scripts/__tests__/body-dialect-producer-scan.test.ts b/scripts/__tests__/body-dialect-producer-scan.test.ts new file mode 100644 index 0000000000..bc4ebea62c --- /dev/null +++ b/scripts/__tests__/body-dialect-producer-scan.test.ts @@ -0,0 +1,572 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Pins for `scripts/body-dialect-producer-scan.mjs` (objectui#9871). + * + * ⭐ The differential control in this file is THE OTHER INSTRUMENT. Most blocks + * plant one corpus and run BOTH `scripts/body-dialect-census.mjs` and the + * producer scan over it, asserting they DISAGREE — because "the scan sees more" + * is the claim, and a scan asserted only against itself cannot fail that way. + * + * ⛔ The value each ablation and each differential is taken on is deliberately + * NOT the headline one. A `body` on an object that already carries a + * string-literal `type` is a value both implementations agree about, so a test + * built on it is green on the census's classifier and on this one alike. The + * values here are the ones they disagree about: a `body` on an object with NO + * `type`, and a `body` spelled INSIDE a string literal. + */ + +import { afterAll, describe, it, expect } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { + scan, + deriveReaders, + producersOf, + unchannelledOf, + passesC2, + dispositionOf, + valueKindAt, + finalVerdict, + walkFrames, + enclosingScope, + CRITERION, + NOT_A_PRODUCER, + KNOWN_LIMITS, + EMISSION_CHANNELS, + CANDIDATE_FILE, + DECLARATION_SEGMENTS, + DATA_VALUE_KINDS, + EXIT_UNREADABLE, +} from '../body-dialect-producer-scan.mjs'; +import { census } from '../body-dialect-census.mjs'; +import { scanSource, blank } from '../js-comment-mask.mjs'; + +const REPO_ROOT = join(__dirname, '..', '..'); +const read = (p: string) => readFileSync(join(REPO_ROOT, p), 'utf8'); + +const roots: string[] = []; +afterAll(() => { + for (const root of roots) rmSync(root, { recursive: true, force: true }); +}); + +/** + * A planted tree. Every corpus needs a READER too: C2 is applied, not assumed, + * so a corpus with no reader is refused rather than reported as empty — which + * is itself pinned below. + */ +const READER_FILE = ` +import { renderChildren } from '../../lib/utils'; +export const Sidebar = ({ schema }) =>
{renderChildren(schema.body)}
; +export const Tabs = ({ schema }) => schema.items?.map((item) => renderChildren(item.content || (item as any).body)); +`; + +function plant(files: Record) { + const root = mkdtempSync(join(tmpdir(), 'body-dialect-producer-9871-')); + roots.push(root); + const all = { 'packages/reader/src/readers.tsx': READER_FILE, ...files }; + for (const [rel, text] of Object.entries(all)) { + const full = join(root, rel); + mkdirSync(dirname(full), { recursive: true }); + writeFileSync(full, text); + } + return root; +} + +describe('⭐ B1 — the classifier does NOT require a sibling `type`', () => { + /** + * The census scores a child list only on an object that also carries a + * string-literal `type`. A tab ITEM carries `label` / `value` / `body` and no + * `type`, so it is PERMANENTLY invisible to that key — not missed, unreachable. + */ + const ITEM_PRODUCER = ` +import { ComponentRegistry } from '@object-ui/core'; +ComponentRegistry.register('tabs', TabsRenderer, { + defaultProps: { + items: [ + { label: 'Tab 1', value: 'tab1', body: [{ type: 'text', content: 'x' }] }, + ], + }, +}); +`; + + it('DIFFERENTIAL — the census reads 0 on the item and the scan reads it as a producer', () => { + const root = plant({ 'packages/widget/src/tabs.tsx': ITEM_PRODUCER }); + + // ── The census, over the same bytes ────────────────────────────────── + const censusRun = census(root); + // Lit control FIRST: the census walked this tree and resolved the INNER + // node, so its zero below is about its classifier and not about a blind + // walk. Without this the assertion after it passes on an empty scan. + expect(censusRun.filesScanned).toBeGreaterThan(0); + expect(censusRun.hits.filter((h: { type: string }) => h.type === 'text').length).toBe(0); + const censusOnItem = censusRun.hits.filter( + (hit: { file: string; body: boolean }) => hit.file.includes('tabs') && hit.body, + ); + expect(censusOnItem).toEqual([]); + + // ── The producer scan, same bytes, same command ────────────────────── + const run = scan(root); + const producers = producersOf(run.hits, run.readers); + expect(producers.map((p: { carrier: string }) => p.carrier)).toEqual(['item']); + expect(producers[0].line).toBe(6); + expect(producers[0].channel).toBe('default-props'); + }); + + it('`type` is a CARRIER FACT, never an admission test — and any value counts', () => { + // The census requires a string LITERAL `type`. Here the value is an + // identifier, so the census leaves the frame unscored; the carrier is still + // recorded as a node because the key is present. + const text = '{ type: SOME_CONST, body: [] }'; + const { comment, literal } = scanSource(text); + const { hits } = walkFrames(blank(text, comment), literal); + expect(hits).toHaveLength(1); + expect(hits[0].hasType).toBe(true); + expect(hits[0].nodeType).toBeNull(); + // Lit control — a string-literal `type` still resolves its value. + const lit = '{ type: "badge", body: [] }'; + const f = scanSource(lit); + expect(walkFrames(blank(lit, f.comment), f.literal).hits[0].nodeType).toBe('badge'); + }); +}); + +describe('⭐ B2 — the scanner READS INSIDE string and template literals', () => { + const SNIPPET_PRODUCER = ` +export class CompletionProvider { + provide() { + const common = [{ name: 'body', desc: 'Child components' }]; + const item = new vscode.CompletionItem('x'); + item.insertText = new vscode.SnippetString('"body": {\\n $0\\n}'); + return [item]; + } +} +`; + + it('DIFFERENTIAL — the census reads 0 inside the literal and the scan reads two producers', () => { + const root = plant({ 'packages/vscode-extension/src/providers/CompletionProvider.ts': SNIPPET_PRODUCER }); + + const censusRun = census(root); + // Lit control — the census walked the tree. + expect(censusRun.filesScanned).toBeGreaterThan(0); + expect( + censusRun.hits.filter((hit: { file: string }) => hit.file.includes('CompletionProvider')), + ).toEqual([]); + + const run = scan(root); + const producers = producersOf(run.hits, run.readers).filter((p: { file: string }) => + p.file.includes('CompletionProvider'), + ); + expect(producers.map((p: { source: string }) => p.source).sort()).toEqual([ + 'key-name-datum', + 'literal-key-syntax', + ]); + for (const producer of producers) { + expect(producer.carrier).toBe('string'); + expect(producer.channel).toBe('snippet'); + // ⛔ A string-carried hit gets NO family disposition: the carrier is + // whatever node the author accepts it onto, which this tree cannot see. + expect(producer.disposition).toBe('carrier-undetermined'); + } + }); + + it('a spelling with NO emission verb in scope is teaching, ⛔ not a producer', () => { + // The same bytes inside a hover card. C1 asks whether the spelling LEAVES + // this tree into somebody's metadata; showing it in documentation does not. + const root = plant({ + 'packages/vscode-extension/src/providers/HoverProvider.ts': ` +export class HoverProvider { + provide() { + return new vscode.MarkdownString('Example:\\n"body": [ ... ]'); + } +} +`, + }); + const run = scan(root); + expect(producersOf(run.hits, run.readers)).toEqual([]); + // Lit control — it is SEEN, and reported; it is only held out of the table. + const unchannelled = unchannelledOf(run.hits, run.readers); + expect(unchannelled.map((h: { file: string }) => h.file.includes('HoverProvider'))).toContain(true); + }); +}); + +describe('C1 — emission, and the three things that are ⛔ not it', () => { + it('a DECLARED value is not emitted data, whatever the key is spelled', () => { + expect(valueKindAt('body: [1]', 5)).toBe('array'); + expect(valueKindAt('body: { a: 1 }', 5)).toBe('object'); + expect(valueKindAt("body: 'x'", 5)).toBe('string'); + expect(valueKindAt('body: z.union([])', 5)).toBe('expression'); + expect(valueKindAt('body: aliasKeyRefusal(x)', 5)).toBe('expression'); + expect(valueKindAt('body: _body,', 5)).toBe('expression'); + expect([...DATA_VALUE_KINDS].sort()).toEqual(['array', 'object', 'string', 'template']); + }); + + it('a zod tombstone is NOT a producer, while real data beside it IS', () => { + const root = plant({ + 'packages/types/src/zod/layout.zod.ts': ` +export const BoxSchema = BaseSchema.extend({ + type: z.literal('box'), + body: aliasKeyRefusal('body', 'children', 'box reads children'), +}); +`, + 'packages/widget/src/w.tsx': ` +ComponentRegistry.register('w', W, { defaultProps: { type: 'w', body: [{ type: 'text' }] } }); +`, + }); + const run = scan(root); + const producers = producersOf(run.hits, run.readers); + // Lit control in the SAME run — the emitting file IS scored, so the zero on + // the tombstone is about the criterion and not about a blind walk. + expect(producers.map((p: { file: string }) => p.file.split('\\').join('/'))).toEqual([ + 'packages/widget/src/w.tsx', + ]); + }); + + it('a registration `inputs` entry DECLARES the key and produces nothing', () => { + const root = plant({ + 'packages/components/src/renderers/overlay/tooltip.tsx': ` +ComponentRegistry.register('tooltip', T, { + inputs: [{ name: 'body', type: 'slot' }], + defaultProps: { type: 'tooltip', body: [{ type: 'text' }] }, +}); +`, + }); + const run = scan(root); + const producers = producersOf(run.hits, run.readers); + // The `inputs` datum is held out; the `defaultProps` data beside it — the + // lit control — is not, so this is the criterion discriminating rather than + // the file being skipped. + expect(producers).toHaveLength(1); + expect(producers[0].channel).toBe('default-props'); + expect(producers[0].carrier).toBe('node'); + expect(DECLARATION_SEGMENTS.some((re: RegExp) => re.test('inputs'))).toBe(true); + // ⛔ `properties` is deliberately NOT a declaration segment: the completion + // provider holds its offerings under exactly that name and then writes them. + expect(DECLARATION_SEGMENTS.some((re: RegExp) => re.test('properties'))).toBe(false); + }); + + it('a channel is recognised by a KEY PATH or an EMISSION VERB, never by a file name', () => { + const ids = EMISSION_CHANNELS.map((c: { id: string }) => c.id); + expect(ids).toEqual(['default-props', 'scaffold', 'snippet']); + for (const channel of EMISSION_CHANNELS as Array<{ kind: string }>) { + expect(['key-path', 'emission-verb']).toContain(channel.kind); + } + }); + + it('the `scaffold` channel fires — measured on a PLANT, because the tree has none today', () => { + // objectui#7181's six producers and objectui#9847's seventh have all + // migrated, so this channel currently matches ZERO live sites. A rule with + // no live instance is a rule nobody has run: it is exercised here instead. + const root = plant({ + 'packages/cli/src/commands/init.ts': ` +const templates = { + simple: { type: 'div', body: [{ type: 'text', content: 'hi' }] }, +}; +export function init() { writeFileSync('app.json', JSON.stringify(templates.simple)); } +`, + }); + const producers = (() => { + const run = scan(root); + return producersOf(run.hits, run.readers); + })(); + expect(producers).toHaveLength(1); + expect(producers[0].channel).toBe('scaffold'); + expect(producers[0].carrier).toBe('node'); + expect(producers[0].nodeType).toBe('div'); + // ⛔ And the verb is real: the same bytes with the write removed produce + // nothing, so `scaffold` is not matching on the word `templates`. + const inert = plant({ 'packages/cli/src/commands/init.ts': ` +const templates = { simple: { type: 'div', body: [{ type: 'text', content: 'hi' }] } }; +export function describeOnly() { return JSON.stringify(templates.simple); } +` }); + const inertRun = scan(inert); + expect(producersOf(inertRun.hits, inertRun.readers)).toEqual([]); + }); +}); + +describe('C2 — applied, ⛔ not merely derived', () => { + it('an HTTP payload `body` fails C2, and the SAME bytes with a node carrier pass', () => { + const root = plant({ + 'packages/data/src/client.ts': ` +export async function send() { + return fetch(url, { method: 'POST', body: { name: 'x' } }); +} +`, + 'packages/widget/src/w.tsx': ` +ComponentRegistry.register('w', W, { defaultProps: { type: 'w', body: [{ type: 'text' }] } }); +`, + }); + const run = scan(root); + const failed = run.hits.filter( + (hit: { file: string }) => hit.file.split('\\').join('/') === 'packages/data/src/client.ts', + ); + expect(failed).toHaveLength(1); + expect(failed[0].carrier).toBe('unknown'); + expect(passesC2(failed[0], run.readers)).toBe(false); + // Lit control — the node-carried twin in the same run passes. + const node = run.hits.find((hit: { carrier: string }) => hit.carrier === 'node'); + expect(node).toBeDefined(); + expect(passesC2(node, run.readers)).toBe(true); + }); + + it('the reader set is DERIVED, and prose ABOUT a read is ⛔ not a read', () => { + const root = plant({ + 'packages/types/src/zod/data-display.zod.ts': ` +export const ListSchema = z.object({}).describe( + 'the renderer draws each entry as item.content || renderChildren(item.body), a read filed under ListItem', +); +`, + }); + const readers = deriveReaders(root); + // The planted reader file is the lit control: the derivation is working, so + // the zero on the docstring is about the literal mask and not about a dead + // instrument. + expect(readers.reads.length).toBeGreaterThan(0); + expect(readers.reads.filter((r: { file: string }) => r.file.includes('data-display'))).toEqual([]); + }); + + it('a corpus with NO reader is REFUSED, not reported as a clean zero', () => { + const root = mkdtempSync(join(tmpdir(), 'body-dialect-producer-noreader-')); + roots.push(root); + const full = join(root, 'packages/widget/src/w.tsx'); + mkdirSync(dirname(full), { recursive: true }); + writeFileSync(full, "const x = { type: 'w', body: [] };\n"); + const run = scan(root); + expect(run.readers.reads).toEqual([]); + const verdict = finalVerdict({ filesScanned: run.filesScanned, readers: run.readers }); + expect(verdict.exit).toBe(EXIT_UNREADABLE); + expect(verdict.refusal?.join(' ')).toContain('Zero readers derived'); + + // Lit control, and the point of the whole card: a real ZERO over a real + // corpus with a real reader is a LEGITIMATE reading and is NOT refused — + // it is the day objectui#6771 step 4 becomes landable. + const clean = scan(plant({})); + expect(producersOf(clean.hits, clean.readers)).toEqual([]); + expect(finalVerdict({ filesScanned: clean.filesScanned, readers: clean.readers }).exit).toBe(0); + }); +}); + +describe('⚠️ C3 — the scan REPORTS the unruled family question and ⛔ does not answer it', () => { + it('an item carrier is filed `unruled` and is NEVER folded into the ruled total', () => { + expect(dispositionOf('node')).toBe('ruled:6771'); + expect(dispositionOf('item')).toBe('unruled:item-carrier'); + expect(dispositionOf('string')).toBe('carrier-undetermined'); + + const root = plant({ + 'packages/widget/src/tabs.tsx': ` +ComponentRegistry.register('tabs', T, { + defaultProps: { + items: [{ label: 'a', value: 'a', body: [{ type: 'text' }] }], + body: [{ type: 'text' }], + }, +}); +`, + }); + const run = scan(root); + const producers = producersOf(run.hits, run.readers); + const byDisposition = producers.map((p: { disposition: string }) => p.disposition).sort(); + // Both carriers present in ONE run, so the split is a discrimination and + // not an artifact of a corpus that only held one kind. + // ⭐ A `defaultProps.body` is NODE-carried even with no sibling `type` — + // the node is the registration, and its `type` is the registered key. So + // one run yields BOTH dispositions, which is what makes the split a + // discrimination rather than an artifact of a one-shaped corpus. + expect(byDisposition).toEqual(['ruled:6771', 'unruled:item-carrier']); + const ruled = producers.find((p: { disposition: string }) => p.disposition === 'ruled:6771'); + expect(ruled.nodeType).toBe('tabs'); + }); + + it('the item-array key set is DERIVED from the reader, not named here', () => { + const readers = deriveReaders(plant({})); + expect(readers.itemArrayKeys).toEqual(['items']); + expect(readers.receivers).toContain('item'); + expect(readers.receivers).toContain('schema'); + }); +}); + +describe('the criterion TRAVELS with the reading', () => { + it('every declaration is emitted, machine-readable and unique', () => { + for (const list of [CRITERION, NOT_A_PRODUCER, KNOWN_LIMITS]) { + const ids = (list as Array<{ id: string; what: string }>).map((l) => l.id); + expect(ids.length).toBeGreaterThan(0); + expect(new Set(ids).size).toBe(ids.length); + for (const entry of list as Array<{ what: string }>) expect(entry.what.length).toBeGreaterThan(0); + } + expect(CRITERION.map((c: { id: string }) => c.id)).toEqual([ + 'C1-emission', + 'C2-resolution', + 'C3-carrier', + ]); + }); + + it('CLI — the payload always carries the criterion, and a refusal reaches the exit code', () => { + const script = join(REPO_ROOT, 'scripts', 'body-dialect-producer-scan.mjs'); + const run = (args: string[]) => spawnSync(process.execPath, [script, ...args], { encoding: 'utf8' }); + + const ok = run(['--root', plant({ 'packages/w/src/w.tsx': "const x = { type: 'w', body: [] };\n" }), '--json']); + expect(ok.status).toBe(0); + const payload = JSON.parse(ok.stdout) as { + criterion: Array<{ id: string }>; + notAProducer: Array<{ id: string }>; + knownLimits: Array<{ id: string }>; + readers: { reads: unknown[] }; + }; + // ⭐ There is no spelling of this payload that reports a table without + // reporting what made it a table: no flag suppresses any of the three. + expect(payload.criterion.map((c) => c.id)).toContain('C2-resolution'); + expect(payload.notAProducer.map((c) => c.id)).toContain('declaration'); + expect(payload.knownLimits.map((c) => c.id)).toContain('unruled-item-carrier'); + expect(payload.readers.reads.length).toBeGreaterThan(0); + + const blind = run(['--root', join(REPO_ROOT, 'no-such-directory-9871'), '--json']); + expect(blind.status).toBe(EXIT_UNREADABLE); + expect(blind.stdout).toBe(''); + expect(blind.stderr).toContain('Zero files scanned'); + }); + + it('is wired into the root package.json, like every other instrument here', () => { + const pkg = JSON.parse(read('package.json')) as { scripts: Record }; + expect(pkg.scripts['census:body-dialect-producers']).toBe( + 'node scripts/body-dialect-producer-scan.mjs', + ); + }); +}); + +describe('⭐ the cheap pre-filter is a SUPERSET, so skipping cost never skips a hit', () => { + /** + * ⚠️ A pre-filter that is not a superset is a SILENT NARROWING — a scanner + * that quietly stops reading files is the defect this whole card exists to + * end. So the claim is pinned per hit SHAPE rather than asserted once, and + * the whole-tree hit-set equality is on objectui#9871's PR. + */ + it('fires for every shape the expensive path can find', () => { + for (const [shape, source] of [ + ['code-key, bare', "const x = { type: 'w', body: [] };"], + ['code-key, quoted (JSON)', '{ "type": "w", "body": [] }'], + ['code-key, spaced', 'const x = { body : [] };'], + ['literal-key-syntax', `item.insertText = new vscode.SnippetString('"body": {}');`], + ['key-name-datum', "const common = [{ name: 'body', desc: 'Child components' }];"], + ['key-name-datum, double-quoted', 'const common = [{ name: "body" }];'], + ] as const) { + expect(CANDIDATE_FILE.test(source), `pre-filter drops ${shape}`).toBe(true); + } + }); + + it('⚠️ BOTH alternatives are load-bearing — the datum has a COMMA, not a colon', () => { + // The first alternative alone would drop the sharpest producer in the tree: + // `{ name: 'body', desc: … }` has no colon after the quoted key. + const datum = "const common = [{ name: 'body', desc: 'Child components' }];"; + expect(/body\s*["']?\s*:/.test(datum), 'the colon alternative alone matches').toBe(false); + expect(/["']body["']/.test(datum)).toBe(true); + expect(CANDIDATE_FILE.test(datum)).toBe(true); + }); + + it('does NOT fire on the `body` population that carries no key', () => { + for (const source of [ + 'const n = schema.bodyExtra;', + '// the response body is parsed downstream', + 'function nobody() { return 1; }', + 'const b = res.body;', + ]) { + expect(CANDIDATE_FILE.test(source), `pre-filter fires on ${source}`).toBe(false); + } + }); + + it('⭐ OVER-accepts by design — a superset may say yes too often, NEVER too rarely', () => { + // `const variant = 'body'` is a quoted `body` that is not a key, and the + // pre-filter admits it. That is CORRECT: the cheap side may only ever be + // wider than the expensive side. The expensive path then finds nothing + // there, which costs a little time and ⛔ never a hit. + expect(CANDIDATE_FILE.test("const variant = 'body';")).toBe(true); + const root = plant({ 'packages/noise/src/t.ts': "export const variant = 'body';\n" }); + const run = scan(root); + expect(run.hits.filter((h: { file: string }) => h.file.includes('noise'))).toEqual([]); + // Lit control — the same run DOES resolve the planted reader file, so the + // zero above is about this file and not about a walk that read nothing. + expect(run.filesScanned).toBeGreaterThan(0); + expect(run.readers.reads.length).toBeGreaterThan(0); + }); + + it('DIFFERENTIAL — the same planted corpus yields the same hits with the filter in place', () => { + // Lit control on the cheap side: the corpus DOES produce a hit, so the + // equality below is between two non-empty readings and not two blanks. + const root = plant({ + 'packages/widget/src/w.tsx': ` +ComponentRegistry.register('w', W, { defaultProps: { type: 'w', body: [{ type: 'text' }] } }); +`, + 'packages/noise/src/http.ts': ` +export const send = () => fetch(url, { method: 'POST' }); +// the response body is parsed downstream, and schema.bodyExtra is a different key +`, + }); + const run = scan(root); + expect(run.hits.length).toBeGreaterThan(0); + // Every hit came from a file the pre-filter admits — the property the + // superset argument rests on, checked against the real hits. + for (const hit of run.hits) { + const text = readFileSync(join(root, hit.file), 'utf8'); + expect(CANDIDATE_FILE.test(text), `${hit.file} produced a hit the pre-filter would drop`).toBe(true); + } + }); +}); + +describe('the emission verb is scoped to the enclosing FUNCTION, not to the file', () => { + it('a verb in a different function does not vouch for this one', () => { + const text = ` +function writes() { editor.insertText('x'); } +function declares() { return { name: 'body' }; } +`; + const { comment, literal } = scanSource(text); + const { frames } = walkFrames(blank(text, comment), literal); + const offset = text.indexOf("name: 'body'"); + const scope = enclosingScope(frames, offset); + expect(scope.length).toBeGreaterThan(0); + const scopeText = text.slice(scope[scope.length - 1].start, scope[scope.length - 1].end); + expect(scopeText).toContain("name: 'body'"); + // The lit control is the other half of the same file, which DOES hold a verb. + expect(scopeText).not.toContain('insertText'); + expect(text).toContain('insertText'); + }); +}); + +describe('the LIVE tree, read through the criterion', () => { + it('the producer table is DERIVED and reaches both shapes the census cannot', () => { + const run = scan(REPO_ROOT); + // Two lit controls: the walk resolved files, and C2 derived readers — so a + // zero below would be a reading and not a blind instrument. + expect(run.filesScanned).toBeGreaterThan(0); + expect(run.readers.reads.length).toBeGreaterThan(0); + + const producers = producersOf(run.hits, run.readers); + const files = new Set(producers.map((p: { file: string }) => p.file.split('\\').join('/'))); + + // ⭐ Asserted as SHAPES, not as a count and not as a list of paths: a + // producer arriving in a file nobody named appears in `producers` without + // anyone extending anything, which is the whole point of this card. + expect(producers.filter((p: { carrier: string }) => p.carrier === 'item').length).toBeGreaterThan(0); + expect(producers.filter((p: { source: string }) => p.source !== 'code-key').length).toBeGreaterThan(0); + expect(files.size).toBeGreaterThan(0); + + // ⭐ And the claim that outlives every one of them: while the table is + // non-empty, objectui#6771 step 4 is not landable. ⛔ When this reds + // because the table emptied, that is the HANDOFF — say so here in those + // terms rather than deleting the assertion. + expect( + producers.length, + 'the producer table is EMPTY — under this criterion nothing ships the dialect, which is ' + + 'the day objectui#6771 step 4 becomes landable. Re-point this block, do not delete it.', + ).toBeGreaterThan(0); + // ⏱ Explicit — one tree-wide scan over 5,136 files, measured 4.0s here; the + // CI shard is at least 1.9x slower (objectui#9871's timeout reading), and + // the default 15s leaves too little room on a loaded shard. Same 60s and the + // same reason as the census block. ⛔ Not a global `testTimeout` bump. + }, 60_000); +}); diff --git a/scripts/body-dialect-producer-scan.mjs b/scripts/body-dialect-producer-scan.mjs new file mode 100644 index 0000000000..035c746541 --- /dev/null +++ b/scripts/body-dialect-producer-scan.mjs @@ -0,0 +1,964 @@ +#!/usr/bin/env node +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `body` dialect PRODUCER scan (objectui#9871). + * + * ## The question this answers, and the one it refuses + * + * ⛔ NOT "how many producers are left". That question is what this file exists + * to end. objectui#7181 named six producers, objectui#9847 found a seventh the + * table never listed, and objectui#9871's retriage found two more — while + * `pnpm census:body-dialect` read CLEAN (`app-metadata` 1 → 0) across all of + * it. The two latest sites share something sharper than "they were missed": + * ⭐ NO instrument in the tree could have found them. + * + * ⇒ the question is **WHAT MAKES THE TABLE COMPLETE**, and the answer has to be + * a CRITERION a future site can be held against — ⛔ not a list a future site + * has to be remembered onto. + * + * ## ⭐ THE CRITERION — what counts as a producer of the `body` child-list dialect + * + * A site is a PRODUCER iff C1 and C2 both hold. C3 is not a qualifier; it is + * the axis the answer is FILED under, and it is recorded rather than assumed. + * + * C1 — EMISSION. The site causes the literal key `body` to appear in metadata + * that a party OUTSIDE this tree then owns. Three channels are measured + * (`EMISSION_CHANNELS`): `default-props` (a registration's declared + * default, materialised into the author's page when the component is + * dropped), `scaffold` (written into files a tool creates), and + * `snippet` (inserted into the author's already-open document). + * + * C2 — RESOLUTION. Some reader in this tree resolves a `body` off that + * carrier INTO A CHILD LIST. This is what separates the dialect from + * every unrelated `body`: an HTTP payload, `bodyExtra`, the `body` + * typography variant. ⭐ The reader set is DERIVED from source on every + * run (`deriveReaders`) — ⛔ never enumerated here, because an + * enumeration is the defect this file replaces. + * + * C3 — CARRIER. Which object the key is spelled ON, because that decides + * WHICH PUBLISHED CONTRACT the site is judged against: + * `node` — the object is a `BaseSchema` node. `body` is a DECLARED + * twin of `children` there (`packages/types/src/base.ts`). + * This is objectui#6771's ruled family. + * `item` — the object is a member of a registration's declared item + * array (`TabItem`, `ListItem`). ⚠️ Its canonical child-list + * key is `content`, and `body` is declared on NEITHER + * published face. ⛔ UNRULED — see "The open question". + * `string` — the spelling is carried by a string the tool writes into + * the author's document. The carrier is whatever node the + * author accepts it onto, which this instrument does not + * see. Reported as such, ⛔ never guessed. + * + * ## ⛔ What the criterion deliberately EXCLUDES, so a zero is readable + * + * `NOT_A_PRODUCER` below is the machine-readable form and every run emits it. + * The four exclusions, and why each is not appetite: + * + * - a DECLARATION (`inputs: [{ name: 'body' }]`, a published type member) + * TEACHES the spelling and writes nothing. It is a real surface and a real + * problem — it is simply a different one, and folding it in would make the + * producer count unreadable. + * - a READER (`renderChildren(schema.body)`) consumes; it does not emit. The + * readers are C2's instrument, so scoring them as producers would make this + * scan report its own denominator. + * - a `body` that no reader resolves as a child list fails C2 outright. + * - authored CORPUS, teaching snippets and fixtures are the dialect's + * VICTIMS, not its producers. They are bucketed and reported apart. + * + * ## ⭐ The two blindnesses this closes, and they are STRUCTURAL not incidental + * + * `scripts/body-dialect-census.mjs` scores a child list only on an object that + * ALSO carries a string-literal `type`, and never reads inside a string or + * template literal. Both limits are DECLARED there — ⛔ this is not a bug + * report against the census. They are the two directions a classifier keyed on + * "object with a `type`" is PERMANENTLY blind in, and each has a live site: + * + * B1 — no sibling `type` is required here. A frame is scored on the `body` + * key ITSELF, so a non-node item (`{ label, value, body: [...] }`) is + * visible. `walkFrames` records `type` as a CARRIER fact, ⛔ never as an + * admission test — and it accepts a `type` of ANY value, where the + * census requires a string literal. + * B2 — literals are READ, not skipped. `scanSource` (the shared masker, + * `scripts/js-comment-mask.mjs`) is projected TWICE over one scan: + * comments-and-literals blanked for the structural walk, comments-only + * blanked for the key sweep. The `literal` flag is then what TELLS THE + * TWO APART — a key token at a literal-flagged offset is a spelling + * inside a string, which is exactly B2's population. + * + * ⚠️ Both projections come from ONE `scanSource` pass, ⛔ never a private + * comment stripper: a regex has no idea what a string literal is, and this file + * would be the last place that could afford to get literals wrong. + * + * ## ⚠️ The open question this scan REPORTS and ⛔ does not answer + * + * Is an `item`-carried `body` inside objectui#6771's ruled family? + * + * ⛔ UNRULED, and this instrument does not rule it. It files `item` hits in + * their own table and REFUSES to fold them into the ruled total, because every + * measured fact points at a DIFFERENT contract rather than the same one: + * #6771's remedy is "`children` is the one spelling" and its step 3 retires + * `body` from `BaseSchema` — while the item face declares `content` (required + * on `TabItem`, optional on `ListItem`), declares no `body` on either the TS or + * the Zod face, and objectui#9256 filed the item channel separately and pinned + * it LIVE on purpose. Deciding it here would EXTEND a ruled family rather than + * apply one. The measurements are on objectui#9871. + * + * Usage: + * node scripts/body-dialect-producer-scan.mjs + * node scripts/body-dialect-producer-scan.mjs --root . --json + */ + +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, relative, extname, sep } from 'node:path'; + +import { scanSource, blank } from './js-comment-mask.mjs'; +import { isEntrypoint } from './invoked-as.mjs'; + +/** ⭐ The criterion, machine-readable. Emitted by every run, both modes. */ +export const CRITERION = [ + { + id: 'C1-emission', + what: + 'The site causes the literal key `body` to appear in metadata a party OUTSIDE this tree then ' + + 'owns. Channels measured: ' + '`default-props`, `scaffold`, `snippet`.', + }, + { + id: 'C2-resolution', + what: + 'Some reader in this tree resolves a `body` off that carrier INTO A CHILD LIST. The reader set ' + + 'is DERIVED per run, never enumerated — an enumeration is the defect this scan replaces.', + }, + { + id: 'C3-carrier', + what: + 'Which object the key is spelled ON — `node` (a `BaseSchema` node, objectui#6771\'s ruled ' + + 'family), `item` (a member of a declared item array, whose canonical key is `content`; ' + + 'UNRULED), or `string` (carried into the author\'s document, carrier not visible here). ' + + 'Recorded, never assumed; it decides which published contract judges the site.', + }, +]; + +/** ⛔ What the criterion excludes. A zero is only readable beside this. */ +export const NOT_A_PRODUCER = [ + { + id: 'declaration', + what: + 'An `inputs: [{ name: \'body\' }]` entry or a published type member TEACHES the spelling and ' + + 'writes nothing. A real surface, a DIFFERENT one — folding it in makes the count unreadable.', + }, + { + id: 'reader', + what: + 'A `renderChildren(x.body)` read CONSUMES the key. Readers are C2\'s instrument, so scoring ' + + 'them here would make this scan report its own denominator.', + }, + { + id: 'unresolved-body', + what: + 'A `body` no derived reader resolves as a child list fails C2: an HTTP payload, `bodyExtra`, ' + + 'the `body` typography variant.', + }, + { + id: 'authored-corpus', + what: + 'Fixtures, tests, docs and example apps AUTHOR the dialect — they are its victims, not its ' + + 'producers. Bucketed and reported apart, never summed into the producer table.', + }, +]; + +/** Every way a zero here is NOT "no producer ships the dialect". */ +export const KNOWN_LIMITS = [ + { + id: 'channel-key-path', + what: + 'An emission channel is recognised by the KEY PATH the frame sits under (`EMISSION_CHANNELS`). ' + + 'A producer that emits from a path none of those patterns name is scored `channel: null` and ' + + 'reported under `unclassified` — visible, but NOT in the producer table.', + }, + { + id: 'string-carrier', + what: + 'A `string`-carried hit has no carrier object in this tree: the author\'s acceptance decides ' + + 'it. So it is a producer with NO family disposition, and asking this scan for one is asking ' + + 'the wrong instrument.', + }, + { + id: 'item-array-derivation', + what: + 'The item-array key set is derived from `schema..map(` / `Array.isArray(schema.)` in ' + + 'the files that read a non-`schema` receiver. An item array reached any other way leaves its ' + + 'members scored `unknown` rather than `item`.', + }, + { + id: 'skipped-directory', + what: + '`SKIP_DIRS` are never walked, and only `SCANNED_EXT` are read. Absence under either is an ' + + 'exclusion, not a reading.', + }, + { + id: 'unruled-item-carrier', + what: + '`item` hits are NOT folded into the ruled total. Whether they belong to objectui#6771 is ' + + 'unruled, and this instrument reports the question rather than answering it.', + }, +]; + +const SCANNED_EXT = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']); +const SKIP_DIRS = new Set([ + 'node_modules', '.git', 'dist', 'build', 'coverage', '.next', '.turbo', 'out', + '.pnpm-store', 'playwright-report', 'test-results', '.objectui-tmp', +]); + +/** + * How a key path is recognised as an EMISSION channel (C1). + * + * Matched against the chain of keys that OPENED the enclosing frames, so + * `defaultProps.items` and `templates.simple.body` both resolve without anyone + * naming a file. ⭐ A file allow-list would be the enumeration this scan exists + * to replace, one level up. + */ +export const EMISSION_CHANNELS = [ + { id: 'default-props', kind: 'key-path', segment: /^defaultProps$/ }, + { + // ⭐ Recognised by an EMISSION VERB, ⛔ never by a file name. A file + // allow-list would be the enumeration this scan exists to replace, one + // level up: the next scaffold would have to be remembered onto it. + id: 'scaffold', + kind: 'emission-verb', + verb: /\b(?:writeFileSync|writeFile|outputFileSync|outputFile|createFile)\s*\(/, + }, + { + id: 'snippet', + kind: 'emission-verb', + verb: /\b(?:insertText|SnippetString|TextEdit|WorkspaceEdit|applyEdit)\b/, + }, +]; + +/** + * Key paths that mark a DECLARATION rather than an emission (⛔ not a producer). + * + * ⚠️ `properties` is deliberately ABSENT. The VS Code completion provider holds + * its offerings under exactly that key and then WRITES them into the author's + * document, so excluding the name would have hidden the sharpest producer in + * the tree behind a word. Declaration is decided by `inputs` — the registration + * input list — and by the absence of an emission verb, ⛔ never by a key name + * that merely sounds descriptive. + */ +export const DECLARATION_SEGMENTS = [/^inputs$/]; + +/** + * ⭐ The cheap NECESSARY CONDITION for a file to hold a hit at all. + * + * ⛔ This is NOT a narrowing of the corpus, and the difference matters: every + * file is still walked and still read. What it skips is the EXPENSIVE + * per-character analysis, and only for a file in which no hit shape could + * match — so the hit set is unchanged BY CONSTRUCTION. + * + * The superset argument, one line per shape this scanner can find, because a + * pre-filter that is not a superset is a silent narrowing and that is the + * defect this whole card exists to end: + * + * - `code-key` / `literal-key-syntax` match `("body"|'body'|body)` followed + * by optional space and `:` ⇒ the raw text holds `body`, an optional + * closing quote, optional space, `:` ⇒ the first alternative below. + * - `key-name-datum` matches `name|key|prop|property` `:` then `"body"` or + * `'body'` ⇒ the raw text holds a QUOTED `body` ⇒ the second. + * + * ⚠️ Both alternatives are load-bearing: a `{ name: 'body', desc: … }` datum + * has a COMMA after the quote, not a colon, so the first alternative alone + * would drop the sharpest producer in the tree. Measured, ⛔ not reasoned + * about — the tests pin one case per shape plus a negative control, and the + * whole-tree hit-set equality is recorded on objectui#9871's PR. + * + * Measured on this tree: 1852 files contain the substring `body`, 500 satisfy + * this predicate — 61% of the bytes never reach `scanSource`, and the hit-set + * digest is identical either way. + */ +export const CANDIDATE_FILE = /body\s*["']?\s*:|["']body["']/; + +// ── C2: derive the reader set ────────────────────────────────────────────── + +/** + * A read that resolves `body` INTO A CHILD LIST, in the two shapes this tree + * writes. Both are anchored on a child-list construct, ⛔ never on the bare key: + * `x.body` alone is not evidence of anything. + */ +const READ_SHAPES = [ + { + id: 'render-children', + // renderChildren( .body ) — including `(recv as any).body` and `recv?.body` + re: /renderChildren\s*\(\s*\(?\s*([A-Za-z_$][\w$]*)(?:\s+as\s+\w+)?\s*\)?\s*\??\.body\b/g, + }, + { + id: 'alternation', + // .children|content ||/?? .body (either order) + re: /([A-Za-z_$][\w$]*)\s*\??\.(?:children|content)\s*(?:\|\||\?\?)\s*(?:renderChildren\s*\(\s*)?\(?\s*\1(?:\s+as\s+\w+)?\s*\)?\s*\??\.body\b/g, + }, + { + id: 'alternation', + re: /([A-Za-z_$][\w$]*)\s*\??\.body\s*(?:\|\||\?\?)\s*\(?\s*\1(?:\s+as\s+\w+)?\s*\)?\s*\??\.(?:children|content)\b/g, + }, +]; + +/** `schema..map(` / `Array.isArray(schema.)` — where an item receiver comes from. */ +const ITEM_ARRAY_SHAPES = [ + /schema\s*\??\.([A-Za-z_$][\w$]*)\s*\??\.map\s*\(/g, + /Array\.isArray\s*\(\s*schema\s*\??\.([A-Za-z_$][\w$]*)\s*\)/g, +]; + +/** + * Derive C2's reader set from source. + * + * Returns the read sites, the receiver identifiers they read from, and the item + * array keys those non-`schema` receivers are drawn from. ⛔ Nothing here is + * hard-coded: a registration that starts resolving `body` joins the set on the + * next run, and one that stops leaves it. + */ +export function deriveReaders(root, files = null) { + const reads = []; + const receivers = new Set(); + const itemArrayKeys = new Set(); + let filesScanned = 0; + for (const file of files ?? walk(root)) { + let text; + try { + text = readFileSync(file, 'utf8'); + } catch { + continue; + } + filesScanned++; + if (!text.includes('.body')) continue; + const rel = relative(root, file); + // ⭐ C2 asks what the PLATFORM resolves, so the reader set is taken over + // shipped source only: a read inside a test or a fixture is that test's + // subject, not a reason the dialect renders for an author. + if (bucketOf(rel) !== 'shipped-source') continue; + const { comment, literal } = scanSource(text); + const code = blank(text, comment); + let sawNonSchema = false; + for (const shape of READ_SHAPES) { + shape.re.lastIndex = 0; + let m; + while ((m = shape.re.exec(code)) !== null) { + // ⚠️ A read is CODE. The same sentence inside a `.describe(...)` string + // or a docblock quote is prose about a read — scoring it would let this + // tree's own documentation manufacture C2 evidence for itself. + if (literal[m.index]) continue; + const receiver = m[1]; + reads.push({ file: rel, line: lineOf(text, m.index), receiver, shape: shape.id }); + receivers.add(receiver); + if (receiver !== 'schema') sawNonSchema = true; + } + } + if (sawNonSchema) { + for (const re of ITEM_ARRAY_SHAPES) { + re.lastIndex = 0; + let m; + while ((m = re.exec(code)) !== null) if (!literal[m.index]) itemArrayKeys.add(m[1]); + } + } + } + return { reads, receivers: [...receivers].sort(), itemArrayKeys: [...itemArrayKeys].sort(), filesScanned }; +} + +// ── The scanner: one `scanSource` pass, two projections ──────────────────── + +/** A key token in key position: `body:`, `"body":`, `'body':`. */ +const KEY_TOKEN = /^(?:"([A-Za-z_$][\w$-]*)"|'([A-Za-z_$][\w$-]*)'|([A-Za-z_$][\w$]*))\s*:/; + +/** `name: 'body'` — the key spelled as a DATUM that names a key (B2, second shape). */ +const KEY_NAME_DATUM = /\b(?:name|key|prop|property)\s*:\s*(?:"body"|'body')/g; + +/** + * Walk object/array frames, recording every `body` key with the frame that owns + * it, that frame's complete sibling key set, and the key path above it. + * + * ⭐ B1 lives here: the frame is scored on the `body` key ITSELF. `type` is + * recorded as a carrier FACT and is ⛔ never an admission test, so an object + * with no `type` — a tab item — is seen. `type` of ANY value counts, where the + * census requires a string literal. + * + * ⭐ ONE projection, not two. The brace stack needs the source with literals + * blanked and the key tokens need it with literals INTACT — but those two + * strings differ ONLY at literal-flagged offsets, which is precisely what + * `literal` already marks. So the structural character is read as a space + * wherever the flag is set, and the second `blank()` pass over every candidate + * file (~0.85s of pure `split('')`/`join('')` across this tree) goes away. + * Byte-identical by construction; the hit-set digest is pinned equal on + * objectui#9871's PR across the change. + * + * @param {string} keyText comments blanked, literals INTACT + * @param {Uint8Array} literal the flag that both blanks the brace stack and + * tells a code key from a spelling inside a string + */ +export function walkFrames(keyText, literal) { + const hits = []; + const frames = []; + const stack = []; + const n = keyText.length; + let i = 0; + let prevSignificant = ''; + let prevWord = ''; + + const finish = (frame) => { + frame.end = i; + frames.push(frame); + for (const pending of frame.pending) { + hits.push({ + ...pending, + siblingKeys: [...frame.keys], + hasType: frame.keys.has('type'), + nodeType: frame.typeValue ?? null, + }); + } + }; + + while (i < n) { + // A brace inside a string literal is not structure — the flag blanks it, + // exactly as a second masked projection used to. + const c = literal[i] ? ' ' : keyText[i]; + const top = stack[stack.length - 1]; + + if (top && top.isObject && /[A-Za-z_$"']/.test(keyText[i]) && !literal[i]) { + const m = KEY_TOKEN.exec(keyText.slice(i, i + 160)); + if (m) { + const key = m[1] ?? m[2] ?? m[3]; + top.keys.add(key); + top.lastKey = key; + if (key === 'type') { + const v = /^\s*(?:"([^"\\]*)"|'([^'\\]*)')/.exec(keyText.slice(i + m[0].length, i + m[0].length + 160)); + if (v) top.typeValue = v[1] ?? v[2]; + } + if (key === 'body') { + top.pending.push({ + offset: i, + carrierPath: stack.map((f) => f.openerKey).filter(Boolean), + source: 'code-key', + valueKind: valueKindAt(keyText, i + m[0].length), + }); + } + i += m[0].length; + prevSignificant = ':'; + prevWord = ''; + continue; + } + } + + if (c === '{' || c === '[') { + // ⭐ A `{` is an object literal OR a FUNCTION BODY, and the difference is + // load-bearing twice over: a block holds statements rather than keys, and + // it is the scope an emission verb has to be found in. A block opener + // follows `)` (a parameter list), `=>`, or one of the statement keywords. + const isBlock = + c === '{' && + (prevSignificant === ')' || + prevSignificant === '>' || + BLOCK_KEYWORDS.has(prevWord)); + const openerKey = top ? (top.isObject ? top.lastKey : top.openerKey) : null; + stack.push({ + isObject: c === '{' && !isBlock, + isBlock, + start: i, + end: n, + keys: new Set(), + pending: [], + openerKey: isBlock ? null : openerKey, + lastKey: null, + typeValue: null, + }); + i++; + prevSignificant = c; + prevWord = ''; + continue; + } + if (c === '}' || c === ']') { + const frame = stack.pop(); + if (frame) finish(frame); + i++; + prevSignificant = c; + prevWord = ''; + continue; + } + if (/[A-Za-z_$]/.test(c)) { + prevWord += c; + prevSignificant = c; + } else if (!/\s/.test(c)) { + prevWord = ''; + prevSignificant = c; + } + i++; + } + // An unbalanced tail still yields its hits — dropping them would turn a + // scanner limitation into a silent zero, which is this card's whole subject. + while (stack.length) finish(stack.pop()); + return { hits, frames }; +} + +/** + * What KIND of thing the `body` key is bound to — ⭐ C1's sharpest edge. + * + * A producer emits DATA: an array, an object, a string, a template. A + * DECLARATION binds an expression: `body: z.union(...)`, `body: + * aliasKeyRefusal(...)`, `body: c.body ?? ''`, `body: _body`, `body: unknown`. + * ⛔ Not a taste call — a validator, a destructured rename and a TS annotation + * put no `body` into anybody's metadata, so they cannot satisfy C1 however the + * key is spelled. + */ +export function valueKindAt(text, offset) { + let i = offset; + while (i < text.length && /\s/.test(text[i])) i++; + const c = text[i]; + if (c === '[') return 'array'; + if (c === '{') return 'object'; + if (c === '"' || c === "'") return 'string'; + if (c === '`') return 'template'; + return 'expression'; +} + +/** The value kinds that can carry emitted metadata. Anything else fails C1. */ +export const DATA_VALUE_KINDS = new Set(['array', 'object', 'string', 'template']); + +/** Statement keywords after which a `{` opens a BLOCK, not an object literal. */ +const BLOCK_KEYWORDS = new Set([ + 'else', 'try', 'finally', 'do', 'catch', 'return', +]); + +/** + * The frames enclosing `offset`, innermost first, stopping AT the nearest + * function body. + * + * ⭐ The stop is the point. An emission verb is evidence about the scope that + * emits, so searching past the enclosing function would let any verb anywhere + * in a file vouch for every `body` in it — which is a file allow-list wearing a + * different hat. + */ +export function enclosingScope(frames, offset) { + const containing = frames + .filter((f) => f.start <= offset && offset <= f.end) + .sort((a, b) => b.start - a.start); + const out = []; + for (const frame of containing) { + out.push(frame); + if (frame.isBlock) break; + } + return out; +} + +/** Bucket a file by WHAT KIND of artifact it is, so victims never sum with producers. */ +export function bucketOf(relPath) { + const p = relPath.split(sep).join('/'); + if (/(^|\/)__tests__\//.test(p) || /\.(test|spec)\.[cm]?[jt]sx?$/.test(p)) return 'test'; + if (/(^|\/)(__fixtures__|fixtures|__mocks__)\//.test(p)) return 'fixture'; + if (/(^|\/)e2e\//.test(p)) return 'e2e'; + if (/(^|\/)examples\//.test(p)) return 'example-app'; + if (/(^|\/)scripts\//.test(p)) return 'tooling'; + if (/(^|\/)(apps|packages)\//.test(p)) return 'shipped-source'; + return 'other'; +} + +function channelOf(carrierPath, scopeText) { + for (const channel of EMISSION_CHANNELS) { + if (channel.kind === 'key-path') { + for (const segment of carrierPath) if (channel.segment.test(segment)) return channel.id; + } else if (channel.kind === 'emission-verb') { + if (channel.verb.test(scopeText)) return channel.id; + } + } + return null; +} + +function isDeclaration(carrierPath) { + return carrierPath.some((segment) => DECLARATION_SEGMENTS.some((re) => re.test(segment))); +} + +/** + * C3 — which object the key is spelled ON. + * + * ⭐ `type` is a CARRIER FACT here and ⛔ never an admission test, which is the + * whole of B1: the census requires a sibling string-literal `type` before it + * will score a child list, and that requirement is what makes a tab item + * permanently invisible to it. A `type` of ANY value counts. + */ +function carrierOf(hit, itemArrayKeys) { + if (hit.source !== 'code-key') return 'string'; + if (hit.hasType) return 'node'; + const owner = hit.carrierPath[hit.carrierPath.length - 1]; + if (owner && itemArrayKeys.includes(owner)) return 'item'; + // ⭐ A `body` directly under `defaultProps` IS node-carried: the node is the + // registration, and its `type` is the registered key — which sits in + // `ComponentRegistry.register('', …)` rather than as a sibling of the + // `body`. Requiring a SIBLING `type` here is B1 all over again one frame up, + // and it is the shape objectui#7181's three `defaultProps` producers had. + if (hit.carrierPath.includes('defaultProps')) return 'node'; + return 'unknown'; +} + +/** `ComponentRegistry.register(''` — where a `defaultProps` node's `type` really lives. */ +const REGISTRATION = /ComponentRegistry\s*\.\s*register\s*\(\s*['"]([^'"]+)['"]/g; + +/** The key of the registration a hit sits inside, or null. */ +export function registrationAt(text, offset) { + REGISTRATION.lastIndex = 0; + let key = null; + let m; + while ((m = REGISTRATION.exec(text)) !== null) { + if (m.index > offset) break; + key = m[1]; + } + return key; +} + +/** ⭐ The disposition axis. `item` is UNRULED and this function says so. */ +export function dispositionOf(carrier) { + if (carrier === 'node') return 'ruled:6771'; + if (carrier === 'item') return 'unruled:item-carrier'; + if (carrier === 'string') return 'carrier-undetermined'; + return 'unknown'; +} + +/** + * Scan one tree for producers under the criterion. + * + * @param {string} root + * @param {{ readers?: ReturnType }} [options] + */ +export function scan(root, options = {}) { + // ONE directory traversal, shared by both phases. The reader derivation and + // the producer pass ask different questions of the same tree; walking it + // twice bought nothing but five thousand redundant `statSync` calls. + const files = [...walk(root)]; + const readers = options.readers ?? deriveReaders(root, files); + const hits = []; + let filesScanned = 0; + + for (const file of files) { + let text; + try { + text = readFileSync(file, 'utf8'); + } catch { + continue; + } + filesScanned++; + // ⭐ The cheap necessary condition — see `CANDIDATE_FILE`. Every file is + // still walked and read; only the per-character analysis is skipped, and + // only where no hit shape could match. + if (!CANDIDATE_FILE.test(text)) continue; + const rel = relative(root, file); + const bucket = bucketOf(rel); + + const { comment, literal } = scanSource(text); + const keyText = blank(text, comment); + + const { hits: frameHits, frames } = walkFrames(keyText, literal); + + const pathAt = (offset) => + enclosingScope(frames, offset) + .map((f) => f.openerKey) + .filter(Boolean) + .reverse(); + + const record = (hit) => { + const scope = enclosingScope(frames, hit.offset); + // ⭐ The scope an emission verb must be found in is the enclosing + // FUNCTION — and when there is none, it is the MODULE. A module-level + // template constant is written out by a function further down the file, + // and scoping its verb to the object literal it sits in would have made + // every scaffold of that shape invisible. That shape is not hypothetical: + // it is how `objectui init` held its templates. + const outermost = scope[scope.length - 1]; + const scopeText = outermost && outermost.isBlock + ? text.slice(outermost.start, outermost.end) + : text; + const carrierPath = hit.carrierPath ?? pathAt(hit.offset); + const carrier = carrierOf({ ...hit, carrierPath }, readers.itemArrayKeys); + const declaration = isDeclaration(carrierPath); + hits.push({ + file: rel, + line: lineOf(text, hit.offset), + bucket, + source: hit.source, + carrierPath, + siblingKeys: hit.siblingKeys ?? [], + carrier, + nodeType: + hit.nodeType + ?? (carrier === 'node' && carrierPath.includes('defaultProps') + ? registrationAt(text, hit.offset) + : null), + valueKind: hit.valueKind ?? 'string', + disposition: dispositionOf(carrier), + declaration, + channel: + declaration || !DATA_VALUE_KINDS.has(hit.valueKind ?? 'string') + ? null + : channelOf(carrierPath, scopeText), + }); + }; + + for (const hit of frameHits) record(hit); + + // ⭐ B2: the same key, spelled INSIDE a literal. The `literal` flag is the + // whole discriminator — a key token at a flagged offset is a spelling the + // tool may write into somebody's document, not a key in this tree's own + // code. Whether it IS written is C1, and C1 is the emission verb above: + // a hover card that merely SHOWS the spelling has no verb and no channel. + const inLiteralKey = /(?:"body"|'body'|\bbody)\s*:/g; + let m; + while ((m = inLiteralKey.exec(keyText)) !== null) { + if (!literal[m.index]) continue; + record({ offset: m.index, source: 'literal-key-syntax' }); + } + KEY_NAME_DATUM.lastIndex = 0; + while ((m = KEY_NAME_DATUM.exec(keyText)) !== null) { + record({ offset: m.index, source: 'key-name-datum' }); + } + } + + return { readers, filesScanned, hits }; +} + +/** + * ⭐ C2, APPLIED — ⛔ not merely derived. + * + * A `body` passes C2 when the derived reader set actually reads from a carrier + * of its class. The receivers those readers name are what decides it, so this + * is the criterion doing work rather than a sentence in a header: + * + * `node` — a `schema.body` reader exists ⇒ passes. + * `item` — an `item.body` reader exists, drawn from a declared item array + * ⇒ passes. + * `string` — the spelling is written into somebody's document as a key; the + * carrier it lands on is theirs ⇒ passes, with no disposition. + * `unknown` — neither a node nor a declared item member. NO derived reader + * resolves such an object's `body` into a child list, so it fails + * C2. ⭐ This is the entire HTTP-payload / `bodyExtra` population, + * and dropping it is the criterion working, not a blind spot — + * the count is REPORTED so the drop is never silent. + */ +export function passesC2(hit, readers) { + if (hit.carrier === 'node') return readers.receivers.includes('schema'); + if (hit.carrier === 'item') return readers.receivers.some((r) => r !== 'schema'); + if (hit.carrier === 'string') return readers.reads.length > 0; + return false; +} + +/** The producer table: C1 ∧ C2, with victims and declarations held out. */ +export function producersOf(hits, readers) { + return hits.filter( + (hit) => + hit.bucket === 'shipped-source' && + !hit.declaration && + passesC2(hit, readers) && + hit.channel !== null, + ); +} + +/** + * Shipped-source spellings that pass C2 but that no `EMISSION_CHANNELS` pattern + * claims. ⭐ This is where a NEW emission channel shows up as a row instead of + * as a silence — the failure this whole card is about. + */ +export function unchannelledOf(hits, readers) { + return hits.filter( + (hit) => + hit.bucket === 'shipped-source' && + !hit.declaration && + DATA_VALUE_KINDS.has(hit.valueKind) && + passesC2(hit, readers) && + hit.channel === null, + ); +} + +// ── Walk / util ──────────────────────────────────────────────────────────── + +function* walk(dir) { + let entries; + try { + entries = readdirSync(dir); + } catch { + return; + } + for (const entry of entries) { + if (SKIP_DIRS.has(entry)) continue; + const full = join(dir, entry); + let st; + try { + st = statSync(full); + } catch { + continue; + } + if (st.isDirectory()) yield* walk(full); + else if (st.isFile() && SCANNED_EXT.has(extname(entry))) yield full; + } +} + +function lineOf(text, offset) { + let line = 1; + for (let i = 0; i < offset && i < text.length; i++) if (text[i] === '\n') line++; + return line; +} + +/** + * Exit code for a run that REFUSES to report a number, kept distinct from 1 so + * "the instrument refused" and "the instrument crashed" are not one reading. + */ +export const EXIT_UNREADABLE = 2; + +/** + * Whether a run may report a table at all. + * + * ⭐ A zero PRODUCER count is a legitimate reading and is NOT refused — it is + * the day objectui#6771 step 4 becomes landable, and refusing it would delete + * the one answer this scan exists to be able to give. What IS refused is a run + * that could not have found anything: a blind walk, or a C2 instrument that + * derived no readers, in which case every `body` fails C2 for a reason that has + * nothing to do with the corpus. + */ +export function finalVerdict({ filesScanned, readers }) { + if (filesScanned === 0) { + return { + exit: EXIT_UNREADABLE, + refusal: [ + '✗ Zero files scanned. A scan that reads nothing because it is blind is', + ' indistinguishable from a tree that ships no producer. Check `--root`.', + ], + }; + } + if (readers.reads.length === 0) { + return { + exit: EXIT_UNREADABLE, + refusal: [ + '✗ Zero readers derived. C2 asks whether a reader resolves `body` into a', + ' child list; with no reader, every site fails C2 for a reason that has', + ' nothing to do with the corpus, and the producer table would read 0.', + ], + }; + } + return { exit: 0, refusal: null }; +} + +function printList(title, entries) { + console.log(`\n#### ${title}\n`); + for (const entry of entries) console.log(`- \`${entry.id}\` — ${entry.what}`); +} + +function main(argv = process.argv.slice(2)) { + const arg = (name, dflt) => { + const idx = argv.indexOf(name); + return idx >= 0 && argv[idx + 1] ? argv[idx + 1] : dflt; + }; + const root = arg('--root', '.'); + const asJson = argv.includes('--json'); + + const { readers, filesScanned, hits } = scan(root); + const verdict = finalVerdict({ filesScanned, readers }); + if (verdict.refusal) { + for (const line of verdict.refusal) console.error(line); + return verdict.exit; + } + + const producers = producersOf(hits, readers); + const unclassified = unchannelledOf(hits, readers); + const failedC2 = hits.filter( + (hit) => hit.bucket === 'shipped-source' && !hit.declaration && !passesC2(hit, readers), + ); + + if (asJson) { + // ⭐ The criterion travels WITH the reading. There is no spelling of this + // payload that reports a table without reporting what made it a table. + console.log( + JSON.stringify( + { + root, + criterion: CRITERION, + notAProducer: NOT_A_PRODUCER, + knownLimits: KNOWN_LIMITS, + readers, + filesScanned, + producers, + unclassified, + failedC2, + hits, + }, + null, + 2, + ), + ); + return verdict.exit; + } + + console.log(`## \`body\` dialect PRODUCER scan (objectui#9871) — root: ${root}`); + console.log(`\nFiles scanned: ${filesScanned}. Candidate \`body\` spellings: ${hits.length}.`); + + printList('⭐ The criterion — what makes a site a producer', CRITERION); + printList('⛔ Excluded by the criterion — so a zero is readable', NOT_A_PRODUCER); + printList('Known limits — every other way a 0 here is not "nothing ships it"', KNOWN_LIMITS); + + console.log('\n### C2 — the reader set, DERIVED this run (never enumerated)\n'); + console.log(`Receivers: ${readers.receivers.map((r) => `\`${r}\``).join(' ')}`); + console.log(`Item-array keys: ${readers.itemArrayKeys.map((k) => `\`${k}\``).join(' ') || '_none_'}`); + console.log(`\n| file:line | receiver | shape |`); + console.log('|:--|:--|:--|'); + for (const read of readers.reads) { + console.log(`| \`${read.file}:${read.line}\` | \`${read.receiver}\` | ${read.shape} |`); + } + + console.log(`\n### ⭐ The PRODUCER table — derived, ${producers.length} site(s)\n`); + if (producers.length === 0) { + console.log('_None._ ⇒ under this criterion no shipped source emits the dialect.'); + } else { + console.log('| file:line | carrier | `type` | channel | disposition | source |'); + console.log('|:--|:--|:--|:--|:--|:--|'); + for (const hit of producers.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line)) { + console.log( + `| \`${hit.file}:${hit.line}\` | ${hit.carrier} | ${hit.nodeType ? `\`${hit.nodeType}\`` : '—'}` + + ` | ${hit.channel} | ${hit.disposition} | ${hit.source} |`, + ); + } + } + + const byDisposition = new Map(); + for (const hit of producers) byDisposition.set(hit.disposition, (byDisposition.get(hit.disposition) ?? 0) + 1); + console.log('\n| disposition | producers |'); + console.log('|:--|--:|'); + for (const [d, c] of [...byDisposition].sort()) console.log(`| ${d} | ${c} |`); + console.log( + '\n⚠️ `unruled:item-carrier` is ⛔ NOT folded into the ruled total. Whether an item-carried' + + ' `body` belongs to objectui#6771 is unruled; this scan reports the question.', + ); + + console.log( + `\n### C2 held ${failedC2.length} shipped-source \`body\` spelling(s) OUT of the table` + + ' — ⛔ not silently\n', + ); + console.log( + 'Their carrier is neither a node nor a member of a declared item array, so NO derived reader' + + '\nresolves them into a child list: an HTTP payload, a `bodyExtra`, a `body` typography' + + '\nvariant. ⭐ The count is printed because a criterion that drops a population silently is' + + '\nthe defect this scan replaces. `--json` carries every row under `failedC2`.', + ); + + if (unclassified.length > 0) { + console.log(`\n### ⚠️ Passes C2, but NO recognised emission channel (${unclassified.length})\n`); + console.log('These carry the dialect on a real carrier and no `EMISSION_CHANNELS` pattern claims'); + console.log('them, so they are visible and ⛔ NOT in the table above. ⭐ A producer emitting by a'); + console.log('route nobody has named yet appears HERE — that is what makes this list load-bearing.\n'); + console.log('| file:line | carrier | path |'); + console.log('|:--|:--|:--|'); + for (const hit of unclassified.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line)) { + console.log(`| \`${hit.file}:${hit.line}\` | ${hit.carrier} | ${hit.carrierPath.join('.') || '_root_'} |`); + } + } + + return verdict.exit; +} + +// The ONE entry-guard predicate, never a hand-typed `process.argv[1]` compare. +if (isEntrypoint(import.meta.url)) process.exit(main());