Skip to content

Commit e483dad

Browse files
AbhinRustagiclaudeAditya-thesys
authored
feat(react-lang): expand parser validation with rule-tagged errors (#350)
* feat(react-lang): expand parser validation with rule-tagged errors Adds three new validation checks to ParseResult.meta.validationErrors: - unknown-component: PascalCase name not found in the library schema - excess-args: more positional args passed than the schema defines - unresolved-ref: identifier referenced but never assigned (one-shot and stream-end only — forward refs are valid mid-stream) Also adds: - ValidationError.rule discriminant field for consumer-side filtering - ValidationRule type exported from the parser index - StreamParser.finalize() to signal stream completion and trigger unresolved-ref promotion Existing missing-required and null-required errors now carry rule tags. Rendering behavior is unchanged — the parser stays permissive. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(react-lang): add parser validation unit tests Adds vitest and 17 tests covering the three new validation checks: - unknown-component: unknown PascalCase names are reported, element still renders - excess-args: extra positional args reported, component still renders - unresolved-ref: promoted to validationErrors in one-shot and on finalize(), but not mid-stream (forward refs are valid during streaming) Also tests that existing missing-required and null-required errors carry the new rule field. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(react-lang): document new parser validation checks and finalize() - Add StreamParser.finalize() docs with usage note about when to call it - Add ValidationRule type and table of all rule values - Add ValidationError.rule field to type signatures - Add filtering example showing consumer-side rule usage - Update README types import list to include ValidationError, ValidationRule Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(react-lang): replace ValidationError with discriminated OpenUIError type Remove finalize() and unresolved-ref error promotion per reviewer feedback. Rename validationErrors → errors, use type+code discriminated union for forward-compatible error taxonomy. Update tests and api-reference docs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(react-lang): fix eslint and prettier CI errors --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Aditya-thesys <aditya@thesys.dev>
1 parent a498d6c commit e483dad

10 files changed

Lines changed: 315 additions & 20 deletions

File tree

docs/content/docs/api-reference/react-lang.mdx

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,19 +152,42 @@ interface ElementNode {
152152
partial: boolean;
153153
}
154154

155-
interface ValidationError {
155+
/**
156+
* Validation error codes for schema-related issues.
157+
*
158+
* - missing-required — required prop absent with no default
159+
* - null-required — required prop explicitly null with no default
160+
* - unknown-component — PascalCase name not in the library schema
161+
* - excess-args — more positional args than the schema defines
162+
*/
163+
type ValidationErrorCode =
164+
| "missing-required"
165+
| "null-required"
166+
| "unknown-component"
167+
| "excess-args";
168+
169+
/**
170+
* Structured error from the parser.
171+
* Use `type` to distinguish error categories and `code` to filter
172+
* or categorize errors on the consumer side.
173+
*/
174+
type OpenUIError = {
175+
type: "validation";
176+
code: ValidationErrorCode;
156177
component: string;
178+
/** JSON Pointer path within props, e.g. "/title". Empty string for structural errors. */
157179
path: string;
158180
message: string;
159-
}
181+
};
160182

161183
interface ParseResult {
162184
root: ElementNode | null;
163185
meta: {
164186
incomplete: boolean;
187+
/** Identifiers referenced but not yet defined. Check after streaming completes. */
165188
unresolved: string[];
166189
statementCount: number;
167-
validationErrors: ValidationError[];
190+
errors: OpenUIError[];
168191
};
169192
}
170193
```

packages/react-lang/README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,43 @@ function AssistantMessage({ response, isStreaming }) {
119119
| `createParser(library)` | Create a one-shot parser for complete OpenUI Lang text |
120120
| `createStreamingParser(library)` | Create an incremental parser for streaming input |
121121

122+
The streaming parser exposes two methods:
123+
124+
| Method | Description |
125+
| :--- | :--- |
126+
| `push(chunk)` | Feed the next chunk; returns the latest `ParseResult` |
127+
| `getResult()` | Get the latest result without consuming new data |
128+
129+
After the stream ends, check `meta.unresolved` for any identifiers that were referenced but never defined. During streaming these are expected (forward refs) and are not treated as errors.
130+
131+
#### Errors
132+
133+
`ParseResult.meta.errors` contains structured `OpenUIError` objects. Each error has a `type` discriminant (currently always `"validation"`) and a `code` for consumer-side filtering:
134+
135+
| Code | Meaning |
136+
| :--- | :--- |
137+
| `missing-required` | Required prop absent with no default |
138+
| `null-required` | Required prop explicitly null with no default |
139+
| `unknown-component` | Component name not found in the library schema |
140+
| `excess-args` | More positional args passed than the schema defines |
141+
142+
Errors do not affect rendering — the parser stays permissive and renders what it can. Use `code` to decide how to surface or log errors:
143+
144+
```ts
145+
const result = parser.parse(output);
146+
const critical = result.meta.errors.filter(
147+
(e) => e.code === "unknown-component"
148+
);
149+
```
150+
151+
To check for unresolved references after streaming, inspect `meta.unresolved`:
152+
153+
```ts
154+
if (result.meta.unresolved.length > 0) {
155+
console.warn("Unresolved refs:", result.meta.unresolved);
156+
}
157+
```
158+
122159
### Context Hooks
123160

124161
Use these inside component renderers to interact with the rendering context:
@@ -157,6 +194,8 @@ import type {
157194
ActionEvent,
158195
ElementNode,
159196
ParseResult,
197+
OpenUIError,
198+
ValidationErrorCode,
160199
LibraryJSONSchema,
161200
} from "@openuidev/react-lang";
162201
```

packages/react-lang/eslint.config.cjs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,24 @@ const unusedImports = require("eslint-plugin-unused-imports");
66
const eslintPluginPrettier = require("eslint-plugin-prettier");
77

88
module.exports = [
9+
{
10+
files: ["**/__tests__/**/*.{ts,tsx}", "**/*.test.{ts,tsx}", "**/*.spec.{ts,tsx}"],
11+
languageOptions: {
12+
parser: typescript,
13+
parserOptions: {
14+
project: "./tsconfig.test.json",
15+
sourceType: "module",
16+
},
17+
},
18+
},
919
{
1020
files: ["**/*.{ts,tsx}"],
11-
ignores: ["**/*.stories.tsx"],
21+
ignores: [
22+
"**/*.stories.tsx",
23+
"**/__tests__/**/*.{ts,tsx}",
24+
"**/*.test.{ts,tsx}",
25+
"**/*.spec.{ts,tsx}",
26+
],
1227
languageOptions: {
1328
parser: typescript,
1429
parserOptions: {

packages/react-lang/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
}
1919
},
2020
"scripts": {
21+
"test": "vitest run",
2122
"build": "tsc -p .",
2223
"watch": "tsc -p . --watch",
2324
"lint:check": "eslint ./src",
@@ -60,6 +61,7 @@
6061
"react": ">=19.0.0"
6162
},
6263
"devDependencies": {
63-
"@types/react": "^19.0.0"
64+
"@types/react": "^19.0.0",
65+
"vitest": "^4.0.18"
6466
}
6567
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { ParamMap } from "../parser";
3+
import { createStreamParser, parse } from "../parser";
4+
5+
// ── Test schema ──────────────────────────────────────────────────────────────
6+
7+
/**
8+
* Minimal schema used across tests.
9+
*
10+
* Stack takes one param (children), Title takes one (text),
11+
* Table takes two (columns, rows). These cover the common test cases.
12+
*/
13+
const schema: ParamMap = new Map([
14+
["Stack", { params: [{ name: "children", required: true }] }],
15+
["Title", { params: [{ name: "text", required: true }] }],
16+
[
17+
"Table",
18+
{
19+
params: [
20+
{ name: "columns", required: true },
21+
{ name: "rows", required: true },
22+
],
23+
},
24+
],
25+
]);
26+
27+
// ── Helpers ───────────────────────────────────────────────────────────────────
28+
29+
const errors = (input: string) => parse(input, schema).meta.errors;
30+
const codes = (input: string) => errors(input).map((e) => e.code);
31+
32+
// ── unknown-component ────────────────────────────────────────────────────────
33+
34+
describe("unknown-component", () => {
35+
it("reports when component name is not in schema", () => {
36+
const result = parse('root = DataTable("col")', schema);
37+
expect(result.meta.errors).toHaveLength(1);
38+
expect(result.meta.errors[0]).toMatchObject({
39+
type: "validation",
40+
code: "unknown-component",
41+
component: "DataTable",
42+
path: "",
43+
});
44+
});
45+
46+
it("still renders the element with _args when unknown", () => {
47+
const result = parse('root = DataTable("col")', schema);
48+
expect(result.root).not.toBeNull();
49+
expect(result.root?.typeName).toBe("DataTable");
50+
expect((result.root?.props as any)._args).toEqual(["col"]);
51+
});
52+
53+
it("reports all unknown components in a tree", () => {
54+
const r = codes('root = Stack([Ghost("a")])\n');
55+
expect(r).toContain("unknown-component");
56+
});
57+
58+
it("does not report for known component names", () => {
59+
const result = parse('root = Stack(["hello"])', schema);
60+
expect(codes('root = Stack(["hello"])')).not.toContain("unknown-component");
61+
expect(result.meta.errors).toHaveLength(0);
62+
});
63+
});
64+
65+
// ── excess-args ───────────────────────────────────────────────────────────────
66+
67+
describe("excess-args", () => {
68+
it("reports when more args are passed than params", () => {
69+
const result = parse('root = Title("hello", "extra")', schema);
70+
expect(result.meta.errors).toHaveLength(1);
71+
expect(result.meta.errors[0]).toMatchObject({
72+
type: "validation",
73+
code: "excess-args",
74+
component: "Title",
75+
path: "",
76+
});
77+
expect(result.meta.errors[0].message).toMatch(/takes 1 arg/);
78+
});
79+
80+
it("still renders the component despite excess args", () => {
81+
const result = parse('root = Title("hello", "extra")', schema);
82+
expect(result.root).not.toBeNull();
83+
expect(result.root?.props.text).toBe("hello");
84+
});
85+
86+
it("does not report when arg count matches param count", () => {
87+
expect(codes('root = Title("hello")')).not.toContain("excess-args");
88+
});
89+
90+
it("does not report when fewer args than params (handled by missing-required)", () => {
91+
expect(codes("root = Table([], [])")).not.toContain("excess-args");
92+
});
93+
});
94+
95+
// ── unresolved references ────────────────────────────────────────────────────
96+
97+
describe("unresolved references", () => {
98+
it("tracks unresolved refs in meta.unresolved (one-shot)", () => {
99+
const result = parse("root = Stack([tbl])", schema);
100+
expect(result.meta.unresolved).toContain("tbl");
101+
});
102+
103+
it("does not produce errors for unresolved refs", () => {
104+
const result = parse("root = Stack([tbl])", schema);
105+
expect(result.meta.errors).toHaveLength(0);
106+
});
107+
108+
it("clears unresolved when ref is defined", () => {
109+
const result = parse('root = Stack([tbl])\ntbl = Title("hello")', schema);
110+
expect(result.meta.unresolved).toHaveLength(0);
111+
});
112+
});
113+
114+
// ── unresolved references (streaming) ─────────────────────────────────────────
115+
116+
describe("unresolved references (streaming)", () => {
117+
it("tracks unresolved refs mid-stream without errors", () => {
118+
const parser = createStreamParser(schema);
119+
const midResult = parser.push("root = Stack([tbl])\n");
120+
expect(midResult.meta.unresolved).toContain("tbl");
121+
expect(midResult.meta.errors).toHaveLength(0);
122+
});
123+
124+
it("resolves automatically when ref is defined in a later chunk", () => {
125+
const parser = createStreamParser(schema);
126+
parser.push("root = Stack([tbl])\n");
127+
const result = parser.push('tbl = Title("hello")\n');
128+
expect(result.meta.unresolved).toHaveLength(0);
129+
});
130+
131+
it("keeps unresolved refs in meta.unresolved at end of stream", () => {
132+
const parser = createStreamParser(schema);
133+
parser.push("root = Stack([tbl])\n");
134+
const result = parser.getResult();
135+
expect(result.meta.unresolved).toContain("tbl");
136+
expect(result.meta.errors).toHaveLength(0);
137+
});
138+
});
139+
140+
// ── existing error rules ──────────────────────────────────────────────────────
141+
142+
describe("existing errors carry type and code", () => {
143+
it("missing-required has correct shape", () => {
144+
const result = parse("root = Stack()", schema);
145+
expect(result.meta.errors).toHaveLength(1);
146+
expect(result.meta.errors[0]).toMatchObject({
147+
type: "validation",
148+
code: "missing-required",
149+
});
150+
});
151+
152+
it("null-required has correct shape", () => {
153+
const result = parse("root = Stack(null)", schema);
154+
expect(result.meta.errors).toHaveLength(1);
155+
expect(result.meta.errors[0]).toMatchObject({
156+
type: "validation",
157+
code: "null-required",
158+
});
159+
});
160+
});

packages/react-lang/src/parser/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
export { BuiltinActionType } from "./types";
2-
export type { ActionEvent, ElementNode, ParseResult, ValidationError } from "./types";
2+
export type {
3+
ActionEvent,
4+
ElementNode,
5+
OpenUIError,
6+
ParseResult,
7+
ValidationErrorCode,
8+
} from "./types";
39

410
export { createParser, createStreamingParser, parse } from "./parser";
511
export type { LibraryJSONSchema, Parser, StreamParser } from "./parser";

0 commit comments

Comments
 (0)