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
88 changes: 88 additions & 0 deletions packages/lang-core/src/parser/__tests__/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

2 changes: 2 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,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;
});
Expand Down
40 changes: 39 additions & 1 deletion packages/lang-core/src/parser/materialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions packages/lang-core/src/parser/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? {};
Expand All @@ -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 });
}
Expand Down
5 changes: 4 additions & 1 deletion packages/lang-core/src/parser/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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
Expand Down
Loading