From 93b7947d7c7c93291c9a04dc0d3932a793a6f372 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Sat, 16 May 2026 10:34:53 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20add=20collectCasRefs=20=E2=80=94=20extr?= =?UTF-8?q?act=20CAS=20refs=20from=20schema=20meta=20annotations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../__tests__/collect-cas-refs.test.ts | 114 ++++++++++++++++ .../workflow-runtime/src/collect-cas-refs.ts | 122 ++++++++++++++++++ packages/workflow-runtime/src/index.ts | 1 + 3 files changed, 237 insertions(+) create mode 100644 packages/workflow-runtime/__tests__/collect-cas-refs.test.ts create mode 100644 packages/workflow-runtime/src/collect-cas-refs.ts diff --git a/packages/workflow-runtime/__tests__/collect-cas-refs.test.ts b/packages/workflow-runtime/__tests__/collect-cas-refs.test.ts new file mode 100644 index 0000000..dbdfc2b --- /dev/null +++ b/packages/workflow-runtime/__tests__/collect-cas-refs.test.ts @@ -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([]); + }); +}); diff --git a/packages/workflow-runtime/src/collect-cas-refs.ts b/packages/workflow-runtime/src/collect-cas-refs.ts new file mode 100644 index 0000000..59e7e14 --- /dev/null +++ b/packages/workflow-runtime/src/collect-cas-refs.ts @@ -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, data: unknown): string[] { + if (data === undefined) { + return []; + } + return walkCasRefs(schema.unwrap(), data); +} + +function walkNullable(schema: z.ZodNullable, data: unknown): string[] { + if (data === null) { + return []; + } + return walkCasRefs(schema.unwrap(), data); +} + +function walkDefault(schema: z.ZodDefault, data: unknown): string[] { + return walkCasRefs(schema.unwrap(), data); +} + +function walkPrefault(schema: z.ZodPrefault, data: unknown): string[] { + return walkCasRefs(schema.unwrap(), data); +} + +function walkCatch(schema: z.ZodCatch, data: unknown): string[] { + return walkCasRefs(schema.unwrap(), data); +} + +function walkReadonly(schema: z.ZodReadonly, 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, data: unknown): string[] { + if (typeof data !== "object" || data === null || Array.isArray(data)) { + return []; + } + const record = data as Record; + 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, 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, 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, data); + case "nullable": + return walkNullable(schema as z.ZodNullable, data); + case "default": + return walkDefault(schema as z.ZodDefault, data); + case "prefault": + return walkPrefault(schema as z.ZodPrefault, data); + case "catch": + return walkCatch(schema as z.ZodCatch, data); + case "readonly": + return walkReadonly(schema as z.ZodReadonly, 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, data); + case "array": + return walkArray(schema as z.ZodArray, data); + case "union": + return walkUnion(schema as z.ZodUnion, 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); +} diff --git a/packages/workflow-runtime/src/index.ts b/packages/workflow-runtime/src/index.ts index 3a7a136..01295e8 100644 --- a/packages/workflow-runtime/src/index.ts +++ b/packages/workflow-runtime/src/index.ts @@ -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"; -- 2.43.0