@norskvideo/ctl-dev-kit 0.1.13 → 0.1.14

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.
@@ -0,0 +1,18 @@
1
+ # The one forced copy inside every product's deployment/build-image.sh: the
2
+ # bootstrap that locates this package before it can source anything from it.
3
+ # check-drift.ts extracts the find_dev_kit function below and requires it
4
+ # verbatim in each product's script (which must also `source` a script from
5
+ # this package's build/). The surrounding wrapper is per-product: tag, port,
6
+ # and — for studio's sanctioned bespoke build — which build/ script it sources.
7
+ find_dev_kit() {
8
+ local dir="$1"
9
+ while [ "${dir}" != "/" ]; do
10
+ if [ -d "${dir}/node_modules/@norskvideo/ctl-dev-kit" ]; then
11
+ printf '%s' "${dir}/node_modules/@norskvideo/ctl-dev-kit"
12
+ return 0
13
+ fi
14
+ dir="$(dirname "${dir}")"
15
+ done
16
+ echo "could not find @norskvideo/ctl-dev-kit — run bun install" >&2
17
+ return 1
18
+ }
@@ -51,15 +51,19 @@
51
51
  diverges. Only the `product:` key is per-repo. Never hand-edit the copy — edit
52
52
  the dev-kit source and re-sync.
53
53
  - **A config-driven workflow needs a parity-checked invariant contract.** If this
54
- product emits its Studio graph dynamically from config, every config dimension's
55
- rules are documented as stable-ID statements in
56
- `shared/src/schemas/INVARIANTS.md`, and every ID has an executable rule in the
57
- composer rule-sweep (`shared/src/workflow/composer-rules.test.ts`). A parity
58
- guard in that suite fails CI on any drift — a documented ID with no rule, or a
59
- rule with no doc ID. So a config change is never done until the invariant is in
60
- BOTH the doc and its test (same ID, added together); a runtime-only invariant
61
- the emitted graph can't show goes in the explicit untestable-IDs set
62
- (`COMPOSER_UNTESTABLE_IDS`), still parity-checked. `norsk-ctl-product-playout`
63
- and `-commentary` are the worked examples.
54
+ product emits its Studio graph dynamically from config, its load-bearing rules
55
+ are documented as stable-ID statements (`INV-<AREA>-<NNN>`) in an
56
+ `INVARIANTS.md`, and every ID is cited verbatim by an executable rule that
57
+ guards it. The parity guard — `invariantParityFromDisk` from
58
+ `@norskvideo/ctl-dev-kit/testing/invariants` (template + copy-paste test in
59
+ `testing/INVARIANTS.template.md`) fails CI on any drift: a documented ID
60
+ with no citing rule, or a rule citing an ID the doc doesn't define. A config
61
+ change is never done until the invariant is in BOTH the doc and its rule
62
+ (same ID, added together); a runtime-only invariant the emitted graph can't
63
+ show is declared in the guard's `untestableIds`, still parity-checked. Where
64
+ the doc lives and whether rules sit in one sweep suite
65
+ (`norsk-ctl-product-playout`, `-commentary`) or beside the code they guard
66
+ (`-probe`, `-turnkey-funke-pegasus`) is the repo's choice — the contract is
67
+ the IDs and the guard, not the layout.
64
68
 
65
69
  <!-- END ctl-shared-conventions v1 -->
@@ -0,0 +1,44 @@
1
+ {
2
+ "$schema": "https://biomejs.dev/schemas/2.5.5/schema.json",
3
+ "vcs": {
4
+ "enabled": true,
5
+ "clientKind": "git",
6
+ "useIgnoreFile": true
7
+ },
8
+ "formatter": {
9
+ "enabled": true,
10
+ "indentStyle": "space",
11
+ "indentWidth": 2,
12
+ "lineWidth": 120
13
+ },
14
+ "linter": {
15
+ "enabled": true,
16
+ "rules": {
17
+ "preset": "recommended"
18
+ }
19
+ },
20
+ "assist": {
21
+ "actions": {
22
+ "source": {
23
+ "organizeImports": "on"
24
+ }
25
+ }
26
+ },
27
+ "files": {
28
+ "includes": [
29
+ "**/*.ts",
30
+ "**/*.tsx",
31
+ "**/*.js",
32
+ "**/*.json",
33
+ "!**/node_modules",
34
+ "!**/dist",
35
+ "!**/generated",
36
+ "!**/_gen",
37
+ "!**/worker-mode/proto",
38
+ "!**/dashboards/*/assets",
39
+ "!**/website",
40
+ "!.longshot",
41
+ "!**/mcp/resources/plugin-template"
42
+ ]
43
+ }
44
+ }
@@ -1,17 +1,24 @@
1
1
  // Workstream I drift gate. Fails a product repo whose forced copies of the
