@norskvideo/ctl-dev-kit 0.1.13 → 0.1.15
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/build/build-image.bootstrap.sh +18 -0
- package/build/flake.nix +17 -1
- package/conventions/CLAUDE.core.md +14 -10
- package/conventions/biome.base.json +44 -0
- package/conventions/check-drift.ts +252 -25
- package/conventions/checks.yml +77 -0
- package/conventions/dprint.base.jsonc +37 -0
- package/conventions/gitignore.core +18 -0
- package/conventions/tsconfig.base.json +14 -0
- package/conventions/upgrade-latest.yml +26 -13
- package/create-product/assets/ci-status-dispatch.yml +70 -0
- package/create-product/backend-turnkey.ts +1389 -0
- package/create-product/canon.ts +52 -0
- package/create-product/cli.ts +84 -0
- package/create-product/create-product.ts +178 -0
- package/package.json +11 -2
- package/testing/INVARIANTS.template.md +49 -0
- package/testing/byte-snapshot.ts +43 -0
- package/testing/invariants.ts +117 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// The generator's canonical inputs are THE SAME FILES the drift gate checks
|
|
2
|
+
// (conventions/* + build/*), resolved package-relative exactly like
|
|
3
|
+
// check-drift.ts's CLI entry — there is no second copy that can drift. A
|
|
4
|
+
// generated repo therefore passes the gate by construction: byte-verbatim
|
|
5
|
+
// files are emitted byte-verbatim, and the per-repo fill-ins live only where
|
|
6
|
+
// the gate already masks or leaves structure free (the workflows' `product:`
|
|
7
|
+
// line, the root tsconfig include list, the .gitignore tail, the wrapper
|
|
8
|
+
// around the build-image bootstrap, the seed's pins).
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
const conventionsDir = join(import.meta.dir, "..", "conventions");
|
|
13
|
+
const buildDir = join(import.meta.dir, "..", "build");
|
|
14
|
+
|
|
15
|
+
export interface Canon {
|
|
16
|
+
core: string;
|
|
17
|
+
flake: string;
|
|
18
|
+
flakeLock: string;
|
|
19
|
+
upgradeLatest: string;
|
|
20
|
+
checks: string;
|
|
21
|
+
biome: string;
|
|
22
|
+
tsconfigBase: string;
|
|
23
|
+
dprint: string;
|
|
24
|
+
buildImageBootstrap: string;
|
|
25
|
+
gitignoreCore: string;
|
|
26
|
+
invariantsTemplate: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function loadCanon(): Canon {
|
|
30
|
+
return {
|
|
31
|
+
core: readFileSync(join(conventionsDir, "CLAUDE.core.md"), "utf8"),
|
|
32
|
+
flake: readFileSync(join(buildDir, "flake.nix"), "utf8"),
|
|
33
|
+
flakeLock: readFileSync(join(buildDir, "flake.lock"), "utf8"),
|
|
34
|
+
upgradeLatest: readFileSync(join(conventionsDir, "upgrade-latest.yml"), "utf8"),
|
|
35
|
+
checks: readFileSync(join(conventionsDir, "checks.yml"), "utf8"),
|
|
36
|
+
biome: readFileSync(join(conventionsDir, "biome.base.json"), "utf8"),
|
|
37
|
+
tsconfigBase: readFileSync(join(conventionsDir, "tsconfig.base.json"), "utf8"),
|
|
38
|
+
dprint: readFileSync(join(conventionsDir, "dprint.base.jsonc"), "utf8"),
|
|
39
|
+
buildImageBootstrap: readFileSync(join(buildDir, "build-image.bootstrap.sh"), "utf8"),
|
|
40
|
+
gitignoreCore: readFileSync(join(conventionsDir, "gitignore.core"), "utf8"),
|
|
41
|
+
invariantsTemplate: readFileSync(join(import.meta.dir, "..", "testing", "INVARIANTS.template.md"), "utf8"),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function loadAsset(name: string): string {
|
|
46
|
+
return readFileSync(join(import.meta.dir, "assets", name), "utf8");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The `product:` value in the shared workflows — the one line the drift gate
|
|
50
|
+
* masks. The dashboard key drops the `norsk-` prefix by fleet convention
|
|
51
|
+
* (norsk-reuters reports as `reuters`). */
|
|
52
|
+
export const PRODUCT_SENTINEL = "__PRODUCT__";
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// `bunx @norskvideo/ctl-dev-kit create-product ...` — a bin rather than a repo
|
|
3
|
+
// script because the generator's whole use case is an EMPTY directory: there
|
|
4
|
+
// is no node_modules to run a script from yet. The dev-kit ships TS verbatim,
|
|
5
|
+
// so the bin runs under bun (the fleet's only runtime).
|
|
6
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
7
|
+
import { parseArgs } from "node:util";
|
|
8
|
+
import { createProduct, type ProductShape } from "./create-product.ts";
|
|
9
|
+
|
|
10
|
+
const USAGE = `Usage:
|
|
11
|
+
ctl-dev-kit create-product --name <name> [options] <dir>
|
|
12
|
+
|
|
13
|
+
Options:
|
|
14
|
+
--shape <shape> Repo shape (default: backend-turnkey; the only shape yet)
|
|
15
|
+
--name <name> Product name, e.g. norsk-acme (lowercase, digits, hyphens)
|
|
16
|
+
--port <port> Local-dev backend port (default: 4323; container is always 4321)
|
|
17
|
+
--media-image <ref> Full media image ref (repo:tag); norsk-sdk npm pin = its tag
|
|
18
|
+
--studio-image <ref> Full studio image ref (repo:tag)
|
|
19
|
+
--studio-lib <ver> norsk-studio* npm pin (default: the studio image's tag)
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
function fail(message: string): never {
|
|
23
|
+
console.error(message);
|
|
24
|
+
console.error(USAGE);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function main(argv: string[]): void {
|
|
29
|
+
const [command, ...rest] = argv;
|
|
30
|
+
if (command !== "create-product") {
|
|
31
|
+
fail(`unknown command ${JSON.stringify(command ?? "")} — the only command is create-product.`);
|
|
32
|
+
}
|
|
33
|
+
const { values, positionals } = parseArgs({
|
|
34
|
+
args: rest,
|
|
35
|
+
allowPositionals: true,
|
|
36
|
+
options: {
|
|
37
|
+
shape: { type: "string", default: "backend-turnkey" },
|
|
38
|
+
name: { type: "string" },
|
|
39
|
+
port: { type: "string" },
|
|
40
|
+
"media-image": { type: "string" },
|
|
41
|
+
"studio-image": { type: "string" },
|
|
42
|
+
"studio-lib": { type: "string" },
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
const dir = positionals[0];
|
|
46
|
+
if (!dir) fail("missing <dir>.");
|
|
47
|
+
if (!values.name) fail("missing --name.");
|
|
48
|
+
if (values.shape !== "backend-turnkey") {
|
|
49
|
+
fail(
|
|
50
|
+
`unknown shape ${JSON.stringify(values.shape)} — shapes: backend-turnkey (turnkey and product are follow-ups).`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
if (existsSync(dir) && readdirSync(dir).length > 0) {
|
|
54
|
+
fail(`${dir} exists and is not empty — refusing to overwrite.`);
|
|
55
|
+
}
|
|
56
|
+
const devPort = values.port === undefined ? undefined : Number(values.port);
|
|
57
|
+
if (devPort !== undefined && (!Number.isInteger(devPort) || devPort < 1 || devPort > 65535)) {
|
|
58
|
+
fail(`--port must be an integer in 1..65535, got ${JSON.stringify(values.port)}.`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const { files } = createProduct({
|
|
62
|
+
dir,
|
|
63
|
+
name: values.name,
|
|
64
|
+
shape: values.shape as ProductShape,
|
|
65
|
+
devPort,
|
|
66
|
+
mediaImage: values["media-image"],
|
|
67
|
+
studioImage: values["studio-image"],
|
|
68
|
+
studioLib: values["studio-lib"],
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
console.log(`Generated ${files.length} files into ${dir} (shape: ${values.shape}).
|
|
72
|
+
|
|
73
|
+
Next steps (also in the generated CLAUDE.md):
|
|
74
|
+
1. cd ${dir} && git init && bun install
|
|
75
|
+
2. UPDATE_SNAPSHOTS=1 bun run test:unit # bake the byte-snapshot fixtures, commit them
|
|
76
|
+
3. bun run check:drift && bun run test:unit && bun run typecheck
|
|
77
|
+
4. Replace the starter graph in shared/src/workflow/ with the real one,
|
|
78
|
+
keeping INVARIANTS.md and its rules tests in step.
|
|
79
|
+
`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (import.meta.main) {
|
|
83
|
+
main(process.argv.slice(2));
|
|
84
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// `ctl-dev-kit create-product` — renders a new product repo from the dev-kit's
|
|
2
|
+
// canonical bytes (see canon.ts). Two layers per shape:
|
|
3
|
+
//
|
|
4
|
+
// - the CONVENTION layer (this file): every file the drift gate checks,
|
|
5
|
+
// identical across shapes, with the per-repo fill-ins placed exactly where
|
|
6
|
+
// the gate masks or leaves structure free;
|
|
7
|
+
// - the SHAPE layer (backend-turnkey.ts, ...): the source skeleton — package
|
|
8
|
+
// manifests, manifest/composer/materials modules, the five test tiers.
|
|
9
|
+
//
|
|
10
|
+
// New shapes (full turnkey, full product) slot in as additional ShapeModule
|
|
11
|
+
// implementations; the convention layer is shared verbatim.
|
|
12
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import { backendTurnkey } from "./backend-turnkey.ts";
|
|
15
|
+
import { type Canon, loadAsset, loadCanon, PRODUCT_SENTINEL } from "./canon.ts";
|
|
16
|
+
|
|
17
|
+
export type ProductShape = "backend-turnkey";
|
|
18
|
+
|
|
19
|
+
export interface CreateProductOptions {
|
|
20
|
+
dir: string;
|
|
21
|
+
name: string;
|
|
22
|
+
shape: ProductShape;
|
|
23
|
+
/** Local-dev backend port (the container always listens on 4321). */
|
|
24
|
+
devPort?: number;
|
|
25
|
+
/** Full media image ref (repo:tag). The npm norsk-sdk pin is the same
|
|
26
|
+
* version string — media npm + image are built from one commit. */
|
|
27
|
+
mediaImage?: string;
|
|
28
|
+
/** Full studio image ref (repo:tag). */
|
|
29
|
+
studioImage?: string;
|
|
30
|
+
/** norsk-studio* npm pin; defaults to the studio image's bare tag (they
|
|
31
|
+
* coincide when the published image matches the npm nightly). */
|
|
32
|
+
studioLib?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface GeneratedFile {
|
|
36
|
+
path: string;
|
|
37
|
+
content: string;
|
|
38
|
+
executable?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ShapeContext {
|
|
42
|
+
name: string;
|
|
43
|
+
/** Workspace scope, `@<name>`. */
|
|
44
|
+
scope: string;
|
|
45
|
+
/** `product:` value for the shared workflows — `name` minus the `norsk-` prefix. */
|
|
46
|
+
dashboardKey: string;
|
|
47
|
+
devPort: number;
|
|
48
|
+
pins: { mediaImage: string; studioImage: string; studioLib: string; mediaLib: string };
|
|
49
|
+
canon: Canon;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ShapeModule {
|
|
53
|
+
files(ctx: ShapeContext): GeneratedFile[];
|
|
54
|
+
claudeHead(ctx: ShapeContext): string;
|
|
55
|
+
claudeTail(ctx: ShapeContext): string;
|
|
56
|
+
rootTsconfigInclude: string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const SHAPES: Record<ProductShape, ShapeModule> = {
|
|
60
|
+
"backend-turnkey": backendTurnkey,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Same known-good coherent set the monorepo's own test-harness pins. A fresh
|
|
64
|
+
// repo's first upgrade-latest.yml run advances every one of these in lockstep.
|
|
65
|
+
const DEFAULT_MEDIA_IMAGE = "norskvideo/norsk:1.0.402-2026-07-20-71aab79d";
|
|
66
|
+
const DEFAULT_STUDIO_IMAGE = "norskvideo/norsk-studio:1.27.0-2026-07-21-fdea1794";
|
|
67
|
+
|
|
68
|
+
const bareTag = (ref: string) => ref.slice(ref.lastIndexOf(":") + 1);
|
|
69
|
+
|
|
70
|
+
export function shapeContext(opts: CreateProductOptions): ShapeContext {
|
|
71
|
+
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(opts.name)) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`invalid product name ${JSON.stringify(opts.name)} — lowercase letters, digits, hyphens; 1-63 chars (it names the container image, npm scope, and manifest).`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
const mediaImage = opts.mediaImage ?? DEFAULT_MEDIA_IMAGE;
|
|
77
|
+
const studioImage = opts.studioImage ?? DEFAULT_STUDIO_IMAGE;
|
|
78
|
+
return {
|
|
79
|
+
name: opts.name,
|
|
80
|
+
scope: `@${opts.name}`,
|
|
81
|
+
dashboardKey: opts.name.replace(/^norsk-/, ""),
|
|
82
|
+
devPort: opts.devPort ?? 4323,
|
|
83
|
+
pins: {
|
|
84
|
+
mediaImage,
|
|
85
|
+
studioImage,
|
|
86
|
+
studioLib: opts.studioLib ?? bareTag(studioImage),
|
|
87
|
+
mediaLib: bareTag(mediaImage),
|
|
88
|
+
},
|
|
89
|
+
canon: loadCanon(),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function conventionFiles(ctx: ShapeContext, shape: ShapeModule): GeneratedFile[] {
|
|
94
|
+
const { canon } = ctx;
|
|
95
|
+
const fillProduct = (workflow: string) => workflow.replaceAll(PRODUCT_SENTINEL, ctx.dashboardKey);
|
|
96
|
+
return [
|
|
97
|
+
{ path: "CLAUDE.md", content: `${shape.claudeHead(ctx)}\n${canon.core}\n${shape.claudeTail(ctx)}` },
|
|
98
|
+
{ path: "flake.nix", content: canon.flake },
|
|
99
|
+
{ path: "flake.lock", content: canon.flakeLock },
|
|
100
|
+
{ path: "biome.json", content: canon.biome },
|
|
101
|
+
{ path: "tsconfig.base.json", content: canon.tsconfigBase },
|
|
102
|
+
{ path: "dprint.json", content: canon.dprint },
|
|
103
|
+
{
|
|
104
|
+
path: "tsconfig.json",
|
|
105
|
+
content: `${JSON.stringify({ extends: "./tsconfig.base.json", include: shape.rootTsconfigInclude }, null, 2)}\n`,
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
path: ".gitignore",
|
|
109
|
+
content: `${canon.gitignoreCore}\n# Repo-specific entries go below (the block above is drift-gated verbatim).\n`,
|
|
110
|
+
},
|
|
111
|
+
{ path: ".github/workflows/checks.yml", content: fillProduct(canon.checks) },
|
|
112
|
+
{ path: ".github/workflows/upgrade-latest.yml", content: fillProduct(canon.upgradeLatest) },
|
|
113
|
+
{ path: ".github/actions/ci-status-dispatch/action.yml", content: loadAsset("ci-status-dispatch.yml") },
|
|
114
|
+
{ path: "manifest.seed.json", content: seedJson(ctx) },
|
|
115
|
+
{ path: "deployment/build-image.sh", content: buildImageWrapper(ctx), executable: true },
|
|
116
|
+
];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function seedJson(ctx: ShapeContext): string {
|
|
120
|
+
return `${JSON.stringify(
|
|
121
|
+
{
|
|
122
|
+
latest: {
|
|
123
|
+
media: ctx.pins.mediaImage,
|
|
124
|
+
studio: ctx.pins.studioImage,
|
|
125
|
+
source: "nightly-baseline",
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
null,
|
|
129
|
+
2,
|
|
130
|
+
)}\n`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function buildImageWrapper(ctx: ShapeContext): string {
|
|
134
|
+
// The gate requires the find_dev_kit function verbatim and a `source` of a
|
|
135
|
+
// dev-kit build/ script; the surrounding wrapper (tag, port) is per-product.
|
|
136
|
+
const canonBootstrap = ctx.canon.buildImageBootstrap;
|
|
137
|
+
const fnStart = canonBootstrap.indexOf("find_dev_kit() {");
|
|
138
|
+
if (fnStart === -1) throw new Error("canonical build-image.bootstrap.sh is missing find_dev_kit()");
|
|
139
|
+
const fn = canonBootstrap.slice(fnStart);
|
|
140
|
+
return `#!/usr/bin/env bash
|
|
141
|
+
# Thin wrapper — all build logic lives in the shared bundle-image driver,
|
|
142
|
+
# single-sourced in @norskvideo/ctl-dev-kit (build/build-bundle-image.sh). This
|
|
143
|
+
# repo carries no copy of the build machinery; only the bootstrap below is a
|
|
144
|
+
# forced (drift-gated) copy.
|
|
145
|
+
set -euo pipefail
|
|
146
|
+
|
|
147
|
+
product_dir="$(cd "$(dirname "\${BASH_SOURCE[0]}")/.." && pwd)"
|
|
148
|
+
|
|
149
|
+
${fn}dev_kit="$(find_dev_kit "\${product_dir}")"
|
|
150
|
+
|
|
151
|
+
export PRODUCT_DIR="\${product_dir}"
|
|
152
|
+
export IMAGE_TAG="\${IMAGE_TAG:-${ctx.name}:dev}"
|
|
153
|
+
# MUST be 4321: the runner's docker-runner hardcodes the container-internal
|
|
154
|
+
# port and publishes 127.0.0.1:<hostPort>:4321 for every product. The dev port
|
|
155
|
+
# in backend/src/index.ts only applies outside the container (PORT overrides it
|
|
156
|
+
# here).
|
|
157
|
+
export PRODUCT_PORT=4321
|
|
158
|
+
# shellcheck source=/dev/null
|
|
159
|
+
source "\${dev_kit}/build/build-bundle-image.sh"
|
|
160
|
+
`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function planProduct(opts: CreateProductOptions): GeneratedFile[] {
|
|
164
|
+
const shape = SHAPES[opts.shape];
|
|
165
|
+
if (!shape) throw new Error(`unknown shape ${JSON.stringify(opts.shape)}`);
|
|
166
|
+
const ctx = shapeContext(opts);
|
|
167
|
+
return [...conventionFiles(ctx, shape), ...shape.files(ctx)];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function createProduct(opts: CreateProductOptions): { files: string[] } {
|
|
171
|
+
const plan = planProduct(opts);
|
|
172
|
+
for (const file of plan) {
|
|
173
|
+
const path = join(opts.dir, file.path);
|
|
174
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
175
|
+
writeFileSync(path, file.content, { mode: file.executable ? 0o755 : 0o644 });
|
|
176
|
+
}
|
|
177
|
+
return { files: plan.map((f) => f.path) };
|
|
178
|
+
}
|
package/package.json
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@norskvideo/ctl-dev-kit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.15",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
|
-
"./package.json": "./package.json"
|
|
6
|
+
"./package.json": "./package.json",
|
|
7
|
+
"./testing/invariants": "./testing/invariants.ts",
|
|
8
|
+
"./testing/byte-snapshot": "./testing/byte-snapshot.ts",
|
|
9
|
+
"./create-product": "./create-product/create-product.ts"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"ctl-dev-kit": "./create-product/cli.ts"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@norskvideo/ctl-sdk": "^0.1.0"
|
|
7
16
|
},
|
|
8
17
|
"publishConfig": {
|
|
9
18
|
"access": "public"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Workflow invariants
|
|
2
|
+
|
|
3
|
+
<!-- Template from @norskvideo/ctl-dev-kit testing/INVARIANTS.template.md.
|
|
4
|
+
Copy to the product repo root as INVARIANTS.md, replace the example rows with
|
|
5
|
+
this product's real invariants, and add the parity-guard test below. -->
|
|
6
|
+
|
|
7
|
+
The load-bearing rules this product's composed workflow must never break. Each
|
|
8
|
+
has a stable ID (`INV-<AREA>-<NNN>`; never renumber, never reuse a retired ID)
|
|
9
|
+
and at least one test that cites the ID verbatim — the parity guard fails CI
|
|
10
|
+
when a row has no citing test or a test cites an unknown ID. When a code
|
|
11
|
+
comment carries one of these rules, cite the ID there too instead of restating
|
|
12
|
+
the rule.
|
|
13
|
+
|
|
14
|
+
| ID | Invariant | Guarded by |
|
|
15
|
+
| ------------ | ---------------------------------------------------------------------------------------------------------- | -------------------------- |
|
|
16
|
+
| INV-SCTE-001 | The SCTE-35 side-car subscribes the programme audio only (reference clock) — it never sits in the AV path | `components.test.ts` |
|
|
17
|
+
| INV-AD-002 | Scheduled ad insertions are emitted only onto pushed destinations, never the local pull | `components.test.ts` |
|
|
18
|
+
| INV-ENC-003 | Every ladder rung carries an `nvidia` encode block only — no software/quadra fallback rung | `components.test.ts` |
|
|
19
|
+
| INV-GPU-004 | No raw in-memory video reaches an NVIDIA surface without a videoCompose upload (engine segfault otherwise) | `compose-workflow.test.ts` |
|
|
20
|
+
| INV-LL-005 | The low-latency rung feeds WHEP only — it never reaches the HLS ladder | `compose-workflow.test.ts` |
|
|
21
|
+
|
|
22
|
+
The rows above are worked examples (funke-pegasus's real invariants); delete
|
|
23
|
+
them when seeding a new product.
|
|
24
|
+
|
|
25
|
+
## Parity guard
|
|
26
|
+
|
|
27
|
+
Copy into `tests/unit/invariants-parity.test.ts`:
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { expect, test } from "bun:test";
|
|
31
|
+
import { join } from "node:path";
|
|
32
|
+
import { invariantParityFromDisk } from "@norskvideo/ctl-dev-kit/testing/invariants";
|
|
33
|
+
|
|
34
|
+
const root = join(import.meta.dir, "..", "..");
|
|
35
|
+
|
|
36
|
+
test("every invariant has a citing test and every cited ID is tabled", () => {
|
|
37
|
+
const report = invariantParityFromDisk({
|
|
38
|
+
invariantsPath: join(root, "INVARIANTS.md"),
|
|
39
|
+
testDirs: [join(root, "shared"), join(root, "tests")],
|
|
40
|
+
});
|
|
41
|
+
expect(report.problems).toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Point `testDirs` at every tree whose `*.test.ts` files guard invariants. A
|
|
46
|
+
runtime-only invariant the emitted graph can't show (e.g. a timing guarantee)
|
|
47
|
+
still gets a table row — declare its ID in the guard's `untestableIds` option;
|
|
48
|
+
the guard rejects unknown entries and flags the marker as stale once a test
|
|
49
|
+
does cite the ID.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Byte-snapshot harness for product wire formats: assert an emitted artifact
|
|
2
|
+
// (workflow YAML, manifest JSON, compose file) matches a committed fixture
|
|
3
|
+
// byte-for-byte, so unintended emission changes show up as a reviewable diff.
|
|
4
|
+
// Refresh deliberately: UPDATE_SNAPSHOTS=1 bun test <suite> rewrites fixtures;
|
|
5
|
+
// commit the diff with the change that caused it. Framework-free (plain throw),
|
|
6
|
+
// so it works under bun:test, vitest, or bare scripts alike.
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { dirname } from "node:path";
|
|
9
|
+
|
|
10
|
+
const UPDATE_HINT = "run UPDATE_SNAPSHOTS=1 bun test to (re)write it, then commit the fixture";
|
|
11
|
+
|
|
12
|
+
function updating(): boolean {
|
|
13
|
+
const v = process.env.UPDATE_SNAPSHOTS;
|
|
14
|
+
return v !== undefined && v !== "" && v !== "0" && v.toLowerCase() !== "false";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function firstDiffLine(actual: string, expected: string): string {
|
|
18
|
+
const a = actual.split("\n");
|
|
19
|
+
const e = expected.split("\n");
|
|
20
|
+
for (let i = 0; i < Math.max(a.length, e.length); i++) {
|
|
21
|
+
if (a[i] !== e[i]) {
|
|
22
|
+
return `line ${i + 1}: snapshot ${JSON.stringify(e[i] ?? "<missing>")}, actual ${JSON.stringify(a[i] ?? "<missing>")}`;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return "content differs";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function expectByteSnapshot(actual: string, fixturePath: string): void {
|
|
29
|
+
if (updating()) {
|
|
30
|
+
mkdirSync(dirname(fixturePath), { recursive: true });
|
|
31
|
+
writeFileSync(fixturePath, actual);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (!existsSync(fixturePath)) {
|
|
35
|
+
throw new Error(`no committed snapshot at ${fixturePath} — ${UPDATE_HINT}.`);
|
|
36
|
+
}
|
|
37
|
+
const expected = readFileSync(fixturePath, "utf8");
|
|
38
|
+
if (actual !== expected) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`emitted bytes differ from the committed snapshot ${fixturePath} (${firstDiffLine(actual, expected)}). If the change is intended, ${UPDATE_HINT}.`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Layer 3 scaffold: a product's load-bearing invariants live in a rules table
|
|
2
|
+
// (INVARIANTS.md, stable IDs) instead of scattered comments, and a parity guard
|
|
3
|
+
// keeps table and tests in lockstep — every invariant is referenced by at least
|
|
4
|
+
// one test, and no test invents an ID the table doesn't define. See
|
|
5
|
+
// INVARIANTS.template.md for the table format and the copy-paste guard test.
|
|
6
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
// IDs are INV-<AREA>-<NNN>: greppable, stable under renumbering-free growth,
|
|
10
|
+
// and specific enough that a bare prose mention can't collide by accident.
|
|
11
|
+
const ID = /INV-[A-Z0-9]+-\d{3}/;
|
|
12
|
+
const ID_TOKEN = new RegExp(`\\b${ID.source}\\b`, "g");
|
|
13
|
+
const TABLE_ROW = new RegExp(`^\\|\\s*(${ID.source})\\s*\\|\\s*(.*?)\\s*\\|`);
|
|
14
|
+
|
|
15
|
+
export interface Invariant {
|
|
16
|
+
id: string;
|
|
17
|
+
rule: string;
|
|
18
|
+
line: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function parseInvariants(markdown: string): Invariant[] {
|
|
22
|
+
const invariants: Invariant[] = [];
|
|
23
|
+
markdown.split("\n").forEach((text, i) => {
|
|
24
|
+
const row = text.match(TABLE_ROW);
|
|
25
|
+
if (row?.[1] !== undefined && row[2] !== undefined) {
|
|
26
|
+
invariants.push({ id: row[1], rule: row[2], line: i + 1 });
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
return invariants;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ParityReport {
|
|
33
|
+
ok: boolean;
|
|
34
|
+
problems: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ParityOptions {
|
|
38
|
+
// Runtime-only invariants no emitted-graph test can show (e.g. a timing
|
|
39
|
+
// guarantee). Still parity-checked: each must exist in the table, and a
|
|
40
|
+
// citing test appearing later flags the marker as stale.
|
|
41
|
+
untestableIds?: string[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function invariantParity(
|
|
45
|
+
markdown: string,
|
|
46
|
+
testSources: Record<string, string>,
|
|
47
|
+
opts: ParityOptions = {},
|
|
48
|
+
): ParityReport {
|
|
49
|
+
const problems: string[] = [];
|
|
50
|
+
const invariants = parseInvariants(markdown);
|
|
51
|
+
const untestable = new Set(opts.untestableIds ?? []);
|
|
52
|
+
|
|
53
|
+
if (invariants.length === 0) {
|
|
54
|
+
problems.push("no invariants parsed — rows must look like `| INV-AREA-001 | rule text | guarded-by |`.");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const seen = new Set<string>();
|
|
58
|
+
for (const inv of invariants) {
|
|
59
|
+
if (seen.has(inv.id)) problems.push(`duplicate invariant ID ${inv.id} (line ${inv.line}).`);
|
|
60
|
+
seen.add(inv.id);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const referenced = new Set<string>();
|
|
64
|
+
for (const [file, source] of Object.entries(testSources)) {
|
|
65
|
+
for (const token of source.match(ID_TOKEN) ?? []) {
|
|
66
|
+
referenced.add(token);
|
|
67
|
+
if (invariants.length > 0 && !seen.has(token)) {
|
|
68
|
+
problems.push(`${file} references ${token}, which is not in the invariants table — add the row or fix the ID.`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const inv of invariants) {
|
|
74
|
+
if (referenced.has(inv.id) && untestable.has(inv.id)) {
|
|
75
|
+
problems.push(
|
|
76
|
+
`invariant ${inv.id} is declared untestable but a test cites it — remove the stale untestableIds entry.`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (!referenced.has(inv.id) && !untestable.has(inv.id)) {
|
|
80
|
+
problems.push(
|
|
81
|
+
`invariant ${inv.id} (${inv.rule}) has no referencing test — cite its ID in the test that guards it, or declare it in untestableIds.`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const id of untestable) {
|
|
87
|
+
if (!invariants.some((inv) => inv.id === id)) {
|
|
88
|
+
problems.push(
|
|
89
|
+
`untestableIds declares ${id}, which is not in the invariants table — add the row or drop the entry.`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { ok: problems.length === 0, problems };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function collectTestFiles(dir: string, out: string[]): void {
|
|
98
|
+
for (const entry of readdirSync(dir)) {
|
|
99
|
+
const path = join(dir, entry);
|
|
100
|
+
if (statSync(path).isDirectory()) {
|
|
101
|
+
if (entry !== "node_modules") collectTestFiles(path, out);
|
|
102
|
+
} else if (/\.test\.tsx?$/.test(entry)) {
|
|
103
|
+
out.push(path);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function invariantParityFromDisk(
|
|
109
|
+
opts: { invariantsPath: string; testDirs: string[] } & ParityOptions,
|
|
110
|
+
): ParityReport {
|
|
111
|
+
const markdown = readFileSync(opts.invariantsPath, "utf8");
|
|
112
|
+
const files: string[] = [];
|
|
113
|
+
for (const dir of opts.testDirs) collectTestFiles(dir, files);
|
|
114
|
+
const sources: Record<string, string> = {};
|
|
115
|
+
for (const file of files) sources[file] = readFileSync(file, "utf8");
|
|
116
|
+
return invariantParity(markdown, sources, { untestableIds: opts.untestableIds });
|
|
117
|
+
}
|