doctrina-cli 0.2.2 → 0.4.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 +20 -2
- package/package.json +1 -1
- package/src/commands/change.js +91 -18
- package/src/commands/contract.js +254 -0
- package/src/commands/coverage.js +179 -0
- package/src/commands/decision.js +83 -1
- package/src/commands/index-rebuild.js +9 -0
- package/src/commands/init.js +7 -1
- package/src/commands/intake.js +32 -8
- package/src/commands/search.js +42 -7
- package/src/commands/spec.js +16 -5
- package/src/commands/templates.js +1 -1
- package/src/commands/trace.js +157 -0
- package/src/commands/validate.js +149 -2
- package/src/commands/verify.js +153 -0
- package/src/commands/work.js +36 -13
- package/src/index.js +14 -16
- package/src/lib/clarity.js +49 -0
- package/src/lib/index-json.js +17 -2
- package/src/lib/scan.js +37 -3
- package/src/lib/version.js +21 -0
- package/templates/change/proposal.md.template +10 -1
- package/templates/contract.md.template +58 -0
- package/templates/decision.md.template +18 -0
- package/templates/doctrina/index.json.template +1 -1
- package/templates/doctrina/product.md.template +18 -1
- package/templates/spec.md.template +45 -3
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
> CLI for the Doctrina framework. The package is published as
|
|
4
4
|
> `doctrina-cli`; the executable it installs is `doctrina`.
|
|
5
5
|
|
|
6
|
-
**Status:** v0.
|
|
6
|
+
**Status:** v0.4.0 — current release. Zero runtime dependencies
|
|
7
7
|
(Node.js standard library only).
|
|
8
8
|
|
|
9
9
|
## Install
|
|
@@ -20,14 +20,19 @@ Requires Node.js 20.12 or later.
|
|
|
20
20
|
|
|
21
21
|
```
|
|
22
22
|
doctrina init scaffold AGENTS.md + .doctrina/ in cwd
|
|
23
|
+
doctrina intake [<file>] store the full description + bootstrap playbook (clarification gate)
|
|
24
|
+
doctrina work "<prompt>" brief prompt -> scaffolded change + work playbook (clarification gate)
|
|
23
25
|
doctrina spec new <capability> create a capability spec
|
|
24
26
|
doctrina spec list list specs with version, status, size
|
|
25
27
|
doctrina change new <id> "<title>" open a change proposal
|
|
26
28
|
doctrina change apply <id> apply spec deltas (ADDED/REMOVED auto)
|
|
27
29
|
doctrina change archive <id> archive an applied change
|
|
28
30
|
doctrina change diff <id> preview spec deltas (line diff for MODIFIED)
|
|
31
|
+
doctrina contract new <name> own the integration surface (ports, env, interfaces)
|
|
32
|
+
doctrina contract check verify port collisions, env drift, referenced specs
|
|
29
33
|
doctrina decision new "<title>" create the next sequential ADR
|
|
30
34
|
doctrina decision accept <num> flip a proposed ADR to accepted
|
|
35
|
+
doctrina decision land <num> [path...] stamp an accepted ADR as implemented (non-mutating Landed:)
|
|
31
36
|
doctrina decision supersede <num> "<t>" supersede an existing ADR
|
|
32
37
|
doctrina decision list list ADRs with status, date, title
|
|
33
38
|
doctrina skill new <name> scaffold an on-demand procedural-memory skill
|
|
@@ -36,8 +41,11 @@ doctrina skill sync mirror skill frontmatter descriptions
|
|
|
36
41
|
doctrina analyze <change-id> inspect a change folder before applying
|
|
37
42
|
doctrina clarify <path> smell-test a Markdown file for ambiguity (--all: tree)
|
|
38
43
|
doctrina context [<capability>] print the context pack in read order (--concat)
|
|
39
|
-
doctrina search <term> [...] search artifacts, grouped by category
|
|
44
|
+
doctrina search <term> [...] search artifacts, grouped by category (ranked)
|
|
40
45
|
doctrina validate schema + artifact-existence checks
|
|
46
|
+
doctrina coverage acceptance criteria with linked evidence (--strict gates)
|
|
47
|
+
doctrina trace intent provenance: product intent -> specs (--strict gates)
|
|
48
|
+
doctrina verify run project typecheck/test/build checks (the real gate)
|
|
41
49
|
doctrina templates list enumerate shipped templates
|
|
42
50
|
doctrina templates check compare project against recommended template shape
|
|
43
51
|
doctrina templates update additive fixer for check findings (preview; --write applies)
|
|
@@ -109,9 +117,19 @@ For each spec delta found under `.doctrina/changes/<id>/specs/`:
|
|
|
109
117
|
16. Each skill's frontmatter description matches `index.json` (drift warn; `skill sync` fixes).
|
|
110
118
|
17. EARS grammar shape per section in specs declaring `## Requirements (EARS)` (warn).
|
|
111
119
|
18. Nested `AGENTS.md` files obey the root size caps (warn > 150, error > 200).
|
|
120
|
+
19. No two ADR files share the same `NNNN` number (merge-collision error).
|
|
121
|
+
20. `index.json` records a `framework_version` matching the running CLI
|
|
122
|
+
(stamp-divergence warn; `index rebuild` migrates it).
|
|
123
|
+
21. Accepted ADRs anchor to reality via `Evidence:` and/or `Landed:`; cited
|
|
124
|
+
paths must exist on disk (drift warn).
|
|
112
125
|
|
|
113
126
|
Exit code: 0 if no errors (warnings allowed), 1 otherwise.
|
|
114
127
|
|
|
128
|
+
Two related read-only reports gate separately: `doctrina coverage`
|
|
129
|
+
(each acceptance criterion cites a real test) and `doctrina trace`
|
|
130
|
+
(each product intent has a capability, each capability traces to an
|
|
131
|
+
intent). Both exit 1 under `--strict` for CI.
|
|
132
|
+
|
|
115
133
|
## Tests
|
|
116
134
|
|
|
117
135
|
```
|
package/package.json
CHANGED
package/src/commands/change.js
CHANGED
|
@@ -144,6 +144,7 @@ function changeApply(args, _flags) {
|
|
|
144
144
|
// Flip proposal status from "proposed" to "applied" when fully successful.
|
|
145
145
|
// Zero-delta changes are trivially successful (no work to fail), so they
|
|
146
146
|
// also flip — otherwise metadata-only changes are stuck at "proposed".
|
|
147
|
+
let flippedToApplied = false;
|
|
147
148
|
if (errors === 0 && manual === 0) {
|
|
148
149
|
const proposalPath = path.join(changeDir, "proposal.md");
|
|
149
150
|
if (exists(proposalPath)) {
|
|
@@ -155,30 +156,41 @@ function changeApply(args, _flags) {
|
|
|
155
156
|
if (updated !== txt) {
|
|
156
157
|
write(proposalPath, updated, { force: true });
|
|
157
158
|
console.log(c.green("status") + " proposal.md → applied");
|
|
159
|
+
flippedToApplied = true;
|
|
158
160
|
}
|
|
159
161
|
}
|
|
160
162
|
}
|
|
161
163
|
|
|
162
|
-
// Update index
|
|
163
|
-
|
|
164
|
+
// Update the index when specs changed (ADDED/REMOVED) and/or the proposal
|
|
165
|
+
// flipped to applied. The change entry's status must mirror the proposal —
|
|
166
|
+
// the same value `index rebuild` derives from the file — or the index
|
|
167
|
+
// drifts from the tree in the apply→archive window (caught by
|
|
168
|
+
// `index rebuild --check`, e.g. in the pre-commit hook).
|
|
169
|
+
if (writes > 0 || flippedToApplied) {
|
|
164
170
|
const index = idx.load(projectRoot);
|
|
165
|
-
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
171
|
+
if (writes > 0) {
|
|
172
|
+
for (const deltaPath of deltaFiles) {
|
|
173
|
+
const text = read(deltaPath);
|
|
174
|
+
const op = parseOperation(text);
|
|
175
|
+
const capability = parseCapabilityFromDelta(text, deltaPath);
|
|
176
|
+
if (!capability) continue;
|
|
177
|
+
if (op === "ADDED") {
|
|
178
|
+
idx.addSpec(index, {
|
|
179
|
+
id: capability,
|
|
180
|
+
path: `.doctrina/specs/${capability}/spec.md`,
|
|
181
|
+
status: "active",
|
|
182
|
+
version: "0.1.0",
|
|
183
|
+
last_updated: date,
|
|
184
|
+
});
|
|
185
|
+
} else if (op === "REMOVED") {
|
|
186
|
+
idx.removeSpec(index, capability);
|
|
187
|
+
}
|
|
180
188
|
}
|
|
181
189
|
}
|
|
190
|
+
if (flippedToApplied) {
|
|
191
|
+
const entry = index.artifacts.changes.find((ch) => ch.id === id);
|
|
192
|
+
if (entry) entry.status = "applied";
|
|
193
|
+
}
|
|
182
194
|
idx.touch(index, date);
|
|
183
195
|
idx.save(projectRoot, index);
|
|
184
196
|
}
|
|
@@ -193,7 +205,7 @@ function changeApply(args, _flags) {
|
|
|
193
205
|
return errors > 0 ? 1 : 0;
|
|
194
206
|
}
|
|
195
207
|
|
|
196
|
-
function changeArchive(args,
|
|
208
|
+
function changeArchive(args, flags) {
|
|
197
209
|
const id = args[0];
|
|
198
210
|
if (!id) {
|
|
199
211
|
console.error(c.red("error:") + " change archive requires <id>");
|
|
@@ -208,6 +220,28 @@ function changeArchive(args, _flags) {
|
|
|
208
220
|
return 1;
|
|
209
221
|
}
|
|
210
222
|
|
|
223
|
+
// Verification gate. "Done" is a claim until it is checked. Archiving is
|
|
224
|
+
// the act of declaring a change finished, so it must refuse while any
|
|
225
|
+
// task (including closing steps) or any declared verification item is
|
|
226
|
+
// still unchecked. This is the difference between "boxes marked" and
|
|
227
|
+
// "verification passed" the framework was faulted for collapsing.
|
|
228
|
+
// --force is the escape hatch: it archives anyway and records the gap.
|
|
229
|
+
const force = flagBool(flags, "force", false);
|
|
230
|
+
const blockers = collectArchiveBlockers(changeDir);
|
|
231
|
+
if (blockers.length > 0) {
|
|
232
|
+
if (!force) {
|
|
233
|
+
console.error(c.red("error:") + ` refusing to archive "${id}" — verification incomplete:`);
|
|
234
|
+
for (const b of blockers) console.error(` - ${b}`);
|
|
235
|
+
console.error(
|
|
236
|
+
c.gray("hint: ") +
|
|
237
|
+
"finish and check the items, or pass --force to archive anyway (records the gap)",
|
|
238
|
+
);
|
|
239
|
+
return 1;
|
|
240
|
+
}
|
|
241
|
+
console.log(c.yellow("warn:") + ` archiving "${id}" with verification incomplete (--force):`);
|
|
242
|
+
for (const b of blockers) console.log(c.yellow(" - ") + b);
|
|
243
|
+
}
|
|
244
|
+
|
|
211
245
|
const date = today();
|
|
212
246
|
const archiveName = `${date}-${id}`;
|
|
213
247
|
const archiveDir = path.join(projectRoot, ".doctrina", "changes", "archive", archiveName);
|
|
@@ -364,6 +398,45 @@ function extractDeltaBody(text) {
|
|
|
364
398
|
return text.slice(idxSep + 5).replace(/^\n+/, "");
|
|
365
399
|
}
|
|
366
400
|
|
|
401
|
+
// Reasons a change is not finished enough to archive. Counts unchecked
|
|
402
|
+
// GitHub-style checkboxes (`- [ ]`) in tasks.md (every task, including the
|
|
403
|
+
// closing steps) and in the proposal's "## Verification" section. Returns
|
|
404
|
+
// a list of human-readable blocker strings; empty means clear to archive.
|
|
405
|
+
function collectArchiveBlockers(changeDir) {
|
|
406
|
+
const blockers = [];
|
|
407
|
+
const countUnchecked = (s) => (s.match(/^\s*-\s*\[ \]/gm) ?? []).length;
|
|
408
|
+
|
|
409
|
+
const tasksPath = path.join(changeDir, "tasks.md");
|
|
410
|
+
if (exists(tasksPath)) {
|
|
411
|
+
const n = countUnchecked(read(tasksPath));
|
|
412
|
+
if (n > 0) blockers.push(`${n} unchecked task${n === 1 ? "" : "s"} in tasks.md (closing steps count)`);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const proposalPath = path.join(changeDir, "proposal.md");
|
|
416
|
+
if (exists(proposalPath)) {
|
|
417
|
+
const n = countUnchecked(extractSection(read(proposalPath), "Verification"));
|
|
418
|
+
if (n > 0) blockers.push(`${n} unmet verification item${n === 1 ? "" : "s"} in proposal.md (## Verification)`);
|
|
419
|
+
}
|
|
420
|
+
return blockers;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Return the body of a "## <name>" section (lines after the heading up to
|
|
424
|
+
// the next "## " heading). Empty string when the section is absent.
|
|
425
|
+
function extractSection(text, name) {
|
|
426
|
+
const lines = text.split(/\r?\n/);
|
|
427
|
+
const head = new RegExp(`^##\\s+${name}\\b`, "i");
|
|
428
|
+
let inSection = false;
|
|
429
|
+
const out = [];
|
|
430
|
+
for (const line of lines) {
|
|
431
|
+
if (/^##\s+/.test(line)) {
|
|
432
|
+
inSection = head.test(line);
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
if (inSection) out.push(line);
|
|
436
|
+
}
|
|
437
|
+
return out.join("\n");
|
|
438
|
+
}
|
|
439
|
+
|
|
367
440
|
function ensureDoctrinaProject(projectRoot) {
|
|
368
441
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
369
442
|
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { readdirSync } from "node:fs";
|
|
4
|
+
import { exists, isDir, isFile, read, relPath, write } from "../lib/fs-ops.js";
|
|
5
|
+
import { locateTemplatesDir, substitute } from "../lib/templates.js";
|
|
6
|
+
import { specHeader } from "../lib/scan.js";
|
|
7
|
+
import * as idx from "../lib/index-json.js";
|
|
8
|
+
import { today } from "../lib/dates.js";
|
|
9
|
+
import { flagBool } from "../lib/args.js";
|
|
10
|
+
import { c } from "../lib/colors.js";
|
|
11
|
+
import { suggest } from "../lib/suggest.js";
|
|
12
|
+
|
|
13
|
+
// Contracts are the first-class home for the integration/runtime surface
|
|
14
|
+
// no single capability owns: the port map, the env/dependency contract,
|
|
15
|
+
// and the API/WS/event interface shapes. The criticism this answers: each
|
|
16
|
+
// capability was built against its own spec in isolation, and the seams
|
|
17
|
+
// between them fell in the gap with nobody accountable. `contract check`
|
|
18
|
+
// turns the parts that are mechanically verifiable (port collisions, env
|
|
19
|
+
// drift vs .env.example, referenced specs that must exist) into a gate.
|
|
20
|
+
|
|
21
|
+
const SUBCOMMANDS = ["new", "list", "check"];
|
|
22
|
+
|
|
23
|
+
export async function run(positional, flags) {
|
|
24
|
+
const sub = positional[0];
|
|
25
|
+
switch (sub) {
|
|
26
|
+
case "new":
|
|
27
|
+
return contractNew(positional.slice(1), flags);
|
|
28
|
+
case "list":
|
|
29
|
+
return contractList();
|
|
30
|
+
case "check":
|
|
31
|
+
return contractCheck(positional.slice(1), flags);
|
|
32
|
+
default:
|
|
33
|
+
console.error(c.red("error:") + ` unknown contract subcommand "${sub ?? ""}"`);
|
|
34
|
+
const guess = suggest(sub, SUBCOMMANDS);
|
|
35
|
+
console.error(c.gray("hint: ") + (guess
|
|
36
|
+
? `did you mean \`doctrina contract ${guess}\`?`
|
|
37
|
+
: `available: ${SUBCOMMANDS.join(", ")}`));
|
|
38
|
+
return 2;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function contractNew(args, flags) {
|
|
43
|
+
const name = args[0];
|
|
44
|
+
if (!name || !/^[a-z][a-z0-9-]*$/.test(name)) {
|
|
45
|
+
console.error(c.red("error:") + " contract name must be lowercase letters, digits, or hyphens (e.g. \"system\", \"api\").");
|
|
46
|
+
return 2;
|
|
47
|
+
}
|
|
48
|
+
const force = flagBool(flags, "force", false);
|
|
49
|
+
const projectRoot = process.cwd();
|
|
50
|
+
ensureDoctrinaProject(projectRoot);
|
|
51
|
+
|
|
52
|
+
const targetPath = path.join(projectRoot, ".doctrina", "contracts", `${name}.md`);
|
|
53
|
+
if (exists(targetPath) && !force) {
|
|
54
|
+
console.error(c.red("error:") + ` ${relPath(projectRoot, targetPath)} already exists (pass --force to overwrite)`);
|
|
55
|
+
return 1;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const templatesDir = locateTemplatesDir();
|
|
59
|
+
const date = today();
|
|
60
|
+
const body = substitute(read(path.join(templatesDir, "contract.md.template")), { CONTRACT_NAME: name, DATE: date });
|
|
61
|
+
write(targetPath, body, { force });
|
|
62
|
+
console.log(c.green("created") + ` ${relPath(projectRoot, targetPath)}`);
|
|
63
|
+
|
|
64
|
+
const index = idx.load(projectRoot);
|
|
65
|
+
idx.addContract(index, {
|
|
66
|
+
id: name,
|
|
67
|
+
path: `.doctrina/contracts/${name}.md`,
|
|
68
|
+
status: "active",
|
|
69
|
+
last_updated: date,
|
|
70
|
+
});
|
|
71
|
+
idx.touch(index, date);
|
|
72
|
+
idx.save(projectRoot, index);
|
|
73
|
+
console.log(c.green("indexed") + ` contract "${name}"`);
|
|
74
|
+
console.log("");
|
|
75
|
+
console.log("Fill in the Ports / Environment / Interfaces tables, then run " + c.cyan("doctrina contract check") + ".");
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function contractList() {
|
|
80
|
+
const projectRoot = process.cwd();
|
|
81
|
+
ensureDoctrinaProject(projectRoot);
|
|
82
|
+
const dir = path.join(projectRoot, ".doctrina", "contracts");
|
|
83
|
+
const rows = [];
|
|
84
|
+
if (isDir(dir)) {
|
|
85
|
+
for (const f of readdirSync(dir).sort()) {
|
|
86
|
+
if (!f.endsWith(".md")) continue;
|
|
87
|
+
const text = read(path.join(dir, f));
|
|
88
|
+
rows.push({ id: f.replace(/\.md$/, ""), status: specHeader(text, "Status") ?? "active", updated: specHeader(text, "Last updated") ?? "—" });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (rows.length === 0) {
|
|
92
|
+
console.log(c.gray("no contracts found in .doctrina/contracts/ (create one with `doctrina contract new <name>`)"));
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
console.log(c.bold("Contracts:"));
|
|
96
|
+
console.log("");
|
|
97
|
+
for (const r of rows) console.log(` ${c.cyan(r.id.padEnd(20))} ${r.status.padEnd(10)} ${r.updated}`);
|
|
98
|
+
console.log("");
|
|
99
|
+
console.log(c.gray(`${rows.length} contract${rows.length === 1 ? "" : "s"}`));
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function contractCheck(args, _flags) {
|
|
104
|
+
const projectRoot = process.cwd();
|
|
105
|
+
ensureDoctrinaProject(projectRoot);
|
|
106
|
+
const dir = path.join(projectRoot, ".doctrina", "contracts");
|
|
107
|
+
|
|
108
|
+
let names;
|
|
109
|
+
if (args[0]) {
|
|
110
|
+
names = [args[0].replace(/\.md$/, "")];
|
|
111
|
+
} else if (isDir(dir)) {
|
|
112
|
+
names = readdirSync(dir).filter((f) => f.endsWith(".md")).map((f) => f.replace(/\.md$/, "")).sort();
|
|
113
|
+
} else {
|
|
114
|
+
names = [];
|
|
115
|
+
}
|
|
116
|
+
if (names.length === 0) {
|
|
117
|
+
console.log(c.gray("no contracts to check in .doctrina/contracts/"));
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The env contract is checked against .env.example when one exists.
|
|
122
|
+
const envExamplePath = path.join(projectRoot, ".env.example");
|
|
123
|
+
const envExample = isFile(envExamplePath) ? read(envExamplePath) : null;
|
|
124
|
+
|
|
125
|
+
let errors = 0;
|
|
126
|
+
let warnings = 0;
|
|
127
|
+
for (const name of names) {
|
|
128
|
+
const file = path.join(dir, `${name}.md`);
|
|
129
|
+
if (!isFile(file)) {
|
|
130
|
+
console.log(c.red("error: ") + `contract "${name}" not found at ${relPath(projectRoot, file)}`);
|
|
131
|
+
errors += 1;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const text = read(file);
|
|
135
|
+
console.log(c.bold(name) + c.gray(` (${relPath(projectRoot, file)})`));
|
|
136
|
+
|
|
137
|
+
// 1. Port collisions — two services must not claim the same port.
|
|
138
|
+
const ports = parseTable(sectionOf(text, "Ports"));
|
|
139
|
+
if (ports) {
|
|
140
|
+
const portCol = colIndex(ports.headers, "port");
|
|
141
|
+
const svcCol = colIndex(ports.headers, "service");
|
|
142
|
+
const seen = new Map();
|
|
143
|
+
for (const row of ports.rows) {
|
|
144
|
+
const port = (row[portCol] ?? "").trim();
|
|
145
|
+
if (!/^\d+$/.test(port)) continue;
|
|
146
|
+
const svc = (row[svcCol] ?? "?").trim();
|
|
147
|
+
if (seen.has(port)) {
|
|
148
|
+
console.log(c.red(" ✗ ") + `port ${port} is claimed by both "${seen.get(port)}" and "${svc}"`);
|
|
149
|
+
errors += 1;
|
|
150
|
+
} else {
|
|
151
|
+
seen.set(port, svc);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 2. Environment drift — every declared variable must exist in
|
|
157
|
+
// .env.example (when present), so code, example, and infra agree.
|
|
158
|
+
const env = parseTable(sectionOf(text, "Environment"));
|
|
159
|
+
if (env && envExample !== null) {
|
|
160
|
+
const varCol = colIndex(env.headers, "variable");
|
|
161
|
+
for (const row of env.rows) {
|
|
162
|
+
const name2 = (row[varCol] ?? "").trim();
|
|
163
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(name2)) continue;
|
|
164
|
+
const declared = new RegExp(`^\\s*(export\\s+)?${name2}\\s*=`, "m").test(envExample);
|
|
165
|
+
if (!declared) {
|
|
166
|
+
console.log(c.yellow(" ! ") + `env var ${name2} is in the contract but absent from .env.example`);
|
|
167
|
+
warnings += 1;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 3. Referenced capability specs must exist.
|
|
173
|
+
for (const refCap of referencedCapabilities(sectionOf(text, "References"))) {
|
|
174
|
+
const specPath = path.join(projectRoot, ".doctrina", "specs", refCap, "spec.md");
|
|
175
|
+
if (!isFile(specPath)) {
|
|
176
|
+
console.log(c.red(" ✗ ") + `references spec "${refCap}" but ${relPath(projectRoot, specPath)} does not exist`);
|
|
177
|
+
errors += 1;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
console.log("");
|
|
183
|
+
if (errors === 0 && warnings === 0) {
|
|
184
|
+
console.log(c.green("ok") + ` ${names.length} contract${names.length === 1 ? "" : "s"} consistent`);
|
|
185
|
+
return 0;
|
|
186
|
+
}
|
|
187
|
+
const summary = `${errors} error${errors === 1 ? "" : "s"}, ${warnings} warning${warnings === 1 ? "" : "s"}`;
|
|
188
|
+
console.log((errors === 0 ? c.green("ok") : c.red("fail")) + " " + summary);
|
|
189
|
+
return errors === 0 ? 0 : 1;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Return the body of a "## <name>" section, up to the next "## " heading.
|
|
193
|
+
function sectionOf(text, name) {
|
|
194
|
+
const lines = text.split(/\r?\n/);
|
|
195
|
+
const head = new RegExp(`^##\\s+${name}\\b`, "i");
|
|
196
|
+
let inSection = false;
|
|
197
|
+
const out = [];
|
|
198
|
+
for (const line of lines) {
|
|
199
|
+
if (/^##\s+/.test(line)) {
|
|
200
|
+
inSection = head.test(line);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (inSection) out.push(line);
|
|
204
|
+
}
|
|
205
|
+
return out.join("\n");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Parse a GitHub-flavoured Markdown table into { headers, rows }.
|
|
209
|
+
function parseTable(text) {
|
|
210
|
+
const lines = text.split(/\r?\n/).filter((l) => /^\s*\|/.test(l));
|
|
211
|
+
if (lines.length < 2) return null;
|
|
212
|
+
const cells = (line) => line.trim().replace(/^\||\|$/g, "").split("|").map((cl) => cl.trim());
|
|
213
|
+
const headers = cells(lines[0]);
|
|
214
|
+
const rows = [];
|
|
215
|
+
for (let i = 1; i < lines.length; i++) {
|
|
216
|
+
const row = cells(lines[i]);
|
|
217
|
+
if (row.every((cl) => /^:?-+:?$/.test(cl) || cl === "")) continue; // separator row
|
|
218
|
+
rows.push(row);
|
|
219
|
+
}
|
|
220
|
+
return { headers, rows };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function colIndex(headers, name) {
|
|
224
|
+
const i = headers.findIndex((h) => h.toLowerCase() === name.toLowerCase());
|
|
225
|
+
return i < 0 ? 0 : i;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Capability names referenced as `specs/<cap>` (placeholder <...> ignored).
|
|
229
|
+
function referencedCapabilities(text) {
|
|
230
|
+
const out = new Set();
|
|
231
|
+
for (const m of text.matchAll(/specs\/([a-z][a-z0-9-]*)/g)) out.add(m[1]);
|
|
232
|
+
return [...out];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function ensureDoctrinaProject(projectRoot) {
|
|
236
|
+
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
237
|
+
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export const help = `
|
|
242
|
+
Usage: doctrina contract <subcommand> [args]
|
|
243
|
+
|
|
244
|
+
Own the integration/runtime surface no single capability spec owns:
|
|
245
|
+
the port map, the environment contract, and API/WS/event interfaces.
|
|
246
|
+
|
|
247
|
+
Subcommands:
|
|
248
|
+
new <name> Scaffold .doctrina/contracts/<name>.md and index it
|
|
249
|
+
list One line per contract: id, status, last updated
|
|
250
|
+
check [<name>] Verify the mechanically checkable parts: no two
|
|
251
|
+
services share a port, every declared env var exists
|
|
252
|
+
in .env.example, and every referenced spec exists.
|
|
253
|
+
Exits 1 on errors (port collisions, missing specs).
|
|
254
|
+
`;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { readdirSync } from "node:fs";
|
|
4
|
+
import { exists, isDir, isFile, read, relPath } from "../lib/fs-ops.js";
|
|
5
|
+
import { flagBool } from "../lib/args.js";
|
|
6
|
+
import { c } from "../lib/colors.js";
|
|
7
|
+
|
|
8
|
+
// Traceability report: how many acceptance criteria cite an artifact or
|
|
9
|
+
// test that actually exists on disk. Doctrina otherwise has no link
|
|
10
|
+
// between what a spec promises and what proves it — this command makes
|
|
11
|
+
// that link a first-class, measurable signal (the gap the framework was
|
|
12
|
+
// criticised for: validating form, never truth).
|
|
13
|
+
//
|
|
14
|
+
// Convention: each numbered acceptance criterion may cite its evidence as
|
|
15
|
+
// a backtick path span, e.g.
|
|
16
|
+
// 1. Returns HTTP 429 above the quota — verified by `test/quota.test.ts`.
|
|
17
|
+
// A criterion is "covered" when at least one cited path resolves to a
|
|
18
|
+
// file. A cited path that does not resolve is "dangling" (broken
|
|
19
|
+
// traceability) and reported separately. Criteria with no cited path are
|
|
20
|
+
// "bare" — the spec promises something nothing is shown to satisfy.
|
|
21
|
+
|
|
22
|
+
export async function run(_positional, flags) {
|
|
23
|
+
const projectRoot = process.cwd();
|
|
24
|
+
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
25
|
+
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
26
|
+
}
|
|
27
|
+
const strict = flagBool(flags, "strict", false);
|
|
28
|
+
|
|
29
|
+
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
30
|
+
const reports = [];
|
|
31
|
+
if (isDir(specsDir)) {
|
|
32
|
+
for (const cap of readdirSync(specsDir).sort()) {
|
|
33
|
+
const specPath = path.join(specsDir, cap, "spec.md");
|
|
34
|
+
if (!isFile(specPath)) continue;
|
|
35
|
+
const criteria = extractAcceptanceCriteria(read(specPath));
|
|
36
|
+
if (criteria.length === 0) continue;
|
|
37
|
+
const rows = criteria.map((crit, i) => classify(crit, i + 1, projectRoot, path.dirname(specPath)));
|
|
38
|
+
reports.push({ cap, specPath, rows });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (reports.length === 0) {
|
|
43
|
+
console.log(c.gray("no acceptance criteria found under .doctrina/specs/"));
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let totalCriteria = 0;
|
|
48
|
+
let totalCovered = 0;
|
|
49
|
+
let totalDangling = 0;
|
|
50
|
+
|
|
51
|
+
console.log(c.bold("Coverage") + c.gray(" — acceptance criteria with linked evidence:"));
|
|
52
|
+
console.log("");
|
|
53
|
+
|
|
54
|
+
for (const rep of reports) {
|
|
55
|
+
const covered = rep.rows.filter((r) => r.kind === "covered").length;
|
|
56
|
+
const dangling = rep.rows.filter((r) => r.kind === "dangling").length;
|
|
57
|
+
totalCriteria += rep.rows.length;
|
|
58
|
+
totalCovered += covered;
|
|
59
|
+
totalDangling += dangling;
|
|
60
|
+
|
|
61
|
+
const danglingNote = dangling > 0 ? c.yellow(` (${dangling} dangling)`) : "";
|
|
62
|
+
console.log(` ${c.cyan(rep.cap.padEnd(20))} ${covered}/${rep.rows.length} criteria${danglingNote}`);
|
|
63
|
+
for (const r of rep.rows) {
|
|
64
|
+
if (r.kind === "covered") continue;
|
|
65
|
+
if (r.kind === "bare") {
|
|
66
|
+
console.log(` ${c.red("✗")} #${r.n} no evidence linked — cite the file/test that proves it in backticks`);
|
|
67
|
+
} else {
|
|
68
|
+
console.log(` ${c.yellow("!")} #${r.n} evidence not found on disk: ${r.missing.map((m) => `\`${m}\``).join(", ")}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const pct = totalCriteria === 0 ? 100 : Math.round((totalCovered / totalCriteria) * 100);
|
|
74
|
+
console.log("");
|
|
75
|
+
const summary = `${totalCovered} of ${totalCriteria} acceptance criteria across ${reports.length} spec${reports.length === 1 ? "" : "s"} have linked evidence (${pct}%)`;
|
|
76
|
+
const clean = totalCovered === totalCriteria && totalDangling === 0;
|
|
77
|
+
if (clean) {
|
|
78
|
+
console.log(c.green("ok") + " " + summary);
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
const danglingSummary = totalDangling > 0 ? `, ${totalDangling} dangling` : "";
|
|
82
|
+
console.log((strict ? c.red("fail") : c.yellow("gap")) + " " + summary + danglingSummary);
|
|
83
|
+
// A report by default (exit 0); a gate under --strict (exit 1 for CI).
|
|
84
|
+
return strict ? 1 : 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Pull the numbered items out of the "## Acceptance criteria" section.
|
|
88
|
+
// Each item may span multiple lines (continuation prose); accumulate until
|
|
89
|
+
// the next number or the next "## " heading. Returns an array of strings.
|
|
90
|
+
function extractAcceptanceCriteria(text) {
|
|
91
|
+
const lines = text.split(/\r?\n/);
|
|
92
|
+
const out = [];
|
|
93
|
+
let inSection = false;
|
|
94
|
+
let buf = null;
|
|
95
|
+
const flush = () => {
|
|
96
|
+
if (buf !== null) out.push(buf.trim());
|
|
97
|
+
buf = null;
|
|
98
|
+
};
|
|
99
|
+
for (const line of lines) {
|
|
100
|
+
if (/^##\s+/.test(line)) {
|
|
101
|
+
// Entering or leaving a section.
|
|
102
|
+
if (inSection) {
|
|
103
|
+
flush();
|
|
104
|
+
inSection = false;
|
|
105
|
+
}
|
|
106
|
+
if (/^##\s+Acceptance criteria\b/i.test(line)) inSection = true;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (!inSection) continue;
|
|
110
|
+
if (/^\s*\d+\.\s+/.test(line)) {
|
|
111
|
+
flush();
|
|
112
|
+
buf = line.replace(/^\s*\d+\.\s+/, "");
|
|
113
|
+
} else if (buf !== null) {
|
|
114
|
+
// Continuation line of the current criterion.
|
|
115
|
+
if (line.trim() === "") buf += " ";
|
|
116
|
+
else buf += " " + line.trim();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
flush();
|
|
120
|
+
// Drop empty placeholder items (a lone "1." with no text).
|
|
121
|
+
return out.filter((s) => s.length > 0);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Decide whether a single criterion is covered, dangling, or bare.
|
|
125
|
+
function classify(criterion, n, projectRoot, specDir) {
|
|
126
|
+
const cited = extractBacktickPaths(criterion);
|
|
127
|
+
if (cited.length === 0) return { kind: "bare", n };
|
|
128
|
+
const missing = [];
|
|
129
|
+
let resolved = 0;
|
|
130
|
+
for (const token of cited) {
|
|
131
|
+
const candidates = [
|
|
132
|
+
path.resolve(projectRoot, token),
|
|
133
|
+
path.resolve(specDir, token),
|
|
134
|
+
];
|
|
135
|
+
if (candidates.some(exists)) resolved += 1;
|
|
136
|
+
else missing.push(token);
|
|
137
|
+
}
|
|
138
|
+
if (resolved > 0) return { kind: "covered", n };
|
|
139
|
+
return { kind: "dangling", n, missing };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Backtick spans that look like repository file paths. Mirrors the
|
|
143
|
+
// validate stale-link heuristic: a token is a path if it has a slash or a
|
|
144
|
+
// file extension and is not a URL/placeholder.
|
|
145
|
+
function extractBacktickPaths(text) {
|
|
146
|
+
const out = new Set();
|
|
147
|
+
for (const m of text.matchAll(/`([^`]+)`/g)) {
|
|
148
|
+
const token = m[1].trim();
|
|
149
|
+
if (looksLikePath(token)) out.add(token);
|
|
150
|
+
}
|
|
151
|
+
return [...out];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function looksLikePath(s) {
|
|
155
|
+
if (!s) return false;
|
|
156
|
+
if (/^(https?:|mailto:|ftp:|#|@)/i.test(s)) return false;
|
|
157
|
+
if (/[\s<>*?{}]/.test(s)) return false;
|
|
158
|
+
const hasSlash = s.includes("/");
|
|
159
|
+
const hasExt = /\.[a-z0-9]{1,8}$/i.test(s);
|
|
160
|
+
if (!hasSlash && !hasExt) return false;
|
|
161
|
+
if (s.startsWith("-")) return false;
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export const help = `
|
|
166
|
+
Usage: doctrina coverage [--strict]
|
|
167
|
+
|
|
168
|
+
Report how many acceptance criteria across .doctrina/specs/ cite an
|
|
169
|
+
artifact or test that exists on disk. Each numbered criterion may cite
|
|
170
|
+
its evidence as a backtick path span, e.g. \`test/quota.test.ts\`.
|
|
171
|
+
|
|
172
|
+
A criterion is covered when at least one cited path resolves to a file,
|
|
173
|
+
dangling when a cited path is missing on disk, and bare when nothing is
|
|
174
|
+
cited. Read-only.
|
|
175
|
+
|
|
176
|
+
Flags:
|
|
177
|
+
--strict Exit 1 when any criterion is bare or dangling (CI gate).
|
|
178
|
+
Without it the command always exits 0 (a report).
|
|
179
|
+
`;
|