doctrina-cli 0.13.0 → 0.15.0

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.
Files changed (71) hide show
  1. package/README.md +1 -1
  2. package/package.json +3 -2
  3. package/src/commands/adapter.js +205 -0
  4. package/src/commands/analyze.js +120 -17
  5. package/src/commands/change.js +100 -42
  6. package/src/commands/clarify.js +8 -1
  7. package/src/commands/close.js +57 -2
  8. package/src/commands/completion.js +6 -0
  9. package/src/commands/constitution.js +8 -1
  10. package/src/commands/context.js +391 -58
  11. package/src/commands/contract.js +76 -43
  12. package/src/commands/coverage.js +126 -12
  13. package/src/commands/decision.js +172 -40
  14. package/src/commands/doctor.js +92 -11
  15. package/src/commands/handoff.js +8 -1
  16. package/src/commands/hooks.js +6 -0
  17. package/src/commands/index-rebuild.js +8 -1
  18. package/src/commands/init.js +139 -6
  19. package/src/commands/intake.js +11 -2
  20. package/src/commands/intent.js +8 -1
  21. package/src/commands/metrics.js +80 -7
  22. package/src/commands/next.js +38 -3
  23. package/src/commands/prime.js +8 -1
  24. package/src/commands/report.js +8 -1
  25. package/src/commands/review.js +8 -1
  26. package/src/commands/search.js +8 -1
  27. package/src/commands/show.js +8 -1
  28. package/src/commands/skill.js +201 -8
  29. package/src/commands/spec.js +13 -6
  30. package/src/commands/status.js +14 -2
  31. package/src/commands/templates.js +193 -59
  32. package/src/commands/trace.js +15 -3
  33. package/src/commands/triage.js +248 -0
  34. package/src/commands/upgrade.js +8 -1
  35. package/src/commands/validate.js +148 -5
  36. package/src/commands/verify.js +126 -5
  37. package/src/commands/watch.js +8 -1
  38. package/src/commands/why.js +10 -24
  39. package/src/commands/work.js +44 -3
  40. package/src/index.js +61 -6
  41. package/src/lib/adapters.js +140 -0
  42. package/src/lib/adr-guard.js +1 -0
  43. package/src/lib/args.js +10 -0
  44. package/src/lib/clarity.js +1 -0
  45. package/src/lib/colors.js +1 -0
  46. package/src/lib/commands.js +252 -27
  47. package/src/lib/criteria.js +1 -0
  48. package/src/lib/dates.js +1 -0
  49. package/src/lib/diff.js +1 -0
  50. package/src/lib/doc-model.js +287 -0
  51. package/src/lib/docs-impact.js +167 -0
  52. package/src/lib/ears.js +1 -0
  53. package/src/lib/exit-codes.js +103 -0
  54. package/src/lib/flag-catalog.js +106 -0
  55. package/src/lib/fs-ops.js +1 -0
  56. package/src/lib/gates.js +123 -0
  57. package/src/lib/git.js +97 -0
  58. package/src/lib/index-json.js +14 -6
  59. package/src/lib/json-out.js +83 -0
  60. package/src/lib/pipeline.js +161 -0
  61. package/src/lib/prompt.js +18 -1
  62. package/src/lib/runtime.js +683 -0
  63. package/src/lib/scan.js +81 -33
  64. package/src/lib/spec-ops.js +94 -9
  65. package/src/lib/suggest.js +1 -0
  66. package/src/lib/templates.js +89 -0
  67. package/src/lib/usage.js +89 -0
  68. package/src/lib/version.js +1 -0
  69. package/templates/AGENTS.md.template +59 -53
  70. package/templates/contract.md.template +74 -3
  71. package/templates/spec.md.template +22 -0
