doctrina-cli 0.14.0 → 0.15.1

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