doctrina-cli 0.3.0 → 0.6.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 +48 -11
- package/package.json +1 -1
- package/src/commands/change.js +131 -29
- package/src/commands/coverage.js +73 -20
- 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 +16 -1
- package/src/commands/search.js +42 -7
- package/src/commands/spec.js +92 -6
- package/src/commands/trace.js +157 -0
- package/src/commands/validate.js +140 -34
- package/src/commands/verify.js +152 -2
- package/src/commands/work.js +243 -19
- package/src/index.js +6 -15
- package/src/lib/clarity.js +49 -0
- package/src/lib/index-json.js +8 -2
- package/src/lib/scan.js +8 -0
- package/src/lib/spec-ops.js +236 -0
- package/src/lib/version.js +21 -0
- package/templates/change/spec-delta.md.template +13 -2
- package/templates/decision.md.template +8 -0
- package/templates/doctrina/index.json.template +1 -1
- package/templates/doctrina/product.md.template +6 -1
- package/templates/spec.md.template +4 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// Structured spec operations (ADR 0007 / review F3). A MODIFIED spec delta
|
|
2
|
+
// historically carried prose ("flip criterion 2 to verified", "bump to
|
|
3
|
+
// 0.3.0") that a human merged by hand — the single most error-prone step in
|
|
4
|
+
// the change flow, and the source of index/version drift. These functions
|
|
5
|
+
// turn that prose into a small, declarative, mechanically applicable
|
|
6
|
+
// operation set so `change apply` can edit the spec and regenerate the index
|
|
7
|
+
// in one transaction. The verbs are deliberately narrow: they touch headers
|
|
8
|
+
// and acceptance-criteria markers, never arbitrary spec prose — semantic
|
|
9
|
+
// rewriting stays the agent's job (ADR 0005); only the bookkeeping is
|
|
10
|
+
// automated.
|
|
11
|
+
//
|
|
12
|
+
// Delta format — an optional fenced block inside a MODIFIED delta body:
|
|
13
|
+
//
|
|
14
|
+
// ```ops
|
|
15
|
+
// set-header Implementation: verified — durable adapter (`src/db.ts`)
|
|
16
|
+
// bump-version minor
|
|
17
|
+
// set-criterion 1: verified
|
|
18
|
+
// replace-criterion 2: [verified] new text — verified by `test/x.test.ts`
|
|
19
|
+
// append-criterion [unverified] new signal — verified by `test/y.test.ts`
|
|
20
|
+
// ```
|
|
21
|
+
//
|
|
22
|
+
// A `# comment` or blank line inside the block is ignored. When a delta has
|
|
23
|
+
// no ops block, apply falls back to the manual-merge pointer (backward
|
|
24
|
+
// compatible).
|
|
25
|
+
|
|
26
|
+
const HEADER_RE = (name) =>
|
|
27
|
+
new RegExp(`^(\\s*(?:-\\s+)?\\*\\*${escapeRe(name)}:\\*\\*)\\s*(.*)$`, "m");
|
|
28
|
+
|
|
29
|
+
function escapeRe(s) {
|
|
30
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Pull the operations out of a delta body. Returns an array of parsed ops
|
|
34
|
+
// (or an empty array when there is no `ops` block). Parsing is line-based and
|
|
35
|
+
// tolerant of CRLF.
|
|
36
|
+
export function extractOps(deltaText) {
|
|
37
|
+
const block = matchOpsBlock(deltaText);
|
|
38
|
+
if (block === null) return [];
|
|
39
|
+
const ops = [];
|
|
40
|
+
for (const raw of block.split(/\r?\n/)) {
|
|
41
|
+
const line = raw.trim();
|
|
42
|
+
if (line === "" || line.startsWith("#") || line.startsWith("<!--")) continue;
|
|
43
|
+
const op = parseOpLine(line);
|
|
44
|
+
ops.push(op); // includes { verb: null, error } for unknown lines
|
|
45
|
+
}
|
|
46
|
+
return ops;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Find the body of the first ```ops fenced block, or null if absent. The
|
|
50
|
+
// info string must be exactly "ops" (case-insensitive) so a normal code
|
|
51
|
+
// fence in the prose is never mistaken for operations.
|
|
52
|
+
function matchOpsBlock(text) {
|
|
53
|
+
const m = text.match(/^[ \t]*```[ \t]*ops[ \t]*\r?\n([\s\S]*?)^[ \t]*```[ \t]*$/m);
|
|
54
|
+
return m ? m[1] : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseOpLine(line) {
|
|
58
|
+
// `<verb> <rest>` — the verb is the first whitespace-delimited token.
|
|
59
|
+
const sp = line.indexOf(" ");
|
|
60
|
+
const verb = (sp < 0 ? line : line.slice(0, sp)).toLowerCase();
|
|
61
|
+
const rest = sp < 0 ? "" : line.slice(sp + 1).trim();
|
|
62
|
+
|
|
63
|
+
switch (verb) {
|
|
64
|
+
case "set-header": {
|
|
65
|
+
// set-header <Name>: <value>
|
|
66
|
+
const colon = rest.indexOf(":");
|
|
67
|
+
if (colon < 0) return { verb, error: `set-header needs "<Name>: <value>" (got "${rest}")` };
|
|
68
|
+
const name = rest.slice(0, colon).trim();
|
|
69
|
+
const value = rest.slice(colon + 1).trim();
|
|
70
|
+
if (!name) return { verb, error: "set-header is missing the header name" };
|
|
71
|
+
return { verb, name, value };
|
|
72
|
+
}
|
|
73
|
+
case "bump-version": {
|
|
74
|
+
const level = rest.toLowerCase();
|
|
75
|
+
if (!["major", "minor", "patch"].includes(level)) {
|
|
76
|
+
return { verb, error: `bump-version needs major|minor|patch (got "${rest}")` };
|
|
77
|
+
}
|
|
78
|
+
return { verb, level };
|
|
79
|
+
}
|
|
80
|
+
case "set-criterion":
|
|
81
|
+
case "replace-criterion": {
|
|
82
|
+
// <verb> <n>: <value>
|
|
83
|
+
const colon = rest.indexOf(":");
|
|
84
|
+
if (colon < 0) return { verb, error: `${verb} needs "<n>: <value>" (got "${rest}")` };
|
|
85
|
+
const n = Number(rest.slice(0, colon).trim());
|
|
86
|
+
const value = rest.slice(colon + 1).trim();
|
|
87
|
+
if (!Number.isInteger(n) || n < 1) return { verb, error: `${verb} needs a positive criterion number (got "${rest}")` };
|
|
88
|
+
if (!value) return { verb, error: `${verb} ${n} is missing a value` };
|
|
89
|
+
return { verb, n, value };
|
|
90
|
+
}
|
|
91
|
+
case "append-criterion": {
|
|
92
|
+
if (!rest) return { verb, error: "append-criterion is missing its text" };
|
|
93
|
+
return { verb, value: rest };
|
|
94
|
+
}
|
|
95
|
+
default:
|
|
96
|
+
return { verb: null, error: `unknown operation "${verb}"` };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Apply a parsed op list to a spec's Markdown text. Pure: returns
|
|
101
|
+
// { text, applied: string[], errors: string[] } and never throws. A failed
|
|
102
|
+
// op is recorded in `errors` and leaves the text unchanged for that op, so
|
|
103
|
+
// `change apply` can refuse to write a partially-mutated spec.
|
|
104
|
+
export function applyOps(specText, ops) {
|
|
105
|
+
let text = specText;
|
|
106
|
+
const applied = [];
|
|
107
|
+
const errors = [];
|
|
108
|
+
for (const op of ops) {
|
|
109
|
+
if (op.error || !op.verb) {
|
|
110
|
+
errors.push(op.error ?? "malformed operation");
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const res = applyOne(text, op);
|
|
114
|
+
if (res.error) errors.push(res.error);
|
|
115
|
+
else {
|
|
116
|
+
text = res.text;
|
|
117
|
+
applied.push(res.summary);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return { text, applied, errors };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function applyOne(text, op) {
|
|
124
|
+
switch (op.verb) {
|
|
125
|
+
case "set-header":
|
|
126
|
+
return setHeader(text, op.name, op.value);
|
|
127
|
+
case "bump-version":
|
|
128
|
+
return bumpVersion(text, op.level);
|
|
129
|
+
case "set-criterion":
|
|
130
|
+
return setCriterionMark(text, op.n, op.value);
|
|
131
|
+
case "replace-criterion":
|
|
132
|
+
return replaceCriterion(text, op.n, op.value);
|
|
133
|
+
case "append-criterion":
|
|
134
|
+
return appendCriterion(text, op.value);
|
|
135
|
+
default:
|
|
136
|
+
return { error: `unknown operation "${op.verb}"` };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Replace the value of a `**Name:** ...` header line (dash-optional),
|
|
141
|
+
// preserving the original prefix. Errors when the header is absent — a
|
|
142
|
+
// set-header that silently does nothing is exactly the drift this prevents.
|
|
143
|
+
export function setHeader(text, name, value) {
|
|
144
|
+
const re = HEADER_RE(name);
|
|
145
|
+
if (!re.test(text)) {
|
|
146
|
+
return { error: `set-header: no "**${name}:**" header in the spec to set` };
|
|
147
|
+
}
|
|
148
|
+
const next = text.replace(re, (_m, prefix) => `${prefix} ${value}`.replace(/\s+$/, ""));
|
|
149
|
+
return { text: next, summary: `set ${name}: ${value}` };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function bumpVersion(text, level) {
|
|
153
|
+
const re = HEADER_RE("Version");
|
|
154
|
+
const m = text.match(re);
|
|
155
|
+
if (!m) return { error: "bump-version: no **Version:** header in the spec" };
|
|
156
|
+
const cur = (m[2] ?? "").trim();
|
|
157
|
+
const parts = cur.split(".").map((x) => Number(x));
|
|
158
|
+
if (parts.length !== 3 || parts.some((x) => !Number.isInteger(x))) {
|
|
159
|
+
return { error: `bump-version: current version "${cur}" is not semver X.Y.Z` };
|
|
160
|
+
}
|
|
161
|
+
let [maj, min, pat] = parts;
|
|
162
|
+
if (level === "major") { maj += 1; min = 0; pat = 0; }
|
|
163
|
+
else if (level === "minor") { min += 1; pat = 0; }
|
|
164
|
+
else { pat += 1; }
|
|
165
|
+
const next = `${maj}.${min}.${pat}`;
|
|
166
|
+
const out = text.replace(re, (_m, prefix) => `${prefix} ${next}`);
|
|
167
|
+
return { text: out, summary: `bump-version ${level}: ${cur} -> ${next}` };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// The acceptance-criteria block: lines under "## Acceptance criteria" that
|
|
171
|
+
// begin with "<n>. ". Returns { start, end, items: [{ n, lineIndex, line }] }
|
|
172
|
+
// where lines is the full split array. Null when the section is absent.
|
|
173
|
+
function locateCriteria(text) {
|
|
174
|
+
const lines = text.split(/\r?\n/);
|
|
175
|
+
let inSection = false;
|
|
176
|
+
let sectionStart = -1;
|
|
177
|
+
const items = [];
|
|
178
|
+
for (let i = 0; i < lines.length; i++) {
|
|
179
|
+
const line = lines[i];
|
|
180
|
+
if (/^##\s+/.test(line)) {
|
|
181
|
+
if (inSection) { inSection = false; }
|
|
182
|
+
if (/^##\s+Acceptance criteria\b/i.test(line)) { inSection = true; sectionStart = i; }
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (!inSection) continue;
|
|
186
|
+
const m = line.match(/^(\s*)(\d+)\.\s+(.*)$/);
|
|
187
|
+
if (m) items.push({ n: Number(m[2]), lineIndex: i, indent: m[1] });
|
|
188
|
+
}
|
|
189
|
+
if (sectionStart < 0) return null;
|
|
190
|
+
return { lines, items };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// set-criterion <n>: <mark> — replace the [mark] token on criterion n.
|
|
194
|
+
// Accepts "verified"/"[verified]"; if the criterion has no [..] token, one
|
|
195
|
+
// is inserted right after the "n. " prefix.
|
|
196
|
+
export function setCriterionMark(text, n, markRaw) {
|
|
197
|
+
const loc = locateCriteria(text);
|
|
198
|
+
if (!loc) return { error: "set-criterion: spec has no '## Acceptance criteria' section" };
|
|
199
|
+
const item = loc.items.find((it) => it.n === n);
|
|
200
|
+
if (!item) return { error: `set-criterion: no criterion #${n} found` };
|
|
201
|
+
const mark = markRaw.replace(/^\[|\]$/g, "").trim();
|
|
202
|
+
const line = loc.lines[item.lineIndex];
|
|
203
|
+
let next;
|
|
204
|
+
if (/\[[^\]]*\]/.test(line)) {
|
|
205
|
+
next = line.replace(/\[[^\]]*\]/, `[${mark}]`);
|
|
206
|
+
} else {
|
|
207
|
+
next = line.replace(/^(\s*\d+\.\s+)/, `$1[${mark}] `);
|
|
208
|
+
}
|
|
209
|
+
loc.lines[item.lineIndex] = next;
|
|
210
|
+
return { text: loc.lines.join("\n"), summary: `set-criterion ${n}: ${mark}` };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// replace-criterion <n>: <text> — replace the prose of criterion n (keeping
|
|
214
|
+
// its "n. " numbering).
|
|
215
|
+
export function replaceCriterion(text, n, value) {
|
|
216
|
+
const loc = locateCriteria(text);
|
|
217
|
+
if (!loc) return { error: "replace-criterion: spec has no '## Acceptance criteria' section" };
|
|
218
|
+
const item = loc.items.find((it) => it.n === n);
|
|
219
|
+
if (!item) return { error: `replace-criterion: no criterion #${n} found` };
|
|
220
|
+
loc.lines[item.lineIndex] = `${item.indent}${n}. ${value}`;
|
|
221
|
+
return { text: loc.lines.join("\n"), summary: `replace-criterion ${n}` };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// append-criterion <text> — add a new numbered criterion after the last one
|
|
225
|
+
// in the section.
|
|
226
|
+
export function appendCriterion(text, value) {
|
|
227
|
+
const loc = locateCriteria(text);
|
|
228
|
+
if (!loc) return { error: "append-criterion: spec has no '## Acceptance criteria' section" };
|
|
229
|
+
if (loc.items.length === 0) {
|
|
230
|
+
return { error: "append-criterion: no existing numbered criterion to append after" };
|
|
231
|
+
}
|
|
232
|
+
const last = loc.items[loc.items.length - 1];
|
|
233
|
+
const nextN = Math.max(...loc.items.map((it) => it.n)) + 1;
|
|
234
|
+
loc.lines.splice(last.lineIndex + 1, 0, `${last.indent}${nextN}. ${value}`);
|
|
235
|
+
return { text: loc.lines.join("\n"), summary: `append-criterion ${nextN}` };
|
|
236
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
|
|
5
|
+
// Single source for "which CLI version is running." Read from the package
|
|
6
|
+
// manifest so it tracks the published version with no extra wiring. Used to
|
|
7
|
+
// stamp index.json (so an artifact tree records the framework that manages
|
|
8
|
+
// it — `doctrina validate` warns and `index rebuild` migrates when the stamp
|
|
9
|
+
// falls behind the running CLI) and to print `doctrina --version`.
|
|
10
|
+
|
|
11
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
|
|
13
|
+
export function cliVersion() {
|
|
14
|
+
// src/lib/version.js -> packages/doctrina-cli/package.json
|
|
15
|
+
const pkgPath = path.resolve(here, "..", "..", "package.json");
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(readFileSync(pkgPath, "utf8")).version ?? "0.0.0";
|
|
18
|
+
} catch {
|
|
19
|
+
return "0.0.0";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -8,8 +8,19 @@ For ADDED: include the full new spec body below. On apply, the body is
|
|
|
8
8
|
written verbatim to the target path.
|
|
9
9
|
|
|
10
10
|
For MODIFIED: include only the sections that change, with clear
|
|
11
|
-
before/after markers.
|
|
12
|
-
|
|
11
|
+
before/after markers. Bookkeeping edits (flip a criterion, bump the
|
|
12
|
+
version, set a header) can be made mechanical by adding a fenced `ops`
|
|
13
|
+
block — on apply the CLI executes it against the target spec, all ops or
|
|
14
|
+
none (ADR 0007). Anything the ops verbs cannot express (rewriting prose)
|
|
15
|
+
stays a by-hand merge; a MODIFIED delta with no `ops` block prints a
|
|
16
|
+
manual-merge pointer.
|
|
17
|
+
|
|
18
|
+
```ops
|
|
19
|
+
set-header Implementation: verified — durable adapter (`src/db.ts`)
|
|
20
|
+
bump-version minor
|
|
21
|
+
set-criterion 1: verified
|
|
22
|
+
append-criterion [unverified] new signal — verified by `test/x.test.ts`
|
|
23
|
+
```
|
|
13
24
|
|
|
14
25
|
For REMOVED: the body may be empty; on apply, the target spec file is
|
|
15
26
|
deleted and the capability is recorded in the change archive only.
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
- **Supersedes:** —
|
|
7
7
|
- **Superseded by:** —
|
|
8
8
|
- **Evidence:** —
|
|
9
|
+
- **Landed:** —
|
|
9
10
|
|
|
10
11
|
<!--
|
|
11
12
|
Evidence anchors the decision to reality. Once accepted, cite the file(s)
|
|
@@ -14,6 +15,13 @@ that prove this decision is actually implemented, in backticks, e.g.
|
|
|
14
15
|
`doctrina validate` warns when cited evidence is missing on disk (the
|
|
15
16
|
decision drifted from the code) or when an accepted ADR cites none. For a
|
|
16
17
|
process decision with no code artifact, write "n/a — <why>".
|
|
18
|
+
|
|
19
|
+
Landed is the non-mutating "this decision is now implemented and verified
|
|
20
|
+
here" stamp. Many ADRs are accepted at design time with "Evidence: n/a — no
|
|
21
|
+
implementation yet"; once the capability ships, run
|
|
22
|
+
"doctrina decision land NNNN `path/to/proof.ts` ..." to record the date and
|
|
23
|
+
proof without editing the immutable body. A filled Landed satisfies the
|
|
24
|
+
accepted-ADR evidence check.
|
|
17
25
|
-->
|
|
18
26
|
|
|
19
27
|
## Context
|
|
@@ -28,7 +28,12 @@ Out of scope (deferred or rejected):
|
|
|
28
28
|
|
|
29
29
|
## Success criteria
|
|
30
30
|
|
|
31
|
-
<!--
|
|
31
|
+
<!--
|
|
32
|
+
How you will know this project is working. Prefer measurable signals.
|
|
33
|
+
Tag a bullet with a short stable id — "- [SC1] ..." — to make it an intent
|
|
34
|
+
anchor that capability specs cite via "**Realizes:** SC1" (`doctrina trace`;
|
|
35
|
+
ADR 0006). Untagged bullets are fine; anchors are opt-in.
|
|
36
|
+
-->
|
|
32
37
|
|
|
33
38
|
## Delivery order (walking skeleton)
|
|
34
39
|
|
|
@@ -17,6 +17,10 @@ An "active" spec left at "planned" is an inventory claim with nothing
|
|
|
17
17
|
behind it, and `doctrina validate` warns. To record a deliberate gap and
|
|
18
18
|
silence the warning, add a note after the value, e.g.
|
|
19
19
|
"**Implementation:** planned — backend deferred to Q3, see ADR 0007".
|
|
20
|
+
|
|
21
|
+
Optional: trace this capability to product intent by adding a header
|
|
22
|
+
"**Realizes:** SC1, SC3" that names the product.md anchors it delivers
|
|
23
|
+
(`doctrina trace`; ADR 0006).
|
|
20
24
|
-->
|
|
21
25
|
|
|
22
26
|
## Purpose
|