#!/usr/bin/env bun // Ships the zod-singleton invariant DOWNSTREAM to every ctl product. // // norsk-studio consolidated onto one hoisted zod 4 (2026-08) and guards it with // its own scripts/check-zod-singleton.mjs — but that guard is repo-local and was // never shipped, so the products are the one layer where studio's `zod ^4.4.3`, // norsk-ctl's exact `zod 4.4.3` (pinned for @modelcontextprotocol/sdk) and // @norskvideo/moq-watch's `zod ^4.5.4` all meet, with the loosest declared range // (`^4.3.6`) and nothing checking the result. That is how probe went red and how // funke/reuters ended up latently split: the tree resolves TWO zod instances and // a schema handed across the boundary (e.g. ManifestSchema from ctl-sdk into // zod-openapi) stops typechecking. // // This is the products' analogue of that guard, run in the `checks` quality job // (conventions/checks.yml) the same way as check-drift.ts — invoked by its // node_modules path, no exports entry needed. // // The invariant: the resolved graph carries exactly ONE zod 4 version. Nested // zod 3.x (a dev-only tool npm/bun isolates) is tolerated and only noted. Any // second distinct 4.x — the split — fails, and the fix is a single-version pin // in the root package.json `overrides`, matching the exact zod the @norskvideo/ctl-* // packages depend on (create-product emits it as PRODUCT_ZOD). That is 4.4.3: // @modelcontextprotocol/sdk does not work with 4.5 whatever its range says, so // do not pin products up past it. import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; // bun.lock is JSONC — it carries trailing commas that JSON.parse and Bun's own // .json() both reject. It has no line/block comments (the `//` you see live // inside URL and sha512 strings), so stripping ONLY a comma that immediately // precedes a closing } or ] is safe and leaves those strings untouched. function parseBunLock(text: string): { packages?: Record } { return JSON.parse(text.replace(/,(\s*[}\]])/g, "$1")); } const ZOD_4 = /^[~^]?4\./; const DEP_SECTIONS = ["dependencies", "devDependencies", "optionalDependencies", "overrides"] as const; // Retired shims from the zod-4 consolidation — a re-appearance means a second // zod is being smuggled back in behind an alias. const BANNED = ["openapi-zod-client", "@zodios/core", "zod-v4", "zod-v3"]; export interface ZodSingletonResult { ok: boolean; errors: string[]; notes: string[]; } export function checkZodSingleton(repoRoot: string): ZodSingletonResult { const errors: string[] = []; const notes: string[] = []; const rootPkgPath = join(repoRoot, "package.json"); if (!existsSync(rootPkgPath)) { return { ok: false, errors: [`no package.json at ${repoRoot}`], notes }; } const rootPkg = JSON.parse(readFileSync(rootPkgPath, "utf8")); // 1. Manifests: root + every workspace. If a manifest declares zod at all it // must be a zod 4 range, and the retired shims must stay gone. const workspaces: string[] = Array.isArray(rootPkg.workspaces) ? rootPkg.workspaces : []; const manifests = ["package.json", ...workspaces.map((w) => join(w, "package.json"))].filter((rel) => existsSync(join(repoRoot, rel)), ); for (const rel of manifests) { const pkg = JSON.parse(readFileSync(join(repoRoot, rel), "utf8")); for (const section of DEP_SECTIONS) { const deps = (pkg[section] ?? {}) as Record; const range = deps.zod; if (range !== undefined && !ZOD_4.test(range)) { errors.push(`${rel}: ${section}.zod is "${range}" — must be a zod 4 range`); } for (const name of BANNED) { if (deps[name] !== undefined) { errors.push(`${rel}: ${section} declares retired package "${name}"`); } } } } // 2. Lockfile: every resolved zod must be the same 4.x. bun.lock keys a zod // package as "zod" (hoisted) or "/zod" (nested); the resolved id is // entry[0], e.g. "zod@4.5.4". Match "zod@" exactly so @zod/mini is not swept // in. Collect the distinct versions and split them into 4.x and legacy 3.x. const lockPath = join(repoRoot, "bun.lock"); if (!existsSync(lockPath)) { errors.push("no bun.lock — cannot verify the resolved zod graph"); } else { const lock = parseBunLock(readFileSync(lockPath, "utf8")); const versions = new Map(); // version -> the lock keys carrying it const ctlZod = new Map(); // exact zod a ctl-* package declares -> those packages for (const [key, entry] of Object.entries(lock.packages ?? {})) { const id = Array.isArray(entry) ? String(entry[0] ?? "") : ""; if (id.startsWith("@norskvideo/ctl-")) { const meta = (Array.isArray(entry) ? entry[2] : undefined) as | { dependencies?: Record } | undefined; const declared = meta?.dependencies?.zod; if (declared !== undefined && /^\d/.test(declared)) { const pkgs = ctlZod.get(declared) ?? []; pkgs.push(id.slice(0, id.lastIndexOf("@"))); ctlZod.set(declared, pkgs); } } if (!id.startsWith("zod@")) continue; const version = id.slice("zod@".length); const keys = versions.get(version) ?? []; keys.push(key || ""); versions.set(version, keys); } const byVersion = [...versions.entries()].sort(([a], [b]) => a.localeCompare(b)); const v4 = byVersion.filter(([v]) => v.startsWith("4.")); const v3 = byVersion.filter(([v]) => v.startsWith("3.")); const other = byVersion.filter(([v]) => !v.startsWith("4.") && !v.startsWith("3.")); for (const [v, keys] of v3) notes.push(`dev-only nested zod ${v} tolerated (${keys.join(", ")})`); for (const [v, keys] of other) { errors.push(`bun.lock: unexpected zod ${v} (${keys.join(", ")}) — only zod 4 is permitted`); } // The ctl-* packages pin zod exactly (4.4.3: @modelcontextprotocol/sdk does not // work with 4.5 whatever its range says). A product's one zod must be that one. const required = ctlZod.size === 1 ? [...ctlZod.keys()][0] : undefined; if (ctlZod.size > 1) { const detail = [...ctlZod].map(([v, pkgs]) => ` zod ${v}: ${pkgs.join(", ")}`).join("\n"); errors.push( `bun.lock: the @norskvideo/ctl-* packages disagree on their zod —\n${detail}\n Align their versions.`, ); } if (v4.length === 0) { errors.push("bun.lock: no zod 4 resolved — expected exactly one"); } else if (v4.length > 1) { const detail = v4.map(([v, keys]) => ` zod@${v}: ${keys.join(", ")}`).join("\n"); const suggested = required ?? v4.find(([, keys]) => keys.some((k) => k.startsWith("@norskvideo/ctl-")))?.[0] ?? v4.at(-1)?.[0]; errors.push( `bun.lock: the zod graph is SPLIT across ${v4.length} versions —\n${detail}\n` + ` A schema crossing this boundary (e.g. ManifestSchema from @norskvideo/ctl-sdk\n` + ` into zod-openapi) will fail to typecheck. Pin ONE version in the root\n` + ` package.json overrides — e.g. "overrides": { "zod": "${suggested}" } — matching\n` + ` the zod your @norskvideo/ctl-* packages resolve, then re-run bun install.`, ); } else if (required !== undefined && v4[0]?.[0] !== required) { errors.push( `bun.lock: zod resolves to ${v4[0]?.[0]}, but the @norskvideo/ctl-* packages pin ${required}.\n` + ` Set "overrides": { "zod": "${required}" } in the root package.json and declare\n` + ` zod ${required} in each workspace, then re-run bun install.`, ); } } return { ok: errors.length === 0, errors, notes }; } // Run directly (conventions/checks.yml invokes this by its node_modules path, // like check-drift.ts): first arg is the repo root, defaulting to cwd. if (import.meta.main) { const repoRoot = process.argv[2] ?? process.cwd(); const { ok, errors, notes } = checkZodSingleton(repoRoot); for (const n of notes) console.log(`note: ${n}`); if (!ok) { console.error("zod singleton check FAILED:"); for (const e of errors) console.error(` - ${e}`); process.exit(1); } console.log("zod singleton check OK: one zod 4 across the resolved graph"); }