doctrina-cli 0.15.1 → 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 (84) 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 -212
  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 +4 -82
  33. package/src/commands/validate.js +14 -871
  34. package/src/commands/verify.js +60 -138
  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 +56 -4
  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 +181 -23
  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 +191 -5
  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/hooks/pre-commit.sample +6 -2
  82. package/templates/playbooks/bootstrap.md.template +43 -0
  83. package/templates/playbooks/chore.md.template +19 -0
  84. package/templates/playbooks/work.md.template +76 -0
@@ -18,6 +18,9 @@
18
18
  // a non-canonical form is REPORTED (so `validate --fix` can repair it)
19
19
  // rather than silently accepted or silently missed.
20
20
  // STRICT ON WRITE. One canonical form per artifact kind, always.
21
+ import path from "node:path";
22
+ import { read } from "./fs-ops.js";
23
+ import { locateTemplatesDir } from "./templates.js";
21
24
 
22
25
  // Artifact kinds and the header form each one canonically uses. Specs and
23
26
  // contracts use bare bold; ADRs, proposals, and the intake use list items.
@@ -285,3 +288,326 @@ export function parseArtifact(text, { kind = ARTIFACT_KIND.UNKNOWN } = {}) {
285
288
  nonConforming: nonConformingHeaders(text, kind),
286
289
  };
287
290
  }
