@norskvideo/ctl-dev-kit 0.1.48 → 0.1.49

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.
@@ -44,7 +44,7 @@ container port 4321), then re-add it into the daemon —
44
44
  under \`tests/unit/__snapshots__/\` (the generator cannot compose without the
45
45
  Studio packages installed). Commit them; from then on any emission change is
46
46
  a reviewable fixture diff.
47
- 3. \`bun run check:drift && bun run test:unit && bun run typecheck\`
47
+ 3. \`bun run check:drift && bun run docs:check && bun run test:unit && bun run typecheck\`
48
48
 
49
49
  ## Version pins
50
50
 
@@ -68,8 +68,9 @@ refreshed \`~/.norsk-ctl/product-templates/<name>/compose.yml\` carries the pins
68
68
  ## Build / test — the five layers
69
69
 
70
70
  - **Layer 1 — typed factories** (\`shared/src/workflow/components.ts\`): configs
71
- \`import type\`d from each built-in's \`_gen/types\`; drift from Studio's
72
- schema is a \`bun run typecheck\` failure.
71
+ \`import type\`d from each built-in's OpenAPI-generated types
72
+ (\`@norskvideo/norsk-studio-built-ins/lib/<component>/_gen/types\`); drift
73
+ from Studio's schema is a \`bun run typecheck\` failure.
73
74
  - **Layer 2 — \`builder.validate()\`**: the composer throws on any issue. Never
74
75
  demote it to a warning; declare out-of-library components in
75
76
  \`COMPONENT_STUBS\` (SDK \`stubbedLibrary\`) instead.
@@ -79,8 +80,8 @@ refreshed \`~/.norsk-ctl/product-templates/<name>/compose.yml\` carries the pins
79
80
  - **Layer 4 — studio-load round-trip** (\`tests/unit/loads-in-studio.test.ts\`):
80
81
  emitted YAML through Studio's real \`document.load()\`.
81
82
  - **Layer 5 — integration**: not scaffolded. Decide + record here whether this
82
- product carries one (unattended nightly bumps need it; see the dev-kit's
83
- \`conventions/integration-testing.md\`).
83
+ product carries one (unattended nightly bumps need it; see
84
+ \`node_modules/@norskvideo/ctl-dev-kit/conventions/integration-testing.md\`).
84
85
 
85
86
  Byte-snapshots (\`tests/unit/workflow-snapshot.test.ts\`) pin the emitted wire
86
87
  bytes; \`UPDATE_SNAPSHOTS=1\` refreshes deliberately.
@@ -90,6 +91,9 @@ bytes; \`UPDATE_SNAPSHOTS=1\` refreshes deliberately.
90
91
  runs the built container and asserts the surface \`product add\` probes.
91
92
  - \`bun run check:drift\` — the shared-conventions gate; CI runs it plus
92
93
  lint/typecheck/test:unit via the drift-gated \`checks.yml\`.
94
+ - \`bun run docs:check\` — every backticked repo path in the README, this
95
+ file and the markdown under docs/ must exist (\`<!-- path-allow -->\` exempts
96
+ a line that names something outside the tree).
93
97
  `;
94
98
  }
95
99
 
