doctrina-cli 0.15.1 → 0.16.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/adapter.js +24 -1
- package/src/commands/analyze.js +8 -212
- package/src/commands/change.js +143 -217
- package/src/commands/ci.js +182 -0
- package/src/commands/clarify.js +58 -21
- package/src/commands/close.js +291 -42
- package/src/commands/constitution.js +14 -91
- package/src/commands/context.js +278 -107
- package/src/commands/contract.js +95 -75
- package/src/commands/coverage.js +58 -273
- package/src/commands/decision.js +54 -7
- package/src/commands/doctor.js +292 -129
- package/src/commands/handoff.js +10 -57
- package/src/commands/hooks.js +11 -2
- package/src/commands/index-rebuild.js +9 -48
- package/src/commands/init.js +70 -13
- package/src/commands/intake.js +30 -92
- package/src/commands/metrics.js +53 -84
- package/src/commands/next.js +105 -147
- package/src/commands/prime.js +24 -91
- package/src/commands/report.js +50 -108
- 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 +16 -25
- package/src/commands/spec.js +26 -5
- package/src/commands/status.js +51 -172
- package/src/commands/templates.js +31 -138
- package/src/commands/trace.js +36 -71
- package/src/commands/triage.js +4 -82
- package/src/commands/validate.js +14 -871
- package/src/commands/verify.js +60 -138
- package/src/commands/why.js +2 -2
- package/src/commands/work.js +234 -263
- package/src/index.js +106 -7
- package/src/lib/actions.js +484 -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/change-ops.js +131 -0
- package/src/lib/commands.js +56 -4
- package/src/lib/config.js +206 -0
- package/src/lib/constitution-model.js +102 -0
- package/src/lib/coverage-model.js +392 -0
- package/src/lib/criteria.js +23 -0
- package/src/lib/doc-model.js +326 -0
- package/src/lib/docs-impact.js +196 -27
- package/src/lib/flag-catalog.js +0 -16
- package/src/lib/fs-ops.js +58 -1
- package/src/lib/gates.js +181 -23
- package/src/lib/git.js +132 -0
- package/src/lib/index-json.js +2 -19
- package/src/lib/intake-model.js +86 -0
- package/src/lib/json-out.js +100 -2
- package/src/lib/ledger.js +192 -0
- package/src/lib/lexicon.js +191 -0
- package/src/lib/metrics-model.js +159 -0
- package/src/lib/names.js +42 -0
- package/src/lib/playbook.js +97 -0
- package/src/lib/project.js +57 -0
- package/src/lib/reproducibility.js +137 -0
- package/src/lib/runtime.js +191 -5
- package/src/lib/scan.js +204 -13
- package/src/lib/signoff.js +147 -0
- package/src/lib/snapshot.js +209 -0
- package/src/lib/spec-ops.js +50 -5
- package/src/lib/templates-model.js +262 -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 +1299 -0
- package/src/lib/version.js +22 -0
- package/src/lib/views.js +410 -0
- package/src/lib/work-model.js +77 -0
- package/templates/AGENTS.md.template +20 -26
- package/templates/README.md +35 -0
- package/templates/change/proposal.md.template +1 -0
- package/templates/hooks/pre-commit.sample +6 -2
- package/templates/playbooks/bootstrap.md.template +43 -0
- package/templates/playbooks/chore.md.template +19 -0
- package/templates/playbooks/work.md.template +76 -0
package/src/lib/scan.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
-
import { getHeader, getSection } from "./doc-model.js";
|
|
2
|
+
import { getHeader, getSection, parseChangeTitle } from "./doc-model.js";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { readdirSync } from "node:fs";
|
|
5
5
|
import { isDir, isFile, read, walk } from "./fs-ops.js";
|
|
6
6
|
import { today } from "./dates.js";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import { cliVersion, newestVersion } from "./version.js";
|
|
8
|
+
import { load } from "./index-json.js";
|
|
9
|
+
import { parseFrontmatter } from "./doc-model.js";
|
|
10
|
+
import { parseCapabilityFromDelta } from "./doc-model.js";
|
|
11
|
+
import { parseOperation } from "./doc-model.js";
|
|
9
12
|
|
|
10
13
|
// Header reading lives in ONE place now (lib/doc-model.js, audit item M3).
|
|
11
14
|
// These two names survive because dozens of call sites use them and the
|
|
@@ -25,6 +28,44 @@ export function parseDependsOn(text) {
|
|
|
25
28
|
return raw.match(/[a-z][a-z0-9][a-z0-9-]*/g) ?? [];
|
|
26
29
|
}
|
|
27
30
|
|
|
31
|
+
/**
|
|
32
|
+
* The source globs a spec DECLARES as its own, from the optional
|
|
33
|
+
* `**Source:**` header — comma-separated, `*` inside a segment and `**`
|
|
34
|
+
* across directories (the same minimal glob the contract's Selectors use).
|
|
35
|
+
*
|
|
36
|
+
* Doctrina infers nothing about which code belongs to which capability
|
|
37
|
+
* (ADR 0027, and the same principle ADR 0023 applies to the runtime
|
|
38
|
+
* surface). Before this header the only signals were the capability name
|
|
39
|
+
* appearing as a path segment and the spec happening to cite a filename,
|
|
40
|
+
* which left 80 of this repository's 92 source files owned by nobody — so
|
|
41
|
+
* `review`, the gate that asks whether the spec kept up with the code,
|
|
42
|
+
* could not see the code it was reviewing.
|
|
43
|
+
*
|
|
44
|
+
* Returns [] when the header is absent, "n/a", or "—".
|
|
45
|
+
*/
|
|
46
|
+
export function parseSourceGlobs(text) {
|
|
47
|
+
const raw = specHeader(text, "Source");
|
|
48
|
+
if (!raw || /^n\/a\b/i.test(raw.trim()) || raw.trim() === "—") return [];
|
|
49
|
+
// Split on commas at brace depth ZERO: a comma inside `{a,b}` separates
|
|
50
|
+
// alternatives of ONE glob, not two globs, and splitting on it blindly
|
|
51
|
+
// shreds every grouped declaration into fragments that match nothing.
|
|
52
|
+
const out = [];
|
|
53
|
+
let cur = "";
|
|
54
|
+
let depth = 0;
|
|
55
|
+
for (const ch of raw) {
|
|
56
|
+
if (ch === "{") depth += 1;
|
|
57
|
+
else if (ch === "}") depth = Math.max(0, depth - 1);
|
|
58
|
+
if (ch === "," && depth === 0) {
|
|
59
|
+
out.push(cur);
|
|
60
|
+
cur = "";
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
cur += ch;
|
|
64
|
+
}
|
|
65
|
+
out.push(cur);
|
|
66
|
+
return out.map((g) => g.trim().replace(/^`|`$/g, "").trim()).filter(Boolean);
|
|
67
|
+
}
|
|
68
|
+
|
|
28
69
|
function dirEntries(dir) {
|
|
29
70
|
if (!isDir(dir)) return [];
|
|
30
71
|
return readdirSync(dir).filter((e) => !e.startsWith(".")).sort();
|
|
@@ -95,6 +136,37 @@ export function decisionEntry(text, basename, prev, date) {
|
|
|
95
136
|
return entry;
|
|
96
137
|
}
|
|
97
138
|
|
|
139
|
+
/**
|
|
140
|
+
* The index entry for ONE open change, derived from its proposal. Same
|
|
141
|
+
* discipline as `decisionEntry` above and for the same reason: `deriveIndex`
|
|
142
|
+
* builds the whole list from it, and `doctrina work` / `change new` register
|
|
143
|
+
* a new change THROUGH it instead of assembling a look-alike by hand.
|
|
144
|
+
*
|
|
145
|
+
* Two constructors for one record shape is how the `lane` field (change 0042)
|
|
146
|
+
* — added to the deriver and not to the writer — turned every freshly opened
|
|
147
|
+
* change into index drift the moment it was written: `doctrina work` followed
|
|
148
|
+
* by `doctrina validate` errored on a tree nobody had touched.
|
|
149
|
+
*
|
|
150
|
+
* @param {string} proposal Text of the change's proposal.md ("" when absent).
|
|
151
|
+
* @param {string} id The change id (its directory name).
|
|
152
|
+
* @param {object|null} prev The entry already in the index, when there is one.
|
|
153
|
+
* @param {string} date Fallback for a proposal carrying no Date header.
|
|
154
|
+
*/
|
|
155
|
+
export function changeEntry(proposal, id, prev, date) {
|
|
156
|
+
const title = parseChangeTitle(proposal);
|
|
157
|
+
return {
|
|
158
|
+
id,
|
|
159
|
+
title: title ?? prev?.title ?? id,
|
|
160
|
+
path: `.doctrina/changes/${id}`,
|
|
161
|
+
status: listHeader(proposal, "Status") ?? prev?.status ?? "proposed",
|
|
162
|
+
opened: listHeader(proposal, "Date") ?? prev?.opened ?? date,
|
|
163
|
+
// The lane the change was born in (change 0042). Optional: a change
|
|
164
|
+
// opened before the field existed simply has none, and every consumer
|
|
165
|
+
// treats its absence as "unknown" rather than as a lane.
|
|
166
|
+
...laneOf(proposal, prev),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
98
170
|
|
|
99
171
|
// Regenerate the index object from the artifacts on disk. The files are
|
|
100
172
|
// the source of truth; fields with no on-disk source (project name,
|
|
@@ -166,6 +238,10 @@ export function deriveIndex(projectRoot, current) {
|
|
|
166
238
|
// dependents of a touched capability.
|
|
167
239
|
const depends = specHeader(text, "Depends on") !== null ? parseDependsOn(text) : prev?.depends_on;
|
|
168
240
|
if (depends && depends.length) entry.depends_on = depends;
|
|
241
|
+
// Source: the code this capability owns (change 0077). Declared, never
|
|
242
|
+
// inferred — the same discipline ADR 0023 applies to the runtime surface.
|
|
243
|
+
const source = specHeader(text, "Source") !== null ? parseSourceGlobs(text) : prev?.source;
|
|
244
|
+
if (source && source.length) entry.source = source;
|
|
169
245
|
out.artifacts.specs.push(entry);
|
|
170
246
|
}
|
|
171
247
|
|
|
@@ -187,14 +263,7 @@ export function deriveIndex(projectRoot, current) {
|
|
|
187
263
|
const prev = (cur.changes ?? []).find((c) => c.id === id);
|
|
188
264
|
const proposalPath = path.join(changesDir, id, "proposal.md");
|
|
189
265
|
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
|
-
});
|
|
266
|
+
out.artifacts.changes.push(changeEntry(proposal, id, prev, date));
|
|
198
267
|
}
|
|
199
268
|
|
|
200
269
|
// Archived changes — folder name carries the applied date and id.
|
|
@@ -205,7 +274,7 @@ export function deriveIndex(projectRoot, current) {
|
|
|
205
274
|
const prev = (cur.changes_archive ?? []).find((c) => c.path?.endsWith(name));
|
|
206
275
|
const proposalPath = path.join(archiveDir, name, "proposal.md");
|
|
207
276
|
const proposal = isFile(proposalPath) ? read(proposalPath) : "";
|
|
208
|
-
const
|
|
277
|
+
const title = parseChangeTitle(proposal);
|
|
209
278
|
const specsAffected = [];
|
|
210
279
|
for (const deltaPath of walk(path.join(archiveDir, name, "specs"))) {
|
|
211
280
|
if (!deltaPath.endsWith("delta.md")) continue;
|
|
@@ -217,7 +286,7 @@ export function deriveIndex(projectRoot, current) {
|
|
|
217
286
|
}
|
|
218
287
|
out.artifacts.changes_archive.push({
|
|
219
288
|
id: m[2],
|
|
220
|
-
title:
|
|
289
|
+
title: title ?? prev?.title ?? m[2],
|
|
221
290
|
path: `.doctrina/changes/archive/${name}`,
|
|
222
291
|
status: "applied",
|
|
223
292
|
applied: m[1],
|
|
@@ -296,3 +365,125 @@ export function indexesMatch(a, b) {
|
|
|
296
365
|
};
|
|
297
366
|
return stableStringify(normalize(a)) === stableStringify(normalize(b));
|
|
298
367
|
}
|
|
368
|
+
|
|
369
|
+
// The lane recorded in a proposal header (change 0042), as an index field —
|
|
370
|
+
// or nothing at all. A change opened before the field existed, or one whose
|
|
371
|
+
// header is still the empty scaffold, has no lane, and "unknown" is the
|
|
372
|
+
// honest answer rather than a default that would poison the mix.
|
|
373
|
+
function laneOf(proposal, prev) {
|
|
374
|
+
const raw = (listHeader(proposal, "Lane") ?? "").trim();
|
|
375
|
+
if (!raw) return prev?.lane ? { lane: prev.lane } : {};
|
|
376
|
+
return { lane: raw };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Is the on-disk index still what the tree derives to, and if not, how does
|
|
381
|
+
* it differ?
|
|
382
|
+
*
|
|
383
|
+
* `index rebuild` renders this (and writes the derived index when asked);
|
|
384
|
+
* `doctor` reports it as one row. Before this it was the command's private
|
|
385
|
+
* business, so `doctor` answered "has the index drifted?" by spawning the
|
|
386
|
+
* CLI again and reading an exit code (audit finding F4).
|
|
387
|
+
*
|
|
388
|
+
* The framework stamp is migrated to the running CLI here rather than in the
|
|
389
|
+
* caller: deriveIndex carries the old value over so `next` does not nag on a
|
|
390
|
+
* version-only difference, and overriding it lets a stale stamp COUNT as
|
|
391
|
+
* drift, so `index rebuild` both reports and fixes it instead of
|
|
392
|
+
* short-circuiting on "nothing to do".
|
|
393
|
+
*
|
|
394
|
+
* @param {string} projectRoot
|
|
395
|
+
* @returns {{ok: boolean, drift: string[], derived: any, current: any, unreadable: string|null}}
|
|
396
|
+
*/
|
|
397
|
+
export function collectIndexDrift(projectRoot) {
|
|
398
|
+
let current = null;
|
|
399
|
+
let unreadable = null;
|
|
400
|
+
try {
|
|
401
|
+
current = load(projectRoot);
|
|
402
|
+
} catch (err) {
|
|
403
|
+
unreadable = err.message;
|
|
404
|
+
}
|
|
405
|
+
const derived = deriveIndex(projectRoot, current);
|
|
406
|
+
// A stamp ahead of this CLI is not drift (change 0116): the tree is
|
|
407
|
+
// managed by a newer release, and `--check` in CI must not go red because
|
|
408
|
+
// one teammate's CLI is older than the one that last wrote the index.
|
|
409
|
+
derived.framework_version = newestVersion(current?.framework_version, cliVersion());
|
|
410
|
+
if (indexesMatch(derived, current)) {
|
|
411
|
+
return { ok: true, drift: [], derived, current, unreadable };
|
|
412
|
+
}
|
|
413
|
+
return { ok: false, drift: describeDrift(current, derived), derived, current, unreadable };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Human-readable category-level drift between the on-disk index and the
|
|
417
|
+
// derived one: added / removed / changed entry ids.
|
|
418
|
+
function describeDrift(current, derived) {
|
|
419
|
+
const lines = [];
|
|
420
|
+
if (!current) return ["index.json missing or unreadable"];
|
|
421
|
+
if ((current.framework_version ?? null) !== (derived.framework_version ?? null)) {
|
|
422
|
+
lines.push(`framework_version: ${current.framework_version ?? "unset"} -> ${derived.framework_version}`);
|
|
423
|
+
}
|
|
424
|
+
const categories = ["specs", "decisions", "changes", "changes_archive", "skills"];
|
|
425
|
+
for (const cat of categories) {
|
|
426
|
+
const cur = new Map((current.artifacts?.[cat] ?? []).map((e) => [e.id, e]));
|
|
427
|
+
const der = new Map((derived.artifacts?.[cat] ?? []).map((e) => [e.id, e]));
|
|
428
|
+
for (const id of der.keys()) {
|
|
429
|
+
if (!cur.has(id)) lines.push(`${cat}: "${id}" on disk but not in index`);
|
|
430
|
+
else if (stableStringify(cur.get(id)) !== stableStringify(der.get(id))) {
|
|
431
|
+
lines.push(`${cat}: "${id}" metadata differs from the files`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
for (const id of cur.keys()) {
|
|
435
|
+
if (!der.has(id)) lines.push(`${cat}: "${id}" in index but not on disk`);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (stableStringify(current.artifacts?.product ?? null) !== stableStringify(derived.artifacts.product)) {
|
|
439
|
+
lines.push("product: metadata differs");
|
|
440
|
+
}
|
|
441
|
+
if (lines.length === 0) lines.push("structural difference (key order or missing category)");
|
|
442
|
+
return lines;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* The capabilities that actually have a spec on disk.
|
|
447
|
+
*
|
|
448
|
+
* The one list every command checks a named capability against. `coverage
|
|
449
|
+
* --only` refuses a name that is not in it (change 0090); `context <cap>`
|
|
450
|
+
* refuses the same way, because a scope filter that matches nothing is the
|
|
451
|
+
* same defect wherever it appears. Kept here rather than in either command
|
|
452
|
+
* so the two cannot disagree about which capabilities exist.
|
|
453
|
+
*
|
|
454
|
+
* @param {string} projectRoot
|
|
455
|
+
* @returns {string[]} directory names, in readdir order
|
|
456
|
+
*/
|
|
457
|
+
export function knownCapabilities(projectRoot) {
|
|
458
|
+
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
459
|
+
if (!isDir(specsDir)) return [];
|
|
460
|
+
return readdirSync(specsDir).filter((e) => isFile(path.join(specsDir, e, "spec.md")));
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Which capabilities declare a dependency on any of `caps`.
|
|
465
|
+
*
|
|
466
|
+
* The `**Depends on:**` header is the tree's only machine-readable statement
|
|
467
|
+
* that one capability builds on another, and two commands ask the same
|
|
468
|
+
* question of it: `review` notes a dependent whose ground moved, and `close`
|
|
469
|
+
* reports their coverage alongside the change's own. One definition, so they
|
|
470
|
+
* cannot disagree about who depends on what.
|
|
471
|
+
*
|
|
472
|
+
* @param {string} projectRoot
|
|
473
|
+
* @param {Iterable<string>} caps
|
|
474
|
+
* @returns {{capability: string, dependsOn: string[]}[]} sorted by capability
|
|
475
|
+
*/
|
|
476
|
+
export function dependentsOf(projectRoot, caps) {
|
|
477
|
+
const wanted = new Set(caps);
|
|
478
|
+
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
479
|
+
if (wanted.size === 0 || !isDir(specsDir)) return [];
|
|
480
|
+
const out = [];
|
|
481
|
+
for (const cap of readdirSync(specsDir).sort()) {
|
|
482
|
+
if (wanted.has(cap)) continue;
|
|
483
|
+
const specPath = path.join(specsDir, cap, "spec.md");
|
|
484
|
+
if (!isFile(specPath)) continue;
|
|
485
|
+
const deps = parseDependsOn(read(specPath)).filter((d) => wanted.has(d));
|
|
486
|
+
if (deps.length > 0) out.push({ capability: cap, dependsOn: deps });
|
|
487
|
+
}
|
|
488
|
+
return out;
|
|
489
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readdirSync } from "node:fs";
|
|
4
|
+
import { isDir, isFile, read, walk } from "./fs-ops.js";
|
|
5
|
+
import * as idx from "./index-json.js";
|
|
6
|
+
import { deriveIndex, indexesMatch, specHeader, listHeader } from "./scan.js";
|
|
7
|
+
import { cliVersion } from "./version.js";
|
|
8
|
+
import { summarize as coverageSummary } from "./coverage-model.js";
|
|
9
|
+
import { summarize as traceSummary } from "./trace-model.js";
|
|
10
|
+
import { acceptedDecisions, productSection, hasProductSection } from "./constitution-model.js";
|
|
11
|
+
import { computeActions } from "./actions.js";
|
|
12
|
+
import { readLedger } from "./ledger.js";
|
|
13
|
+
import { parseChangeTitle, checklistProgress } from "./doc-model.js";
|
|
14
|
+
import { summarizeSignoffs } from "./signoff.js";
|
|
15
|
+
|
|
16
|
+
// ONE collector, several views (audit finding F7).
|
|
17
|
+
//
|
|
18
|
+
// `status`, `prime`, `handoff` and `report` were four commands rendering the
|
|
19
|
+
// same three collections, and they got at them by importing functions out of
|
|
20
|
+
// each other's modules: `prime` reached into `status` for `collectStatus`,
|
|
21
|
+
// `handoff` and `report` reached into `prime` for `openChanges`, and all
|
|
22
|
+
// three reached into `coverage` and `trace` for their summaries. Every one of
|
|
23
|
+
// those is a command module — a RENDERER — being used as a data source, which
|
|
24
|
+
// is how four surfaces end up able to disagree about the same number.
|
|
25
|
+
//
|
|
26
|
+
// So the project's state is collected ONCE, here, into a plain object. The
|
|
27
|
+
// four commands become formatters over it: same numbers, four shapes, and a
|
|
28
|
+
// new view costs a function rather than a fifth traversal of the tree.
|
|
29
|
+
//
|
|
30
|
+
// Nothing here prints and nothing here writes.
|
|
31
|
+
|
|
32
|
+
const VERIFY_CONFIG_REL = ".doctrina/verify.json";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Everything the read-only views report, collected in one pass.
|
|
36
|
+
*
|
|
37
|
+
* `actions` is included because three of the four views end with "what next",
|
|
38
|
+
* and computing it separately in each was the same duplication one level up.
|
|
39
|
+
*/
|
|
40
|
+
export function collectSnapshot(projectRoot, { actions = true } = {}) {
|
|
41
|
+
let index = null;
|
|
42
|
+
try {
|
|
43
|
+
index = idx.load(projectRoot);
|
|
44
|
+
} catch {
|
|
45
|
+
index = null;
|
|
46
|
+
}
|
|
47
|
+
let indexState = "missing";
|
|
48
|
+
if (index) {
|
|
49
|
+
indexState = indexesMatch(deriveIndex(projectRoot, index), index) ? "in-sync" : "drifted";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Collected once, then shared: the views render it and `computeActions`
|
|
53
|
+
// recommends from it, so a recommendation can never disagree with the row
|
|
54
|
+
// it comes from (change 0037, extended to `next` by change 0064).
|
|
55
|
+
const coverage = coverageSummary(projectRoot);
|
|
56
|
+
const trace = traceSummary(projectRoot);
|
|
57
|
+
const verify = readVerifyConfig(projectRoot);
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
project: index?.project ?? path.basename(projectRoot),
|
|
61
|
+
stamp: index?.framework_version ?? null,
|
|
62
|
+
cli: cliVersion(),
|
|
63
|
+
indexState,
|
|
64
|
+
coverage,
|
|
65
|
+
trace,
|
|
66
|
+
verify,
|
|
67
|
+
specs: countSpecs(projectRoot),
|
|
68
|
+
decisions: countDecisions(projectRoot),
|
|
69
|
+
skills: countSkills(projectRoot),
|
|
70
|
+
openChanges: openChanges(projectRoot),
|
|
71
|
+
adrs: acceptedDecisions(projectRoot),
|
|
72
|
+
nonGoals: productSection(projectRoot, "Non-goals"),
|
|
73
|
+
hasNonGoalsSection: hasProductSection(projectRoot, "Non-goals"),
|
|
74
|
+
// The gate state is already collected above; hand it over so the whole
|
|
75
|
+
// snapshot stays one read of the tree (change 0037).
|
|
76
|
+
actions: actions ? computeActions(projectRoot, { coverage, trace, verify }) : [],
|
|
77
|
+
archive: index?.artifacts?.changes_archive ?? [],
|
|
78
|
+
// The lane each open change was born in, for the report's mix. Read from
|
|
79
|
+
// the index rather than re-parsed, so one derivation owns the field.
|
|
80
|
+
openLanes: (index?.artifacts?.changes ?? []).map((c) => c.lane ?? null),
|
|
81
|
+
// The archive ledger, parsed (change 0046). The index records WHICH
|
|
82
|
+
// changes landed; the ledger records what each one touched and when, in
|
|
83
|
+
// one append-only file — which is what a question like "how often has
|
|
84
|
+
// this capability moved?" is actually asking about.
|
|
85
|
+
ledger: readLedger(projectRoot).entries,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The subset `status --json`, `doctor` and the dashboard have always
|
|
91
|
+
* published. Kept as its own shape so the JSON envelope does not silently
|
|
92
|
+
* grow the day a view needs one more field.
|
|
93
|
+
*/
|
|
94
|
+
export function collectStatus(projectRoot) {
|
|
95
|
+
const s = collectSnapshot(projectRoot, { actions: false });
|
|
96
|
+
return {
|
|
97
|
+
project: s.project,
|
|
98
|
+
stamp: s.stamp,
|
|
99
|
+
cli: s.cli,
|
|
100
|
+
indexState: s.indexState,
|
|
101
|
+
coverage: s.coverage,
|
|
102
|
+
trace: s.trace,
|
|
103
|
+
verify: s.verify,
|
|
104
|
+
specs: s.specs,
|
|
105
|
+
decisions: s.decisions,
|
|
106
|
+
skills: s.skills,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function readVerifyConfig(projectRoot) {
|
|
111
|
+
const p = path.join(projectRoot, VERIFY_CONFIG_REL);
|
|
112
|
+
if (!isFile(p)) return { configured: false, checks: 0, invalid: false, signoffs: null };
|
|
113
|
+
try {
|
|
114
|
+
const cfg = JSON.parse(read(p));
|
|
115
|
+
const checks = Array.isArray(cfg?.checks) ? cfg.checks : [];
|
|
116
|
+
// Executed proof and SIGNED proof are different claims (change 0039), and
|
|
117
|
+
// a report that adds them together hides which is which. The counts ride
|
|
118
|
+
// along so every view can say so without loading the sign-off store
|
|
119
|
+
// itself.
|
|
120
|
+
const signoffs = summarizeSignoffs(projectRoot, checks);
|
|
121
|
+
return { configured: true, checks: checks.length, invalid: false, signoffs };
|
|
122
|
+
} catch {
|
|
123
|
+
return { configured: true, checks: 0, invalid: true, signoffs: null };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Open changes with their proposal status/title and task progress. */
|
|
128
|
+
export function openChanges(projectRoot) {
|
|
129
|
+
const changesDir = path.join(projectRoot, ".doctrina", "changes");
|
|
130
|
+
const out = [];
|
|
131
|
+
if (!isDir(changesDir)) return out;
|
|
132
|
+
for (const id of readdirSync(changesDir).sort()) {
|
|
133
|
+
if (id === "archive" || id.startsWith(".")) continue;
|
|
134
|
+
if (!isDir(path.join(changesDir, id))) continue;
|
|
135
|
+
const proposalPath = path.join(changesDir, id, "proposal.md");
|
|
136
|
+
const proposal = isFile(proposalPath) ? read(proposalPath) : null;
|
|
137
|
+
const title = proposal ? parseChangeTitle(proposal) : null;
|
|
138
|
+
const status = proposal ? (listHeader(proposal, "Status") ?? "proposed") : "no proposal.md";
|
|
139
|
+
const tasksPath = path.join(changesDir, id, "tasks.md");
|
|
140
|
+
// One counter for every surface (change 0067). This one used to require
|
|
141
|
+
// TEXT after the box, so the scaffold's empty placeholders were invisible
|
|
142
|
+
// and `prime` reported "tasks 0/3" for a change with six open boxes.
|
|
143
|
+
const progress = isFile(tasksPath)
|
|
144
|
+
? checklistProgress(read(tasksPath))
|
|
145
|
+
: { total: 0, done: 0, open: [], placeholders: 0 };
|
|
146
|
+
const tasksTotal = progress.total;
|
|
147
|
+
const tasksDone = progress.done;
|
|
148
|
+
const unchecked = progress.open.map((t) => t || "(unwritten task — the scaffold's placeholder)");
|
|
149
|
+
out.push({ id, title, status, tasksDone, tasksTotal, unchecked });
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function countSpecs(projectRoot) {
|
|
155
|
+
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
156
|
+
const impl = {};
|
|
157
|
+
let total = 0;
|
|
158
|
+
if (isDir(specsDir)) {
|
|
159
|
+
for (const cap of readdirSync(specsDir).sort()) {
|
|
160
|
+
const p = path.join(specsDir, cap, "spec.md");
|
|
161
|
+
if (!isFile(p)) continue;
|
|
162
|
+
total += 1;
|
|
163
|
+
const implRaw = specHeader(read(p), "Implementation");
|
|
164
|
+
if (implRaw) {
|
|
165
|
+
const word = implRaw.trim().split(/[\s—-]+/)[0].toLowerCase();
|
|
166
|
+
impl[word] = (impl[word] ?? 0) + 1;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const changesDir = path.join(projectRoot, ".doctrina", "changes");
|
|
171
|
+
let open = 0;
|
|
172
|
+
if (isDir(changesDir)) {
|
|
173
|
+
for (const e of readdirSync(changesDir)) {
|
|
174
|
+
if (e === "archive" || e.startsWith(".")) continue;
|
|
175
|
+
if (isDir(path.join(changesDir, e))) open += 1;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return { total, impl, openChanges: open };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function countDecisions(projectRoot) {
|
|
182
|
+
const adrDir = path.join(projectRoot, ".doctrina", "decisions");
|
|
183
|
+
let total = 0, proposed = 0, bare = 0;
|
|
184
|
+
const isBare = (v) => {
|
|
185
|
+
const t = (v ?? "").trim();
|
|
186
|
+
return t === "" || t === "—" || t === "-";
|
|
187
|
+
};
|
|
188
|
+
if (isDir(adrDir)) {
|
|
189
|
+
for (const f of walk(adrDir)) {
|
|
190
|
+
if (!/^\d{4}-.+\.md$/.test(path.basename(f))) continue;
|
|
191
|
+
total += 1;
|
|
192
|
+
const text = read(f);
|
|
193
|
+
const status = (listHeader(text, "Status") ?? "").toLowerCase();
|
|
194
|
+
if (status === "proposed") proposed += 1;
|
|
195
|
+
if (status === "accepted") {
|
|
196
|
+
const evidence = listHeader(text, "Evidence");
|
|
197
|
+
const landed = listHeader(text, "Landed");
|
|
198
|
+
if (evidence !== null && isBare(evidence) && isBare(landed)) bare += 1;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return { total, proposed, bare };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function countSkills(projectRoot) {
|
|
206
|
+
const skillsDir = path.join(projectRoot, ".doctrina", "skills");
|
|
207
|
+
if (!isDir(skillsDir)) return 0;
|
|
208
|
+
return walk(skillsDir).filter((f) => f.endsWith(".md")).length;
|
|
209
|
+
}
|