feat: add collectCasRefs — extract CAS refs from schema meta annotations
Replaces manual extractRefs functions with declarative schema-level
casRef annotations. Walks Zod v4 schemas recursively, collecting
string values from fields marked with .meta({ casRef: true }).
Supports: objects, arrays, discriminatedUnion, nullable/optional.
8/8 test cases pass (flat, nested, union, null, mixed).
Refs #285, addresses #286
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import * as z from "zod/v4";
|
||||
import { collectCasRefs } from "../src/collect-cas-refs.js";
|
||||
|
||||
const phaseSchema = z.object({
|
||||
hash: z.string().meta({ casRef: true }),
|
||||
title: z.string(),
|
||||
});
|
||||
|
||||
const plannerMetaSchema = z.discriminatedUnion("status", [
|
||||
z.object({
|
||||
status: z.literal("planned"),
|
||||
phases: z.array(phaseSchema),
|
||||
}),
|
||||
z.object({
|
||||
status: z.literal("aborted"),
|
||||
reason: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
describe("collectCasRefs", () => {
|
||||
test("1. flat field with casRef annotation", () => {
|
||||
const schema = z.object({
|
||||
completedPhase: z.string().meta({ casRef: true }),
|
||||
});
|
||||
expect(collectCasRefs(schema, { completedPhase: "BHAAAAAAAAAAA" })).toEqual(["BHAAAAAAAAAAA"]);
|
||||
});
|
||||
|
||||
test("2. plain string without annotation is ignored", () => {
|
||||
const schema = z.object({
|
||||
summary: z.string(),
|
||||
completedPhase: z.string().meta({ casRef: true }),
|
||||
});
|
||||
expect(
|
||||
collectCasRefs(schema, {
|
||||
summary: "done",
|
||||
completedPhase: "BHBBBBBBBBBBB",
|
||||
}),
|
||||
).toEqual(["BHBBBBBBBBBBB"]);
|
||||
});
|
||||
|
||||
test("3. nested array of objects collects each annotated hash", () => {
|
||||
const schema = z.object({
|
||||
phases: z.array(phaseSchema),
|
||||
});
|
||||
expect(
|
||||
collectCasRefs(schema, {
|
||||
phases: [
|
||||
{ hash: "BH11111111111", title: "setup" },
|
||||
{ hash: "BH22222222222", title: "impl" },
|
||||
],
|
||||
}),
|
||||
).toEqual(["BH11111111111", "BH22222222222"]);
|
||||
});
|
||||
|
||||
test("4. discriminatedUnion — planner planned branch", () => {
|
||||
expect(
|
||||
collectCasRefs(plannerMetaSchema, {
|
||||
status: "planned",
|
||||
phases: [
|
||||
{ hash: "BH33333333333", title: "a" },
|
||||
{ hash: "BH44444444444", title: "b" },
|
||||
],
|
||||
}),
|
||||
).toEqual(["BH33333333333", "BH44444444444"]);
|
||||
});
|
||||
|
||||
test("4b. discriminatedUnion — planner aborted branch", () => {
|
||||
expect(
|
||||
collectCasRefs(plannerMetaSchema, {
|
||||
status: "aborted",
|
||||
reason: "missing workspace",
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("5. null and undefined annotated fields are skipped", () => {
|
||||
const schema = z.object({
|
||||
ref: z.string().meta({ casRef: true }).nullable(),
|
||||
optionalRef: z.string().meta({ casRef: true }).optional(),
|
||||
});
|
||||
expect(collectCasRefs(schema, { ref: null, optionalRef: undefined })).toEqual([]);
|
||||
expect(collectCasRefs(schema, { ref: "BH55555555555", optionalRef: undefined })).toEqual([
|
||||
"BH55555555555",
|
||||
]);
|
||||
});
|
||||
|
||||
test("6. mixed annotated and plain fields at multiple levels", () => {
|
||||
const schema = z.object({
|
||||
label: z.string(),
|
||||
phase: z.object({
|
||||
hash: z.string().meta({ casRef: true }),
|
||||
title: z.string(),
|
||||
}),
|
||||
tags: z.array(z.string()),
|
||||
});
|
||||
expect(
|
||||
collectCasRefs(schema, {
|
||||
label: "coder",
|
||||
phase: { hash: "BH66666666666", title: "fix" },
|
||||
tags: ["a", "b"],
|
||||
}),
|
||||
).toEqual(["BH66666666666"]);
|
||||
});
|
||||
|
||||
test("7. empty phases array yields no refs", () => {
|
||||
expect(
|
||||
collectCasRefs(plannerMetaSchema, {
|
||||
status: "planned",
|
||||
phases: [],
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import * as z from "zod/v4";
|
||||
|
||||
type ZodSchema = z.ZodType;
|
||||
|
||||
type DefPipeIn = { in: ZodSchema };
|
||||
|
||||
function hasCasRef(schema: ZodSchema): boolean {
|
||||
const meta = z.globalRegistry.get(schema);
|
||||
return meta !== undefined && meta.casRef === true;
|
||||
}
|
||||
|
||||
function walkOptional(schema: z.ZodOptional<ZodSchema>, data: unknown): string[] {
|
||||
if (data === undefined) {
|
||||
return [];
|
||||
}
|
||||
return walkCasRefs(schema.unwrap(), data);
|
||||
}
|
||||
|
||||
function walkNullable(schema: z.ZodNullable<ZodSchema>, data: unknown): string[] {
|
||||
if (data === null) {
|
||||
return [];
|
||||
}
|
||||
return walkCasRefs(schema.unwrap(), data);
|
||||
}
|
||||
|
||||
function walkDefault(schema: z.ZodDefault<ZodSchema>, data: unknown): string[] {
|
||||
return walkCasRefs(schema.unwrap(), data);
|
||||
}
|
||||
|
||||
function walkPrefault(schema: z.ZodPrefault<ZodSchema>, data: unknown): string[] {
|
||||
return walkCasRefs(schema.unwrap(), data);
|
||||
}
|
||||
|
||||
function walkCatch(schema: z.ZodCatch<ZodSchema>, data: unknown): string[] {
|
||||
return walkCasRefs(schema.unwrap(), data);
|
||||
}
|
||||
|
||||
function walkReadonly(schema: z.ZodReadonly<ZodSchema>, data: unknown): string[] {
|
||||
return walkCasRefs(schema.unwrap(), data);
|
||||
}
|
||||
|
||||
function walkPipe(def: DefPipeIn, data: unknown): string[] {
|
||||
return walkCasRefs(def.in, data);
|
||||
}
|
||||
|
||||
function walkString(schema: ZodSchema, data: unknown): string[] {
|
||||
if (hasCasRef(schema) && typeof data === "string") {
|
||||
return [data];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function walkObject(schema: z.ZodObject<z.ZodRawShape>, data: unknown): string[] {
|
||||
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
||||
return [];
|
||||
}
|
||||
const record = data as Record<string, unknown>;
|
||||
const shape = schema.shape;
|
||||
const refs: string[] = [];
|
||||
for (const [key, fieldSchema] of Object.entries(shape)) {
|
||||
refs.push(...walkCasRefs(fieldSchema as ZodSchema, record[key]));
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
function walkArray(schema: z.ZodArray<ZodSchema>, data: unknown): string[] {
|
||||
if (!Array.isArray(data)) {
|
||||
return [];
|
||||
}
|
||||
const element = schema.element;
|
||||
const refs: string[] = [];
|
||||
for (const item of data) {
|
||||
refs.push(...walkCasRefs(element, item));
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
function walkUnion(schema: z.ZodUnion<readonly ZodSchema[]>, data: unknown): string[] {
|
||||
for (const option of schema.options) {
|
||||
const parsed = option.safeParse(data);
|
||||
if (parsed.success) {
|
||||
return walkCasRefs(option, data);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function walkCasRefs(schema: ZodSchema, data: unknown): string[] {
|
||||
const def = schema.def;
|
||||
|
||||
switch (def.type) {
|
||||
case "optional":
|
||||
return walkOptional(schema as z.ZodOptional<ZodSchema>, data);
|
||||
case "nullable":
|
||||
return walkNullable(schema as z.ZodNullable<ZodSchema>, data);
|
||||
case "default":
|
||||
return walkDefault(schema as z.ZodDefault<ZodSchema>, data);
|
||||
case "prefault":
|
||||
return walkPrefault(schema as z.ZodPrefault<ZodSchema>, data);
|
||||
case "catch":
|
||||
return walkCatch(schema as z.ZodCatch<ZodSchema>, data);
|
||||
case "readonly":
|
||||
return walkReadonly(schema as z.ZodReadonly<ZodSchema>, data);
|
||||
case "pipe":
|
||||
return walkPipe(def as unknown as DefPipeIn, data);
|
||||
case "string":
|
||||
return walkString(schema, data);
|
||||
case "object":
|
||||
return walkObject(schema as z.ZodObject<z.ZodRawShape>, data);
|
||||
case "array":
|
||||
return walkArray(schema as z.ZodArray<ZodSchema>, data);
|
||||
case "union":
|
||||
return walkUnion(schema as z.ZodUnion<readonly ZodSchema[]>, data);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Collect CAS content hashes from meta using `casRef` annotations on the Zod schema. */
|
||||
export function collectCasRefs(schema: ZodSchema, data: unknown): string[] {
|
||||
return walkCasRefs(schema, data);
|
||||
}
|
||||
@@ -26,5 +26,6 @@ export type {
|
||||
} from "@uncaged/workflow-protocol";
|
||||
export { END, START } from "@uncaged/workflow-protocol";
|
||||
export { buildThreadContext } from "./build-context.js";
|
||||
export { collectCasRefs } from "./collect-cas-refs.js";
|
||||
export { createWorkflow } from "./create-workflow.js";
|
||||
export { err, ok } from "./result.js";
|
||||
|
||||
Reference in New Issue
Block a user