2
- // shared conventions have diverged from this dev-kit's canonical source:
3
- // - the fenced `ctl-shared-conventions v1` core inside its CLAUDE.md, and
4
- // - its root flake.nix.
2
+ // shared conventions have diverged from this dev-kit's canonical source. Each
3
+ // gated file uses the loosest mechanism that still pins what is shared:
4
+ // - byte-verbatim: CLAUDE.md fenced core, flake.nix, biome.json,
5
+ // tsconfig.base.json, the .gitignore core block
6
+ // - masked per-repo line: checks.yml + upgrade-latest.yml (`product:` key)
7
+ // - base + sanctioned extension block: dprint.json (repo-specific excludes)
8
+ // - structural: root tsconfig.json (shape-dependent includes stay free),
9
+ // deployment/build-image.sh (per-product wrapper around a verbatim
10
+ // bootstrap), manifest.seed.json (per-product pins, SDK-schema shape)
5
11
  //
6
- // Both are copied verbatim (not @import-ed / not a dep) so a human reads them on
7
- // GitHub with no tooling — which is exactly where drift starts. The gate makes
8
- // "single-sourced in @norskvideo/ctl-dev-kit" true: a product runs it in CI
9
- // against the dev-kit version it has installed, so bumping the pinned dev-kit
10
- // forces a re-sync. Canonical bytes are the files shipped alongside this script
11
- // (conventions/CLAUDE.core.md + build/flake.nix), resolved package-relative so
12
- // they work both workspace-symlinked and installed from the published tarball.
12
+ // Files are copied verbatim (not @import-ed / not a dep) so a human reads them
13
+ // on GitHub with no tooling — which is exactly where drift starts. The gate
14
+ // makes "single-sourced in @norskvideo/ctl-dev-kit" true: a product runs it in
15
+ // CI against the dev-kit version it has installed, so bumping the pinned
16
+ // dev-kit forces a re-sync. Canonical bytes ship alongside this script
17
+ // (conventions/* + build/*), resolved package-relative so they work both
18
+ // workspace-symlinked and installed from the published tarball.
13
19
  import { existsSync, readFileSync } from "node:fs";
14
20
  import { join } from "node:path";
21
+ import { parseManifestSeed } from "@norskvideo/ctl-sdk/manifest-seed";
15
22
 
