From 862b33cb1ca86d3c9b207a96320169ae90ab2f65 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 09:09:32 +0000 Subject: [PATCH 1/6] fix(vscode-extension): honour the declared `children` child-list spelling Both extension-host readers guarded their child-list recursion on `schema.body` alone, while `children` is the spelling declared by the TypeScript face and zod mirror in `@object-ui/types`, by `validateSchema` in `@object-ui/core` (which reads `schema.children || schema.body`), and by the manifest tier's `BASE_PROPS` in `@object-ui/sdui-parser`. An author writing the declared spelling therefore got a blank preview and children that were never validated -- both failures silent, with no diagnostic and no refusal. Read `children` first and KEEP the `body` arm, so every `body`-spelled document behaves exactly as before. Removing that arm is a separate, already-ruled step and is not done here. The validator's diagnostic path now names the key the document actually used, so a `children`-spelled node no longer reports problems at a `.body[...]` address that does not occur in the file. Pinned by executing the shipped sources rather than asserting on their text: the preview's renderer lives inside a template literal that nothing type-checks, and the validator is transpiled and run against a stub of the `vscode` members it touches. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq --- .../body-dialect-children-arm-7181.test.ts | 320 ++++++++++++++++++ .../src/providers/PreviewProvider.ts | 21 +- .../src/providers/SchemaValidator.ts | 27 +- 3 files changed, 359 insertions(+), 9 deletions(-) create mode 100644 packages/vscode-extension/src/__tests__/body-dialect-children-arm-7181.test.ts diff --git a/packages/vscode-extension/src/__tests__/body-dialect-children-arm-7181.test.ts b/packages/vscode-extension/src/__tests__/body-dialect-children-arm-7181.test.ts new file mode 100644 index 0000000000..10bf22d5af --- /dev/null +++ b/packages/vscode-extension/src/__tests__/body-dialect-children-arm-7181.test.ts @@ -0,0 +1,320 @@ +/** + * 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. + */ + +/** + * objectui#7181 — the two extension-host readers honour `children`, the + * spelling the rest of the platform declares. + * + * ## The defect these pins were built for + * + * `children` is declared on four faces: the TypeScript declaration and the zod + * mirror in `@object-ui/types`, `validateSchema` in `@object-ui/core` (which + * reads `schema.children || schema.body`, children FIRST), and the manifest + * tier's `BASE_PROPS` in `@object-ui/sdui-parser` (which carries `children` and + * does NOT carry `body`). Both readers in this package guarded their child-list + * recursion on `schema.body` ALONE. + * + * So an author who wrote the declared spelling got, from the tool that is + * supposed to be teaching them the format: + * + * - a VS Code preview that rendered the node EMPTY — no error, no diagnostic, + * just a blank card or container; and + * - every child silently skipped by the validator, so a child missing its + * required `type` drew no warning at all. + * + * Both failures are SILENT, which is the worst direction for a metadata tool: + * the author's file is fine, the platform blesses it, and the tooling reports + * nothing while showing them nothing. + * + * ## Why these run the SHIPPED source instead of importing the modules + * + * Two different reasons, one per reader, and both are the same reason the + * sibling `export-to-react-*` pins give: + * + * - `PreviewProvider`'s renderer is not a module at all. It lives as JS text + * inside a TEMPLATE LITERAL that becomes the webview's inline script, so + * this package's `tsc --noEmit` sees one string and nothing type-checks or + * executes it. Extracting and running that text is the only way to observe + * the code the user's preview actually runs — and it means the pin is on + * the SHIPPED text, not on a copy of it that can drift. + * - `SchemaValidator` imports `vscode`, which has no runtime module outside + * the extension host and no alias in this repo's vitest config. It is + * transpiled here and executed against a stub of the seven `vscode` members + * it touches, so the pin exercises the real recursion rather than asserting + * on source text. + * + * ⚠️ Source-text assertions were deliberately NOT used for either. An assertion + * that the string `schema.children` appears would pass against a `children` arm + * that is present but unreachable, which is precisely the failure being pinned. + * + * ## The `body` arm is expected to keep working + * + * Each pin asserts BOTH spellings. Removing the `body` arm is objectui#6771 + * step 2, not this change, so a `body`-spelled document rendering or validating + * differently than before is a regression these pins must also catch. + */ + +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; +import { describe, expect, it } from 'vitest'; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +const PREVIEW_SOURCE = readFileSync( + resolve(HERE, '../providers/PreviewProvider.ts'), + 'utf8' +); +const VALIDATOR_SOURCE = readFileSync( + resolve(HERE, '../providers/SchemaValidator.ts'), + 'utf8' +); + +/* ------------------------------------------------------------------ * + * Reader 1 — the preview's shipped inline renderer + * ------------------------------------------------------------------ */ + +interface StubElement { + tag: string; + className: string; + textContent: string; + rows: number; + type: string; + placeholder: string; + children: StubElement[]; + appendChild(child: StubElement): void; +} + +function createStubElement(tag: string): StubElement { + return { + tag, + className: '', + textContent: '', + rows: 0, + type: '', + placeholder: '', + children: [], + appendChild(child: StubElement) { + this.children.push(child); + }, + }; +} + +/** Every element below the one returned, at any depth. */ +function countDescendants(element: StubElement): number { + return element.children.reduce( + (total, child) => total + 1 + countDescendants(child), + 0 + ); +} + +type RendererFn = (schema: unknown) => StubElement; + +/** + * Lift `createElementFromSchema` out of the webview HTML template and make it + * callable. The anchors are asserted rather than assumed: if either moves, this + * fails loudly instead of silently pinning nothing. + */ +function loadShippedPreviewRenderer(): RendererFn { + const start = PREVIEW_SOURCE.indexOf('function createElementFromSchema'); + expect( + start, + 'anchor "function createElementFromSchema" not found in PreviewProvider.ts — ' + + 'the renderer moved and this pin is measuring nothing' + ).toBeGreaterThan(-1); + + const end = PREVIEW_SOURCE.indexOf('// Render the schema', start); + expect( + end, + 'anchor "// Render the schema" not found after the renderer in PreviewProvider.ts' + ).toBeGreaterThan(start); + + // The renderer is written inside a template literal, so its backticks and + // interpolations are escaped in the TypeScript source. Undo exactly that. + const source = PREVIEW_SOURCE.slice(start, end) + .replace(/\\`/g, '`') + .replace(/\\\$\{/g, '${') + .replace(/\\\\/g, '\\'); + + const document = { createElement: createStubElement }; + return new Function( + 'document', + `${source}\nreturn createElementFromSchema;` + )(document) as RendererFn; +} + +describe('objectui#7181 — the VS Code preview renders `children`, not only `body`', () => { + const render = loadShippedPreviewRenderer(); + const child = { type: 'text', content: 'Hello' }; + + it('renders a `card` node the same whichever child-list spelling it uses', () => { + const viaBody = countDescendants( + render({ type: 'card', title: 'T', body: [child] }) + ); + const viaChildren = countDescendants( + render({ type: 'card', title: 'T', children: [child] }) + ); + + // The title element plus the one rendered child. Asserted absolutely, so a + // renderer that dropped BOTH spellings could not satisfy this by symmetry. + expect(viaBody).toBe(2); + expect(viaChildren).toBe(2); + }); + + it('renders a generic container node the same whichever spelling it uses', () => { + const viaBody = countDescendants(render({ type: 'div', body: [child] })); + const viaChildren = countDescendants( + render({ type: 'div', children: [child] }) + ); + + expect(viaBody).toBe(1); + expect(viaChildren).toBe(1); + }); + + it('renders nested `children` all the way down', () => { + const tree = { + type: 'div', + children: [{ type: 'card', title: 'T', children: [child] }], + }; + // div -> card -> (title, text) + expect(countDescendants(render(tree))).toBe(3); + }); + + it('still honours a single non-array child node under either spelling', () => { + expect(countDescendants(render({ type: 'div', body: child }))).toBe(1); + expect(countDescendants(render({ type: 'div', children: child }))).toBe(1); + }); +}); + +/* ------------------------------------------------------------------ * + * Reader 2 — the extension's schema validator + * ------------------------------------------------------------------ */ + +interface CapturedDiagnostic { + message: string; +} + +/** + * Transpile and execute `SchemaValidator.ts` against a stub of the `vscode` + * members it touches, returning the diagnostics it publishes for `json`. + */ +function validateWithShippedValidator(schema: unknown): CapturedDiagnostic[] { + const published: CapturedDiagnostic[] = []; + + const vscodeStub = { + languages: { + createDiagnosticCollection: () => ({ + set: (_uri: unknown, diagnostics: CapturedDiagnostic[]) => { + published.splice(0, published.length, ...diagnostics); + }, + clear: () => undefined, + dispose: () => undefined, + }), + }, + window: { + setStatusBarMessage: () => undefined, + showErrorMessage: () => undefined, + showInformationMessage: () => undefined, + }, + // `Diagnostic` is captured by message only; the range is not under test. + Diagnostic: class { + constructor( + public range: unknown, + public message: string, + public severity?: unknown + ) {} + }, + Range: class { + constructor(...args: unknown[]) { + this.args = args; + } + args: unknown[]; + translate() { + return this; + } + }, + DiagnosticSeverity: { Error: 0, Warning: 1, Information: 2, Hint: 3 }, + }; + + const transpiled = ts.transpileModule(VALIDATOR_SOURCE, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + }, + }).outputText; + + const moduleShim: { exports: Record } = { exports: {} }; + new Function( + 'require', + 'module', + 'exports', + transpiled + )( + (specifier: string) => { + if (specifier === 'vscode') return vscodeStub; + throw new Error(`unexpected require("${specifier}") in SchemaValidator`); + }, + moduleShim, + moduleShim.exports + ); + + const SchemaValidatorClass = moduleShim.exports.SchemaValidator as new () => { + validateDocument(document: unknown): Promise; + }; + + const text = JSON.stringify(schema, null, 2); + const validator = new SchemaValidatorClass(); + // `validateDocument` is synchronous in everything it does before publishing. + void validator.validateDocument({ + getText: () => text, + positionAt: () => ({ translate: () => ({}) }), + uri: { toString: () => 'stub://doc.objectui.json' }, + }); + + return published; +} + +describe('objectui#7181 — the VS Code validator recurses into `children`, not only `body`', () => { + // A child missing its required `type` is the observable: the validator warns + // about it ONLY if the recursion actually reached that child. + const untypedChild = { className: 'p-4' }; + + it('reaches a child spelled `children` and reports its missing `type`', () => { + const diagnostics = validateWithShippedValidator({ + type: 'card', + children: [untypedChild], + }); + + expect(diagnostics.map((d) => d.message)).toContain( + 'Missing required property "type"' + ); + }); + + it('still reaches a child spelled `body` and reports its missing `type`', () => { + const diagnostics = validateWithShippedValidator({ + type: 'card', + body: [untypedChild], + }); + + expect(diagnostics.map((d) => d.message)).toContain( + 'Missing required property "type"' + ); + }); + + it('reports nothing for a well-formed tree under either spelling', () => { + const typedChild = { type: 'text', content: 'Hello' }; + + expect( + validateWithShippedValidator({ type: 'card', children: [typedChild] }) + ).toHaveLength(0); + expect( + validateWithShippedValidator({ type: 'card', body: [typedChild] }) + ).toHaveLength(0); + }); +}); diff --git a/packages/vscode-extension/src/providers/PreviewProvider.ts b/packages/vscode-extension/src/providers/PreviewProvider.ts index 6a10bc8714..6ae10b81e5 100644 --- a/packages/vscode-extension/src/providers/PreviewProvider.ts +++ b/packages/vscode-extension/src/providers/PreviewProvider.ts @@ -223,8 +223,10 @@ export class PreviewProvider { desc.textContent = schema.description; element.appendChild(desc); } - if (schema.body) { - const body = Array.isArray(schema.body) ? schema.body : [schema.body]; + // `children` first, `body` kept — see the container branch below. + const cardChildren = schema.children || schema.body; + if (cardChildren) { + const body = Array.isArray(cardChildren) ? cardChildren : [cardChildren]; body.forEach(child => { element.appendChild(createElementFromSchema(child)); }); @@ -236,9 +238,18 @@ export class PreviewProvider { break; default: - // Handle generic div/container - if (schema.body) { - const body = Array.isArray(schema.body) ? schema.body : [schema.body]; + // Handle generic div/container. + // + // `children` is read FIRST, `body` is kept as a second arm. This + // guard used to name \`schema.body\` alone, so a node spelling its + // child list \`children\` — the spelling the TypeScript declaration, + // the zod mirror, core's validator and the manifest tier all bless — + // rendered as an EMPTY element: no error, no diagnostic, just a blank + // preview (objectui#7181). The \`body\` arm stays; removing it is + // objectui#6771 step 2, not this change. + const containerChildren = schema.children || schema.body; + if (containerChildren) { + const body = Array.isArray(containerChildren) ? containerChildren : [containerChildren]; body.forEach(child => { element.appendChild(createElementFromSchema(child)); }); diff --git a/packages/vscode-extension/src/providers/SchemaValidator.ts b/packages/vscode-extension/src/providers/SchemaValidator.ts index 6c5f3c217c..9fc641b8c4 100644 --- a/packages/vscode-extension/src/providers/SchemaValidator.ts +++ b/packages/vscode-extension/src/providers/SchemaValidator.ts @@ -98,15 +98,34 @@ export class SchemaValidator { this.validateTypeSpecificProps(schema, diagnostics, document, path); } - // Recursively validate children - if (schema.body) { - const children = Array.isArray(schema.body) ? schema.body : [schema.body]; + // Recursively validate children. + // + // `children` is read FIRST and `body` is kept as a second arm, matching + // `schema.children || schema.body` in `@object-ui/core`'s `validateSchema` + // and in the `div` / `card` renderers (objectui#7181). + // + // This guard used to name `schema.body` alone, which made the extension a + // `body`-ONLY reader while the TypeScript declaration, the zod mirror, + // core's validator and the manifest tier all declare `children`. An author + // who wrote the declared spelling therefore had every child silently + // skipped by this recursion — no diagnostic, no refusal, just an unvisited + // subtree — while the preview rendered the same document blank. + // + // The `body` arm is deliberately KEPT: dropping it is objectui#6771 step 2, + // not this change, and every `body`-spelled document must keep working. + const childList = schema.children || schema.body; + if (childList) { + // The diagnostic path has to name the key this document actually used, + // or a `children`-spelled node reports its problems at a `.body[...]` + // address that does not occur anywhere in the file being validated. + const childKey = schema.children ? 'children' : 'body'; + const children = Array.isArray(childList) ? childList : [childList]; children.forEach((child: any, index: number) => { this.validateSchema( child, diagnostics, document, - `${path}.body[${index}]` + `${path}.${childKey}[${index}]` ); }); } From 1b80c285a1ef68e520f9574336609d7b5a103fd2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 09:15:53 +0000 Subject: [PATCH 2/6] fix(cli,vscode-extension,components,runner): producers emit `children` Six code paths emitted `body`-spelled child lists into metadata a user then owns or that ships as a component's declared default: packages/cli/src/commands/init.ts 53 nodes packages/vscode-extension/src/extension.ts 14 nodes packages/components/.../complex/carousel.tsx 3 nodes packages/components/.../complex/resizable.tsx 2 nodes packages/runner/src/App.tsx 2 nodes packages/components/.../complex/scroll-area.tsx 1 node Counts are the node counts `pnpm census:body-dialect` reports, not line counts; `init.ts` matches `body` on 55 LINES because two of them are the HTML body tags in the generated `index.html`, which are not schema nodes and are untouched here. The three `defaultProps` cases were the sharpest: the registration shipped the dialect inside its own declared default, so the component emitted by default a spelling the retirement is removing. Every node type involved -- `div`, `card`, `page` -- already read `children`, so this is a rename and not a re-shape. New pins, neither of which existed before: the scaffolded output of every `objectui init` template, and every `Create New Schema` template. Both discover their template population from the code rather than listing it, so a template added later is covered without editing the test. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq --- .../init-template-dialect-7181.test.ts | 164 +++++++++++++++ packages/cli/src/commands/init.ts | 106 +++++----- .../src/renderers/complex/carousel.tsx | 6 +- .../src/renderers/complex/resizable.tsx | 4 +- .../src/renderers/complex/scroll-area.tsx | 2 +- packages/runner/src/App.tsx | 2 +- .../__tests__/template-dialect-7181.test.ts | 192 ++++++++++++++++++ packages/vscode-extension/src/extension.ts | 28 +-- 8 files changed, 430 insertions(+), 74 deletions(-) create mode 100644 packages/cli/src/__tests__/init-template-dialect-7181.test.ts create mode 100644 packages/vscode-extension/src/__tests__/template-dialect-7181.test.ts diff --git a/packages/cli/src/__tests__/init-template-dialect-7181.test.ts b/packages/cli/src/__tests__/init-template-dialect-7181.test.ts new file mode 100644 index 0000000000..c83f8b2959 --- /dev/null +++ b/packages/cli/src/__tests__/init-template-dialect-7181.test.ts @@ -0,0 +1,164 @@ +/** + * 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. + */ + +/** + * objectui#7181 — everything `objectui init` scaffolds spells its child lists + * `children`. + * + * ## Why this pin exists + * + * `init.ts` is a PRODUCER: it writes JSON into a project directory that the + * user then owns and edits. Every template it shipped spelled its child lists + * `body`, the dialect objectui#6771 retires. Nothing pinned that, so nothing + * would have noticed either the dialect drifting back or a newly added template + * arriving in the old spelling. + * + * The ordering constraint this protects is the card's own: once the authoring + * tier teaches `children` only, a scaffolder still emitting `body` produces a + * project that the very next `objectui validate` rejects — a new user's first + * two commands contradicting each other. + * + * ## The template population is DERIVED, never listed here + * + * `buildInitFiles` names its own templates in the error it throws for an + * unknown one. This pin reads that list rather than repeating it, so a template + * added to `init.ts` tomorrow is covered by this file today. Per AGENTS.md #9, + * a count or a list written into a test is derived once and never again; the + * instrument that re-derives it is what should be pointed at. + * + * ## Both directions are asserted, so a zero cannot pass vacuously + * + * Asserting only "no node spells `body`" would pass against a template that + * emitted no nodes at all, or against a walk that visited nothing. Each + * template therefore also has to yield at least one node that DOES spell + * `children` — the lit control for the absence assertion beside it. + */ + +import { describe, expect, it } from 'vitest'; + +import { buildInitFiles } from '../commands/init.js'; + +/** Ask `buildInitFiles` itself which templates exist. */ +function discoverTemplateNames(): string[] { + let message = ''; + try { + buildInitFiles('probe', '__no_such_template__'); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + + const match = /Available templates:\s*(.+)$/m.exec(message); + expect( + match, + 'buildInitFiles no longer reports its template list on an unknown name — ' + + 'this pin can no longer discover the population it is supposed to cover' + ).not.toBeNull(); + + const names = match![1] + .split(',') + .map((name) => name.trim()) + .filter(Boolean); + expect(names.length, 'no templates discovered').toBeGreaterThan(0); + return names; +} + +interface DialectTally { + body: number; + children: number; + nodes: number; +} + +/** Walk any parsed JSON value and tally which child-list key each node uses. */ +function tallyDialect(value: unknown, tally: DialectTally): DialectTally { + if (Array.isArray(value)) { + value.forEach((entry) => tallyDialect(entry, tally)); + return tally; + } + if (!value || typeof value !== 'object') return tally; + + const node = value as Record; + if (typeof node.type === 'string') tally.nodes += 1; + if ('body' in node) tally.body += 1; + if ('children' in node) tally.children += 1; + + Object.values(node).forEach((entry) => tallyDialect(entry, tally)); + return tally; +} + +/** The JSON files a scaffolded project is created with. */ +function scaffoldedJson(templateName: string): Array<[string, unknown]> { + const files = buildInitFiles('probe-app', templateName); + const parsed = Object.entries(files) + .filter(([fileName]) => fileName.endsWith('.json')) + .map(([fileName, contents]) => { + let value: unknown; + expect( + () => { + value = JSON.parse(contents); + }, + `${templateName}/${fileName} is not parseable JSON — a scaffolded project would not load` + ).not.toThrow(); + return [fileName, value] as [string, unknown]; + }); + + expect( + parsed.length, + `template "${templateName}" scaffolds no .json file at all` + ).toBeGreaterThan(0); + return parsed; +} + +describe('objectui#7181 — `objectui init` scaffolds the `children` spelling', () => { + const templateNames = discoverTemplateNames(); + + it.each(templateNames)( + 'template "%s" emits no `body` child list anywhere', + (templateName) => { + const tally = scaffoldedJson(templateName).reduce( + (acc, [, value]) => tallyDialect(value, acc), + { body: 0, children: 0, nodes: 0 } + ); + + // Lit control first: if the walk found no nodes, or none of them spells + // `children`, the zero below would mean nothing. + expect( + tally.nodes, + `template "${templateName}": the walk visited no typed node` + ).toBeGreaterThan(0); + expect( + tally.children, + `template "${templateName}": no node spells \`children\` — the absence ` + + 'assertion below would be vacuous' + ).toBeGreaterThan(0); + + expect( + tally.body, + `template "${templateName}" still emits the retired \`body\` child-list dialect` + ).toBe(0); + } + ); + + it('scaffolds the same node count under the new spelling as the old one carried', () => { + // The migration was a rename, not a re-shape: every template must still + // carry at least as many typed nodes as child lists, and no template may + // have lost its tree to a bad edit. + templateNames.forEach((templateName) => { + const tally = scaffoldedJson(templateName).reduce( + (acc, [, value]) => tallyDialect(value, acc), + { body: 0, children: 0, nodes: 0 } + ); + expect(tally.nodes).toBeGreaterThanOrEqual(tally.children); + }); + }); + + it('rejects an unknown template by name rather than scaffolding a default', () => { + expect(() => buildInitFiles('probe', '__no_such_template__')).toThrow( + /Unknown template/ + ); + }); +}); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 2e3e57e75b..ad7bde24bf 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -24,15 +24,15 @@ const templates = { simple: { type: 'div', className: 'min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100', - body: { + children: { type: 'card', className: 'w-full max-w-md shadow-lg', title: 'Welcome to Object UI', description: 'Start building your application with JSON schemas', - body: { + children: { type: 'div', className: 'p-6 space-y-4', - body: [ + children: [ { type: 'text', content: 'This is a simple example. Edit app.json to customize your application.', @@ -50,19 +50,19 @@ const templates = { form: { type: 'div', className: 'min-h-screen flex items-center justify-center bg-gradient-to-br from-purple-50 to-pink-100 p-4', - body: { + children: { type: 'card', className: 'w-full max-w-2xl shadow-xl', title: 'Contact Form', description: 'Fill out the form below to get in touch', - body: { + children: { type: 'div', className: 'p-6 space-y-6', - body: [ + children: [ { type: 'div', className: 'grid grid-cols-2 gap-4', - body: [ + children: [ { type: 'input', label: 'First Name', @@ -99,7 +99,7 @@ const templates = { { type: 'div', className: 'flex gap-3', - body: [ + children: [ { type: 'button', label: 'Submit', @@ -120,21 +120,21 @@ const templates = { dashboard: { type: 'div', className: 'min-h-screen bg-muted/10', - body: [ + children: [ { type: 'div', className: 'border-b bg-background', - body: { + children: { type: 'div', className: 'container mx-auto px-6 py-4', - body: { + children: { type: 'div', className: 'flex items-center justify-between', - body: [ + children: [ { type: 'div', className: 'text-2xl font-bold', - body: { type: 'text', content: 'Dashboard' }, + children: { type: 'text', content: 'Dashboard' }, }, { type: 'button', @@ -148,37 +148,37 @@ const templates = { { type: 'div', className: 'container mx-auto p-6 space-y-6', - body: [ + children: [ { type: 'div', className: 'grid gap-4 md:grid-cols-2 lg:grid-cols-4', - body: [ + children: [ { type: 'card', className: 'shadow-sm', - body: [ + children: [ { type: 'div', className: 'p-6 pb-2', - body: { + children: { type: 'div', className: 'text-sm font-medium text-muted-foreground', - body: { type: 'text', content: 'Total Revenue' }, + children: { type: 'text', content: 'Total Revenue' }, }, }, { type: 'div', className: 'p-6 pt-0', - body: [ + children: [ { type: 'div', className: 'text-2xl font-bold', - body: { type: 'text', content: '$45,231.89' }, + children: { type: 'text', content: '$45,231.89' }, }, { type: 'div', className: 'text-xs text-muted-foreground mt-1', - body: { type: 'text', content: '+20.1% from last month' }, + children: { type: 'text', content: '+20.1% from last month' }, }, ], }, @@ -187,29 +187,29 @@ const templates = { { type: 'card', className: 'shadow-sm', - body: [ + children: [ { type: 'div', className: 'p-6 pb-2', - body: { + children: { type: 'div', className: 'text-sm font-medium text-muted-foreground', - body: { type: 'text', content: 'Active Users' }, + children: { type: 'text', content: 'Active Users' }, }, }, { type: 'div', className: 'p-6 pt-0', - body: [ + children: [ { type: 'div', className: 'text-2xl font-bold', - body: { type: 'text', content: '+2,350' }, + children: { type: 'text', content: '+2,350' }, }, { type: 'div', className: 'text-xs text-muted-foreground mt-1', - body: { type: 'text', content: '+180.1% from last month' }, + children: { type: 'text', content: '+180.1% from last month' }, }, ], }, @@ -218,29 +218,29 @@ const templates = { { type: 'card', className: 'shadow-sm', - body: [ + children: [ { type: 'div', className: 'p-6 pb-2', - body: { + children: { type: 'div', className: 'text-sm font-medium text-muted-foreground', - body: { type: 'text', content: 'Sales' }, + children: { type: 'text', content: 'Sales' }, }, }, { type: 'div', className: 'p-6 pt-0', - body: [ + children: [ { type: 'div', className: 'text-2xl font-bold', - body: { type: 'text', content: '+12,234' }, + children: { type: 'text', content: '+12,234' }, }, { type: 'div', className: 'text-xs text-muted-foreground mt-1', - body: { type: 'text', content: '+19% from last month' }, + children: { type: 'text', content: '+19% from last month' }, }, ], }, @@ -249,29 +249,29 @@ const templates = { { type: 'card', className: 'shadow-sm', - body: [ + children: [ { type: 'div', className: 'p-6 pb-2', - body: { + children: { type: 'div', className: 'text-sm font-medium text-muted-foreground', - body: { type: 'text', content: 'Active Now' }, + children: { type: 'text', content: 'Active Now' }, }, }, { type: 'div', className: 'p-6 pt-0', - body: [ + children: [ { type: 'div', className: 'text-2xl font-bold', - body: { type: 'text', content: '+573' }, + children: { type: 'text', content: '+573' }, }, { type: 'div', className: 'text-xs text-muted-foreground mt-1', - body: { type: 'text', content: '+201 since last hour' }, + children: { type: 'text', content: '+201 since last hour' }, }, ], }, @@ -284,27 +284,27 @@ const templates = { className: 'shadow-sm', title: 'Recent Activity', description: 'Your latest updates and notifications', - body: { + children: { type: 'div', className: 'p-6 pt-0 space-y-4', - body: [ + children: [ { type: 'div', className: 'flex items-center gap-4 border-b pb-4', - body: [ + children: [ { type: 'div', className: 'flex-1', - body: [ + children: [ { type: 'div', className: 'font-medium', - body: { type: 'text', content: 'New user registration' }, + children: { type: 'text', content: 'New user registration' }, }, { type: 'div', className: 'text-sm text-muted-foreground', - body: { type: 'text', content: '2 minutes ago' }, + children: { type: 'text', content: '2 minutes ago' }, }, ], }, @@ -313,20 +313,20 @@ const templates = { { type: 'div', className: 'flex items-center gap-4 border-b pb-4', - body: [ + children: [ { type: 'div', className: 'flex-1', - body: [ + children: [ { type: 'div', className: 'font-medium', - body: { type: 'text', content: 'Payment received' }, + children: { type: 'text', content: 'Payment received' }, }, { type: 'div', className: 'text-sm text-muted-foreground', - body: { type: 'text', content: '15 minutes ago' }, + children: { type: 'text', content: '15 minutes ago' }, }, ], }, @@ -335,20 +335,20 @@ const templates = { { type: 'div', className: 'flex items-center gap-4', - body: [ + children: [ { type: 'div', className: 'flex-1', - body: [ + children: [ { type: 'div', className: 'font-medium', - body: { type: 'text', content: 'New order placed' }, + children: { type: 'text', content: 'New order placed' }, }, { type: 'div', className: 'text-sm text-muted-foreground', - body: { type: 'text', content: '1 hour ago' }, + children: { type: 'text', content: '1 hour ago' }, }, ], }, diff --git a/packages/components/src/renderers/complex/carousel.tsx b/packages/components/src/renderers/complex/carousel.tsx index 07128421a7..3b9635aea7 100644 --- a/packages/components/src/renderers/complex/carousel.tsx +++ b/packages/components/src/renderers/complex/carousel.tsx @@ -58,9 +58,9 @@ ComponentRegistry.register('carousel', orientation: 'horizontal', showArrows: true, items: [ - [{ type: 'div', className: 'p-4 sm:p-6 md:p-8 border rounded bg-slate-50', body: [{ type: 'text', content: 'Slide 1' }] }], - [{ type: 'div', className: 'p-4 sm:p-6 md:p-8 border rounded bg-slate-50', body: [{ type: 'text', content: 'Slide 2' }] }], - [{ type: 'div', className: 'p-4 sm:p-6 md:p-8 border rounded bg-slate-50', body: [{ type: 'text', content: 'Slide 3' }] }] + [{ type: 'div', className: 'p-4 sm:p-6 md:p-8 border rounded bg-slate-50', children: [{ type: 'text', content: 'Slide 1' }] }], + [{ type: 'div', className: 'p-4 sm:p-6 md:p-8 border rounded bg-slate-50', children: [{ type: 'text', content: 'Slide 2' }] }], + [{ type: 'div', className: 'p-4 sm:p-6 md:p-8 border rounded bg-slate-50', children: [{ type: 'text', content: 'Slide 3' }] }] ], className: 'w-full max-w-xs' } diff --git a/packages/components/src/renderers/complex/resizable.tsx b/packages/components/src/renderers/complex/resizable.tsx index 06f08541d7..4096a4f71b 100644 --- a/packages/components/src/renderers/complex/resizable.tsx +++ b/packages/components/src/renderers/complex/resizable.tsx @@ -62,8 +62,8 @@ ComponentRegistry.register('resizable', minHeight: '200px', withHandle: true, panels: [ - { defaultSize: 50, content: [{ type: 'div', className: 'p-4', body: [{ type: 'text', content: 'Panel 1' }] }] }, - { defaultSize: 50, content: [{ type: 'div', className: 'p-4', body: [{ type: 'text', content: 'Panel 2' }] }] } + { defaultSize: 50, content: [{ type: 'div', className: 'p-4', children: [{ type: 'text', content: 'Panel 1' }] }] }, + { defaultSize: 50, content: [{ type: 'div', className: 'p-4', children: [{ type: 'text', content: 'Panel 2' }] }] } ], className: 'rounded-lg border' } diff --git a/packages/components/src/renderers/complex/scroll-area.tsx b/packages/components/src/renderers/complex/scroll-area.tsx index 80e61df040..82cfd3673d 100644 --- a/packages/components/src/renderers/complex/scroll-area.tsx +++ b/packages/components/src/renderers/complex/scroll-area.tsx @@ -50,7 +50,7 @@ ComponentRegistry.register('scroll-area', width: '100%', orientation: 'vertical', children: [ - { type: 'div', className: 'p-4', body: [{ type: 'text', content: 'Scrollable content goes here. Add more content to see scrolling behavior.' }] } + { type: 'div', className: 'p-4', children: [{ type: 'text', content: 'Scrollable content goes here. Add more content to see scrolling behavior.' }] } ], className: 'rounded-md border' } diff --git a/packages/runner/src/App.tsx b/packages/runner/src/App.tsx index 0838025d66..c241e54f36 100644 --- a/packages/runner/src/App.tsx +++ b/packages/runner/src/App.tsx @@ -142,7 +142,7 @@ export default function RunnerApp() { setPageSchema({ type: 'page', title: 'Welcome to Object UI', - body: [{ type: 'div', className: "p-10 text-center text-muted-foreground", body: 'No index page found.' }] + children: [{ type: 'div', className: "p-10 text-center text-muted-foreground", children: 'No index page found.' }] } as any); } else { setPageSchema(null); diff --git a/packages/vscode-extension/src/__tests__/template-dialect-7181.test.ts b/packages/vscode-extension/src/__tests__/template-dialect-7181.test.ts new file mode 100644 index 0000000000..9d3c6f52cc --- /dev/null +++ b/packages/vscode-extension/src/__tests__/template-dialect-7181.test.ts @@ -0,0 +1,192 @@ +/** + * 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. + */ + +/** + * objectui#7181 — every template `Object UI: Create New Schema` writes spells + * its child lists `children`. + * + * ## Why this pin exists + * + * `getTemplateSchema` is a PRODUCER, and the tightest one in the repo: the + * command writes the template to a `.objectui.json` file on disk and opens it, + * and the extension's OWN preview and validator then read that same file. Every + * template it shipped spelled its child lists `body`. + * + * That made the round trip self-contradicting in both directions at once. While + * the readers were `body`-only, migrating the templates alone would have made + * `Create New Schema` followed by `Open Preview` render blank. Now that the + * readers honour `children` (see `body-dialect-children-arm-7181.test.ts`), + * nothing but this pin would notice a template drifting back to `body`, or a + * newly added template arriving in the old spelling. + * + * ## Why the function is transpiled rather than imported + * + * `getTemplateSchema` is module-local, and `extension.ts` imports `vscode`, + * which has no runtime module outside the extension host and no alias in this + * repo's vitest config. The file is therefore transpiled and executed against a + * stub, and the function is taken out of the module scope by name. ⚠️ The + * SOURCE file is never modified — only the in-memory transpiled copy is given a + * trailing `return`, so this pin cannot drift from the shipped text. + * + * ## The template population is DERIVED, never listed here + * + * The names come from the quick-pick list `createNewSchema` offers, read out of + * the source, so a template added tomorrow is covered today (AGENTS.md #9). + * ⚠️ `getTemplateSchema` falls back to `empty` for an unknown name, so a pin + * that guessed names would pass on every typo while testing one template three + * times — the discovery below is checked against the template map itself. + */ + +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; +import { describe, expect, it } from 'vitest'; + +const EXTENSION_SOURCE = readFileSync( + resolve(dirname(fileURLToPath(import.meta.url)), '../extension.ts'), + 'utf8' +); + +/** + * Transpile `extension.ts` against a `vscode` stub and hand back the + * module-local `getTemplateSchema`. + */ +function loadShippedTemplateFactory(): (type: string) => string { + const transpiled = ts.transpileModule(EXTENSION_SOURCE, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + }, + }).outputText; + + // A stub that answers any member access with something callable, newable and + // further-accessible. `getTemplateSchema` touches none of it; this only has + // to survive module evaluation, which constructs nothing (`activate()` is + // never called here). + const inertStub: unknown = new Proxy( + function stub() {} as unknown as object, + { + get: () => inertStub, + apply: () => undefined, + construct: () => ({}), + } + ); + + const moduleShim = { exports: {} as Record }; + const factory = new Function( + 'require', + 'module', + 'exports', + `${transpiled}\nreturn typeof getTemplateSchema === 'function' ? getTemplateSchema : undefined;` + )( + (specifier: string) => { + // `vscode` and the extension's own sibling modules are stubbed; a NEW + // third-party dependency still throws, so this stays a real guard rather + // than a catch-all. + if (specifier === 'vscode' || specifier.startsWith('.')) return inertStub; + throw new Error(`unexpected require("${specifier}") in extension.ts`); + }, + moduleShim, + moduleShim.exports + ); + + expect( + typeof factory, + 'getTemplateSchema was not found in the transpiled extension module — ' + + 'it was renamed or moved, and this pin is measuring nothing' + ).toBe('function'); + + return factory as (type: string) => string; +} + +/** The template names `createNewSchema` offers in its quick pick. */ +function discoverTemplateNames(): string[] { + const names = [...EXTENSION_SOURCE.matchAll(/\{\s*label:\s*'[^']+',\s*value:\s*'([^']+)'\s*\}/g)] + .map((match) => match[1]); + + expect( + names.length, + 'no template names discovered in createNewSchema\'s quick-pick list — ' + + 'the list moved and this pin would silently cover nothing' + ).toBeGreaterThan(0); + return names; +} + +interface DialectTally { + body: number; + children: number; + nodes: number; +} + +function tallyDialect(value: unknown, tally: DialectTally): DialectTally { + if (Array.isArray(value)) { + value.forEach((entry) => tallyDialect(entry, tally)); + return tally; + } + if (!value || typeof value !== 'object') return tally; + + const node = value as Record; + if (typeof node.type === 'string') tally.nodes += 1; + if ('body' in node) tally.body += 1; + if ('children' in node) tally.children += 1; + + Object.values(node).forEach((entry) => tallyDialect(entry, tally)); + return tally; +} + +describe('objectui#7181 — the VS Code new-schema templates use `children`', () => { + const getTemplateSchema = loadShippedTemplateFactory(); + const templateNames = discoverTemplateNames(); + + it.each(templateNames)( + 'template "%s" emits no `body` child list anywhere', + (templateName) => { + const raw = getTemplateSchema(templateName); + let parsed: unknown; + expect( + () => { + parsed = JSON.parse(raw); + }, + `template "${templateName}" is not parseable JSON` + ).not.toThrow(); + + const tally = tallyDialect(parsed, { body: 0, children: 0, nodes: 0 }); + + // Lit controls: without these the zero below could come from an empty + // template or a walk that visited nothing. + expect( + tally.nodes, + `template "${templateName}": the walk visited no typed node` + ).toBeGreaterThan(0); + expect( + tally.children, + `template "${templateName}": no node spells \`children\`` + ).toBeGreaterThan(0); + + expect( + tally.body, + `template "${templateName}" still emits the retired \`body\` child-list dialect` + ).toBe(0); + } + ); + + it('offers templates that are distinct documents, not the `empty` fallback repeated', () => { + // `getTemplateSchema` returns `templates.empty` for any unknown name, so a + // discovery that drifted out of sync with the template map would test one + // template N times and still be green. Distinctness is what detects that. + const rendered = templateNames.map((name) => getTemplateSchema(name)); + const distinct = new Set(rendered); + + expect( + distinct.size, + 'every offered template rendered the same document — the quick-pick ' + + 'names no longer match the keys of the template map' + ).toBeGreaterThan(1); + }); +}); diff --git a/packages/vscode-extension/src/extension.ts b/packages/vscode-extension/src/extension.ts index 3f85710d10..e9f001d546 100644 --- a/packages/vscode-extension/src/extension.ts +++ b/packages/vscode-extension/src/extension.ts @@ -304,7 +304,7 @@ function getTemplateSchema(type: string): string { empty: { type: 'div', className: 'p-4', - body: { + children: { type: 'text', content: 'Hello Object UI!', }, @@ -312,13 +312,13 @@ function getTemplateSchema(type: string): string { form: { type: 'div', className: 'max-w-2xl mx-auto p-6', - body: { + children: { type: 'card', title: 'Contact Form', - body: { + children: { type: 'div', className: 'space-y-4', - body: [ + children: [ { type: 'input', label: 'Name', @@ -350,11 +350,11 @@ function getTemplateSchema(type: string): string { dashboard: { type: 'div', className: 'min-h-screen p-6', - body: [ + children: [ { type: 'div', className: 'mb-6', - body: { + children: { type: 'text', content: 'Dashboard', className: 'text-3xl font-bold', @@ -363,11 +363,11 @@ function getTemplateSchema(type: string): string { { type: 'div', className: 'grid gap-4 md:grid-cols-3', - body: [ + children: [ { type: 'card', title: 'Total Users', - body: { + children: { type: 'text', content: '1,234', className: 'text-2xl font-bold', @@ -376,7 +376,7 @@ function getTemplateSchema(type: string): string { { type: 'card', title: 'Revenue', - body: { + children: { type: 'text', content: '$56,789', className: 'text-2xl font-bold', @@ -385,7 +385,7 @@ function getTemplateSchema(type: string): string { { type: 'card', title: 'Orders', - body: { + children: { type: 'text', content: '432', className: 'text-2xl font-bold', @@ -398,11 +398,11 @@ function getTemplateSchema(type: string): string { card: { type: 'div', className: 'max-w-md mx-auto p-6', - body: { + children: { type: 'card', title: 'Card Title', description: 'This is a card description', - body: { + children: { type: 'text', content: 'Card content goes here', }, @@ -411,10 +411,10 @@ function getTemplateSchema(type: string): string { table: { type: 'div', className: 'p-6', - body: { + children: { type: 'card', title: 'Data Table', - body: { + children: { type: 'text', content: 'Table component coming soon...', }, From 9e964364b8fa32fdefae31013a92a2f6aee1ee76 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 09:27:01 +0000 Subject: [PATCH 3/6] fix(vscode-extension): drop backticks from comments inside the webview template The comments added with the `children` arm sat INSIDE the preview's HTML template literal, where a backtick is not decoration -- it ends the string. `tsc` reported TS1005/TS1443 at the first one. Nothing else caught it, and the reason is worth keeping: the pins for this behaviour read the provider as TEXT rather than importing it, so they were green against a file TypeScript rejected. `type-check` is what found it. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq --- .../src/providers/PreviewProvider.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/vscode-extension/src/providers/PreviewProvider.ts b/packages/vscode-extension/src/providers/PreviewProvider.ts index 6ae10b81e5..befde17787 100644 --- a/packages/vscode-extension/src/providers/PreviewProvider.ts +++ b/packages/vscode-extension/src/providers/PreviewProvider.ts @@ -223,7 +223,7 @@ export class PreviewProvider { desc.textContent = schema.description; element.appendChild(desc); } - // `children` first, `body` kept — see the container branch below. + // children first, body kept -- see the container branch below. const cardChildren = schema.children || schema.body; if (cardChildren) { const body = Array.isArray(cardChildren) ? cardChildren : [cardChildren]; @@ -240,13 +240,17 @@ export class PreviewProvider { default: // Handle generic div/container. // - // `children` is read FIRST, `body` is kept as a second arm. This - // guard used to name \`schema.body\` alone, so a node spelling its - // child list \`children\` — the spelling the TypeScript declaration, - // the zod mirror, core's validator and the manifest tier all bless — - // rendered as an EMPTY element: no error, no diagnostic, just a blank - // preview (objectui#7181). The \`body\` arm stays; removing it is + // children is read FIRST, body is kept as a second arm. This guard + // used to name schema.body alone, so a node spelling its child list + // children -- the spelling the TypeScript declaration, the zod + // mirror, core's validator and the manifest tier all bless -- + // rendered as an EMPTY element: no error, no diagnostic, just a + // blank preview (objectui#7181). The body arm stays; removing it is // objectui#6771 step 2, not this change. + // + // NOTE: this comment carries no backticks on purpose. It lives + // INSIDE the webview's template literal, where an unescaped backtick + // ends the string and the file stops parsing. const containerChildren = schema.children || schema.body; if (containerChildren) { const body = Array.isArray(containerChildren) ? containerChildren : [containerChildren]; From 19f2a4e51a269f704bf4314283c1af1bec29c6e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 09:29:44 +0000 Subject: [PATCH 4/6] docs(changeset): declare the reader defect and the producer migration Scored `minor` across the four released packages this branch touches. The reader half is a user-visible defect fix, not a refactor note, so the changeset names the symptom -- a silently blank preview and unvalidated children for a spelling the platform blesses on four faces. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq --- ...7181-body-dialect-producers-to-children.md | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .changeset/7181-body-dialect-producers-to-children.md diff --git a/.changeset/7181-body-dialect-producers-to-children.md b/.changeset/7181-body-dialect-producers-to-children.md new file mode 100644 index 0000000000..826ef6ddd0 --- /dev/null +++ b/.changeset/7181-body-dialect-producers-to-children.md @@ -0,0 +1,58 @@ +--- +'object-ui': minor +'@object-ui/cli': minor +'@object-ui/components': minor +'@object-ui/runner': minor +--- + +The VS Code extension no longer ignores a child list spelled `children`, and everything +the platform scaffolds now emits that spelling (objectui#7181). + +## The defect, and who it hit + +`children` is the spelling ObjectUI declares on four faces: the `BaseSchema` TypeScript +declaration and its zod mirror in `@object-ui/types`, `validateSchema` in +`@object-ui/core`, and the authoring tier's `BASE_PROPS` in `@object-ui/sdui-parser` +(which accepts `children` and does **not** list `body`). + +Both readers in the VS Code extension honoured only `body`. So an author who wrote the +spelling the platform blesses got, from the tool meant to be teaching them the format: + +- **a blank preview** — `Object UI: Open Preview` rendered the node as an empty card or + container, with no error and no diagnostic; and +- **children that were never validated** — the validator's recursion never reached them, + so a child missing its required `type` drew no warning at all. + +Both failures were silent in both directions: nothing told the author their file was +being skipped, and nothing told them it was fine. Metadata written to a file by one +party and re-authored by another was simply dropped by the reader. + +Both readers now read `children` first and **keep** `body`, so every existing +`body`-spelled document behaves exactly as it did. Nothing is removed and no accepted +spelling is narrowed. The extension's validator also reports diagnostics at the key the +document actually used, instead of addressing them to `.body[...]` in a file that +contains no such key. + +## Scaffolders and shipped defaults now emit `children` + +The same dialect was being *produced* into files users own, and into components' own +declared defaults: + +- `objectui init` — the `app.json` written for every template +- `Object UI: Create New Schema` — every template the VS Code extension writes +- `carousel`, `resizable` and `scroll-area` — the child lists inside their shipped + `defaultProps` +- the runner's "no index page found" fallback page + +Every node type involved already read `children`, so rendering is unchanged; what moves +is the spelling users are handed as their starting point. + +⚠️ This is a change to what those tools *emit*, not to what ObjectUI accepts: `body` +remains readable everywhere it was readable before, and a project scaffolded by an +earlier version needs no edit. Retiring the `body` arm is a separate, already-ruled +step and is not part of this release. `pnpm census:body-dialect` is the instrument that +reports where the dialect still lives. + +Scaffolded output and shipped defaults are now pinned by tests that discover the +template population from the code, so a template added later is covered without anyone +remembering to extend them. From d91e4103e60e7c38038c9cc2873ba734245f598e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 10:01:07 +0000 Subject: [PATCH 5/6] test(scripts): re-point the census pins at what is still true This branch migrated six producers off the `body` dialect, which made two claims in the census pin false. Repairing a claim this diff falsified is what is owed; the claims themselves are kept. "the platform SHIPS the dialect it is being asked to refuse" keeps its wording and its ordering rationale, and gains a live subject: six producers moved, and `generatePage` in commands/generate.ts still writes a `pages/NAME.json` spelled `body`. Both halves are asserted, so either a migrated producer drifting back or that last producer moving without this block being re-pointed will red. Deliberately NOT inverted into "the producers now carry `children`" -- that asserts this branch's own diff back at itself and throws away the reason the block exists. The generic-reader block's assertions were already passing, so they are not weakened: the two vscode lines are STRENGTHENED to the same `schema.children || schema.body` spelling the core line pins, which is what those readers now say. Its comment recorded the three readers as alike; they were not, and that difference is the whole of this branch. Found by CI, not locally: this test lives in scripts/__tests__ and reads other packages' source text, so no affected-package suite reaches it. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq --- scripts/__tests__/body-dialect-census.test.ts | 75 +++++++++++++++---- 1 file changed, 59 insertions(+), 16 deletions(-) diff --git a/scripts/__tests__/body-dialect-census.test.ts b/scripts/__tests__/body-dialect-census.test.ts index 317748517b..4bc366aa24 100644 --- a/scripts/__tests__/body-dialect-census.test.ts +++ b/scripts/__tests__/body-dialect-census.test.ts @@ -204,27 +204,70 @@ describe('the `body` consumers the ruling does not enumerate', () => { // the tier — and these three are none of those. They read `body` for ANY // node type, so they outlive the per-registration convergence and would // keep the dialect alive after step 2 lands. - expect(read('packages/core/src/validation/schema-validator.ts')).toContain( - 'schema.children || schema.body', - ); - expect(read('packages/vscode-extension/src/providers/SchemaValidator.ts')).toContain('schema.body'); - expect(read('packages/vscode-extension/src/providers/PreviewProvider.ts')).toContain('schema.body'); + // + // ⚠️ The three were NOT alike, and the difference decided objectui#7181. + // Core has always been `children`-first, so it degrades gracefully. The two + // vscode readers guarded on `schema.body` ALONE — they did not resolve + // `children` at all — so they did not merely keep the old dialect alive, + // they BLOCKED the producers from moving: a `children`-spelled document + // rendered blank and had its children skipped by validation, silently. + // objectui#7181 converged both onto the core spelling, adding the + // `children` arm and KEEPING `body`. Nothing was retired; that is step 2. + // + // ⇒ all three now read the same way, which is what these three assertions + // pin. The `body` arm surviving in each is the half that says step 2 is + // still outstanding. + for (const reader of [ + 'packages/core/src/validation/schema-validator.ts', + 'packages/vscode-extension/src/providers/SchemaValidator.ts', + 'packages/vscode-extension/src/providers/PreviewProvider.ts', + ]) { + expect(read(reader), reader).toContain('schema.children || schema.body'); + } }); it('the platform SHIPS the dialect it is being asked to refuse', () => { - // The sharpest census finding. Ruling step 5's principle is that "the - // platform never refuses a spelling it still ships" — and today these - // PRODUCERS emit `body` into metadata a user then owns: + // The sharpest census finding, and the claim is UNCHANGED: ruling step 5's + // principle is that "the platform never refuses a spelling it still ships", + // so step 4 (tier teaches `children` only) cannot land while any PRODUCER + // still emits `body` into metadata a user then owns. // - // - `objectui init` scaffolds every new project's app in `body` - // - the VS Code extension's new-file templates do the same - // - two registrations ship `body` inside their own `defaultProps` + // What moved is the subject, not the claim. objectui#7181 migrated the six + // producers its table named — `objectui init`, the VS Code extension's + // new-file templates, the three `defaultProps` registrations and the + // runner's fallback page. ⛔ It did NOT finish the population: the census + // that found those six also reaches a SEVENTH the table never listed, and + // `generatePage` in `packages/cli/src/commands/generate.ts` still writes a + // `pages/NAME.json` whose child list is spelled `body` (objectui#9847). // - // So step 4 (tier teaches `children` only) cannot land before these move, - // or every freshly scaffolded project fails validation on day one. - expect(read('packages/cli/src/commands/init.ts')).toMatch(/^\s*body:/m); - expect(read('packages/vscode-extension/src/extension.ts')).toMatch(/^\s*body:/m); - expect(read('packages/components/src/renderers/complex/carousel.tsx')).toContain("body: [{ type: 'text'"); + // ⇒ the statement above is still TRUE today, for exactly one file. Both + // halves are asserted, so a regression in EITHER direction reds: a migrated + // producer drifting back to `body`, or objectui#9847 landing without this + // block being re-pointed at whatever still ships the dialect — or at + // nothing, once nothing does, which is the day step 4 becomes landable. + // + // ⛔ Deliberately NOT inverted into "the producers now carry `children`". + // That would assert objectui#7181's own diff back at itself and discard the + // ordering rationale this block exists to carry. + const migrated = [ + 'packages/cli/src/commands/init.ts', + 'packages/vscode-extension/src/extension.ts', + 'packages/components/src/renderers/complex/carousel.tsx', + 'packages/components/src/renderers/complex/resizable.tsx', + 'packages/components/src/renderers/complex/scroll-area.tsx', + 'packages/runner/src/App.tsx', + ]; + + for (const producer of migrated) { + const text = read(producer); + // Lit control first: without a child list present at all, the absence + // assertion beside it would hold over an empty or unreadable file. + expect(text, producer).toContain('children:'); + expect(text, producer).not.toContain('body:'); + } + + // The half that keeps the claim true — and keeps step 4 blocked. + expect(read('packages/cli/src/commands/generate.ts')).toMatch(/^\s*body:/m); }); }); From 616e838c014d08fd9948d973b0db1cc21e87d76b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 10:02:46 +0000 Subject: [PATCH 6/6] test(scripts): source-text pins read code, not the prose beside it The strengthened reader assertion did not bite. Ablating the `children` arm out of providers/SchemaValidator.ts left the pin GREEN, because that file documents the arm by quoting `schema.children || schema.body` in a comment directly above the line implementing it -- `toContain` was matching the prose. A `readCode` helper strips whole-line comments, and the three source-text blocks use it. Whole-line only, so a `//` inside a string survives: both a URL and the preview provider's inlined webview script appear in the files these blocks read. This is the principle the scanner is already pinned on one block above ("prose is not a call site"), applied to the assertions themselves. Caught by ablation, which is the only reason it was visible at all: the assertion was green both before and after the mutation. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq --- scripts/__tests__/body-dialect-census.test.ts | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/scripts/__tests__/body-dialect-census.test.ts b/scripts/__tests__/body-dialect-census.test.ts index 4bc366aa24..51ec51ad62 100644 --- a/scripts/__tests__/body-dialect-census.test.ts +++ b/scripts/__tests__/body-dialect-census.test.ts @@ -48,6 +48,29 @@ import { const REPO_ROOT = join(__dirname, '..', '..'); const read = (p: string) => readFileSync(join(REPO_ROOT, p), 'utf8'); +/** + * `read`, with whole-line comments removed. + * + * ⚠️ Measured, not hypothetical. `providers/SchemaValidator.ts` documents its + * child-list arm by QUOTING `schema.children || schema.body` in a comment + * directly above the line that implements it. A plain `toContain` over the raw + * file therefore stayed green with that implementation ablated away — the + * assertion was passing off the prose, which is the phantom-check shape this + * file's own neighbours warn about: an assertion that cannot fail is not a pin. + * + * Only WHOLE-LINE comments are stripped, so a `//` inside a string survives — + * a URL, or the preview provider's inlined webview script, both of which appear + * in the files these blocks read. + */ +const readCode = (p: string) => + read(p) + .split('\n') + .filter((line) => { + const trimmed = line.trimStart(); + return !trimmed.startsWith('//') && !trimmed.startsWith('*') && !trimmed.startsWith('/*'); + }) + .join('\n'); + const keysOf = (text: string, type: string) => { const node = scanNodes(text).find((n: { type: string }) => n.type === type); return node ? [...(node.keys as Set)] : null; @@ -222,7 +245,7 @@ describe('the `body` consumers the ruling does not enumerate', () => { 'packages/vscode-extension/src/providers/SchemaValidator.ts', 'packages/vscode-extension/src/providers/PreviewProvider.ts', ]) { - expect(read(reader), reader).toContain('schema.children || schema.body'); + expect(readCode(reader), reader).toContain('schema.children || schema.body'); } }); @@ -259,7 +282,7 @@ describe('the `body` consumers the ruling does not enumerate', () => { ]; for (const producer of migrated) { - const text = read(producer); + const text = readCode(producer); // Lit control first: without a child list present at all, the absence // assertion beside it would hold over an empty or unreadable file. expect(text, producer).toContain('children:'); @@ -267,7 +290,7 @@ describe('the `body` consumers the ruling does not enumerate', () => { } // The half that keeps the claim true — and keeps step 4 blocked. - expect(read('packages/cli/src/commands/generate.ts')).toMatch(/^\s*body:/m); + expect(readCode('packages/cli/src/commands/generate.ts')).toMatch(/^\s*body:/m); }); });