doctrina-cli 0.2.2 → 0.3.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/change.js +62 -1
- package/src/commands/contract.js +254 -0
- package/src/commands/coverage.js +179 -0
- package/src/commands/intake.js +16 -7
- package/src/commands/spec.js +16 -5
- package/src/commands/templates.js +1 -1
- package/src/commands/validate.js +106 -0
- package/src/commands/verify.js +153 -0
- package/src/commands/work.js +21 -10
- package/src/index.js +9 -2
- package/src/lib/index-json.js +9 -0
- package/src/lib/scan.js +29 -3
- package/templates/change/proposal.md.template +10 -1
- package/templates/contract.md.template +58 -0
- package/templates/decision.md.template +10 -0
- package/templates/doctrina/product.md.template +12 -0
- package/templates/spec.md.template +41 -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.3.0 — current release. Zero runtime dependencies
|
|
7
7
|
(Node.js standard library only).
|
|
8
8
|
|
|
9
9
|
## Install
|
package/package.json
CHANGED
package/src/commands/change.js
CHANGED
|
@@ -193,7 +193,7 @@ function changeApply(args, _flags) {
|
|
|
193
193
|
return errors > 0 ? 1 : 0;
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
-
function changeArchive(args,
|
|
196
|
+
function changeArchive(args, flags) {
|
|
197
197
|
const id = args[0];
|
|
198
198
|
if (!id) {
|
|
199
199
|
console.error(c.red("error:") + " change archive requires <id>");
|
|
@@ -208,6 +208,28 @@ function changeArchive(args, _flags) {
|
|
|
208
208
|
return 1;
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
// Verification gate. "Done" is a claim until it is checked. Archiving is
|
|
212
|
+
// the act of declaring a change finished, so it must refuse while any
|
|
213
|
+
// task (including closing steps) or any declared verification item is
|
|
214
|
+
// still unchecked. This is the difference between "boxes marked" and
|
|
215
|
+
// "verification passed" the framework was faulted for collapsing.
|
|
216
|
+
// --force is the escape hatch: it archives anyway and records the gap.
|
|
217
|
+
const force = flagBool(flags, "force", false);
|
|
218
|
+
const blockers = collectArchiveBlockers(changeDir);
|
|
219
|
+
if (blockers.length > 0) {
|
|
220
|
+
if (!force) {
|
|
221
|
+
console.error(c.red("error:") + ` refusing to archive "${id}" — verification incomplete:`);
|
|
222
|
+
for (const b of blockers) console.error(` - ${b}`);
|
|
223
|
+
console.error(
|
|
224
|
+
c.gray("hint: ") +
|
|
225
|
+
"finish and check the items, or pass --force to archive anyway (records the gap)",
|
|
226
|
+
);
|
|
227
|
+
return 1;
|
|
228
|
+
}
|
|
229
|
+
console.log(c.yellow("warn:") + ` archiving "${id}" with verification incomplete (--force):`);
|
|
230
|
+
for (const b of blockers) console.log(c.yellow(" - ") + b);
|
|
231
|
+
}
|
|
232
|
+
|
|
211
233
|
const date = today();
|
|
212
234
|
const archiveName = `${date}-${id}`;
|
|
213
235
|
const archiveDir = path.join(projectRoot, ".doctrina", "changes", "archive", archiveName);
|
|
@@ -364,6 +386,45 @@ function extractDeltaBody(text) {
|
|
|
364
386
|
return text.slice(idxSep + 5).replace(/^\n+/, "");
|
|
365
387
|
}
|
|
366
388
|
|
|
389
|
+
// Reasons a change is not finished enough to archive. Counts unchecked
|
|
390
|
+
// GitHub-style checkboxes (`- [ ]`) in tasks.md (every task, including the
|
|
391
|
+
// closing steps) and in the proposal's "## Verification" section. Returns
|
|
392
|
+
// a list of human-readable blocker strings; empty means clear to archive.
|
|
393
|
+
function collectArchiveBlockers(changeDir) {
|
|
394
|
+
const blockers = [];
|
|
395
|
+
const countUnchecked = (s) => (s.match(/^\s*-\s*\[ \]/gm) ?? []).length;
|
|
396
|
+
|
|
397
|
+
const tasksPath = path.join(changeDir, "tasks.md");
|
|
398
|
+
if (exists(tasksPath)) {
|
|
399
|
+
const n = countUnchecked(read(tasksPath));
|
|
400
|
+
if (n > 0) blockers.push(`${n} unchecked task${n === 1 ? "" : "s"} in tasks.md (closing steps count)`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const proposalPath = path.join(changeDir, "proposal.md");
|
|
404
|
+
if (exists(proposalPath)) {
|
|
405
|
+
const n = countUnchecked(extractSection(read(proposalPath), "Verification"));
|
|
406
|
+
if (n > 0) blockers.push(`${n} unmet verification item${n === 1 ? "" : "s"} in proposal.md (## Verification)`);
|
|
407
|
+
}
|
|
408
|
+
return blockers;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Return the body of a "## <name>" section (lines after the heading up to
|
|
412
|
+
// the next "## " heading). Empty string when the section is absent.
|
|
413
|
+
function extractSection(text, name) {
|
|
414
|
+
const lines = text.split(/\r?\n/);
|
|
415
|
+
const head = new RegExp(`^##\\s+${name}\\b`, "i");
|
|
416
|
+
let inSection = false;
|
|
417
|
+
const out = [];
|
|
418
|
+
for (const line of lines) {
|
|
419
|
+
if (/^##\s+/.test(line)) {
|
|
420
|
+
inSection = head.test(line);
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
if (inSection) out.push(line);
|
|
424
|
+
}
|
|
425
|
+
return out.join("\n");
|
|
426
|
+
}
|
|
427
|
+
|
|
367
428
|
function ensureDoctrinaProject(projectRoot) {
|
|
368
429
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
369
430
|
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
|
+
`;
|
package/src/commands/intake.js
CHANGED
|
@@ -110,7 +110,9 @@ export function printBootstrapPlaybook(projectRoot) {
|
|
|
110
110
|
console.log(`1. Read ${c.cyan(".doctrina/intake.md")} (raw intent) and ${c.cyan(".doctrina/product.md")}.`);
|
|
111
111
|
console.log("");
|
|
112
112
|
console.log("2. Fill every product.md section from the intake: Vision, Problem,");
|
|
113
|
-
console.log(" Target users, Scope (in/out), Non-goals, Success criteria
|
|
113
|
+
console.log(" Target users, Scope (in/out), Non-goals, Success criteria, and the");
|
|
114
|
+
console.log(" Delivery order — name the ONE end-to-end walking skeleton to build");
|
|
115
|
+
console.log(" and verify before fanning out (depth before breadth).");
|
|
114
116
|
console.log(" One fact, one home — product.md holds vision, never requirements.");
|
|
115
117
|
console.log("");
|
|
116
118
|
console.log("3. Derive the capability list: kebab-case, one per area of behaviour");
|
|
@@ -119,22 +121,29 @@ export function printBootstrapPlaybook(projectRoot) {
|
|
|
119
121
|
console.log(" then replace the template placeholders in");
|
|
120
122
|
console.log(" .doctrina/specs/<capability>/spec.md with EARS requirements derived");
|
|
121
123
|
console.log(" from the intake (Ubiquitous / Event-driven / State-driven /");
|
|
122
|
-
console.log(" Unwanted-behavior / Optional).
|
|
123
|
-
console.log("
|
|
124
|
+
console.log(" Unwanted-behavior / Optional). Keep the two axes honest — leave");
|
|
125
|
+
console.log(" Implementation: planned for what is not built yet; keep aspiration");
|
|
126
|
+
console.log(" under ## Maturity → Future, not in EARS; write concrete Acceptance");
|
|
127
|
+
console.log(" criteria, each [unverified] until a cited test proves it.");
|
|
124
128
|
console.log("");
|
|
125
|
-
console.log("4.
|
|
129
|
+
console.log("4. If the project spans services/front-ends, own the seams between");
|
|
130
|
+
console.log(" them (port map, env, API/WS/event shapes) as a contract:");
|
|
131
|
+
console.log(` ${c.cyan("doctrina contract new <name>")} → fill it → ${c.cyan("doctrina contract check")}`);
|
|
132
|
+
console.log("");
|
|
133
|
+
console.log("5. Record any architectural decision the intake forces (cite Evidence):");
|
|
126
134
|
console.log(` ${c.cyan("doctrina decision new \"<title>\"")} → edit → ${c.cyan("doctrina decision accept <num>")}`);
|
|
127
135
|
console.log("");
|
|
128
|
-
console.log("
|
|
136
|
+
console.log("6. Quality gates — fix everything they report before moving on:");
|
|
129
137
|
console.log(` ${c.cyan("doctrina clarify --all")}`);
|
|
130
138
|
console.log(` ${c.cyan("doctrina validate")}`);
|
|
139
|
+
console.log(` ${c.cyan("doctrina coverage")} (and ${c.cyan("doctrina verify --init")} to declare the build gate)`);
|
|
131
140
|
console.log(" If something in the intake is genuinely ambiguous, ask the user");
|
|
132
141
|
console.log(" before assuming.");
|
|
133
142
|
console.log("");
|
|
134
|
-
console.log("
|
|
143
|
+
console.log("7. Mark the intake consumed: in .doctrina/intake.md flip");
|
|
135
144
|
console.log(" \"- **Status:** pending\" to \"- **Status:** converted\".");
|
|
136
145
|
console.log("");
|
|
137
|
-
console.log(`
|
|
146
|
+
console.log(`8. ${c.cyan("doctrina next")} — follow the recommendation. Implementation then`);
|
|
138
147
|
console.log(` flows through ${c.cyan("doctrina work \"<brief prompt>\"")}, one change at a time.`);
|
|
139
148
|
if (!hasSpecs) {
|
|
140
149
|
console.log("");
|
package/src/commands/spec.js
CHANGED
|
@@ -51,13 +51,20 @@ export async function run(positional, flags) {
|
|
|
51
51
|
console.log(c.green("created") + ` ${relPath(projectRoot, targetPath)}${bug ? " (bug-spec shape)" : ""}`);
|
|
52
52
|
|
|
53
53
|
const index = idx.load(projectRoot);
|
|
54
|
-
|
|
54
|
+
// Two axes: a fresh capability scaffold is a draft document for a
|
|
55
|
+
// planned capability. Promote `status` to active once it reflects
|
|
56
|
+
// intent; advance `implementation` as code lands (see `doctrina
|
|
57
|
+
// coverage`). Bug specs are a different shape and stay off the
|
|
58
|
+
// implementation axis — only the capability template carries it.
|
|
59
|
+
const entry = {
|
|
55
60
|
id: capability,
|
|
56
61
|
path: `.doctrina/specs/${capability}/spec.md`,
|
|
57
|
-
status: "active",
|
|
62
|
+
status: bug ? "active" : "draft",
|
|
58
63
|
version: "0.1.0",
|
|
59
64
|
last_updated: date,
|
|
60
|
-
}
|
|
65
|
+
};
|
|
66
|
+
if (!bug) entry.implementation = "planned";
|
|
67
|
+
idx.addSpec(index, entry);
|
|
61
68
|
idx.touch(index, date);
|
|
62
69
|
idx.save(projectRoot, index);
|
|
63
70
|
console.log(c.green("indexed") + ` spec "${capability}"`);
|
|
@@ -75,10 +82,14 @@ function specList() {
|
|
|
75
82
|
const p = path.join(specsDir, cap, "spec.md");
|
|
76
83
|
if (!isFile(p)) continue;
|
|
77
84
|
const text = read(p);
|
|
85
|
+
// Show only the leading state word of Implementation, dropping any
|
|
86
|
+
// explanatory note after it ("planned — deferred" -> "planned").
|
|
87
|
+
const implRaw = specHeader(text, "Implementation");
|
|
78
88
|
rows.push({
|
|
79
89
|
id: cap,
|
|
80
90
|
version: specHeader(text, "Version") ?? "—",
|
|
81
91
|
status: specHeader(text, "Status") ?? "active",
|
|
92
|
+
impl: implRaw ? implRaw.trim().split(/[\s—-]+/)[0] : "—",
|
|
82
93
|
lines: lineCount(p),
|
|
83
94
|
updated: specHeader(text, "Last updated") ?? "—",
|
|
84
95
|
});
|
|
@@ -88,10 +99,10 @@ function specList() {
|
|
|
88
99
|
console.log(c.gray("no specs found in .doctrina/specs/"));
|
|
89
100
|
return 0;
|
|
90
101
|
}
|
|
91
|
-
console.log(c.bold("Specs:"));
|
|
102
|
+
console.log(c.bold("Specs:") + c.gray(" (status = document, impl = capability)"));
|
|
92
103
|
console.log("");
|
|
93
104
|
for (const r of rows) {
|
|
94
|
-
console.log(` ${c.cyan(r.id.padEnd(20))} ${r.version.padEnd(8)} ${r.status.padEnd(10)} ${String(r.lines).padStart(4)} lines ${r.updated}`);
|
|
105
|
+
console.log(` ${c.cyan(r.id.padEnd(20))} ${r.version.padEnd(8)} ${r.status.padEnd(10)} ${r.impl.padEnd(12)} ${String(r.lines).padStart(4)} lines ${r.updated}`);
|
|
95
106
|
}
|
|
96
107
|
console.log("");
|
|
97
108
|
console.log(c.gray(`${rows.length} spec${rows.length === 1 ? "" : "s"}`));
|
|
@@ -89,7 +89,7 @@ function updateTemplates(flags) {
|
|
|
89
89
|
if (idx.$schema_version === undefined) describe.push('index.json: add "$schema_version": "0.1.0"');
|
|
90
90
|
if (idx.project === undefined) describe.push(`index.json: add "project": "${path.basename(projectRoot)}"`);
|
|
91
91
|
if (idx.artifacts === undefined) describe.push('index.json: add empty "artifacts" object');
|
|
92
|
-
const categories = ["specs", "decisions", "changes", "changes_archive", "skills"];
|
|
92
|
+
const categories = ["specs", "decisions", "changes", "changes_archive", "skills", "contracts"];
|
|
93
93
|
for (const cat of categories) {
|
|
94
94
|
if (idx.artifacts && idx.artifacts[cat] === undefined) {
|
|
95
95
|
describe.push(`index.json: add empty artifact category "${cat}"`);
|
package/src/commands/validate.js
CHANGED
|
@@ -6,6 +6,7 @@ import * as idx from "../lib/index-json.js";
|
|
|
6
6
|
import { c } from "../lib/colors.js";
|
|
7
7
|
import { parseFrontmatter } from "./skill.js";
|
|
8
8
|
import { checkEars, isEarsSpec } from "../lib/ears.js";
|
|
9
|
+
import { specHeader, listHeader } from "../lib/scan.js";
|
|
9
10
|
|
|
10
11
|
export async function run(_positional, _flags) {
|
|
11
12
|
const projectRoot = process.cwd();
|
|
@@ -57,6 +58,7 @@ export async function run(_positional, _flags) {
|
|
|
57
58
|
for (const d of index.artifacts.decisions ?? []) allArtifactPaths.push(d.path);
|
|
58
59
|
for (const ch of index.artifacts.changes ?? []) allArtifactPaths.push(ch.path);
|
|
59
60
|
for (const ch of index.artifacts.changes_archive ?? []) allArtifactPaths.push(ch.path);
|
|
61
|
+
for (const ct of index.artifacts.contracts ?? []) allArtifactPaths.push(ct.path);
|
|
60
62
|
for (const rel of allArtifactPaths) {
|
|
61
63
|
const full = path.join(projectRoot, rel);
|
|
62
64
|
if (!exists(full)) errors.push(`index.json references missing artifact: ${rel}`);
|
|
@@ -99,6 +101,37 @@ export async function run(_positional, _flags) {
|
|
|
99
101
|
`(expected a Markdown list item exactly like "- **Status:** accepted")`,
|
|
100
102
|
);
|
|
101
103
|
}
|
|
104
|
+
|
|
105
|
+
// 5b. ADR evidence drift. A decision is only true if reality reflects
|
|
106
|
+
// it; an accepted ADR that points at nothing — or at a file that
|
|
107
|
+
// no longer exists — is the classic "ADR decrees X, the code
|
|
108
|
+
// never did X". Only ADRs that adopt the **Evidence:** header are
|
|
109
|
+
// checked, so legacy/process ADRs that opt out stay quiet.
|
|
110
|
+
const evidence = listHeader(text, "Evidence");
|
|
111
|
+
if (evidence !== null) {
|
|
112
|
+
const adrStatus = (listHeader(text, "Status") ?? "").toLowerCase();
|
|
113
|
+
const trimmed = evidence.trim();
|
|
114
|
+
const bare = trimmed === "—" || trimmed === "-" || trimmed === "";
|
|
115
|
+
if (bare) {
|
|
116
|
+
if (adrStatus === "accepted") {
|
|
117
|
+
warnings.push(
|
|
118
|
+
`${relPath(projectRoot, f)}: accepted ADR cites no implementation evidence ` +
|
|
119
|
+
`(link the file(s) that prove it, or note "n/a — <why>"); a decision with ` +
|
|
120
|
+
`nothing behind it may be drift waiting to be superseded`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
for (const token of extractBacktickPaths(evidence)) {
|
|
125
|
+
const candidates = [path.resolve(path.dirname(f), token), path.resolve(projectRoot, token)];
|
|
126
|
+
if (!candidates.some(exists)) {
|
|
127
|
+
warnings.push(
|
|
128
|
+
`${relPath(projectRoot, f)}: Evidence cites \`${token}\` which is missing on disk — ` +
|
|
129
|
+
`the decision may have drifted from the code (update the evidence or supersede the ADR)`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
102
135
|
}
|
|
103
136
|
}
|
|
104
137
|
|
|
@@ -145,6 +178,30 @@ export async function run(_positional, _flags) {
|
|
|
145
178
|
warnings.push(`${relPath(projectRoot, specPath)}:${f.line} ${f.message}`);
|
|
146
179
|
}
|
|
147
180
|
}
|
|
181
|
+
|
|
182
|
+
// 8c. Capability-state honesty (two-axis status). An "active"
|
|
183
|
+
// document that records no built capability is an inventory
|
|
184
|
+
// claim — the gap the framework is meant to make visible.
|
|
185
|
+
// Warn when Status is active and Implementation is "planned"
|
|
186
|
+
// with no explanatory note after the state word. A note
|
|
187
|
+
// ("planned — deferred, see ADR 0007") is the deliberate-gap
|
|
188
|
+
// escape hatch. Specs with no Implementation header are off
|
|
189
|
+
// the axis and never warn.
|
|
190
|
+
const implRaw = specHeader(text, "Implementation");
|
|
191
|
+
if (implRaw) {
|
|
192
|
+
const docStatus = (specHeader(text, "Status") ?? "active").toLowerCase();
|
|
193
|
+
const implTokens = implRaw.trim().split(/\s+/);
|
|
194
|
+
const implWord = (implTokens[0] ?? "").replace(/[—-]+$/, "").toLowerCase();
|
|
195
|
+
const hasNote = implTokens.length > 1;
|
|
196
|
+
if (docStatus === "active" && implWord === "planned" && !hasNote) {
|
|
197
|
+
warnings.push(
|
|
198
|
+
`${relPath(projectRoot, specPath)}: Status is "active" but Implementation is ` +
|
|
199
|
+
`"planned" with no note — an active spec with no built capability is an ` +
|
|
200
|
+
`inventory claim (advance Implementation, set Status: draft, or add a note ` +
|
|
201
|
+
`after the value: "planned — <why deferred>")`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
148
205
|
}
|
|
149
206
|
}
|
|
150
207
|
}
|
|
@@ -202,6 +259,44 @@ export async function run(_positional, _flags) {
|
|
|
202
259
|
}
|
|
203
260
|
}
|
|
204
261
|
}
|
|
262
|
+
const indexedContractIds = new Set((index.artifacts.contracts ?? []).map((ct) => ct.id));
|
|
263
|
+
const contractsDir = path.join(projectRoot, ".doctrina", "contracts");
|
|
264
|
+
if (isDir(contractsDir)) {
|
|
265
|
+
for (const f of walk(contractsDir)) {
|
|
266
|
+
if (!f.endsWith(".md")) continue;
|
|
267
|
+
const id = path.basename(f, ".md");
|
|
268
|
+
if (!indexedContractIds.has(id)) {
|
|
269
|
+
warnings.push(`orphan contract ${relPath(projectRoot, f)} (id "${id}" not in index.json)`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// 11. Archive ledger ↔ index cross-check. The history of archived
|
|
276
|
+
// changes is recorded twice — once human-facing in
|
|
277
|
+
// changes/archive/LEDGER.md, once machine-facing in
|
|
278
|
+
// index.json.changes_archive. Two sources of truth that silently
|
|
279
|
+
// disagree are worse than one: an incomplete ledger makes the
|
|
280
|
+
// history look shorter than it is. When the ledger exists, every
|
|
281
|
+
// archived change must appear in both, or validation fails.
|
|
282
|
+
const ledgerPath = path.join(changesDir, "archive", "LEDGER.md");
|
|
283
|
+
if (index?.artifacts && isFile(ledgerPath)) {
|
|
284
|
+
const ledgerIds = new Set();
|
|
285
|
+
for (const line of read(ledgerPath).split(/\r?\n/)) {
|
|
286
|
+
const m = line.match(/^-\s+\d{4}-\d{2}-\d{2}\s+[—-]\s+(\S+)\s+[—-]\s+/);
|
|
287
|
+
if (m) ledgerIds.add(m[1]);
|
|
288
|
+
}
|
|
289
|
+
const archiveIds = new Set((index.artifacts.changes_archive ?? []).map((ch) => ch.id));
|
|
290
|
+
for (const id of archiveIds) {
|
|
291
|
+
if (!ledgerIds.has(id)) {
|
|
292
|
+
errors.push(`archived change "${id}" is in index.json but missing from changes/archive/LEDGER.md (ledger and index disagree)`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
for (const id of ledgerIds) {
|
|
296
|
+
if (!archiveIds.has(id)) {
|
|
297
|
+
errors.push(`changes/archive/LEDGER.md lists "${id}" but index.json.changes_archive does not (ledger and index disagree)`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
205
300
|
}
|
|
206
301
|
|
|
207
302
|
// 12-14. Skills validation
|
|
@@ -290,6 +385,17 @@ function extractPathReferences(text) {
|
|
|
290
385
|
return [...out];
|
|
291
386
|
}
|
|
292
387
|
|
|
388
|
+
// Path-like tokens inside backtick spans — the convention for citing
|
|
389
|
+
// evidence (ADR **Evidence:**, acceptance-criteria links).
|
|
390
|
+
function extractBacktickPaths(text) {
|
|
391
|
+
const out = new Set();
|
|
392
|
+
for (const m of text.matchAll(/`([^`]+)`/g)) {
|
|
393
|
+
const token = m[1].trim();
|
|
394
|
+
if (isLikelyPath(token)) out.add(stripFragment(token));
|
|
395
|
+
}
|
|
396
|
+
return [...out];
|
|
397
|
+
}
|
|
398
|
+
|
|
293
399
|
function stripFragment(s) {
|
|
294
400
|
// Remove an in-page anchor (#section) or query string from a path token.
|
|
295
401
|
return s.split(/[#?]/)[0];
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { exists, isFile, read, write, relPath } from "../lib/fs-ops.js";
|
|
5
|
+
import { flagBool } from "../lib/args.js";
|
|
6
|
+
import { c } from "../lib/colors.js";
|
|
7
|
+
|
|
8
|
+
// The build/verify gate `validate` is not. `validate` checks the shape of
|
|
9
|
+
// the artifact tree; it never runs the project, so a repo that does not
|
|
10
|
+
// compile still "passes". `verify` closes that hole: it executes the
|
|
11
|
+
// project-declared checks (typecheck / test / build) and fails when any
|
|
12
|
+
// of them fails. Doctrina is language-agnostic and ships zero runtime
|
|
13
|
+
// deps, so it cannot know your build — you declare it in
|
|
14
|
+
// `.doctrina/verify.json`, and `verify` runs exactly that, in order.
|
|
15
|
+
|
|
16
|
+
const CONFIG_REL = ".doctrina/verify.json";
|
|
17
|
+
|
|
18
|
+
const STARTER = {
|
|
19
|
+
$comment:
|
|
20
|
+
"doctrina verify runs each check below, in order, through your shell. " +
|
|
21
|
+
"Replace the example with your real typecheck/test/build commands. A " +
|
|
22
|
+
"non-zero exit fails the gate. This file is the project's executable " +
|
|
23
|
+
"definition of 'the code actually works'.",
|
|
24
|
+
checks: [
|
|
25
|
+
{ name: "example", run: "exit 1" },
|
|
26
|
+
],
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export async function run(_positional, flags) {
|
|
30
|
+
const projectRoot = process.cwd();
|
|
31
|
+
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
32
|
+
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const configPath = path.join(projectRoot, CONFIG_REL);
|
|
36
|
+
|
|
37
|
+
// --init scaffolds a starter config so the gate is one edit away.
|
|
38
|
+
if (flagBool(flags, "init", false)) {
|
|
39
|
+
const force = flagBool(flags, "force", false);
|
|
40
|
+
if (isFile(configPath) && !force) {
|
|
41
|
+
console.error(c.red("error:") + ` ${CONFIG_REL} already exists (pass --force to overwrite)`);
|
|
42
|
+
return 1;
|
|
43
|
+
}
|
|
44
|
+
write(configPath, JSON.stringify(STARTER, null, 2) + "\n", { force: true });
|
|
45
|
+
console.log(c.green("created") + ` ${CONFIG_REL}`);
|
|
46
|
+
console.log(c.gray("Edit the `checks` array with your real commands, then run `doctrina verify`."));
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!isFile(configPath)) {
|
|
51
|
+
// A verify gate with nothing configured would pass vacuously — exactly
|
|
52
|
+
// the false confidence this command exists to prevent. Fail loudly.
|
|
53
|
+
console.error(c.red("error:") + ` no ${CONFIG_REL} — nothing to verify`);
|
|
54
|
+
console.error(c.gray("hint: ") + "scaffold one with `doctrina verify --init`, then declare your typecheck/test/build commands");
|
|
55
|
+
return 1;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let config;
|
|
59
|
+
try {
|
|
60
|
+
config = JSON.parse(read(configPath));
|
|
61
|
+
} catch (err) {
|
|
62
|
+
console.error(c.red("error:") + ` ${CONFIG_REL} is not valid JSON: ${err.message}`);
|
|
63
|
+
return 1;
|
|
64
|
+
}
|
|
65
|
+
const checks = Array.isArray(config?.checks) ? config.checks : null;
|
|
66
|
+
if (!checks) {
|
|
67
|
+
console.error(c.red("error:") + ` ${CONFIG_REL} must contain a "checks" array of { name, run }`);
|
|
68
|
+
return 1;
|
|
69
|
+
}
|
|
70
|
+
for (const ch of checks) {
|
|
71
|
+
if (!ch || typeof ch.run !== "string" || ch.run.trim() === "") {
|
|
72
|
+
console.error(c.red("error:") + ` every check in ${CONFIG_REL} needs a non-empty "run" string`);
|
|
73
|
+
return 1;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (checks.length === 0) {
|
|
77
|
+
console.error(c.red("error:") + ` ${CONFIG_REL} declares zero checks — a verify gate with no checks proves nothing`);
|
|
78
|
+
return 1;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// --list prints the configured checks without running them.
|
|
82
|
+
if (flagBool(flags, "list", false)) {
|
|
83
|
+
console.log(c.bold("Verify checks") + c.gray(` (from ${CONFIG_REL}):`));
|
|
84
|
+
console.log("");
|
|
85
|
+
checks.forEach((ch, i) => {
|
|
86
|
+
console.log(` ${i + 1}. ${c.cyan((ch.name ?? "check").padEnd(16))} ${c.gray(ch.run)}`);
|
|
87
|
+
});
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
console.log(c.bold("doctrina verify") + c.gray(` — ${checks.length} check${checks.length === 1 ? "" : "s"} from ${CONFIG_REL}`));
|
|
92
|
+
const results = [];
|
|
93
|
+
for (const ch of checks) {
|
|
94
|
+
const name = ch.name ?? "check";
|
|
95
|
+
console.log("");
|
|
96
|
+
const where = ch.cwd ? c.gray(` [${ch.cwd}]`) : "";
|
|
97
|
+
console.log(c.gray(`──── ${name}: `) + ch.run + where);
|
|
98
|
+
// Run through the shell so declared commands can use pipes, &&, env,
|
|
99
|
+
// and platform builtins. stdio is inherited so the operator sees the
|
|
100
|
+
// real output — verify shows the truth, it does not summarise it away.
|
|
101
|
+
// An optional per-check `cwd` (relative to the project root) lets a
|
|
102
|
+
// monorepo target a sub-package, e.g. { run: "node --test", cwd:
|
|
103
|
+
// "packages/api" } — the multi-service shape the contract artifact
|
|
104
|
+
// also serves.
|
|
105
|
+
const res = spawnSync(ch.run, {
|
|
106
|
+
cwd: ch.cwd ? path.resolve(projectRoot, ch.cwd) : projectRoot,
|
|
107
|
+
shell: true,
|
|
108
|
+
stdio: "inherit",
|
|
109
|
+
});
|
|
110
|
+
const ok = !res.error && res.status === 0;
|
|
111
|
+
if (res.error) {
|
|
112
|
+
console.log(c.red(`✗ ${name}`) + c.gray(` — could not run: ${res.error.message}`));
|
|
113
|
+
} else {
|
|
114
|
+
console.log(ok ? c.green(`✓ ${name}`) : c.red(`✗ ${name} (exit ${res.status})`));
|
|
115
|
+
}
|
|
116
|
+
results.push({ name, ok });
|
|
117
|
+
// Fail fast is tempting, but running every check surfaces all breakage
|
|
118
|
+
// in one pass — more useful for an agent fixing the gap.
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const passed = results.filter((r) => r.ok).length;
|
|
122
|
+
console.log("");
|
|
123
|
+
if (passed === results.length) {
|
|
124
|
+
console.log(c.green("ok") + ` ${passed}/${results.length} checks passed`);
|
|
125
|
+
return 0;
|
|
126
|
+
}
|
|
127
|
+
const failed = results.filter((r) => !r.ok).map((r) => r.name).join(", ");
|
|
128
|
+
console.log(c.red("fail") + ` ${passed}/${results.length} checks passed — failed: ${failed}`);
|
|
129
|
+
return 1;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export const help = `
|
|
133
|
+
Usage: doctrina verify [--list] [--init] [--force]
|
|
134
|
+
|
|
135
|
+
Run the project-declared build/verify checks from ${CONFIG_REL} in order,
|
|
136
|
+
streaming their output, and exit non-zero if any check fails. This is the
|
|
137
|
+
real "does the code work" gate, separate from the structural \`validate\`.
|
|
138
|
+
|
|
139
|
+
${CONFIG_REL} shape (an optional per-check "cwd", relative to the project
|
|
140
|
+
root, targets a sub-package in a monorepo):
|
|
141
|
+
{
|
|
142
|
+
"checks": [
|
|
143
|
+
{ "name": "typecheck", "run": "tsc --noEmit", "cwd": "packages/api" },
|
|
144
|
+
{ "name": "test", "run": "npm test" },
|
|
145
|
+
{ "name": "build", "run": "npm run build" }
|
|
146
|
+
]
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
Flags:
|
|
150
|
+
--init Scaffold a starter ${CONFIG_REL} (refuses to overwrite without --force)
|
|
151
|
+
--list Print the configured checks without running them
|
|
152
|
+
--force With --init, overwrite an existing config
|
|
153
|
+
`;
|
package/src/commands/work.js
CHANGED
|
@@ -9,9 +9,10 @@ import { changeNew } from "./change.js";
|
|
|
9
9
|
// `work` is the second half of the no-ceremony path (ADR 0005): a brief
|
|
10
10
|
// prompt ("add login") becomes a fully scaffolded change plus a playbook
|
|
11
11
|
// the host agent executes — context, spec deltas, tasks, implementation,
|
|
12
|
-
// then the analyze → apply → archive → validate close
|
|
13
|
-
//
|
|
14
|
-
//
|
|
12
|
+
// then the analyze → apply → verify → archive → validate close (verify =
|
|
13
|
+
// `doctrina verify` + `doctrina coverage`, since archive now refuses
|
|
14
|
+
// unproven verification). The CLI's own language processing stops at
|
|
15
|
+
// slugging and term counting; everything semantic is the agent's job.
|
|
15
16
|
|
|
16
17
|
export async function run(positional, flags) {
|
|
17
18
|
const prompt = positional.join(" ").trim();
|
|
@@ -183,18 +184,27 @@ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability })
|
|
|
183
184
|
console.log(c.gray(" **Operation:** ADDED | MODIFIED | REMOVED"));
|
|
184
185
|
console.log(c.gray(" **Target spec on apply:** `.doctrina/specs/<capability>/spec.md`"));
|
|
185
186
|
console.log(c.gray(" ---"));
|
|
186
|
-
console.log(c.gray(" <EARS
|
|
187
|
+
console.log(c.gray(" <EARS body. Keep the two axes honest (Status vs Implementation),"));
|
|
188
|
+
console.log(c.gray(" keep aspiration under ## Maturity → Future, and cite the proof per"));
|
|
189
|
+
console.log(c.gray(" criterion: \"1. [unverified] <signal> — verified by `path/to/test`\">"));
|
|
187
190
|
console.log("");
|
|
188
191
|
console.log(`4. Replace the placeholder tasks in .doctrina/changes/${id}/tasks.md`);
|
|
189
192
|
console.log(" with small, checkable implementation tasks (a few hours each, max).");
|
|
190
193
|
console.log("");
|
|
191
|
-
console.log("5. Implement task by task, checking each box as it lands.
|
|
192
|
-
console.log("
|
|
194
|
+
console.log("5. Implement task by task, checking each box as it lands. Advance the");
|
|
195
|
+
console.log(" spec's Implementation: planned → partial → implemented as code lands.");
|
|
196
|
+
console.log(" If the prompt is genuinely ambiguous, ask the user before assuming.");
|
|
193
197
|
console.log("");
|
|
194
198
|
console.log(`6. ${c.cyan(`doctrina analyze ${id}`)} — fix every ✗ before applying.`);
|
|
195
199
|
console.log(`7. ${c.cyan(`doctrina change apply ${id}`)} (MODIFIED deltas are merged by hand).`);
|
|
196
|
-
console.log(
|
|
197
|
-
console.log(`
|
|
200
|
+
console.log("8. Prove it before declaring done (archive refuses unchecked boxes):");
|
|
201
|
+
console.log(` ${c.cyan("doctrina verify")} — the project's typecheck/test/build gate`);
|
|
202
|
+
console.log(` ${c.cyan("doctrina coverage")} — each acceptance criterion cites a real test`);
|
|
203
|
+
console.log(" Then check the proposal's ## Verification boxes and every task,");
|
|
204
|
+
console.log(" closing steps included, and bump Implementation to verified.");
|
|
205
|
+
console.log("");
|
|
206
|
+
console.log(`9. ${c.cyan(`doctrina change archive ${id}`)}`);
|
|
207
|
+
console.log(`10. ${c.cyan("doctrina validate")}, then ${c.cyan("doctrina next")} for the follow-up.`);
|
|
198
208
|
}
|
|
199
209
|
|
|
200
210
|
export const help = `
|
|
@@ -205,8 +215,9 @@ host AI agent executes (see ADR 0005). The CLI derives a sequential
|
|
|
205
215
|
change id (NNNN-<slug>) from the prompt, opens the change folder,
|
|
206
216
|
records the prompt under the proposal's "## Why", ranks existing specs
|
|
207
217
|
by term overlap as a capability hint, and prints the ordered steps:
|
|
208
|
-
context → spec delta → tasks → implement → analyze → apply →
|
|
209
|
-
validate. No natural-language
|
|
218
|
+
context → spec delta → tasks → implement → analyze → apply → verify
|
|
219
|
+
(verify + coverage) → archive → validate. No natural-language
|
|
220
|
+
interpretation happens in the CLI.
|
|
210
221
|
|
|
211
222
|
Options:
|
|
212
223
|
--capability <cap> Pin the capability instead of ranking matches
|
package/src/index.js
CHANGED
|
@@ -24,11 +24,14 @@ import * as context from "./commands/context.js";
|
|
|
24
24
|
import * as search from "./commands/search.js";
|
|
25
25
|
import * as intake from "./commands/intake.js";
|
|
26
26
|
import * as work from "./commands/work.js";
|
|
27
|
+
import * as coverage from "./commands/coverage.js";
|
|
28
|
+
import * as verify from "./commands/verify.js";
|
|
29
|
+
import * as contract from "./commands/contract.js";
|
|
27
30
|
|
|
28
31
|
const COMMANDS = {
|
|
29
32
|
init, spec, change, decision, validate, hooks, analyze, clarify,
|
|
30
33
|
templates, skill, index: indexCmd, next, metrics, context, search,
|
|
31
|
-
intake, work,
|
|
34
|
+
intake, work, coverage, verify, contract,
|
|
32
35
|
};
|
|
33
36
|
|
|
34
37
|
const TOP_HELP = `
|
|
@@ -44,6 +47,8 @@ Commands:
|
|
|
44
47
|
change apply Apply spec deltas (ADDED/REMOVED auto, MODIFIED manual)
|
|
45
48
|
change archive Archive an applied change
|
|
46
49
|
change diff Preview spec deltas (line diff for MODIFIED)
|
|
50
|
+
contract new Own the integration surface (ports, env, interfaces)
|
|
51
|
+
contract check Verify port collisions, env drift, referenced specs
|
|
47
52
|
decision new Create the next sequentially numbered ADR
|
|
48
53
|
decision accept Flip a proposed ADR to accepted
|
|
49
54
|
decision supersede Create a new ADR that supersedes an existing one
|
|
@@ -56,6 +61,8 @@ Commands:
|
|
|
56
61
|
context Print the context pack for a task in read order
|
|
57
62
|
search Search the artifact tree, grouped by category
|
|
58
63
|
validate Run schema and structural checks
|
|
64
|
+
coverage Report acceptance criteria with linked evidence (--strict gates)
|
|
65
|
+
verify Run project-declared typecheck/test/build checks (the real gate)
|
|
59
66
|
templates list List the templates shipped by the installed CLI
|
|
60
67
|
templates check Compare the project against the recommended template shape
|
|
61
68
|
hooks install Install the pre-commit hook
|
|
@@ -70,7 +77,7 @@ Global flags:
|
|
|
70
77
|
|
|
71
78
|
async function main(argv) {
|
|
72
79
|
const { positional, flags } = parseArgs(argv, {
|
|
73
|
-
boolean: ["help", "h", "version", "v", "force", "non-interactive", "check", "save", "bug", "write", "all", "concat", "archive"],
|
|
80
|
+
boolean: ["help", "h", "version", "v", "force", "non-interactive", "check", "save", "bug", "write", "all", "concat", "archive", "strict", "list", "init"],
|
|
74
81
|
});
|
|
75
82
|
|
|
76
83
|
if (flags.get("version") || flags.get("v")) {
|
package/src/lib/index-json.js
CHANGED
|
@@ -44,6 +44,7 @@ export function blank(projectName, date) {
|
|
|
44
44
|
changes: [],
|
|
45
45
|
changes_archive: [],
|
|
46
46
|
skills: [],
|
|
47
|
+
contracts: [],
|
|
47
48
|
},
|
|
48
49
|
};
|
|
49
50
|
}
|
|
@@ -56,6 +57,14 @@ export function addSkill(index, entry) {
|
|
|
56
57
|
return index;
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
export function addContract(index, entry) {
|
|
61
|
+
if (!index.artifacts.contracts) index.artifacts.contracts = [];
|
|
62
|
+
if (!index.artifacts.contracts.some((s) => s.id === entry.id)) {
|
|
63
|
+
index.artifacts.contracts.push(entry);
|
|
64
|
+
}
|
|
65
|
+
return index;
|
|
66
|
+
}
|
|
67
|
+
|
|
59
68
|
export function removeSkill(index, id) {
|
|
60
69
|
if (!index.artifacts.skills) return index;
|
|
61
70
|
index.artifacts.skills = index.artifacts.skills.filter((s) => s.id !== id);
|
package/src/lib/scan.js
CHANGED
|
@@ -55,6 +55,7 @@ export function deriveIndex(projectRoot, current) {
|
|
|
55
55
|
changes: [],
|
|
56
56
|
changes_archive: [],
|
|
57
57
|
skills: [],
|
|
58
|
+
contracts: [],
|
|
58
59
|
},
|
|
59
60
|
};
|
|
60
61
|
|
|
@@ -65,13 +66,22 @@ export function deriveIndex(projectRoot, current) {
|
|
|
65
66
|
if (!isFile(p)) continue;
|
|
66
67
|
const text = read(p);
|
|
67
68
|
const prev = (cur.specs ?? []).find((s) => s.id === cap);
|
|
68
|
-
|
|
69
|
+
const entry = {
|
|
69
70
|
id: cap,
|
|
70
71
|
path: `.doctrina/specs/${cap}/spec.md`,
|
|
71
72
|
status: specHeader(text, "Status") ?? prev?.status ?? "active",
|
|
72
73
|
version: specHeader(text, "Version") ?? prev?.version ?? "0.1.0",
|
|
73
74
|
last_updated: specHeader(text, "Last updated") ?? prev?.last_updated ?? date,
|
|
74
|
-
}
|
|
75
|
+
};
|
|
76
|
+
// Implementation is the capability axis (planned -> partial ->
|
|
77
|
+
// implemented -> verified), independent of the document Status. Only
|
|
78
|
+
// record it when the spec declares it or the index already tracked it,
|
|
79
|
+
// so specs that never opted in (e.g. bug specs) stay off the axis.
|
|
80
|
+
// The stored value keeps any explanatory note after the state word.
|
|
81
|
+
const implRaw = specHeader(text, "Implementation");
|
|
82
|
+
const impl = implRaw ? implRaw.trim() : prev?.implementation;
|
|
83
|
+
if (impl) entry.implementation = impl;
|
|
84
|
+
out.artifacts.specs.push(entry);
|
|
75
85
|
}
|
|
76
86
|
|
|
77
87
|
// Decisions — NNNN-slug.md files; Status/Date headers win.
|
|
@@ -143,6 +153,22 @@ export function deriveIndex(projectRoot, current) {
|
|
|
143
153
|
});
|
|
144
154
|
}
|
|
145
155
|
|
|
156
|
+
// Contracts — the integration/runtime surface (ports, env, interfaces)
|
|
157
|
+
// that no single capability owns. Headers inside each contract win.
|
|
158
|
+
const contractsDir = path.join(dot, "contracts");
|
|
159
|
+
for (const f of walk(contractsDir)) {
|
|
160
|
+
if (!f.endsWith(".md")) continue;
|
|
161
|
+
const id = path.basename(f, ".md");
|
|
162
|
+
const text = read(f);
|
|
163
|
+
const prev = (cur.contracts ?? []).find((s) => s.id === id);
|
|
164
|
+
out.artifacts.contracts.push({
|
|
165
|
+
id,
|
|
166
|
+
path: `.doctrina/contracts/${id}.md`,
|
|
167
|
+
status: specHeader(text, "Status") ?? prev?.status ?? "active",
|
|
168
|
+
last_updated: specHeader(text, "Last updated") ?? prev?.last_updated ?? date,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
146
172
|
// Skills — frontmatter description wins.
|
|
147
173
|
const skillsDir = path.join(dot, "skills");
|
|
148
174
|
for (const f of walk(skillsDir)) {
|
|
@@ -183,7 +209,7 @@ export function indexesMatch(a, b) {
|
|
|
183
209
|
const clone = { ...x };
|
|
184
210
|
delete clone.last_updated;
|
|
185
211
|
const arts = { ...(clone.artifacts ?? {}) };
|
|
186
|
-
for (const cat of ["specs", "decisions", "changes", "changes_archive", "skills"]) {
|
|
212
|
+
for (const cat of ["specs", "decisions", "changes", "changes_archive", "skills", "contracts"]) {
|
|
187
213
|
arts[cat] = arts[cat] ?? [];
|
|
188
214
|
}
|
|
189
215
|
clone.artifacts = arts;
|
|
@@ -19,7 +19,16 @@
|
|
|
19
19
|
|
|
20
20
|
## Verification
|
|
21
21
|
|
|
22
|
-
<!--
|
|
22
|
+
<!--
|
|
23
|
+
How you will know the change is correctly applied. Use checkboxes: every
|
|
24
|
+
box here is a claim that must be PROVEN before the change is done.
|
|
25
|
+
`doctrina change archive` refuses to archive while any box below is
|
|
26
|
+
unchecked (pass --force to archive anyway and record the gap). Distinguish
|
|
27
|
+
"task marked done" from "verification passed" — link the evidence.
|
|
28
|
+
-->
|
|
29
|
+
|
|
30
|
+
- [ ] Automated checks pass (`doctrina verify`, or the project's typecheck/test/build).
|
|
31
|
+
- [ ] The affected spec's acceptance criteria are met and cite their evidence (`doctrina coverage`).
|
|
23
32
|
|
|
24
33
|
## Open questions
|
|
25
34
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Contract — {{CONTRACT_NAME}}
|
|
2
|
+
|
|
3
|
+
**Contract:** {{CONTRACT_NAME}}
|
|
4
|
+
**Status:** active
|
|
5
|
+
**Last updated:** {{DATE}}
|
|
6
|
+
|
|
7
|
+
## Purpose
|
|
8
|
+
|
|
9
|
+
<!--
|
|
10
|
+
The integration/runtime surface this contract OWNS. No single capability
|
|
11
|
+
spec owns the seams between services and front-ends — this artifact does.
|
|
12
|
+
If a login response shape, a WebSocket path, or a Kafka topic falls in the
|
|
13
|
+
gap between two specs, it belongs here.
|
|
14
|
+
-->
|
|
15
|
+
|
|
16
|
+
## Ports
|
|
17
|
+
|
|
18
|
+
<!--
|
|
19
|
+
The single source of truth for who listens where. `doctrina contract check`
|
|
20
|
+
fails when two rows claim the same port (the "front points at the wrong
|
|
21
|
+
port" class of bug). Front-ends and clients read their targets from here.
|
|
22
|
+
-->
|
|
23
|
+
|
|
24
|
+
| Service | Port | Protocol |
|
|
25
|
+
|---------|------|----------|
|
|
26
|
+
| gateway | 8080 | http |
|
|
27
|
+
|
|
28
|
+
## Environment
|
|
29
|
+
|
|
30
|
+
<!--
|
|
31
|
+
Variables the running system requires, named IDENTICALLY across code,
|
|
32
|
+
`.env.example`, and infra (compose/helm). `doctrina contract check` warns
|
|
33
|
+
when a variable here is missing from `.env.example` — the DATABASE_URL /
|
|
34
|
+
KAFKA_BROKER drift class.
|
|
35
|
+
-->
|
|
36
|
+
|
|
37
|
+
| Variable | Required | Example |
|
|
38
|
+
|--------------|----------|-------------------------------------------|
|
|
39
|
+
| DATABASE_URL | yes | postgres://user:pass@localhost:5432/app |
|
|
40
|
+
|
|
41
|
+
## Interfaces
|
|
42
|
+
|
|
43
|
+
<!--
|
|
44
|
+
API / WebSocket / event-stream contracts: endpoints, message shapes, event
|
|
45
|
+
schemas, and the exact response shape each consumer can rely on (e.g. login
|
|
46
|
+
returns `{ token, user }`, not a bare `TokenPair`).
|
|
47
|
+
-->
|
|
48
|
+
|
|
49
|
+
-
|
|
50
|
+
|
|
51
|
+
## References
|
|
52
|
+
|
|
53
|
+
<!--
|
|
54
|
+
The capability specs that must conform to this contract. List each as
|
|
55
|
+
`specs/<capability>`; `doctrina contract check` verifies each spec exists.
|
|
56
|
+
-->
|
|
57
|
+
|
|
58
|
+
- `specs/<capability>`
|
|
@@ -5,6 +5,16 @@
|
|
|
5
5
|
- **Deciders:**
|
|
6
6
|
- **Supersedes:** —
|
|
7
7
|
- **Superseded by:** —
|
|
8
|
+
- **Evidence:** —
|
|
9
|
+
|
|
10
|
+
<!--
|
|
11
|
+
Evidence anchors the decision to reality. Once accepted, cite the file(s)
|
|
12
|
+
that prove this decision is actually implemented, in backticks, e.g.
|
|
13
|
+
"- **Evidence:** `proto/order.proto`, `services/gateway/grpc.ts`".
|
|
14
|
+
`doctrina validate` warns when cited evidence is missing on disk (the
|
|
15
|
+
decision drifted from the code) or when an accepted ADR cites none. For a
|
|
16
|
+
process decision with no code artifact, write "n/a — <why>".
|
|
17
|
+
-->
|
|
8
18
|
|
|
9
19
|
## Context
|
|
10
20
|
|
|
@@ -29,3 +29,15 @@ Out of scope (deferred or rejected):
|
|
|
29
29
|
## Success criteria
|
|
30
30
|
|
|
31
31
|
<!-- How you will know this project is working. Prefer measurable signals. -->
|
|
32
|
+
|
|
33
|
+
## Delivery order (walking skeleton)
|
|
34
|
+
|
|
35
|
+
<!--
|
|
36
|
+
Depth before breadth. Name the ONE end-to-end path that must work and be
|
|
37
|
+
verified before any capability fans out — the walking skeleton (e.g.
|
|
38
|
+
auth -> ingest -> ws -> ui). A spec'd capability with no working slice
|
|
39
|
+
through it is breadth bought on credit: build and `doctrina verify` the
|
|
40
|
+
skeleton first, then spec the next capability.
|
|
41
|
+
-->
|
|
42
|
+
|
|
43
|
+
1.
|
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
# Spec — {{CAPABILITY}}
|
|
2
2
|
|
|
3
3
|
**Capability:** {{CAPABILITY}}
|
|
4
|
-
**Status:**
|
|
4
|
+
**Status:** draft
|
|
5
|
+
**Implementation:** planned
|
|
5
6
|
**Last updated:** {{DATE}}
|
|
6
7
|
**Version:** 0.1.0
|
|
7
8
|
|
|
9
|
+
<!--
|
|
10
|
+
Two independent axes — keep them honest:
|
|
11
|
+
Status — the document: draft -> active -> deprecated
|
|
12
|
+
Implementation — the capability: planned -> partial -> implemented -> verified
|
|
13
|
+
Flip Status to "active" only once this spec reflects intended truth.
|
|
14
|
+
Advance Implementation as code lands; "verified" means a test proves it
|
|
15
|
+
(cite the test per acceptance criterion — see `doctrina coverage`).
|
|
16
|
+
An "active" spec left at "planned" is an inventory claim with nothing
|
|
17
|
+
behind it, and `doctrina validate` warns. To record a deliberate gap and
|
|
18
|
+
silence the warning, add a note after the value, e.g.
|
|
19
|
+
"**Implementation:** planned — backend deferred to Q3, see ADR 0007".
|
|
20
|
+
-->
|
|
21
|
+
|
|
8
22
|
## Purpose
|
|
9
23
|
|
|
10
24
|
<!-- One paragraph: what this capability does and why it exists. -->
|
|
@@ -43,9 +57,33 @@ Easy Approach to Requirements Syntax. Use one of the five forms per item:
|
|
|
43
57
|
|
|
44
58
|
## Acceptance criteria
|
|
45
59
|
|
|
46
|
-
<!--
|
|
60
|
+
<!--
|
|
61
|
+
Concrete, observable signals that the capability is correctly implemented.
|
|
62
|
+
Each criterion is also a claim that must be proven: mark it [verified] or
|
|
63
|
+
[unverified] and cite the artifact/test in backticks. `doctrina coverage`
|
|
64
|
+
reports which criteria have no linked evidence — an [unverified] criterion
|
|
65
|
+
with no test is explicit debt, not a fact.
|
|
66
|
+
-->
|
|
67
|
+
|
|
68
|
+
1. [unverified] <observable signal> — verified by `path/to/test`.
|
|
47
69
|
|
|
48
|
-
|
|
70
|
+
## Maturity
|
|
71
|
+
|
|
72
|
+
<!--
|
|
73
|
+
The line between an MVP and a product wishlist. List under MVP only what
|
|
74
|
+
this capability COMMITS to now. Anything aspirational goes under Future and
|
|
75
|
+
is explicitly NOT accepted scope — do not write Future items in EARS as if
|
|
76
|
+
they were already built (no WebGPU/multi-cloud/"50k entities at 30fps"
|
|
77
|
+
smuggled in as committed requirements).
|
|
78
|
+
-->
|
|
79
|
+
|
|
80
|
+
**MVP (committed):**
|
|
81
|
+
|
|
82
|
+
-
|
|
83
|
+
|
|
84
|
+
**Future (aspirational, not committed):**
|
|
85
|
+
|
|
86
|
+
-
|
|
49
87
|
|
|
50
88
|
## Out of scope for this spec
|
|
51
89
|
|