/** * spec-parity — two REAL properties between a documented RAW spec and its * Zod-PARSED form (what `index.ts` hands to `generate()` since lot 1): * * (a) no-strip every key path of the RAW example — recursively, arrays by * index — survives in the parsed spec WITH THE SAME VALUE. * A schema that drops a key the renderer reads, or rewrites * a value, is red here (`stripped` / `changed`). * (b) defaults-only every key path present in the PARSED spec and absent from * the RAW one is a `.default()` / `.prefault()` DECLARED by * the schema at that path pattern, with that declared value. * A validate() that injects a derived key, a transform that * adds one, a default applied at the wrong path or with the * wrong value is red here (`undeclared`). * * QC 2026-09-24 (lot 13): the lot-1 "parity" tests compared * `generate(parse(x))` to `generate(parse(parse(x)))` — both operands were * schema output, so a stripping schema could never be detected. The oracles * here are the RAW document for (a) and the schema's DECLARED defaults, read * by introspection of the zod v4 node tree (`_zod.def`), for (b): neither is * the parse output. Zod v4 is the deployed skill runtime * (templates/skills/package.json); an unrecognised node throws — a silent * green on an unknown node would be the very defect this helper exists for. */ import type { ZodType } from 'zod' const isPlainObject = (v: unknown): v is Record => typeof v === 'object' && v !== null && !Array.isArray(v) /** Every key path of a JSON value, parents before children: `a`, `a.b`, `a.b[0]`, `a.b[0].c`. * A key holding `undefined` counts as absent (JSON semantics — what `--spec` carries). */ export function keyPaths(value: unknown, prefix = ''): string[] { const out: string[] = [] if (Array.isArray(value)) { value.forEach((item, i) => { const p = `${prefix}[${i}]` out.push(p, ...keyPaths(item, p)) }) } else if (isPlainObject(value)) { for (const [k, v] of Object.entries(value)) { if (v === undefined) continue const p = prefix ? `${prefix}.${k}` : k out.push(p, ...keyPaths(v, p)) } } return out } /** `a.b[2].c` → `['a', 'b', 2, 'c']`. */ function segments(path: string): Array { const out: Array = [] for (const m of path.matchAll(/([^.[\]]+)|\[(\d+)\]/g)) { if (m[2] !== undefined) out.push(Number(m[2])) else out.push(m[1]) } return out } /** The value at a key path, and whether the path exists (an `undefined` value counts as absent). */ export function getAt(value: unknown, path: string): { present: boolean; value: unknown } { let cur: unknown = value for (const seg of segments(path)) { if (typeof seg === 'number') { if (!Array.isArray(cur) || seg >= cur.length) return { present: false, value: undefined } cur = cur[seg] } else { if (!isPlainObject(cur) || !(seg in cur) || cur[seg] === undefined) return { present: false, value: undefined } cur = cur[seg] } } return { present: true, value: cur } } /** Structural equality with JSON semantics (`undefined`-valued keys are absent). */ export function deepEqual(a: unknown, b: unknown): boolean { if (Object.is(a, b)) return true if (Array.isArray(a) || Array.isArray(b)) { if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false return a.every((v, i) => deepEqual(v, b[i])) } if (isPlainObject(a) && isPlainObject(b)) { const ka = Object.keys(a).filter(k => a[k] !== undefined).sort() const kb = Object.keys(b).filter(k => b[k] !== undefined).sort() if (ka.length !== kb.length || ka.some((k, i) => k !== kb[i])) return false return ka.every(k => deepEqual(a[k], b[k])) } return false } // ─── (b) — the schema's DECLARED defaults, by introspection ────────────────── export interface DeclaredDefault { /** `.default()` returns the value as is (zod v4); `.prefault()` parses it through the inner schema. */ kind: 'default' | 'prefault' value: unknown inner: ZodType } /** The zod v4 node definition (`_zod.def`), loosely typed for the walk. */ interface ZodNodeDef { type?: string shape?: Record innerType?: ZodType element?: ZodType items?: ZodType[] options?: ZodType[] left?: ZodType right?: ZodType valueType?: ZodType keyType?: ZodType getter?: () => ZodType in?: ZodType out?: ZodType defaultValue?: unknown } function defOf(schema: ZodType): ZodNodeDef { const def = (schema as unknown as { _zod?: { def?: ZodNodeDef }; _def?: ZodNodeDef })._zod?.def ?? (schema as unknown as { _def?: ZodNodeDef })._def if (!def || typeof def.type !== 'string') { throw new Error('spec-parity: unrecognised Zod node — expected a zod v4 schema (`_zod.def.type`)') } return def } /** `entities[3].customActions[0].scope` → `entities[].customActions[].scope`. */ export function pathPattern(path: string): string { return path.replace(/\[\d+\]/g, '[]') } /** * Every `.default()` / `.prefault()` the schema declares, keyed by path pattern * (`fields[].required`, `pageFilePaths.*` for a record value). A union lists the * defaults of every option. Kinds with no children (string, enum, literal…) are leaves. */ export function declaredDefaults(schema: ZodType): Map { const acc = new Map() const visited = new WeakMap>() const walk = (node: ZodType, pattern: string): void => { const seen = visited.get(node) ?? new Set() if (seen.has(pattern)) return seen.add(pattern) visited.set(node, seen) const def = defOf(node) switch (def.type) { case 'default': case 'prefault': { const list = acc.get(pattern) ?? [] list.push({ kind: def.type, value: def.defaultValue, inner: def.innerType as ZodType }) acc.set(pattern, list) walk(def.innerType as ZodType, pattern) return } case 'optional': case 'nullable': case 'nonoptional': case 'readonly': case 'catch': case 'promise': if (def.innerType) walk(def.innerType, pattern) return case 'object': case 'interface': for (const [key, child] of Object.entries(def.shape ?? {})) walk(child, pattern ? `${pattern}.${key}` : key) return case 'array': if (def.element) walk(def.element, `${pattern}[]`) return case 'tuple': for (const item of def.items ?? []) walk(item, `${pattern}[]`) return case 'union': for (const option of def.options ?? []) walk(option, pattern) return case 'intersection': if (def.left) walk(def.left, pattern) if (def.right) walk(def.right, pattern) return case 'record': case 'map': if (def.valueType) walk(def.valueType, pattern ? `${pattern}.*` : '*') return case 'lazy': if (def.getter) walk(def.getter(), pattern) return case 'pipe': if (def.in) walk(def.in, pattern) if (def.out) walk(def.out, pattern) return default: return // a leaf (string, number, boolean, enum, literal, unknown, any, date, …) } } walk(schema, '') return acc } /** The declared defaults at a path — exact pattern first, then one record wildcard per segment. */ function declaredAt(defaults: Map, path: string): DeclaredDefault[] { const pattern = pathPattern(path) const exact = defaults.get(pattern) if (exact) return exact const parts = pattern.split('.') for (let i = 0; i < parts.length; i++) { const candidate = [...parts.slice(0, i), '*', ...parts.slice(i + 1)].join('.') const hit = defaults.get(candidate) if (hit) return hit } return [] } function expectedValue(d: DeclaredDefault): unknown { if (d.kind === 'default') return d.value const r = d.inner.safeParse(d.value) return r.success ? r.data : d.value } // ─── The report ────────────────────────────────────────────────────────────── export interface AddedPath { path: string /** What the parse put there. */ value: unknown /** The declared default(s) found at that path pattern, as the parse would apply them (empty: none declared). */ declared: unknown[] matches: boolean } export interface ParityReport { /** Every key path of the RAW example — what (a) verified. */ rawPaths: string[] /** (a) Present in RAW, absent in PARSED — the SHALLOWEST missing path only (a stripped key). */ stripped: string[] /** (a) Present in both with a different value (a rewritten key; a type change lists here too). */ changed: Array<{ path: string; raw: unknown; parsed: unknown }> /** (b) Present in PARSED only — the shallowest added path, with the declared default it was checked against. */ added: AddedPath[] /** (b) Subset of `added` that matches NO declared default (or a default with another value). */ undeclared: string[] } /** * Compare a RAW spec to its PARSED form against the schema's declared defaults. * `parsed` is what the CLI runs on — `validate(raw).data`, or `Schema.parse(raw)`. */ export function specParity(raw: unknown, parsed: unknown, schema: ZodType): ParityReport { const report: ParityReport = { rawPaths: keyPaths(raw), stripped: [], changed: [], added: [], undeclared: [] } // (a) — walk the RAW side; stop at the first missing ancestor. const walkRaw = (r: unknown, p: unknown, prefix: string): void => { if (Array.isArray(r)) { if (!Array.isArray(p)) { report.changed.push({ path: prefix, raw: r, parsed: p }); return } r.forEach((item, i) => { const path = `${prefix}[${i}]` if (i >= p.length) { report.stripped.push(path); return } walkRaw(item, p[i], path) }) return } if (isPlainObject(r)) { if (!isPlainObject(p)) { report.changed.push({ path: prefix, raw: r, parsed: p }); return } for (const [k, v] of Object.entries(r)) { if (v === undefined) continue const path = prefix ? `${prefix}.${k}` : k if (!(k in p) || p[k] === undefined) { report.stripped.push(path); continue } walkRaw(v, p[k], path) } return } if (!Object.is(r, p)) report.changed.push({ path: prefix, raw: r, parsed: p }) } walkRaw(raw, parsed, '') // (b) — walk the PARSED side; an added root is checked against the declared defaults, not descended. const defaults = declaredDefaults(schema) const walkParsed = (r: unknown, p: unknown, prefix: string): void => { if (Array.isArray(p) && Array.isArray(r)) { p.forEach((item, i) => { const path = `${prefix}[${i}]` if (i >= r.length) record(path, item) else walkParsed(r[i], item, path) }) return } if (isPlainObject(p) && isPlainObject(r)) { for (const [k, v] of Object.entries(p)) { if (v === undefined) continue const path = prefix ? `${prefix}.${k}` : k if (!(k in r) || r[k] === undefined) record(path, v) else walkParsed(r[k], v, path) } } } const record = (path: string, value: unknown): void => { const declared = declaredAt(defaults, path).map(expectedValue) const matches = declared.some(d => deepEqual(value, d)) report.added.push({ path, value, declared, matches }) if (!matches) report.undeclared.push(path) } walkParsed(raw, parsed, '') return report }