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/gates.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
-
import { getSection } from "./doc-model.js";
|
|
2
|
+
import { checklistProgress, getSection } from "./doc-model.js";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { appendFileSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { exists, isFile, mkdirp, read } from "./fs-ops.js";
|
|
6
6
|
import { today } from "./dates.js";
|
|
7
|
-
import { collectAnalysis } from "
|
|
7
|
+
import { collectAnalysis } from "./analysis.js";
|
|
8
|
+
import { appendLedgerLine, forcedLine } from "./ledger.js";
|
|
8
9
|
|
|
9
10
|
// The gate-to-transition map (audit item C6).
|
|
10
11
|
//
|
|
@@ -23,10 +24,10 @@ import { collectAnalysis } from "../commands/analyze.js";
|
|
|
23
24
|
// A gate is a named precondition returning human-readable blockers.
|
|
24
25
|
export const GATES = {
|
|
25
26
|
// Structural validity: the change's own files parse and point at real
|
|
26
|
-
// targets. The same checks `doctrina
|
|
27
|
+
// targets. The same checks `doctrina change check` reports first.
|
|
27
28
|
structure: {
|
|
28
29
|
label: "structure",
|
|
29
|
-
rerun: (id) => `doctrina
|
|
30
|
+
rerun: (id) => `doctrina change check ${id}`,
|
|
30
31
|
blockers(projectRoot, changeDir) {
|
|
31
32
|
return collectAnalysis(projectRoot, changeDir)
|
|
32
33
|
.filter((r) => r.kind === "fail")
|
|
@@ -48,7 +49,7 @@ export const GATES = {
|
|
|
48
49
|
// history recorded six hollow proposals reaching the archive.
|
|
49
50
|
integrity: {
|
|
50
51
|
label: "integrity",
|
|
51
|
-
rerun: (id) => `doctrina
|
|
52
|
+
rerun: (id) => `doctrina change check ${id}`,
|
|
52
53
|
blockers(projectRoot, changeDir) {
|
|
53
54
|
return collectAnalysis(projectRoot, changeDir)
|
|
54
55
|
.filter((r) => r.kind === "fail" && r.scope !== "pre-apply")
|
|
@@ -57,23 +58,26 @@ export const GATES = {
|
|
|
57
58
|
},
|
|
58
59
|
|
|
59
60
|
// Verification: the work is claimed complete. Every checkbox in
|
|
60
|
-
// tasks.md
|
|
61
|
-
// "##
|
|
61
|
+
// tasks.md and in the proposal's "## Verification" section — except a
|
|
62
|
+
// legacy "## Closing steps" list, which names what the close itself does
|
|
63
|
+
// (change 0195).
|
|
62
64
|
verification: {
|
|
63
65
|
label: "verification",
|
|
64
66
|
rerun: (id) => `doctrina change tick ${id}`,
|
|
65
67
|
blockers(projectRoot, changeDir) {
|
|
66
68
|
const out = [];
|
|
67
|
-
|
|
69
|
+
// Same counter as every other surface (change 0067).
|
|
70
|
+
const countUnchecked = (text, section = null) =>
|
|
71
|
+
checklistProgress(text, { section }).total - checklistProgress(text, { section }).done;
|
|
68
72
|
|
|
69
73
|
const tasksPath = path.join(changeDir, "tasks.md");
|
|
70
74
|
if (isFile(tasksPath)) {
|
|
71
75
|
const n = countUnchecked(read(tasksPath));
|
|
72
|
-
if (n > 0) out.push(`${n} unchecked task${n === 1 ? "" : "s"} in tasks.md
|
|
76
|
+
if (n > 0) out.push(`${n} unchecked task${n === 1 ? "" : "s"} in tasks.md`);
|
|
73
77
|
}
|
|
74
78
|
const proposalPath = path.join(changeDir, "proposal.md");
|
|
75
79
|
if (isFile(proposalPath)) {
|
|
76
|
-
const n = countUnchecked(
|
|
80
|
+
const n = countUnchecked(read(proposalPath), "Verification");
|
|
77
81
|
if (n > 0) out.push(`${n} unmet verification item${n === 1 ? "" : "s"} in proposal.md (## Verification)`);
|
|
78
82
|
}
|
|
79
83
|
return out;
|
|
@@ -105,6 +109,218 @@ export const TRANSITIONS = {
|
|
|
105
109
|
archive: { label: "change archive", gates: ["integrity", "verification"] },
|
|
106
110
|
};
|
|
107
111
|
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// The gate SEQUENCES (audit finding F1)
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
//
|
|
116
|
+
// ADR 0017 promised ONE gate map. TRANSITIONS above delivered it for the two
|
|
117
|
+
// mutating transitions and stopped there: `close` carried a literal array of
|
|
118
|
+
// ten steps, `doctor` eight hand-written rows, and `action.yml` five YAML
|
|
119
|
+
// steps. Four lists, four owners, and nothing that noticed when they
|
|
120
|
+
// diverged — which is how the runtime gate could land in `close` and stay
|
|
121
|
+
// absent from CI (change 0033 had to add it to both by hand, and nothing
|
|
122
|
+
// would have caught it if the second edit had been forgotten).
|
|
123
|
+
//
|
|
124
|
+
// So a sequence is DATA. Each step declares what it is, how hard it bites,
|
|
125
|
+
// and the command that runs it standalone. The surfaces only RENDER it:
|
|
126
|
+
// `close` executes it in order, `doctor` reports each as a row, and
|
|
127
|
+
// `doctrina ci --emit github` writes it as CI YAML. None of them may invent
|
|
128
|
+
// a step, drop one, or reorder — a drift test holds each surface to this
|
|
129
|
+
// declaration, and the emitted YAML to the versioned `action.yml`.
|
|
130
|
+
//
|
|
131
|
+
// What this does NOT decide is which gates belong in a sequence; it declares
|
|
132
|
+
// the sequences that exist so that the answer lives in one file.
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* One step of a gate sequence.
|
|
136
|
+
*
|
|
137
|
+
* @typedef {object} GateStep
|
|
138
|
+
* @property {string} id Stable key. The surfaces bind their renderer/runner to it.
|
|
139
|
+
* @property {string} label What the surface calls the step.
|
|
140
|
+
* @property {"blocking"|"advisory"|"forceable"} level
|
|
141
|
+
* blocking — a non-zero result stops the sequence.
|
|
142
|
+
* advisory — reported, never stops anything.
|
|
143
|
+
* forceable — blocking, unless --force, which records the gap in the ledger.
|
|
144
|
+
* @property {string[]|null} argv
|
|
145
|
+
* The standalone CLI invocation this step is, or null when the step is not
|
|
146
|
+
* one command (the ADR checkpoint, the docs gate). The token "<id>" is
|
|
147
|
+
* replaced with the change id at render time.
|
|
148
|
+
* @property {string} [rerun] Literal rerun line, for a step argv cannot describe.
|
|
149
|
+
* @property {string} [why] One line of rationale. The CI emitter writes it as a comment.
|
|
150
|
+
* @property {boolean} [strict] CI only: the command takes --strict when the action runs strict.
|
|
151
|
+
* @property {string} [script] CI only: a shell body that replaces the derived one-liner.
|
|
152
|
+
* @property {string} [flag] Only run/report this step when that flag is set.
|
|
153
|
+
* @property {string} [short] CI only: the name used in the action's own description.
|
|
154
|
+
* @property {string} [summary] CI only: the name plus what it checks, for the file header.
|
|
155
|
+
*/
|
|
156
|
+
|
|
157
|
+
/** @type {Record<string, GateStep[]>} */
|
|
158
|
+
export const SEQUENCES = {
|
|
159
|
+
// The closing sequence. `close` runs these in order, in-process where it
|
|
160
|
+
// can, stopping at the first blocking failure with the step's rerun line.
|
|
161
|
+
close: [
|
|
162
|
+
// The structural pre-flight. Its id stays "analyze" (the module it runs
|
|
163
|
+
// in-process), but a reader sees "structure", the gate it holds: change
|
|
164
|
+
// 0182 deprecated `doctrina analyze`, and the command a person reruns is
|
|
165
|
+
// `change check`, which reports the same checks first (change 0183).
|
|
166
|
+
{ id: "analyze", label: "structure", level: "blocking", argv: ["analyze", "<id>"], rerun: "doctrina change check <id>" },
|
|
167
|
+
{ id: "adr-checkpoint", label: "ADR checkpoint (advisory)", level: "advisory", argv: ["decision", "list"] },
|
|
168
|
+
// Conformance review (audit finding F3). It is the richest analysis the
|
|
169
|
+
// project has — capabilities whose code moved while their spec did not,
|
|
170
|
+
// affected dependants, dangling coverage — and no driver invoked it, so
|
|
171
|
+
// it only ever happened when somebody typed the command.
|
|
172
|
+
//
|
|
173
|
+
// BEFORE the apply, deliberately: that is the point where its findings can
|
|
174
|
+
// still change what gets written. Advisory for now — it raises a break for
|
|
175
|
+
// every capability with touched code and a still spec, and that noise has
|
|
176
|
+
// to be measured before it is allowed to refuse. `review --strict` remains
|
|
177
|
+
// the door for a project that wants CI to block on it.
|
|
178
|
+
{ id: "review", label: "review (advisory)", level: "advisory", argv: ["review"] },
|
|
179
|
+
{ id: "apply", label: "apply", level: "blocking", argv: ["change", "apply", "<id>"] },
|
|
180
|
+
{ id: "runtime", label: "runtime", level: "blocking", argv: ["contract", "check"] },
|
|
181
|
+
{ id: "implementation", label: "implementation (advisory)", level: "advisory", argv: null, rerun: "doctrina spec set <cap> --implementation auto" },
|
|
182
|
+
{ id: "verify", label: "verify", level: "blocking", argv: ["verify"] },
|
|
183
|
+
{ id: "coverage", label: "coverage", level: "blocking", argv: ["coverage", "--strict"] },
|
|
184
|
+
{ id: "trace", label: "trace", level: "advisory", argv: ["trace"] },
|
|
185
|
+
{ id: "docs", label: "docs", level: "forceable", argv: null, rerun: "document the change, then rerun" },
|
|
186
|
+
{ id: "archive", label: "archive", level: "blocking", argv: ["change", "archive", "<id>"] },
|
|
187
|
+
// AFTER the archive, because the archive is the last step that WRITES the
|
|
188
|
+
// index, and a gate placed before the step it guards cannot guard it.
|
|
189
|
+
//
|
|
190
|
+
// The drift check already ran, inside `verify` — and that is exactly why
|
|
191
|
+
// it missed: `verify` is five steps earlier, so it certified an index the
|
|
192
|
+
// archive had not yet rewritten. Change 0138 closed green on a tree whose
|
|
193
|
+
// index had drifted, the commit shipped, and all six test legs of CI went
|
|
194
|
+
// red on the next push. The close was not wrong about what it checked; it
|
|
195
|
+
// checked before the damage.
|
|
196
|
+
//
|
|
197
|
+
// `validate` cannot stand in for it: drift of that kind is only visible by
|
|
198
|
+
// rebuilding the index and comparing, which validate deliberately does not
|
|
199
|
+
// do.
|
|
200
|
+
{ id: "index-drift", label: "index drift", level: "blocking", argv: ["index", "rebuild", "--check"] },
|
|
201
|
+
{ id: "validate", label: "validate", level: "blocking", argv: ["validate"] },
|
|
202
|
+
],
|
|
203
|
+
|
|
204
|
+
// The diagnostic sequence. Every row is advisory in the sense that `doctor`
|
|
205
|
+
// never mutates anything; the level says whether the row counts as a
|
|
206
|
+
// failing area (exit 1) or an advisory one (exit 0).
|
|
207
|
+
doctor: [
|
|
208
|
+
{ id: "validate", label: "validate", level: "blocking", argv: ["validate"] },
|
|
209
|
+
{ id: "index", label: "index", level: "blocking", argv: ["index", "rebuild", "--check"] },
|
|
210
|
+
{ id: "coverage", label: "coverage", level: "advisory", argv: ["coverage"] },
|
|
211
|
+
{ id: "trace", label: "trace", level: "advisory", argv: ["trace"] },
|
|
212
|
+
{ id: "clean-checkout", label: "clean-checkout", level: "blocking", argv: ["verify", "--clean"] },
|
|
213
|
+
{ id: "templates", label: "templates", level: "advisory", argv: ["upgrade"] },
|
|
214
|
+
{ id: "runtime", label: "runtime", level: "blocking", argv: ["contract", "check"] },
|
|
215
|
+
{ id: "local-env", label: "local .env", level: "blocking", argv: null, rerun: "doctrina doctor --env", flag: "env" },
|
|
216
|
+
{ id: "verify-config", label: "verify config", level: "blocking", argv: ["verify", "--init"] },
|
|
217
|
+
// The declared size budgets, and the headroom left. Reported BEFORE either
|
|
218
|
+
// is breached: `agents-md-lines` is OUTPUT, so `analyze` refuses the
|
|
219
|
+
// raise-the-ceiling fix, and the generated surface block lives inside
|
|
220
|
+
// AGENTS.md — one command added spends a line of both (change 0072).
|
|
221
|
+
{ id: "budgets", label: "budgets", level: "advisory", argv: null, rerun: "doctrina validate" },
|
|
222
|
+
// Not a check — a READOUT. Every other row can fail; this one exists
|
|
223
|
+
// because a project could not see what it had configured without reading
|
|
224
|
+
// the CLI's source, which is how a pt-BR project sat red under `clarify`
|
|
225
|
+
// with no clue why (change 0047). Advisory: a default is not a fault.
|
|
226
|
+
{ id: "config", label: "config", level: "advisory", argv: null, rerun: "edit .doctrina/config.json" },
|
|
227
|
+
// Also a readout, and also advisory. It appears only when the operator
|
|
228
|
+
// has switched the usage log on: an instrument that is off has nothing
|
|
229
|
+
// to say, and a row saying so every run would train the reader to skip
|
|
230
|
+
// the section (change 0050).
|
|
231
|
+
{ id: "usage", label: "usage", level: "advisory", argv: null, rerun: "doctrina metrics --commands" },
|
|
232
|
+
],
|
|
233
|
+
|
|
234
|
+
// The CI sequence, emitted as the composite action. Deliberately WITHOUT
|
|
235
|
+
// `verify`: the build gate is the adopting project's own to run, and the
|
|
236
|
+
// action exists to check the artifact tree.
|
|
237
|
+
ci: [
|
|
238
|
+
{ id: "validate", label: "doctrina validate", level: "blocking", argv: ["validate"], short: "validate", summary: "validate (schema/structure)" },
|
|
239
|
+
{ id: "index-drift", label: "doctrina index rebuild --check", level: "blocking", argv: ["index", "rebuild", "--check"], short: "index drift", summary: "index rebuild --check (index ↔ tree drift)" },
|
|
240
|
+
{
|
|
241
|
+
id: "contract-check",
|
|
242
|
+
label: "doctrina contract check",
|
|
243
|
+
level: "blocking",
|
|
244
|
+
argv: ["contract", "check"],
|
|
245
|
+
short: "contract check",
|
|
246
|
+
summary: "contract check (the declared runtime surface, RT01-RT05)",
|
|
247
|
+
why:
|
|
248
|
+
"The runtime gate (RT01-RT05). Every other step reads Markdown; this one\n" +
|
|
249
|
+
"reads what the Markdown CLAIMS about the running system and holds the\n" +
|
|
250
|
+
"implementation to it — the variable no workflow exports, the default an\n" +
|
|
251
|
+
"empty CI value never triggers, the unvalidated enum, the selector that\n" +
|
|
252
|
+
"matches nothing and still exits 0. Not gated on `strict`: a declaration\n" +
|
|
253
|
+
"that does not hold is an error at any adoption stage. A project with no\n" +
|
|
254
|
+
"contracts prints one line and exits 0.",
|
|
255
|
+
},
|
|
256
|
+
{ id: "coverage", label: "doctrina coverage", level: "blocking", argv: ["coverage"], strict: true, short: "coverage", summary: "coverage (acceptance-criteria evidence)" },
|
|
257
|
+
{ id: "trace", label: "doctrina trace", level: "blocking", argv: ["trace"], strict: true, short: "trace", summary: "trace (intent provenance)" },
|
|
258
|
+
{
|
|
259
|
+
id: "context-budget",
|
|
260
|
+
label: "doctrina context (budget)",
|
|
261
|
+
level: "blocking",
|
|
262
|
+
argv: ["context"],
|
|
263
|
+
short: "context budget",
|
|
264
|
+
summary: "context (the pack budget, ADR 0022)",
|
|
265
|
+
why:
|
|
266
|
+
"The context budget (ADR 0022). A pack that degrades to fit is fine; this\n" +
|
|
267
|
+
"fails only when a pack's irreducible core no longer fits at all, which\n" +
|
|
268
|
+
"means a spec has outgrown itself or a change has gone stale. Configure\n" +
|
|
269
|
+
"the ceiling per project with \"context_budget\": n in\n" +
|
|
270
|
+
".doctrina/config.json.",
|
|
271
|
+
script:
|
|
272
|
+
"{{PREFIX}} context >/dev/null\n" +
|
|
273
|
+
"for cap in $(ls .doctrina/specs 2>/dev/null); do\n" +
|
|
274
|
+
" {{PREFIX}} context \"$cap\" >/dev/null\n" +
|
|
275
|
+
"done",
|
|
276
|
+
},
|
|
277
|
+
],
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
/** The declared steps of a sequence, or an error naming the ones that exist. */
|
|
281
|
+
export function sequence(name) {
|
|
282
|
+
const steps = SEQUENCES[name];
|
|
283
|
+
if (!steps) throw new Error(`unknown gate sequence "${name}" (declared: ${Object.keys(SEQUENCES).join(", ")})`);
|
|
284
|
+
return steps;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The sequence as a reader sees it: each step's label, in order, with the
|
|
289
|
+
* forceable ones marked the way the advisory ones already are in their own
|
|
290
|
+
* labels.
|
|
291
|
+
*
|
|
292
|
+
* Every surface that SHOWS the sequence renders it from here. Four copies of
|
|
293
|
+
* the close sequence were written out by hand — `close --help`, two adapter
|
|
294
|
+
* command files, and the flow page in both languages — and all four had gone
|
|
295
|
+
* stale, each to a different list: one named four steps of thirteen, another
|
|
296
|
+
* eleven. A sequence a reader can count is a sequence a reader will trust, so
|
|
297
|
+
* it has exactly one author.
|
|
298
|
+
*/
|
|
299
|
+
export function sequenceLabels(name) {
|
|
300
|
+
// The marker comes from `level`, never from the label text: labels had
|
|
301
|
+
// already drifted among themselves — `trace` is advisory and did not say so
|
|
302
|
+
// while `ADR checkpoint` did — so any marker already written in is stripped
|
|
303
|
+
// and re-derived. One rule, applied to every step.
|
|
304
|
+
return sequence(name).map((s) => {
|
|
305
|
+
const base = s.label.replace(/\s*\((?:advisory|forceable)\)\s*$/, "");
|
|
306
|
+
if (s.level === "advisory") return `${base} (advisory)`;
|
|
307
|
+
if (s.level === "forceable") return `${base} (forceable)`;
|
|
308
|
+
return base;
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* The command that runs one step on its own — what a surface prints when it
|
|
314
|
+
* tells the operator how to clear the gate. Derived from `argv` so the
|
|
315
|
+
* declaration cannot disagree with itself; `rerun` covers the steps that are
|
|
316
|
+
* not a single command.
|
|
317
|
+
*/
|
|
318
|
+
export function stepRerun(step, changeId = "<id>") {
|
|
319
|
+
if (step.rerun) return step.rerun.replace(/<id>/g, changeId);
|
|
320
|
+
if (!step.argv) return `doctrina ${step.id}`;
|
|
321
|
+
return `doctrina ${step.argv.map((a) => (a === "<id>" ? changeId : a)).join(" ")}`;
|
|
322
|
+
}
|
|
323
|
+
|
|
108
324
|
// Evaluate a transition's gates. Returns { ok, blockers, gates } where
|
|
109
325
|
// blockers is a flat list of human-readable reasons.
|
|
110
326
|
export function checkTransition(projectRoot, changeDir, transition) {
|
|
@@ -123,25 +339,11 @@ export function checkTransition(projectRoot, changeDir, transition) {
|
|
|
123
339
|
// waved through is part of the visible history rather than nothing at all
|
|
124
340
|
// — the same posture `change archive --force` and `change abandon` take.
|
|
125
341
|
export function recordForcedGap(projectRoot, id, transition, blockers) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
// this is meant to break.
|
|
132
|
-
if (!exists(ledgerPath)) {
|
|
133
|
-
mkdirp(archiveDir);
|
|
134
|
-
writeFileSync(ledgerPath,
|
|
135
|
-
"# Change ledger\n\n" +
|
|
136
|
-
"One line per archived change, newest last. Appended by\n" +
|
|
137
|
-
"`doctrina change archive`; edit freely, the CLI only appends.\n\n");
|
|
138
|
-
}
|
|
139
|
-
const summary = blockers.map((b) => `${b.gate}: ${b.message}`).join("; ");
|
|
140
|
-
appendFileSync(
|
|
141
|
-
ledgerPath,
|
|
142
|
-
` - ${today()} — ${id} — forced ${transition} past ${blockers.length} ` +
|
|
143
|
-
`blocker${blockers.length === 1 ? "" : "s"} (${summary})\n`,
|
|
144
|
-
);
|
|
342
|
+
// The ledger owns its own format and its own creation (lib/ledger.js): a
|
|
343
|
+
// forced transition can happen before any change has ever been archived,
|
|
344
|
+
// and a gap that goes unrecorded because the file did not exist yet is
|
|
345
|
+
// exactly the silence this is meant to break.
|
|
346
|
+
appendLedgerLine(projectRoot, forcedLine(id, transition, blockers));
|
|
145
347
|
return true;
|
|
146
348
|
}
|
|
147
349
|
|
package/src/lib/git.js
CHANGED
|
@@ -69,6 +69,23 @@ export function hasCommits(cwd) {
|
|
|
69
69
|
return git(cwd, ["rev-parse", "--verify", "HEAD"]).state === GIT_STATE.OK;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Does this ref resolve to a commit in this repository?
|
|
74
|
+
*
|
|
75
|
+
* Asked directly, because the message heuristic above cannot answer it: git
|
|
76
|
+
* says "unknown revision or path not in the working tree" both for a
|
|
77
|
+
* repository with no commits and for a ref that does not exist, so a diff
|
|
78
|
+
* against a missing ref came back as GIT_STATE.EMPTY and every caller read
|
|
79
|
+
* it as "nothing changed". `doctrina review --diff main --strict` therefore
|
|
80
|
+
* passed on a shallow clone with no local `main` (change 0092).
|
|
81
|
+
*
|
|
82
|
+
* False when git is absent, this is not a repository, or the ref is unknown —
|
|
83
|
+
* callers that need to tell those apart ask `historyState` as well.
|
|
84
|
+
*/
|
|
85
|
+
export function refExists(cwd, ref) {
|
|
86
|
+
return git(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]).state === GIT_STATE.OK;
|
|
87
|
+
}
|
|
88
|
+
|
|
72
89
|
// The state a command should report before trying to read history:
|
|
73
90
|
// { usable, state, reason } where `reason` is a sentence fit to print.
|
|
74
91
|
export function historyState(cwd) {
|
|
@@ -95,3 +112,118 @@ export function gitLines(cwd, args) {
|
|
|
95
112
|
function splitLines(s) {
|
|
96
113
|
return s.split(/\r?\n/).map((x) => x.trim()).filter(Boolean);
|
|
97
114
|
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// The period window the report view describes (change 0037)
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
//
|
|
120
|
+
// `report` shelled out to git twice with its own spawnSync, which is the one
|
|
121
|
+
// pattern this module exists to end: a caller that reads a non-zero exit as
|
|
122
|
+
// "no commits" rather than "the command refused". Both probes go through
|
|
123
|
+
// `git()` here, so an absent binary, a non-repository and a real failure stay
|
|
124
|
+
// distinguishable — and the view above stays a pure formatter.
|
|
125
|
+
|
|
126
|
+
/** The ISO date `days` ago — the left edge of a report window. */
|
|
127
|
+
export function windowCutoff(days) {
|
|
128
|
+
return new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Commits, fix-shaped share and top-churn files in the window.
|
|
133
|
+
* Returns null when there is no history to read — outside a repository, with
|
|
134
|
+
* git absent, or before the first commit — so the caller can say so rather
|
|
135
|
+
* than print a confident zero.
|
|
136
|
+
*/
|
|
137
|
+
export function gitWindow(cwd, days) {
|
|
138
|
+
const since = `--since=${days} days ago`;
|
|
139
|
+
const log = git(cwd, ["log", since, "--pretty=%s"]);
|
|
140
|
+
if (log.state !== GIT_STATE.OK) return null;
|
|
141
|
+
const subjects = log.lines.filter((l) => l.trim().length > 0);
|
|
142
|
+
const fixes = subjects.filter((sub) => /^(fix|bug|hotfix|patch)(\(|:|!)/i.test(sub)).length;
|
|
143
|
+
|
|
144
|
+
const files = git(cwd, ["log", since, "--name-only", "--pretty=format:"]);
|
|
145
|
+
const counts = new Map();
|
|
146
|
+
if (files.state === GIT_STATE.OK) {
|
|
147
|
+
for (const line of files.lines) {
|
|
148
|
+
const f = line.trim();
|
|
149
|
+
if (!f) continue;
|
|
150
|
+
counts.set(f, (counts.get(f) ?? 0) + 1);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const churn = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
|
|
154
|
+
return { commits: subjects.length, fixes, churn };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// Changed files: the one answer, four callers (audit finding F5)
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
//
|
|
161
|
+
// `work`, `review`, `context` and `docs-impact` each carried their own
|
|
162
|
+
// "which files changed" — four spawnSync helpers, four slightly different
|
|
163
|
+
// answers, and each one reading a non-zero exit as an empty list rather than
|
|
164
|
+
// a refusal. The semantics that genuinely differ between them are OPTIONS
|
|
165
|
+
// here, not separate implementations: `docs-impact` needs the merge-base so a
|
|
166
|
+
// branch's earlier commits count, `context --diff` compares against a named
|
|
167
|
+
// ref, and a review against a ref does not want untracked files mixed in.
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The files that changed, as the caller defines "changed".
|
|
171
|
+
*
|
|
172
|
+
* @param {string} cwd
|
|
173
|
+
* @param {object} [opts]
|
|
174
|
+
* @param {string|null} [opts.since] Compare against this ref instead of HEAD.
|
|
175
|
+
* @param {boolean} [opts.untracked] Include untracked, non-ignored files (default true).
|
|
176
|
+
* @param {boolean} [opts.mergeBase] Also include everything since the merge-base with the
|
|
177
|
+
* default branch, so a branch's earlier commits count.
|
|
178
|
+
* @param {string[]} [opts.bases] Which branch names to try as the merge-base target.
|
|
179
|
+
* @returns {{ ok: boolean, files: string[], state: string }}
|
|
180
|
+
* `ok` is false when git could not answer — outside a repository, with git
|
|
181
|
+
* absent, or on a real failure. A caller that treats "not ok" as "nothing
|
|
182
|
+
* changed" is making the mistake this door exists to prevent, so the state
|
|
183
|
+
* is returned rather than folded into an empty list.
|
|
184
|
+
*/
|
|
185
|
+
export function changedFiles(cwd, { since = null, untracked = true, mergeBase = false, bases = ["main", "master"] } = {}) {
|
|
186
|
+
const out = new Set();
|
|
187
|
+
const add = (r) => {
|
|
188
|
+
for (const line of r.lines) {
|
|
189
|
+
const p = line.trim();
|
|
190
|
+
if (p) out.add(p.replace(/\\/g, "/"));
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const diff = git(cwd, since ? ["diff", "--name-only", since, "--"] : ["diff", "--name-only", "HEAD"]);
|
|
195
|
+
if (diff.state !== GIT_STATE.OK) return { ok: false, files: [], state: diff.state };
|
|
196
|
+
add(diff);
|
|
197
|
+
|
|
198
|
+
if (untracked) {
|
|
199
|
+
const others = git(cwd, ["ls-files", "--others", "--exclude-standard"]);
|
|
200
|
+
if (others.state === GIT_STATE.OK) add(others);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (mergeBase) {
|
|
204
|
+
for (const base of bases) {
|
|
205
|
+
const mb = git(cwd, ["merge-base", "HEAD", base]);
|
|
206
|
+
if (mb.state === GIT_STATE.OK && mb.lines[0]) {
|
|
207
|
+
const since = git(cwd, ["diff", "--name-only", `${mb.lines[0].trim()}..HEAD`]);
|
|
208
|
+
if (since.state === GIT_STATE.OK) add(since);
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return { ok: true, files: [...out].sort(), state: GIT_STATE.OK };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The date of the most recent tag, as YYYY-MM-DD, or null.
|
|
219
|
+
*
|
|
220
|
+
* The left edge of "what has landed since the last release". Null when there
|
|
221
|
+
* is no tag, no history, or no repository — the caller then has to say which
|
|
222
|
+
* window it used rather than pretend there was one.
|
|
223
|
+
*/
|
|
224
|
+
export function lastTagDate(cwd) {
|
|
225
|
+
const tags = gitLines(cwd, ["for-each-ref", "--sort=-creatordate", "--count=1",
|
|
226
|
+
"--format=%(creatordate:short)", "refs/tags"]);
|
|
227
|
+
const date = tags[0] ?? "";
|
|
228
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null;
|
|
229
|
+
}
|
package/src/lib/index-json.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { read, write, exists } from "./fs-ops.js";
|
|
4
|
-
import { cliVersion } from "./version.js";
|
|
4
|
+
import { cliVersion, newestVersion } from "./version.js";
|
|
5
5
|
|
|
6
6
|
export const SCHEMA_VERSION = "0.1.0";
|
|
7
7
|
|
|
@@ -39,7 +39,7 @@ export function save(projectRoot, index) {
|
|
|
39
39
|
// records which CLI last wrote it, so a stale stamp (an index written by an
|
|
40
40
|
// older CLI) is detectable by `doctrina validate` and migrated by
|
|
41
41
|
// `doctrina index rebuild`.
|
|
42
|
-
index.framework_version = cliVersion();
|
|
42
|
+
index.framework_version = newestVersion(index.framework_version, cliVersion());
|
|
43
43
|
const p = indexPath(projectRoot);
|
|
44
44
|
const text = JSON.stringify(index, null, 2) + "\n";
|
|
45
45
|
write(p, text, { force: true });
|
|
@@ -64,10 +64,45 @@ export function blank(projectName, date) {
|
|
|
64
64
|
};
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
// INSERT WHERE A REBUILD WOULD HAVE PUT IT.
|
|
68
|
+
//
|
|
69
|
+
// Every category here is written twice: incrementally, by the command that
|
|
70
|
+
// creates the artifact, and wholesale by `index rebuild`, which walks the
|
|
71
|
+
// directory — `readdirSync(...).sort()` — and writes what it finds, in that
|
|
72
|
+
// order. The two agreed only by luck: appending matches a sorted walk exactly
|
|
73
|
+
// when the new entry happens to sort last.
|
|
74
|
+
//
|
|
75
|
+
// It usually does, which is why this survived. `doctrina spec new alpha` after
|
|
76
|
+
// `spec new zebra` does not, and neither does closing change 0138 after 0139 —
|
|
77
|
+
// that one shipped, and turned all six test legs of CI red on a tree whose own
|
|
78
|
+
// `close` had just reported green.
|
|
79
|
+
//
|
|
80
|
+
// `validate` cannot see it: drift of this kind is only visible by rebuilding
|
|
81
|
+
// and comparing, which is `index rebuild --check`, a different gate. So the
|
|
82
|
+
// tree looked healthy from inside and failed from outside.
|
|
83
|
+
//
|
|
84
|
+
// Each entry's `path` embeds the on-disk name the rebuild sorts by, and the
|
|
85
|
+
// parent directory is constant within a category — so ordering by `path`
|
|
86
|
+
// reproduces the walk exactly, without this module having to know how any
|
|
87
|
+
// category is laid out.
|
|
88
|
+
// The comparison has to be the SAME one: `readdirSync(dir).sort()` takes the
|
|
89
|
+
// default comparator, which orders by UTF-16 code unit, and `localeCompare`
|
|
90
|
+
// does not — it ignores or reweights punctuation, so `0100-a` and `0100_a`
|
|
91
|
+
// can come out in the opposite order. Matching the walk means comparing the
|
|
92
|
+
// way the walk compares.
|
|
93
|
+
function insertInRebuildOrder(list, entry) {
|
|
94
|
+
list.push(entry);
|
|
95
|
+
list.sort((a, b) => {
|
|
96
|
+
const x = String(a.path ?? ""), y = String(b.path ?? "");
|
|
97
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
98
|
+
});
|
|
99
|
+
return list;
|
|
100
|
+
}
|
|
101
|
+
|
|
67
102
|
export function addSkill(index, entry) {
|
|
68
103
|
if (!index.artifacts.skills) index.artifacts.skills = [];
|
|
69
104
|
if (!index.artifacts.skills.some((s) => s.id === entry.id)) {
|
|
70
|
-
index.artifacts.skills
|
|
105
|
+
insertInRebuildOrder(index.artifacts.skills, entry);
|
|
71
106
|
}
|
|
72
107
|
return index;
|
|
73
108
|
}
|
|
@@ -75,17 +110,11 @@ export function addSkill(index, entry) {
|
|
|
75
110
|
export function addContract(index, entry) {
|
|
76
111
|
if (!index.artifacts.contracts) index.artifacts.contracts = [];
|
|
77
112
|
if (!index.artifacts.contracts.some((s) => s.id === entry.id)) {
|
|
78
|
-
index.artifacts.contracts
|
|
113
|
+
insertInRebuildOrder(index.artifacts.contracts, entry);
|
|
79
114
|
}
|
|
80
115
|
return index;
|
|
81
116
|
}
|
|
82
117
|
|
|
83
|
-
export function removeSkill(index, id) {
|
|
84
|
-
if (!index.artifacts.skills) return index;
|
|
85
|
-
index.artifacts.skills = index.artifacts.skills.filter((s) => s.id !== id);
|
|
86
|
-
return index;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
118
|
export function touch(index, date) {
|
|
90
119
|
index.last_updated = date;
|
|
91
120
|
return index;
|
|
@@ -93,19 +122,14 @@ export function touch(index, date) {
|
|
|
93
122
|
|
|
94
123
|
export function addSpec(index, entry) {
|
|
95
124
|
if (!index.artifacts.specs.some((s) => s.id === entry.id)) {
|
|
96
|
-
index.artifacts.specs
|
|
125
|
+
insertInRebuildOrder(index.artifacts.specs, entry);
|
|
97
126
|
}
|
|
98
127
|
return index;
|
|
99
128
|
}
|
|
100
129
|
|
|
101
|
-
export function removeSpec(index, id) {
|
|
102
|
-
index.artifacts.specs = index.artifacts.specs.filter((s) => s.id !== id);
|
|
103
|
-
return index;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
130
|
export function addDecision(index, entry) {
|
|
107
131
|
if (!index.artifacts.decisions.some((d) => d.id === entry.id)) {
|
|
108
|
-
index.artifacts.decisions
|
|
132
|
+
insertInRebuildOrder(index.artifacts.decisions, entry);
|
|
109
133
|
}
|
|
110
134
|
return index;
|
|
111
135
|
}
|
|
@@ -117,17 +141,11 @@ export function updateDecision(index, id, mutator) {
|
|
|
117
141
|
return index;
|
|
118
142
|
}
|
|
119
143
|
|
|
120
|
-
export function addChange(index, entry) {
|
|
121
|
-
if (!index.artifacts.changes.some((c) => c.id === entry.id)) {
|
|
122
|
-
index.artifacts.changes.push(entry);
|
|
123
|
-
}
|
|
124
|
-
return index;
|
|
125
|
-
}
|
|
126
144
|
|
|
127
145
|
export function moveChangeToArchive(index, id, archiveEntry) {
|
|
128
146
|
index.artifacts.changes = index.artifacts.changes.filter((c) => c.id !== id);
|
|
129
147
|
if (!index.artifacts.changes_archive.some((c) => c.id === id)) {
|
|
130
|
-
index.artifacts.changes_archive
|
|
148
|
+
insertInRebuildOrder(index.artifacts.changes_archive, archiveEntry);
|
|
131
149
|
}
|
|
132
150
|
return index;
|
|
133
151
|
}
|