doctrina-cli 0.15.0 → 0.16.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 (85) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +24 -1
  4. package/src/commands/analyze.js +8 -199
  5. package/src/commands/change.js +143 -217
  6. package/src/commands/ci.js +182 -0
  7. package/src/commands/clarify.js +58 -21
  8. package/src/commands/close.js +291 -42
  9. package/src/commands/constitution.js +14 -91
  10. package/src/commands/context.js +278 -107
  11. package/src/commands/contract.js +95 -75
  12. package/src/commands/coverage.js +58 -273
  13. package/src/commands/decision.js +54 -7
  14. package/src/commands/doctor.js +292 -129
  15. package/src/commands/handoff.js +10 -57
  16. package/src/commands/hooks.js +11 -2
  17. package/src/commands/index-rebuild.js +9 -48
  18. package/src/commands/init.js +70 -13
  19. package/src/commands/intake.js +30 -92
  20. package/src/commands/metrics.js +53 -84
  21. package/src/commands/next.js +105 -147
  22. package/src/commands/prime.js +24 -91
  23. package/src/commands/report.js +50 -108
  24. package/src/commands/review.js +68 -36
  25. package/src/commands/search.js +18 -4
  26. package/src/commands/show.js +24 -9
  27. package/src/commands/skill.js +16 -25
  28. package/src/commands/spec.js +26 -5
  29. package/src/commands/status.js +51 -172
  30. package/src/commands/templates.js +31 -138
  31. package/src/commands/trace.js +36 -71
  32. package/src/commands/triage.js +7 -75
  33. package/src/commands/validate.js +14 -871
  34. package/src/commands/verify.js +121 -160
  35. package/src/commands/why.js +2 -2
  36. package/src/commands/work.js +234 -263
  37. package/src/index.js +106 -7
  38. package/src/lib/actions.js +484 -0
  39. package/src/lib/adapters.js +0 -11
  40. package/src/lib/adr-guard.js +19 -0
  41. package/src/lib/agent-changelog.js +133 -0
  42. package/src/lib/analysis.js +264 -0
  43. package/src/lib/change-ops.js +131 -0
  44. package/src/lib/commands.js +109 -6
  45. package/src/lib/config.js +206 -0
  46. package/src/lib/constitution-model.js +102 -0
  47. package/src/lib/coverage-model.js +392 -0
  48. package/src/lib/criteria.js +23 -0
  49. package/src/lib/doc-model.js +326 -0
  50. package/src/lib/docs-impact.js +196 -27
  51. package/src/lib/flag-catalog.js +0 -16
  52. package/src/lib/fs-ops.js +58 -1
  53. package/src/lib/gates.js +210 -24
  54. package/src/lib/git.js +132 -0
  55. package/src/lib/index-json.js +2 -19
  56. package/src/lib/intake-model.js +86 -0
  57. package/src/lib/json-out.js +100 -2
  58. package/src/lib/ledger.js +192 -0
  59. package/src/lib/lexicon.js +191 -0
  60. package/src/lib/metrics-model.js +159 -0
  61. package/src/lib/names.js +42 -0
  62. package/src/lib/playbook.js +97 -0
  63. package/src/lib/project.js +57 -0
  64. package/src/lib/reproducibility.js +137 -0
  65. package/src/lib/runtime.js +238 -11
  66. package/src/lib/scan.js +204 -13
  67. package/src/lib/signoff.js +147 -0
  68. package/src/lib/snapshot.js +209 -0
  69. package/src/lib/spec-ops.js +50 -5
  70. package/src/lib/templates-model.js +262 -0
  71. package/src/lib/trace-model.js +95 -0
  72. package/src/lib/triage-model.js +121 -0
  73. package/src/lib/usage.js +6 -4
  74. package/src/lib/validation-model.js +1299 -0
  75. package/src/lib/version.js +22 -0
  76. package/src/lib/views.js +410 -0
  77. package/src/lib/work-model.js +77 -0
  78. package/templates/AGENTS.md.template +20 -26
  79. package/templates/README.md +35 -0
  80. package/templates/change/proposal.md.template +1 -0
  81. package/templates/contract.md.template +7 -0
  82. package/templates/hooks/pre-commit.sample +6 -2
  83. package/templates/playbooks/bootstrap.md.template +43 -0
  84. package/templates/playbooks/chore.md.template +19 -0
  85. package/templates/playbooks/work.md.template +76 -0
@@ -1,29 +1,17 @@
1
1
  // @ts-check
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
- import { readdirSync } from "node:fs";
5
- import { exists, isDir, isFile, lineCount, read, relPath, walk , write} from "../lib/fs-ops.js";
6
- import * as idx from "../lib/index-json.js";
7
- import { SCHEMA_VERSION } from "../lib/index-json.js";
8
- import { cliVersion } from "../lib/version.js";
9
- import { today } from "../lib/dates.js";
4
+ import { isDir } from "../lib/fs-ops.js";
10
5
  import { flagBool } from "../lib/args.js";
11
6
  import { c } from "../lib/colors.js";
12
- import { kindFromPath, nonConformingHeaders, repairHeaders } from "../lib/doc-model.js";
13
7
  import { emitJson } from "../lib/json-out.js";
14
8
  import { notADoctrinaProject } from "../lib/exit-codes.js";
15
- import { parseFrontmatter } from "./skill.js";
16
- import { checkEars, isEarsSpec } from "../lib/ears.js";
17
- import { specHeader, listHeader, deriveIndex, indexesMatch, stableStringify } from "../lib/scan.js";
18
- import { COMMAND_NAMES, referencedCommands } from "../lib/commands.js";
19
- import { parseAcceptanceCriteria, isVerified } from "../lib/criteria.js";
20
- import { parsePipeline, checkPipeline } from "../lib/pipeline.js";
21
- import { collectRuntimeFindings } from "../lib/runtime.js";
9
+ import { collectValidation } from "../lib/validation-model.js";
22
10
 
23
- // AGENTS.md is treated as a maintained doctrina-command catalog only once it
24
- // documents at least this many real commands; below it, the file defers to
25
- // `doctrina --help` and is never nagged about omissions.
26
- const CATALOG_THRESHOLD = 8;
11
+ // The structural gate. The checks themselves live in lib/validation-model.js
12
+ // so a driver can ASK for them instead of spawning this binary and parsing
13
+ // its own JSON back (ADR 0025); what stays here is the rendering and the
14
+ // exit code.
27
15
 
28
16
  // Flags this command accepts. Declared HERE, with the command, so
29
17
  // adding a command never requires editing the entrypoint — the gap that
