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" */