doctrina-cli 0.15.1 → 0.17.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 +27 -72
- package/package.json +3 -3
- package/src/commands/adapter.js +27 -3
- package/src/commands/analyze.js +16 -212
- package/src/commands/change.js +144 -267
- package/src/commands/ci.js +182 -0
- package/src/commands/clarify.js +65 -21
- package/src/commands/close.js +349 -45
- package/src/commands/completion.js +8 -2
- package/src/commands/context.js +284 -111
- package/src/commands/contract.js +95 -75
- package/src/commands/coverage.js +58 -273
- package/src/commands/decision.js +66 -7
- package/src/commands/doctor.js +293 -130
- package/src/commands/handoff.js +10 -57
- package/src/commands/hooks.js +17 -3
- package/src/commands/index-rebuild.js +55 -44
- package/src/commands/init.js +88 -16
- package/src/commands/intake.js +85 -99
- package/src/commands/intent.js +14 -1
- package/src/commands/metrics.js +65 -88
- package/src/commands/next.js +105 -147
- package/src/commands/prime.js +23 -91
- package/src/commands/report.js +40 -109
- package/src/commands/review.js +68 -36
- package/src/commands/search.js +18 -4
- package/src/commands/show.js +24 -9
- package/src/commands/skill.js +23 -39
- package/src/commands/spec.js +38 -11
- package/src/commands/status.js +69 -171
- package/src/commands/templates.js +35 -148
- package/src/commands/trace.js +36 -71
- package/src/commands/triage.js +4 -82
- package/src/commands/upgrade.js +31 -6
- package/src/commands/validate.js +44 -877
- package/src/commands/verify.js +60 -138
- package/src/commands/why.js +2 -2
- package/src/commands/work.js +243 -267
- package/src/index.js +139 -12
- package/src/lib/actions.js +490 -0
- package/src/lib/adapters.js +0 -11
- package/src/lib/adr-guard.js +19 -0
- package/src/lib/agent-changelog.js +133 -0
- package/src/lib/analysis.js +264 -0
- package/src/lib/args.js +14 -0
- package/src/lib/change-ops.js +133 -0
- package/src/lib/commands.js +135 -10
- package/src/lib/config.js +206 -0
- package/src/lib/constitution-model.js +102 -0
- package/src/lib/coverage-model.js +431 -0
- package/src/lib/criteria.js +23 -0
- package/src/lib/doc-model.js +370 -3
- package/src/lib/docs-impact.js +270 -27
- package/src/lib/ears.js +24 -1
- package/src/lib/flag-catalog.js +0 -16
- package/src/lib/fs-ops.js +58 -1
- package/src/lib/gates.js +231 -29
- package/src/lib/git.js +132 -0
- package/src/lib/index-json.js +42 -24
- package/src/lib/intake-model.js +155 -0
- package/src/lib/json-out.js +100 -2
- package/src/lib/ledger.js +192 -0
- package/src/lib/lexicon.js +228 -0
- package/src/lib/metrics-model.js +189 -0
- package/src/lib/names.js +42 -0
- package/src/lib/playbook.js +97 -0
- package/src/lib/project.js +105 -0
- package/src/lib/reproducibility.js +137 -0
- package/src/lib/runtime.js +191 -5
- package/src/lib/scan.js +328 -25
- package/src/lib/signoff.js +147 -0
- package/src/lib/snapshot.js +231 -0
- package/src/lib/spec-ops.js +72 -8
- package/src/lib/templates-model.js +289 -0
- package/src/lib/trace-model.js +95 -0
- package/src/lib/triage-model.js +121 -0
- package/src/lib/usage.js +6 -4
- package/src/lib/validation-model.js +1381 -0
- package/src/lib/version.js +22 -0
- package/src/lib/views.js +413 -0
- package/src/lib/work-model.js +102 -0
- package/templates/AGENTS.md.template +21 -28
- package/templates/README.md +35 -0
- package/templates/adapters/claude/.claude/commands/doctrina-work.md.template +6 -3
- package/templates/adapters/cursor/.cursor/commands/doctrina-work.md.template +4 -3
- package/templates/change/proposal.md.template +16 -0
- package/templates/change/tasks.md.template +3 -8
- package/templates/contract.md.template +2 -2
- package/templates/hooks/pre-commit.sample +16 -2
- package/templates/playbooks/bootstrap.md.template +47 -0
- package/templates/playbooks/chore.md.template +24 -0
- package/templates/playbooks/work.md.template +83 -0
- package/src/commands/constitution.js +0 -145
package/src/lib/scan.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
-
import { getHeader, getSection } from "./doc-model.js";
|
|
2
|
+
import { getHeader, getSection, parseChangeTitle } from "./doc-model.js";
|
|
3
|
+
import { projectName } from "./project.js";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { readdirSync } from "node:fs";
|
|
5
6
|
import { isDir, isFile, read, walk } from "./fs-ops.js";
|
|
6
7
|
import { today } from "./dates.js";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
8
|
+
import { cliVersion, newestVersion } from "./version.js";
|
|
9
|
+
import { load } from "./index-json.js";
|
|
10
|
+
import { parseFrontmatter, isScaffoldValue } from "./doc-model.js";
|
|
11
|
+
import { parseCapabilityFromDelta } from "./doc-model.js";
|
|
12
|
+
import { parseOperation } from "./doc-model.js";
|
|
13
|
+
import { git, gitLines, isRepo, GIT_STATE } from "./git.js";
|
|
9
14
|
|
|
10
15
|
// Header reading lives in ONE place now (lib/doc-model.js, audit item M3).
|
|
11
16
|
// These two names survive because dozens of call sites use them and the
|
|
@@ -25,6 +30,44 @@ export function parseDependsOn(text) {
|
|
|
25
30
|
return raw.match(/[a-z][a-z0-9][a-z0-9-]*/g) ?? [];
|
|
26
31
|
}
|
|
27
32
|
|
|
33
|
+
/**
|
|
34
|
+
* The source globs a spec DECLARES as its own, from the optional
|
|
35
|
+
* `**Source:**` header — comma-separated, `*` inside a segment and `**`
|
|
36
|
+
* across directories (the same minimal glob the contract's Selectors use).
|
|
37
|
+
*
|
|
38
|
+
* Doctrina infers nothing about which code belongs to which capability
|
|
39
|
+
* (ADR 0027, and the same principle ADR 0023 applies to the runtime
|
|
40
|
+
* surface). Before this header the only signals were the capability name
|
|
41
|
+
* appearing as a path segment and the spec happening to cite a filename,
|
|
42
|
+
* which left 80 of this repository's 92 source files owned by nobody — so
|
|
43
|
+
* `review`, the gate that asks whether the spec kept up with the code,
|
|
44
|
+
* could not see the code it was reviewing.
|
|
45
|
+
*
|
|
46
|
+
* Returns [] when the header is absent, "n/a", or "—".
|
|
47
|
+
*/
|
|
48
|
+
export function parseSourceGlobs(text) {
|
|
49
|
+
const raw = specHeader(text, "Source");
|
|
50
|
+
if (!raw || /^n\/a\b/i.test(raw.trim()) || raw.trim() === "—") return [];
|
|
51
|
+
// Split on commas at brace depth ZERO: a comma inside `{a,b}` separates
|
|
52
|
+
// alternatives of ONE glob, not two globs, and splitting on it blindly
|
|
53
|
+
// shreds every grouped declaration into fragments that match nothing.
|
|
54
|
+
const out = [];
|
|
55
|
+
let cur = "";
|
|
56
|
+
let depth = 0;
|
|
57
|
+
for (const ch of raw) {
|
|
58
|
+
if (ch === "{") depth += 1;
|
|
59
|
+
else if (ch === "}") depth = Math.max(0, depth - 1);
|
|
60
|
+
if (ch === "," && depth === 0) {
|
|
61
|
+
out.push(cur);
|
|
62
|
+
cur = "";
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
cur += ch;
|
|
66
|
+
}
|
|
67
|
+
out.push(cur);
|
|
68
|
+
return out.map((g) => g.trim().replace(/^`|`$/g, "").trim()).filter(Boolean);
|
|
69
|
+
}
|
|
70
|
+
|
|
28
71
|
function dirEntries(dir) {
|
|
29
72
|
if (!isDir(dir)) return [];
|
|
30
73
|
return readdirSync(dir).filter((e) => !e.startsWith(".")).sort();
|
|
@@ -95,6 +138,54 @@ export function decisionEntry(text, basename, prev, date) {
|
|
|
95
138
|
return entry;
|
|
96
139
|
}
|
|
97
140
|
|
|
141
|
+
/**
|
|
142
|
+
* The index entry for ONE open change, derived from its proposal. Same
|
|
143
|
+
* discipline as `decisionEntry` above and for the same reason: `deriveIndex`
|
|
144
|
+
* builds the whole list from it, and `doctrina work` / `change new` register
|
|
145
|
+
* a new change THROUGH it instead of assembling a look-alike by hand.
|
|
146
|
+
*
|
|
147
|
+
* Two constructors for one record shape is how the `lane` field (change 0042)
|
|
148
|
+
* — added to the deriver and not to the writer — turned every freshly opened
|
|
149
|
+
* change into index drift the moment it was written: `doctrina work` followed
|
|
150
|
+
* by `doctrina validate` errored on a tree nobody had touched.
|
|
151
|
+
*
|
|
152
|
+
* @param {string} proposal Text of the change's proposal.md ("" when absent).
|
|
153
|
+
* @param {string} id The change id (its directory name).
|
|
154
|
+
* @param {object|null} prev The entry already in the index, when there is one.
|
|
155
|
+
* @param {string} date Fallback for a proposal carrying no Date header.
|
|
156
|
+
*/
|
|
157
|
+
export function changeEntry(proposal, id, prev, date) {
|
|
158
|
+
const title = parseChangeTitle(proposal);
|
|
159
|
+
return {
|
|
160
|
+
id,
|
|
161
|
+
title: title ?? prev?.title ?? id,
|
|
162
|
+
path: `.doctrina/changes/${id}`,
|
|
163
|
+
status: listHeader(proposal, "Status") ?? prev?.status ?? "proposed",
|
|
164
|
+
opened: listHeader(proposal, "Date") ?? prev?.opened ?? date,
|
|
165
|
+
// The lane the change was born in (change 0042). Optional: a change
|
|
166
|
+
// opened before the field existed simply has none, and every consumer
|
|
167
|
+
// treats its absence as "unknown" rather than as a lane.
|
|
168
|
+
...laneOf(proposal, prev),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
// An archived change, as an index entry — the one constructor both the
|
|
174
|
+
// rebuild and `change archive` use. They each built their own, and neither
|
|
175
|
+
// carried the lane: an archived change lost the lane it was born in, so the
|
|
176
|
+
// report's "Lanes" section counted the whole history as unknown (change
|
|
177
|
+
// 0192), which is the question change 0042 recorded the lane to answer.
|
|
178
|
+
export function archivedChangeEntry(proposal, { id, archiveName, applied, specsAffected, prev = null, title = undefined }) {
|
|
179
|
+
return {
|
|
180
|
+
id,
|
|
181
|
+
title: (title === undefined ? parseChangeTitle(proposal) : title) ?? prev?.title ?? id,
|
|
182
|
+
path: `.doctrina/changes/archive/${archiveName}`,
|
|
183
|
+
status: "applied",
|
|
184
|
+
applied,
|
|
185
|
+
specs_affected: specsAffected,
|
|
186
|
+
...laneOf(proposal, prev),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
98
189
|
|
|
99
190
|
// Regenerate the index object from the artifacts on disk. The files are
|
|
100
191
|
// the source of truth; fields with no on-disk source (project name,
|
|
@@ -106,7 +197,7 @@ export function deriveIndex(projectRoot, current) {
|
|
|
106
197
|
|
|
107
198
|
const out = {
|
|
108
199
|
$schema_version: current?.$schema_version ?? "0.1.0",
|
|
109
|
-
project:
|
|
200
|
+
project: projectName(projectRoot, current),
|
|
110
201
|
framework_version: current?.framework_version ?? "0.0.0",
|
|
111
202
|
last_updated: current?.last_updated ?? date,
|
|
112
203
|
// Project settings, carried over verbatim. `deriveIndex` rebuilds the
|
|
@@ -166,6 +257,10 @@ export function deriveIndex(projectRoot, current) {
|
|
|
166
257
|
// dependents of a touched capability.
|
|
167
258
|
const depends = specHeader(text, "Depends on") !== null ? parseDependsOn(text) : prev?.depends_on;
|
|
168
259
|
if (depends && depends.length) entry.depends_on = depends;
|
|
260
|
+
// Source: the code this capability owns (change 0077). Declared, never
|
|
261
|
+
// inferred — the same discipline ADR 0023 applies to the runtime surface.
|
|
262
|
+
const source = specHeader(text, "Source") !== null ? parseSourceGlobs(text) : prev?.source;
|
|
263
|
+
if (source && source.length) entry.source = source;
|
|
169
264
|
out.artifacts.specs.push(entry);
|
|
170
265
|
}
|
|
171
266
|
|
|
@@ -187,14 +282,7 @@ export function deriveIndex(projectRoot, current) {
|
|
|
187
282
|
const prev = (cur.changes ?? []).find((c) => c.id === id);
|
|
188
283
|
const proposalPath = path.join(changesDir, id, "proposal.md");
|
|
189
284
|
const proposal = isFile(proposalPath) ? read(proposalPath) : "";
|
|
190
|
-
|
|
191
|
-
out.artifacts.changes.push({
|
|
192
|
-
id,
|
|
193
|
-
title: titleMatch ? titleMatch[1].trim() : prev?.title ?? id,
|
|
194
|
-
path: `.doctrina/changes/${id}`,
|
|
195
|
-
status: listHeader(proposal, "Status") ?? prev?.status ?? "proposed",
|
|
196
|
-
opened: listHeader(proposal, "Date") ?? prev?.opened ?? date,
|
|
197
|
-
});
|
|
285
|
+
out.artifacts.changes.push(changeEntry(proposal, id, prev, date));
|
|
198
286
|
}
|
|
199
287
|
|
|
200
288
|
// Archived changes — folder name carries the applied date and id.
|
|
@@ -205,7 +293,7 @@ export function deriveIndex(projectRoot, current) {
|
|
|
205
293
|
const prev = (cur.changes_archive ?? []).find((c) => c.path?.endsWith(name));
|
|
206
294
|
const proposalPath = path.join(archiveDir, name, "proposal.md");
|
|
207
295
|
const proposal = isFile(proposalPath) ? read(proposalPath) : "";
|
|
208
|
-
const
|
|
296
|
+
const title = parseChangeTitle(proposal);
|
|
209
297
|
const specsAffected = [];
|
|
210
298
|
for (const deltaPath of walk(path.join(archiveDir, name, "specs"))) {
|
|
211
299
|
if (!deltaPath.endsWith("delta.md")) continue;
|
|
@@ -215,14 +303,9 @@ export function deriveIndex(projectRoot, current) {
|
|
|
215
303
|
specsAffected.push({ capability, operation: parseOperation(text) ?? "MODIFIED" });
|
|
216
304
|
}
|
|
217
305
|
}
|
|
218
|
-
out.artifacts.changes_archive.push({
|
|
219
|
-
id: m[2],
|
|
220
|
-
|
|
221
|
-
path: `.doctrina/changes/archive/${name}`,
|
|
222
|
-
status: "applied",
|
|
223
|
-
applied: m[1],
|
|
224
|
-
specs_affected: specsAffected,
|
|
225
|
-
});
|
|
306
|
+
out.artifacts.changes_archive.push(archivedChangeEntry(proposal, {
|
|
307
|
+
id: m[2], archiveName: name, applied: m[1], specsAffected, prev, title,
|
|
308
|
+
}));
|
|
226
309
|
}
|
|
227
310
|
|
|
228
311
|
// Contracts — the integration/runtime surface (ports, env, interfaces)
|
|
@@ -241,18 +324,26 @@ export function deriveIndex(projectRoot, current) {
|
|
|
241
324
|
});
|
|
242
325
|
}
|
|
243
326
|
|
|
244
|
-
// Skills — frontmatter description wins.
|
|
327
|
+
// Skills — a WRITTEN frontmatter description wins. The rebuild is the one
|
|
328
|
+
// reconciliation (change 0177 retired `skill sync`, which did a subset of
|
|
329
|
+
// this), so it carries sync's rule: a description still in the scaffold's
|
|
330
|
+
// `<...>` form never overwrites one somebody wrote (change 0111), and the
|
|
331
|
+
// entry's date moves when its description does.
|
|
245
332
|
const skillsDir = path.join(dot, "skills");
|
|
246
333
|
for (const f of walk(skillsDir)) {
|
|
247
334
|
if (!f.endsWith(".md")) continue;
|
|
248
335
|
const id = path.basename(f, ".md");
|
|
249
336
|
const prev = (cur.skills ?? []).find((s) => s.id === id);
|
|
337
|
+
const written = parseFrontmatter(read(f), "description");
|
|
338
|
+
const kept = prev?.description && !isScaffoldValue(prev.description) ? prev.description : null;
|
|
339
|
+
const description = written && !isScaffoldValue(written)
|
|
340
|
+
? written
|
|
341
|
+
: kept ?? written ?? "<edit me — one-sentence summary>";
|
|
250
342
|
out.artifacts.skills.push({
|
|
251
343
|
id,
|
|
252
344
|
path: `.doctrina/skills/${id}.md`,
|
|
253
|
-
description
|
|
254
|
-
|
|
255
|
-
last_updated: prev?.last_updated ?? date,
|
|
345
|
+
description,
|
|
346
|
+
last_updated: prev && prev.description === description ? prev.last_updated ?? date : date,
|
|
256
347
|
});
|
|
257
348
|
}
|
|
258
349
|
|
|
@@ -296,3 +387,215 @@ export function indexesMatch(a, b) {
|
|
|
296
387
|
};
|
|
297
388
|
return stableStringify(normalize(a)) === stableStringify(normalize(b));
|
|
298
389
|
}
|
|
390
|
+
|
|
391
|
+
// The lane recorded in a proposal header (change 0042), as an index field —
|
|
392
|
+
// or nothing at all. A change opened before the field existed, or one whose
|
|
393
|
+
// header is still the empty scaffold, has no lane, and "unknown" is the
|
|
394
|
+
// honest answer rather than a default that would poison the mix.
|
|
395
|
+
function laneOf(proposal, prev) {
|
|
396
|
+
const raw = (listHeader(proposal, "Lane") ?? "").trim();
|
|
397
|
+
if (!raw) return prev?.lane ? { lane: prev.lane } : {};
|
|
398
|
+
return { lane: raw };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Is the on-disk index still what the tree derives to, and if not, how does
|
|
403
|
+
* it differ?
|
|
404
|
+
*
|
|
405
|
+
* `index rebuild` renders this (and writes the derived index when asked);
|
|
406
|
+
* `doctor` reports it as one row. Before this it was the command's private
|
|
407
|
+
* business, so `doctor` answered "has the index drifted?" by spawning the
|
|
408
|
+
* CLI again and reading an exit code (audit finding F4).
|
|
409
|
+
*
|
|
410
|
+
* The framework stamp is migrated to the running CLI here rather than in the
|
|
411
|
+
* caller: deriveIndex carries the old value over so `next` does not nag on a
|
|
412
|
+
* version-only difference, and overriding it lets a stale stamp COUNT as
|
|
413
|
+
* drift, so `index rebuild` both reports and fixes it instead of
|
|
414
|
+
* short-circuiting on "nothing to do".
|
|
415
|
+
*
|
|
416
|
+
* @param {string} projectRoot
|
|
417
|
+
* @returns {{ok: boolean, drift: string[], derived: any, current: any, unreadable: string|null}}
|
|
418
|
+
*/
|
|
419
|
+
export function collectIndexDrift(projectRoot) {
|
|
420
|
+
let current = null;
|
|
421
|
+
let unreadable = null;
|
|
422
|
+
try {
|
|
423
|
+
current = load(projectRoot);
|
|
424
|
+
} catch (err) {
|
|
425
|
+
unreadable = err.message;
|
|
426
|
+
}
|
|
427
|
+
const derived = deriveIndex(projectRoot, current);
|
|
428
|
+
// A stamp ahead of this CLI is not drift (change 0116): the tree is
|
|
429
|
+
// managed by a newer release, and `--check` in CI must not go red because
|
|
430
|
+
// one teammate's CLI is older than the one that last wrote the index.
|
|
431
|
+
derived.framework_version = newestVersion(current?.framework_version, cliVersion());
|
|
432
|
+
if (indexesMatch(derived, current)) {
|
|
433
|
+
return { ok: true, drift: [], derived, current, unreadable };
|
|
434
|
+
}
|
|
435
|
+
return { ok: false, drift: describeDrift(current, derived), derived, current, unreadable };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Human-readable category-level drift between the on-disk index and the
|
|
439
|
+
// derived one: added / removed / changed entry ids.
|
|
440
|
+
function describeDrift(current, derived) {
|
|
441
|
+
const lines = [];
|
|
442
|
+
if (!current) return ["index.json missing or unreadable"];
|
|
443
|
+
if ((current.framework_version ?? null) !== (derived.framework_version ?? null)) {
|
|
444
|
+
lines.push(`framework_version: ${current.framework_version ?? "unset"} -> ${derived.framework_version}`);
|
|
445
|
+
}
|
|
446
|
+
const categories = ["specs", "decisions", "changes", "changes_archive", "skills"];
|
|
447
|
+
for (const cat of categories) {
|
|
448
|
+
const cur = new Map((current.artifacts?.[cat] ?? []).map((e) => [e.id, e]));
|
|
449
|
+
const der = new Map((derived.artifacts?.[cat] ?? []).map((e) => [e.id, e]));
|
|
450
|
+
for (const id of der.keys()) {
|
|
451
|
+
if (!cur.has(id)) lines.push(`${cat}: "${id}" on disk but not in index`);
|
|
452
|
+
else if (stableStringify(cur.get(id)) !== stableStringify(der.get(id))) {
|
|
453
|
+
lines.push(`${cat}: "${id}" metadata differs from the files`);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
for (const id of cur.keys()) {
|
|
457
|
+
if (!der.has(id)) lines.push(`${cat}: "${id}" in index but not on disk`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (stableStringify(current.artifacts?.product ?? null) !== stableStringify(derived.artifacts.product)) {
|
|
461
|
+
lines.push("product: metadata differs");
|
|
462
|
+
}
|
|
463
|
+
if (lines.length === 0) lines.push("structural difference (key order or missing category)");
|
|
464
|
+
return lines;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* The capabilities that actually have a spec on disk.
|
|
469
|
+
*
|
|
470
|
+
* The one list every command checks a named capability against. `coverage
|
|
471
|
+
* --only` refuses a name that is not in it (change 0090); `context <cap>`
|
|
472
|
+
* refuses the same way, because a scope filter that matches nothing is the
|
|
473
|
+
* same defect wherever it appears. Kept here rather than in either command
|
|
474
|
+
* so the two cannot disagree about which capabilities exist.
|
|
475
|
+
*
|
|
476
|
+
* @param {string} projectRoot
|
|
477
|
+
* @returns {string[]} directory names, in readdir order
|
|
478
|
+
*/
|
|
479
|
+
export function knownCapabilities(projectRoot) {
|
|
480
|
+
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
481
|
+
if (!isDir(specsDir)) return [];
|
|
482
|
+
return readdirSync(specsDir).filter((e) => isFile(path.join(specsDir, e, "spec.md")));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Which capabilities declare a dependency on any of `caps`.
|
|
487
|
+
*
|
|
488
|
+
* The `**Depends on:**` header is the tree's only machine-readable statement
|
|
489
|
+
* that one capability builds on another, and two commands ask the same
|
|
490
|
+
* question of it: `review` notes a dependent whose ground moved, and `close`
|
|
491
|
+
* reports their coverage alongside the change's own. One definition, so they
|
|
492
|
+
* cannot disagree about who depends on what.
|
|
493
|
+
*
|
|
494
|
+
* @param {string} projectRoot
|
|
495
|
+
* @param {Iterable<string>} caps
|
|
496
|
+
* @returns {{capability: string, dependsOn: string[]}[]} sorted by capability
|
|
497
|
+
*/
|
|
498
|
+
export function dependentsOf(projectRoot, caps) {
|
|
499
|
+
const wanted = new Set(caps);
|
|
500
|
+
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
501
|
+
if (wanted.size === 0 || !isDir(specsDir)) return [];
|
|
502
|
+
const out = [];
|
|
503
|
+
for (const cap of readdirSync(specsDir).sort()) {
|
|
504
|
+
if (wanted.has(cap)) continue;
|
|
505
|
+
const specPath = path.join(specsDir, cap, "spec.md");
|
|
506
|
+
if (!isFile(specPath)) continue;
|
|
507
|
+
const deps = parseDependsOn(read(specPath)).filter((d) => wanted.has(d));
|
|
508
|
+
if (deps.length > 0) out.push({ capability: cap, dependsOn: deps });
|
|
509
|
+
}
|
|
510
|
+
return out;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ---------------------------------------------------------------- staged
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* The same question as `collectIndexDrift`, asked of the COMMIT instead of
|
|
517
|
+
* the disk.
|
|
518
|
+
*
|
|
519
|
+
* `index.json` is derived from the whole tree, so it cannot describe a
|
|
520
|
+
* subset of it. Stage some of `.doctrina/` and the index that rides along
|
|
521
|
+
* names artifacts the commit does not carry — and every gate that would
|
|
522
|
+
* catch it reads the working tree, where those artifacts are still present.
|
|
523
|
+
* Four commits of this repository shipped that way, each green locally and
|
|
524
|
+
* each red on checkout: `index.json references missing artifact`.
|
|
525
|
+
*
|
|
526
|
+
* Read with git plumbing, never from disk: `:<path>` is the staged blob and
|
|
527
|
+
* `ls-files --cached` is the file list the commit will have.
|
|
528
|
+
*
|
|
529
|
+
* @param {string} projectRoot
|
|
530
|
+
* @returns {{ok: boolean, applicable: boolean, missing: string[], unindexed: string[], reason: string|null}}
|
|
531
|
+
*/
|
|
532
|
+
export function collectStagedIndexDrift(projectRoot) {
|
|
533
|
+
const nothing = { ok: true, applicable: false, missing: [], unindexed: [], reason: null };
|
|
534
|
+
if (!isRepo(projectRoot)) return { ...nothing, reason: "not a git repository" };
|
|
535
|
+
|
|
536
|
+
const blob = git(projectRoot, ["show", ":.doctrina/index.json"]);
|
|
537
|
+
if (blob.state !== GIT_STATE.OK || !blob.stdout.trim()) {
|
|
538
|
+
// Not staged at all — this commit does not carry an index, so it cannot
|
|
539
|
+
// carry one that disagrees with itself.
|
|
540
|
+
return { ...nothing, reason: "no staged .doctrina/index.json" };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
let staged;
|
|
544
|
+
try {
|
|
545
|
+
staged = JSON.parse(blob.stdout);
|
|
546
|
+
} catch {
|
|
547
|
+
return { ok: false, applicable: true, missing: [], unindexed: [],
|
|
548
|
+
reason: "the staged .doctrina/index.json is not valid JSON" };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const files = new Set(gitLines(projectRoot, ["ls-files", "--cached", "--", ".doctrina"]));
|
|
552
|
+
// An entry's path is a file for specs, ADRs, skills and contracts, and a
|
|
553
|
+
// DIRECTORY for changes — a directory is in the commit when any file under
|
|
554
|
+
// it is.
|
|
555
|
+
const inCommit = (p) => files.has(p) || [...files].some((f) => f.startsWith(`${p}/`));
|
|
556
|
+
|
|
557
|
+
const artifacts = staged.artifacts ?? {};
|
|
558
|
+
const missing = [];
|
|
559
|
+
for (const [kind, entries] of Object.entries(artifacts)) {
|
|
560
|
+
if (!Array.isArray(entries)) continue;
|
|
561
|
+
for (const entry of entries) {
|
|
562
|
+
const p = String(entry?.path ?? "");
|
|
563
|
+
if (p && !inCommit(p)) missing.push(`${kind}: ${p}`);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// The mirror case: an artifact staged into the commit that the staged index
|
|
568
|
+
// never heard of. Same shapes `index rebuild` derives entries from.
|
|
569
|
+
const indexed = new Set();
|
|
570
|
+
for (const entries of Object.values(artifacts)) {
|
|
571
|
+
if (Array.isArray(entries)) for (const e of entries) indexed.add(String(e?.path ?? ""));
|
|
572
|
+
}
|
|
573
|
+
const unindexed = [];
|
|
574
|
+
for (const f of [...files].sort()) {
|
|
575
|
+
const owner = artifactPathOf(f);
|
|
576
|
+
if (owner && !indexed.has(owner)) unindexed.push(owner);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const unique = [...new Set(unindexed)];
|
|
580
|
+
return {
|
|
581
|
+
ok: missing.length === 0 && unique.length === 0,
|
|
582
|
+
applicable: true,
|
|
583
|
+
missing,
|
|
584
|
+
unindexed: unique,
|
|
585
|
+
reason: null,
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// The index entry a staged file belongs to, or null when the file is not an
|
|
590
|
+
// artifact (templates, config.json, product.md, the index itself).
|
|
591
|
+
function artifactPathOf(file) {
|
|
592
|
+
let m = /^\.doctrina\/specs\/[^/]+\/spec\.md$/.exec(file);
|
|
593
|
+
if (m) return file;
|
|
594
|
+
m = /^\.doctrina\/(?:decisions|skills|contracts)\/[^/]+\.md$/.exec(file);
|
|
595
|
+
if (m) return file;
|
|
596
|
+
m = /^(\.doctrina\/changes\/archive\/[^/]+)\/proposal\.md$/.exec(file);
|
|
597
|
+
if (m) return m[1];
|
|
598
|
+
m = /^(\.doctrina\/changes\/(?!archive\/)[^/]+)\/proposal\.md$/.exec(file);
|
|
599
|
+
if (m) return m[1];
|
|
600
|
+
return null;
|
|
601
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { isFile, read, write } from "./fs-ops.js";
|
|
4
|
+
import { today } from "./dates.js";
|
|
5
|
+
import { git, GIT_STATE, isRepo } from "./git.js";
|
|
6
|
+
|
|
7
|
+
// A manual sign-off has an EXPIRY (audit finding F12).
|
|
8
|
+
//
|
|
9
|
+
// `verify --signoff` recorded `{ date, note }`, that file was read by nothing
|
|
10
|
+
// but `verify` itself, and the signature held forever. So the one deliberate
|
|
11
|
+
// escape hatch from the build gate was also the one place a green gate could
|
|
12
|
+
// lie indefinitely: sign off "the error copy reads well", rewrite every
|
|
13
|
+
// message the next morning, and the gate still says yes.
|
|
14
|
+
//
|
|
15
|
+
// That is honest gates (ADR 0008) turned on the escape hatch itself. A
|
|
16
|
+
// signature is a statement about code at a moment; when that code moves, the
|
|
17
|
+
// statement is no longer evidence — it is history.
|
|
18
|
+
//
|
|
19
|
+
// The mechanism stays: a qualitative check is legitimate, and nothing here
|
|
20
|
+
// removes it. What changes is that the record now carries WHAT was signed
|
|
21
|
+
// (the covered paths, declared in verify.json like everything else — never
|
|
22
|
+
// inferred) and WHEN (the commit). `verify` compares the two against the
|
|
23
|
+
// working tree and reports one of four states.
|
|
24
|
+
|
|
25
|
+
export const SIGNOFF_REL = ".doctrina/verify.signoffs.json";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The state of a manual check's evidence.
|
|
29
|
+
*
|
|
30
|
+
* pending never signed off.
|
|
31
|
+
* fresh signed, and nothing it covers has moved since.
|
|
32
|
+
* expired signed, but a covered path changed after the signature.
|
|
33
|
+
* unverifiable signed in the pre-expiry format (no commit recorded), or
|
|
34
|
+
* signed outside a repository — so "has it moved?" has no
|
|
35
|
+
* answer. Not a pass: silence about whether evidence still
|
|
36
|
+
* holds is not evidence that it does.
|
|
37
|
+
*
|
|
38
|
+
* Only `fresh` passes. The other three are warnings by default and failures
|
|
39
|
+
* under --strict, which is exactly the rule `pending` already followed — a
|
|
40
|
+
* manual check has always been non-blocking locally and required in CI, and
|
|
41
|
+
* this keeps one rule rather than inventing a second.
|
|
42
|
+
*/
|
|
43
|
+
export function signoffState(projectRoot, check, record) {
|
|
44
|
+
if (!record || !record.date) return { state: "pending" };
|
|
45
|
+
const paths = declaredPaths(check);
|
|
46
|
+
|
|
47
|
+
if (!record.sha) {
|
|
48
|
+
return {
|
|
49
|
+
state: "unverifiable",
|
|
50
|
+
why: "signed before sign-offs recorded a commit, so what it covered cannot be checked",
|
|
51
|
+
record,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
if (!isRepo(projectRoot)) {
|
|
55
|
+
return { state: "unverifiable", why: "not a git repository, so a change since the signature cannot be detected", record };
|
|
56
|
+
}
|
|
57
|
+
if (paths.length === 0) {
|
|
58
|
+
return {
|
|
59
|
+
state: "unverifiable",
|
|
60
|
+
why: `the check declares no "paths", so there is nothing to hold the signature to`,
|
|
61
|
+
record,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const changed = changedSince(projectRoot, record.sha, paths);
|
|
66
|
+
if (changed === null) {
|
|
67
|
+
return { state: "unverifiable", why: `commit ${short(record.sha)} is not in this repository's history`, record };
|
|
68
|
+
}
|
|
69
|
+
if (changed.length > 0) return { state: "expired", changed, record };
|
|
70
|
+
return { state: "fresh", record };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The paths a check declares it covers. Declared, never inferred. */
|
|
74
|
+
export function declaredPaths(check) {
|
|
75
|
+
const raw = check?.paths;
|
|
76
|
+
if (Array.isArray(raw)) return raw.filter((p) => typeof p === "string" && p.trim() !== "");
|
|
77
|
+
if (typeof raw === "string" && raw.trim() !== "") return [raw];
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Which of `paths` changed between `sha` and the working tree.
|
|
83
|
+
* Returns null when `sha` is not a commit this repository knows — an
|
|
84
|
+
* unanswerable question, reported as such rather than as "nothing changed".
|
|
85
|
+
*/
|
|
86
|
+
export function changedSince(projectRoot, sha, paths) {
|
|
87
|
+
const known = git(projectRoot, ["cat-file", "-e", `${sha}^{commit}`]);
|
|
88
|
+
if (known.state !== GIT_STATE.OK) return null;
|
|
89
|
+
// Committed changes and the working tree both count: a signature is about
|
|
90
|
+
// the code as it stands, not as it was last committed.
|
|
91
|
+
const committed = git(projectRoot, ["diff", "--name-only", sha, "--", ...paths]);
|
|
92
|
+
const working = git(projectRoot, ["diff", "--name-only", "HEAD", "--", ...paths]);
|
|
93
|
+
if (committed.state !== GIT_STATE.OK) return null;
|
|
94
|
+
const out = new Set(committed.lines.filter(Boolean));
|
|
95
|
+
if (working.state === GIT_STATE.OK) for (const f of working.lines.filter(Boolean)) out.add(f);
|
|
96
|
+
return [...out].sort();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The commit a signature is anchored to, or null outside a repository. */
|
|
100
|
+
export function currentSha(projectRoot) {
|
|
101
|
+
const r = git(projectRoot, ["rev-parse", "HEAD"]);
|
|
102
|
+
return r.state === GIT_STATE.OK ? r.stdout.trim() : null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function short(sha) {
|
|
106
|
+
return String(sha ?? "").slice(0, 8);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function loadSignoffs(projectRoot) {
|
|
110
|
+
const p = path.join(projectRoot, SIGNOFF_REL);
|
|
111
|
+
if (!isFile(p)) return {};
|
|
112
|
+
try {
|
|
113
|
+
return JSON.parse(read(p)) ?? {};
|
|
114
|
+
} catch {
|
|
115
|
+
return {};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function saveSignoffs(projectRoot, signoffs) {
|
|
120
|
+
write(path.join(projectRoot, SIGNOFF_REL), JSON.stringify(signoffs, null, 2) + "\n", { force: true });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Record a signature against today's date, the current commit, and what it covers. */
|
|
124
|
+
export function recordSignoff(projectRoot, check, note) {
|
|
125
|
+
const entry = { date: today(), note };
|
|
126
|
+
const sha = currentSha(projectRoot);
|
|
127
|
+
if (sha) entry.sha = sha;
|
|
128
|
+
const paths = declaredPaths(check);
|
|
129
|
+
if (paths.length > 0) entry.paths = paths;
|
|
130
|
+
return entry;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* How many of a project's manual checks are signed and still fresh — the
|
|
135
|
+
* number that lets a report say "executed proof" and "signed proof" apart
|
|
136
|
+
* instead of counting both as green.
|
|
137
|
+
*/
|
|
138
|
+
export function summarizeSignoffs(projectRoot, checks) {
|
|
139
|
+
const signoffs = loadSignoffs(projectRoot);
|
|
140
|
+
const counts = { manual: 0, fresh: 0, expired: 0, unverifiable: 0, pending: 0 };
|
|
141
|
+
for (const ch of checks) {
|
|
142
|
+
if (ch?.type !== "manual") continue;
|
|
143
|
+
counts.manual += 1;
|
|
144
|
+
counts[signoffState(projectRoot, ch, signoffs[ch.name ?? "check"]).state] += 1;
|
|
145
|
+
}
|
|
146
|
+
return counts;
|
|
147
|
+
}
|