Skip to content
Merged
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
21 changes: 15 additions & 6 deletions src/domain/models/method_execution_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import {
createFileWriterFactory,
createResourceWriter,
} from "./data_writer.ts";
import { coerceMethodArgs } from "./zod_type_coercion.ts";
import { coerceMethodArgs, getObjectShape } from "./zod_type_coercion.ts";
import {
containsExpression,
valueContainsExpression,
Expand Down Expand Up @@ -449,12 +449,21 @@ export class DefaultMethodExecutionService implements MethodExecutionService {
modelDef.globalArguments,
);
const globalArgsSchema = modelDef.globalArguments;
const strictGlobalArgs = (
globalArgsSchema as unknown as {
strict?(): typeof globalArgsSchema;
const shape = getObjectShape(globalArgsSchema);
if (shape) {
const unknownKeys = Object.keys(coercedGlobalArgs).filter(
(k) => !Object.hasOwn(shape, k),
);
if (unknownKeys.length > 0) {
const validKeys = Object.keys(shape).join(", ");
throw new Error(
`Global arguments validation failed: Unknown argument(s): ${
unknownKeys.join(", ")
}. Valid arguments are: ${validKeys || "none"}`,
);
}
).strict?.() ?? globalArgsSchema;
const globalArgsResult = strictGlobalArgs.safeParse(
}
const globalArgsResult = globalArgsSchema.safeParse(
coercedGlobalArgs,
);
if (!globalArgsResult.success) {
Expand Down
45 changes: 36 additions & 9 deletions src/domain/models/validation_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
stripExpressionFields,
} from "../expressions/expression_parser.ts";
import { detectEnvVarUsageInDefinition } from "./env_var_detector.ts";
import { getObjectShape } from "./zod_type_coercion.ts";
import {
extractEnvReferences,
extractPathReferences,
Expand Down Expand Up @@ -482,14 +483,28 @@ export class DefaultModelValidationService implements ModelValidationService {
return Promise.resolve(ValidationResult.pass("Global arguments"));
}

// All fields are static, validate with strict mode to reject unknown keys
// All fields are static — reject unknown keys explicitly so the check
// also catches schemas wrapped in .refine()/.transform() (no .strict()
// available on ZodEffects) and is not bypassed by prototype-chain keys.
const globalArgsSchema = modelDef.globalArguments;
const strictGlobalArgs = (
globalArgsSchema as unknown as { strict?(): typeof globalArgsSchema }
).strict?.() ?? globalArgsSchema;
const shape = getObjectShape(globalArgsSchema);
if (shape) {
const unknownKeys = Object.keys(staticArgs).filter(
(k) => !Object.hasOwn(shape, k),
);
if (unknownKeys.length > 0) {
const validKeys = Object.keys(shape).join(", ");
return Promise.resolve(ValidationResult.fail(
"Global arguments",
`Unknown global argument(s): ${
unknownKeys.join(", ")
}. Valid arguments are: ${validKeys || "none"}`,
));
}
}
return this.validateWithSchema(
"Global arguments",
strictGlobalArgs,
globalArgsSchema,
staticArgs,
);
}
Expand All @@ -515,10 +530,22 @@ export class DefaultModelValidationService implements ModelValidationService {
}

const methodArgsSchema = methodDef.arguments;
const strictMethodArgs = (
methodArgsSchema as unknown as { strict?(): typeof methodArgsSchema }
).strict?.() ?? methodArgsSchema;
const result = strictMethodArgs.safeParse(staticArgs);
const shape = getObjectShape(methodArgsSchema);
if (shape) {
const unknownKeys = Object.keys(staticArgs).filter(
(k) => !Object.hasOwn(shape, k),
);
if (unknownKeys.length > 0) {
const validKeys = Object.keys(shape).join(", ");
errors.push(
`Method "${methodName}": Unknown argument(s): ${
unknownKeys.join(", ")
}. Valid arguments are: ${validKeys || "none"}`,
);
continue;
}
}
const result = methodArgsSchema.safeParse(staticArgs);
if (!result.success) {
errors.push(
`Method "${methodName}": ${formatZodError(result.error)}`,
Expand Down
57 changes: 50 additions & 7 deletions src/domain/models/zod_type_coercion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,20 @@
import type { z } from "zod";

/**
* Internal Zod v4 definition structure for schema introspection.
* Internal Zod definition structure for schema introspection.
*
* Extension authors may import Zod v3 (`npm:zod@3`) or Zod v4 (`npm:zod@4`).
* The two versions name the type field differently (`typeName` in v3,
* `type` in v4) and store object shape differently (`shape()` function in
* v3, `shape` value in v4). Both fields are read here so the helpers work
* regardless of which Zod version the extension imports.
*/
interface ZodDef {
type: string;
type?: string;
typeName?: string;
innerType?: z.ZodTypeAny;
schema?: z.ZodTypeAny;
shape?: Record<string, z.ZodTypeAny>;
shape?: Record<string, z.ZodTypeAny> | (() => Record<string, z.ZodTypeAny>);
}

/**
Expand All @@ -37,10 +44,27 @@ function getSchemaDef(schema: z.ZodTypeAny): ZodDef {
}

/**
* Gets the definition type string from a Zod schema.
* Returns a normalized type name ("object", "optional", "effects", ...) for
* a Zod schema. Maps Zod v3 typeName values (e.g. "ZodObject") to Zod v4
* type values (e.g. "object").
*/
function getSchemaType(schema: z.ZodTypeAny): string {
return getSchemaDef(schema)?.type ?? "";
const def = getSchemaDef(schema);
if (!def) return "";
if (def.type) return def.type;
if (def.typeName) {
return def.typeName.replace(/^Zod/, "").toLowerCase();
}
return "";
}

/**
* Returns the field shape of a ZodObject, accepting both Zod v3 (where
* `_def.shape` is a function) and Zod v4 (where `_def.shape` is a value).
*/
function readShape(def: ZodDef): Record<string, z.ZodTypeAny> | undefined {
if (typeof def.shape === "function") return def.shape();
return def.shape;
}

/**
Expand Down Expand Up @@ -83,7 +107,8 @@ export function coerceMethodArgs(
}

const def = getSchemaDef(unwrapped);
if (!def.shape) {
const shape = readShape(def);
if (!shape) {
return args;
}

Expand All @@ -94,7 +119,7 @@ export function coerceMethodArgs(
continue;
}

const fieldSchema = def.shape[key];
const fieldSchema = shape[key];
if (!fieldSchema) {
continue;
}
Expand All @@ -117,3 +142,21 @@ export function coerceMethodArgs(

return result;
}

/**
* Returns the field shape of the inner ZodObject for a schema, unwrapping
* optional/nullable/default/effects wrappers. Returns undefined when the
* schema does not resolve to a ZodObject (e.g. a primitive or a union).
*
* Used to detect unknown keys passed via CLI flags before Zod's default
* strip mode silently discards them.
*/
export function getObjectShape(
schema: z.ZodTypeAny,
): Record<string, z.ZodTypeAny> | undefined {
const unwrapped = unwrapSchema(schema);
if (getSchemaType(unwrapped) !== "object") {
return undefined;
}
return readShape(getSchemaDef(unwrapped));
}
62 changes: 61 additions & 1 deletion src/domain/models/zod_type_coercion_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

import { assertEquals } from "@std/assert";
import { z } from "zod";
import { coerceMethodArgs } from "./zod_type_coercion.ts";
import { coerceMethodArgs, getObjectShape } from "./zod_type_coercion.ts";

Deno.test("coerces string 'true' to boolean true", () => {
const schema = z.object({ enabled: z.boolean() });
Expand Down Expand Up @@ -137,3 +137,63 @@ Deno.test("does not coerce empty string to number", () => {
const result = coerceMethodArgs({ count: "" }, schema);
assertEquals(result, { count: 0 });
});

// ---------- getObjectShape ----------

Deno.test("getObjectShape returns shape for plain ZodObject", () => {
const schema = z.object({ name: z.string(), count: z.number() });
const shape = getObjectShape(schema);
assertEquals(Object.keys(shape ?? {}).sort(), ["count", "name"]);
});

Deno.test("getObjectShape returns shape for ZodObject wrapped in .refine()", () => {
const schema = z.object({ name: z.string() }).refine(
(v) => v.name.length > 0,
);
const shape = getObjectShape(schema);
assertEquals(Object.keys(shape ?? {}), ["name"]);
});

Deno.test("getObjectShape returns shape for ZodObject wrapped in .optional()", () => {
const schema = z.object({ name: z.string() }).optional();
const shape = getObjectShape(schema);
assertEquals(Object.keys(shape ?? {}), ["name"]);
});

Deno.test("getObjectShape returns undefined for non-object schema", () => {
const schema = z.string();
assertEquals(getObjectShape(schema), undefined);
});

Deno.test("getObjectShape handles Zod v3 internal structure (typeName + shape function)", () => {
// Extensions may import npm:zod@3, whose ZodObject stores its type as
// `_def.typeName` ("ZodObject") and its shape as a function `_def.shape()`.
// This test simulates that structure to ensure compatibility.
const v3LikeSchema = {
_def: {
typeName: "ZodObject",
shape: () => ({
name: z.string(),
count: z.number(),
}),
},
} as unknown as z.ZodTypeAny;
const shape = getObjectShape(v3LikeSchema);
assertEquals(Object.keys(shape ?? {}).sort(), ["count", "name"]);
});

Deno.test("getObjectShape handles Zod v3 ZodEffects (typeName + .schema)", () => {
const v3LikeRefined = {
_def: {
typeName: "ZodEffects",
schema: {
_def: {
typeName: "ZodObject",
shape: () => ({ name: z.string() }),
},
},
},
} as unknown as z.ZodTypeAny;
const shape = getObjectShape(v3LikeRefined);
assertEquals(Object.keys(shape ?? {}), ["name"]);
});
31 changes: 23 additions & 8 deletions src/libswamp/models/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
zodToJsonSchema,
} from "../types/schema_helpers.ts";
import { coerceInputTypes } from "../../domain/inputs/mod.ts";
import { getObjectShape } from "../../domain/models/zod_type_coercion.ts";

import { withGeneratorSpan } from "../../infrastructure/tracing/mod.ts";
/**
Expand Down Expand Up @@ -161,16 +162,30 @@ export async function* modelCreate(
jsonSchema as Record<string, unknown>,
);
const globalArgsSchema = modelDef.globalArguments;
const strictGlobalArgs = (
globalArgsSchema as unknown as {
strict?(): typeof globalArgsSchema;
const shape = getObjectShape(globalArgsSchema);
if (shape) {
const unknownKeys = Object.keys(globalArguments).filter(
(k) => !Object.hasOwn(shape, k),
);
if (unknownKeys.length > 0) {
const validKeys = Object.keys(shape).join(", ");
yield {
kind: "error",
error: validationFailed(
`Unknown global argument(s) for type '${modelType.normalized}': ${
unknownKeys.join(", ")
}. Valid arguments are: ${validKeys || "none"}`,
),
};
return;
}
).strict?.() ?? globalArgsSchema;
const result = strictGlobalArgs.safeParse(globalArguments);
}
const result = globalArgsSchema.safeParse(globalArguments);
if (!result.success) {
const issues = result.error.issues.map((i) =>
` ${i.path.join(".")}: ${i.message}`
).join("\n");
const issues = result.error.issues.map((i) => {
const path = i.path.length > 0 ? `${i.path.join(".")}: ` : "";
return ` ${path}${i.message}`;
}).join("\n");
yield {
kind: "error",
error: validationFailed(
Expand Down
63 changes: 63 additions & 0 deletions src/libswamp/models/create_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,69 @@ Deno.test("modelCreate: accepts all known global arg keys", async () => {
assertEquals(last.kind, "completed");
});

Deno.test("modelCreate: rejects prototype-chain key like 'constructor' as global arg", async () => {
const globalArgsSchema = z.object({ name: z.string() });

const deps = makeDeps({
getModelDef: () => ({
type: { normalized: "test/strict" },
version: "1.0.0",
globalArguments: globalArgsSchema,
methods: {},
resources: {},
} as unknown as ModelDefinition),
});

const events = await collect<ModelCreateEvent>(
modelCreate(createLibSwampContext(), deps, {
typeArg: "test/strict",
name: "my-model",
globalArguments: { name: "hello", constructor: "oops" },
}),
);

const last = events[events.length - 1] as Extract<
ModelCreateEvent,
{ kind: "error" }
>;
assertEquals(last.kind, "error");
assertEquals(last.error.code, "validation_failed");
assertEquals(last.error.message.includes("constructor"), true);
});

Deno.test("modelCreate: rejects unknown global arg when schema uses .refine()", async () => {
const globalArgsSchema = z.object({ name: z.string() }).refine(
(v) => v.name.length > 0,
{ message: "name cannot be empty" },
);

const deps = makeDeps({
getModelDef: () => ({
type: { normalized: "test/refined" },
version: "1.0.0",
globalArguments: globalArgsSchema,
methods: {},
resources: {},
} as unknown as ModelDefinition),
});

const events = await collect<ModelCreateEvent>(
modelCreate(createLibSwampContext(), deps, {
typeArg: "test/refined",
name: "my-model",
globalArguments: { name: "hello", typoKey: "oops" },
}),
);

const last = events[events.length - 1] as Extract<
ModelCreateEvent,
{ kind: "error" }
>;
assertEquals(last.kind, "error");
assertEquals(last.error.code, "validation_failed");
assertEquals(last.error.message.includes("typoKey"), true);
});

Deno.test("modelCreate: yields error when name already exists", async () => {
const deps = makeDeps({
findByNameGlobal: () => Promise.resolve(true),
Expand Down
Loading
Loading