Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions packages/lang-core/src/parser/__tests__/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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```")';
Expand Down
4 changes: 4 additions & 0 deletions packages/lang-core/src/parser/enrich-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
Expand Down
17 changes: 16 additions & 1 deletion packages/lang-core/src/parser/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions packages/lang-core/src/parser/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
*/
Expand Down
Loading