@@ -111,6 +115,7 @@ function rootPackageJson(ctx: ShapeContext): string {
111
115
  "test:unit": "bun test shared/src/ backend/src/ tests/unit/",
112
116
  "test:image": "bun test tests/image/",
113
117
  "check:drift": "bun run node_modules/@norskvideo/ctl-dev-kit/conventions/check-drift.ts",
118
+ "docs:check": "bun run node_modules/@norskvideo/ctl-dev-kit/docs/path-existence.ts",
114
119
  "sync:drift": "bun run node_modules/@norskvideo/ctl-dev-kit/conventions/sync-drift.ts",
115
120
  typecheck: "bunx tsc --noEmit -p shared && bunx tsc --noEmit -p backend && bunx tsc --noEmit -p .",
116
121
  build: "bun run lint && bun run typecheck && bun run build:no-lint",
@@ -73,7 +73,7 @@ export function main(argv: string[]): void {
73
73
  Next steps (also in the generated CLAUDE.md):
74
74
  1. cd ${dir} && git init && bun install
75
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
76
+ 3. bun run check:drift && bun run docs:check && bun run test:unit && bun run typecheck
77
77
  4. Replace the starter graph in shared/src/workflow/ with the real one,
78
78
  keeping INVARIANTS.md and its rules tests in step.
79
79
  `);
@@ -0,0 +1,286 @@
1
+ // Path-existence lint over the hand-written docs (fleet review 02 s11.1). Every
2
+ // backticked token in README.md, CLAUDE.md and docs/**/*.md that looks like a
3
+ // repo path must resolve to something in the tree. Rot in these files is
4
+ // invisible to every other gate: a CLAUDE.md that points an agent at a file
5
+ // deleted two refactors ago still reads as authoritative.
6
+ //
7
+ // "The tree" is what git tracks or would track (cached + untracked, minus
8
+ // ignored), so the answer is the same on a fresh CI checkout as on a dev box
9
+ // with build output lying around. A path that git IGNORES is a declared output
10
+ // (frontend/dist, docs/generated) rather than rot, so it counts as existing;
11
+ // deciding how an index may link into generated output is the review's s13
12
+ // follow-up, not this lint's call. Outside a git repo the tree is a filesystem
13
+ // walk and nothing is ignored.
14
+ import { readdirSync, readFileSync, realpathSync } from "node:fs";
15
+ import { basename, dirname, join, posix } from "node:path";
16
+
17
+ export interface PathFinding {
18
+ file: string;
19
+ line: number;
20
+ token: string;
21
+ message: string;
22
+ }
23
+
24
+ export interface PathExistenceOptions {
25
+ // Tracked paths relative to repoRoot; loaded via loadTree() when omitted.
26
+ tree?: string[];
27
+ // Extra bases a path may be relative to, on top of the repo root and the
28
+ // citing doc's own directory (ctl's CLAUDE.md writes paths relative to
29
+ // packages/norsk-ctl, and says so).
30
+ roots?: string[];
31
+ skipPrefixes?: string[];
32
+ // Which of the given candidate paths are gitignored; defaults to asking git.
33
+ ignored?: (candidates: string[]) => Set<string>;
34
+ }
35
+
36
+ export const PATH_ALLOW = "<!-- path-allow -->";
37
+ // The fenced core a product's CLAUDE.md copies verbatim from the dev-kit is
38
+ // fleet-generic prose under its own gate (check-drift); the paths it names are
39
+ // conventions, not claims about this repo's tree.
40
+ const CORE_BEGIN = "<!-- BEGIN ctl-shared-conventions v1 -->";
41
+ const CORE_END = "<!-- END ctl-shared-conventions v1 -->";
42
+
43
+ // The five prefixes the review names (its `@norskvideo/` widened to every
44
+ // scoped npm name), plus the classes they imply: any absolute path (container
45
+ // paths, URL paths) and the GitHub/Docker Hub org prefixes that read like a
46
+ // path but name a repo or an image.
47
+ export const DEFAULT_SKIP_PREFIXES = [
48
+ "~/",
49
+ "/api/",
50
+ "http",
51
+ "@",
52
+ "node_modules/",
53
+ "/",
54
+ "id3as/",
55
+ "norskvideo/",
56
+ "ghcr.io/",
57
+ "docker.io/",
58
+ ];
59
+
60
+ const PATH_WITH_SLASH = /^[\w@./-]+\/[\w./*-]+$/;
61
+ // A bare filename is only a repo-path claim when its extension says "source":
62
+ // bare config names (config.yaml, default.yaml, package.json) far more often
63
+ // name daemon state or a file inside the container.
64
+ const SOURCE_EXTENSIONS = "md|mdx|ts|tsx|js|mjs|cjs|sh|nix";
65
+ const BARE_FILENAME = new RegExp(`^[\\w.-]+\\.(${SOURCE_EXTENSIONS})$`);
66
+ // `path:12`, `path:12-15`, `path#anchor` are citations of a path, not a path.
67
+ const CITATION_SUFFIX = /(:\d+(-\d+)?|#[\w-]+)$/;
68
+ const INLINE_CODE = /`([^`\n]+)`/g;
69
+
70
+ export function extractPathTokens(line: string): string[] {
71
+ const tokens: string[] = [];
72
+ for (const match of line.matchAll(INLINE_CODE)) {
73
+ const raw = match[1]?.trim() ?? "";
74
+ const token = raw.replace(CITATION_SUFFIX, "");
75
+ if (PATH_WITH_SLASH.test(token) || BARE_FILENAME.test(token)) tokens.push(token);
76
+ }
77
+ return tokens;
78
+ }
79
+
80
+ export function docFilesFor(tree: string[], exclude: string[] = []): string[] {
81
+ const excluded = exclude.map((g) => new Bun.Glob(g));
82
+ return tree.filter((path) => {
83
+ const inScope =
84
+ path === "README.md" ||
85
+ path === "CLAUDE.md" ||
86
+ (path.startsWith("docs/") &&
87
+ path.endsWith(".md") &&
88
+ !path.startsWith("docs/internal/") &&
89
+ !path.startsWith("docs/generated/"));
90
+ return inScope && !excluded.some((g) => g.match(path));
91
+ });
92
+ }
93
+
94
+ const WALK_SKIP = new Set(["node_modules", ".git"]);
95
+
96
+ function walk(root: string, rel = ""): string[] {
97
+ const out: string[] = [];
98
+ for (const entry of readdirSync(join(root, rel), { withFileTypes: true })) {
99
+ if (WALK_SKIP.has(entry.name)) continue;
100
+ const path = rel ? `${rel}/${entry.name}` : entry.name;
101
+ if (entry.isDirectory()) out.push(...walk(root, path));
102
+ else out.push(path);
103
+ }
104
+ return out;
105
+ }
106
+
107
+ function gitLines(repoRoot: string, args: string[], stdin?: string): string[] | undefined {
108
+ const git = Bun.spawnSync(["git", "-C", repoRoot, ...args], {
109
+ stdin: stdin === undefined ? undefined : Buffer.from(stdin),
110
+ });
111
+ // check-ignore exits 1 when nothing matched, which is an answer, not a failure.
112
+ if (git.exitCode !== 0 && git.exitCode !== 1) return undefined;
113
+ return git.stdout
114
+ .toString()
115
+ .split("\n")
116
+ .filter((l) => l.length > 0);
117
+ }
118
+
119
+ // Only a repo's own toplevel is git's to answer: a root nested somewhere inside
120
+ // another repo (a generated skeleton under an ignored scratch dir) would come
121
+ // back empty from ls-files, so it is walked instead.
122
+ function isGitToplevel(repoRoot: string): boolean {
123
+ const top = gitLines(repoRoot, ["rev-parse", "--show-toplevel"])?.[0];
124
+ return top !== undefined && realpathSync(top) === realpathSync(repoRoot);
125
+ }
126
+
127
+ export function loadTree(repoRoot: string): string[] {
128
+ if (!isGitToplevel(repoRoot)) return walk(repoRoot);
129
+ return gitLines(repoRoot, ["ls-files", "--cached", "--others", "--exclude-standard"]) ?? walk(repoRoot);
130
+ }
131
+
132
+ // A directory pattern (`dist/`) only matches a path git knows is a directory,
133
+ // and a path that is not on disk has no type, so ask about both spellings.
134
+ export function gitIgnored(repoRoot: string): (candidates: string[]) => Set<string> {
135
+ return (candidates) => {
136
+ if (candidates.length === 0 || !isGitToplevel(repoRoot)) return new Set();
137
+ const asked = candidates.flatMap((c) => [c, `${c}/`]);
138
+ const hits = gitLines(repoRoot, ["check-ignore", "--stdin"], `${asked.join("\n")}\n`) ?? [];
139
+ return new Set(hits.map((h) => h.replace(/\/$/, "")));
140
+ };
141
+ }
142
+
143
+ class Tree {
144
+ private readonly files: Set<string>;
145
+ private readonly dirs: Set<string>;
146
+ private readonly basenames: Set<string>;
147
+
148
+ constructor(entries: string[]) {
149
+ this.files = new Set(entries);
150
+ this.dirs = new Set<string>();
151
+ this.basenames = new Set<string>();
152
+ for (const file of entries) {
153
+ this.basenames.add(basename(file));
154
+ let dir = dirname(file);
155
+ while (dir !== "." && dir !== "/" && !this.dirs.has(dir)) {
156
+ this.dirs.add(dir);
157
+ dir = dirname(dir);
158
+ }
159
+ }
160
+ }
161
+
162
+ has(path: string, directoryOnly: boolean): boolean {
163
+ if (path === "" || path === ".") return true;
164
+ if (path.includes("*")) {
165
+ const glob = new Bun.Glob(path);
166
+ for (const dir of this.dirs) if (glob.match(dir)) return true;
167
+ if (directoryOnly) return false;
168
+ for (const file of this.files) if (glob.match(file)) return true;
169
+ return false;
170
+ }
171
+ if (this.dirs.has(path)) return true;
172
+ return !directoryOnly && this.files.has(path);
173
+ }
174
+
175
+ hasBasename(name: string): boolean {
176
+ return this.basenames.has(name);
177
+ }
178
+ }
179
+
180
+ function isSkipped(token: string, skipPrefixes: string[]): boolean {
181
+ return skipPrefixes.some((p) => token.startsWith(p));
182
+ }
183
+
184
+ interface Miss {
185
+ file: string;
186
+ line: number;
187
+ token: string;
188
+ bases: string[];
189
+ candidates: string[];
190
+ }
191
+
192
+ export function checkPathExistence(
193
+ repoRoot: string,
194
+ files: string[],
195
+ {
196
+ tree: entries,
197
+ roots = [],
198
+ skipPrefixes = DEFAULT_SKIP_PREFIXES,
199
+ ignored = gitIgnored(repoRoot),
200
+ }: PathExistenceOptions = {},
201
+ ): PathFinding[] {
202
+ const tree = new Tree(entries ?? loadTree(repoRoot));
203
+ const misses: Miss[] = [];
204
+
205
+ for (const file of files) {
206
+ const bases = unique([dirname(file) === "." ? "" : dirname(file), "", ...roots]);
207
+ const lines = readFileSync(join(repoRoot, file), "utf8").split("\n");
208
+ let inFence = false;
209
+ let inCore = false;
210
+ lines.forEach((line, index) => {
211
+ if (/^\s*(```|~~~)/.test(line)) {
212
+ inFence = !inFence;
213
+ return;
214
+ }
215
+ if (line.includes(CORE_BEGIN)) inCore = true;
216
+ if (line.includes(CORE_END)) inCore = false;
217
+ if (inFence || inCore || line.includes(PATH_ALLOW)) return;
218
+ for (const token of extractPathTokens(line)) {
219
+ if (isSkipped(token, skipPrefixes)) continue;
220
+ if (!token.includes("/")) {
221
+ if (!tree.hasBasename(token)) misses.push({ file, line: index + 1, token, bases, candidates: [] });
222
+ continue;
223
+ }
224
+ const directoryOnly = token.endsWith("/");
225
+ const candidates = bases
226
+ .map((base) => posix.normalize(posix.join(base, token.replace(/\/+$/, ""))))
227
+ .filter((c) => !c.startsWith(".."));
228
+ // Every reading leaves the repo (`../norsk-ctl`): not this tree's claim.
229
+ if (candidates.length === 0) continue;
230
+ if (candidates.some((c) => tree.has(c, directoryOnly))) continue;
231
+ misses.push({ file, line: index + 1, token, bases, candidates });
232
+ }
233
+ });
234
+ }
235
+
236
+ // One git round-trip for every miss, not one per miss.
237
+ const declaredOutputs = ignored(unique(misses.flatMap((m) => m.candidates)));
238
+ return misses
239
+ .filter((m) => !m.candidates.some((c) => declaredOutputs.has(c)))
240
+ .map(({ file, line, token, bases }) => {
241
+ const looked = bases.map((b) => (b === "" ? "<repo root>" : `${b}/`)).join(", ");
242
+ return {
243
+ file,
244
+ line,
245
+ token,
246
+ message: `${file}:${line}: \`${token}\` does not exist (looked under ${looked}). Fix the path, or append ${PATH_ALLOW} to the line if it is not a repo path.`,
247
+ };
248
+ });
249
+ }
250
+
251
+ function unique<T>(xs: T[]): T[] {
252
+ return [...new Set(xs)];
253
+ }
254
+
255
+ function usage(): never {
256
+ console.error(
257
+ "usage: path-existence.ts [repoRoot] [--root <dir>]... [--exclude <glob>]...\n" +
258
+ " --root extra base a doc path may be relative to (repeatable)\n" +
259
+ " --exclude glob of docs to leave out, e.g. docs/review/** (repeatable)",
260
+ );
261
+ process.exit(2);
262
+ }
263
+
264
+ if (import.meta.main) {
265
+ const args = process.argv.slice(2);
266
+ let repoRoot = process.cwd();
267
+ const roots: string[] = [];
268
+ const exclude: string[] = [];
269
+ for (let i = 0; i < args.length; i++) {
270
+ const arg = args[i] as string;
271
+ if (arg === "--root") roots.push(args[++i] ?? usage());
272
+ else if (arg === "--exclude") exclude.push(args[++i] ?? usage());
273
+ else if (arg.startsWith("-")) usage();
274
+ else repoRoot = arg;
275
+ }
276
+ const tree = loadTree(repoRoot);
277
+ const files = docFilesFor(tree, exclude);
278
+ const findings = checkPathExistence(repoRoot, files, { tree, roots });
279
+ if (findings.length === 0) {
280
+ console.log(`docs path-check: ${files.length} file(s), every cited repo path exists.`);
281
+ process.exit(0);
282
+ }
283
+ console.error(`docs path-check FAILED (${findings.length} dead path(s) across ${files.length} file(s)):`);
284
+ for (const f of findings) console.error(` - ${f.message}`);
285
+ process.exit(1);
286
+ }
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-dev-kit",
3
- "version": "0.1.48",
3
+ "version": "0.1.49",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./package.json": "./package.json",
7
7
  "./testing/invariants": "./testing/invariants.ts",
8
8
  "./testing/byte-snapshot": "./testing/byte-snapshot.ts",
9
9
  "./create-product": "./create-product/create-product.ts",
10
+ "./docs/path-existence": "./docs/path-existence.ts",
10
11
  "./doc-guide": "./doc-guide/doc-guide.js",
11
12
  "./doc-guide/build-manual": "./doc-guide/build-manual.js",
12
13
  "./doc-guide/guides-config": "./doc-guide/guides-config.js",