From 1fd270e57f2d54da5bed59ba616fb24f150c868e Mon Sep 17 00:00:00 2001 From: Shinyaigeek Date: Wed, 10 Jun 2026 17:28:51 +0900 Subject: [PATCH] fix(parser): emit `no-root` diagnostic when no root component resolves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A program with statements but no resolvable root component (e.g. only `$state`/literal/data declarations) returned `root: null` with empty `errors`/`unresolved`/`orphaned` — no signal at all that a top-level component statement was missing. `pickEntryId` falls back to the first statement even when none is a component, and `buildResult` then set `root` to null silently when it materialized to a non-element. Add a `no-root` ValidationErrorCode and emit it from `buildResult` when the entry resolves to a non-component value. Gated two ways to avoid noise: - `!wasIncomplete` — root is legitimately null mid-stream. - `errors.length === 0` — don't double-report when something else (e.g. `missing-required` on `root = Stack()`) already explains it. `enrichErrors` adds an actionable hint pointing at the `root = ...` convention, so the automated correction loop can recover. Tests cover: $state-only, literal-entry, and data-only programs emit `no-root`; valid root does not; no double-report with missing-required; no emit mid-stream. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/parser/__tests__/parser.test.ts | 45 +++++++++++++++++++ .../lang-core/src/parser/enrich-errors.ts | 4 ++ packages/lang-core/src/parser/parser.ts | 17 ++++++- packages/lang-core/src/parser/types.ts | 5 ++- 4 files changed, 68 insertions(+), 3 deletions(-) diff --git a/packages/lang-core/src/parser/__tests__/parser.test.ts b/packages/lang-core/src/parser/__tests__/parser.test.ts index b95ed32b2..733442b54 100644 --- a/packages/lang-core/src/parser/__tests__/parser.test.ts +++ b/packages/lang-core/src/parser/__tests__/parser.test.ts @@ -236,6 +236,51 @@ describe("orphaned statements", () => { }); }); +describe("no-root diagnostic", () => { + it("emits no-root when only a $state declaration is present", () => { + const result = parse('$data = ["a", "b"]', schema); + expect(result.root).toBeNull(); + expect(result.meta.errors).toHaveLength(1); + expect(result.meta.errors[0]).toMatchObject({ + code: "no-root", + statementId: "$data", + }); + }); + + it("emits no-root when the entry resolves to a literal, not a component", () => { + const result = parse('greeting = "hello"', schema); + expect(result.root).toBeNull(); + expect(codes('greeting = "hello"')).toContain("no-root"); + }); + + it("emits no-root for a data-only program (multiple non-component statements)", () => { + const result = parse("$items = [1, 2, 3]\n$count = 3", schema); + expect(result.root).toBeNull(); + expect(result.meta.errors.map((e: { code: string }) => e.code)).toEqual(["no-root"]); + }); + + it("does NOT emit no-root when a valid root resolves", () => { + const result = parse('root = Stack(["hi"])', schema); + expect(result.root).not.toBeNull(); + expect(codes('root = Stack(["hi"])')).not.toContain("no-root"); + }); + + it("does NOT double-report: a missing-required root is not also no-root", () => { + const result = parse("root = Stack()", schema); + expect(result.root).toBeNull(); + const errCodes = result.meta.errors.map((e: { code: string }) => e.code); + expect(errCodes).toContain("missing-required"); + expect(errCodes).not.toContain("no-root"); + }); + + it("does NOT emit no-root mid-stream while input is still incomplete", () => { + const sp = createStreamParser(schema); + const p1 = sp.push("$data = [1, 2"); + expect(p1.meta.incomplete).toBe(true); + expect(p1.meta.errors.map((e: { code: string }) => e.code)).not.toContain("no-root"); + }); +}); + describe("markdown fences and multiline comments in strings", () => { it("preserves js markdown fences inside strings", () => { const code = 'root = Title("```js\\nconsole.log(\\"Hello World\\");\\n```")'; diff --git a/packages/lang-core/src/parser/enrich-errors.ts b/packages/lang-core/src/parser/enrich-errors.ts index 65fef725d..8c77adbcf 100644 --- a/packages/lang-core/src/parser/enrich-errors.ts +++ b/packages/lang-core/src/parser/enrich-errors.ts @@ -38,6 +38,10 @@ export function enrichErrors( error.hint = buildSignatureHint(ve.component, schema.$defs?.[ve.component]); } else if (ve.code === "inline-reserved") { error.hint = `Declare as a top-level statement: myVar = ${ve.component}(...)`; + } else if (ve.code === "no-root") { + error.hint = componentNames.length + ? `Add a top-level component statement named \`root\`, e.g. root = ${componentNames[0]}(...)` + : "Add a top-level component statement named `root`, e.g. root = Stack([...])"; } return error; }); diff --git a/packages/lang-core/src/parser/parser.ts b/packages/lang-core/src/parser/parser.ts index 9be78733c..c9b890ad8 100644 --- a/packages/lang-core/src/parser/parser.ts +++ b/packages/lang-core/src/parser/parser.ts @@ -218,7 +218,22 @@ function buildResult( const materialized = materializeValue(syms.get(entryId)!, ctx); const root = isElementNode(materialized) ? materialized : null; - if (root) root.statementId = entryId; + if (root) { + root.statementId = entryId; + } else if (!wasIncomplete && errors.length === 0) { + // The entry statement resolved to a non-component value and nothing else + // explained the null root (no missing-required/unknown-component/etc.). + // Without this, callers see `root: null` with empty errors/unresolved/orphaned + // and no signal that a top-level component statement is missing. Gated on + // `!wasIncomplete` because root is legitimately null mid-stream. + errors.push({ + code: "no-root", + component: "", + path: "", + message: `No root component found. Define a top-level component statement named \`root\`, e.g. \`root = Stack([...])\`. Entry "${entryId}" resolved to a non-component value.`, + statementId: entryId, + }); + } const { stateDeclarations, queryStatements, mutationStatements } = extractStatements( typedStmts, diff --git a/packages/lang-core/src/parser/types.ts b/packages/lang-core/src/parser/types.ts index dbe32229c..f985f2510 100644 --- a/packages/lang-core/src/parser/types.ts +++ b/packages/lang-core/src/parser/types.ts @@ -73,7 +73,8 @@ export type ValidationErrorCode = | "null-required" | "unknown-component" | "inline-reserved" - | "excess-args"; + | "excess-args" + | "no-root"; /** * A prop validation error. Components with missing required props are @@ -101,7 +102,7 @@ export type OpenUIErrorSource = "parser" | "runtime" | "query" | "mutation"; * Machine-readable error codes for the openui-lang pipeline. * * - Parser: "unknown-component", "missing-required", "null-required", "inline-reserved", - * "parse-exception", "parse-failed" + * "no-root", "parse-exception", "parse-failed" * - Runtime: "runtime-error" (prop evaluation), "render-error" (React render) * - Query/Mutation: "tool-not-found", "tool-error", "mcp-error" */