fix(workflow-utils): dryRun llmExtract returns schema-shaped defaults

Add schemaDefaults() from Zod def types; export from package; tests for nested/array/enum/optional.

Made-with: Cursor
This commit is contained in:
2026-04-25 04:31:46 +00:00
parent 111b7e2734
commit 7c999a0689
5 changed files with 255 additions and 3 deletions
@@ -108,7 +108,7 @@ describe("llmExtract", () => {
expect(result.error.kind).toBe("schema_validation_failed");
});
it("dryRun skips fetch and returns an empty stub value", async () => {
it("dryRun skips fetch and returns schema-shaped stub values", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
@@ -125,6 +125,6 @@ describe("llmExtract", () => {
if (!result.ok) {
return;
}
expect(result.value).toEqual({});
expect(result.value).toEqual({ n: 0 });
});
});
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { z } from "zod";
import { schemaDefaults } from "../schema-defaults.js";
describe("schemaDefaults", () => {
it("fills nested objects with primitive placeholders", () => {
const schema = z.object({
meta: z.object({
id: z.string(),
count: z.number(),
flag: z.boolean(),
}),
});
expect(schemaDefaults(schema)).toEqual({
meta: { id: "", count: 0, flag: false },
});
});
it("uses empty arrays for array fields", () => {
const schema = z.object({
roles: z.array(z.object({ name: z.string(), level: z.number() })),
});
const out = schemaDefaults(schema) as { roles: { name: string; level: number }[] };
expect(out.roles).toEqual([]);
expect(out.roles.map((r) => r.name)).toEqual([]);
});
it("uses the first enum value", () => {
const schema = z.object({
status: z.enum(["pending", "done", "failed"]),
code: z.nativeEnum({ A: 1, B: 2 }),
});
expect(schemaDefaults(schema)).toEqual({
status: "pending",
code: 1,
});
});
it("sets optional fields to undefined and omits exactOptional keys", () => {
const schema = z.object({
req: z.string(),
maybe: z.string().optional(),
exact: z.string().exactOptional(),
});
expect(schemaDefaults(schema)).toEqual({
req: "",
maybe: undefined,
});
expect(Object.keys(schemaDefaults(schema) as object).includes("exact")).toBe(false);
});
it("respects .default()", () => {
const schema = z.object({
n: z.number().default(42),
});
expect(schemaDefaults(schema)).toEqual({ n: 42 });
});
});
+1
View File
@@ -11,6 +11,7 @@ export {
type LlmExtractOptions,
type LlmProvider,
} from "./llm-extract.js";
export { schemaDefaults } from "./schema-defaults.js";
export {
nerveCommandEnv,
spawnSafe,
+3 -1
View File
@@ -1,6 +1,8 @@
import { type Result, err, ok } from "@uncaged/nerve-core";
import { toJSONSchema, type z } from "zod";
import { schemaDefaults } from "./schema-defaults.js";
export type LlmProvider = {
baseUrl: string;
apiKey: string;
@@ -102,7 +104,7 @@ export async function llmExtract<T>(
): Promise<Result<T, LlmError>> {
const dryRun = resolveLlmExtractDryRun(options);
if (dryRun) {
return ok({} as T);
return ok(schemaDefaults(options.schema) as T);
}
const rawJsonSchema = toJSONSchema(options.schema) as Record<string, unknown>;
@@ -0,0 +1,190 @@
import type { z } from "zod";
type ZodTypeAny = z.ZodType;
type Def = Record<string, unknown> & { type: string };
type TypeHandler = (schema: ZodTypeAny, def: Def) => unknown;
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isZodExactOptional(s: ZodTypeAny): boolean {
return s.constructor.name === "ZodExactOptional";
}
function resolveDefaultValue(defaultValue: unknown | (() => unknown)): unknown {
if (typeof defaultValue === "function") {
return (defaultValue as () => unknown)();
}
return defaultValue;
}
function mergeIntersection(left: unknown, right: unknown): unknown {
if (isPlainObject(left) && isPlainObject(right)) {
return { ...left, ...right };
}
return right;
}
function defaultsForObject(_schema: ZodTypeAny, def: Def): unknown {
const shape = def.shape as Record<string, ZodTypeAny> | undefined;
if (shape === undefined) {
return {};
}
const out: Record<string, unknown> = {};
for (const key of Object.keys(shape)) {
const child = shape[key];
const cdef = child.def as { type: string };
if (cdef.type === "optional") {
if (isZodExactOptional(child)) {
continue;
}
out[key] = undefined;
} else {
out[key] = schemaDefaultsInner(child);
}
}
return out;
}
function firstUnionOption(_schema: ZodTypeAny, def: Def): unknown {
const options = def.options as readonly ZodTypeAny[] | undefined;
if (options === undefined || options.length === 0) {
return null;
}
return schemaDefaultsInner(options[0]);
}
function defaultsFromNullable(_schema: ZodTypeAny, _def: Def): unknown {
return null;
}
function defaultsFromInner(_schema: ZodTypeAny, def: Def): unknown {
const inner = def.innerType as ZodTypeAny | undefined;
if (inner === undefined) {
return null;
}
return schemaDefaultsInner(inner);
}
function defaultsForPipe(_schema: ZodTypeAny, def: Def): unknown {
const out = def.out as ZodTypeAny | undefined;
if (out === undefined) {
return null;
}
return schemaDefaultsInner(out);
}
function defaultsForIntersection(_schema: ZodTypeAny, def: Def): unknown {
const left = def.left as ZodTypeAny | undefined;
const right = def.right as ZodTypeAny | undefined;
if (left === undefined || right === undefined) {
return null;
}
return mergeIntersection(schemaDefaultsInner(left), schemaDefaultsInner(right));
}
function defaultsForTuple(_schema: ZodTypeAny, def: Def): unknown {
const items = def.items as readonly ZodTypeAny[] | undefined;
if (items === undefined) {
return [];
}
return items.map((item) => schemaDefaultsInner(item));
}
function defaultsForLazy(schema: ZodTypeAny, def: Def): unknown {
const inner =
(schema as { _zod?: { innerType?: ZodTypeAny } })._zod?.innerType ??
(def.getter as (() => ZodTypeAny) | undefined)?.();
if (inner === undefined) {
return null;
}
return schemaDefaultsInner(inner);
}
function defaultsForPromise(_schema: ZodTypeAny, def: Def): unknown {
const inner = def.innerType as ZodTypeAny | undefined;
if (inner === undefined) {
return Promise.resolve(null);
}
return Promise.resolve(schemaDefaultsInner(inner));
}
function firstEnumValue(_schema: ZodTypeAny, def: Def): unknown {
const entries = def.entries as Record<string, string | number> | undefined;
if (entries === undefined) {
return null;
}
const values = Object.values(entries);
return values[0] ?? null;
}
function firstLiteralValue(_schema: ZodTypeAny, def: Def): unknown {
const values = def.values as unknown[] | undefined;
if (values === undefined || values.length === 0) {
return null;
}
return values[0];
}
const TYPE_HANDLERS: Record<string, TypeHandler> = {
string: () => "",
number: () => 0,
boolean: () => false,
bigint: () => 0n,
date: () => new Date(0),
symbol: () => Symbol(),
undefined: () => undefined,
null: () => null,
void: () => undefined,
any: () => null,
unknown: () => null,
never: () => undefined,
nan: () => Number.NaN,
array: () => [],
object: defaultsForObject,
record: () => ({}),
map: () => new Map(),
set: () => new Set(),
enum: firstEnumValue,
literal: firstLiteralValue,
optional: () => undefined,
nullable: defaultsFromNullable,
default: (_s, def) => resolveDefaultValue(def.defaultValue as unknown | (() => unknown)),
prefault: (_s, def) => resolveDefaultValue(def.defaultValue as unknown | (() => unknown)),
nonoptional: defaultsFromInner,
catch: defaultsFromInner,
success: () => false,
readonly: defaultsFromInner,
union: firstUnionOption,
xor: firstUnionOption,
intersection: defaultsForIntersection,
pipe: defaultsForPipe,
transform: () => null,
tuple: defaultsForTuple,
lazy: defaultsForLazy,
promise: defaultsForPromise,
file: () => new File([], ""),
function: () => null,
custom: () => null,
template_literal: () => "",
};
/**
* Produces a structurally valid placeholder that mirrors primitive/array/object
* shape for a Zod schema. Used for `llmExtract` dry runs so downstream code
* (e.g. `.roles.map`) does not throw on `undefined` fields.
*/
export function schemaDefaults(schema: z.ZodType): unknown {
return schemaDefaultsInner(schema as ZodTypeAny);
}
function schemaDefaultsInner(schema: ZodTypeAny): unknown {
const def = schema.def as Def;
const run = TYPE_HANDLERS[def.type];
if (run === undefined) {
return null;
}
return run(schema, def);
}