doctrina-cli 0.10.0 → 0.12.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.
@@ -7,6 +7,7 @@ import { deriveIndex, indexesMatch, specHeader, listHeader } from "../lib/scan.j
7
7
  import { cliVersion } from "../lib/version.js";
8
8
  import { summarize as coverageSummary } from "./coverage.js";
9
9
  import { summarize as traceSummary } from "./trace.js";
10
+ import { flagBool } from "../lib/args.js";
10
11
  import { c } from "../lib/colors.js";
11
12
 
12
13
  // One-glance project health (review 2026-06-27 passive-user feature #1): a
@@ -19,53 +20,47 @@ import { c } from "../lib/colors.js";
19
20
 
20
21
  const CONFIG_REL = ".doctrina/verify.json";
21
22
 
22
- export async function run(_positional, _flags) {
23
+ export async function run(_positional, flags) {
23
24
  const projectRoot = process.cwd();
24
25
  if (!exists(path.join(projectRoot, ".doctrina"))) {
25
26
  throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
26
27
  }
27
28
 
28
- let index = null;
29
- try {
30
- index = idx.load(projectRoot);
31
- } catch {
32
- index = null;
29
+ const s = collectStatus(projectRoot);
30
+
31
+ if (flagBool(flags, "json", false)) {
32
+ console.log(JSON.stringify(s, null, 2));
33
+ return 0;
33
34
  }
34
35
 
35
- const project = index?.project ?? path.basename(projectRoot);
36
- const stamp = index?.framework_version ?? "—";
37
- const cli = cliVersion();
38
- console.log(c.bold("Doctrina status") + c.gray(` — ${project} (framework ${stamp} / CLI ${cli})`));
36
+ console.log(c.bold("Doctrina status") + c.gray(` — ${s.project} (framework ${s.stamp ?? "—"} / CLI ${s.cli})`));
39
37
  console.log("");
40
38
 
41
39
  // --- Gates ---
42
40
  console.log(c.bold(" Gates"));
43
- // Index drift (the silent rot validate now errors on).
44
41
  let driftLine;
45
- if (!index) {
42
+ if (s.indexState === "missing") {
46
43
  driftLine = c.red("missing/unreadable") + c.gray(" — run `doctrina index rebuild`");
47
- } else if (indexesMatch(deriveIndex(projectRoot, index), index)) {
44
+ } else if (s.indexState === "in-sync") {
48
45
  driftLine = c.green("in sync");
49
46
  } else {
50
47
  driftLine = c.yellow("drifted") + c.gray(" — run `doctrina validate --fix`");
51
48
  }
52
49
  console.log(` ${"index".padEnd(11)} ${driftLine}`);
53
50
 
54
- // Framework stamp divergence.
55
- const stampLine = stamp === cli
51
+ const stampLine = s.stamp === s.cli
56
52
  ? c.green("current")
57
- : c.yellow(`${stamp} (CLI ${cli})`) + c.gray(" — `doctrina index rebuild`");
53
+ : c.yellow(`${s.stamp ?? "—"} (CLI ${s.cli})`) + c.gray(" — `doctrina index rebuild`");
58
54
  console.log(` ${"stamp".padEnd(11)} ${stampLine}`);
59
55
 
60
- // Coverage and trace ratios.
61
- const cov = coverageSummary(projectRoot);
56
+ const cov = s.coverage;
62
57
  const covExtra = cov.totalDangling + cov.totalConditional > 0
63
58
  ? c.yellow(` ${cov.totalDangling} dangling, ${cov.totalConditional} conditional`)
64
59
  : "";
65
60
  const covColor = cov.pct === 100 ? c.green : cov.pct >= 50 ? c.yellow : c.red;
66
61
  console.log(` ${"coverage".padEnd(11)} ${covColor(`${cov.pct}%`)} ${c.gray(`(${cov.totalCovered}/${cov.totalCriteria} criteria)`)}${covExtra}`);
67
62
 
68
- const tr = traceSummary(projectRoot);
63
+ const tr = s.trace;
69
64
  const trExtra = tr.untraceable + tr.dropped + tr.dangling > 0
70
65
  ? c.yellow(` ${tr.dropped} dropped, ${tr.untraceable} untraceable`)
71
66
  : "";
@@ -73,40 +68,72 @@ export async function run(_positional, _flags) {
73
68
  const trText = tr.anchors === 0 ? "no anchors" : `${tr.realized}/${tr.anchors} anchors`;
74
69
  console.log(` ${"trace".padEnd(11)} ${trColor(trText)}${trExtra}`);
75
70
 
76
- // Verify config presence (running it is the slow, authoritative gate).
77
- const verifyCfg = path.join(projectRoot, CONFIG_REL);
78
71
  let verifyLine = c.gray("not configured") + c.gray(" — `doctrina verify --init`");
79
- if (isFile(verifyCfg)) {
80
- try {
81
- const cfg = JSON.parse(read(verifyCfg));
82
- const n = Array.isArray(cfg?.checks) ? cfg.checks.length : 0;
83
- verifyLine = c.cyan(`${n} check${n === 1 ? "" : "s"}`) + c.gray(" — run `doctrina verify`");
84
- } catch {
85
- verifyLine = c.red("invalid JSON");
86
- }
72
+ if (s.verify.invalid) {
73
+ verifyLine = c.red("invalid JSON");
74
+ } else if (s.verify.configured) {
75
+ const n = s.verify.checks;
76
+ verifyLine = c.cyan(`${n} check${n === 1 ? "" : "s"}`) + c.gray(" — run `doctrina verify`");
87
77
  }
88
78
  console.log(` ${"verify".padEnd(11)} ${verifyLine}`);
89
79
 
90
80
  // --- Work ---
91
81
  console.log("");
92
82
  console.log(c.bold(" Work"));
93
- const specs = countSpecs(projectRoot);
94
- const implBreak = Object.entries(specs.impl).map(([k, v]) => `${v} ${k}`).join(", ");
95
- console.log(` ${"specs".padEnd(11)} ${specs.total}${implBreak ? c.gray(` (${implBreak})`) : ""}`);
96
- console.log(` ${"changes".padEnd(11)} ${specs.openChanges} open`);
97
- const adr = countDecisions(projectRoot);
83
+ const implBreak = Object.entries(s.specs.impl).map(([k, v]) => `${v} ${k}`).join(", ");
84
+ console.log(` ${"specs".padEnd(11)} ${s.specs.total}${implBreak ? c.gray(` (${implBreak})`) : ""}`);
85
+ console.log(` ${"changes".padEnd(11)} ${s.specs.openChanges} open`);
98
86
  const adrNotes = [];
99
- if (adr.proposed > 0) adrNotes.push(c.yellow(`${adr.proposed} proposed`));
100
- if (adr.bare > 0) adrNotes.push(c.yellow(`${adr.bare} unproven`));
101
- console.log(` ${"decisions".padEnd(11)} ${adr.total}${adrNotes.length ? c.gray(" (") + adrNotes.join(c.gray(", ")) + c.gray(")") : ""}`);
102
- const skills = countSkills(projectRoot);
103
- console.log(` ${"skills".padEnd(11)} ${skills}`);
87
+ if (s.decisions.proposed > 0) adrNotes.push(c.yellow(`${s.decisions.proposed} proposed`));
88
+ if (s.decisions.bare > 0) adrNotes.push(c.yellow(`${s.decisions.bare} unproven`));
89
+ console.log(` ${"decisions".padEnd(11)} ${s.decisions.total}${adrNotes.length ? c.gray(" (") + adrNotes.join(c.gray(", ")) + c.gray(")") : ""}`);
90
+ console.log(` ${"skills".padEnd(11)} ${s.skills}`);
104
91
 
105
92
  console.log("");
106
93
  console.log(c.gray(" Full gates: `doctrina validate` · `doctrina verify`. Next step: `doctrina next`."));
107
94
  return 0;
108
95
  }
109
96
 
97
+ // Pure, render-free status snapshot — the data behind the dashboard, shared
98
+ // with `prime`, `handoff`, `doctor`, and the `--json` output so every consumer
99
+ // reads the same numbers.
100
+ export function collectStatus(projectRoot) {
101
+ let index = null;
102
+ try {
103
+ index = idx.load(projectRoot);
104
+ } catch {
105
+ index = null;
106
+ }
107
+ let indexState = "missing";
108
+ if (index) {
109
+ indexState = indexesMatch(deriveIndex(projectRoot, index), index) ? "in-sync" : "drifted";
110
+ }
111
+
112
+ const verifyCfg = path.join(projectRoot, CONFIG_REL);
113
+ let verify = { configured: false, checks: 0, invalid: false };
114
+ if (isFile(verifyCfg)) {
115
+ try {
116
+ const cfg = JSON.parse(read(verifyCfg));
117
+ verify = { configured: true, checks: Array.isArray(cfg?.checks) ? cfg.checks.length : 0, invalid: false };
118
+ } catch {
119
+ verify = { configured: true, checks: 0, invalid: true };
120
+ }
121
+ }
122
+
123
+ return {
124
+ project: index?.project ?? path.basename(projectRoot),
125
+ stamp: index?.framework_version ?? null,
126
+ cli: cliVersion(),
127
+ indexState,
128
+ coverage: coverageSummary(projectRoot),
129
+ trace: traceSummary(projectRoot),
130
+ verify,
131
+ specs: countSpecs(projectRoot),
132
+ decisions: countDecisions(projectRoot),
133
+ skills: countSkills(projectRoot),
134
+ };
135
+ }
136
+
110
137
  function countSpecs(projectRoot) {
111
138
  const specsDir = path.join(projectRoot, ".doctrina", "specs");
112
139
  const impl = {};
@@ -165,13 +192,16 @@ function countSkills(projectRoot) {
165
192
  }
166
193
 
167
194
  export const help = `
168
- Usage: doctrina status
195
+ Usage: doctrina status [--json]
169
196
 
170
197
  Print a one-glance health dashboard for the .doctrina/ project: the gate
171
198
  signals (index drift, framework stamp, coverage %, trace anchors, whether
172
199
  verify is configured) and the artifact counts (specs by implementation
173
200
  state, open changes, decisions, skills). Read-only; always exits 0.
174
201
 
202
+ Flags:
203
+ --json Emit the snapshot as JSON (stable shape for agents and CI).
204
+
175
205
  It is a fast summary, not the authoritative gate: run \`doctrina validate\`
176
206
  and \`doctrina verify\` for the full structural and build checks, and
177
207
  \`doctrina next\` for the recommended next action.
@@ -32,6 +32,7 @@ export async function run(_positional, flags) {
32
32
  throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
33
33
  }
34
34
  const strict = flagBool(flags, "strict", false);
35
+ const json = flagBool(flags, "json", false);
35
36
 
36
37
  const anchors = collectAnchors(projectRoot); // [{ id }], in document order
37
38
  const specs = collectSpecs(projectRoot); // [{ cap, status, realizes: [id] }]
@@ -41,6 +42,10 @@ export async function run(_positional, flags) {
41
42
 
42
43
  // The feature is unused: do not nag a project that never opted in.
43
44
  if (anchors.length === 0 && !anyRealizes) {
45
+ if (json) {
46
+ console.log(JSON.stringify({ anchors: [], dangling: [], untraceable: [], summary: summarize(projectRoot) }, null, 2));
47
+ return 0;
48
+ }
44
49
  console.log(
45
50
  c.gray(
46
51
  "no intent-provenance markers found — tag product.md bullets with `[SC1]` " +
@@ -69,6 +74,13 @@ export async function run(_positional, flags) {
69
74
  .filter((s) => s.realizes === null && s.status === "active")
70
75
  .map((s) => s.cap);
71
76
 
77
+ if (json) {
78
+ const rows = anchors.map((a) => ({ id: a.id, realizedBy: (realizedBy.get(a.id) ?? []).sort() }));
79
+ const clean = rows.every((r) => r.realizedBy.length > 0) && dangling.length === 0 && untraceable.length === 0;
80
+ console.log(JSON.stringify({ anchors: rows, dangling, untraceable, summary: summarize(projectRoot) }, null, 2));
81
+ return clean ? 0 : strict ? 1 : 0;
82
+ }
83
+
72
84
  console.log(c.bold("Trace") + c.gray(" — product intent → capability provenance:"));
73
85
  console.log("");
74
86
 
@@ -183,6 +195,7 @@ trace reports three provenance breaks:
183
195
  untraceable spec an active spec with no Realizes: header
184
196
 
185
197
  Read-only. Exits 0 as a report; with --strict, exits 1 when any break
186
- exists (CI gate). It checks that the link exists and is complete — not
187
- that a criterion is faithful to the intent (that stays a human/LLM call).
198
+ exists (CI gate). --json emits anchors/dangling/untraceable + summary
199
+ as JSON. It checks that the link exists and is complete — not that a
200
+ criterion is faithful to the intent (that stays a human/LLM call).
188
201
  `;
@@ -0,0 +1,129 @@
1
+ import path from "node:path";
2
+ import process from "node:process";
3
+ import { exists } from "../lib/fs-ops.js";
4
+ import * as idx from "../lib/index-json.js";
5
+ import { deriveIndex, indexesMatch } from "../lib/scan.js";
6
+ import { cliVersion } from "../lib/version.js";
7
+ import { flagBool } from "../lib/args.js";
8
+ import { c } from "../lib/colors.js";
9
+ import { today } from "../lib/dates.js";
10
+ import * as templates from "./templates.js";
11
+ import * as validate from "./validate.js";
12
+
13
+ // Bring an existing project up to the installed CLI (user-reported gap: you
14
+ // `npm update doctrina-cli`, but the project you once `doctrina init`-ed
15
+ // keeps its old scaffold — stale framework stamp, an AGENTS.md that predates
16
+ // new commands, missing recommended sections). `upgrade` is the one command
17
+ // that closes that window, and it is an ORCHESTRATOR over the pieces that
18
+ // already exist — templates update (additive-only), index rebuild (stamp
19
+ // migration), validate --fix — so there is exactly one definition of each
20
+ // step. Preview by default; --write applies. Never rewrites user content
21
+ // (the same additive-only bound as `templates update`).
22
+
23
+ export async function run(_positional, flags) {
24
+ const projectRoot = process.cwd();
25
+ if (!exists(path.join(projectRoot, ".doctrina"))) {
26
+ throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
27
+ }
28
+ const writeMode = flagBool(flags, "write", false);
29
+ const running = cliVersion();
30
+
31
+ // 1. Version gap: what the tree was last managed by vs what is installed.
32
+ let index = null;
33
+ let stamped = null;
34
+ try {
35
+ index = idx.load(projectRoot);
36
+ stamped = index.framework_version ?? null;
37
+ } catch {
38
+ index = null;
39
+ }
40
+ console.log(c.bold("doctrina upgrade") + c.gray(` — project ${stamped ?? "unstamped"} → CLI ${running}${writeMode ? "" : " (preview; --write applies)"}`));
41
+ console.log("");
42
+ if (stamped === running) {
43
+ console.log(c.gray("The framework stamp already matches the installed CLI — checking the scaffold anyway."));
44
+ console.log("");
45
+ }
46
+
47
+ let pending = 0;
48
+
49
+ // 2. Additive template updates (missing recommended sections in AGENTS.md /
50
+ // product.md, missing index.json fields). Reuses `templates update`
51
+ // verbatim: preview without --write, apply with it.
52
+ console.log(c.gray("──── 1/3 scaffold shape (templates update)"));
53
+ const tplFlags = writeMode ? new Map([["write", true]]) : new Map();
54
+ const tplCode = await templates.run(["update"], tplFlags);
55
+ if (tplCode !== 0 && !writeMode) pending += 1; // preview exits 1 when updates are pending
56
+
57
+ // 3. Index: rebuild from the tree, which also migrates the framework stamp.
58
+ console.log("");
59
+ console.log(c.gray("──── 2/3 index + framework stamp"));
60
+ try {
61
+ const current = idx.load(projectRoot);
62
+ const drifted = !indexesMatch(deriveIndex(projectRoot, current), current);
63
+ const staleStamp = (current.framework_version ?? null) !== running;
64
+ if (!drifted && !staleStamp) {
65
+ console.log(c.green("ok") + " index.json matches the tree and the stamp is current");
66
+ } else if (writeMode) {
67
+ const derived = deriveIndex(projectRoot, current);
68
+ derived.last_updated = today();
69
+ idx.save(projectRoot, derived); // save() stamps framework_version
70
+ console.log(c.green("rebuilt") + ` index.json${staleStamp ? ` — stamp ${current.framework_version ?? "unstamped"} → ${running}` : ""}`);
71
+ } else {
72
+ if (drifted) console.log(c.yellow("would ") + "rebuild index.json from the tree (drifted)");
73
+ if (staleStamp) console.log(c.yellow("would ") + `migrate framework stamp ${current.framework_version ?? "unstamped"} → ${running}`);
74
+ pending += 1;
75
+ }
76
+ } catch {
77
+ if (writeMode) {
78
+ console.log(c.red("error: ") + "index.json is missing or unreadable — run `doctrina index rebuild` manually");
79
+ } else {
80
+ console.log(c.yellow("would ") + "rebuild index.json (missing or unreadable)");
81
+ pending += 1;
82
+ }
83
+ }
84
+
85
+ // 4. Full structural check — surfaces what an automatic, additive upgrade
86
+ // cannot fix: an AGENTS.md documenting a stale command surface, new
87
+ // validate checks the old scaffold fails, and so on. --fix under --write
88
+ // so the mechanical part self-heals.
89
+ console.log("");
90
+ console.log(c.gray("──── 3/3 validate" + (writeMode ? " --fix" : "")));
91
+ const vFlags = writeMode ? new Map([["fix", true]]) : new Map();
92
+ const vCode = await validate.run([], vFlags);
93
+
94
+ console.log("");
95
+ if (!writeMode) {
96
+ if (pending > 0) {
97
+ console.log(c.yellow("pending") + ` upgrade steps found — apply them: ${c.cyan("doctrina upgrade --write")}`);
98
+ return 1;
99
+ }
100
+ console.log(c.green("ok") + " nothing to upgrade — the project matches the installed CLI");
101
+ return vCode;
102
+ }
103
+ if (vCode === 0) {
104
+ console.log(c.green(`✓ project upgraded to ${running}`) + c.gray(" — review any warnings above; new commands: `doctrina --help`."));
105
+ console.log(c.gray("If AGENTS.md lists the command surface, refresh that section so agents see the new commands."));
106
+ } else {
107
+ console.log(c.yellow("upgraded with findings") + " — resolve the validate errors above (they predate or exceed what an additive upgrade can fix).");
108
+ }
109
+ return vCode;
110
+ }
111
+
112
+ export const help = `
113
+ Usage: doctrina upgrade [--write]
114
+
115
+ Bring an existing project up to the installed CLI after an npm update.
116
+ The project keeps the scaffold of the version that init-ed it; this is
117
+ the one command that closes the gap, orchestrating the existing pieces:
118
+
119
+ 1. templates update — append missing recommended sections to AGENTS.md
120
+ / product.md and missing index.json fields
121
+ (additive-only; never rewrites your content)
122
+ 2. index rebuild — regenerate index.json from the tree and migrate
123
+ the framework_version stamp to the running CLI
124
+ 3. validate (--fix) — surface anything the additive upgrade cannot fix
125
+ (e.g. an AGENTS.md documenting a stale command
126
+ surface — refresh it so agents see new commands)
127
+
128
+ Preview by default (exits 1 when steps are pending); --write applies.
129
+ `;
@@ -451,8 +451,15 @@ export async function run(_positional, flags) {
451
451
  if (index?.artifacts && isFile(ledgerPath)) {
452
452
  const ledgerIds = new Set();
453
453
  for (const line of read(ledgerPath).split(/\r?\n/)) {
454
- const m = line.match(/^-\s+\d{4}-\d{2}-\d{2}\s+[—-]\s+(\S+)\s+[—-]\s+/);
455
- if (m) ledgerIds.add(m[1]);
454
+ const m = line.match(/^-\s+\d{4}-\d{2}-\d{2}\s+[—-]\s+(\S+)\s+[—-]\s+(.*)$/);
455
+ if (!m) continue;
456
+ // An abandoned change is ledger-only BY DESIGN: `change abandon`
457
+ // records the discard in history but deletes the folder, so there is
458
+ // deliberately no archive entry to cross-check. Requiring one made
459
+ // every abandonment turn validate permanently red (found dogfooding
460
+ // change 0001-review-followups).
461
+ if (/^abandoned\b/i.test(m[2].trim())) continue;
462
+ ledgerIds.add(m[1]);
456
463
  }
457
464
  const archiveIds = new Set((index.artifacts.changes_archive ?? []).map((ch) => ch.id));
458
465
  for (const id of archiveIds) {
@@ -499,7 +506,43 @@ export async function run(_positional, flags) {
499
506
  }
500
507
  }
501
508
 
509
+ // 16. Bilingual docs parity. Projects that keep an EN-source docs tree
510
+ // with a PT mirror (docs/en + docs/pt) get filename-parity checking:
511
+ // a file added in one language and missing in the other is invisible
512
+ // to every other gate, and the mirror silently falls behind. Only the
513
+ // deterministic half (file sets) is checked — content staleness stays
514
+ // a human judgement. Projects without both trees never see this.
515
+ const docsEn = path.join(projectRoot, "docs", "en");
516
+ const docsPt = path.join(projectRoot, "docs", "pt");
517
+ if (isDir(docsEn) && isDir(docsPt)) {
518
+ const mdSet = (dir) => new Set(readdirSync(dir).filter((f) => f.endsWith(".md")));
519
+ const enSet = mdSet(docsEn);
520
+ const ptSet = mdSet(docsPt);
521
+ for (const f of [...enSet].sort()) {
522
+ if (!ptSet.has(f)) {
523
+ warnings.push(`docs/en/${f} has no docs/pt/${f} counterpart (EN is the source; the PT mirror fell behind)`);
524
+ }
525
+ }
526
+ for (const f of [...ptSet].sort()) {
527
+ if (!enSet.has(f)) {
528
+ warnings.push(`docs/pt/${f} has no docs/en/${f} counterpart (PT translates an EN source, never leads it)`);
529
+ }
530
+ }
531
+ }
532
+
533
+ // Project rules — permanent, lintable constraints (.doctrina/rules.json;
534
+ // 0.11.0 field review item 12: "never reference company X" lived only in
535
+ // agent memory, and the remaining mentions sailed through every gate). Each
536
+ // rule is a forbid-regex over glob-scoped paths; a match is an ERROR with
537
+ // the rule's own message. Deterministic, zero-deps, and the instruction
538
+ // survives sessions because it is an artifact, not a memory.
539
+ for (const line of checkProjectRules(projectRoot)) errors.push(line);
540
+
502
541
  // Output
542
+ if (flagBool(flags, "json", false)) {
543
+ console.log(JSON.stringify({ ok: errors.length === 0, errors, warnings }, null, 2));
544
+ return errors.length === 0 ? 0 : 1;
545
+ }
503
546
  for (const w of warnings) console.log(c.yellow("warn: ") + w);
504
547
  for (const e of errors) console.log(c.red("error: ") + e);
505
548
 
@@ -513,6 +556,106 @@ export async function run(_positional, flags) {
513
556
  return errors.length === 0 ? 0 : 1;
514
557
  }
515
558
 
559
+ // Enforce .doctrina/rules.json — permanent project constraints as
560
+ // forbid-regexes over glob-scoped paths:
561
+ // { "rules": [ { "id": "white-label", "forbid": "\\bAcmeCorp\\b",
562
+ // "paths": ["src/**", ".doctrina/specs/**"],
563
+ // "message": "white-label product; use a generic placeholder" } ] }
564
+ // Returns error strings (one per offending file+rule, capped per rule so a
565
+ // mass violation stays readable). Missing/invalid file → no rules (an
566
+ // invalid JSON is reported once). Binary-ish and vendored dirs are skipped
567
+ // by the same bounded walk validate already uses elsewhere.
568
+ const RULES_SKIP_DIRS = new Set([
569
+ ".git", "node_modules", "vendor", "dist", "build", "out", "target",
570
+ ".venv", "venv", "__pycache__", ".next", "coverage",
571
+ ]);
572
+ const RULES_MAX_HITS_PER_RULE = 10;
573
+
574
+ function checkProjectRules(projectRoot) {
575
+ const rulesPath = path.join(projectRoot, ".doctrina", "rules.json");
576
+ if (!isFile(rulesPath)) return [];
577
+ let cfg;
578
+ try {
579
+ cfg = JSON.parse(read(rulesPath));
580
+ } catch (err) {
581
+ return [`.doctrina/rules.json is not valid JSON: ${err.message}`];
582
+ }
583
+ const rules = Array.isArray(cfg?.rules) ? cfg.rules : [];
584
+ const out = [];
585
+ const compiled = [];
586
+ for (const r of rules) {
587
+ if (!r || typeof r.forbid !== "string" || !r.forbid) {
588
+ out.push(`.doctrina/rules.json: rule "${r?.id ?? "?"}" needs a non-empty "forbid" regex`);
589
+ continue;
590
+ }
591
+ let re;
592
+ try {
593
+ re = new RegExp(r.forbid, "m");
594
+ } catch (err) {
595
+ out.push(`.doctrina/rules.json: rule "${r.id ?? r.forbid}" has an invalid regex: ${err.message}`);
596
+ continue;
597
+ }
598
+ const paths = Array.isArray(r.paths) && r.paths.length ? r.paths : ["**"];
599
+ compiled.push({ id: r.id ?? r.forbid, re, matchers: paths.map(globToRegExp), message: r.message ?? "", hits: 0 });
600
+ }
601
+ if (compiled.length === 0) return out;
602
+
603
+ // One bounded walk; each text file is read at most once.
604
+ const stack = [projectRoot];
605
+ while (stack.length) {
606
+ const dir = stack.pop();
607
+ let entries;
608
+ try {
609
+ entries = readdirSync(dir, { withFileTypes: true });
610
+ } catch {
611
+ continue;
612
+ }
613
+ for (const entry of entries) {
614
+ const full = path.join(dir, entry.name);
615
+ if (entry.isDirectory()) {
616
+ if (!RULES_SKIP_DIRS.has(entry.name)) stack.push(full);
617
+ continue;
618
+ }
619
+ const rel = relPath(projectRoot, full).replace(/\\/g, "/");
620
+ if (rel === ".doctrina/rules.json") continue; // the rule text itself always matches
621
+ const applicable = compiled.filter((cr) => cr.hits < RULES_MAX_HITS_PER_RULE && cr.matchers.some((m) => m.test(rel)));
622
+ if (applicable.length === 0) continue;
623
+ let text;
624
+ try {
625
+ text = read(full);
626
+ } catch {
627
+ continue;
628
+ }
629
+ for (const cr of applicable) {
630
+ const m = cr.re.exec(text);
631
+ if (m) {
632
+ cr.hits += 1;
633
+ const lineNo = text.slice(0, m.index).split("\n").length;
634
+ out.push(`rule "${cr.id}": ${rel}:${lineNo} matches forbidden pattern${cr.message ? ` — ${cr.message}` : ""}`);
635
+ }
636
+ }
637
+ }
638
+ }
639
+ for (const cr of compiled) {
640
+ if (cr.hits >= RULES_MAX_HITS_PER_RULE) {
641
+ out.push(`rule "${cr.id}": more matches suppressed after ${RULES_MAX_HITS_PER_RULE} files`);
642
+ }
643
+ }
644
+ return out;
645
+ }
646
+
647
+ // Minimal glob → RegExp: ** crosses directories, * stays within a segment.
648
+ // Anchored to the whole repo-relative POSIX path.
649
+ function globToRegExp(glob) {
650
+ const esc = String(glob).replace(/[.+^${}()|[\]\\]/g, "\\$&");
651
+ const body = esc
652
+ .replace(/\*\*\//g, "(?:.*/)?")
653
+ .replace(/\*\*/g, ".*")
654
+ .replace(/\*/g, "[^/]*")
655
+ .replace(/\?/g, "[^/]");
656
+ return new RegExp(`^${body}$`);
657
+ }
658
+
516
659
  // Per-entry metadata drift between the on-disk index and the tree-derived
517
660
  // one, for entries present in BOTH. Presence drift (an artifact on disk but
518
661
  // not indexed, or indexed but missing) is left to the orphan warning and the
@@ -613,9 +756,13 @@ function isLikelyPath(s) {
613
756
  }
614
757
 
615
758
  export const help = `
616
- Usage: doctrina validate
759
+ Usage: doctrina validate [--fix] [--json]
617
760
 
618
761
  Run schema, artifact-existence, and structural checks against the
619
762
  .doctrina/ tree in the current working directory. Exits 0 if no errors
620
763
  (warnings allowed), 1 otherwise.
764
+
765
+ Flags:
766
+ --fix Rebuild index.json from the tree before checking (heals drift).
767
+ --json Emit { ok, errors, warnings } as JSON (stable shape for agents/CI).
621
768
  `;