@@ -0,0 +1,683 @@
1
+ // @ts-check
2
+ import path from "node:path";
3
+ import { getSection } from "./doc-model.js";
4
+ import { isDir, isFile, read, relPath, walk } from "./fs-ops.js";
5
+
6
+ // The RUNTIME surface: the half of the truth that never lived in Markdown.
7
+ //
8
+ // Doctrina's index assumes truth lives in versioned prose. Operational truth
9
+ // does not. It lives in a job's `env:` block, in an absent `${{ vars.X }}`
10
+ // collapsing to the empty string so a `getenv(name, default)` default never
11
+ // applies, in an enum a contract declares and no code validates, and in a
12
+ // test selector that matches zero cases and still exits 0. None of that is
13
+ // EARS, and until now no gate could see any of it — so the agent read YAML
14
+ // and source by hand and recorded the lesson only afterwards.
15
+ //
16
+ // The constraint that keeps this honest: Doctrina learns NO CI system, test
17
+ // runner, or language. Every check below reads a DECLARATION the project
18
+ // wrote in its contract — an origin, a workflow path, a glob, a pattern —
19
+ // and verifies the implementation against it. GitHub Actions and Behave are
20
+ // examples in the docs, never code paths here. That is what keeps the
21
+ // product non-goal ("not a CI/CD system, not a test framework") and SC1
22
+ // ("no runtime dependencies beside Node.js") intact.
23
+ //
24
+ // Every check returns FINDINGS, never prints: one shape, so `contract
25
+ // check`, `validate --runtime`, `triage`, and `doctor` can all render the
26
+ // same verdict and never disagree about it.
27
+
28
+ /**
29
+ * @typedef {object} Finding
30
+ * @property {string} code Stable machine code (RT01...), for remedies and tests.
31
+ * @property {"error"|"warn"} level
32
+ * @property {string} message What is wrong, in the agent's terms.
33
+ * @property {string} remedy The exact thing to do about it.
34
+ * @property {string} [file] Project-relative path the finding is about.
35
+ * @property {string} [contract] Contract the finding came from.
36
+ */
37
+
38
+ /** Origins whose value CI can inject as an EMPTY STRING rather than unset. */
39
+ const INJECTABLE_ORIGINS = new Set(["vars", "secrets"]);
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Declaration parsing
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /**
46
+ * Parse a contract's runtime declarations. Every section is OPTIONAL: a
47
+ * contract written before this existed parses to empty lists and produces no
48
+ * findings, which is what makes the whole feature additive.
49
+ */
50
+ export function parseRuntimeDeclaration(text) {
51
+ return {
52
+ env: rows(getSection(text, "Environment"), {
53
+ name: "variable",
54
+ required: "required",
55
+ values: "values",
56
+ example: "example",
57
+ }),
58
+ wiring: rows(getSection(text, "Wiring"), {
59
+ name: "variable",
60
+ origin: "origin",
61
+ workflow: "workflow",
62
+ job: "job/step",
63
+ consumer: "consumer",
64
+ }),
65
+ selectors: rows(getSection(text, "Selectors"), {
66
+ name: "selector",
67
+ source: "source",
68
+ pattern: "pattern",
69
+ usedBy: "used by",
70
+ }),
71
+ budgets: rows(getSection(text, "Budgets"), {
72
+ name: "limit",
73
+ direction: "direction",
74
+ value: "value",
75
+ }),
76
+ };
77
+ }
78
+
79
+ // A GitHub-flavoured Markdown table, mapped onto named fields. Rows whose
80
+ // key cell is a template placeholder (`<...>`) are scaffolding, not
81
+ // declarations, and are skipped — a freshly scaffolded contract must not
82
+ // start failing its own gate.
83
+ function rows(section, fields) {
84
+ const table = parseTable(section);
85
+ if (!table) return [];
86
+ const out = [];
87
+ for (const row of table.rows) {
88
+ const item = {};
89
+ for (const [key, header] of Object.entries(fields)) {
90
+ const i = table.headers.findIndex((h) => h.toLowerCase() === header);
91
+ item[key] = i < 0 ? "" : stripCode((row[i] ?? "").trim());
92
+ }
93
+ if (!item.name || /^[<(]/.test(item.name) || item.name === "—") continue;
94
+ out.push(item);
95
+ }
96
+ return out;
97
+ }
98
+
99
+ export function parseTable(text) {
100
+ const lines = String(text ?? "").split(/\r?\n/).filter((l) => /^\s*\|/.test(l));
101
+ if (lines.length < 2) return null;
102
+ const headers = tableCells(lines[0]);
103
+ const body = [];
104
+ for (let i = 1; i < lines.length; i++) {
105
+ const row = tableCells(lines[i]);
106
+ if (row.every((cl) => /^:?-+:?$/.test(cl) || cl === "")) continue;
107
+ body.push(row);
108
+ }
109
+ return { headers, rows: body };
110
+ }
111
+
112
+ // Split one table row on UNESCAPED pipes, unescaping `\|` into a literal.
113
+ // GFM's own escape, and the notation a declared enum needs most: an
114
+ // alternation like `none|critical|serious` is written `none\|critical\|serious`
115
+ // in a cell, and splitting naively on every pipe shreds it into three columns.
116
+ export function tableCells(line) {
117
+ const body = line.trim().replace(/^\|/, "");
118
+ const out = [];
119
+ let cur = "";
120
+ for (let i = 0; i < body.length; i++) {
121
+ if (body[i] === "\\" && body[i + 1] === "|") {
122
+ cur += "|";
123
+ i += 1;
124
+ continue;
125
+ }
126
+ if (body[i] === "|") {
127
+ out.push(cur.trim());
128
+ cur = "";
129
+ continue;
130
+ }
131
+ cur += body[i];
132
+ }
133
+ out.push(cur.trim());
134
+ // A row written with a closing pipe leaves one empty cell behind it.
135
+ if (out.length > 1 && out[out.length - 1] === "" && /(?:^|[^\\])\|\s*$/.test(line)) out.pop();
136
+ return out;
137
+ }
138
+
139
+ function stripCode(s) {
140
+ return String(s).replace(/^`|`$/g, "").trim();
141
+ }
142
+
143
+ // A declared enum: `none|critical|serious`, or `-`/empty for "not an enum".
144
+ function parseValues(cell) {
145
+ const raw = stripCode(String(cell ?? "")).trim();
146
+ if (!raw || raw === "—" || raw === "-") return [];
147
+ if (!raw.includes("|")) return [];
148
+ return raw.split("|").map((v) => stripCode(v)).filter(Boolean);
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // The workflow reader — a deliberately small, indentation-aware scan
153
+ // ---------------------------------------------------------------------------
154
+
155
+ /**
156
+ * Every `env:` mapping in a workflow file, with the job and step it belongs
157
+ * to and the expression each key is bound to.
158
+ *
159
+ * This is NOT a YAML parser and does not want to be: adding one would mean a
160
+ * runtime dependency (SC1) and knowledge of a specific CI schema. It reads
161
+ * the two shapes every CI format shares — an `env:` block, and keys indented
162
+ * under it — and attributes each to the nearest enclosing named block. What
163
+ * it cannot read it reports as unreadable rather than as "absent", so an
164
+ * unusual file never turns into a false accusation.
165
+ *
166
+ * @returns {{ entries: Array<{key:string, expr:string, job:string|null, step:string|null, line:number}>, readable: boolean }}
167
+ */
168
+ export function readWorkflowEnv(text) {
169
+ const lines = String(text ?? "").split(/\r?\n/);
170
+ const entries = [];
171
+ let job = null;
172
+ let jobIndent = -1;
173
+ let step = null;
174
+ let inEnv = false;
175
+ let envIndent = -1;
176
+ let sawJobs = false;
177
+
178
+ for (let i = 0; i < lines.length; i++) {
179
+ const line = lines[i];
180
+ if (!line.trim() || /^\s*#/.test(line)) continue;
181
+ const indent = line.match(/^\s*/)[0].length;
182
+
183
+ if (/^jobs:\s*$/.test(line)) {
184
+ sawJobs = true;
185
+ jobIndent = -1;
186
+ inEnv = false;
187
+ continue;
188
+ }
189
+
190
+ // A job header: the first mapping key nested under `jobs:`.
191
+ if (sawJobs && /^\s+[A-Za-z0-9_.-]+:\s*$/.test(line) && (jobIndent === -1 || indent === jobIndent)) {
192
+ if (jobIndent === -1) jobIndent = indent;
193
+ if (indent === jobIndent) {
194
+ job = line.trim().replace(/:$/, "");
195
+ step = null;
196
+ inEnv = false;
197
+ continue;
198
+ }
199
+ }
200
+
201
+ // A step: `- name: X` or `- uses: X` inside a job's `steps:`.
202
+ const stepMatch = line.match(/^\s*-\s+(?:name|uses):\s*(.+?)\s*$/);
203
+ if (stepMatch) {
204
+ step = stripQuotes(stepMatch[1]);
205
+ inEnv = false;
206
+ continue;
207
+ }
208
+
209
+ if (/^\s*env:\s*$/.test(line)) {
210
+ inEnv = true;
211
+ envIndent = indent;
212
+ continue;
213
+ }
214
+
215
+ if (inEnv) {
216
+ if (indent <= envIndent) {
217
+ inEnv = false;
218
+ } else {
219
+ const kv = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$/);
220
+ if (kv) {
221
+ entries.push({ key: kv[1], expr: stripQuotes(kv[2].trim()), job, step, line: i + 1 });
222
+ continue;
223
+ }
224
+ }
225
+ }
226
+ }
227
+
228
+ // A workflow with no `jobs:` and no `env:` at all is more likely an
229
+ // unreadable/foreign shape than a genuinely empty one. Saying so is the
230
+ // difference between a finding and a false accusation.
231
+ return { entries, readable: sawJobs || entries.length > 0 };
232
+ }
233
+
234
+ function stripQuotes(s) {
235
+ return String(s).replace(/^["']|["']$/g, "").trim();
236
+ }
237
+
238
+ // The variable an expression pulls from, e.g. `${{ vars.FOO }}` -> vars/FOO.
239
+ export function referencedVar(expr) {
240
+ const m = String(expr ?? "").match(/\$\{\{\s*(vars|secrets|env|inputs)\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/);
241
+ return m ? { origin: m[1], name: m[2] } : null;
242
+ }
243
+
244
+ function escapeRe(s) {
245
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
246
+ }
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // Check A / RT01-RT02 — the declared flag must actually be wired
250
+ // ---------------------------------------------------------------------------
251
+
252
+ export function checkWiring(projectRoot, decl) {
253
+ /** @type {Finding[]} */
254
+ const findings = [];
255
+ const cache = new Map();
256
+
257
+ for (const row of decl.wiring) {
258
+ const origin = row.origin.toLowerCase();
259
+ if (!INJECTABLE_ORIGINS.has(origin)) continue;
260
+ if (!row.workflow) {
261
+ findings.push({
262
+ code: "RT01",
263
+ level: "warn",
264
+ message: `${row.name} declares origin "${origin}" but names no workflow — nothing binds the declaration to a job`,
265
+ remedy: `fill the Workflow column for ${row.name} in the contract's Wiring table`,
266
+ });
267
+ continue;
268
+ }
269
+
270
+ const full = path.join(projectRoot, row.workflow);
271
+ if (!isFile(full)) {
272
+ findings.push({
273
+ code: "RT01",
274
+ level: "error",
275
+ message: `${row.name} is wired to ${row.workflow}, which does not exist`,
276
+ remedy: `correct the Workflow column for ${row.name}, or add ${row.workflow}`,
277
+ file: row.workflow,
278
+ });
279
+ continue;
280
+ }
281
+
282
+ if (!cache.has(full)) cache.set(full, readWorkflowEnv(read(full)));
283
+ const wf = cache.get(full);
284
+ if (!wf.readable) {
285
+ findings.push({
286
+ code: "RT01",
287
+ level: "warn",
288
+ message: `${row.workflow} has no readable jobs or env: blocks — ${row.name} could not be checked`,
289
+ remedy: `confirm ${row.workflow} is the right file; Doctrina reads env: blocks textually, not as YAML`,
290
+ file: row.workflow,
291
+ });
292
+ continue;
293
+ }
294
+
295
+ // The declaration is satisfied when the variable is exported under the
296
+ // name the consumer reads. This is the whole "I set the secret in GitHub
297
+ // and nothing happened" class: the secret exists, and no `env:` line
298
+ // ever puts it in the process.
299
+ const exported = wf.entries.filter((e) => e.key === row.name);
300
+ if (exported.length === 0) {
301
+ const where = row.job ? ` (expected in ${row.job})` : "";
302
+ findings.push({
303
+ code: "RT01",
304
+ level: "error",
305
+ message: `${row.name} is declared with origin "${origin}" but no env: block in ${row.workflow} exports it${where} — the value exists in CI and never reaches the process`,
306
+ remedy: `add "${row.name}: \${{ ${origin}.${row.name} }}" to the env: block of the ${row.job || "relevant"} job in ${row.workflow}`,
307
+ file: row.workflow,
308
+ });
309
+ continue;
310
+ }
311
+
312
+ for (const e of exported) {
313
+ const ref = referencedVar(e.expr);
314
+ if (!ref) continue; // a literal value is a legitimate wiring
315
+ if (ref.origin !== origin && (ref.origin === "vars" || ref.origin === "secrets")) {
316
+ findings.push({
317
+ code: "RT02",
318
+ level: "error",
319
+ message: `${row.name} is declared as "${origin}" but ${row.workflow}:${e.line} reads it from ${ref.origin} — one of the two is wrong, and CI will read the empty one`,
320
+ remedy: `align the Origin column with ${row.workflow}:${e.line} (a value set as ${origin} is not visible as ${ref.origin})`,
321
+ file: row.workflow,
322
+ });
323
+ } else if (ref.name !== row.name && ref.origin === origin) {
324
+ findings.push({
325
+ code: "RT02",
326
+ level: "warn",
327
+ message: `${row.workflow}:${e.line} exports ${row.name} from ${ref.origin}.${ref.name} — the names differ, so renaming one silently empties the other`,
328
+ remedy: `use \${{ ${origin}.${row.name} }}, or record the intentional rename in the Wiring row`,
329
+ file: row.workflow,
330
+ });
331
+ }
332
+ }
333
+ }
334
+ return findings;
335
+ }
336
+
337
+ // ---------------------------------------------------------------------------
338
+ // Check B / RT03 — empty is not unset
339
+ // ---------------------------------------------------------------------------
340
+
341
+ // The footgun patterns, per language family. Each one supplies a DEFAULT that
342
+ // only applies when the variable is ABSENT — and a CI expression that resolves
343
+ // to nothing injects the empty STRING, which is present. The default never
344
+ // fires, and the flag reads as "" instead of its documented fallback.
345
+ //
346
+ // A textual lint, and its finding says so: no program analysis, no language
347
+ // runtime, no parse. It matches the shapes that are wrong on their face and
348
+ // stays silent on everything else.
349
+ const EMPTY_UNSAFE = [
350
+ {
351
+ build: (n) => new RegExp(`getenv\\(\\s*["']${n}["']\\s*,`),
352
+ hint: (n) => `getenv("${n}", default) returns "" when CI injects an empty value`,
353
+ },
354
+ {
355
+ build: (n) => new RegExp(`environ\\.get\\(\\s*["']${n}["']\\s*,`),
356
+ hint: (n) => `environ.get("${n}", default) returns "" when CI injects an empty value`,
357
+ },
358
+ {
359
+ build: (n) => new RegExp(`process\\.env\\.${n}\\s*\\?\\?`),
360
+ hint: (n) => `process.env.${n} ?? default keeps "" (only null/undefined fall through)`,
361
+ },
362
+ {
363
+ build: (n) => new RegExp(`process\\.env\\[\\s*["']${n}["']\\s*\\]\\s*\\?\\?`),
364
+ hint: (n) => `process.env["${n}"] ?? default keeps "" (only null/undefined fall through)`,
365
+ },
366
+ ];
367
+
368
+ export function checkEmptySemantics(projectRoot, decl) {
369
+ /** @type {Finding[]} */
370
+ const findings = [];
371
+ const cache = new Map();
372
+
373
+ for (const row of decl.wiring) {
374
+ const origin = row.origin.toLowerCase();
375
+ if (!INJECTABLE_ORIGINS.has(origin) || !row.consumer) continue;
376
+ const full = path.join(projectRoot, row.consumer);
377
+ if (!isFile(full)) {
378
+ findings.push({
379
+ code: "RT03",
380
+ level: "warn",
381
+ message: `${row.name} names consumer ${row.consumer}, which does not exist`,
382
+ remedy: `correct the Consumer column for ${row.name} in the Wiring table`,
383
+ file: row.consumer,
384
+ });
385
+ continue;
386
+ }
387
+ if (!cache.has(full)) cache.set(full, read(full));
388
+ const src = cache.get(full);
389
+ const name = escapeRe(row.name);
390
+ for (const pattern of EMPTY_UNSAFE) {
391
+ if (!pattern.build(name).test(src)) continue;
392
+ findings.push({
393
+ code: "RT03",
394
+ level: "error",
395
+ message: `${row.consumer} gives ${row.name} a default that an empty value never triggers — ${pattern.hint(row.name)}. CI injects "" for an unset ${origin}, so the documented default silently does not apply`,
396
+ remedy: `read the raw value, then treat empty as unset: take the default when the value is missing OR blank (textual lint — confirm the call site)`,
397
+ file: row.consumer,
398
+ });
399
+ break;
400
+ }
401
+ }
402
+ return findings;
403
+ }
404
+
405
+ // ---------------------------------------------------------------------------
406
+ // Check RT04 — a declared enum nobody validates
407
+ // ---------------------------------------------------------------------------
408
+
409
+ export function checkEnums(projectRoot, decl) {
410
+ /** @type {Finding[]} */
411
+ const findings = [];
412
+ const envExamplePath = path.join(projectRoot, ".env.example");
413
+ const envExample = isFile(envExamplePath) ? read(envExamplePath) : null;
414
+ const consumerFor = new Map(decl.wiring.map((w) => [w.name, w.consumer]));
415
+
416
+ for (const row of decl.env) {
417
+ const values = parseValues(row.values);
418
+ if (values.length === 0) continue;
419
+
420
+ // The example must be a member — `AXE_SEVERITY=true` against an enum of
421
+ // none|critical|serious is the invalid value that aborts config
422
+ // validation on boot, discovered in CI rather than on the laptop.
423
+ if (envExample !== null) {
424
+ const m = envExample.match(new RegExp(`^\\s*(?:export\\s+)?${escapeRe(row.name)}\\s*=\\s*(.*)$`, "m"));
425
+ const example = m ? stripQuotes(m[1].trim()) : null;
426
+ if (example && !values.includes(example)) {
427
+ findings.push({
428
+ code: "RT04",
429
+ level: "error",
430
+ message: `.env.example sets ${row.name} to a value outside ${values.join("|")}`,
431
+ remedy: `set ${row.name} to one of ${values.join("|")} in .env.example, or widen the contract's Values cell`,
432
+ file: ".env.example",
433
+ });
434
+ }
435
+ }
436
+
437
+ // An enum the consumer never mentions is an enum nothing enforces: the
438
+ // contract says none|critical|serious and the code takes any string.
439
+ const consumer = consumerFor.get(row.name);
440
+ if (!consumer) continue;
441
+ const full = path.join(projectRoot, consumer);
442
+ if (!isFile(full)) continue;
443
+ const src = read(full);
444
+ if (!values.some((v) => src.includes(v))) {
445
+ findings.push({
446
+ code: "RT04",
447
+ level: "warn",
448
+ message: `${consumer} never mentions any of ${row.name}'s declared values (${values.join("|")}) — the contract declares an enum the consumer does not appear to validate`,
449
+ remedy: `validate ${row.name} against ${values.join("|")} in ${consumer}, or drop the Values cell if it is not really an enum`,
450
+ file: consumer,
451
+ });
452
+ }
453
+ }
454
+ return findings;
455
+ }
456
+
457
+ // ---------------------------------------------------------------------------
458
+ // Check D / RT05 — the selector that matches nothing
459
+ // ---------------------------------------------------------------------------
460
+
461
+ /**
462
+ * A test selector (a BDD tag, a suite name, a filter expression) that matches
463
+ * zero targets and still exits 0 is a green job that ran nothing. The
464
+ * contract declares WHERE selectors live (a glob) and HOW to read them (a
465
+ * regex whose first group is the name); this verifies that every selector
466
+ * the project dispatches on actually matches something.
467
+ */
468
+ export function checkSelectors(projectRoot, decl) {
469
+ /** @type {Finding[]} */
470
+ const findings = [];
471
+ for (const row of decl.selectors) {
472
+ const used = row.usedBy
473
+ ? row.usedBy.split(/[,\s]+/).map(stripCode).filter(Boolean)
474
+ : [row.name];
475
+ if (!row.source || !row.pattern) {
476
+ findings.push({
477
+ code: "RT05",
478
+ level: "warn",
479
+ message: `selector "${row.name}" declares no Source glob or no Pattern — nothing can be extracted to compare against`,
480
+ remedy: `fill Source (e.g. features/**/*.feature) and Pattern (a regex whose group 1 is the selector) for "${row.name}"`,
481
+ });
482
+ continue;
483
+ }
484
+
485
+ let re;
486
+ try {
487
+ re = new RegExp(row.pattern, "g");
488
+ } catch {
489
+ findings.push({
490
+ code: "RT05",
491
+ level: "error",
492
+ message: `selector "${row.name}" declares an invalid Pattern regex: ${row.pattern}`,
493
+ remedy: `fix the Pattern cell for "${row.name}" so it compiles as a regular expression`,
494
+ });
495
+ continue;
496
+ }
497
+
498
+ const files = filesMatching(projectRoot, row.source);
499
+ if (files.length === 0) {
500
+ findings.push({
501
+ code: "RT05",
502
+ level: "error",
503
+ message: `selector "${row.name}" declares source "${row.source}", which matches no files — every dispatch on it would run zero cases and still exit 0`,
504
+ remedy: `correct the Source glob for "${row.name}"`,
505
+ });
506
+ continue;
507
+ }
508
+
509
+ const found = new Set();
510
+ for (const f of files) {
511
+ for (const m of read(f).matchAll(re)) {
512
+ if (m[1]) found.add(m[1]);
513
+ }
514
+ }
515
+
516
+ for (const sel of used) {
517
+ if (found.has(sel)) continue;
518
+ // A separator typo (`smoke-test` vs `smoke_test`) is the common shape
519
+ // of this bug, and naming the near-miss turns a hunt into a one-line fix.
520
+ const near = [...found].filter((f) => f.replace(/[-_]/g, "") === sel.replace(/[-_]/g, ""));
521
+ findings.push({
522
+ code: "RT05",
523
+ level: "error",
524
+ message: `selector "${sel}" matches nothing under ${row.source} — a run dispatched on it executes 0 cases and exits 0` +
525
+ (near.length > 0 ? ` (found "${near[0]}" — the separator differs)` : ""),
526
+ remedy: near.length > 0
527
+ ? `use "${near[0]}", or rename the target to "${sel}"`
528
+ : `add the selector to a file under ${row.source}, or correct the Used by cell`,
529
+ });
530
+ }
531
+ }
532
+ return findings;
533
+ }
534
+
535
+ // A minimal glob: `**` spans directories, `*` stays inside one segment.
536
+ // Enough for the "where do my selectors live" declarations, with no
537
+ // dependency and no surprises.
538
+ export function globToRegExp(glob) {
539
+ const segments = String(glob).split("/");
540
+ const parts = [];
541
+ for (let i = 0; i < segments.length; i++) {
542
+ const last = i === segments.length - 1;
543
+ const seg = segments[i];
544
+ if (seg === "**") {
545
+ // `**/` spans zero or more directories (and supplies its own
546
+ // separator); a trailing `**` takes everything below.
547
+ parts.push(last ? ".*" : "(?:[^/]+/)*");
548
+ continue;
549
+ }
550
+ // Character by character: only `*` and `?` are wildcards, and every
551
+ // other character is a literal. Escaping per character keeps this
552
+ // readable and immune to the ordering bugs a chain of replaces invites.
553
+ const body = [...seg]
554
+ .map((ch) => (ch === "*" ? "[^/]*" : ch === "?" ? "[^/]" : escapeRe(ch)))
555
+ .join("");
556
+ parts.push(last ? body : `${body}/`);
557
+ }
558
+ return new RegExp(`^${parts.join("")}$`);
559
+ }
560
+
561
+ function filesMatching(projectRoot, glob) {
562
+ const re = globToRegExp(glob);
563
+ // Walk from the deepest literal directory prefix so a narrow glob never
564
+ // costs a full-tree walk.
565
+ const prefix = String(glob).split("/").filter((s) => !/[*?]/.test(s));
566
+ let base = projectRoot;
567
+ for (const seg of prefix) {
568
+ const next = path.join(base, seg);
569
+ if (isDir(next)) base = next;
570
+ else break;
571
+ }
572
+ if (!isDir(base)) return [];
573
+ return walk(base).filter((f) => re.test(relPath(projectRoot, f).replaceAll("\\", "/")));
574
+ }
575
+
576
+ // ---------------------------------------------------------------------------
577
+ // Budgets (G) — a ceiling is a contract, not a preference
578
+ // ---------------------------------------------------------------------------
579
+
580
+ /** Declared ceilings, keyed by limit name: { direction, value }. */
581
+ export function parseBudgets(decl) {
582
+ const out = new Map();
583
+ for (const row of decl.budgets) {
584
+ const value = Number.parseInt(String(row.value).replace(/[^0-9]/g, ""), 10);
585
+ if (!Number.isFinite(value)) continue;
586
+ out.set(row.name, { direction: String(row.direction).toLowerCase(), value });
587
+ }
588
+ return out;
589
+ }
590
+
591
+ /** Every declared budget across every contract, keyed by limit name. */
592
+ export function collectBudgets(projectRoot) {
593
+ const out = new Map();
594
+ for (const file of contractPaths(projectRoot)) {
595
+ for (const [name, budget] of parseBudgets(parseRuntimeDeclaration(read(file)))) {
596
+ out.set(name, { ...budget, contract: relPath(projectRoot, file).replaceAll("\\", "/") });
597
+ }
598
+ }
599
+ return out;
600
+ }
601
+
602
+ // ---------------------------------------------------------------------------
603
+ // The whole runtime verdict, for every contract in the project
604
+ // ---------------------------------------------------------------------------
605
+
606
+ export function contractPaths(projectRoot) {
607
+ const dir = path.join(projectRoot, ".doctrina", "contracts");
608
+ if (!isDir(dir)) return [];
609
+ return walk(dir).filter((f) => f.endsWith(".md")).sort();
610
+ }
611
+
612
+ /**
613
+ * Run every runtime check over every contract.
614
+ * @returns {{ findings: Finding[], contracts: number, declared: number }}
615
+ */
616
+ export function collectRuntimeFindings(projectRoot) {
617
+ /** @type {Finding[]} */
618
+ const findings = [];
619
+ let declared = 0;
620
+ const files = contractPaths(projectRoot);
621
+ for (const file of files) {
622
+ const decl = parseRuntimeDeclaration(read(file));
623
+ declared += decl.wiring.length + decl.selectors.length;
624
+ const where = relPath(projectRoot, file).replaceAll("\\", "/");
625
+ for (const f of [
626
+ ...checkWiring(projectRoot, decl),
627
+ ...checkEmptySemantics(projectRoot, decl),
628
+ ...checkEnums(projectRoot, decl),
629
+ ...checkSelectors(projectRoot, decl),
630
+ ]) {
631
+ findings.push({ ...f, contract: where });
632
+ }
633
+ }
634
+ return { findings, contracts: files.length, declared };
635
+ }
636
+
637
+ /**
638
+ * Local `.env` against the declared enums — NAMES and membership only. The
639
+ * value is never read into a message, never printed, and never logged: the
640
+ * point is to catch the invalid value before it aborts config validation in
641
+ * CI, not to leak a secret into a terminal or a CI log.
642
+ */
643
+ export function checkLocalEnv(projectRoot) {
644
+ /** @type {Finding[]} */
645
+ const findings = [];
646
+ const envPath = path.join(projectRoot, ".env");
647
+ if (!isFile(envPath)) return findings;
648
+ const env = new Map();
649
+ for (const line of read(envPath).split(/\r?\n/)) {
650
+ if (/^\s*#/.test(line)) continue;
651
+ const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
652
+ if (m) env.set(m[1], stripQuotes(m[2].trim()));
653
+ }
654
+ for (const file of contractPaths(projectRoot)) {
655
+ const decl = parseRuntimeDeclaration(read(file));
656
+ for (const row of decl.env) {
657
+ const values = parseValues(row.values);
658
+ const required = /^(yes|true|required)$/i.test(String(row.required).trim());
659
+ if (required && !env.has(row.name)) {
660
+ findings.push({
661
+ code: "RT06",
662
+ level: "error",
663
+ message: `.env does not set ${row.name}, which the contract marks required`,
664
+ remedy: `add ${row.name} to your local .env (see .env.example)`,
665
+ file: ".env",
666
+ });
667
+ continue;
668
+ }
669
+ if (values.length === 0 || !env.has(row.name)) continue;
670
+ if (!values.includes(env.get(row.name))) {
671
+ findings.push({
672
+ code: "RT06",
673
+ level: "error",
674
+ // The offending value is deliberately absent from this message.
675
+ message: `.env sets ${row.name} to a value outside ${values.join("|")} — config validation will reject it on boot (value withheld)`,
676
+ remedy: `set ${row.name} to one of ${values.join("|")}`,
677
+ file: ".env",
678
+ });
679
+ }
680
+ }
681
+ }
682
+ return findings;
683
+ }