16
23
  export interface DriftReport {
17
24
  ok: boolean;
@@ -36,20 +43,20 @@ function firstDiffLine(actual: string, expected: string): string {
36
43
  return "content differs";
37
44
  }
38
45
 
39
- // The one legitimately per-repo line in upgrade-latest.yml: the dashboard key
40
- // the nightly result is dispatched under. Everything else is verbatim-shared.
41
- const PRODUCT_LINE = /^(\s*product:\s*).*$/m;
46
+ // The one legitimately per-repo line in the shared workflows: the dashboard key
47
+ // the CI result is dispatched under. Everything else is verbatim-shared.
48
+ const PRODUCT_LINE = /^(\s*product:\s*).*$/gm;
42
49
  const PRODUCT_SENTINEL = "__PRODUCT__";
43
50
 
44
- function workflowProblem(actual: string, canonical: string): string | undefined {
51
+ function workflowProblem(relPath: string, canonicalRef: string, actual: string, canonical: string): string | undefined {
45
52
  // Compare structure with the product value masked so any real key passes, then
46
53
  // separately reject a copy that still carries the unfilled sentinel.
47
54
  const mask = (s: string) => s.replace(PRODUCT_LINE, `$1${PRODUCT_SENTINEL}`);
48
55
  if (mask(actual) !== mask(canonical)) {
49
- return `.github/workflows/upgrade-latest.yml has drifted from @norskvideo/ctl-dev-kit conventions/upgrade-latest.yml (${firstDiffLine(mask(actual), mask(canonical))}). ${RESYNC}`;
56
+ return `${relPath} has drifted from @norskvideo/ctl-dev-kit ${canonicalRef} (${firstDiffLine(mask(actual), mask(canonical))}). ${RESYNC}`;
50
57
  }
51
58
  if (actual.match(/^\s*product:\s*(\S+)/m)?.[1] === PRODUCT_SENTINEL) {
52
- return `.github/workflows/upgrade-latest.yml still has the ${PRODUCT_SENTINEL} placeholder — set \`product:\` to this repo's dashboard key.`;
59
+ return `${relPath} still has the ${PRODUCT_SENTINEL} placeholder — set \`product:\` to this repo's dashboard key.`;
53
60
  }
54
61
  return undefined;
55
62
  }
@@ -70,6 +77,124 @@ function flakeProblem(actual: string, canonical: string): string | undefined {
70
77
  return `flake.nix has drifted from @norskvideo/ctl-dev-kit build/flake.nix (${firstDiffLine(ma, mc)}). ${RESYNC}`;
71
78
  }
72
79
 
80
+ // dprint.json is canonical-plus-extension: the shared config is verbatim, but a
81
+ // repo may exclude paths only it has (e.g. probe's generated components/lib/)
82
+ // inside a sanctioned marker block at the head of `excludes`. Everything outside
83
+ // the block is byte-compared; inside, only well-formed `"entry",` lines are
84
+ // allowed so the block can't smuggle arbitrary config past the byte check.
85
+ const DPRINT_BEGIN = "// BEGIN repo-specific excludes";
86
+ const DPRINT_END = "// END repo-specific excludes";
87
+
88
+ function splitAtMarkers(s: string): { prefix: string; body: string[]; suffix: string } | undefined {
89
+ const lines = s.split("\n");
90
+ const begin = lines.findIndex((l) => l.trim() === DPRINT_BEGIN);
91
+ const end = lines.findIndex((l) => l.trim() === DPRINT_END);
92
+ if (begin === -1 || end === -1 || end < begin) return undefined;
93
+ return {
94
+ prefix: lines.slice(0, begin + 1).join("\n"),
95
+ body: lines.slice(begin + 1, end),
96
+ suffix: lines.slice(end).join("\n"),
97
+ };
98
+ }
99
+
100
+ function dprintProblem(actual: string, canonical: string): string | undefined {
101
+ const c = splitAtMarkers(canonical);
102
+ if (!c) throw new Error("canonical dprint.base.jsonc is missing its own marker block");
103
+ const a = splitAtMarkers(actual);
104
+ if (!a) {
105
+ return `dprint.json is missing the '${DPRINT_BEGIN}' / '${DPRINT_END}' marker lines inside "excludes". Re-sync from @norskvideo/ctl-dev-kit conventions/dprint.base.jsonc; repo-specific excludes go between the markers.`;
106
+ }
107
+ const masked = (x: { prefix: string; suffix: string }) => `${x.prefix}\n${x.suffix}`;
108
+ if (masked(a) !== masked(c)) {
109
+ return `dprint.json has drifted from @norskvideo/ctl-dev-kit conventions/dprint.base.jsonc outside the repo-specific excludes block (${firstDiffLine(masked(a), masked(c))}). ${RESYNC}`;
110
+ }
111
+ const badLine = a.body.find((l) => !/^\s*"[^"]*",$/.test(l));
112
+ if (badLine !== undefined) {
113
+ return `dprint.json repo-specific excludes block has a malformed line (${JSON.stringify(badLine.trim())}) — one double-quoted exclude entry per line, each ending with a comma.`;
114
+ }
115
+ return undefined;
116
+ }
117
+
118
+ // deployment/build-image.sh: the wrapper around the shared build is per-product
119
+ // (tag, port, and studio's sanctioned bespoke build), but its dev-kit-locating
120
+ // bootstrap is the one forced copy — it must find this package before it can
121
+ // source anything from it, so it is kept identical everywhere and gated here.
122
+ // The script must also actually source a build/ script from the located dev-kit.
123
+ const SOURCES_DEV_KIT = /^source "\$\{dev_kit\}\/build\/[^"]+\.sh"$/m;
124
+
125
+ function buildImageProblem(actual: string, canonicalBootstrap: string): string | undefined {
126
+ const fnStart = canonicalBootstrap.indexOf("find_dev_kit() {");
127
+ if (fnStart === -1) throw new Error("canonical build-image.bootstrap.sh is missing find_dev_kit()");
128
+ const fn = canonicalBootstrap.slice(fnStart);
129
+ if (!actual.includes(fn)) {
130
+ return `deployment/build-image.sh does not contain the canonical find_dev_kit bootstrap from @norskvideo/ctl-dev-kit build/build-image.bootstrap.sh. ${RESYNC}`;
131
+ }
132
+ if (!SOURCES_DEV_KIT.test(actual)) {
133
+ return `deployment/build-image.sh never sources a script from "\${dev_kit}/build/" — the build machinery is single-sourced in @norskvideo/ctl-dev-kit, not copied into the product.`;
134
+ }
135
+ return undefined;
136
+ }
137
+
138
+ // Root tsconfig.json is gated structurally, not byte-wise: its `include` list
139
+ // legitimately differs by repo shape (probe adds scripts + a playwright config,
140
+ // studio adds scripts/, the turnkeys are tests-only), so the gate pins what is
141
+ // genuinely common — all compiler semantics live in the byte-gated
142
+ // tsconfig.base.json, the root file only extends it and names the trees — and
143
+ // leaves the include list free beyond the tests/**/* every repo carries.
144
+ const ROOT_TSCONFIG_KEYS = new Set(["//", "extends", "include"]);
145
+
146
+ function rootTsconfigProblem(actual: string): string | undefined {
147
+ let parsed: unknown;
148
+ try {
149
+ parsed = JSON.parse(actual);
150
+ } catch (e) {
151
+ return `tsconfig.json at the repo root is not parseable JSON (${e instanceof Error ? e.message : String(e)}).`;
152
+ }
153
+ const cfg = parsed as Record<string, unknown>;
154
+ const extraKeys = Object.keys(cfg).filter((k) => !ROOT_TSCONFIG_KEYS.has(k));
155
+ if (extraKeys.includes("compilerOptions")) {
156
+ return `tsconfig.json must not carry compilerOptions — all compiler semantics live in the drift-gated tsconfig.base.json.`;
157
+ }
158
+ if (extraKeys.length > 0) {
159
+ return `tsconfig.json carries unexpected top-level keys (${extraKeys.join(", ")}) — only "//", "extends" and "include" are sanctioned; semantics belong in tsconfig.base.json.`;
160
+ }
161
+ if (cfg.extends !== "./tsconfig.base.json") {
162
+ return `tsconfig.json must have "extends": "./tsconfig.base.json" (found ${JSON.stringify(cfg.extends)}).`;
163
+ }
164
+ if (!Array.isArray(cfg.include) || !cfg.include.includes("tests/**/*")) {
165
+ return `tsconfig.json "include" must contain "tests/**/*" (every product typechecks its tests tree); shape-specific extras are fine.`;
166
+ }
167
+ return undefined;
168
+ }
169
+
170
+ // .gitignore is base-block-plus-additions: the canonical block must appear
171
+ // verbatim (an unrecognised entry inside it would be drift), while entries a
172
+ // repo's shape genuinely needs (build outputs, codegen trees) live freely
173
+ // outside it — extra ignore lines for paths another repo lacks are inert, so
174
+ // they need no sanctioning mechanism beyond staying out of the block.
175
+ function gitignoreProblem(actual: string, canonicalBlock: string): string | undefined {
176
+ if (actual.includes(canonicalBlock)) return undefined;
177
+ return `.gitignore does not contain the ctl-shared-gitignore block verbatim. Copy conventions/gitignore.core from @norskvideo/ctl-dev-kit; repo-specific entries go outside the block. ${RESYNC}`;
178
+ }
179
+
180
+ // manifest.seed.json is conformance-checked, not byte-compared: its pins are
181
+ // per-product by design, but the SHAPE is the SDK's SeedSchema. This is the CI
182
+ // gate ctl-sdk/manifest-seed's comment always assumed — version.ts consumes the
183
+ // JSON as typed data precisely because every seed is validated here.
184
+ function seedProblem(actual: string): string | undefined {
185
+ let raw: unknown;
186
+ try {
187
+ raw = JSON.parse(actual);
188
+ } catch (e) {
189
+ return `manifest.seed.json is not parseable JSON (${e instanceof Error ? e.message : String(e)}).`;
190
+ }
191
+ const parsed = parseManifestSeed(raw);
192
+ if (parsed.status === "error") {
193
+ return `manifest.seed.json does not conform to @norskvideo/ctl-sdk/manifest-seed's SeedSchema:\n${parsed.error}`;
194
+ }
195
+ return undefined;
196
+ }
197
+
73
198
  function coreProblem(claude: string, canonicalCore: string): string | undefined {
74
199
  if (claude.includes(canonicalCore)) return undefined;
75
200
 
@@ -82,18 +207,47 @@ function coreProblem(claude: string, canonicalCore: string): string | undefined
82
207
  return `CLAUDE.md fenced core has drifted from @norskvideo/ctl-dev-kit conventions/CLAUDE.core.md (${firstDiffLine(region, canonicalCore.trimEnd())}). ${RESYNC}`;
83
208
  }
84
209
 
85
- export function checkDrift(
210
+ export interface CanonicalBytes {
211
+ core: string;
212
+ flake: string;
213
+ upgradeLatest: string;
214
+ checks: string;
215
+ biome: string;
216
+ tsconfigBase: string;
217
+ dprint: string;
218
+ buildImageBootstrap: string;
219
+ gitignoreCore: string;
220
+ }
221
+
222
+ // A file that must be a verbatim, byte-for-byte copy of its dev-kit canonical.
223
+ function verbatimProblem(
86
224
  repoRoot: string,
87
- canonical: { core: string; flake: string; upgradeLatest: string },
88
- ): DriftReport {
225
+ relPath: string,
226
+ canonicalRef: string,
227
+ canonical: string,
228
+ ): string | undefined {
229
+ const path = join(repoRoot, relPath);
230
+ if (!existsSync(path)) {
231
+ return `${relPath} not found at repo root (${path}). It is copied verbatim from @norskvideo/ctl-dev-kit ${canonicalRef}.`;
232
+ }
233
+ const actual = readFileSync(path, "utf8");
234
+ if (actual !== canonical) {
235
+ return `${relPath} has drifted from @norskvideo/ctl-dev-kit ${canonicalRef} (${firstDiffLine(actual, canonical)}). ${RESYNC}`;
236
+ }
237
+ return undefined;
238
+ }
239
+
240
+ export function checkDrift(repoRoot: string, canonical: CanonicalBytes): DriftReport {
89
241
  const problems: string[] = [];
242
+ const push = (problem: string | undefined) => {
243
+ if (problem) problems.push(problem);
244
+ };
90
245
 
91
246
  const claudePath = join(repoRoot, "CLAUDE.md");
92
247
  if (!existsSync(claudePath)) {
93
248
  problems.push(`CLAUDE.md not found at repo root (${claudePath}). It must embed the ctl-shared-conventions core.`);
94
249
  } else {
95
- const problem = coreProblem(readFileSync(claudePath, "utf8"), canonical.core);
96
- if (problem) problems.push(problem);
250
+ push(coreProblem(readFileSync(claudePath, "utf8"), canonical.core));
97
251
  }
98
252
 
99
253
  const flakePath = join(repoRoot, "flake.nix");
@@ -105,14 +259,81 @@ export function checkDrift(
105
259
  const problem = flakeProblem(readFileSync(flakePath, "utf8"), canonical.flake);
106
260
  if (problem) problems.push(problem);
107
261
  }
262
+ push(verbatimProblem(repoRoot, "biome.json", "conventions/biome.base.json", canonical.biome));
263
+ push(verbatimProblem(repoRoot, "tsconfig.base.json", "conventions/tsconfig.base.json", canonical.tsconfigBase));
264
+
265
+ const rootTsconfigPath = join(repoRoot, "tsconfig.json");
266
+ if (!existsSync(rootTsconfigPath)) {
267
+ problems.push(
268
+ `tsconfig.json not found at repo root (${rootTsconfigPath}). It must extend the drift-gated tsconfig.base.json and include tests/**/*.`,
269
+ );
270
+ } else {
271
+ push(rootTsconfigProblem(readFileSync(rootTsconfigPath, "utf8")));
272
+ }
273
+
274
+ const dprintPath = join(repoRoot, "dprint.json");
275
+ if (!existsSync(dprintPath)) {
276
+ problems.push(
277
+ `dprint.json not found at repo root (${dprintPath}). Copy conventions/dprint.base.jsonc from @norskvideo/ctl-dev-kit (repo-specific excludes go inside its marker block).`,
278
+ );
279
+ } else {
280
+ push(dprintProblem(readFileSync(dprintPath, "utf8"), canonical.dprint));
281
+ }
282
+
283
+ const seedPath = join(repoRoot, "manifest.seed.json");
284
+ if (!existsSync(seedPath)) {
285
+ problems.push(
286
+ `manifest.seed.json not found at repo root (${seedPath}). Every product pins its {media, studio} pair there (RFC 0001 Workstream C).`,
287
+ );
288
+ } else {
289
+ push(seedProblem(readFileSync(seedPath, "utf8")));
290
+ }
291
+
292
+ const gitignorePath = join(repoRoot, ".gitignore");
293
+ if (!existsSync(gitignorePath)) {
294
+ problems.push(
295
+ `.gitignore not found at repo root (${gitignorePath}). It must contain the ctl-shared-gitignore block from @norskvideo/ctl-dev-kit conventions/gitignore.core.`,
296
+ );
297
+ } else {
298
+ push(gitignoreProblem(readFileSync(gitignorePath, "utf8"), canonical.gitignoreCore));
299
+ }
300
+
301
+ const buildImagePath = join(repoRoot, "deployment", "build-image.sh");
302
+ if (!existsSync(buildImagePath)) {
303
+ problems.push(
304
+ `deployment/build-image.sh not found (${buildImagePath}). Every product ships the thin wrapper over the dev-kit build; see build/build-image.bootstrap.sh.`,
305
+ );
306
+ } else {
307
+ push(buildImageProblem(readFileSync(buildImagePath, "utf8"), canonical.buildImageBootstrap));
308
+ }
309
+
310
+ // checks.yml is REQUIRED — it is the CI entry point that runs this very gate
311
+ // (plus the shared quality jobs), so a repo without it never re-converges.
312
+ // Only the `product:` dispatch key is per-repo; it is masked like
313
+ // upgrade-latest.yml's.
314
+ const checksPath = join(repoRoot, ".github", "workflows", "checks.yml");
315
+ const checksRel = ".github/workflows/checks.yml";
316
+ if (!existsSync(checksPath)) {
317
+ problems.push(
318
+ `${checksRel} not found (${checksPath}). Copy conventions/checks.yml from @norskvideo/ctl-dev-kit and set \`product:\` to this repo's dashboard key.`,
319
+ );
320
+ } else {
321
+ push(workflowProblem(checksRel, "conventions/checks.yml", readFileSync(checksPath, "utf8"), canonical.checks));
322
+ }
108
323
 
109
324
  // upgrade-latest.yml is OPTIONAL — a product may ship without a nightly bump
110
325
  // (e.g. until it has an integration tier). But when present it is the shared,
111
326
  // single-sourced workflow and must not diverge.
112
327
  const workflowPath = join(repoRoot, ".github", "workflows", "upgrade-latest.yml");
113
328
  if (existsSync(workflowPath)) {
114
- const problem = workflowProblem(readFileSync(workflowPath, "utf8"), canonical.upgradeLatest);
115
- if (problem) problems.push(problem);
329
+ push(
330
+ workflowProblem(
331
+ ".github/workflows/upgrade-latest.yml",
332
+ "conventions/upgrade-latest.yml",
333
+ readFileSync(workflowPath, "utf8"),
334
+ canonical.upgradeLatest,
335
+ ),
336
+ );
116
337
  }
117
338
 
118
339
  return { ok: problems.length === 0, problems };
@@ -120,14 +341,20 @@ export function checkDrift(
120
341
 
121
342
  if (import.meta.main) {
122
343
  const repoRoot = process.argv[2] ?? process.cwd();
123
- const canonical = {
344
+ const canonical: CanonicalBytes = {
124
345
  core: readFileSync(join(import.meta.dir, "CLAUDE.core.md"), "utf8"),
125
346
  flake: readFileSync(join(import.meta.dir, "..", "build", "flake.nix"), "utf8"),
126
347
  upgradeLatest: readFileSync(join(import.meta.dir, "upgrade-latest.yml"), "utf8"),
348
+ checks: readFileSync(join(import.meta.dir, "checks.yml"), "utf8"),
349
+ biome: readFileSync(join(import.meta.dir, "biome.base.json"), "utf8"),
350
+ tsconfigBase: readFileSync(join(import.meta.dir, "tsconfig.base.json"), "utf8"),
351
+ dprint: readFileSync(join(import.meta.dir, "dprint.base.jsonc"), "utf8"),
352
+ buildImageBootstrap: readFileSync(join(import.meta.dir, "..", "build", "build-image.bootstrap.sh"), "utf8"),
353
+ gitignoreCore: readFileSync(join(import.meta.dir, "gitignore.core"), "utf8"),
127
354
  };
128
355
  const report = checkDrift(repoRoot, canonical);
129
356
  if (report.ok) {
130
- console.log("drift-check: CLAUDE.md core + flake.nix + upgrade-latest.yml match @norskvideo/ctl-dev-kit.");
357
+ console.log("drift-check: all shared-convention copies match @norskvideo/ctl-dev-kit.");
131
358
  process.exit(0);
132
359
  }
133
360
  console.error(
@@ -0,0 +1,77 @@
1
+ # Pre-merge gates, single-sourced in @norskvideo/ctl-dev-kit (conventions/checks.yml)
2
+ # and drift-checked — only the ci-status-dispatch `product:` key is per-repo.
3
+ #
4
+ # Two jobs:
5
+ # - drift: the RFC 0001 Workstream I shared-conventions drift-check. Fails if
6
+ # this repo's forced copies have diverged from the pinned dev-kit — the
7
+ # fenced CLAUDE.md core, flake.nix, the shared config files, the manifest
8
+ # seed. Copies exist so a human reads them on GitHub with no tooling; the
9
+ # gate is the only thing keeping them true.
10
+ # - quality: the repo's own lint + typecheck + unit tests, via the shared
11
+ # package.json script interface every product carries. The "whole project
12
+ # clean" convention in the CLAUDE core has no teeth without this.
13
+ #
14
+ # Both run the same proven way build-image does — inside `nix develop .#build`
15
+ # (which provides bun) on the x64 self-hosted pool — but skip the Docker image
16
+ # build, so they also gate pull requests. To re-sync after a legitimate dev-kit
17
+ # change: bump the @norskvideo/ctl-dev-kit pin, then copy the new files in.
18
+ name: checks
19
+
20
+ on:
21
+ push:
22
+ branches: [main]
23
+ pull_request:
24
+
25
+ permissions:
26
+ contents: read
27
+
28
+ jobs:
29
+ drift:
30
+ runs-on: x64
31
+ steps:
32
+ - uses: actions/checkout@v5
33
+ - name: Shared-conventions drift-check (Workstream I)
34
+ run: |
35
+ nix develop .#build --command bash -c '
36
+ set -euo pipefail
37
+ bun install --frozen-lockfile
38
+ bun run check:drift
39
+ '
40
+
41
+ quality:
42
+ runs-on: x64
43
+ steps:
44
+ - uses: actions/checkout@v5
45
+ - name: Lint + typecheck + unit tests
46
+ run: |
47
+ nix develop .#build --command bash -c '
48
+ set -euo pipefail
49
+ bun install --frozen-lockfile
50
+ bun run lint
51
+ bun run typecheck
52
+ bun run test:unit
53
+ '
54
+
55
+ # Report this pipeline's result to the aggregated product CI dashboard
56
+ # (id3as/ci-workflows) instead of posting its own pony — the dashboard renders
57
+ # the pony/emoji from the dispatched result. always() so a red or manual run
58
+ # still reports.
59
+ notify:
60
+ needs: [drift, quality]
61
+ if: always() && github.event_name == 'push'
62
+ runs-on: x64
63
+ steps:
64
+ - uses: actions/checkout@v5
65
+ - id: meta
66
+ run: |
67
+ if [ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "true" ]; then
68
+ echo "state=failure" >> "$GITHUB_OUTPUT"
69
+ else
70
+ echo "state=success" >> "$GITHUB_OUTPUT"
71
+ fi
72
+ - uses: ./.github/actions/ci-status-dispatch
73
+ with:
74
+ token: ${{ secrets.CI_DISPATCH_TOKEN }}
75
+ product: __PRODUCT__
76
+ pipeline: checks
77
+ status: ${{ steps.meta.outputs.state }}
@@ -0,0 +1,37 @@
1
+ {
2
+ // Dprint owns Markdown + YAML only. Biome stays the formatter/linter for
3
+ // everything it supports (JS/TS/JSON/CSS) — see biome.json. Migrate YAML to
4
+ // Biome once it ships a YAML formatter (https://biomejs.dev/blog/roadmap-2026/).
5
+ "markdown": {
6
+ "textWrap": "maintain"
7
+ },
8
+ "yaml": {
9
+ // No trailing comma in expanded flow sequences — kubb's OpenAPI YAML parser
10
+ // rejects `[a, b,]` (Unexpected token), which broke `bun run gen`.
11
+ "trailingComma": false,
12
+ "flowSequence.preferSingleLine": true
13
+ },
14
+ "includes": ["**/*.{md,yaml,yml}"],
15
+ "excludes": [
16
+ // BEGIN repo-specific excludes
17
+ // END repo-specific excludes
18
+ "node_modules/",
19
+ "**/dist/",
20
+ "**/dist-embedded/",
21
+ "**/build/",
22
+ "**/src/generated/",
23
+ "**/test-temp/",
24
+ ".longshot/",
25
+ ".ai/",
26
+ ".claude/",
27
+ "**/docs/generated/",
28
+ "**/docs/committed/",
29
+ "**/studio-save-files/",
30
+ "**/starter-pack/",
31
+ "**/terraform/templates/"
32
+ ],
33
+ "plugins": [
34
+ "https://plugins.dprint.dev/markdown-0.22.1.wasm",
35
+ "https://plugins.dprint.dev/g-plane/pretty_yaml-v0.6.0.wasm"
36
+ ]
37
+ }
@@ -0,0 +1,18 @@
1
+ # BEGIN ctl-shared-gitignore v1 (do not edit — drift-gated; repo-specific entries go outside this block)
2
+ node_modules/
3
+ dist/
4
+ generated
5
+ logs/
6
+ test-temp/
7
+ test-results/
8
+ .DS_Store
9
+ .bun_audit
10
+ .envrc
11
+ .vscode
12
+ .claude/
13
+ *.pem
14
+ *.log
15
+ *.tgz
16
+ .env
17
+ .env.local
18
+ # END ctl-shared-gitignore v1
@@ -0,0 +1,14 @@
1
+ {
2
+ "//": "Shared TS config for the server/library packages in the norsk-ctl compile graph (its sub-projects + the @product/* libs). Packages extend this and set only their own lib/jsx/outDir/include/exclude. `types` is pinned explicitly on purpose: auto-inclusion of @types walks node_modules/@types, which is unreliable under bun's isolated linker + scoped --filter installs, so ambient node/bun globals must be named here to be deterministic. Browser/frontend and product packages keep their own configs.",
3
+ "compilerOptions": {
4
+ "target": "ESNext",
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "allowImportingTsExtensions": true,
11
+ "noEmit": true,
12
+ "types": ["node", "bun"]
13
+ }
14
+ }