@nikovirtala/adr 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -9
- package/dist/cli.js +821 -61
- package/dist/cli.js.map +1 -1
- package/dist/index.js +3 -3
- package/dist/{kind-DqFhApu7.js → kind-DjvA3dyA.js} +10 -23
- package/dist/kind-DjvA3dyA.js.map +1 -0
- package/dist/kind.js +1 -1
- package/dist/scope-CLqH-q1y.js +2873 -0
- package/dist/scope-CLqH-q1y.js.map +1 -0
- package/dist/types/cli/accept.d.ts +2 -2
- package/dist/types/cli/deprecate.d.ts +3 -0
- package/dist/types/cli/for.d.ts +3 -0
- package/dist/types/cli/hooks.d.ts +6 -0
- package/dist/types/cli/index-command.d.ts +3 -0
- package/dist/types/cli/init.d.ts +3 -0
- package/dist/types/cli/link.d.ts +3 -0
- package/dist/types/cli/list.d.ts +1 -1
- package/dist/types/cli/new.d.ts +1 -1
- package/dist/types/cli/note.d.ts +3 -0
- package/dist/types/cli/open.d.ts +2 -2
- package/dist/types/cli/reject.d.ts +3 -0
- package/dist/types/cli/scope.d.ts +7 -0
- package/dist/types/cli/supersede.d.ts +1 -1
- package/dist/types/cli/text.d.ts +39 -3
- package/dist/types/cli/withdraw.d.ts +3 -0
- package/dist/types/commit-actions.d.ts +72 -0
- package/dist/types/config.d.ts +16 -2
- package/dist/types/hooks.d.ts +59 -0
- package/dist/types/index.d.ts +6 -2
- package/dist/types/init.d.ts +33 -0
- package/dist/types/log-index.d.ts +117 -0
- package/dist/types/log.d.ts +34 -1
- package/dist/types/scope.d.ts +51 -0
- package/dist/types/verify.d.ts +6 -4
- package/dist/types/views.d.ts +9 -3
- package/dist/types/write.d.ts +206 -26
- package/package.json +2 -2
- package/dist/kind-DqFhApu7.js.map +0 -1
- package/dist/write-C3IW5WrA.js +0 -1011
- package/dist/write-C3IW5WrA.js.map +0 -1
package/dist/cli.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { a as
|
|
3
|
-
import { n as DECISION_STATUSES, t as adrKind } from "./kind-
|
|
2
|
+
import { A as openDecisionLog, C as adrChecks, D as runDecisionIndex, N as resolveDecision, O as changedSince, S as withdrawDecision, _ as proposeDecision, a as initDecisionLog, c as installDecisionHooks, d as uninstallDecisionHooks, f as acceptDecision, g as noteDecision, h as linkDecision, j as readDecisions, k as numberingNote, l as printDecisionHooks, m as followScopeRenames, n as listDecisions, o as addHookTrailers, p as deprecateDecision, r as showDecision, t as decisionsFor, u as runPreCommitHook, v as rejectDecision, x as supersedeDecision, y as scopeDecision } from "./scope-CLqH-q1y.js";
|
|
3
|
+
import { n as DECISION_STATUSES, t as adrKind } from "./kind-DjvA3dyA.js";
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
|
-
import { UsageError, renderStateHeader,
|
|
6
|
-
import {
|
|
5
|
+
import { UsageError, exitCode, findingsReport, formatText, renderStateHeader, resolveAttribution, stagedRenames, templateHistoryRef } from "@nikovirtala/ledger";
|
|
6
|
+
import { resolve } from "node:path";
|
|
7
|
+
import { COMMIT_OPTIONS, commitChoice, confirm, processIO, runCli, verifyCommand } from "@nikovirtala/ledger/cli";
|
|
7
8
|
import { spawn } from "node:child_process";
|
|
8
9
|
// src/cli/open.ts
|
|
9
10
|
/**
|
|
@@ -27,10 +28,8 @@ async function displayAfterWrite(log) {
|
|
|
27
28
|
const read = await readDecisions(log);
|
|
28
29
|
return { display: (id) => read.projection.views.get(id)?.display ?? id };
|
|
29
30
|
}
|
|
30
|
-
/**
|
|
31
|
-
|
|
32
|
-
return typeof values.commit === "boolean" ? values.commit : log.adr.autoCommit;
|
|
33
|
-
}
|
|
31
|
+
/** `notes` with `note` appended when there is one. */
|
|
32
|
+
const notesWith = (notes, note) => note === null ? notes : [...notes, note];
|
|
34
33
|
// src/cli/text.ts
|
|
35
34
|
/** A table: each column padded to its widest value, trailing spaces removed. */
|
|
36
35
|
function columns(rows) {
|
|
@@ -43,6 +42,21 @@ function columns(rows) {
|
|
|
43
42
|
/** `adr list`: one row per decision, `→ <effective>` when it differs from the decision. */
|
|
44
43
|
function listText(doc, display) {
|
|
45
44
|
if (doc.decisions.length === 0) return "No decisions.";
|
|
45
|
+
return listRows(doc, display).join("\n");
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* `adr list --since <rev>` (design 2b D7): a heading, then each row followed by the event types
|
|
49
|
+
* added since `rev`; nothing at all when no decision changed, so the post-merge hook stays quiet.
|
|
50
|
+
*/
|
|
51
|
+
function sinceText(doc, since, display) {
|
|
52
|
+
if (doc.decisions.length === 0) return "";
|
|
53
|
+
const rows = listRows(doc, display);
|
|
54
|
+
return [`Changed since ${since.rev} (${since.commit.slice(0, 7)}):`, ...doc.decisions.flatMap((v, i) => {
|
|
55
|
+
const types = [...new Set(since.events.filter((e) => e.subject === v.id).map((e) => e.type))];
|
|
56
|
+
return [rows[i] ?? "", ` [new: ${types.join(", ")}]`];
|
|
57
|
+
})].join("\n");
|
|
58
|
+
}
|
|
59
|
+
function listRows(doc, display) {
|
|
46
60
|
return columns(doc.decisions.map((v) => {
|
|
47
61
|
const arrow = v.effective === null ? "→ (cycle)" : v.effective === v.id ? "" : `→ ${display(v.effective)}`;
|
|
48
62
|
return [
|
|
@@ -52,7 +66,7 @@ function listText(doc, display) {
|
|
|
52
66
|
v.title,
|
|
53
67
|
arrow
|
|
54
68
|
];
|
|
55
|
-
}));
|
|
69
|
+
})).split("\n");
|
|
56
70
|
}
|
|
57
71
|
/** `ADR-0003 (m2x7p4d2)`, or the id alone when it has no number. */
|
|
58
72
|
function named(id, display) {
|
|
@@ -90,18 +104,15 @@ const REASONS = {
|
|
|
90
104
|
"is empty": "empty",
|
|
91
105
|
"still contains its guidance text": "guidance text left"
|
|
92
106
|
};
|
|
93
|
-
/**
|
|
94
|
-
|
|
95
|
-
* section findings, and one line per other finding.
|
|
96
|
-
*/
|
|
97
|
-
function draftAdvice(findings) {
|
|
107
|
+
/** The section findings of a draft lint as headings and reasons, and the other findings. */
|
|
108
|
+
function sectionGaps(findings) {
|
|
98
109
|
const headings = [];
|
|
99
110
|
const reasons = [];
|
|
100
111
|
const other = [];
|
|
101
112
|
for (const f of findings) {
|
|
102
113
|
const m = SECTION_FINDING.exec(f.message);
|
|
103
114
|
if (m === null) {
|
|
104
|
-
other.push(
|
|
115
|
+
other.push(f);
|
|
105
116
|
continue;
|
|
106
117
|
}
|
|
107
118
|
const [, heading = "", what = ""] = m;
|
|
@@ -109,47 +120,138 @@ function draftAdvice(findings) {
|
|
|
109
120
|
if (!headings.includes(heading)) headings.push(heading);
|
|
110
121
|
if (!reasons.includes(reason)) reasons.push(reason);
|
|
111
122
|
}
|
|
123
|
+
return {
|
|
124
|
+
headings,
|
|
125
|
+
reasons,
|
|
126
|
+
other
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The draft lint of `new` as advice: `Fill in N sections before accepting: A, B (why)` for the
|
|
131
|
+
* section findings, and one line per other finding.
|
|
132
|
+
*/
|
|
133
|
+
function draftAdvice(findings) {
|
|
134
|
+
const { headings, reasons, other } = sectionGaps(findings);
|
|
135
|
+
const n = headings.length;
|
|
136
|
+
return [...n === 0 ? [] : [`Fill in ${n === 1 ? "1 section" : `${n} sections`} before accepting: ${headings.join(", ")} (${reasons.join(", ")})`], ...other.map((f) => `Draft: ${f.message} [${f.rule}]`)];
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The stderr warning of `new` when the draft it wrote has unfilled sections (design §7.2), or
|
|
140
|
+
* `null` when there are none.
|
|
141
|
+
*/
|
|
142
|
+
function unfilledWarning(doc) {
|
|
143
|
+
if (doc.command !== "new" || !doc.ok || doc.written.length === 0) return null;
|
|
144
|
+
const { headings, reasons } = sectionGaps(doc.findings);
|
|
112
145
|
const n = headings.length;
|
|
113
|
-
|
|
146
|
+
const path = doc.findings.find((f) => f.file !== null)?.file ?? doc.decision?.body_path ?? null;
|
|
147
|
+
if (n === 0 || path === null) return null;
|
|
148
|
+
return `${path}: fill in ${n === 1 ? "1 required section" : `${n} required sections`} before committing or accepting: ${headings.join(", ")} (${reasons.join(", ")}); adr verify reports this until then`;
|
|
114
149
|
}
|
|
115
150
|
function findingLines(findings) {
|
|
116
151
|
return findings.map((f) => ` ${f.file === null ? "" : `${f.file}: `}${f.message} [${f.rule}]`);
|
|
117
152
|
}
|
|
118
|
-
const short = (sha) => sha.slice(0, 7);
|
|
153
|
+
const short$1 = (sha) => sha.slice(0, 7);
|
|
154
|
+
/**
|
|
155
|
+
* `adr scope --follow-renames`: one `<named>: <old globs> → <new globs>` line per changed
|
|
156
|
+
* decision (`affected`), whether or not a decision was given.
|
|
157
|
+
*/
|
|
158
|
+
function followRenamesText(doc, display, o) {
|
|
159
|
+
if (!o.renamed) return "No staged renames.";
|
|
160
|
+
if (doc.affected.length === 0) return "No scope globs follow the staged renames.";
|
|
161
|
+
return [...doc.affected.map((v) => {
|
|
162
|
+
const before = o.before.get(v.id) ?? [];
|
|
163
|
+
const old = before.filter((g) => !v.applies_to.includes(g));
|
|
164
|
+
const now = v.applies_to.filter((g) => !before.includes(g));
|
|
165
|
+
const change = old.length === 0 ? `+ ${now.join(", ")}` : `${old.join(", ")} → ${now.join(", ")}`;
|
|
166
|
+
return `${named(v.id, display)}: ${change}`;
|
|
167
|
+
}), "Staged; commit the scoped events with the renames."].join("\n");
|
|
168
|
+
}
|
|
169
|
+
/** The past participle of each write command, for `Not <verb>: …`. */
|
|
170
|
+
const VERBS = {
|
|
171
|
+
new: "proposed",
|
|
172
|
+
accept: "accepted",
|
|
173
|
+
supersede: "superseded",
|
|
174
|
+
reject: "rejected",
|
|
175
|
+
withdraw: "withdrawn",
|
|
176
|
+
deprecate: "deprecated",
|
|
177
|
+
note: "noted",
|
|
178
|
+
link: "linked",
|
|
179
|
+
scope: "scoped"
|
|
180
|
+
};
|
|
119
181
|
/**
|
|
120
|
-
* The text of
|
|
121
|
-
*
|
|
182
|
+
* The text of a write command (design §5.6, §6.1). `subject` names what a refused command was
|
|
183
|
+
* about; `number` is the decision's number in the write result, if it has one now; `reason` is
|
|
184
|
+
* the reason a lifecycle exit recorded.
|
|
122
185
|
*/
|
|
123
186
|
function writeText(doc, display, o) {
|
|
124
|
-
const verb =
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
187
|
+
const verb = VERBS[doc.command];
|
|
188
|
+
if (!doc.ok) {
|
|
189
|
+
const unfilled = doc.command === "new" && sectionGaps(doc.findings).headings.length > 0;
|
|
190
|
+
return [
|
|
191
|
+
`Not ${verb}: ${o.subject}`,
|
|
192
|
+
...findingLines(doc.findings),
|
|
193
|
+
...unfilled ? ["Fill them in with --section <id>=<markdown>, or run without --commit and edit the draft."] : []
|
|
194
|
+
].join("\n");
|
|
195
|
+
}
|
|
196
|
+
if (o.followRenames !== void 0) return followRenamesText(doc, display, o.followRenames);
|
|
130
197
|
const v = doc.decision;
|
|
131
198
|
if (v === null) return "";
|
|
132
|
-
const committed = doc.commit === null ? null : `Committed ${short(doc.commit)}`;
|
|
199
|
+
const committed = doc.commit === null ? null : `Committed ${short$1(doc.commit)}`;
|
|
200
|
+
const recorded = committed === null ? "Staged; commit to record it." : `${committed}.`;
|
|
133
201
|
switch (doc.command) {
|
|
134
202
|
case "new": return [
|
|
135
203
|
`Proposed ${v.id}: ${v.title}`,
|
|
136
204
|
...doc.written.map((p) => ` ${p}`),
|
|
137
205
|
...draftAdvice(doc.findings),
|
|
138
206
|
...committed === null ? [] : [`${committed}.`],
|
|
139
|
-
`Next: adr accept ${v.id}`
|
|
207
|
+
o.actor?.kind === "agent" && o.acceptRequiresHuman === true ? `Next: ask a human to review and accept it (adr accept ${v.id})` : `Next: adr accept ${v.id}`
|
|
140
208
|
].join("\n");
|
|
141
209
|
case "accept": return [
|
|
142
210
|
`Accepted ${v.id}: ${v.title}`,
|
|
143
211
|
...doc.affected.map((a) => `Superseded ${named(a.id, display)}: ${a.title}`),
|
|
212
|
+
...o.copied === void 0 || o.copied === null ? [] : [`Copied scope from ${o.copied.displays.join(", ")}: ${o.copied.globs.join(", ")}`],
|
|
144
213
|
committed === null ? "Staged; commit to record it." : o.number === null ? `${committed}. The number is assigned when this commit lands on main.` : `${committed} as ${o.number}.`
|
|
145
214
|
].join("\n");
|
|
146
215
|
case "supersede": return [
|
|
147
216
|
`Superseded ${named(v.id, display)}: ${v.title}`,
|
|
148
217
|
` by ${named(v.superseded_by ?? "", display)}`,
|
|
149
|
-
|
|
218
|
+
recorded
|
|
150
219
|
].join("\n");
|
|
220
|
+
case "reject":
|
|
221
|
+
case "withdraw":
|
|
222
|
+
case "deprecate": return [
|
|
223
|
+
`${verb.charAt(0).toUpperCase()}${verb.slice(1)} ${named(v.id, display)}: ${v.title}`,
|
|
224
|
+
...o.reason === void 0 || o.reason === null ? [] : [` reason: ${o.reason}`],
|
|
225
|
+
recorded
|
|
226
|
+
].join("\n");
|
|
227
|
+
case "note": return [`Noted on ${named(v.id, display)}: ${v.title}`, recorded].join("\n");
|
|
228
|
+
case "link": {
|
|
229
|
+
const link = doc.written.length === 0 ? void 0 : v.links.at(-1);
|
|
230
|
+
if (link === void 0) return `Links of ${named(v.id, display)} unchanged`;
|
|
231
|
+
return [`Linked ${named(v.id, display)} ${link.rel} ${named(link.to, display)}`, recorded].join("\n");
|
|
232
|
+
}
|
|
233
|
+
case "scope": {
|
|
234
|
+
const before = new Set(o.scopeBefore ?? []);
|
|
235
|
+
const after = new Set(v.applies_to);
|
|
236
|
+
const globs = v.applies_to.length === 0 ? "no globs" : v.applies_to.join(", ");
|
|
237
|
+
if (doc.written.length === 0) return `Scope of ${named(v.id, display)} unchanged: ${globs}`;
|
|
238
|
+
return [
|
|
239
|
+
`Scope of ${named(v.id, display)}: ${v.title}`,
|
|
240
|
+
...v.applies_to.filter((g) => !before.has(g)).map((g) => ` + ${g}`),
|
|
241
|
+
...[...before].filter((g) => !after.has(g)).map((g) => ` - ${g}`),
|
|
242
|
+
` = ${globs}`,
|
|
243
|
+
recorded
|
|
244
|
+
].join("\n");
|
|
245
|
+
}
|
|
151
246
|
}
|
|
152
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* "Applies to" without globs: nothing is recorded, which isn't "governs nothing" (design §7.3),
|
|
250
|
+
* with the command that adds globs while the decision can still take them.
|
|
251
|
+
*/
|
|
252
|
+
function noGlobs(v, display) {
|
|
253
|
+
return adrKind.machine.allowed(v.status, "scoped") ? `no globs recorded; add them with adr scope ${display(v.id)} --add <glob>` : "no globs recorded";
|
|
254
|
+
}
|
|
153
255
|
/** `adr show`: the state header, the decision's fields and its body. */
|
|
154
256
|
function showText(doc, display) {
|
|
155
257
|
const v = doc.decision;
|
|
@@ -160,7 +262,7 @@ function showText(doc, display) {
|
|
|
160
262
|
["Effective", v.effective === null ? "none (supersession cycle)" : named(v.effective, display)],
|
|
161
263
|
["Chain", v.chain.map(display).join(" → ")],
|
|
162
264
|
["Supersedes", supersedes],
|
|
163
|
-
["Applies to", v.applies_to.length === 0 ?
|
|
265
|
+
["Applies to", v.applies_to.length === 0 ? noGlobs(v, display) : v.applies_to.join(", ")],
|
|
164
266
|
["Body", bodyLine(v)]
|
|
165
267
|
];
|
|
166
268
|
if (doc.events === null) fields.push(["History", v.history.join(" · ")]);
|
|
@@ -170,13 +272,81 @@ function showText(doc, display) {
|
|
|
170
272
|
const body = doc.body === null ? "" : `\n${doc.body.endsWith("\n") ? doc.body : `${doc.body}\n`}`;
|
|
171
273
|
return `${renderStateHeader(doc.header)}\n${lines.join("\n")}\n${body}`;
|
|
172
274
|
}
|
|
275
|
+
/**
|
|
276
|
+
* `adr for`: per path, one row per decision (named, status, title, how it matched), its warning
|
|
277
|
+
* below it when it isn't in force, then the anchors that name no decision of this log.
|
|
278
|
+
*/
|
|
279
|
+
function forText(doc, display, keyword) {
|
|
280
|
+
const lines = [];
|
|
281
|
+
for (const p of doc.paths) {
|
|
282
|
+
lines.push(p.tracked ? p.path : `${p.path} (not tracked)`);
|
|
283
|
+
if (p.decisions.length === 0 && p.unresolved.length === 0) lines.push(" no decisions");
|
|
284
|
+
const rows = columns(p.decisions.map((d) => [
|
|
285
|
+
named(d.id, display),
|
|
286
|
+
d.status,
|
|
287
|
+
d.title,
|
|
288
|
+
d.via.map((v) => v.type === "glob" ? `glob ${v.glob}` : `anchor line ${v.line}`).join(", ")
|
|
289
|
+
])).split("\n");
|
|
290
|
+
p.decisions.forEach((d, i) => {
|
|
291
|
+
lines.push(` ${rows[i] ?? ""}`);
|
|
292
|
+
if (d.warning !== null) lines.push(` ${d.warning}`);
|
|
293
|
+
});
|
|
294
|
+
for (const u of p.unresolved) {
|
|
295
|
+
const why = u.reason === "other-log" ? "in another log" : "not in this log";
|
|
296
|
+
lines.push(` unresolved: ${keyword}:${u.ref} (line ${u.line}, ${why})`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return lines.join("\n");
|
|
300
|
+
}
|
|
301
|
+
/** `adr init`: what was written and kept, then the next step; or that nothing was needed. */
|
|
302
|
+
function initText(doc) {
|
|
303
|
+
if (doc.written.length === 0) return `Already initialized: ${doc.root} (kept ${doc.kept.join(", ")})`;
|
|
304
|
+
return [
|
|
305
|
+
`Initialized ${doc.root}`,
|
|
306
|
+
...doc.written.map((p) => ` ${p}`),
|
|
307
|
+
...doc.kept.map((p) => `Kept ${p}`),
|
|
308
|
+
...doc.commit === null ? [] : [`Committed ${short$1(doc.commit)}.`],
|
|
309
|
+
"Next: adr new \"<title>\""
|
|
310
|
+
].join("\n");
|
|
311
|
+
}
|
|
173
312
|
// src/cli/accept.ts
|
|
174
|
-
|
|
313
|
+
const MAX_LISTED = 5;
|
|
314
|
+
/** `Copy the scope of ADR-0002 (src/a/**, src/b/**) to k3f9q8a1? [y/N] ` (design §6.10). */
|
|
315
|
+
function question(id, offer) {
|
|
316
|
+
const shown = offer.globs.slice(0, MAX_LISTED).join(", ");
|
|
317
|
+
const more = offer.globs.length - MAX_LISTED;
|
|
318
|
+
const globs = more > 0 ? `${shown} and ${more} more` : shown;
|
|
319
|
+
const ds = offer.displays;
|
|
320
|
+
return `Copy the scope of ${ds.length === 1 ? ds[0] : `${ds.slice(0, -1).join(", ")} and ${ds.at(-1)}`} (${ds.length === 1 ? globs : `${offer.globs.length} globs: ${globs}`}) to ${id}? [y/N] `;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* The flags decide; without them a person at a terminal is asked, and everyone else (scripts,
|
|
324
|
+
* hooks, CI, `--json`) gets the note with the `adr scope` command (design §6.10, D-e). `seen`
|
|
325
|
+
* receives the offer that was copied, for the text.
|
|
326
|
+
*/
|
|
327
|
+
function copyChoice(io, json, value, seen) {
|
|
328
|
+
const copy = async (offer) => {
|
|
329
|
+
seen(offer);
|
|
330
|
+
return true;
|
|
331
|
+
};
|
|
332
|
+
if (value === true) return copy;
|
|
333
|
+
if (value === false) return "skip";
|
|
334
|
+
if (json || !io.interactive) return "suggest";
|
|
335
|
+
return async (offer) => {
|
|
336
|
+
const yes = await confirm(io, question(offer.id, offer), { json });
|
|
337
|
+
if (yes) seen(offer);
|
|
338
|
+
return yes;
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
/** `adr accept <decision> [--copy-scope | --no-copy-scope] …` (design §5.6, §6.10). */
|
|
175
342
|
const acceptCommand = {
|
|
176
343
|
name: "accept",
|
|
177
344
|
summary: "accept a proposed decision, superseding the decisions it declares",
|
|
178
|
-
usage: "accept <decision> [--commit | --no-commit]",
|
|
179
|
-
options: {
|
|
345
|
+
usage: "accept <decision> [--copy-scope | --no-copy-scope] [--commit | --no-commit] [-m <message>]",
|
|
346
|
+
options: {
|
|
347
|
+
"copy-scope": { type: "boolean" },
|
|
348
|
+
...COMMIT_OPTIONS
|
|
349
|
+
},
|
|
180
350
|
positionals: {
|
|
181
351
|
min: 1,
|
|
182
352
|
max: 1,
|
|
@@ -186,11 +356,19 @@ const acceptCommand = {
|
|
|
186
356
|
async run({ io, flags, values, positionals }) {
|
|
187
357
|
const input = positionals[0] ?? "";
|
|
188
358
|
const log = await openForCli(io, flags);
|
|
189
|
-
const actor = await
|
|
359
|
+
const { actor, notes: attribution } = await resolveAttribution(log.git, { env: io.env });
|
|
360
|
+
const seen = { offer: null };
|
|
361
|
+
const copyScope = copyChoice(io, flags.json, values["copy-scope"], (offer) => {
|
|
362
|
+
seen.offer = offer;
|
|
363
|
+
});
|
|
190
364
|
const doc = await acceptDecision(log, {
|
|
191
365
|
id: input,
|
|
192
366
|
actor
|
|
193
|
-
}, {
|
|
367
|
+
}, {
|
|
368
|
+
...commitChoice(values, { autoCommit: log.adr.autoCommit }),
|
|
369
|
+
notes: attribution,
|
|
370
|
+
copyScope
|
|
371
|
+
});
|
|
194
372
|
const { display } = await displayAfterWrite(log);
|
|
195
373
|
const v = doc.decision;
|
|
196
374
|
return {
|
|
@@ -198,7 +376,295 @@ const acceptCommand = {
|
|
|
198
376
|
json: doc,
|
|
199
377
|
text: writeText(doc, display, {
|
|
200
378
|
subject: v === null ? input.trim() : `${display(v.id)}: ${v.title}`,
|
|
201
|
-
number: v === null || doc.commit === null || v.number === null ? null : v.display
|
|
379
|
+
number: v === null || doc.commit === null || v.number === null ? null : v.display,
|
|
380
|
+
copied: doc.ok ? seen.offer : null
|
|
381
|
+
}),
|
|
382
|
+
notes: doc.notes
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
// src/cli/deprecate.ts
|
|
387
|
+
const USAGE$5 = "deprecate <decision> --reason <text> [--commit | --no-commit] [-m <message>]";
|
|
388
|
+
/** `adr deprecate` (design §6.5): an accepted decision no longer in force. */
|
|
389
|
+
const deprecateCommand = {
|
|
390
|
+
name: "deprecate",
|
|
391
|
+
summary: "deprecate an accepted decision",
|
|
392
|
+
usage: USAGE$5,
|
|
393
|
+
options: {
|
|
394
|
+
reason: { type: "string" },
|
|
395
|
+
...COMMIT_OPTIONS
|
|
396
|
+
},
|
|
397
|
+
positionals: {
|
|
398
|
+
min: 1,
|
|
399
|
+
max: 1,
|
|
400
|
+
names: ["decision"]
|
|
401
|
+
},
|
|
402
|
+
ref: "reject",
|
|
403
|
+
async run({ io, flags, values, positionals }) {
|
|
404
|
+
const input = positionals[0] ?? "";
|
|
405
|
+
if (typeof values.reason !== "string" || values.reason.trim() === "") throw new UsageError(`missing --reason <text>; usage: adr ${USAGE$5}`);
|
|
406
|
+
const reason = typeof values.reason === "string" ? values.reason.trim() : null;
|
|
407
|
+
const log = await openForCli(io, flags);
|
|
408
|
+
const { actor, notes } = await resolveAttribution(log.git, { env: io.env });
|
|
409
|
+
const doc = await deprecateDecision(log, {
|
|
410
|
+
id: input,
|
|
411
|
+
reason,
|
|
412
|
+
actor
|
|
413
|
+
}, {
|
|
414
|
+
...commitChoice(values, { autoCommit: log.adr.autoCommit }),
|
|
415
|
+
notes
|
|
416
|
+
});
|
|
417
|
+
const { display } = await displayAfterWrite(log);
|
|
418
|
+
const v = doc.decision;
|
|
419
|
+
return {
|
|
420
|
+
exit: doc.ok ? 0 : 1,
|
|
421
|
+
json: doc,
|
|
422
|
+
text: writeText(doc, display, {
|
|
423
|
+
subject: v === null ? input.trim() : `${display(v.id)}: ${v.title}`,
|
|
424
|
+
number: null,
|
|
425
|
+
reason
|
|
426
|
+
}),
|
|
427
|
+
notes: doc.notes
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
// src/cli/for.ts
|
|
432
|
+
/** `adr for <path>… [--ref <ref>]` (design §6.9): the decisions that govern each file. */
|
|
433
|
+
const forCommand = {
|
|
434
|
+
name: "for",
|
|
435
|
+
summary: "list the decisions that govern files, by scope glob and code anchor",
|
|
436
|
+
usage: "for <path>…",
|
|
437
|
+
options: {},
|
|
438
|
+
positionals: {
|
|
439
|
+
min: 1,
|
|
440
|
+
max: 100,
|
|
441
|
+
names: ["path"]
|
|
442
|
+
},
|
|
443
|
+
ref: "read",
|
|
444
|
+
async run({ io, flags, positionals }) {
|
|
445
|
+
const log = await openForCli(io, flags);
|
|
446
|
+
const read = await readDecisions(log);
|
|
447
|
+
const doc = await decisionsFor(read, log, {
|
|
448
|
+
paths: positionals,
|
|
449
|
+
cwd: io.cwd
|
|
450
|
+
});
|
|
451
|
+
const display = (id) => read.projection.views.get(id)?.display ?? id;
|
|
452
|
+
return {
|
|
453
|
+
exit: 0,
|
|
454
|
+
json: doc,
|
|
455
|
+
text: forText(doc, display, read.adr.anchorKeyword),
|
|
456
|
+
notes: notesWith(doc.notes, numberingNote(read, { unavailable: false }))
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
// src/cli/hooks.ts
|
|
461
|
+
const USAGE$4 = "hooks install | uninstall | print --manager lefthook|husky|pre-commit | run pre-commit | trailers <message-file>";
|
|
462
|
+
const MANAGERS = [
|
|
463
|
+
"lefthook",
|
|
464
|
+
"husky",
|
|
465
|
+
"pre-commit"
|
|
466
|
+
];
|
|
467
|
+
const outcome = (doc, text, notes) => ({
|
|
468
|
+
exit: 0,
|
|
469
|
+
json: doc,
|
|
470
|
+
text,
|
|
471
|
+
notes
|
|
472
|
+
});
|
|
473
|
+
/**
|
|
474
|
+
* `adr hooks install | uninstall | print --manager … | run pre-commit | trailers <message-file>`
|
|
475
|
+
* (design 2b D4, D8, §4.3): adr's git hooks through ledger's shims, shared with other ark tools.
|
|
476
|
+
*/
|
|
477
|
+
const hooksCommand = {
|
|
478
|
+
name: "hooks",
|
|
479
|
+
summary: "install, remove or print adr's git hooks, and run their steps",
|
|
480
|
+
usage: USAGE$4,
|
|
481
|
+
options: { manager: { type: "string" } },
|
|
482
|
+
positionals: {
|
|
483
|
+
min: 1,
|
|
484
|
+
max: 2,
|
|
485
|
+
names: ["action", "argument"]
|
|
486
|
+
},
|
|
487
|
+
ref: "reject",
|
|
488
|
+
async run({ io, flags, values, positionals }) {
|
|
489
|
+
const [action = "", argument] = positionals;
|
|
490
|
+
const manager = values.manager;
|
|
491
|
+
if (manager !== void 0 && action !== "print") throw new UsageError(`--manager goes with hooks print only; usage: adr ${USAGE$4}`);
|
|
492
|
+
const noArgument = () => {
|
|
493
|
+
if (argument !== void 0) throw new UsageError(`unexpected argument ${JSON.stringify(argument)}; usage: adr ${USAGE$4}`);
|
|
494
|
+
};
|
|
495
|
+
switch (action) {
|
|
496
|
+
case "install": {
|
|
497
|
+
noArgument();
|
|
498
|
+
const log = await openForCli(io, flags);
|
|
499
|
+
const doc = await installDecisionHooks(log);
|
|
500
|
+
const lines = [...[...doc.written, ...doc.unchanged].map((p) => `${doc.written.includes(p) ? "wrote" : "unchanged"} ${p}`).sort((a, b) => order(a) - order(b)), `adr hooks: ${doc.registrations.map((r) => r.hook).join(", ")}`];
|
|
501
|
+
return outcome(doc, lines.join("\n"), log.notes);
|
|
502
|
+
}
|
|
503
|
+
case "uninstall": {
|
|
504
|
+
noArgument();
|
|
505
|
+
const log = await openForCli(io, flags);
|
|
506
|
+
const doc = await uninstallDecisionHooks(log);
|
|
507
|
+
const text = doc.written.length === 0 ? "adr hooks were not installed" : doc.written.map((p) => `removed adr from ${p}`).join("\n");
|
|
508
|
+
return outcome(doc, text, log.notes);
|
|
509
|
+
}
|
|
510
|
+
case "print": {
|
|
511
|
+
noArgument();
|
|
512
|
+
if (manager === void 0) throw new UsageError("hooks print needs --manager lefthook, husky or pre-commit");
|
|
513
|
+
if (!MANAGERS.includes(manager)) throw new UsageError(`unknown hook manager ${JSON.stringify(manager)}; use lefthook, husky or pre-commit`);
|
|
514
|
+
const log = await openForCli(io, flags);
|
|
515
|
+
const doc = printDecisionHooks(log, manager);
|
|
516
|
+
return outcome(doc, doc.snippet ?? "", log.notes);
|
|
517
|
+
}
|
|
518
|
+
case "run": {
|
|
519
|
+
if (argument !== "pre-commit") throw new UsageError("adr hooks run supports pre-commit");
|
|
520
|
+
const worktree = await openForCli(io, flags);
|
|
521
|
+
const index = await openForCli(io, flags, { reader: "index" });
|
|
522
|
+
const r = await runPreCommitHook({
|
|
523
|
+
worktree,
|
|
524
|
+
index,
|
|
525
|
+
env: io.env
|
|
526
|
+
});
|
|
527
|
+
return {
|
|
528
|
+
exit: r.exit,
|
|
529
|
+
json: findingsReport(r.findings, { failOn: r.failOn }),
|
|
530
|
+
text: r.findings.length === 0 ? "" : formatText(r.findings),
|
|
531
|
+
notes: r.notes
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
case "trailers": {
|
|
535
|
+
if (argument === void 0) throw new UsageError(`missing argument <message-file>; usage: adr ${USAGE$4}`);
|
|
536
|
+
const log = await openForCli(io, flags);
|
|
537
|
+
const doc = await addHookTrailers(log, resolve(io.cwd, argument));
|
|
538
|
+
return outcome(doc, "", log.notes);
|
|
539
|
+
}
|
|
540
|
+
default: throw new UsageError(`unknown hooks action ${JSON.stringify(action)}; use install, uninstall, print, run or trailers`);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
};
|
|
544
|
+
/** A shim line's rank in hook order, for the install text. */
|
|
545
|
+
function order(line) {
|
|
546
|
+
return [
|
|
547
|
+
"pre-commit",
|
|
548
|
+
"commit-msg",
|
|
549
|
+
"pre-push",
|
|
550
|
+
"post-merge"
|
|
551
|
+
].findIndex((h) => line.endsWith(`/${h}`));
|
|
552
|
+
}
|
|
553
|
+
// src/cli/index-command.ts
|
|
554
|
+
const short = (sha) => sha.slice(0, 7);
|
|
555
|
+
/** The text of `adr index` (design 2b §4.5). */
|
|
556
|
+
function indexText({ doc, refreshed }) {
|
|
557
|
+
const { path } = doc;
|
|
558
|
+
if (doc.check) return doc.findings.length === 0 ? `${path} is up to date` : formatText(doc.findings);
|
|
559
|
+
if (doc.commit !== null) return [`Committed ${path} as ${short(doc.commit)}`, ...refreshed ? [`Wrote ${path} with uncommitted log changes`] : []].join("\n");
|
|
560
|
+
if (refreshed) return `${path} is up to date in HEAD; wrote the work tree's copy, which includes uncommitted log changes`;
|
|
561
|
+
if (doc.fresh) return `${path} is up to date`;
|
|
562
|
+
return doc.written.length > 0 ? `Wrote ${path}` : `Staged ${path}`;
|
|
563
|
+
}
|
|
564
|
+
/** `adr index [--check] [--commit | --no-commit] [-m <message>]` (design 2b §4.5). */
|
|
565
|
+
const indexCommand = {
|
|
566
|
+
name: "index",
|
|
567
|
+
summary: "regenerate the decision index (index.md), or check that it is current",
|
|
568
|
+
usage: "index [--check] [--commit | --no-commit] [-m <message>]",
|
|
569
|
+
options: {
|
|
570
|
+
check: { type: "boolean" },
|
|
571
|
+
...COMMIT_OPTIONS
|
|
572
|
+
},
|
|
573
|
+
positionals: {
|
|
574
|
+
min: 0,
|
|
575
|
+
max: 0,
|
|
576
|
+
names: []
|
|
577
|
+
},
|
|
578
|
+
ref: "read",
|
|
579
|
+
async run({ io, flags, values }) {
|
|
580
|
+
const check = values.check === true;
|
|
581
|
+
if (check && (values.commit === true || typeof values.message === "string")) throw new UsageError("--check writes nothing; drop --commit and --message");
|
|
582
|
+
if (!check && flags.ref !== null) throw new UsageError("adr index writes the work tree; use --ref only with --check");
|
|
583
|
+
const log = await openForCli(io, flags);
|
|
584
|
+
const run = await runDecisionIndex(log, {
|
|
585
|
+
check,
|
|
586
|
+
...check ? {} : commitChoice(values, { autoCommit: log.adr.autoCommit })
|
|
587
|
+
});
|
|
588
|
+
return {
|
|
589
|
+
exit: check ? exitCode(run.doc.findings, { failOn: log.adr.failOn }) : 0,
|
|
590
|
+
json: run.doc,
|
|
591
|
+
text: indexText(run),
|
|
592
|
+
notes: run.doc.notes
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
// src/cli/init.ts
|
|
597
|
+
/** `adr init [--template <name>]… [--commit | --no-commit] [-m <message>]` (design §6.2). */
|
|
598
|
+
const initCommand = {
|
|
599
|
+
name: "init",
|
|
600
|
+
summary: "set up a decision log: a config file and starter templates",
|
|
601
|
+
usage: "init [--template <name>]… [--commit | --no-commit] [-m <message>]",
|
|
602
|
+
options: {
|
|
603
|
+
template: {
|
|
604
|
+
type: "string",
|
|
605
|
+
multiple: true
|
|
606
|
+
},
|
|
607
|
+
...COMMIT_OPTIONS
|
|
608
|
+
},
|
|
609
|
+
positionals: {
|
|
610
|
+
min: 0,
|
|
611
|
+
max: 0,
|
|
612
|
+
names: []
|
|
613
|
+
},
|
|
614
|
+
ref: "reject",
|
|
615
|
+
async run({ io, flags, values }) {
|
|
616
|
+
const templates = Array.isArray(values.template) ? values.template : void 0;
|
|
617
|
+
const log = await openForCli(io, flags);
|
|
618
|
+
const doc = await initDecisionLog(log, {
|
|
619
|
+
...templates === void 0 ? {} : { templates },
|
|
620
|
+
...commitChoice(values, { autoCommit: log.adr.autoCommit })
|
|
621
|
+
});
|
|
622
|
+
return {
|
|
623
|
+
exit: 0,
|
|
624
|
+
json: doc,
|
|
625
|
+
text: initText(doc),
|
|
626
|
+
notes: doc.notes
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
// src/cli/link.ts
|
|
631
|
+
/** `adr link <decision> <target> [--rel <rel>]` (design §6.7): a typed link, in any state. */
|
|
632
|
+
const linkCommand = {
|
|
633
|
+
name: "link",
|
|
634
|
+
summary: "link a decision to another one, here or in a configured source",
|
|
635
|
+
usage: "link <decision> <target> [--rel relates-to|refines|depends-on] [--commit | --no-commit] [-m <message>]",
|
|
636
|
+
options: {
|
|
637
|
+
rel: { type: "string" },
|
|
638
|
+
...COMMIT_OPTIONS
|
|
639
|
+
},
|
|
640
|
+
positionals: {
|
|
641
|
+
min: 2,
|
|
642
|
+
max: 2,
|
|
643
|
+
names: ["decision", "target"]
|
|
644
|
+
},
|
|
645
|
+
ref: "reject",
|
|
646
|
+
async run({ io, flags, values, positionals }) {
|
|
647
|
+
const [input = "", to = ""] = positionals;
|
|
648
|
+
const rel = typeof values.rel === "string" ? values.rel : "relates-to";
|
|
649
|
+
const log = await openForCli(io, flags);
|
|
650
|
+
const { actor, notes } = await resolveAttribution(log.git, { env: io.env });
|
|
651
|
+
const doc = await linkDecision(log, {
|
|
652
|
+
id: input,
|
|
653
|
+
to,
|
|
654
|
+
rel,
|
|
655
|
+
actor
|
|
656
|
+
}, {
|
|
657
|
+
...commitChoice(values, { autoCommit: log.adr.autoCommit }),
|
|
658
|
+
notes
|
|
659
|
+
});
|
|
660
|
+
const { display } = await displayAfterWrite(log);
|
|
661
|
+
const v = doc.decision;
|
|
662
|
+
return {
|
|
663
|
+
exit: doc.ok ? 0 : 1,
|
|
664
|
+
json: doc,
|
|
665
|
+
text: writeText(doc, display, {
|
|
666
|
+
subject: v === null ? input.trim() : `${display(v.id)}: ${v.title}`,
|
|
667
|
+
number: null
|
|
202
668
|
}),
|
|
203
669
|
notes: doc.notes
|
|
204
670
|
};
|
|
@@ -212,15 +678,25 @@ function statuses(values) {
|
|
|
212
678
|
return s;
|
|
213
679
|
});
|
|
214
680
|
}
|
|
215
|
-
/** `
|
|
681
|
+
/** `--since`'s revision: non-empty and not an option. */
|
|
682
|
+
function sinceRev(values) {
|
|
683
|
+
const v = values.since;
|
|
684
|
+
if (typeof v !== "string") return null;
|
|
685
|
+
if (v.trim() === "" || v.startsWith("-")) throw new UsageError("--since must be a revision");
|
|
686
|
+
return v;
|
|
687
|
+
}
|
|
688
|
+
/** `adr list [--status <status>]… [--since <rev>]` (design §5.6, 2b D7). */
|
|
216
689
|
const listCommand = {
|
|
217
690
|
name: "list",
|
|
218
691
|
summary: "list decisions with their computed status",
|
|
219
|
-
usage: "list [--status <status>]…",
|
|
220
|
-
options: {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
692
|
+
usage: "list [--status <status>]… [--since <rev>]",
|
|
693
|
+
options: {
|
|
694
|
+
status: {
|
|
695
|
+
type: "string",
|
|
696
|
+
multiple: true
|
|
697
|
+
},
|
|
698
|
+
since: { type: "string" }
|
|
699
|
+
},
|
|
224
700
|
positionals: {
|
|
225
701
|
min: 0,
|
|
226
702
|
max: 0,
|
|
@@ -229,10 +705,17 @@ const listCommand = {
|
|
|
229
705
|
ref: "read",
|
|
230
706
|
async run({ io, flags, values }) {
|
|
231
707
|
const status = statuses(values);
|
|
232
|
-
const
|
|
233
|
-
const
|
|
708
|
+
const rev = sinceRev(values);
|
|
709
|
+
const log = await openForCli(io, flags);
|
|
710
|
+
const since = rev === null ? null : await changedSince(log, rev);
|
|
711
|
+
const read = await readDecisions(log);
|
|
712
|
+
const doc = listDecisions(read, {
|
|
713
|
+
status,
|
|
714
|
+
since
|
|
715
|
+
});
|
|
234
716
|
const notes = [...doc.notes];
|
|
235
|
-
|
|
717
|
+
const numbering = numberingNote(read, { unavailable: true });
|
|
718
|
+
if (numbering !== null && doc.decisions.length > 0) notes.push(numbering);
|
|
236
719
|
if (doc.findings.length > 0) notes.push(`the decision log has ${plural(doc.findings.length, "problem", "problems")}; run adr verify`);
|
|
237
720
|
if (doc.unreadable.length > 0) notes.push(`${plural(doc.unreadable.length, "decision uses", "decisions use")} a newer format; upgrade adr`);
|
|
238
721
|
if (doc.unfoldable.length > 0) notes.push(`${plural(doc.unfoldable.length, "decision", "decisions")} can't be read; run adr verify`);
|
|
@@ -240,7 +723,7 @@ const listCommand = {
|
|
|
240
723
|
return {
|
|
241
724
|
exit: 0,
|
|
242
725
|
json: doc,
|
|
243
|
-
text: listText(doc, display),
|
|
726
|
+
text: doc.since === null ? listText(doc, display) : sinceText(doc, doc.since, display),
|
|
244
727
|
notes
|
|
245
728
|
};
|
|
246
729
|
}
|
|
@@ -283,7 +766,7 @@ function editorFor(env, o) {
|
|
|
283
766
|
});
|
|
284
767
|
}
|
|
285
768
|
// src/cli/new.ts
|
|
286
|
-
const strings = (v) => Array.isArray(v) ? v : [];
|
|
769
|
+
const strings$1 = (v) => Array.isArray(v) ? v : [];
|
|
287
770
|
/** `--section <id>=<markdown>` values, split at the first `=`; each id at most once. */
|
|
288
771
|
function sections(values) {
|
|
289
772
|
const out = {};
|
|
@@ -303,7 +786,7 @@ function sections(values) {
|
|
|
303
786
|
const newCommand = {
|
|
304
787
|
name: "new",
|
|
305
788
|
summary: "propose a decision from a template and stage it",
|
|
306
|
-
usage: "new <title> [--template <name>] [--supersedes <decision>]… [--section <id>=<markdown>]… [--edit | --no-edit] [--commit | --no-commit]",
|
|
789
|
+
usage: "new <title> [--template <name>] [--supersedes <decision>]… [--section <id>=<markdown>]… [--edit | --no-edit] [--commit | --no-commit] [-m <message>]",
|
|
307
790
|
options: {
|
|
308
791
|
template: { type: "string" },
|
|
309
792
|
supersedes: {
|
|
@@ -315,7 +798,7 @@ const newCommand = {
|
|
|
315
798
|
multiple: true
|
|
316
799
|
},
|
|
317
800
|
edit: { type: "boolean" },
|
|
318
|
-
|
|
801
|
+
...COMMIT_OPTIONS
|
|
319
802
|
},
|
|
320
803
|
positionals: {
|
|
321
804
|
min: 1,
|
|
@@ -325,10 +808,10 @@ const newCommand = {
|
|
|
325
808
|
ref: "reject",
|
|
326
809
|
async run({ io, flags, values, positionals }) {
|
|
327
810
|
const title = positionals[0] ?? "";
|
|
328
|
-
const given = sections(strings(values.section));
|
|
811
|
+
const given = sections(strings$1(values.section));
|
|
329
812
|
const log = await openForCli(io, flags);
|
|
330
|
-
const actor = await
|
|
331
|
-
const requested =
|
|
813
|
+
const { actor, notes: attribution } = await resolveAttribution(log.git, { env: io.env });
|
|
814
|
+
const { commit: requested, message } = commitChoice(values, { autoCommit: log.adr.autoCommit });
|
|
332
815
|
const notes = [];
|
|
333
816
|
const editor = editorFor(io.env, { stdoutIsTTY: io.stdoutIsTTY });
|
|
334
817
|
const wantsEdit = values.edit === true || values.edit === void 0 && io.interactive;
|
|
@@ -341,24 +824,245 @@ const newCommand = {
|
|
|
341
824
|
const doc = await proposeDecision(log, {
|
|
342
825
|
title,
|
|
343
826
|
sections: given,
|
|
344
|
-
supersedes: strings(values.supersedes),
|
|
827
|
+
supersedes: strings$1(values.supersedes),
|
|
345
828
|
actor,
|
|
346
829
|
...typeof values.template === "string" ? { template: values.template } : {}
|
|
347
830
|
}, {
|
|
348
831
|
commit: requested,
|
|
832
|
+
message,
|
|
833
|
+
notes: attribution,
|
|
349
834
|
...edit === void 0 ? {} : { edit },
|
|
350
835
|
onNote: (note) => notes.push(requested ? `not committed: ${note}` : note)
|
|
351
836
|
});
|
|
352
837
|
const { display } = await displayAfterWrite(log);
|
|
353
838
|
const v = doc.decision;
|
|
839
|
+
const unfilled = unfilledWarning(doc);
|
|
354
840
|
return {
|
|
355
841
|
exit: doc.ok ? 0 : 1,
|
|
356
842
|
json: doc,
|
|
357
843
|
text: writeText(doc, display, {
|
|
358
844
|
subject: title.trim(),
|
|
359
|
-
number: v === null || v.number === null ? null : v.display
|
|
845
|
+
number: v === null || v.number === null ? null : v.display,
|
|
846
|
+
actor,
|
|
847
|
+
acceptRequiresHuman: log.adr.acceptRequiresHuman
|
|
848
|
+
}),
|
|
849
|
+
notes: [
|
|
850
|
+
...doc.notes,
|
|
851
|
+
...notes,
|
|
852
|
+
...unfilled === null ? [] : [unfilled]
|
|
853
|
+
]
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
};
|
|
857
|
+
// src/cli/note.ts
|
|
858
|
+
/** `adr note <decision> <text>` (design §6.6): a note on a decision in any state. */
|
|
859
|
+
const noteCommand = {
|
|
860
|
+
name: "note",
|
|
861
|
+
summary: "add a note to a decision, in any state",
|
|
862
|
+
usage: "note <decision> <text> [--commit | --no-commit] [-m <message>]",
|
|
863
|
+
options: { ...COMMIT_OPTIONS },
|
|
864
|
+
positionals: {
|
|
865
|
+
min: 2,
|
|
866
|
+
max: 2,
|
|
867
|
+
names: ["decision", "text"]
|
|
868
|
+
},
|
|
869
|
+
ref: "reject",
|
|
870
|
+
async run({ io, flags, values, positionals }) {
|
|
871
|
+
const [input = "", text = ""] = positionals;
|
|
872
|
+
const log = await openForCli(io, flags);
|
|
873
|
+
const { actor, notes } = await resolveAttribution(log.git, { env: io.env });
|
|
874
|
+
const doc = await noteDecision(log, {
|
|
875
|
+
id: input,
|
|
876
|
+
text,
|
|
877
|
+
actor
|
|
878
|
+
}, {
|
|
879
|
+
...commitChoice(values, { autoCommit: log.adr.autoCommit }),
|
|
880
|
+
notes
|
|
881
|
+
});
|
|
882
|
+
const { display } = await displayAfterWrite(log);
|
|
883
|
+
const v = doc.decision;
|
|
884
|
+
return {
|
|
885
|
+
exit: doc.ok ? 0 : 1,
|
|
886
|
+
json: doc,
|
|
887
|
+
text: writeText(doc, display, {
|
|
888
|
+
subject: v === null ? input.trim() : `${display(v.id)}: ${v.title}`,
|
|
889
|
+
number: null
|
|
890
|
+
}),
|
|
891
|
+
notes: doc.notes
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
};
|
|
895
|
+
// src/cli/reject.ts
|
|
896
|
+
const USAGE$3 = "reject <decision> --reason <text> [--commit | --no-commit] [-m <message>]";
|
|
897
|
+
/** `adr reject` (design §6.3): a proposal that won't be adopted. */
|
|
898
|
+
const rejectCommand = {
|
|
899
|
+
name: "reject",
|
|
900
|
+
summary: "reject a proposed decision",
|
|
901
|
+
usage: USAGE$3,
|
|
902
|
+
options: {
|
|
903
|
+
reason: { type: "string" },
|
|
904
|
+
...COMMIT_OPTIONS
|
|
905
|
+
},
|
|
906
|
+
positionals: {
|
|
907
|
+
min: 1,
|
|
908
|
+
max: 1,
|
|
909
|
+
names: ["decision"]
|
|
910
|
+
},
|
|
911
|
+
ref: "reject",
|
|
912
|
+
async run({ io, flags, values, positionals }) {
|
|
913
|
+
const input = positionals[0] ?? "";
|
|
914
|
+
if (typeof values.reason !== "string" || values.reason.trim() === "") throw new UsageError(`missing --reason <text>; usage: adr ${USAGE$3}`);
|
|
915
|
+
const reason = typeof values.reason === "string" ? values.reason.trim() : null;
|
|
916
|
+
const log = await openForCli(io, flags);
|
|
917
|
+
const { actor, notes } = await resolveAttribution(log.git, { env: io.env });
|
|
918
|
+
const doc = await rejectDecision(log, {
|
|
919
|
+
id: input,
|
|
920
|
+
reason,
|
|
921
|
+
actor
|
|
922
|
+
}, {
|
|
923
|
+
...commitChoice(values, { autoCommit: log.adr.autoCommit }),
|
|
924
|
+
notes
|
|
925
|
+
});
|
|
926
|
+
const { display } = await displayAfterWrite(log);
|
|
927
|
+
const v = doc.decision;
|
|
928
|
+
return {
|
|
929
|
+
exit: doc.ok ? 0 : 1,
|
|
930
|
+
json: doc,
|
|
931
|
+
text: writeText(doc, display, {
|
|
932
|
+
subject: v === null ? input.trim() : `${display(v.id)}: ${v.title}`,
|
|
933
|
+
number: null,
|
|
934
|
+
reason
|
|
935
|
+
}),
|
|
936
|
+
notes: doc.notes
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
};
|
|
940
|
+
// src/cli/scope.ts
|
|
941
|
+
const USAGE$2 = "scope <decision> ([--set <glob>]… | --clear | [--add <glob>]… [--remove <glob>]…) [--commit | --no-commit] [-m <message>] | scope [<decision>] --follow-renames";
|
|
942
|
+
/** The flag names `applySetOp` uses in its messages (design §3.2). */
|
|
943
|
+
const NAMES = {
|
|
944
|
+
set: "--set",
|
|
945
|
+
add: "--add",
|
|
946
|
+
remove: "--remove",
|
|
947
|
+
clear: "--clear"
|
|
948
|
+
};
|
|
949
|
+
const strings = (v) => Array.isArray(v) ? v : [];
|
|
950
|
+
/** `--set`/`--clear`/`--add`/`--remove` as a set operation; `--clear` is `{ set: [] }`. */
|
|
951
|
+
function setOp(values) {
|
|
952
|
+
const set = strings(values.set);
|
|
953
|
+
const add = strings(values.add);
|
|
954
|
+
const remove = strings(values.remove);
|
|
955
|
+
if (values.clear === true) {
|
|
956
|
+
if (set.length > 0 || add.length > 0 || remove.length > 0) throw new UsageError("--clear can't be combined with --set, --add or --remove");
|
|
957
|
+
return { set: [] };
|
|
958
|
+
}
|
|
959
|
+
return {
|
|
960
|
+
...set.length > 0 ? { set } : {},
|
|
961
|
+
add,
|
|
962
|
+
remove
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* `--follow-renames` alone: no set operation, no commit and no message, all refused before
|
|
967
|
+
* `commitChoice` so the answer doesn't depend on `auto_commit` (design §6.8).
|
|
968
|
+
*/
|
|
969
|
+
function checkFollowRenames(values) {
|
|
970
|
+
if ([
|
|
971
|
+
"set",
|
|
972
|
+
"add",
|
|
973
|
+
"remove"
|
|
974
|
+
].some((k) => strings(values[k]).length > 0) || values.clear === true) throw new UsageError("--follow-renames can't be combined with --set, --clear, --add or --remove");
|
|
975
|
+
if (values.commit === true) throw new UsageError("--follow-renames stages its events for the commit that contains the renames; commit them together");
|
|
976
|
+
if (typeof values.message === "string") throw new UsageError("--follow-renames never commits, so it takes no --message");
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* `adr scope <decision> --set|--clear|--add|--remove …` (design §6.8): the globs of the code a
|
|
980
|
+
* decision governs, written as one whole-set `scoped` event. `adr scope [<decision>]
|
|
981
|
+
* --follow-renames` rewrites them after staged renames and stages the events, never committing.
|
|
982
|
+
*/
|
|
983
|
+
const scopeCommand = {
|
|
984
|
+
name: "scope",
|
|
985
|
+
summary: "set, add or remove the globs of the code a decision governs",
|
|
986
|
+
usage: USAGE$2,
|
|
987
|
+
options: {
|
|
988
|
+
set: {
|
|
989
|
+
type: "string",
|
|
990
|
+
multiple: true
|
|
991
|
+
},
|
|
992
|
+
add: {
|
|
993
|
+
type: "string",
|
|
994
|
+
multiple: true
|
|
995
|
+
},
|
|
996
|
+
remove: {
|
|
997
|
+
type: "string",
|
|
998
|
+
multiple: true
|
|
999
|
+
},
|
|
1000
|
+
clear: { type: "boolean" },
|
|
1001
|
+
"follow-renames": { type: "boolean" },
|
|
1002
|
+
...COMMIT_OPTIONS
|
|
1003
|
+
},
|
|
1004
|
+
positionals: {
|
|
1005
|
+
min: 0,
|
|
1006
|
+
max: 1,
|
|
1007
|
+
names: ["decision"]
|
|
1008
|
+
},
|
|
1009
|
+
ref: "reject",
|
|
1010
|
+
async run({ io, flags, values, positionals }) {
|
|
1011
|
+
const input = positionals[0];
|
|
1012
|
+
if (values["follow-renames"] === true) {
|
|
1013
|
+
checkFollowRenames(values);
|
|
1014
|
+
const log = await openForCli(io, flags);
|
|
1015
|
+
const { actor, notes } = await resolveAttribution(log.git, { env: io.env });
|
|
1016
|
+
const read = await readDecisions(log);
|
|
1017
|
+
const before = new Map([...read.projection.views].map(([id, v]) => [id, v.applies_to]));
|
|
1018
|
+
const renamed = (await stagedRenames(log.git)).length > 0;
|
|
1019
|
+
const doc = await followScopeRenames(log, {
|
|
1020
|
+
...input === void 0 ? {} : { id: input },
|
|
1021
|
+
actor
|
|
1022
|
+
}, { notes });
|
|
1023
|
+
const { display } = await displayAfterWrite(log);
|
|
1024
|
+
const v = doc.decision;
|
|
1025
|
+
return {
|
|
1026
|
+
exit: doc.ok ? 0 : 1,
|
|
1027
|
+
json: doc,
|
|
1028
|
+
text: writeText(doc, display, {
|
|
1029
|
+
subject: v === null ? (input ?? "").trim() : `${display(v.id)}: ${v.title}`,
|
|
1030
|
+
number: null,
|
|
1031
|
+
followRenames: {
|
|
1032
|
+
before,
|
|
1033
|
+
renamed
|
|
1034
|
+
}
|
|
1035
|
+
}),
|
|
1036
|
+
notes: doc.notes
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
const op = setOp(values);
|
|
1040
|
+
if (input === void 0) throw new UsageError(`missing argument <decision>; usage: adr ${USAGE$2}`);
|
|
1041
|
+
const log = await openForCli(io, flags);
|
|
1042
|
+
const { actor, notes } = await resolveAttribution(log.git, { env: io.env });
|
|
1043
|
+
const choice = commitChoice(values, { autoCommit: log.adr.autoCommit });
|
|
1044
|
+
const read = await readDecisions(log);
|
|
1045
|
+
const before = read.projection.views.get(resolveDecision(read, input))?.applies_to ?? [];
|
|
1046
|
+
const doc = await scopeDecision(log, {
|
|
1047
|
+
id: input,
|
|
1048
|
+
op,
|
|
1049
|
+
actor,
|
|
1050
|
+
names: NAMES
|
|
1051
|
+
}, {
|
|
1052
|
+
...choice,
|
|
1053
|
+
notes
|
|
1054
|
+
});
|
|
1055
|
+
const { display } = await displayAfterWrite(log);
|
|
1056
|
+
const v = doc.decision;
|
|
1057
|
+
return {
|
|
1058
|
+
exit: doc.ok ? 0 : 1,
|
|
1059
|
+
json: doc,
|
|
1060
|
+
text: writeText(doc, display, {
|
|
1061
|
+
subject: v === null ? input.trim() : `${display(v.id)}: ${v.title}`,
|
|
1062
|
+
number: null,
|
|
1063
|
+
scopeBefore: before
|
|
360
1064
|
}),
|
|
361
|
-
notes:
|
|
1065
|
+
notes: doc.notes
|
|
362
1066
|
};
|
|
363
1067
|
}
|
|
364
1068
|
};
|
|
@@ -384,21 +1088,21 @@ const showCommand = {
|
|
|
384
1088
|
exit: 0,
|
|
385
1089
|
json: doc,
|
|
386
1090
|
text: showText(doc, display),
|
|
387
|
-
notes: doc.notes
|
|
1091
|
+
notes: notesWith(doc.notes, numberingNote(read, { unavailable: false }))
|
|
388
1092
|
};
|
|
389
1093
|
}
|
|
390
1094
|
};
|
|
391
1095
|
// src/cli/supersede.ts
|
|
392
|
-
const USAGE = "supersede <decision> --by <decision> [--reason <text>] [--commit | --no-commit]";
|
|
1096
|
+
const USAGE$1 = "supersede <decision> --by <decision> [--reason <text>] [--commit | --no-commit] [-m <message>]";
|
|
393
1097
|
/** `adr supersede <decision> --by <decision> …` (design §5.6). */
|
|
394
1098
|
const supersedeCommand = {
|
|
395
1099
|
name: "supersede",
|
|
396
1100
|
summary: "mark an accepted decision as superseded by another accepted one",
|
|
397
|
-
usage: USAGE,
|
|
1101
|
+
usage: USAGE$1,
|
|
398
1102
|
options: {
|
|
399
1103
|
by: { type: "string" },
|
|
400
1104
|
reason: { type: "string" },
|
|
401
|
-
|
|
1105
|
+
...COMMIT_OPTIONS
|
|
402
1106
|
},
|
|
403
1107
|
positionals: {
|
|
404
1108
|
min: 1,
|
|
@@ -408,15 +1112,18 @@ const supersedeCommand = {
|
|
|
408
1112
|
ref: "reject",
|
|
409
1113
|
async run({ io, flags, values, positionals }) {
|
|
410
1114
|
const input = positionals[0] ?? "";
|
|
411
|
-
if (typeof values.by !== "string") throw new UsageError(`missing --by <decision>; usage: adr ${USAGE}`);
|
|
1115
|
+
if (typeof values.by !== "string") throw new UsageError(`missing --by <decision>; usage: adr ${USAGE$1}`);
|
|
412
1116
|
const log = await openForCli(io, flags);
|
|
413
|
-
const actor = await
|
|
1117
|
+
const { actor, notes: attribution } = await resolveAttribution(log.git, { env: io.env });
|
|
414
1118
|
const doc = await supersedeDecision(log, {
|
|
415
1119
|
id: input,
|
|
416
1120
|
by: values.by,
|
|
417
1121
|
actor,
|
|
418
1122
|
...typeof values.reason === "string" ? { reason: values.reason } : {}
|
|
419
|
-
}, {
|
|
1123
|
+
}, {
|
|
1124
|
+
...commitChoice(values, { autoCommit: log.adr.autoCommit }),
|
|
1125
|
+
notes: attribution
|
|
1126
|
+
});
|
|
420
1127
|
const { display } = await displayAfterWrite(log);
|
|
421
1128
|
const v = doc.decision;
|
|
422
1129
|
return {
|
|
@@ -430,6 +1137,49 @@ const supersedeCommand = {
|
|
|
430
1137
|
};
|
|
431
1138
|
}
|
|
432
1139
|
};
|
|
1140
|
+
/** `adr withdraw` (design §6.4): a proposal its author takes back. */
|
|
1141
|
+
const withdrawCommand = {
|
|
1142
|
+
name: "withdraw",
|
|
1143
|
+
summary: "withdraw a proposed decision",
|
|
1144
|
+
usage: "withdraw <decision> [--reason <text>] [--commit | --no-commit] [-m <message>]",
|
|
1145
|
+
options: {
|
|
1146
|
+
reason: { type: "string" },
|
|
1147
|
+
...COMMIT_OPTIONS
|
|
1148
|
+
},
|
|
1149
|
+
positionals: {
|
|
1150
|
+
min: 1,
|
|
1151
|
+
max: 1,
|
|
1152
|
+
names: ["decision"]
|
|
1153
|
+
},
|
|
1154
|
+
ref: "reject",
|
|
1155
|
+
async run({ io, flags, values, positionals }) {
|
|
1156
|
+
const input = positionals[0] ?? "";
|
|
1157
|
+
if (typeof values.reason === "string" && values.reason.trim() === "") throw new UsageError("--reason must not be empty");
|
|
1158
|
+
const reason = typeof values.reason === "string" ? values.reason.trim() : null;
|
|
1159
|
+
const log = await openForCli(io, flags);
|
|
1160
|
+
const { actor, notes } = await resolveAttribution(log.git, { env: io.env });
|
|
1161
|
+
const doc = await withdrawDecision(log, {
|
|
1162
|
+
id: input,
|
|
1163
|
+
reason,
|
|
1164
|
+
actor
|
|
1165
|
+
}, {
|
|
1166
|
+
...commitChoice(values, { autoCommit: log.adr.autoCommit }),
|
|
1167
|
+
notes
|
|
1168
|
+
});
|
|
1169
|
+
const { display } = await displayAfterWrite(log);
|
|
1170
|
+
const v = doc.decision;
|
|
1171
|
+
return {
|
|
1172
|
+
exit: doc.ok ? 0 : 1,
|
|
1173
|
+
json: doc,
|
|
1174
|
+
text: writeText(doc, display, {
|
|
1175
|
+
subject: v === null ? input.trim() : `${display(v.id)}: ${v.title}`,
|
|
1176
|
+
number: null,
|
|
1177
|
+
reason
|
|
1178
|
+
}),
|
|
1179
|
+
notes: doc.notes
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
};
|
|
433
1183
|
// src/cli/commands.ts
|
|
434
1184
|
/**
|
|
435
1185
|
* ledger's shared `verify`: the log as the mode reads it (the index for `--staged`, else the
|
|
@@ -453,9 +1203,19 @@ function adrCli(version) {
|
|
|
453
1203
|
version,
|
|
454
1204
|
summary: "Architecture decision records kept as event logs in git.",
|
|
455
1205
|
commands: [
|
|
1206
|
+
initCommand,
|
|
456
1207
|
newCommand,
|
|
457
1208
|
acceptCommand,
|
|
1209
|
+
rejectCommand,
|
|
1210
|
+
withdrawCommand,
|
|
1211
|
+
deprecateCommand,
|
|
458
1212
|
supersedeCommand,
|
|
1213
|
+
noteCommand,
|
|
1214
|
+
linkCommand,
|
|
1215
|
+
scopeCommand,
|
|
1216
|
+
indexCommand,
|
|
1217
|
+
hooksCommand,
|
|
1218
|
+
forCommand,
|
|
459
1219
|
listCommand,
|
|
460
1220
|
showCommand,
|
|
461
1221
|
verify
|