@norskvideo/ctl-dev-kit 0.1.93 → 0.1.94
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/conventions/checks.yml +5 -0
- package/conventions/zod-singleton.ts +141 -0
- package/package.json +1 -1
package/conventions/checks.yml
CHANGED
|
@@ -180,6 +180,11 @@ jobs:
|
|
|
180
180
|
bun run typecheck
|
|
181
181
|
bun run test:unit
|
|
182
182
|
bun run docs:check
|
|
183
|
+
# The tree must resolve ONE zod 4 (conventions/zod-singleton.ts).
|
|
184
|
+
# studio guards this in-repo but never shipped the guard; products
|
|
185
|
+
# are where studio/ctl/moq-watch zod ranges meet, so a split slips
|
|
186
|
+
# in here and breaks a schema handed across the instance boundary.
|
|
187
|
+
bun node_modules/@norskvideo/ctl-dev-kit/conventions/zod-singleton.ts
|
|
183
188
|
'
|
|
184
189
|
|
|
185
190
|
# The two halves of one licence guarantee, run together because they only
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
// Ships the zod-singleton invariant DOWNSTREAM to every ctl product.
|
|
4
|
+
//
|
|
5
|
+
// norsk-studio consolidated onto one hoisted zod 4 (2026-08) and guards it with
|
|
6
|
+
// its own scripts/check-zod-singleton.mjs — but that guard is repo-local and was
|
|
7
|
+
// never shipped, so the products are the one layer where studio's `zod ^4.4.3`,
|
|
8
|
+
// norsk-ctl's exact `zod 4.4.3` (pinned for @modelcontextprotocol/sdk) and
|
|
9
|
+
// @norskvideo/moq-watch's `zod ^4.5.4` all meet, with the loosest declared range
|
|
10
|
+
// (`^4.3.6`) and nothing checking the result. That is how probe went red and how
|
|
11
|
+
// funke/reuters ended up latently split: the tree resolves TWO zod instances and
|
|
12
|
+
// a schema handed across the boundary (e.g. ManifestSchema from ctl-sdk into
|
|
13
|
+
// zod-openapi) stops typechecking.
|
|
14
|
+
//
|
|
15
|
+
// This is the products' analogue of that guard, run in the `checks` quality job
|
|
16
|
+
// (conventions/checks.yml) the same way as check-drift.ts — invoked by its
|
|
17
|
+
// node_modules path, no exports entry needed.
|
|
18
|
+
//
|
|
19
|
+
// The invariant: the resolved graph carries exactly ONE zod 4 version. Nested
|
|
20
|
+
// zod 3.x (a dev-only tool npm/bun isolates) is tolerated and only noted. Any
|
|
21
|
+
// second distinct 4.x — the split — fails, and the fix is a single-version pin
|
|
22
|
+
// in the root package.json `overrides`, matching the zod your @norskvideo/ctl-*
|
|
23
|
+
// packages resolve (4.4.3 today). Do NOT pin products DOWN to satisfy norsk-ctl:
|
|
24
|
+
// its 4.4.3 exists for an MCP-SDK typecheck that products do not perform — pin
|
|
25
|
+
// UP to the version the rest of the tree wants and let ctl's copy dedupe onto it.
|
|
26
|
+
|
|
27
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
28
|
+
import { join } from "node:path";
|
|
29
|
+
|
|
30
|
+
// bun.lock is JSONC — it carries trailing commas that JSON.parse and Bun's own
|
|
31
|
+
// .json() both reject. It has no line/block comments (the `//` you see live
|
|
32
|
+
// inside URL and sha512 strings), so stripping ONLY a comma that immediately
|
|
33
|
+
// precedes a closing } or ] is safe and leaves those strings untouched.
|
|
34
|
+
function parseBunLock(text: string): { packages?: Record<string, unknown[]> } {
|
|
35
|
+
return JSON.parse(text.replace(/,(\s*[}\]])/g, "$1"));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const ZOD_4 = /^[~^]?4\./;
|
|
39
|
+
const DEP_SECTIONS = ["dependencies", "devDependencies", "optionalDependencies", "overrides"] as const;
|
|
40
|
+
// Retired shims from the zod-4 consolidation — a re-appearance means a second
|
|
41
|
+
// zod is being smuggled back in behind an alias.
|
|
42
|
+
const BANNED = ["openapi-zod-client", "@zodios/core", "zod-v4", "zod-v3"];
|
|
43
|
+
|
|
44
|
+
export interface ZodSingletonResult {
|
|
45
|
+
ok: boolean;
|
|
46
|
+
errors: string[];
|
|
47
|
+
notes: string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function checkZodSingleton(repoRoot: string): ZodSingletonResult {
|
|
51
|
+
const errors: string[] = [];
|
|
52
|
+
const notes: string[] = [];
|
|
53
|
+
|
|
54
|
+
const rootPkgPath = join(repoRoot, "package.json");
|
|
55
|
+
if (!existsSync(rootPkgPath)) {
|
|
56
|
+
return { ok: false, errors: [`no package.json at ${repoRoot}`], notes };
|
|
57
|
+
}
|
|
58
|
+
const rootPkg = JSON.parse(readFileSync(rootPkgPath, "utf8"));
|
|
59
|
+
|
|
60
|
+
// 1. Manifests: root + every workspace. If a manifest declares zod at all it
|
|
61
|
+
// must be a zod 4 range, and the retired shims must stay gone.
|
|
62
|
+
const workspaces: string[] = Array.isArray(rootPkg.workspaces) ? rootPkg.workspaces : [];
|
|
63
|
+
const manifests = ["package.json", ...workspaces.map((w) => join(w, "package.json"))].filter((rel) =>
|
|
64
|
+
existsSync(join(repoRoot, rel)),
|
|
65
|
+
);
|
|
66
|
+
for (const rel of manifests) {
|
|
67
|
+
const pkg = JSON.parse(readFileSync(join(repoRoot, rel), "utf8"));
|
|
68
|
+
for (const section of DEP_SECTIONS) {
|
|
69
|
+
const deps = (pkg[section] ?? {}) as Record<string, string>;
|
|
70
|
+
const range = deps.zod;
|
|
71
|
+
if (range !== undefined && !ZOD_4.test(range)) {
|
|
72
|
+
errors.push(`${rel}: ${section}.zod is "${range}" — must be a zod 4 range`);
|
|
73
|
+
}
|
|
74
|
+
for (const name of BANNED) {
|
|
75
|
+
if (deps[name] !== undefined) {
|
|
76
|
+
errors.push(`${rel}: ${section} declares retired package "${name}"`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 2. Lockfile: every resolved zod must be the same 4.x. bun.lock keys a zod
|
|
83
|
+
// package as "zod" (hoisted) or "<parent>/zod" (nested); the resolved id is
|
|
84
|
+
// entry[0], e.g. "zod@4.5.4". Match "zod@" exactly so @zod/mini is not swept
|
|
85
|
+
// in. Collect the distinct versions and split them into 4.x and legacy 3.x.
|
|
86
|
+
const lockPath = join(repoRoot, "bun.lock");
|
|
87
|
+
if (!existsSync(lockPath)) {
|
|
88
|
+
errors.push("no bun.lock — cannot verify the resolved zod graph");
|
|
89
|
+
} else {
|
|
90
|
+
const lock = parseBunLock(readFileSync(lockPath, "utf8"));
|
|
91
|
+
const versions = new Map<string, string[]>(); // version -> the lock keys carrying it
|
|
92
|
+
for (const [key, entry] of Object.entries(lock.packages ?? {})) {
|
|
93
|
+
const id = Array.isArray(entry) ? String(entry[0] ?? "") : "";
|
|
94
|
+
if (!id.startsWith("zod@")) continue;
|
|
95
|
+
const version = id.slice("zod@".length);
|
|
96
|
+
const keys = versions.get(version) ?? [];
|
|
97
|
+
keys.push(key || "<root>");
|
|
98
|
+
versions.set(version, keys);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const byVersion = [...versions.entries()].sort(([a], [b]) => a.localeCompare(b));
|
|
102
|
+
const v4 = byVersion.filter(([v]) => v.startsWith("4."));
|
|
103
|
+
const v3 = byVersion.filter(([v]) => v.startsWith("3."));
|
|
104
|
+
const other = byVersion.filter(([v]) => !v.startsWith("4.") && !v.startsWith("3."));
|
|
105
|
+
|
|
106
|
+
for (const [v, keys] of v3) notes.push(`dev-only nested zod ${v} tolerated (${keys.join(", ")})`);
|
|
107
|
+
for (const [v, keys] of other) {
|
|
108
|
+
errors.push(`bun.lock: unexpected zod ${v} (${keys.join(", ")}) — only zod 4 is permitted`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (v4.length === 0) {
|
|
112
|
+
errors.push("bun.lock: no zod 4 resolved — expected exactly one");
|
|
113
|
+
} else if (v4.length > 1) {
|
|
114
|
+
const detail = v4.map(([v, keys]) => ` zod@${v}: ${keys.join(", ")}`).join("\n");
|
|
115
|
+
const suggested = v4.at(-1)?.[0];
|
|
116
|
+
errors.push(
|
|
117
|
+
`bun.lock: the zod graph is SPLIT across ${v4.length} versions —\n${detail}\n` +
|
|
118
|
+
` A schema crossing this boundary (e.g. ManifestSchema from @norskvideo/ctl-sdk\n` +
|
|
119
|
+
` into zod-openapi) will fail to typecheck. Pin ONE version in the root\n` +
|
|
120
|
+
` package.json overrides — e.g. "overrides": { "zod": "${suggested}" } — matching\n` +
|
|
121
|
+
` the zod your @norskvideo/ctl-* packages resolve, then re-run bun install.`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { ok: errors.length === 0, errors, notes };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Run directly (conventions/checks.yml invokes this by its node_modules path,
|
|
130
|
+
// like check-drift.ts): first arg is the repo root, defaulting to cwd.
|
|
131
|
+
if (import.meta.main) {
|
|
132
|
+
const repoRoot = process.argv[2] ?? process.cwd();
|
|
133
|
+
const { ok, errors, notes } = checkZodSingleton(repoRoot);
|
|
134
|
+
for (const n of notes) console.log(`note: ${n}`);
|
|
135
|
+
if (!ok) {
|
|
136
|
+
console.error("zod singleton check FAILED:");
|
|
137
|
+
for (const e of errors) console.error(` - ${e}`);
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
console.log("zod singleton check OK: one zod 4 across the resolved graph");
|
|
141
|
+
}
|