@norskvideo/ctl-dev-kit 0.2.70 → 0.2.71

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,109 @@
1
+ // The doc-guides' spoken-budget lint, read from the source so a PR meets it.
2
+ //
3
+ // DocGuide.step() and .text() throw when a caption cannot be narrated
4
+ // (captionFault), but only under DOCS_GENERATE=1, and an engine-tier guide
5
+ // regenerates only in the nightly docs run. So an over-long caption merged
6
+ // green and failed the next night, one per night, since the lint stops at the
7
+ // first (commentary, 2026-10-11/12). checks.yml runs this instead:
8
+ //
9
+ // bun node_modules/@norskvideo/ctl-dev-kit/conventions/caption-check.ts
10
+ //
11
+ // For each step()/text() call whose caption has literal text, it takes the
12
+ // worst case (every optional clause, the longer arm of a ternary, one word per
13
+ // substitution) and applies captionFault with the guide's budget: a literal
14
+ // `captionWords` in the file, else captionBudget(). A caption with no literal
15
+ // text (a wrapper passing variables through) cannot be judged here; the
16
+ // runtime lint still holds it.
17
+
18
+ import { readFileSync } from "node:fs";
19
+ import ts from "typescript";
20
+
21
+ import { captionBudget, captionFault } from "../doc-guide/doc-guide.js";
22
+
23
+ export interface CaptionFault {
24
+ file: string;
25
+ line: number;
26
+ what: string;
27
+ fault: string;
28
+ }
29
+
30
+ const count = (s: string) => s.split(/\s+/).filter(Boolean).length;
31
+
32
+ /** The longest text an expression can narrate, or null when it has no literal
33
+ * text to judge (a variable, a function, a call). */
34
+ function worstCase(e: ts.Expression): string | null {
35
+ if (ts.isStringLiteral(e) || ts.isNoSubstitutionTemplateLiteral(e)) return e.text;
36
+ if (ts.isTemplateExpression(e)) return e.head.text + e.templateSpans.map((s) => ` x ${s.literal.text}`).join("");
37
+ if (ts.isParenthesizedExpression(e)) return worstCase(e.expression);
38
+ if (ts.isConditionalExpression(e)) {
39
+ const [a, b] = [worstCase(e.whenTrue), worstCase(e.whenFalse)];
40
+ if (a === null && b === null) return null;
41
+ return count(a ?? "x") >= count(b ?? "x") ? (a ?? "x") : (b ?? "x");
42
+ }
43
+ if (ts.isBinaryExpression(e) && e.operatorToken.kind === ts.SyntaxKind.PlusToken) {
44
+ const [l, r] = [worstCase(e.left), worstCase(e.right)];
45
+ return l === null && r === null ? null : `${l ?? " x "}${r ?? " x "}`;
46
+ }
47
+ if (ts.isBinaryExpression(e) && e.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken)
48
+ return worstCase(e.right);
49
+ return null;
50
+ }
51
+
52
+ /** Every caption in one source file that cannot be narrated within budget. */
53
+ export function captionFaultsIn(source: string, file: string, budget: number): CaptionFault[] {
54
+ const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true);
55
+ const own = /captionWords\s*:\s*(\d+)/.exec(source);
56
+ const limit = own ? Number(own[1]) : budget;
57
+ const out: CaptionFault[] = [];
58
+ const visit = (n: ts.Node) => {
59
+ if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression)) {
60
+ const method = n.expression.name.text;
61
+ const [first, second] = n.arguments;
62
+ let what: string | null = null;
63
+ let narrated: string | null = null;
64
+ if (method === "step" && first && (!second || worstCase(second) !== null)) {
65
+ const heading = worstCase(first);
66
+ const description = second ? worstCase(second) : "";
67
+ if (heading !== null) {
68
+ what = `step "${heading}"`;
69
+ narrated = description ? `${heading} ${description}` : heading;
70
+ }
71
+ } else if (method === "text" && first && n.arguments.length === 1) {
72
+ const prose = worstCase(first);
73
+ if (prose !== null) {
74
+ what = `text "${prose.split(/\s+/).slice(0, 6).join(" ")} ..."`;
75
+ narrated = prose;
76
+ }
77
+ }
78
+ const fault = narrated === null ? null : captionFault(narrated, limit);
79
+ if (what && fault)
80
+ out.push({ file, line: sf.getLineAndCharacterOfPosition(n.getStart(sf)).line + 1, what, fault });
81
+ }
82
+ ts.forEachChild(n, visit);
83
+ };
84
+ visit(sf);
85
+ return out;
86
+ }
87
+
88
+ /** The repo's guide sources: tracked .ts files that are guides or use DocGuide. */
89
+ function guideFiles(): string[] {
90
+ const listed = Bun.spawnSync(["git", "ls-files", "*.ts"]).stdout.toString().split("\n").filter(Boolean);
91
+ return listed.filter(
92
+ (f) =>
93
+ !f.includes("node_modules/") && (f.endsWith(".guide.test.ts") || readFileSync(f, "utf8").includes("DocGuide")),
94
+ );
95
+ }
96
+
97
+ if (import.meta.main) {
98
+ const files = guideFiles();
99
+ const faults = files.flatMap((f) => captionFaultsIn(readFileSync(f, "utf8"), f, captionBudget()));
100
+ for (const f of faults)
101
+ console.log(`::error file=${f.file},line=${f.line}::doc-guide ${f.what} cannot be narrated: ${f.fault}`);
102
+ if (faults.length) {
103
+ console.error(
104
+ `caption-check: ${faults.length} caption(s) in ${files.length} guide file(s) cannot be narrated (worst case: every optional clause included).`,
105
+ );
106
+ process.exit(1);
107
+ }
108
+ console.log(`caption-check: ${files.length} guide file(s), every caption within budget.`);
109
+ }
@@ -209,6 +209,10 @@ jobs:
209
209
  bun "$q" check
