@norskvideo/ctl-dev-kit 0.2.111 → 0.2.112

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-dev-kit",
3
- "version": "0.2.111",
3
+ "version": "0.2.112",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -47,3 +47,11 @@ runtime-only invariant the emitted graph can't show (e.g. a timing guarantee)
47
47
  still gets a table row — declare its ID in the guard's `untestableIds` option;
48
48
  the guard rejects unknown entries and flags the marker as stale once a test
49
49
  does cite the ID.
50
+
51
+ An existing product whose doc lists invariants as bullets
52
+ (`- **P01** — rule text`) and proves them with a table of rules rather than
53
+ cited IDs can keep that shape: pass `format: "bold-bullet"` and
54
+ `coveredIds: RULES.map((r) => r.id)` (`testDirs` is then unneeded). Only a
55
+ bullet that starts a line defines an ID, so prose like "see rule **P01**"
56
+ never counts; every bullet needs a covering rule or an `untestableIds` entry,
57
+ and a rule ID the doc lacks fails the guard. New products should use the table.
@@ -29,6 +29,23 @@ export function parseInvariants(markdown: string): Invariant[] {
29
29
  return invariants;
30
30
  }
31
31
 
32
+ // The older doc shape some products keep (commentary, playout): one bullet per
33
+ // invariant, `- **P01** — rule text`, with the IDs proven by a rule sweep rather
34
+ // than cited in test text. Anchored to the line start so a prose mention like
35
+ // "rule **IN02** (below)" never counts as a definition.
36
+ const BOLD_BULLET = /^\s*[-*]\s+\*\*([A-Z]+[0-9]+[a-z]?)\*\*\s*(?:[—–-]\s*)?(.*)$/;
37
+
38
+ export function parseBoldBulletInvariants(markdown: string): Invariant[] {
39
+ const invariants: Invariant[] = [];
40
+ markdown.split("\n").forEach((text, i) => {
41
+ const bullet = text.match(BOLD_BULLET);
42
+ if (bullet?.[1] !== undefined && bullet[2] !== undefined) {
43
+ invariants.push({ id: bullet[1], rule: bullet[2], line: i + 1 });
44
+ }
45
+ });
46
+ return invariants;
47
+ }
48
+
32
49
  export interface ParityReport {
33
50
  ok: boolean;
34
51
  problems: string[];
@@ -38,20 +55,35 @@ export interface ParityOptions {
38
55
  // Runtime-only invariants no emitted-graph test can show (e.g. a timing
39
56
  // guarantee). Still parity-checked: each must exist in the table, and a
40
57
  // citing test appearing later flags the marker as stale.
41
- untestableIds?: string[];
58
+ untestableIds?: Iterable<string>;
59
+ format?: "table" | "bold-bullet";
60
+ // The IDs a rule sweep covers, when coverage is data rather than test text.
61
+ // Given, it replaces the scan of test sources entirely.
62
+ coveredIds?: Iterable<string>;
42
63
  }
43
64
 
65
+ const EXPECTED_SHAPE = {
66
+ table: "rows must look like `| INV-AREA-001 | rule text | guarded-by |`",
67
+ "bold-bullet": "bullets must look like `- **AREA01** — rule text`",
68
+ };
69
+
44
70
  export function invariantParity(
45
71
  markdown: string,
46
72
  testSources: Record<string, string>,
47
73
  opts: ParityOptions = {},
48
74
  ): ParityReport {
49
75
  const problems: string[] = [];
50
- const invariants = parseInvariants(markdown);
76
+ const format = opts.format ?? "table";
77
+ const invariants = format === "bold-bullet" ? parseBoldBulletInvariants(markdown) : parseInvariants(markdown);
51
78
  const untestable = new Set(opts.untestableIds ?? []);
79
+ const covered = opts.coveredIds === undefined ? undefined : new Set(opts.coveredIds);
52
80
 
81
+ if (format === "bold-bullet" && covered === undefined) {
82
+ // Bullet IDs are too short to scan test text for safely, so coverage has to be given.
83
+ return { ok: false, problems: ["bold-bullet format needs coveredIds — pass the IDs your rule sweep covers."] };
84
+ }
53
85
  if (invariants.length === 0) {
54
- problems.push("no invariants parsed — rows must look like `| INV-AREA-001 | rule text | guarded-by |`.");
86
+ problems.push(`no invariants parsed — ${EXPECTED_SHAPE[format]}.`);
55
87
  }
56
88
 
57
89
  const seen = new Set<string>();
@@ -60,25 +92,35 @@ export function invariantParity(
60
92
  seen.add(inv.id);
61
93
  }
62
94
 
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.`);
95
+ const referenced = covered ?? new Set<string>();
96
+ if (covered !== undefined) {
97
+ for (const id of covered) {
98
+ if (invariants.length > 0 && !seen.has(id)) {
99
+ problems.push(`covered ID ${id} is not in the invariants doc — add the entry or fix the ID.`);
100
+ }
101
+ }
102
+ } else {
103
+ for (const [file, source] of Object.entries(testSources)) {
104
+ for (const token of source.match(ID_TOKEN) ?? []) {
105
+ referenced.add(token);
106
+ if (invariants.length > 0 && !seen.has(token)) {
107
+ problems.push(
108
+ `${file} references ${token}, which is not in the invariants table — add the row or fix the ID.`,
109
+ );
110
+ }
69
111
  }
70
112
  }
71
113
  }
72
114
 
73
115
  for (const inv of invariants) {
74
116
  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
- );
117
+ const why = covered ? "is in coveredIds" : "a test cites it";
118
+ problems.push(`invariant ${inv.id} is declared untestable but ${why} — remove the stale untestableIds entry.`);
78
119
  }
79
120
  if (!referenced.has(inv.id) && !untestable.has(inv.id)) {
121
+ const fix = covered ? "add a rule for it (coveredIds)" : "cite its ID in the test that guards it,";
80
122
  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.`,
123
+ `invariant ${inv.id} (${inv.rule}) has no referencing test — ${fix} or declare it in untestableIds.`,
82
124
  );
83
125
  }
84
126
  }
@@ -105,13 +147,16 @@ function collectTestFiles(dir: string, out: string[]): void {
105
147
  }
106
148
  }
107
149
 
150
+ // testDirs is only read when coverage comes from test text (no coveredIds).
108
151
  export function invariantParityFromDisk(
109
- opts: { invariantsPath: string; testDirs: string[] } & ParityOptions,
152
+ opts: { invariantsPath: string; testDirs?: string[] } & ParityOptions,
110
153
  ): ParityReport {
111
154
  const markdown = readFileSync(opts.invariantsPath, "utf8");
112
- const files: string[] = [];
113
- for (const dir of opts.testDirs) collectTestFiles(dir, files);
114
155
  const sources: Record<string, string> = {};
115
- for (const file of files) sources[file] = readFileSync(file, "utf8");
116
- return invariantParity(markdown, sources, { untestableIds: opts.untestableIds });
156
+ if (opts.coveredIds === undefined) {
157
+ const files: string[] = [];
158
+ for (const dir of opts.testDirs ?? []) collectTestFiles(dir, files);
159
+ for (const file of files) sources[file] = readFileSync(file, "utf8");
160
+ }
161
+ return invariantParity(markdown, sources, opts);
117
162
  }