From 00adf555aa321af85fae0d9e1daf692abdca9f34 Mon Sep 17 00:00:00 2001 From: Aditya Pandey Date: Tue, 14 Apr 2026 16:06:29 +0530 Subject: [PATCH] Implement type-mismatch detection in parser --- .../src/parser/__tests__/parser.test.ts | 88 +++++++++++++++++++ .../lang-core/src/parser/enrich-errors.ts | 2 + packages/lang-core/src/parser/materialize.ts | 40 ++++++++- packages/lang-core/src/parser/parser.ts | 10 +++ packages/lang-core/src/parser/types.ts | 5 +- 5 files changed, 143 insertions(+), 2 deletions(-) diff --git a/packages/lang-core/src/parser/__tests__/parser.test.ts b/packages/lang-core/src/parser/__tests__/parser.test.ts index 2bd339aa3..a57b7b033 100644 --- a/packages/lang-core/src/parser/__tests__/parser.test.ts +++ b/packages/lang-core/src/parser/__tests__/parser.test.ts @@ -235,3 +235,91 @@ describe("orphaned statements", () => { expect(result.meta.orphaned).toHaveLength(0); }); }); + +// ── type-mismatch ─────────────────────────────────────────────────────────── + +const typedSchema: ParamMap = new Map([ + [ + "Header", + { + params: [ + { name: "title", required: true, type: "string" }, + { name: "icon", required: false, type: "string" }, + ], + }, + ], + [ + "Chart", + { + params: [ + { name: "title", required: true, type: "string" }, + { name: "data", required: true, type: "array" }, + ], + }, + ], +]); + +describe("type-mismatch", () => { + it("reports when literal arg type does not match expected type", () => { + // Chart(title: string, data: array) called with (array, string) — both swapped + const result = parse('root = Chart([1, 2], "Revenue")', typedSchema); + const errs = result.meta.errors.filter((e) => e.code === "type-mismatch"); + expect(errs).toHaveLength(2); + expect(errs[0]).toMatchObject({ + code: "type-mismatch", + component: "Chart", + path: "/title", + }); + expect(errs[0].message).toContain("expects string, got array"); + expect(errs[1]).toMatchObject({ + code: "type-mismatch", + component: "Chart", + path: "/data", + }); + expect(errs[1].message).toContain("expects array, got string"); + }); + + it("does not report when types match", () => { + const result = parse('root = Chart("Revenue", [1, 2])', typedSchema); + expect(result.meta.errors.filter((e) => e.code === "type-mismatch")).toHaveLength(0); + }); + + it("skips check for non-literal args (Ref)", () => { + const result = parse('myData = [1, 2]\nroot = Chart(myData, [1, 2])', typedSchema); + // myData is a Ref — can't infer type at parse time + expect(result.meta.errors.filter((e) => e.code === "type-mismatch")).toHaveLength(0); + }); + + it("skips check for null args (handled by null-required)", () => { + const result = parse('root = Header(null, "icon")', typedSchema); + expect(result.meta.errors.filter((e) => e.code === "type-mismatch")).toHaveLength(0); + }); + + it("component still renders despite type mismatch (non-fatal)", () => { + const result = parse('root = Header(42, "icon")', typedSchema); + expect(result.meta.errors.some((e) => e.code === "type-mismatch")).toBe(true); + expect(result.root).not.toBeNull(); + expect(result.root?.props.title).toBe(42); + }); + + it("reports number where string expected", () => { + const result = parse("root = Header(42)", typedSchema); + const errs = result.meta.errors.filter((e) => e.code === "type-mismatch"); + expect(errs).toHaveLength(1); + expect(errs[0].message).toContain("expects string, got number"); + }); + + it("reports object where string expected", () => { + const result = parse('root = Header({key: "val"})', typedSchema); + const errs = result.meta.errors.filter((e) => e.code === "type-mismatch"); + expect(errs).toHaveLength(1); + expect(errs[0].message).toContain("expects string, got object"); + }); + + it("does not check when schema has no type", () => { + // Using the base schema (no type) — should never produce type-mismatch + const result = parse('root = Stack(42)', schema); + expect(result.meta.errors.filter((e) => e.code === "type-mismatch")).toHaveLength(0); + }); +}); + diff --git a/packages/lang-core/src/parser/enrich-errors.ts b/packages/lang-core/src/parser/enrich-errors.ts index 65fef725d..0c834c592 100644 --- a/packages/lang-core/src/parser/enrich-errors.ts +++ b/packages/lang-core/src/parser/enrich-errors.ts @@ -38,6 +38,8 @@ 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 === "type-mismatch") { + error.hint = buildSignatureHint(ve.component, schema.$defs?.[ve.component]); } return error; }); diff --git a/packages/lang-core/src/parser/materialize.ts b/packages/lang-core/src/parser/materialize.ts index f97bab3f2..d9ce1a814 100644 --- a/packages/lang-core/src/parser/materialize.ts +++ b/packages/lang-core/src/parser/materialize.ts @@ -186,6 +186,30 @@ export function materializeExpr(node: ASTNode, ctx: MaterializeCtx): ASTNode { return materializeExprInternal(node, ctx, new Set()); } +/** + * Infer the basic type of a literal AST node. + * Returns undefined for non-literal nodes (Ref, Comp, expressions) where + * the type can't be determined at parse time. + */ +function inferASTType(node: ASTNode): string | undefined { + switch (node.k) { + case "Str": + return "string"; + case "Num": + return "number"; + case "Bool": + return "boolean"; + case "Arr": + return "array"; + case "Obj": + return "object"; + case "Null": + return "null"; + default: + return undefined; + } +} + /** * Schema-aware materialization: resolves refs, normalizes catalog component args * to named props, validates required props, applies defaults, converts literals @@ -264,7 +288,21 @@ export function materializeValue(node: ASTNode, ctx: MaterializeCtx): unknown { if (def) { // Catalog component: map positional args → named props for (let i = 0; i < def.params.length && i < args.length; i++) { - props[def.params[i].name] = materializeValue(args[i], ctx); + const param = def.params[i]; + // Type-mismatch check on raw AST before materialization + if (param.type) { + const actualType = inferASTType(args[i]); + if (actualType && actualType !== "null" && actualType !== param.type) { + ctx.errors.push({ + code: "type-mismatch", + component: name, + path: `/${param.name}`, + message: `Arg ${i + 1} (${param.name}) expects ${param.type}, got ${actualType}`, + statementId: ctx.currentStatementId, + }); + } + } + props[param.name] = materializeValue(args[i], ctx); } // Report excess positional args (extra args are silently dropped) diff --git a/packages/lang-core/src/parser/parser.ts b/packages/lang-core/src/parser/parser.ts index beae85b57..2d93f308a 100644 --- a/packages/lang-core/src/parser/parser.ts +++ b/packages/lang-core/src/parser/parser.ts @@ -595,6 +595,15 @@ function getSchemaDefaultValue(property: unknown): unknown { return (property as { default?: unknown }).default; } +function getSchemaType(property: unknown): string | undefined { + if (!property || typeof property !== "object" || Array.isArray(property)) return undefined; + const type = (property as { type?: unknown }).type; + if (typeof type !== "string") return undefined; + // Normalize "integer" to "number" to match AST type inference + if (type === "integer") return "number"; + return type; +} + function compileSchema(schema: LibraryJSONSchema): ParamMap { const map: ParamMap = new Map(); const defs = schema.$defs ?? {}; @@ -606,6 +615,7 @@ function compileSchema(schema: LibraryJSONSchema): ParamMap { name: key, required: required.includes(key), defaultValue: getSchemaDefaultValue(properties[key]), + type: getSchemaType(properties[key]), })); map.set(name, { params }); } diff --git a/packages/lang-core/src/parser/types.ts b/packages/lang-core/src/parser/types.ts index dbe32229c..decef0be7 100644 --- a/packages/lang-core/src/parser/types.ts +++ b/packages/lang-core/src/parser/types.ts @@ -21,6 +21,8 @@ export interface ParamDef { required: boolean; /** Default value from JSON Schema — used when the required field is missing/null. */ defaultValue?: unknown; + /** JSON Schema type — used for type-mismatch detection on literal args. */ + type?: string; } /** Internal parameter map for positional-arg to named-prop mapping. */ @@ -73,7 +75,8 @@ export type ValidationErrorCode = | "null-required" | "unknown-component" | "inline-reserved" - | "excess-args"; + | "excess-args" + | "type-mismatch"; /** * A prop validation error. Components with missing required props are