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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/commands/analyze.js +8 -2
- package/src/commands/change.js +78 -7
- package/src/commands/clarify.js +76 -4
- package/src/commands/close.js +42 -9
- package/src/commands/completion.js +116 -0
- package/src/commands/constitution.js +6 -4
- package/src/commands/context.js +118 -25
- package/src/commands/coverage.js +185 -44
- package/src/commands/doctor.js +135 -0
- package/src/commands/handoff.js +94 -0
- package/src/commands/init.js +12 -1
- package/src/commands/intent.js +173 -0
- package/src/commands/next.js +32 -16
- package/src/commands/prime.js +118 -0
- package/src/commands/report.js +142 -0
- package/src/commands/review.js +25 -4
- package/src/commands/show.js +173 -0
- package/src/commands/skill.js +46 -7
- package/src/commands/spec.js +13 -3
- package/src/commands/status.js +70 -40
- package/src/commands/trace.js +15 -2
- package/src/commands/upgrade.js +129 -0
- package/src/commands/validate.js +150 -3
- package/src/commands/why.js +83 -10
- package/src/commands/work.js +45 -16
- package/src/index.js +20 -42
- package/src/lib/commands.js +76 -4
- package/src/lib/scan.js +16 -0
- package/templates/AGENTS.md.template +8 -4
- package/templates/README.md +7 -1
- package/templates/adapters/aider/CONVENTIONS.md.template +4 -3
- package/templates/spec.md.template +9 -1
package/src/commands/status.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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 (
|
|
42
|
+
if (s.indexState === "missing") {
|
|
46
43
|
driftLine = c.red("missing/unreadable") + c.gray(" — run `doctrina index rebuild`");
|
|
47
|
-
} else if (
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 (
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
94
|
-
|
|
95
|
-
console.log(` ${"
|
|
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 (
|
|
100
|
-
if (
|
|
101
|
-
console.log(` ${"decisions".padEnd(11)} ${
|
|
102
|
-
|
|
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.
|
package/src/commands/trace.js
CHANGED
|
@@ -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).
|
|
187
|
-
that
|
|
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
|
+
`;
|
package/src/commands/validate.js
CHANGED
|
@@ -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)
|
|
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
|
`;
|