@@ -40,644 +28,20 @@ export async function run(_positional, flags) {
40
28
  // gate failures about missing files: the remedy is `doctrina init`,
41
29
  // not editing artifacts that do not exist yet (C7).
42
30
  if (!isDir(path.join(projectRoot, ".doctrina"))) throw notADoctrinaProject();
43
- const errors = [];
44
- const warnings = [];
45
31
 
46
- // 1. AGENTS.md
47
- const agentsMd = path.join(projectRoot, "AGENTS.md");
48
- if (!isFile(agentsMd)) {
49
- errors.push("AGENTS.md missing at project root");
50
- } else {
51
- const lines = lineCount(agentsMd);
52
- if (lines > 200) errors.push(`AGENTS.md is ${lines} lines (>200, hard limit)`);
53
- else if (lines > 150) warnings.push(`AGENTS.md is ${lines} lines (>150 soft limit)`);
54
-
55
- // 1c. AGENTS.md command-surface drift. AGENTS.md is the hub the agent reads
56
- // first, so the doctrina commands it documents must match the real CLI.
57
- // Two drifts are flagged: a reference to a command that does not exist
58
- // (a typo or a removed command — the agent will try to run it and fail),
59
- // and a maintained catalog that has fallen behind the CLI (commands the
60
- // agent never discovers because the hub never names them). validate only
61
- // checked AGENTS.md *size* before, so the hub could rot while staying
62
- // green; this closes that gap.
63
- const agentsText = read(agentsMd);
64
- const referenced = referencedCommands(agentsText);
65
- const known = new Set(COMMAND_NAMES);
66
- // Reverse drift is always flagged — a dangling command reference misleads
67
- // the agent regardless of how the file is structured.
68
- for (const cmd of [...referenced].sort()) {
69
- if (!known.has(cmd)) {
70
- warnings.push(
71
- `AGENTS.md references \`doctrina ${cmd}\` which is not a CLI command ` +
72
- `(typo or removed command — agents reading the hub will try to run it)`,
73
- );
74
- }
75
- }
76
- // Forward drift (omitted commands) is flagged only when the file presents
77
- // itself as an exhaustive catalog: it documents many commands AND does not
78
- // defer to `doctrina --help`. A file that defers is declaring its list
79
- // illustrative (the shipped template does this), so its omissions are
80
- // intentional and stay silent.
81
- const documented = [...referenced].filter((cmd) => known.has(cmd));
82
- const defersToHelp = /doctrina(?:-cli)?\s+--help/.test(agentsText);
83
- if (documented.length >= CATALOG_THRESHOLD && !defersToHelp) {
84
- const missing = COMMAND_NAMES.filter((cmd) => !referenced.has(cmd));
85
- if (missing.length > 0) {
86
- warnings.push(
87
- `AGENTS.md documents the doctrina command surface but omits ` +
88
- `${missing.length} command${missing.length === 1 ? "" : "s"} ` +
89
- `(${missing.join(", ")}) — agents reading the hub will not discover ` +
90
- `them (add them, or defer to \`doctrina --help\` for the full list)`,
91
- );
92
- }
93
- }
94
- }
95
-
96
- // 1b. Nested AGENTS.md files ("nearest wins" hierarchy) obey the same
97
- // size caps as the root file.
98
- for (const nested of findNestedAgentsMd(projectRoot)) {
99
- const rel = relPath(projectRoot, nested);
100
- const lines = lineCount(nested);
101
- if (lines > 200) errors.push(`${rel} is ${lines} lines (>200, hard limit)`);
102
- else if (lines > 150) warnings.push(`${rel} is ${lines} lines (>150 soft limit)`);
103
- }
104
-
105
- // 2. product.md
106
- const productMd = path.join(projectRoot, ".doctrina", "product.md");
107
- if (!isFile(productMd)) errors.push(".doctrina/product.md missing");
108
-
109
- // Header repair (M3). One grammar means a non-canonical header can be
110
- // REWRITTEN, not just reported: `- **Status**: x` becomes
111
- // `- **Status:** x`, and a spec written with list-style headers is
112
- // normalised to the bare-bold form its kind uses. Content is never
113
- // touched — only the header's own punctuation and style.
114
- const doFix = flagBool(flags, "fix", false);
115
- {
116
- const artifacts = [];
117
- const dot = path.join(projectRoot, ".doctrina");
118
- for (const f of walk(dot)) {
119
- if (!f.endsWith(".md")) continue;
120
- const rel = relPath(projectRoot, f).replace(/\\/g, "/");
121
- if (rel.includes("/changes/archive/")) continue; // history is immutable
122
- artifacts.push({ file: f, rel, kind: kindFromPath(rel) });
123
- }
124
- let repairedFiles = 0;
125
- for (const a of artifacts) {
126
- const text = read(a.file);
127
- const bad = nonConformingHeaders(text, a.kind);
128
- if (bad.length === 0) continue;
129
- if (doFix) {
130
- const { text: fixed, repaired } = repairHeaders(text, a.kind);
131
- if (repaired > 0 && fixed !== text) {
132
- write(a.file, fixed, { force: true });
133
- repairedFiles += 1;
134
- }
135
- } else {
136
- for (const h of bad) {
137
- warnings.push(
138
- `${a.rel}:${h.line} header "${h.name}" is ${h.wrongStyle ? `in ${h.style} style but a ${a.kind} uses ${h.expectedStyle} style` : "not in canonical form"} — run \`doctrina validate --fix\``,
139
- );
140
- }
141
- }
142
- }
143
- if (doFix && repairedFiles > 0) {
144
- console.log(c.green("fixed") + ` normalised headers in ${repairedFiles} artifact${repairedFiles === 1 ? "" : "s"}`);
145
- }
146
- }
147
-
148
- // 3. index.json
149
- let index = null;
150
- try {
151
- index = idx.load(projectRoot);
152
- } catch (err) {
153
- errors.push(err.message);
154
- }
155
-
156
- if (index) {
157
- // --fix (F5): regenerate the index from the tree before validating, so a
158
- // drifted index is repaired rather than reported. A full rebuild — it also
159
- // absorbs orphans and migrates the framework stamp, matching
160
- // `index rebuild`. Subsequent checks then see the repaired index.
161
- if (flagBool(flags, "fix", false)) {
162
- const derived = deriveIndex(projectRoot, index);
163
- const staleStamp = (index.framework_version ?? null) !== cliVersion();
164
- if (!indexesMatch(derived, index) || staleStamp) {
165
- derived.framework_version = cliVersion();
166
- derived.last_updated = today();
167
- idx.save(projectRoot, derived);
168
- console.log(c.green("fixed") + " rebuilt .doctrina/index.json from the tree");
169
- index = derived;
170
- }
171
- }
172
-
173
- if (index.$schema_version !== SCHEMA_VERSION) {
174
- warnings.push(`index.json $schema_version is "${index.$schema_version}" (expected "${SCHEMA_VERSION}")`);
175
- }
176
- // framework_version stamp divergence (3.6). The index records which CLI
177
- // manages it; a stamp behind (or absent vs.) the running CLI means the
178
- // tree may predate the current schema. `index rebuild` migrates it.
179
- const runningVersion = cliVersion();
180
- if (!index.framework_version) {
181
- warnings.push(`index.json has no framework_version (run \`doctrina index rebuild\` to stamp it)`);
182
- } else if (index.framework_version !== runningVersion) {
183
- warnings.push(
184
- `index.json framework_version is "${index.framework_version}" but the running CLI is ` +
185
- `${runningVersion} (run \`doctrina index rebuild\` to stamp/migrate)`,
186
- );
187
- }
188
- if (!index.artifacts) {
189
- errors.push("index.json missing artifacts object");
190
- } else {
191
- // 4. Every referenced artifact path must exist
192
- const allArtifactPaths = [];
193
- if (index.artifacts.product?.path) allArtifactPaths.push(index.artifacts.product.path);
194
- for (const s of index.artifacts.specs ?? []) allArtifactPaths.push(s.path);
195
- for (const d of index.artifacts.decisions ?? []) allArtifactPaths.push(d.path);
196
- for (const ch of index.artifacts.changes ?? []) allArtifactPaths.push(ch.path);
197
- for (const ch of index.artifacts.changes_archive ?? []) allArtifactPaths.push(ch.path);
198
- for (const ct of index.artifacts.contracts ?? []) allArtifactPaths.push(ct.path);
199
- for (const rel of allArtifactPaths) {
200
- const full = path.join(projectRoot, rel);
201
- if (!exists(full)) errors.push(`index.json references missing artifact: ${rel}`);
202
- }
203
-
204
- // 4b. Index metadata drift (F5 / review G5 / G7). validate is the single
205
- // source of truth about whether the project is OK, so an indexed
206
- // artifact whose recorded metadata (version, implementation, status,
207
- // ...) no longer matches its file is an ERROR here — not a
208
- // pass-with-warning that only `index rebuild --check` would catch
209
- // (the "validate says OK while next says drift" footgun). Presence
210
- // drift (orphan on disk / missing file) stays the orphan warning and
211
- // the missing-artifact error above; this targets the metadata that
212
- // silently lies. `validate --fix` (above) regenerates instead.
213
- const derived = deriveIndex(projectRoot, index);
214
- for (const line of metadataDrift(index, derived)) {
215
- errors.push(`${line} — run \`doctrina validate --fix\` or \`doctrina index rebuild\``);
216
- }
217
- }
218
- }
219
-
220
- // 5. ADRs have parseable Status: header
221
- const adrDir = path.join(projectRoot, ".doctrina", "decisions");
222
- // Track which files claim each NNNN so a merge-time collision (two branches
223
- // both allocating the same number) becomes a loud error rather than one ADR
224
- // silently shadowing the other (C2).
225
- const adrByNumber = new Map();
226
- if (isDir(adrDir)) {
227
- for (const f of walk(adrDir)) {
228
- if (!f.endsWith(".md")) continue;
229
- const base = path.basename(f);
230
- const numMatch = base.match(/^(\d{4})-/);
231
- const num = numMatch ? numMatch[1] : null;
232
- if (num) {
233
- if (!adrByNumber.has(num)) adrByNumber.set(num, []);
234
- adrByNumber.get(num).push(relPath(projectRoot, f));
235
- }
236
- // ADR filenames must be NNNN-slug.md (four digits). The entire ADR
237
- // toolchain — decision new/accept/supersede, index derivation, and
238
- // the orphan check below — keys off this shape; a file like
239
- // "ADR-001-foo.md" is silently invisible to all of them.
240
- if (base !== "README.md" && !/^\d{4}-.+\.md$/.test(base)) {
241
- errors.push(
242
- `${relPath(projectRoot, f)} has a non-canonical ADR filename ` +
243
- `(expected four digits + slug, e.g. "0001-jwt-algorithm.md"; ` +
244
- `the decision and index commands silently ignore other shapes)`,
245
- );
246
- }
247
- const text = read(f);
248
- if (!/^-\s+\*\*Status:\*\*\s+\S+/m.test(text)) {
249
- errors.push(
250
- `${relPath(projectRoot, f)} missing or malformed Status: header ` +
251
- `(expected a Markdown list item exactly like "- **Status:** accepted")`,
252
- );
253
- }
254
-
255
- // 5b. ADR evidence drift. A decision is only true if reality reflects
256
- // it; an accepted ADR that points at nothing — or at a file that
257
- // no longer exists — is the classic "ADR decrees X, the code
258
- // never did X". Two header fields anchor a decision to reality:
259
- // **Evidence:** (cited at any point) and **Landed:** (the
260
- // non-mutating "this is now implemented and verified here" stamp,
261
- // written by `doctrina decision land`; 3.4). Both are checked for
262
- // dangling citations; an accepted ADR is only flagged bare when
263
- // BOTH are empty. ADRs that adopt neither header opt out entirely.
264
- const evidence = listHeader(text, "Evidence");
265
- const landed = listHeader(text, "Landed");
266
- const isBare = (v) => {
267
- const t = (v ?? "").trim();
268
- return t === "" || t === "—" || t === "-";
269
- };
270
- for (const [field, value] of [["Evidence", evidence], ["Landed", landed]]) {
271
- if (value === null || isBare(value)) continue;
272
- for (const token of extractBacktickPaths(value)) {
273
- const candidates = [path.resolve(path.dirname(f), token), path.resolve(projectRoot, token)];
274
- if (!candidates.some(exists)) {
275
- warnings.push(
276
- `${relPath(projectRoot, f)}: ${field} cites \`${token}\` which is missing on disk — ` +
277
- `the decision may have drifted from the code (update it or supersede the ADR)`,
278
- );
279
- }
280
- }
281
- }
282
- const adrStatus = (listHeader(text, "Status") ?? "").toLowerCase();
283
- if (adrStatus === "accepted" && evidence !== null && isBare(evidence) && isBare(landed)) {
284
- warnings.push(
285
- `${relPath(projectRoot, f)}: accepted ADR cites no implementation evidence ` +
286
- `(link the file(s) that prove it, run \`doctrina decision land ${num ?? "NNNN"}\` once ` +
287
- `it ships, or note "n/a — <why>"); a decision with nothing behind it may be drift ` +
288
- `waiting to be superseded`,
289
- );
290
- }
291
- }
292
-
293
- // 5c. Duplicate ADR numbers (C2).
294
- for (const [num, files] of adrByNumber) {
295
- if (files.length > 1) {
296
- errors.push(
297
- `ADR number ${num} is claimed by ${files.length} files (${files.join(", ")}) — ` +
298
- `two branches likely allocated it concurrently; renumber all but one ` +
299
- `(the index keys decisions by number, so duplicates silently shadow each other)`,
300
- );
301
- }
302
- }
303
- }
304
-
305
- // 6. Adapter templates under 30 lines
306
- const adaptersDir = path.join(projectRoot, ".doctrina", "templates", "adapters");
307
- if (isDir(adaptersDir)) {
308
- for (const f of walk(adaptersDir)) {
309
- if (!f.endsWith(".template")) continue;
310
- const lines = lineCount(f);
311
- if (lines > 30) errors.push(`${relPath(projectRoot, f)} is ${lines} lines (>30, adapter cap)`);
312
- }
313
- }
314
-
315
- // 7. Every open change has a proposal, and its delta files carry a
316
- // well-formed **Operation:** header. The delta used to be the one
317
- // 100% hand-authored file with no early check: a missing header passed
318
- // silently at creation and exploded days later in the closing analyze
319
- // (operator review 2026-07-19 §3.2). Warning-level — the change is
320
- // still a draft — but now every `validate` run names it.
321
- const changesDir = path.join(projectRoot, ".doctrina", "changes");
322
- if (isDir(changesDir)) {
323
- const { readdirSync } = await import("node:fs");
324
- for (const entry of readdirSync(changesDir)) {
325
- if (entry === "archive" || entry.startsWith(".")) continue;
326
- const proposal = path.join(changesDir, entry, "proposal.md");
327
- if (!isFile(proposal)) {
328
- errors.push(
329
- `open change "${entry}" missing proposal.md ` +
330
- `(every dir under .doctrina/changes/ needs a proposal.md — that exact filename)`,
331
- );
332
- }
333
- // A change whose tasks are still the scaffold placeholders was opened
334
- // but never planned — the agent is (or will be) implementing with no
335
- // recorded plan. Early, every-run signal; analyze/close hard-fail it.
336
- const tasksPath = path.join(changesDir, entry, "tasks.md");
337
- if (isFile(tasksPath)) {
338
- const ph = (read(tasksPath).match(/^\s*-\s*\[[ xX]\]\s*$/gm) ?? []).length;
339
- if (ph > 0) {
340
- warnings.push(
341
- `open change "${entry}" tasks.md still carries ${ph} scaffold placeholder task${ph === 1 ? "" : "s"} — ` +
342
- `the change was opened but never planned (replace them with real tasks; analyze/close refuse them)`,
343
- );
344
- }
345
- }
346
- for (const deltaPath of walk(path.join(changesDir, entry, "specs"))) {
347
- if (!deltaPath.endsWith("delta.md")) continue;
348
- const text = read(deltaPath);
349
- const m = text.match(/^\*\*Operation:\*\*\s*(\S+)/m);
350
- if (!m) {
351
- warnings.push(
352
- `${relPath(projectRoot, deltaPath)} has no **Operation:** header — ` +
353
- `analyze/apply will refuse it at close time (add "**Operation:** ADDED|MODIFIED|REMOVED")`,
354
- );
355
- } else if (!["ADDED", "MODIFIED", "REMOVED"].includes(m[1])) {
356
- warnings.push(
357
- `${relPath(projectRoot, deltaPath)} Operation "${m[1]}" is not ADDED|MODIFIED|REMOVED — ` +
358
- `analyze/apply will refuse it at close time`,
359
- );
360
- }
361
- }
362
- }
363
- }
364
-
365
- // 8. Specs over 400 lines warn (lost-in-the-middle).
366
- const specsDir = path.join(projectRoot, ".doctrina", "specs");
367
- if (isDir(specsDir)) {
368
- const { readdirSync } = await import("node:fs");
369
- const KNOWN_SPEC_HEADERS = ["Capability", "Status", "Implementation", "Version", "Last updated", "Realizes"];
370
- for (const cap of readdirSync(specsDir)) {
371
- const specPath = path.join(specsDir, cap, "spec.md");
372
- if (isFile(specPath)) {
373
- const lines = lineCount(specPath);
374
- if (lines > 400) warnings.push(`${relPath(projectRoot, specPath)} is ${lines} lines (>400 soft cap)`);
375
-
376
- // 8b. EARS grammar shape — section-appropriate When/While/Where/shall.
377
- const text = read(specPath);
378
- if (isEarsSpec(text)) {
379
- for (const f of checkEars(text)) {
380
- warnings.push(`${relPath(projectRoot, specPath)}:${f.line} ${f.message}`);
381
- }
382
- }
383
-
384
- // 8b-ii. ORDERED requirements (change 0029). EARS states each
385
- // event-driven requirement independently and says nothing about
386
- // order, so a consumer step and its producer both pass while the
387
- // consumer reads last run's file. A `### Pipeline` block declares
388
- // the order and the artifact each step hands on; the invariant is
389
- // that a step may only require what an EARLIER step produced.
390
- // Opt-in: a spec without the block is never checked.
391
- for (const f of checkPipeline(parsePipeline(text))) {
392
- errors.push(`${relPath(projectRoot, specPath)}:${f.line} ${f.message} [${f.code}] — ${f.remedy}`);
393
- }
394
-
395
- // 8c. Capability-state honesty (two-axis status). An "active"
396
- // document that records no built capability is an inventory
397
- // claim — the gap the framework is meant to make visible.
398
- // Warn when Status is active and Implementation is "planned"
399
- // with no explanatory note after the state word. A note
400
- // ("planned — deferred, see ADR 0007") is the deliberate-gap
401
- // escape hatch. Specs with no Implementation header are off
402
- // the axis and never warn.
403
- const implRaw = specHeader(text, "Implementation");
404
- if (implRaw) {
405
- const docStatus = (specHeader(text, "Status") ?? "active").toLowerCase();
406
- const implTokens = implRaw.trim().split(/\s+/);
407
- const implWord = (implTokens[0] ?? "").replace(/[—-]+$/, "").toLowerCase();
408
- const hasNote = implTokens.length > 1;
409
- if (docStatus === "active" && implWord === "planned" && !hasNote) {
410
- warnings.push(
411
- `${relPath(projectRoot, specPath)}: Status is "active" but Implementation is ` +
412
- `"planned" with no note — an active spec with no built capability is an ` +
413
- `inventory claim (advance Implementation, set Status: draft, or add a note ` +
414
- `after the value: "planned — <why deferred>")`,
415
- );
416
- }
417
- }
418
-
419
- // 8e. Provenance adoption (review 2026-06-27, §5/§9). `trace` is the
420
- // intent→capability link, but it stays inert when nothing forces
421
- // the tag, so an active capability with no Realizes: header is an
422
- // untraced promise — warn here (validate is always run) rather than
423
- // only in the opt-in `trace`. Advisory, with the same escape hatch
424
- // pattern as Implementation: any value silences it, including a
425
- // deliberate "n/a — <why>" for an internal capability that maps to
426
- // no product anchor. Bug specs (off the implementation axis) and
427
- // draft/deprecated documents are exempt — only active capability
428
- // specs make the promise.
429
- const docStatusForRealizes = (specHeader(text, "Status") ?? "active").toLowerCase();
430
- const onImplAxis = specHeader(text, "Implementation") !== null;
431
- if (docStatusForRealizes === "active" && onImplAxis && specHeader(text, "Realizes") === null) {
432
- warnings.push(
433
- `${relPath(projectRoot, specPath)}: active spec has no Realizes: header — ` +
434
- `it traces to no product intent (\`doctrina trace\`). Tag a product.md ` +
435
- `success-criteria bullet "- [SC1] ..." and add "**Realizes:** SC1", or ` +
436
- `record "**Realizes:** n/a — <why>" if it maps to no product anchor`,
437
- );
438
- }
439
-
440
- // 8d. Metadata-header shape (review G11). In the header block (before
441
- // the first `## ` section or `<!--` comment), a known key written
442
- // without the canonical `**Key:** value` form silently fails to
443
- // parse — the index then falls back to a default and drifts. Warn
444
- // with the fix rather than letting the mis-typed header vanish.
445
- const headerLines = text.split(/\r?\n/);
446
- for (let i = 0; i < headerLines.length; i++) {
447
- if (/^\s*(##\s|<!--)/.test(headerLines[i])) break;
448
- for (const key of KNOWN_SPEC_HEADERS) {
449
- const attempt = new RegExp(`^\\s*-?\\s*\\**${key}\\**\\s*:`, "i");
450
- const canonical = new RegExp(`^(?:-\\s+)?\\*\\*${key}:\\*\\*\\s+\\S`);
451
- if (attempt.test(headerLines[i]) && !canonical.test(headerLines[i])) {
452
- warnings.push(
453
- `${relPath(projectRoot, specPath)}:${i + 1} metadata header "${key}" is not in ` +
454
- `canonical "**${key}:** value" form — it will not parse (G11)`,
455
- );
456
- }
457
- }
458
- }
459
-
460
- // 8f. Self-certified acceptance criteria (honest gates, ADR 0008). A
461
- // criterion that asserts [verified] but cites no proof path is a
462
- // claim with nothing behind it — the dishonest-green case. `coverage`
463
- // reports every bare criterion; this names the specific combo
464
- // (claims verified, proves nothing) in the always-run gate. It reads
465
- // through the shared multi-line parser, so a proof cited on a
466
- // continuation line still counts (no false positive).
467
- for (const crit of parseAcceptanceCriteria(text)) {
468
- if (isVerified(crit) && crit.proofPaths.length === 0) {
469
- warnings.push(
470
- `${relPath(projectRoot, specPath)}: acceptance criterion #${crit.n} is marked ` +
471
- `[verified] but cites no proof path — self-certified (cite the file/test in ` +
472
- `backticks, or drop the marker; \`doctrina coverage\`)`,
473
- );
474
- }
475
- }
476
- }
477
- }
478
- }
479
-
480
- // 9. ADRs over 300 lines warn.
481
- if (isDir(adrDir)) {
482
- for (const f of walk(adrDir)) {
483
- if (!f.endsWith(".md")) continue;
484
- const lines = lineCount(f);
485
- if (lines > 300) warnings.push(`${relPath(projectRoot, f)} is ${lines} lines (>300 soft cap)`);
486
- }
487
- }
488
-
489
- // 10b. Stale-reference detection — Markdown link targets inside specs
490
- // and ADRs that no longer exist on disk. Backtick paths are NOT
491
- // checked (descriptive prose, too noisy). Links are pointers.
492
- for (const dir of [specsDir, adrDir]) {
493
- if (!isDir(dir)) continue;
494
- for (const f of walk(dir)) {
495
- if (!f.endsWith(".md")) continue;
496
- const text = read(f);
497
- const fileDir = path.dirname(f);
498
- for (const ref of extractPathReferences(text)) {
499
- // Try relative to the file first, then to project root
500
- const candidates = [
501
- path.resolve(fileDir, ref),
502
- path.resolve(projectRoot, ref),
503
- ];
504
- if (!candidates.some(exists)) {
505
- warnings.push(`${relPath(projectRoot, f)} references missing path \`${ref}\``);
506
- }
507
- }
508
- }
509
- }
510
-
511
- // 10. Orphan detection — files on disk not referenced in index.json.
512
- if (index?.artifacts) {
513
- const indexedSpecIds = new Set((index.artifacts.specs ?? []).map((s) => s.id ?? s.capability));
514
- if (isDir(specsDir)) {
515
- const { readdirSync } = await import("node:fs");
516
- for (const cap of readdirSync(specsDir)) {
517
- const specPath = path.join(specsDir, cap, "spec.md");
518
- if (isFile(specPath) && !indexedSpecIds.has(cap)) {
519
- warnings.push(`orphan spec ${relPath(projectRoot, specPath)} (capability "${cap}" not in index.json)`);
520
- }
521
- }
522
- }
523
- const indexedAdrIds = new Set((index.artifacts.decisions ?? []).map((d) => d.id));
524
- if (isDir(adrDir)) {
525
- for (const f of walk(adrDir)) {
526
- if (!f.endsWith(".md")) continue;
527
- const m = path.basename(f).match(/^(\d{4})-/);
528
- if (m && !indexedAdrIds.has(m[1])) {
529
- warnings.push(`orphan ADR ${relPath(projectRoot, f)} (id "${m[1]}" not in index.json)`);
530
- }
531
- }
532
- }
533
- const indexedContractIds = new Set((index.artifacts.contracts ?? []).map((ct) => ct.id));
534
- const contractsDir = path.join(projectRoot, ".doctrina", "contracts");
535
- if (isDir(contractsDir)) {
536
- for (const f of walk(contractsDir)) {
537
- if (!f.endsWith(".md")) continue;
538
- const id = path.basename(f, ".md");
539
- if (!indexedContractIds.has(id)) {
540
- warnings.push(`orphan contract ${relPath(projectRoot, f)} (id "${id}" not in index.json)`);
541
- }
542
- }
543
- }
544
- }
545
-
546
- // 11. Archive ledger ↔ index cross-check. The history of archived
547
- // changes is recorded twice — once human-facing in
548
- // changes/archive/LEDGER.md, once machine-facing in
549
- // index.json.changes_archive. Two sources of truth that silently
550
- // disagree are worse than one: an incomplete ledger makes the
551
- // history look shorter than it is. When the ledger exists, every
552
- // archived change must appear in both, or validation fails.
553
- const ledgerPath = path.join(changesDir, "archive", "LEDGER.md");
554
- if (index?.artifacts && isFile(ledgerPath)) {
555
- const ledgerIds = new Set();
556
- for (const line of read(ledgerPath).split(/\r?\n/)) {
557
- const m = line.match(/^-\s+\d{4}-\d{2}-\d{2}\s+[—-]\s+(\S+)\s+[—-]\s+(.*)$/);
558
- if (!m) continue;
559
- // An abandoned change is ledger-only BY DESIGN: `change abandon`
560
- // records the discard in history but deletes the folder, so there is
561
- // deliberately no archive entry to cross-check. Requiring one made
562
- // every abandonment turn validate permanently red (found dogfooding
563
- // change 0001-review-followups).
564
- if (/^abandoned\b/i.test(m[2].trim())) continue;
565
- ledgerIds.add(m[1]);
566
- }
567
- const archiveIds = new Set((index.artifacts.changes_archive ?? []).map((ch) => ch.id));
568
- for (const id of archiveIds) {
569
- if (!ledgerIds.has(id)) {
570
- errors.push(`archived change "${id}" is in index.json but missing from changes/archive/LEDGER.md (ledger and index disagree)`);
571
- }
572
- }
573
- for (const id of ledgerIds) {
574
- if (!archiveIds.has(id)) {
575
- errors.push(`changes/archive/LEDGER.md lists "${id}" but index.json.changes_archive does not (ledger and index disagree)`);
576
- }
577
- }
578
- }
579
-
580
- // 12-14. Skills validation
581
- const skillsDir = path.join(projectRoot, ".doctrina", "skills");
582
- if (isDir(skillsDir)) {
583
- for (const f of walk(skillsDir)) {
584
- if (!f.endsWith(".md")) continue;
585
- const text = read(f);
586
- const rel = relPath(projectRoot, f);
587
- const baseName = path.basename(f, ".md");
588
- const nameField = parseFrontmatter(text, "name");
589
- const descField = parseFrontmatter(text, "description");
590
- const whenField = parseFrontmatter(text, "when");
591
- if (!nameField) warnings.push(`${rel} missing required frontmatter field "name"`);
592
- if (!descField) warnings.push(`${rel} missing required frontmatter field "description"`);
593
- if (!whenField) warnings.push(`${rel} missing required frontmatter field "when"`);
594
- if (nameField && nameField !== baseName) {
595
- warnings.push(`${rel} name "${nameField}" does not match filename slug "${baseName}"`);
596
- }
597
-
598
- // 14b. A skill nothing can TRIGGER is a file, not a memory (change
599
- // 0029). `context` lists a skill's `when:` so an agent can fire
600
- // the right one without loading any of them — but a trigger
601
- // written as pure prose ("when it makes sense", "as needed")
602
- // gives it nothing to match on, and the skill is never loaded by
603
- // anyone who did not already know it existed. A usable trigger
604
- // names something concrete: a keyword, a path, a command, an
605
- // error string.
606
- if (whenField && !hasDetectableTrigger(whenField)) {
607
- warnings.push(
608
- `${rel} frontmatter "when" has no detectable trigger — name a concrete ` +
609
- `keyword, path, command or error string, or nothing can match it ` +
610
- `(got: "${whenField.length > 60 ? `${whenField.slice(0, 59)}…` : whenField}")`,
611
- );
612
- }
613
- const lines = lineCount(f);
614
- if (lines > 200) warnings.push(`${rel} is ${lines} lines (>200 soft cap)`);
615
- else if (lines > 150) warnings.push(`${rel} is ${lines} lines (>150 soft cap)`);
616
-
617
- // 15. Skill description drift — index.json must mirror the
618
- // frontmatter description (run `doctrina skill sync`).
619
- if (descField && index?.artifacts?.skills) {
620
- const entry = index.artifacts.skills.find((s) => s.id === baseName);
621
- if (entry && entry.description !== descField) {
622
- warnings.push(`${rel} description differs from index.json (run \`doctrina skill sync\`)`);
623
- }
624
- }
625
- }
626
- }
627
-
628
- // 16. Bilingual docs parity. Projects that keep an EN-source docs tree
629
- // with a PT mirror (docs/en + docs/pt) get filename-parity checking:
630
- // a file added in one language and missing in the other is invisible
631
- // to every other gate, and the mirror silently falls behind. Only the
632
- // deterministic half (file sets) is checked — content staleness stays
633
- // a human judgement. Projects without both trees never see this.
634
- const docsEn = path.join(projectRoot, "docs", "en");
635
- const docsPt = path.join(projectRoot, "docs", "pt");
636
- if (isDir(docsEn) && isDir(docsPt)) {
637
- const mdSet = (dir) => new Set(readdirSync(dir).filter((f) => f.endsWith(".md")));
638
- const enSet = mdSet(docsEn);
639
- const ptSet = mdSet(docsPt);
640
- for (const f of [...enSet].sort()) {
641
- if (!ptSet.has(f)) {
642
- warnings.push(`docs/en/${f} has no docs/pt/${f} counterpart (EN is the source; the PT mirror fell behind)`);
643
- }
644
- }
645
- for (const f of [...ptSet].sort()) {
646
- if (!enSet.has(f)) {
647
- warnings.push(`docs/pt/${f} has no docs/en/${f} counterpart (PT translates an EN source, never leads it)`);
648
- }
649
- }
650
- }
651
-
652
- // Project rules — permanent, lintable constraints (.doctrina/rules.json;
653
- // 0.11.0 field review item 12: "never reference company X" lived only in
654
- // agent memory, and the remaining mentions sailed through every gate). Each
655
- // rule is a forbid-regex over glob-scoped paths; a match is an ERROR with
656
- // the rule's own message. Deterministic, zero-deps, and the instruction
657
- // survives sessions because it is an artifact, not a memory.
658
- for (const line of checkProjectRules(projectRoot)) errors.push(line);
659
-
660
- // 18. --runtime folds the RUNTIME gate into the structural one, so a
661
- // single call covers both halves of the truth: the shape of the
662
- // artifacts, and whether what they declare about the running system
663
- // still holds. Opt-in because it reads files outside .doctrina/
664
- // (workflows, consumers, test sources) and a structural validate
665
- // should stay cheap. Delegates to lib/runtime.js — the same checks
666
- // `contract check`, `triage` and `doctor` render, so the four can
667
- // never disagree.
668
- if (flagBool(flags, "runtime", false)) {
669
- for (const f of collectRuntimeFindings(projectRoot).findings) {
670
- const line = `${f.contract}: ${f.message} [${f.code}] — ${f.remedy}`;
671
- if (f.level === "error") errors.push(line);
672
- else warnings.push(line);
673
- }
674
- }
32
+ const { errors, warnings, fixes } = collectValidation(projectRoot, {
33
+ fix: flagBool(flags, "fix", false),
34
+ runtime: flagBool(flags, "runtime", false),
35
+ });
675
36
 
676
37
  // Output
677
38
  if (flagBool(flags, "json", false)) {
678
- emitJson("validate", { ok: errors.length === 0, errors, warnings });
39
+ emitJson("validate", { errors, warnings });
679
40
  return errors.length === 0 ? 0 : 1;
680
41
  }
42
+ // The repairs print first and in the order they were made: --fix changes
43
+ // the tree, and what it changed must be visible before what it then found.
44
+ for (const f of fixes) console.log(c.green("fixed") + " " + f);
681
45
  for (const w of warnings) console.log(c.yellow("warn: ") + w);
682
46
  for (const e of errors) console.log(c.red("error: ") + e);
683
47
 
@@ -691,227 +55,6 @@ export async function run(_positional, flags) {
691
55
  return errors.length === 0 ? 0 : 1;
692
56
  }
693
57
 
694
- // Does a skill's `when:` give anything to MATCH on? A trigger written as
695
- // pure prose ("when it seems relevant", "as needed") reads fine and can
696
- // never fire: `context` ranks skills by comparing the trigger against the
697
- // task, and there is nothing there to compare. A usable trigger names
698
- // something concrete — a path or glob, a command, a quoted error string, a
699
- // file extension, an ALL_CAPS identifier, or simply enough distinctive
700
- // keywords to match on.
701
- const VAGUE_TRIGGER = /^(?:when(?:ever)?\s+)?(?:it|this|you|the agent)?\s*(?:is\s+)?(?:seems?|feels?|looks?)?\s*(?:relevant|appropriate|needed|necessary|useful|applicable|as needed|if needed)\.?$/i;
702
-
703
- export function hasDetectableTrigger(when) {
704
- const text = String(when ?? "").trim();
705
- if (text === "" || VAGUE_TRIGGER.test(text)) return false;
706
- // Anything structural is inherently matchable.
707
- if (/[\/\]|\*|`|"|'|\.\w{2,4}|[A-Z][A-Z0-9_]{2,}/.test(text)) return true;
708
- // Otherwise: enough distinctive words to rank on. Stopwords do not count.
709
- const STOP = new Set(["when", "whenever", "the", "a", "an", "is", "are", "you", "your",
710
- "it", "its", "this", "that", "and", "or", "to", "of", "in", "on", "for", "with",
711
- "any", "some", "need", "needs", "needed", "should", "must", "at", "as", "by", "be"]);
712
- const words = text.toLowerCase().match(/[a-z][a-z0-9-]{2,}/g) ?? [];
713
- return words.filter((w) => !STOP.has(w)).length >= 2;
714
- }
715
-
716
- // Enforce .doctrina/rules.json — permanent project constraints as
717
- // forbid-regexes over glob-scoped paths:
718
- // { "rules": [ { "id": "white-label", "forbid": "\\bAcmeCorp\\b",
719
- // "paths": ["src/**", ".doctrina/specs/**"],
720
- // "message": "white-label product; use a generic placeholder" } ] }
721
- // Returns error strings (one per offending file+rule, capped per rule so a
722
- // mass violation stays readable). Missing/invalid file → no rules (an
723
- // invalid JSON is reported once). Binary-ish and vendored dirs are skipped
724
- // by the same bounded walk validate already uses elsewhere.
725
- const RULES_SKIP_DIRS = new Set([
726
- ".git", "node_modules", "vendor", "dist", "build", "out", "target",
727
- ".venv", "venv", "__pycache__", ".next", "coverage",
728
- ]);
729
- const RULES_MAX_HITS_PER_RULE = 10;
730
-
731
- function checkProjectRules(projectRoot) {
732
- const rulesPath = path.join(projectRoot, ".doctrina", "rules.json");
733
- if (!isFile(rulesPath)) return [];
734
- let cfg;
735
- try {
736
- cfg = JSON.parse(read(rulesPath));
737
- } catch (err) {
738
- return [`.doctrina/rules.json is not valid JSON: ${err.message}`];
739
- }
740
- const rules = Array.isArray(cfg?.rules) ? cfg.rules : [];
741
- const out = [];
742
- const compiled = [];
743
- for (const r of rules) {
744
- if (!r || typeof r.forbid !== "string" || !r.forbid) {
745
- out.push(`.doctrina/rules.json: rule "${r?.id ?? "?"}" needs a non-empty "forbid" regex`);
746
- continue;
747
- }
748
- let re;
749
- try {
750
- re = new RegExp(r.forbid, "m");
751
- } catch (err) {
752
- out.push(`.doctrina/rules.json: rule "${r.id ?? r.forbid}" has an invalid regex: ${err.message}`);
753
- continue;
754
- }
755
- const paths = Array.isArray(r.paths) && r.paths.length ? r.paths : ["**"];
756
- compiled.push({ id: r.id ?? r.forbid, re, matchers: paths.map(globToRegExp), message: r.message ?? "", hits: 0 });
757
- }
758
- if (compiled.length === 0) return out;
759
-
760
- // One bounded walk; each text file is read at most once.
761
- const stack = [projectRoot];
762
- while (stack.length) {
763
- const dir = stack.pop();
764
- let entries;
765
- try {
766
- entries = readdirSync(dir, { withFileTypes: true });
767
- } catch {
768
- continue;
769
- }
770
- for (const entry of entries) {
771
- const full = path.join(dir, entry.name);
772
- if (entry.isDirectory()) {
773
- if (!RULES_SKIP_DIRS.has(entry.name)) stack.push(full);
774
- continue;
775
- }
776
- const rel = relPath(projectRoot, full).replace(/\\/g, "/");
777
- if (rel === ".doctrina/rules.json") continue; // the rule text itself always matches
778
- const applicable = compiled.filter((cr) => cr.hits < RULES_MAX_HITS_PER_RULE && cr.matchers.some((m) => m.test(rel)));
779
- if (applicable.length === 0) continue;
780
- let text;
781
- try {
782
- text = read(full);
783
- } catch {
784
- continue;
785
- }
786
- for (const cr of applicable) {
787
- const m = cr.re.exec(text);
788
- if (m) {
789
- cr.hits += 1;
790
- const lineNo = text.slice(0, m.index).split("\n").length;
791
- out.push(`rule "${cr.id}": ${rel}:${lineNo} matches forbidden pattern${cr.message ? ` — ${cr.message}` : ""}`);
792
- }
793
- }
794
- }
795
- }
796
- for (const cr of compiled) {
797
- if (cr.hits >= RULES_MAX_HITS_PER_RULE) {
798
- out.push(`rule "${cr.id}": more matches suppressed after ${RULES_MAX_HITS_PER_RULE} files`);
799
- }
800
- }
801
- return out;
802
- }
803
-
804
- // Minimal glob → RegExp: ** crosses directories, * stays within a segment.
805
- // Anchored to the whole repo-relative POSIX path.
806
- function globToRegExp(glob) {
807
- const esc = String(glob).replace(/[.+^${}()|[\]\\]/g, "\\$&");
808
- const body = esc
809
- .replace(/\*\*\//g, "(?:.*/)?")
810
- .replace(/\*\*/g, ".*")
811
- .replace(/\*/g, "[^/]*")
812
- .replace(/\?/g, "[^/]");
813
- return new RegExp(`^${body}$`);
814
- }
815
-
816
- // Per-entry metadata drift between the on-disk index and the tree-derived
817
- // one, for entries present in BOTH. Presence drift (an artifact on disk but
818
- // not indexed, or indexed but missing) is left to the orphan warning and the
819
- // missing-artifact error; this isolates the case the review hit — an indexed
820
- // artifact whose recorded metadata no longer matches its file.
821
- function metadataDrift(current, derived) {
822
- const lines = [];
823
- // Skills are deliberately excluded: their description has a dedicated
824
- // reconciliation command (`doctrina skill sync`) and stays an advisory
825
- // warning (see section 15), not a hard error.
826
- const cats = ["specs", "decisions", "changes", "changes_archive", "contracts"];
827
- for (const cat of cats) {
828
- const cur = new Map((current.artifacts?.[cat] ?? []).map((e) => [e.id, e]));
829
- const der = new Map((derived.artifacts?.[cat] ?? []).map((e) => [e.id, e]));
830
- for (const [id, entry] of cur) {
831
- if (der.has(id) && stableStringify(entry) !== stableStringify(der.get(id))) {
832
- lines.push(`index.json: ${cat} "${id}" metadata differs from its file`);
833
- }
834
- }
835
- }
836
- return lines;
837
- }
838
-
839
- // Find AGENTS.md files below the root (the "nearest AGENTS.md wins"
840
- // hierarchy). Bounded walk: dependency, build, and VCS directories are
841
- // skipped so validate stays fast on large repositories.
842
- const NESTED_SKIP_DIRS = new Set([
843
- ".git", "node_modules", ".doctrina", "vendor", "dist", "build", "out",
844
- "target", ".venv", "venv", "__pycache__", ".next", "coverage",
845
- ]);
846
-
847
- function findNestedAgentsMd(projectRoot) {
848
- const found = [];
849
- const stack = readdirSync(projectRoot)
850
- .filter((e) => !NESTED_SKIP_DIRS.has(e) && !e.startsWith("."))
851
- .map((e) => path.join(projectRoot, e))
852
- .filter(isDir);
853
- while (stack.length) {
854
- const dir = stack.pop();
855
- for (const entry of readdirSync(dir)) {
856
- const full = path.join(dir, entry);
857
- if (isDir(full)) {
858
- if (!NESTED_SKIP_DIRS.has(entry) && !entry.startsWith(".")) stack.push(full);
859
- } else if (entry === "AGENTS.md") {
860
- found.push(full);
861
- }
862
- }
863
- }
864
- return found.sort();
865
- }
866
-
867
- // Extract candidate file path references from Markdown text. Only
868
- // Markdown link targets `[text](path)` are checked — backtick spans
869
- // are descriptive prose and produce too many false positives.
870
- function extractPathReferences(text) {
871
- const out = new Set();
872
- for (const m of text.matchAll(/\]\(([^)\s]+)\)/g)) {
873
- const token = m[1].trim();
874
- if (isLikelyPath(token)) out.add(stripFragment(token));
875
- }
876
- return [...out];
877
- }
878
-
879
- // Path-like tokens inside backtick spans — the convention for citing
880
- // evidence (ADR **Evidence:**, acceptance-criteria links).
881
- function extractBacktickPaths(text) {
882
- const out = new Set();
883
- for (const m of text.matchAll(/`([^`]+)`/g)) {
884
- const token = m[1].trim();
885
- if (isLikelyPath(token)) out.add(stripFragment(token));
886
- }
887
- return [...out];
888
- }
889
-
890
- function stripFragment(s) {
891
- // Remove an in-page anchor (#section) or query string from a path token.
892
- return s.split(/[#?]/)[0];
893
- }
894
-
895
- function isLikelyPath(s) {
896
- if (!s) return false;
897
- if (/^(https?:|mailto:|ftp:|#|@)/i.test(s)) return false;
898
- if (s.includes("{{") || s.includes("}}")) return false;
899
- if (s.includes("*") || s.includes("?")) return false;
900
- if (s.includes("<") || s.includes(">")) return false;
901
- if (s.includes(" ") || s.includes("`")) return false;
902
- // Strip in-page anchor or query string before further checks
903
- const head = stripFragment(s);
904
- if (!head) return false;
905
- // Folder references (path ending with /) are skipped: not file targets
906
- if (head.endsWith("/")) return false;
907
- // Must look like a file path: contains a slash or has a file extension
908
- const hasSlash = head.includes("/");
909
- const hasExt = /\.[a-z0-9]{1,8}$/i.test(head);
910
- if (!hasSlash && !hasExt) return false;
911
- if (head.startsWith("-")) return false;
912
- return true;
913
- }
914
-
915
58
  export const help = `
916
59
  Usage: doctrina validate [--fix] [--runtime] [--json]
917
60