210
210
  BUN_OPTIONS="$(bun "$q" bun-options)" bun run test:unit
211
211
  bun run docs:check
212
+ # Every doc-guide caption within the spoken budget, read from the
213
+ # source (conventions/caption-check.ts): the runtime lint only runs
214
+ # when a guide regenerates, which for the engine tier is the nightly.
215
+ bun node_modules/@norskvideo/ctl-dev-kit/conventions/caption-check.ts
212
216
  # The tree must resolve ONE zod 4 (conventions/zod-singleton.ts).
213
217
  # studio guards this in-repo but never shipped the guard; products
214
218
  # are where studio/ctl/moq-watch zod ranges meet, so a split slips
@@ -212,6 +212,10 @@ jobs:
212
212
  bun "$q" check
213
213
  BUN_OPTIONS="$(bun "$q" bun-options)" bun run test:unit
214
214
  bun run docs:check
215
+ # Every doc-guide caption within the spoken budget, read from the
216
+ # source (conventions/caption-check.ts): the runtime lint only runs
217
+ # when a guide regenerates, which for the engine tier is the nightly.
218
+ bun node_modules/@norskvideo/ctl-dev-kit/conventions/caption-check.ts
215
219
  # The tree must resolve ONE zod 4 (conventions/zod-singleton.ts).
216
220
  # studio guards this in-repo but never shipped the guard; products
217
221
  # are where studio/ctl/moq-watch zod ranges meet, so a split slips
@@ -28,7 +28,8 @@ export declare function docsRoot(): string;
28
28
  * at about 2.5 words/s: 35 words is 14 s on one still, 50 is 20 s, and the
29
29
  * renderer's own lint warns at 35. This is the hard stop. */
30
30
  export declare const DEFAULT_CAPTION_WORDS = 50;
31
- /** The part of a step's text that is narrated: everything but its fenced blocks. */
31
+ /** The part of a step's text that is narrated: everything but its fenced
32
+ * blocks, with each markdown link read as its label. */
32
33
  export declare function spokenText(text: string): string;
33
34
  /** Why a step's text cannot be narrated as written, or null when it can. Pure,
34
35
  * so the wording is pinned without a browser. Checks the budget first: an
@@ -20,9 +20,12 @@ const SLASH_BETWEEN_WORDS = /\S+\/\S+/;
20
20
  /** A fenced block (``` or ~~~, closed or running to the end as markdown has
21
21
  * it) is a transcript or a file to copy: the page shows it, nobody speaks it. */
22
22
  const FENCED_BLOCK = /^(`{3,}|~{3,})[^\n]*$[\s\S]*?(?:^\1[ \t]*$|(?![\s\S]))/gm;
23
- /** The part of a step's text that is narrated: everything but its fenced blocks. */
23
+ /** A markdown link: the narration reads its label, the page carries the target. */
24
+ const MARKDOWN_LINK = /\[([^\]]*)\]\([^)\s]*\)/g;
25
+ /** The part of a step's text that is narrated: everything but its fenced
26
+ * blocks, with each markdown link read as its label. */
24
27
  export function spokenText(text) {
25
- return text.replace(FENCED_BLOCK, "");
28
+ return text.replace(FENCED_BLOCK, "").replace(MARKDOWN_LINK, "$1");
26
29
  }
27
30
  /** Why a step's text cannot be narrated as written, or null when it can. Pure,
28
31
  * so the wording is pinned without a browser. Checks the budget first: an
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-dev-kit",
3
- "version": "0.2.70",
3
+ "version": "0.2.71",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./create-product": "./create-product/create-product.ts",
@@ -34,7 +34,8 @@
34
34
  "marked": "18.0.11"
35
35
  },
36
36
  "peerDependencies": {
37
- "@playwright/test": "*"
37
+ "@playwright/test": "*",
38
+ "typescript": ">=5"
38
39
  },
39
40
  "devDependencies": {
40
41
  "@norskvideo/ctl-oas-to-ts": "^0.1.0",