291
+
292
+ // ---------------------------------------------------------------------------
293
+ // The rest of the on-disk grammar (ADR 0021, change 0043)
294
+ // ---------------------------------------------------------------------------
295
+ //
296
+ // ADR 0021 declares ONE document model that owns how a Doctrina artifact is
297
+ // read off disk. Three parsers lived outside it — skill frontmatter in a
298
+ // command module, and the two delta parsers in another — and `lib/scan.js`
299
+ // imported them FROM `commands/`, inverting the dependency the layering
300
+ // depends on. Change 0037 broke that edge by moving them into lib/; this is
301
+ // the second half: they belong to the module the ADR names, not to two more
302
+ // libraries beside it.
303
+ //
304
+ // Pure text predicates, all of them. Nothing here reads a file.
305
+
306
+ export function parseFrontmatter(text, key) {
307
+ // Match frontmatter blocks bounded by `---` lines at start of file.
308
+ const fmMatch = text.match(/^---\s*\n([\s\S]*?)\n---\s*\n/);
309
+ if (!fmMatch) return null;
310
+ const block = fmMatch[1];
311
+ const lineRe = new RegExp(`^${key}\\s*:\\s*(.+)$`, "m");
312
+ const m = block.match(lineRe);
313
+ return m ? m[1].trim() : null;
314
+ }
315
+
316
+ export function parseOperation(text) {
317
+ const m = text.match(/^\*\*Operation:\*\*\s*([A-Z]+)/m);
318
+ if (!m) return null;
319
+ const op = m[1];
320
+ if (op === "ADDED" || op === "MODIFIED" || op === "REMOVED") return op;
321
+ return null;
322
+ }
323
+
324
+ export function parseCapabilityFromDelta(text, deltaPath) {
325
+ // Prefer the explicit header "# Spec Delta — capability: <name>"
326
+ const m = text.match(/^#\s+Spec Delta\s*[—-]\s*capability:\s*([a-z][a-z0-9-]*)/m);
327
+ if (m) return m[1];
328
+ // Fall back to the parent directory name of the delta file
329
+ const parent = path.basename(path.dirname(deltaPath));
330
+ if (/^[a-z][a-z0-9-]*$/.test(parent)) return parent;
331
+ return null;
332
+ }
333
+
334
+ /**
335
+ * The value a shipped template writes for `name`, or null when the template
336
+ * cannot be located (an unusual install) or does not carry that header.
337
+ *
338
+ * @param {string} templateName e.g. "spec.md.template"
339
+ * @param {string} name
340
+ * @returns {string|null}
341
+ */
342
+ export function templateHeaderValue(templateName, name) {
343
+ try {
344
+ return getHeader(read(path.join(locateTemplatesDir(), templateName)), name);
345
+ } catch {
346
+ return null;
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Is this header value still the scaffold's placeholder?
352
+ *
353
+ * A check written to catch a MISSING header is dead in the normal flow when
354
+ * the scaffold writes a value for it: the escape hatch arrives pre-armed
355
+ * (change 0057). The templates mark a placeholder by wrapping the whole
356
+ * value in angle brackets, so that is the portable rule; when the templates
357
+ * can be located, the template's own value settles it exactly.
358
+ *
359
+ * @param {string|null|undefined} value
360
+ * @param {{ template?: string, name?: string }} [opts]
361
+ * @returns {boolean}
362
+ */
363
+ export function isPlaceholderHeaderValue(value, opts = {}) {
364
+ if (value === null || value === undefined) return true;
365
+ const v = String(value).trim();
366
+ if (v === "") return true;
367
+ // `<...>` wrapping the WHOLE value — the templates' placeholder convention.
368
+ // A real value may still contain angle brackets ("n/a — <why>" does not,
369
+ // but a prose value could), which is why the whole value must be wrapped.
370
+ if (/^<[\s\S]*>$/.test(v)) return true;
371
+ if (opts.template && opts.name) {
372
+ const tpl = templateHeaderValue(opts.template, opts.name);
373
+ if (tpl !== null && tpl.trim() === v) return true;
374
+ }
375
+ return false;
376
+ }
377
+
378
+ /**
379
+ * Every GitHub-style checkbox in `text`, in reading order.
380
+ *
381
+ * One owner for the box grammar (ADR 0021). It had six, and they disagreed:
382
+ * `snapshot.js` required TEXT after the box (`\[[ xX]\]\s+(.*)`), so the three
383
+ * empty placeholders `work` scaffolds were invisible to it and `prime` printed
384
+ * "tasks 0/3" for a change with six open boxes — the three it counted being
385
+ * the closing steps, not the work. `gates.js` required no text and counted
386
+ * six; `change tick` counted eight, adding the proposal's Verification boxes.
387
+ * Three numbers for the same file, side by side in the same session.
388
+ *
389
+ * Each entry carries whether the box is a scaffold PLACEHOLDER — a box with
390
+ * nothing written after it — because that is a real distinction (`analyze`
391
+ * refuses a change that still has them) and it is the one the counters must
392
+ * agree to make explicitly rather than by accident.
393
+ *
394
+ * @param {string} text
395
+ * @param {{ section?: string|null }} [opts] limit to one `## ` section
396
+ * @returns {Array<{ line: number, checked: boolean, text: string, placeholder: boolean }>}
397
+ */
398
+ export function parseChecklist(text, opts = {}) {
399
+ const section = opts.section ?? null;
400
+ const lines = String(text).split(/\r?\n/);
401
+ const out = [];
402
+ let inSection = section === null;
403
+ for (let i = 0; i < lines.length; i++) {
404
+ if (/^##\s+/.test(lines[i])) {
405
+ if (section !== null) {
406
+ inSection = new RegExp(`^##\\s+${section.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i")
407
+ .test(lines[i]);
408
+ }
409
+ continue;
410
+ }
411
+ if (!inSection) continue;
412
+ // Any Markdown bullet marker opens a box (change 0104): `* [ ]` and
413
+ // `+ [ ]` are task items to every renderer, and a box the grammar
414
+ // cannot see is one `tick` cannot tick and the archive gate cannot
415
+ // count — an unchecked task that closes green.
416
+ const m = /^\s*[-*+]\s*\[([ xX])\]\s*(.*)$/.exec(lines[i]);
417
+ if (!m) continue;
418
+ const body = m[2].trim();
419
+ out.push({ line: i, checked: m[1] !== " ", text: body, placeholder: body === "" });
420
+ }
421
+ return out;
422
+ }
423
+
424
+ /**
425
+ * How many boxes a checklist holds and how many are done — the pair every
426
+ * progress line prints. Placeholders count: a box nobody wrote is a box
427
+ * nobody finished, and hiding it is what made "0/3" mean six.
428
+ *
429
+ * @param {string} text
430
+ * @param {{ section?: string|null }} [opts]
431
+ * @returns {{ total: number, done: number, open: string[], placeholders: number }}
432
+ */
433
+ export function checklistProgress(text, opts = {}) {
434
+ const boxes = parseChecklist(text, opts);
435
+ return {
436
+ total: boxes.length,
437
+ done: boxes.filter((b) => b.checked).length,
438
+ open: boxes.filter((b) => !b.checked).map((b) => b.text),
439
+ placeholders: boxes.filter((b) => b.placeholder).length,
440
+ };
441
+ }
442
+
443
+ /**
444
+ * Is this section still the shipped template's — empty, or nothing but the
445
+ * template's own instructional comment and placeholder?
446
+ *
447
+ * The framework writes artifacts from templates and then trusts that someone
448
+ * filled them in. Exactly one gate checked that (`analyze`, over a change
449
+ * proposal); everything else took the mould for a decision (change 0065).
450
+ * This is the same ruler `isPlaceholderHeaderValue` applies to a header,
451
+ * applied to a section body: an HTML comment is annotation (change 0055), so
452
+ * a section that is only annotation is a section nobody wrote.
453
+ *
454
+ * @param {string} text the whole artifact
455
+ * @param {string} name the `## ` section name
456
+ * @param {{ template?: string }} [opts] template file to compare against
457
+ * @returns {boolean}
458
+ */
459
+ export function isUnwrittenSection(text, name, opts = {}) {
460
+ const body = getSection(text, name);
461
+ if (body === null) return true;
462
+ const prose = maskComments(body).trim();
463
+ if (prose === "") return true;
464
+ // A body that is nothing but the template's placeholder bullets or an
465
+ // angle-bracket placeholder is unwritten too.
466
+ const stripped = prose.replace(/^[-*]\s*$/gm, "").trim();
467
+ if (stripped === "") return true;
468
+ if (/^<[\s\S]*>$/.test(stripped)) return true;
469
+ if (opts.template) {
470
+ try {
471
+ const tpl = read(path.join(locateTemplatesDir(), opts.template));
472
+ const tplBody = getSection(tpl, name);
473
+ if (tplBody !== null && maskComments(tplBody).trim() === prose) return true;
474
+ } catch {
475
+ // No templates dir — the checks above already carry the common cases.
476
+ }
477
+ }
478
+ return false;
479
+ }
480
+
481
+ /**
482
+ * The named sections of `text` that nobody has written yet, in order.
483
+ *
484
+ * @param {string} text
485
+ * @param {string[]} names
486
+ * @param {{ template?: string }} [opts]
487
+ * @returns {string[]}
488
+ */
489
+ export function unwrittenSections(text, names, opts = {}) {
490
+ return names.filter((name) => isUnwrittenSection(text, name, opts));
491
+ }
492
+
493
+ // Is the on-disk spec still the untouched `spec new <cap>` scaffold? Precise
494
+ // check: render the shipped capability template for the same capability and
495
+ // compare, ignoring the date-bearing "Last updated" line and whitespace
496
+ // normalisation. When the template cannot be located (unusual installs),
497
+ // fall back to the scaffold's own placeholder fingerprints — text no real
498
+ // spec keeps. Used by `change apply` so an ADDED delta can replace a
499
+ // scaffold (the canonical spec-new → delta flow) without ever clobbering a
500
+ // spec that carries real content.
501
+ export function isUntouchedScaffold(specText, capability) {
502
+ const normalize = (s) =>
503
+ s.replace(/\r\n/g, "\n")
504
+ .split("\n")
505
+ .filter((line) => !/^\*\*Last updated:\*\*/.test(line))
506
+ .join("\n")
507
+ .trim();
508
+ try {
509
+ const tplPath = path.join(locateTemplatesDir(), "spec.md.template");
510
+ const rendered = read(tplPath)
511
+ .replace(/\{\{CAPABILITY\}\}/g, capability)
512
+ .replace(/\{\{DATE\}\}/g, "");
513
+ if (normalize(rendered) === normalize(specText)) return true;
514
+ } catch {
515
+ // fall through to the fingerprint heuristic
516
+ }
517
+ // Fingerprints: the Purpose placeholder comment AND an empty Ubiquitous
518
+ // section survive only in a scaffold nobody edited.
519
+ return (
520
+ specText.includes("<!-- One paragraph: what this capability does and why it exists. -->") &&
521
+ /##\s+Requirements \(EARS\)[\s\S]*?### Ubiquitous\s*\n\s*-\s*\n/.test(specText)
522
+ );
523
+ }
524
+
525
+
526
+ /**
527
+ * The byte ranges every HTML comment occupies in `text`, in file order.
528
+ *
529
+ * A comment is annotation, not content: the scaffolded spec carries the
530
+ * five-line EARS legend inside one, and the guessed delta (change 0044)
531
+ * carries its `RANKED GUESS` note inside another. Every reader of an
532
+ * on-disk artifact has to agree on that, so the ranges are computed HERE
533
+ * and nowhere else (ADR 0021 — one owner for the on-disk grammar).
534
+ *
535
+ * @param {string} text
536
+ * @returns {Array<[number, number]>}
537
+ */
538
+ export function commentRanges(text) {
539
+ /** @type {Array<[number, number]>} */
540
+ const ranges = [];
541
+ for (const m of text.matchAll(/<!--[\s\S]*?-->/g)) {
542
+ ranges.push([m.index ?? 0, (m.index ?? 0) + m[0].length]);
543
+ }
544
+ return ranges;
545
+ }
546
+
547
+ /**
548
+ * Is this offset inside one of `ranges`?
549
+ *
550
+ * @param {Array<[number, number]>} ranges
551
+ * @param {number} offset
552
+ * @returns {boolean}
553
+ */
554
+ export function isInsideComment(ranges, offset) {
555
+ return ranges.some(([a, b]) => offset >= a && offset < b);
556
+ }
557
+
558
+ /**
559
+ * `text` with every HTML comment blanked out — same length, same line
560
+ * count, same offset for every character that survives.
561
+ *
562
+ * This is the skip-by-position rule of `spec-ops.matchOpsBlock` in a form
563
+ * a line- or regex-oriented scanner can use directly: a bullet inside a
564
+ * comment stops looking like a bullet, and a command name inside one stops
565
+ * looking like a reference, WITHOUT any surviving character moving. Masking
566
+ * is for SCANNING only. Never write the masked text back to disk, and never
567
+ * derive an authored value from it — a delta whose `append-criterion` value
568
+ * legitimately contains `<!-- illustrative -->` must land with the marker
569
+ * intact, which is why `applyOps` still works on the raw text.
570
+ *
571
+ * @param {string} text
572
+ * @returns {string}
573
+ */
574
+ export function maskComments(text) {
575
+ let out = text;
576
+ for (const [a, b] of commentRanges(text)) {
577
+ const blanked = text.slice(a, b).replace(/[^\r\n]/g, " ");
578
+ out = out.slice(0, a) + blanked + out.slice(b);
579
+ }
580
+ return out;
581
+ }
582
+
583
+ /**
584
+ * The title a change proposal's H1 states, or null.
585
+ *
586
+ * The H1 the template writes is `# Change <id> — <title>`, and the id itself
587
+ * contains hyphens (`NNNN-slug`) — which is what four separate copies of this
588
+ * regex kept getting wrong in four slightly different ways. The one that read
589
+ * `[^—-]*` for the id stopped at the FIRST hyphen, which in any multi-word id
590
+ * is the id's own, so `prime`, `handoff` and `report` printed the slug glued
591
+ * in front of the title on every change `work` had ever generated. The ones
592
+ * that read `\s*[—-]\s*` accepted a bare hyphen with no spaces around it, so
593
+ * an H1 with no separator at all had its last segment read as the title.
594
+ *
595
+ * The separator is a dash WITH whitespace on both sides; an id's hyphens
596
+ * never have that, which is the whole ambiguity, resolved. Both the em dash
597
+ * and the plain hyphen are accepted, because the character was never the
598
+ * problem — a hand-written H1 using `-` reads exactly as clearly.
599
+ *
600
+ * The `Change <id> —` prefix is optional: an H1 written without it is a title
601
+ * in its own right and is returned whole (ADR 0021 — one owner for the
602
+ * on-disk grammar).
603
+ *
604
+ * @param {string} text the proposal, or just its first line
605
+ * @returns {string|null}
606
+ */
607
+ export function parseChangeTitle(text) {
608
+ const first = String(text ?? "").split(/\r?\n/).find((l) => /^#\s+\S/.test(l));
609
+ if (!first) return null;
610
+ const m = first.match(/^#\s+(?:Change\s+\S+\s+[—-]\s+)?(.+)$/);
611
+ const title = m?.[1]?.trim();
612
+ return title ? title : null;
613
+ }
@@ -1,9 +1,12 @@
1
1
  // @ts-check
2
2
  import path from "node:path";
3
- import { spawnSync } from "node:child_process";
4
- import { isFile, read, walk } from "./fs-ops.js";
3
+ import { readdirSync } from "node:fs";
4
+ import { isDir, isFile, read, walk } from "./fs-ops.js";
5
5
  import { COMMAND_NAMES } from "./commands.js";
6
6
  import { locateTemplatesDir } from "./templates.js";
7
+ import { changedFiles, isRepo } from "./git.js";
8
+ import { maskComments, getSection } from "./doc-model.js";
9
+ import { listHeader } from "./scan.js";
7
10
 
8
11
  // "Docs ship inside the change, never after it" (audit item D2).
9
12
  //
@@ -46,11 +49,81 @@ function scaffoldLines() {
46
49
  return lines;
47
50
  }
48
51
 
52
+ function escapeRe(x) {
53
+ return x.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
54
+ }
55
+
56
+ // The names THIS project declares as its integration surface, read from its
57
+ // own contracts: the variables of the Environment and Wiring tables, the
58
+ // services of the Ports table, and the backticked tokens of Interfaces.
59
+ //
60
+ // A contract is the right home for this. It is where a project already states
61
+ // what an external consumer integrates against, it is versioned beside the
62
+ // code, and `contract check` already holds parts of it to the implementation
63
+ // (ADR 0023 — the runtime surface is declared, never inferred). The same
64
+ // principle, applied to documentation: declared, never guessed.
65
+ //
66
+ // Returns [] for a project with no contracts, which is what keeps the change
67
+ // backward compatible: that project's gate behaves exactly as it did.
68
+ export function declaredSurfaceNames(projectRoot) {
69
+ const dir = path.join(projectRoot, ".doctrina", "contracts");
70
+ if (!isDir(dir)) return [];
71
+ const names = new Set();
72
+ for (const file of walk(dir)) {
73
+ if (!file.endsWith(".md")) continue;
74
+ const text = maskComments(read(file));
75
+
76
+ // First column of a table, for the sections whose first column IS the name.
77
+ for (const section of ["Ports", "Environment", "Wiring", "Selectors", "Budgets"]) {
78
+ const body = getSection(text, section);
79
+ if (!body) continue;
80
+ for (const line of body.split(/\r?\n/)) {
81
+ const m = /^\s*\|\s*([A-Za-z][\w.-]*)\s*\|/.exec(line);
82
+ if (!m) continue;
83
+ const cell = m[1];
84
+ // Skip the header row and the template's own placeholder rows.
85
+ if (/^(service|variable|selector|limit|name)$/i.test(cell)) continue;
86
+ if (/^-+$/.test(cell)) continue;
87
+ names.add(cell);
88
+ }
89
+ }
90
+
91
+ // Interfaces is prose with backticked tokens: endpoints, flags, shapes.
92
+ const interfaces = getSection(text, "Interfaces");
93
+ if (interfaces) {
94
+ for (const m of interfaces.matchAll(/`([^`\n]{2,60})`/g)) {
95
+ const token = m[1].trim();
96
+ // A command with a subcommand ("ledgerly reconcile") is exactly the
97
+ // shape worth declaring, so single internal spaces are allowed —
98
+ // anything with punctuation or prose in it is not a name.
99
+ if (/^[\w./:@+-]+(?: [\w./:@+-]+)*$/.test(token)) names.add(token);
100
+ }
101
+ }
102
+ }
103
+ return [...names];
104
+ }
105
+
49
106
  // Does this change alter something the documentation makes promises about?
50
107
  // Read from the change's own artifacts — the deltas it will merge into
51
108
  // specs, plus the proposal that states its shape. Returns the list of
52
109
  // signals found (empty when the change touches no documented surface).
53
- export function documentedSurfaceSignals(changeDir) {
110
+ // Drop the proposal's `## Verification` section before reading it for
111
+ // surface signals. That section answers "how will you know this landed", so
112
+ // the commands it names are the ones the author will RUN — the same reason
113
+ // the template's own checklist lines are subtracted below. Running a gate is
114
+ // not changing it (change 0088).
115
+ function withoutVerification(text) {
116
+ const lines = text.split(/\r?\n/);
117
+ const out = [];
118
+ let skipping = false;
119
+ for (const line of lines) {
120
+ if (/^##\s+/.test(line)) skipping = /^##\s+Verification\b/i.test(line);
121
+ if (!skipping) out.push(line);
122
+ }
123
+ return out.join("\n");
124
+ }
125
+
126
+ export function documentedSurfaceSignals(changeDir, projectRoot = null) {
54
127
  const signals = [];
55
128
  const known = new Set(COMMAND_NAMES);
56
129
 
@@ -59,15 +132,39 @@ export function documentedSurfaceSignals(changeDir) {
59
132
  // and `doctrina coverage` — so scanning the raw file made every change look
60
133
  // like it touched a documented surface. A gate that fires on everything is
61
134
  // a gate that gets ignored, so the template's lines are subtracted first.
135
+ // An HTML comment is annotation, not authored surface. The guessed delta
136
+ // change 0044 scaffolds carries a `RANKED GUESS` note that names
137
+ // `doctrina work` — not template text, so the subtraction below never
138
+ // reaches it, and every change on the default path arrived at the docs
139
+ // gate carrying a phantom `commands: work`. Comments are blanked FIRST,
140
+ // by the document model, which is the one owner of that rule.
62
141
  const boilerplate = scaffoldLines();
63
- const authored = (text) => text
142
+ const authored = (text) => maskComments(text)
64
143
  .split(/\r?\n/)
65
144
  .filter((line) => !boilerplate.has(normaliseScaffoldLine(line)))
66
145
  .join("\n");
67
146
 
68
147
  const sources = [];
69
148
  const proposal = path.join(changeDir, "proposal.md");
70
- if (isFile(proposal)) sources.push(authored(read(proposal)));
149
+ if (isFile(proposal)) {
150
+ const text = read(proposal);
151
+
152
+ // A CHORE declares that no behaviour changes and no spec moves — that is
153
+ // what the lane means, and `analyze` already reads it that way ("0 spec
154
+ // deltas, metadata-only change"). Asking such a change to document a
155
+ // surface it declared it does not touch contradicts its own lane. The
156
+ // declaration is the author's, recorded in the proposal header and the
157
+ // index (change 0042), so it is auditable rather than invisible.
158
+ //
159
+ // This is the case that mattered: of the archived changes, every chore
160
+ // names a command in code context and none of them changes one. Change
161
+ // 0085 reorganised headings in AGENTS.md and was refused for "commands:
162
+ // close, templates" — the two commands its own proposal cited to describe
163
+ // the finding — and had to close with --force (change 0088).
164
+ if (/chore/i.test(listHeader(text, "Lane") ?? "")) return signals;
165
+
166
+ sources.push(authored(withoutVerification(text)));
167
+ }
71
168
  for (const p of walk(path.join(changeDir, "specs"))) {
72
169
  if (p.endsWith("delta.md")) sources.push(authored(read(p)));
73
170
  }
@@ -89,12 +186,52 @@ export function documentedSurfaceSignals(changeDir) {
89
186
  signals.push(`commands: ${[...commands].sort().join(", ")}`);
90
187
  }
91
188
 
189
+ // The surface the PROJECT declares, matched by name (change 0075's sibling
190
+ // problem, found in the second audit). Matching commands against Doctrina's
191
+ // own catalog made the gate maximally sensitive inside this repository and
192
+ // inert everywhere else: in an adopting project a new command, a public HTTP
193
+ // endpoint, a renamed environment variable and a changed config key all
194
+ // produced ZERO signals, and the change closed with no documentation and no
195
+ // complaint. The vocabulary was already in the right place — the contract
196
+ // declares Ports, Environment, Wiring, Selectors and Interfaces — so the gate
197
+ // reads it instead of carrying a catalog that only fits its author.
198
+ const declared = projectRoot === null ? [] : declaredSurfaceNames(projectRoot);
199
+ const hit = declared.filter((name) => new RegExp(`\\b${escapeRe(name)}\\b`).test(code));
200
+ // Report the most specific declaration only: "ledgerly reconcile" says more
201
+ // than the bare service name it starts with.
202
+ const touchedDeclared = hit.filter((a) => !hit.some((b) => b !== a && b.includes(a)));
203
+ if (touchedDeclared.length > 0) {
204
+ signals.push(`declared surface: ${touchedDeclared.sort().slice(0, 8).join(", ")}`);
205
+ }
206
+
92
207
  const flags = new Set();
93
208
  for (const m of code.matchAll(/--([a-z][a-z0-9-]{2,})/g)) flags.add(`--${m[1]}`);
94
209
  if (flags.size > 0) {
95
210
  signals.push(`flags: ${[...flags].sort().slice(0, 8).join(", ")}`);
96
211
  }
97
212
 
213
+ // Surface SHAPES, for the surface a change is ADDING — which by definition
214
+ // is not in the contract yet, and is the case the declared-name match cannot
215
+ // reach. Deterministic and language-agnostic (ADR 0005): a route path, an
216
+ // HTTP method in front of one, an environment-variable identifier. Read from
217
+ // code spans only, so prose that happens to contain a slash is not a signal.
218
+ const routes = new Set();
219
+ for (const m of code.matchAll(/(?:^|[\s`"'(])((?:GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+)?(\/[a-z][\w.-]*(?:\/[\w.:{}$<>-]+)+)/gi)) {
220
+ routes.add(((m[1] ?? "").toUpperCase().trim() + " " + m[2]).trim());
221
+ }
222
+ if (routes.size > 0) {
223
+ signals.push(`endpoints: ${[...routes].sort().slice(0, 6).join(", ")}`);
224
+ }
225
+
226
+ const envVars = new Set();
227
+ for (const m of code.matchAll(/\b([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+)\b/g)) {
228
+ // Not twice: a declared variable is already reported by name above.
229
+ if (!touchedDeclared.includes(m[1])) envVars.add(m[1]);
230
+ }
231
+ if (envVars.size > 0) {
232
+ signals.push(`environment: ${[...envVars].sort().slice(0, 8).join(", ")}`);
233
+ }
234
+
98
235
  if (/\bexit\s+code|\bexits?\s+(?:with\s+)?[0-4]\b/i.test(text)) {
99
236
  signals.push("exit codes");
100
237
  }
@@ -116,38 +253,70 @@ export function documentedSurfaceSignals(changeDir) {
116
253
  // all, and attributing a hunk to a change would need guesswork the rest of
117
254
  // the framework refuses to do (ADR 0005).
118
255
  export function docsTouched(projectRoot) {
119
- const git = (args) => {
120
- const r = spawnSync("git", args, { cwd: projectRoot, encoding: "utf8" });
121
- if (r.error || r.status !== 0) return [];
122
- return r.stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
123
- };
124
- const set = new Set();
125
- for (const f of git(["diff", "--name-only", "HEAD"])) set.add(f);
126
- for (const f of git(["ls-files", "--others", "--exclude-standard"])) set.add(f);
127
- for (const base of ["main", "master"]) {
128
- const mb = git(["merge-base", "HEAD", base]);
129
- if (mb.length > 0) {
130
- for (const f of git(["diff", "--name-only", `${mb[0]}..HEAD`])) set.add(f);
131
- break;
132
- }
133
- }
134
- return [...set]
135
- .map((f) => f.replace(/\\/g, "/"))
256
+ // mergeBase: a branch's EARLIER commits count as documentation that moved
257
+ // with the change — the gate asks "did docs ship with this work", not "did
258
+ // docs change since the last commit".
259
+ return changedFiles(projectRoot, { mergeBase: true })
260
+ .files
136
261
  .filter((f) => DOC_PATHS.some((re) => re.test(f)));
137
262
  }
138
263
 
139
264
  export function isGitRepo(projectRoot) {
140
- const r = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], {
141
- cwd: projectRoot, encoding: "utf8",
142
- });
143
- return !r.error && r.status === 0;
265
+ return isRepo(projectRoot);
266
+ }
267
+
268
+ // Where THIS project keeps its documentation, read off its own tree.
269
+ //
270
+ // The gate is portable; the instruction it printed was not. It named
271
+ // `docs/en/` AND `docs/pt/` and a skill that exists only in Doctrina's own
272
+ // repository, so an adopting project with neither read that it had to
273
+ // translate (change 0058). The gate itself never asked for any of that — it
274
+ // accepts anything under `docs/` or a README — so the remedy is derived from
275
+ // what the checked project actually has.
276
+ //
277
+ // Returns the documentation homes in reading order, or [] for a project that
278
+ // has none yet.
279
+ export function documentationHomes(projectRoot) {
280
+ const homes = [];
281
+ const docsDir = path.join(projectRoot, "docs");
282
+ // Only a directory that holds prose is somewhere to write prose: naming
283
+ // `docs/assets/` — an SVG and nothing else — turned change 0058's portable
284
+ // hint into a five-item list with a wrong item in it.
285
+ const holdsMarkdown = (dir) => walk(dir).some((f) => f.endsWith(".md"));
286
+ if (isDir(docsDir)) {
287
+ // A per-language or per-audience split is a convention, not a rule: name
288
+ // the subdirectories this project HAS, and fall back to `docs/` itself.
289
+ const subs = readdirSync(docsDir, { withFileTypes: true })
290
+ .filter((e) => e.isDirectory() && !e.name.startsWith("."))
291
+ .filter((e) => holdsMarkdown(path.join(docsDir, e.name)))
292
+ .map((e) => `docs/${e.name}/`)
293
+ .sort();
294
+ if (subs.length > 0) homes.push(...subs);
295
+ else if (holdsMarkdown(docsDir)) homes.push("docs/");
296
+ }
297
+ for (const readme of ["README.md", "README.pt.md"]) {
298
+ if (isFile(path.join(projectRoot, readme))) homes.push(readme);
299
+ }
300
+ return homes;
301
+ }
302
+
303
+ // The hint the docs gate prints when it refuses. Names the places this
304
+ // project documents in, and says "a README" when it documents nowhere yet —
305
+ // never a path the checked project does not have.
306
+ export function docsRemedy(projectRoot) {
307
+ const homes = documentationHomes(projectRoot);
308
+ if (homes.length === 0) return "document it in a README, or under docs/";
309
+ if (homes.length === 1) return `document it in ${homes[0]}`;
310
+ const last = homes[homes.length - 1];
311
+ return `document it in ${homes.slice(0, -1).join(", ")} or ${last}` +
312
+ (homes.length > 2 ? " — whichever this change belongs in" : "");
144
313
  }
145
314
 
146
315
  // The gate itself: { ok, signals, touched, reason }. `ok` is true when the
147
316
  // change touches no documented surface, when documentation moved with it,
148
317
  // or when git cannot answer.
149
318
  export function checkDocsImpact(projectRoot, changeDir) {
150
- const signals = documentedSurfaceSignals(changeDir);
319
+ const signals = documentedSurfaceSignals(changeDir, projectRoot);
151
320
  if (signals.length === 0) {
152
321
  return { ok: true, signals, touched: [], reason: "touches no documented surface" };
153
322
  }
@@ -2,7 +2,6 @@
2
2
  import { readFileSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { COMMAND_NAMES } from "./commands.js";
6
5
 
7
6
  // One catalog of "which flags does command X accept", with two consumers:
8
7
  //
@@ -64,12 +63,6 @@ export async function declaredFlags(commandName) {
64
63
  ]);
65
64
  }
66
65
 
67
- export async function loadAllFlagSpecs() {
68
- const out = new Map();
69
- for (const name of COMMAND_NAMES) out.set(name, await loadFlagSpec(name));
70
- return out;
71
- }
72
-
73
66
  // Flag names a source file READS, by scanning its call sites:
74
67
  // flagBool(flags, "x") flagString(flags, "x") flags.has("x") flags.get("x")
75
68
  // Deterministic text scan, not execution — it sees every branch, including
@@ -92,15 +85,6 @@ export function readCommandSource(commandName) {
92
85
  return readFileSync(modulePathFor(commandName), "utf8");
93
86
  }
94
87
 
95
- // Flags a command's `help` string documents: every `--flag` token in it.
96
- // The docs gate compares prose against this too, so help and reference
97
- // cannot drift apart from the declaration.
98
- export function scanHelpFlags(helpText) {
99
- const out = new Set();
100
- for (const m of String(helpText ?? "").matchAll(/--([a-z][a-z0-9-]*)/g)) out.add(m[1]);
101
- return out;
102
- }
103
-
104
88
  function pathToUrl(p) {
105
89
  return "file:///" + p.replace(/\\/g, "/");
106
90
  }