@nikovirtala/adr 0.0.0 → 0.2.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/LICENSE +21 -0
- package/README.md +156 -0
- package/dist/cli.js +1057 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.js +3 -0
- package/dist/kind-BcHjCHvi.js +415 -0
- package/dist/kind-BcHjCHvi.js.map +1 -0
- package/dist/kind.js +1 -0
- package/dist/types/cli/accept.d.ts +3 -0
- package/dist/types/cli/commands.d.ts +3 -0
- package/dist/types/cli/deprecate.d.ts +3 -0
- package/dist/types/cli/editor.d.ts +12 -0
- package/dist/types/cli/for.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 +3 -0
- package/dist/types/cli/new.d.ts +6 -0
- package/dist/types/cli/note.d.ts +3 -0
- package/dist/types/cli/open.d.ts +17 -0
- package/dist/types/cli/reject.d.ts +3 -0
- package/dist/types/cli/scope.d.ts +7 -0
- package/dist/types/cli/show.d.ts +3 -0
- package/dist/types/cli/supersede.d.ts +3 -0
- package/dist/types/cli/text.d.ts +46 -0
- package/dist/types/cli/withdraw.d.ts +3 -0
- package/dist/types/cli.d.ts +2 -0
- package/dist/types/config.d.ts +33 -0
- package/dist/types/index.d.ts +8 -0
- package/dist/types/init.d.ts +27 -0
- package/dist/types/kind/project.d.ts +50 -0
- package/dist/types/kind/reduce.d.ts +35 -0
- package/dist/types/kind/rules.d.ts +20 -0
- package/dist/types/kind/schemas.d.ts +37 -0
- package/dist/types/kind.d.ts +18 -0
- package/dist/types/log.d.ts +53 -0
- package/dist/types/scope.d.ts +51 -0
- package/dist/types/templates/builtin.d.ts +8 -0
- package/dist/types/verify.d.ts +10 -0
- package/dist/types/views.d.ts +65 -0
- package/dist/types/write.d.ts +333 -0
- package/dist/write-9IaqHRrY.js +1986 -0
- package/dist/write-9IaqHRrY.js.map +1 -0
- package/package.json +55 -1
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1057 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { n as DECISION_STATUSES, t as adrKind } from "./kind-BcHjCHvi.js";
|
|
3
|
+
import { C as initDecisionLog, S as unavailableReason, a as noteDecision, c as scopeDecision, d as withdrawDecision, f as adrChecks, g as showDecision, h as listDecisions, i as linkDecision, m as decisionsFor, n as deprecateDecision, o as proposeDecision, r as followScopeRenames, s as rejectDecision, t as acceptDecision, u as supersedeDecision, v as openDecisionLog, x as resolveDecision, y as readDecisions } from "./write-9IaqHRrY.js";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { UsageError, renderStateHeader, resolveAttribution, stagedRenames, templateHistoryRef } from "@nikovirtala/ledger";
|
|
6
|
+
import { COMMIT_OPTIONS, commitChoice, confirm, processIO, runCli, verifyCommand } from "@nikovirtala/ledger/cli";
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
// src/cli/open.ts
|
|
9
|
+
/**
|
|
10
|
+
* The decision log a command works on: `--root` and `--ref` from the globals, git run with the
|
|
11
|
+
* CLI's environment.
|
|
12
|
+
*/
|
|
13
|
+
function openForCli(io, flags, o = {}) {
|
|
14
|
+
return openDecisionLog({
|
|
15
|
+
cwd: io.cwd,
|
|
16
|
+
root: flags.root,
|
|
17
|
+
ref: flags.ref,
|
|
18
|
+
...o.reader === void 0 ? {} : { reader: o.reader },
|
|
19
|
+
gitOptions: { env: io.env }
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* How other decisions display in a write's text, read again from the work tree, like the write
|
|
24
|
+
* result's own views: a commit on the main ref has numbered the decisions it accepted.
|
|
25
|
+
*/
|
|
26
|
+
async function displayAfterWrite(log) {
|
|
27
|
+
const read = await readDecisions(log);
|
|
28
|
+
return { display: (id) => read.projection.views.get(id)?.display ?? id };
|
|
29
|
+
}
|
|
30
|
+
// src/cli/text.ts
|
|
31
|
+
/** A table: each column padded to its widest value, trailing spaces removed. */
|
|
32
|
+
function columns(rows) {
|
|
33
|
+
const widths = [];
|
|
34
|
+
for (const row of rows) row.forEach((cell, i) => {
|
|
35
|
+
widths[i] = Math.max(widths[i] ?? 0, cell.length);
|
|
36
|
+
});
|
|
37
|
+
return rows.map((row) => row.map((cell, i) => i === row.length - 1 ? cell : cell.padEnd(widths[i] ?? 0)).join(" ").trimEnd()).join("\n");
|
|
38
|
+
}
|
|
39
|
+
/** `adr list`: one row per decision, `→ <effective>` when it differs from the decision. */
|
|
40
|
+
function listText(doc, display) {
|
|
41
|
+
if (doc.decisions.length === 0) return "No decisions.";
|
|
42
|
+
return columns(doc.decisions.map((v) => {
|
|
43
|
+
const arrow = v.effective === null ? "→ (cycle)" : v.effective === v.id ? "" : `→ ${display(v.effective)}`;
|
|
44
|
+
return [
|
|
45
|
+
v.number === null ? "-" : v.display,
|
|
46
|
+
v.id,
|
|
47
|
+
v.status,
|
|
48
|
+
v.title,
|
|
49
|
+
arrow
|
|
50
|
+
];
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
/** `ADR-0003 (m2x7p4d2)`, or the id alone when it has no number. */
|
|
54
|
+
function named(id, display) {
|
|
55
|
+
const shown = display(id);
|
|
56
|
+
return shown === id ? id : `${shown} (${id})`;
|
|
57
|
+
}
|
|
58
|
+
const day = (at) => (at ?? "").slice(0, 10);
|
|
59
|
+
const list = (ids, display) => ids.length === 0 ? "none" : ids.map((id) => named(id, display)).join(", ");
|
|
60
|
+
function bodyLine(v) {
|
|
61
|
+
if (v.body_path === null) return "missing";
|
|
62
|
+
if (v.body_intact === null) return v.body_path;
|
|
63
|
+
return `${v.body_path} (${v.body_intact ? "intact" : "changed after acceptance"})`;
|
|
64
|
+
}
|
|
65
|
+
function actorText(e) {
|
|
66
|
+
const a = e.actor;
|
|
67
|
+
return a.kind === "agent" ? `${a.name} (agent ${a.agent} for ${a.on_behalf_of})` : a.name;
|
|
68
|
+
}
|
|
69
|
+
/** The second line of an event in `show --history`: its reason, else a summary of its data. */
|
|
70
|
+
function eventDetail(e) {
|
|
71
|
+
if (typeof e.reason === "string") return `reason: ${e.reason}`;
|
|
72
|
+
const entries = Object.entries(e.data);
|
|
73
|
+
if (entries.length === 0) return null;
|
|
74
|
+
return entries.map(([k, v]) => `${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`).join(", ");
|
|
75
|
+
}
|
|
76
|
+
function historyLines(events) {
|
|
77
|
+
const width = Math.max(...events.map((e) => e.type.length));
|
|
78
|
+
return events.flatMap((e) => {
|
|
79
|
+
const detail = eventDetail(e);
|
|
80
|
+
return [` ${e.at} ${e.type.padEnd(width)} ${actorText(e)}`, ...detail === null ? [] : [` ${detail}`]];
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
const SECTION_FINDING = /^(?:required )?section (.+?) (is missing|is empty|still contains its guidance text)$/;
|
|
84
|
+
const REASONS = {
|
|
85
|
+
"is missing": "missing",
|
|
86
|
+
"is empty": "empty",
|
|
87
|
+
"still contains its guidance text": "guidance text left"
|
|
88
|
+
};
|
|
89
|
+
/** The section findings of a draft lint as headings and reasons, and the other findings. */
|
|
90
|
+
function sectionGaps(findings) {
|
|
91
|
+
const headings = [];
|
|
92
|
+
const reasons = [];
|
|
93
|
+
const other = [];
|
|
94
|
+
for (const f of findings) {
|
|
95
|
+
const m = SECTION_FINDING.exec(f.message);
|
|
96
|
+
if (m === null) {
|
|
97
|
+
other.push(f);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const [, heading = "", what = ""] = m;
|
|
101
|
+
const reason = REASONS[what] ?? what;
|
|
102
|
+
if (!headings.includes(heading)) headings.push(heading);
|
|
103
|
+
if (!reasons.includes(reason)) reasons.push(reason);
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
headings,
|
|
107
|
+
reasons,
|
|
108
|
+
other
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The draft lint of `new` as advice: `Fill in N sections before accepting: A, B (why)` for the
|
|
113
|
+
* section findings, and one line per other finding.
|
|
114
|
+
*/
|
|
115
|
+
function draftAdvice(findings) {
|
|
116
|
+
const { headings, reasons, other } = sectionGaps(findings);
|
|
117
|
+
const n = headings.length;
|
|
118
|
+
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}]`)];
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The stderr warning of `new` when the draft it wrote has unfilled sections (design §7.2), or
|
|
122
|
+
* `null` when there are none.
|
|
123
|
+
*/
|
|
124
|
+
function unfilledWarning(doc) {
|
|
125
|
+
if (doc.command !== "new" || !doc.ok || doc.written.length === 0) return null;
|
|
126
|
+
const { headings, reasons } = sectionGaps(doc.findings);
|
|
127
|
+
const n = headings.length;
|
|
128
|
+
const path = doc.findings.find((f) => f.file !== null)?.file ?? doc.decision?.body_path ?? null;
|
|
129
|
+
if (n === 0 || path === null) return null;
|
|
130
|
+
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`;
|
|
131
|
+
}
|
|
132
|
+
function findingLines(findings) {
|
|
133
|
+
return findings.map((f) => ` ${f.file === null ? "" : `${f.file}: `}${f.message} [${f.rule}]`);
|
|
134
|
+
}
|
|
135
|
+
const short = (sha) => sha.slice(0, 7);
|
|
136
|
+
/**
|
|
137
|
+
* `adr scope --follow-renames`: one `<named>: <old globs> → <new globs>` line per changed
|
|
138
|
+
* decision (`affected`), whether or not a decision was given.
|
|
139
|
+
*/
|
|
140
|
+
function followRenamesText(doc, display, o) {
|
|
141
|
+
if (!o.renamed) return "No staged renames.";
|
|
142
|
+
if (doc.affected.length === 0) return "No scope globs follow the staged renames.";
|
|
143
|
+
return [...doc.affected.map((v) => {
|
|
144
|
+
const before = o.before.get(v.id) ?? [];
|
|
145
|
+
const old = before.filter((g) => !v.applies_to.includes(g));
|
|
146
|
+
const now = v.applies_to.filter((g) => !before.includes(g));
|
|
147
|
+
const change = old.length === 0 ? `+ ${now.join(", ")}` : `${old.join(", ")} → ${now.join(", ")}`;
|
|
148
|
+
return `${named(v.id, display)}: ${change}`;
|
|
149
|
+
}), "Staged; commit the scoped events with the renames."].join("\n");
|
|
150
|
+
}
|
|
151
|
+
/** The past participle of each write command, for `Not <verb>: …`. */
|
|
152
|
+
const VERBS = {
|
|
153
|
+
new: "proposed",
|
|
154
|
+
accept: "accepted",
|
|
155
|
+
supersede: "superseded",
|
|
156
|
+
reject: "rejected",
|
|
157
|
+
withdraw: "withdrawn",
|
|
158
|
+
deprecate: "deprecated",
|
|
159
|
+
note: "noted",
|
|
160
|
+
link: "linked",
|
|
161
|
+
scope: "scoped"
|
|
162
|
+
};
|
|
163
|
+
/**
|
|
164
|
+
* The text of a write command (design §5.6, §6.1). `subject` names what a refused command was
|
|
165
|
+
* about; `number` is the decision's number in the write result, if it has one now; `reason` is
|
|
166
|
+
* the reason a lifecycle exit recorded.
|
|
167
|
+
*/
|
|
168
|
+
function writeText(doc, display, o) {
|
|
169
|
+
const verb = VERBS[doc.command];
|
|
170
|
+
if (!doc.ok) {
|
|
171
|
+
const unfilled = doc.command === "new" && sectionGaps(doc.findings).headings.length > 0;
|
|
172
|
+
return [
|
|
173
|
+
`Not ${verb}: ${o.subject}`,
|
|
174
|
+
...findingLines(doc.findings),
|
|
175
|
+
...unfilled ? ["Fill them in with --section <id>=<markdown>, or run without --commit and edit the draft."] : []
|
|
176
|
+
].join("\n");
|
|
177
|
+
}
|
|
178
|
+
if (o.followRenames !== void 0) return followRenamesText(doc, display, o.followRenames);
|
|
179
|
+
const v = doc.decision;
|
|
180
|
+
if (v === null) return "";
|
|
181
|
+
const committed = doc.commit === null ? null : `Committed ${short(doc.commit)}`;
|
|
182
|
+
const recorded = committed === null ? "Staged; commit to record it." : `${committed}.`;
|
|
183
|
+
switch (doc.command) {
|
|
184
|
+
case "new": return [
|
|
185
|
+
`Proposed ${v.id}: ${v.title}`,
|
|
186
|
+
...doc.written.map((p) => ` ${p}`),
|
|
187
|
+
...draftAdvice(doc.findings),
|
|
188
|
+
...committed === null ? [] : [`${committed}.`],
|
|
189
|
+
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}`
|
|
190
|
+
].join("\n");
|
|
191
|
+
case "accept": return [
|
|
192
|
+
`Accepted ${v.id}: ${v.title}`,
|
|
193
|
+
...doc.affected.map((a) => `Superseded ${named(a.id, display)}: ${a.title}`),
|
|
194
|
+
...o.copied === void 0 || o.copied === null ? [] : [`Copied scope from ${o.copied.displays.join(", ")}: ${o.copied.globs.join(", ")}`],
|
|
195
|
+
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}.`
|
|
196
|
+
].join("\n");
|
|
197
|
+
case "supersede": return [
|
|
198
|
+
`Superseded ${named(v.id, display)}: ${v.title}`,
|
|
199
|
+
` by ${named(v.superseded_by ?? "", display)}`,
|
|
200
|
+
recorded
|
|
201
|
+
].join("\n");
|
|
202
|
+
case "reject":
|
|
203
|
+
case "withdraw":
|
|
204
|
+
case "deprecate": return [
|
|
205
|
+
`${verb.charAt(0).toUpperCase()}${verb.slice(1)} ${named(v.id, display)}: ${v.title}`,
|
|
206
|
+
...o.reason === void 0 || o.reason === null ? [] : [` reason: ${o.reason}`],
|
|
207
|
+
recorded
|
|
208
|
+
].join("\n");
|
|
209
|
+
case "note": return [`Noted on ${named(v.id, display)}: ${v.title}`, recorded].join("\n");
|
|
210
|
+
case "link": {
|
|
211
|
+
const link = doc.written.length === 0 ? void 0 : v.links.at(-1);
|
|
212
|
+
if (link === void 0) return `Links of ${named(v.id, display)} unchanged`;
|
|
213
|
+
return [`Linked ${named(v.id, display)} ${link.rel} ${named(link.to, display)}`, recorded].join("\n");
|
|
214
|
+
}
|
|
215
|
+
case "scope": {
|
|
216
|
+
const before = new Set(o.scopeBefore ?? []);
|
|
217
|
+
const after = new Set(v.applies_to);
|
|
218
|
+
const globs = v.applies_to.length === 0 ? "no globs" : v.applies_to.join(", ");
|
|
219
|
+
if (doc.written.length === 0) return `Scope of ${named(v.id, display)} unchanged: ${globs}`;
|
|
220
|
+
return [
|
|
221
|
+
`Scope of ${named(v.id, display)}: ${v.title}`,
|
|
222
|
+
...v.applies_to.filter((g) => !before.has(g)).map((g) => ` + ${g}`),
|
|
223
|
+
...[...before].filter((g) => !after.has(g)).map((g) => ` - ${g}`),
|
|
224
|
+
` = ${globs}`,
|
|
225
|
+
recorded
|
|
226
|
+
].join("\n");
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* "Applies to" without globs: nothing is recorded, which isn't "governs nothing" (design §7.3),
|
|
232
|
+
* with the command that adds globs while the decision can still take them.
|
|
233
|
+
*/
|
|
234
|
+
function noGlobs(v, display) {
|
|
235
|
+
return adrKind.machine.allowed(v.status, "scoped") ? `no globs recorded; add them with adr scope ${display(v.id)} --add <glob>` : "no globs recorded";
|
|
236
|
+
}
|
|
237
|
+
/** `adr show`: the state header, the decision's fields and its body. */
|
|
238
|
+
function showText(doc, display) {
|
|
239
|
+
const v = doc.decision;
|
|
240
|
+
const supersedes = v.supersedes.length === 0 && v.declared_supersedes.length > 0 ? `none (declared: ${list(v.declared_supersedes, display)})` : list(v.supersedes, display);
|
|
241
|
+
const fields = [
|
|
242
|
+
["Title", v.title],
|
|
243
|
+
["Status", `${v.status} (${day(v.status_at)})`],
|
|
244
|
+
["Effective", v.effective === null ? "none (supersession cycle)" : named(v.effective, display)],
|
|
245
|
+
["Chain", v.chain.map(display).join(" → ")],
|
|
246
|
+
["Supersedes", supersedes],
|
|
247
|
+
["Applies to", v.applies_to.length === 0 ? noGlobs(v, display) : v.applies_to.join(", ")],
|
|
248
|
+
["Body", bodyLine(v)]
|
|
249
|
+
];
|
|
250
|
+
if (doc.events === null) fields.push(["History", v.history.join(" · ")]);
|
|
251
|
+
const width = Math.max(...fields.map(([k]) => k.length)) + 2;
|
|
252
|
+
const lines = fields.map(([k, value]) => `${`${k}:`.padEnd(width)}${value}`);
|
|
253
|
+
if (doc.events !== null) lines.push("History:", ...historyLines(doc.events));
|
|
254
|
+
const body = doc.body === null ? "" : `\n${doc.body.endsWith("\n") ? doc.body : `${doc.body}\n`}`;
|
|
255
|
+
return `${renderStateHeader(doc.header)}\n${lines.join("\n")}\n${body}`;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* `adr for`: per path, one row per decision (named, status, title, how it matched), its warning
|
|
259
|
+
* below it when it isn't in force, then the anchors that name no decision of this log.
|
|
260
|
+
*/
|
|
261
|
+
function forText(doc, display, keyword) {
|
|
262
|
+
const lines = [];
|
|
263
|
+
for (const p of doc.paths) {
|
|
264
|
+
lines.push(p.tracked ? p.path : `${p.path} (not tracked)`);
|
|
265
|
+
if (p.decisions.length === 0 && p.unresolved.length === 0) lines.push(" no decisions");
|
|
266
|
+
const rows = columns(p.decisions.map((d) => [
|
|
267
|
+
named(d.id, display),
|
|
268
|
+
d.status,
|
|
269
|
+
d.title,
|
|
270
|
+
d.via.map((v) => v.type === "glob" ? `glob ${v.glob}` : `anchor line ${v.line}`).join(", ")
|
|
271
|
+
])).split("\n");
|
|
272
|
+
p.decisions.forEach((d, i) => {
|
|
273
|
+
lines.push(` ${rows[i] ?? ""}`);
|
|
274
|
+
if (d.warning !== null) lines.push(` ${d.warning}`);
|
|
275
|
+
});
|
|
276
|
+
for (const u of p.unresolved) {
|
|
277
|
+
const why = u.reason === "other-log" ? "in another log" : "not in this log";
|
|
278
|
+
lines.push(` unresolved: ${keyword}:${u.ref} (line ${u.line}, ${why})`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return lines.join("\n");
|
|
282
|
+
}
|
|
283
|
+
/** `adr init`: what was written and kept, then the next step; or that nothing was needed. */
|
|
284
|
+
function initText(doc) {
|
|
285
|
+
if (doc.written.length === 0) return `Already initialized: ${doc.root} (kept ${doc.kept.join(", ")})`;
|
|
286
|
+
return [
|
|
287
|
+
`Initialized ${doc.root}`,
|
|
288
|
+
...doc.written.map((p) => ` ${p}`),
|
|
289
|
+
...doc.kept.map((p) => `Kept ${p}`),
|
|
290
|
+
...doc.commit === null ? [] : [`Committed ${short(doc.commit)}.`],
|
|
291
|
+
"Next: adr new \"<title>\""
|
|
292
|
+
].join("\n");
|
|
293
|
+
}
|
|
294
|
+
// src/cli/accept.ts
|
|
295
|
+
const MAX_LISTED = 5;
|
|
296
|
+
/** `Copy the scope of ADR-0002 (src/a/**, src/b/**) to k3f9q8a1? [y/N] ` (design §6.10). */
|
|
297
|
+
function question(id, offer) {
|
|
298
|
+
const shown = offer.globs.slice(0, MAX_LISTED).join(", ");
|
|
299
|
+
const more = offer.globs.length - MAX_LISTED;
|
|
300
|
+
const globs = more > 0 ? `${shown} and ${more} more` : shown;
|
|
301
|
+
const ds = offer.displays;
|
|
302
|
+
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] `;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* The flags decide; without them a person at a terminal is asked, and everyone else (scripts,
|
|
306
|
+
* hooks, CI, `--json`) gets the note with the `adr scope` command (design §6.10, D-e). `seen`
|
|
307
|
+
* receives the offer that was copied, for the text.
|
|
308
|
+
*/
|
|
309
|
+
function copyChoice(io, json, value, seen) {
|
|
310
|
+
const copy = async (offer) => {
|
|
311
|
+
seen(offer);
|
|
312
|
+
return true;
|
|
313
|
+
};
|
|
314
|
+
if (value === true) return copy;
|
|
315
|
+
if (value === false) return "skip";
|
|
316
|
+
if (json || !io.interactive) return "suggest";
|
|
317
|
+
return async (offer) => {
|
|
318
|
+
const yes = await confirm(io, question(offer.id, offer), { json });
|
|
319
|
+
if (yes) seen(offer);
|
|
320
|
+
return yes;
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
/** `adr accept <decision> [--copy-scope | --no-copy-scope] …` (design §5.6, §6.10). */
|
|
324
|
+
const acceptCommand = {
|
|
325
|
+
name: "accept",
|
|
326
|
+
summary: "accept a proposed decision, superseding the decisions it declares",
|
|
327
|
+
usage: "accept <decision> [--copy-scope | --no-copy-scope] [--commit | --no-commit] [-m <message>]",
|
|
328
|
+
options: {
|
|
329
|
+
"copy-scope": { type: "boolean" },
|
|
330
|
+
...COMMIT_OPTIONS
|
|
331
|
+
},
|
|
332
|
+
positionals: {
|
|
333
|
+
min: 1,
|
|
334
|
+
max: 1,
|
|
335
|
+
names: ["decision"]
|
|
336
|
+
},
|
|
337
|
+
ref: "reject",
|
|
338
|
+
async run({ io, flags, values, positionals }) {
|
|
339
|
+
const input = positionals[0] ?? "";
|
|
340
|
+
const log = await openForCli(io, flags);
|
|
341
|
+
const { actor, notes: attribution } = await resolveAttribution(log.git, { env: io.env });
|
|
342
|
+
const seen = { offer: null };
|
|
343
|
+
const copyScope = copyChoice(io, flags.json, values["copy-scope"], (offer) => {
|
|
344
|
+
seen.offer = offer;
|
|
345
|
+
});
|
|
346
|
+
const doc = await acceptDecision(log, {
|
|
347
|
+
id: input,
|
|
348
|
+
actor
|
|
349
|
+
}, {
|
|
350
|
+
...commitChoice(values, { autoCommit: log.adr.autoCommit }),
|
|
351
|
+
notes: attribution,
|
|
352
|
+
copyScope
|
|
353
|
+
});
|
|
354
|
+
const { display } = await displayAfterWrite(log);
|
|
355
|
+
const v = doc.decision;
|
|
356
|
+
return {
|
|
357
|
+
exit: doc.ok ? 0 : 1,
|
|
358
|
+
json: doc,
|
|
359
|
+
text: writeText(doc, display, {
|
|
360
|
+
subject: v === null ? input.trim() : `${display(v.id)}: ${v.title}`,
|
|
361
|
+
number: v === null || doc.commit === null || v.number === null ? null : v.display,
|
|
362
|
+
copied: doc.ok ? seen.offer : null
|
|
363
|
+
}),
|
|
364
|
+
notes: doc.notes
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
// src/cli/deprecate.ts
|
|
369
|
+
const USAGE$4 = "deprecate <decision> --reason <text> [--commit | --no-commit] [-m <message>]";
|
|
370
|
+
/** `adr deprecate` (design §6.5): an accepted decision no longer in force. */
|
|
371
|
+
const deprecateCommand = {
|
|
372
|
+
name: "deprecate",
|
|
373
|
+
summary: "deprecate an accepted decision",
|
|
374
|
+
usage: USAGE$4,
|
|
375
|
+
options: {
|
|
376
|
+
reason: { type: "string" },
|
|
377
|
+
...COMMIT_OPTIONS
|
|
378
|
+
},
|
|
379
|
+
positionals: {
|
|
380
|
+
min: 1,
|
|
381
|
+
max: 1,
|
|
382
|
+
names: ["decision"]
|
|
383
|
+
},
|
|
384
|
+
ref: "reject",
|
|
385
|
+
async run({ io, flags, values, positionals }) {
|
|
386
|
+
const input = positionals[0] ?? "";
|
|
387
|
+
if (typeof values.reason !== "string" || values.reason.trim() === "") throw new UsageError(`missing --reason <text>; usage: adr ${USAGE$4}`);
|
|
388
|
+
const reason = typeof values.reason === "string" ? values.reason.trim() : null;
|
|
389
|
+
const log = await openForCli(io, flags);
|
|
390
|
+
const { actor, notes } = await resolveAttribution(log.git, { env: io.env });
|
|
391
|
+
const doc = await deprecateDecision(log, {
|
|
392
|
+
id: input,
|
|
393
|
+
reason,
|
|
394
|
+
actor
|
|
395
|
+
}, {
|
|
396
|
+
...commitChoice(values, { autoCommit: log.adr.autoCommit }),
|
|
397
|
+
notes
|
|
398
|
+
});
|
|
399
|
+
const { display } = await displayAfterWrite(log);
|
|
400
|
+
const v = doc.decision;
|
|
401
|
+
return {
|
|
402
|
+
exit: doc.ok ? 0 : 1,
|
|
403
|
+
json: doc,
|
|
404
|
+
text: writeText(doc, display, {
|
|
405
|
+
subject: v === null ? input.trim() : `${display(v.id)}: ${v.title}`,
|
|
406
|
+
number: null,
|
|
407
|
+
reason
|
|
408
|
+
}),
|
|
409
|
+
notes: doc.notes
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
// src/cli/for.ts
|
|
414
|
+
/** `adr for <path>… [--ref <ref>]` (design §6.9): the decisions that govern each file. */
|
|
415
|
+
const forCommand = {
|
|
416
|
+
name: "for",
|
|
417
|
+
summary: "list the decisions that govern files, by scope glob and code anchor",
|
|
418
|
+
usage: "for <path>…",
|
|
419
|
+
options: {},
|
|
420
|
+
positionals: {
|
|
421
|
+
min: 1,
|
|
422
|
+
max: 100,
|
|
423
|
+
names: ["path"]
|
|
424
|
+
},
|
|
425
|
+
ref: "read",
|
|
426
|
+
async run({ io, flags, positionals }) {
|
|
427
|
+
const log = await openForCli(io, flags);
|
|
428
|
+
const read = await readDecisions(log);
|
|
429
|
+
const doc = await decisionsFor(read, log, {
|
|
430
|
+
paths: positionals,
|
|
431
|
+
cwd: io.cwd
|
|
432
|
+
});
|
|
433
|
+
const display = (id) => read.projection.views.get(id)?.display ?? id;
|
|
434
|
+
return {
|
|
435
|
+
exit: 0,
|
|
436
|
+
json: doc,
|
|
437
|
+
text: forText(doc, display, read.adr.anchorKeyword),
|
|
438
|
+
notes: doc.notes
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
// src/cli/init.ts
|
|
443
|
+
/** `adr init [--template <name>]… [--commit | --no-commit] [-m <message>]` (design §6.2). */
|
|
444
|
+
const initCommand = {
|
|
445
|
+
name: "init",
|
|
446
|
+
summary: "set up a decision log: a config file and starter templates",
|
|
447
|
+
usage: "init [--template <name>]… [--commit | --no-commit] [-m <message>]",
|
|
448
|
+
options: {
|
|
449
|
+
template: {
|
|
450
|
+
type: "string",
|
|
451
|
+
multiple: true
|
|
452
|
+
},
|
|
453
|
+
...COMMIT_OPTIONS
|
|
454
|
+
},
|
|
455
|
+
positionals: {
|
|
456
|
+
min: 0,
|
|
457
|
+
max: 0,
|
|
458
|
+
names: []
|
|
459
|
+
},
|
|
460
|
+
ref: "reject",
|
|
461
|
+
async run({ io, flags, values }) {
|
|
462
|
+
const templates = Array.isArray(values.template) ? values.template : void 0;
|
|
463
|
+
const log = await openForCli(io, flags);
|
|
464
|
+
const doc = await initDecisionLog(log, {
|
|
465
|
+
...templates === void 0 ? {} : { templates },
|
|
466
|
+
...commitChoice(values, { autoCommit: log.adr.autoCommit })
|
|
467
|
+
});
|
|
468
|
+
return {
|
|
469
|
+
exit: 0,
|
|
470
|
+
json: doc,
|
|
471
|
+
text: initText(doc),
|
|
472
|
+
notes: doc.notes
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
// src/cli/link.ts
|
|
477
|
+
/** `adr link <decision> <target> [--rel <rel>]` (design §6.7): a typed link, in any state. */
|
|
478
|
+
const linkCommand = {
|
|
479
|
+
name: "link",
|
|
480
|
+
summary: "link a decision to another one, here or in a configured source",
|
|
481
|
+
usage: "link <decision> <target> [--rel relates-to|refines|depends-on] [--commit | --no-commit] [-m <message>]",
|
|
482
|
+
options: {
|
|
483
|
+
rel: { type: "string" },
|
|
484
|
+
...COMMIT_OPTIONS
|
|
485
|
+
},
|
|
486
|
+
positionals: {
|
|
487
|
+
min: 2,
|
|
488
|
+
max: 2,
|
|
489
|
+
names: ["decision", "target"]
|
|
490
|
+
},
|
|
491
|
+
ref: "reject",
|
|
492
|
+
async run({ io, flags, values, positionals }) {
|
|
493
|
+
const [input = "", to = ""] = positionals;
|
|
494
|
+
const rel = typeof values.rel === "string" ? values.rel : "relates-to";
|
|
495
|
+
const log = await openForCli(io, flags);
|
|
496
|
+
const { actor, notes } = await resolveAttribution(log.git, { env: io.env });
|
|
497
|
+
const doc = await linkDecision(log, {
|
|
498
|
+
id: input,
|
|
499
|
+
to,
|
|
500
|
+
rel,
|
|
501
|
+
actor
|
|
502
|
+
}, {
|
|
503
|
+
...commitChoice(values, { autoCommit: log.adr.autoCommit }),
|
|
504
|
+
notes
|
|
505
|
+
});
|
|
506
|
+
const { display } = await displayAfterWrite(log);
|
|
507
|
+
const v = doc.decision;
|
|
508
|
+
return {
|
|
509
|
+
exit: doc.ok ? 0 : 1,
|
|
510
|
+
json: doc,
|
|
511
|
+
text: writeText(doc, display, {
|
|
512
|
+
subject: v === null ? input.trim() : `${display(v.id)}: ${v.title}`,
|
|
513
|
+
number: null
|
|
514
|
+
}),
|
|
515
|
+
notes: doc.notes
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
// src/cli/list.ts
|
|
520
|
+
const plural = (n, one, many) => n === 1 ? `1 ${one}` : `${n} ${many}`;
|
|
521
|
+
function statuses(values) {
|
|
522
|
+
return (Array.isArray(values.status) ? values.status : []).map((s) => {
|
|
523
|
+
if (!DECISION_STATUSES.includes(s)) throw new UsageError(`unknown status ${JSON.stringify(s)}; use one of ${DECISION_STATUSES.join(", ")}`);
|
|
524
|
+
return s;
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
/** `adr list [--status <status>]…` (design §5.6). */
|
|
528
|
+
const listCommand = {
|
|
529
|
+
name: "list",
|
|
530
|
+
summary: "list decisions with their computed status",
|
|
531
|
+
usage: "list [--status <status>]…",
|
|
532
|
+
options: { status: {
|
|
533
|
+
type: "string",
|
|
534
|
+
multiple: true
|
|
535
|
+
} },
|
|
536
|
+
positionals: {
|
|
537
|
+
min: 0,
|
|
538
|
+
max: 0,
|
|
539
|
+
names: []
|
|
540
|
+
},
|
|
541
|
+
ref: "read",
|
|
542
|
+
async run({ io, flags, values }) {
|
|
543
|
+
const status = statuses(values);
|
|
544
|
+
const read = await readDecisions(await openForCli(io, flags));
|
|
545
|
+
const doc = listDecisions(read, { status });
|
|
546
|
+
const notes = [...doc.notes];
|
|
547
|
+
if (!read.numbering.available && doc.decisions.length > 0) notes.push(`numbers are unavailable (${unavailableReason(read.numbering)}); decisions display by id`);
|
|
548
|
+
if (doc.findings.length > 0) notes.push(`the decision log has ${plural(doc.findings.length, "problem", "problems")}; run adr verify`);
|
|
549
|
+
if (doc.unreadable.length > 0) notes.push(`${plural(doc.unreadable.length, "decision uses", "decisions use")} a newer format; upgrade adr`);
|
|
550
|
+
if (doc.unfoldable.length > 0) notes.push(`${plural(doc.unfoldable.length, "decision", "decisions")} can't be read; run adr verify`);
|
|
551
|
+
const display = (id) => read.projection.views.get(id)?.display ?? id;
|
|
552
|
+
return {
|
|
553
|
+
exit: 0,
|
|
554
|
+
json: doc,
|
|
555
|
+
text: listText(doc, display),
|
|
556
|
+
notes
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
// src/cli/editor.ts
|
|
561
|
+
/**
|
|
562
|
+
* The user's editor as an `edit` callback (design §5.7), or `null` when neither `VISUAL` nor
|
|
563
|
+
* `EDITOR` is set. The editor string is run by `sh -c` the way git runs `core.editor`, with the
|
|
564
|
+
* path as the positional parameter `$1`, never interpolated. When stdout isn't a TTY (piped
|
|
565
|
+
* `--json`, say), the editor's stdout goes to stderr so the CLI's stdout stays one document.
|
|
566
|
+
* The callback never throws: a spawn error or a non-zero exit becomes the note.
|
|
567
|
+
*/
|
|
568
|
+
function editorFor(env, o) {
|
|
569
|
+
const editor = [env.VISUAL, env.EDITOR].map((v) => v?.trim() ?? "").find((v) => v !== "");
|
|
570
|
+
if (editor === void 0) return null;
|
|
571
|
+
return (absPath) => new Promise((resolve) => {
|
|
572
|
+
const fail = (e) => resolve(`could not start the editor: ${e instanceof Error ? e.message : String(e)}`);
|
|
573
|
+
try {
|
|
574
|
+
const child = spawn("sh", [
|
|
575
|
+
"-c",
|
|
576
|
+
`${editor} "$1"`,
|
|
577
|
+
"sh",
|
|
578
|
+
absPath
|
|
579
|
+
], {
|
|
580
|
+
env,
|
|
581
|
+
stdio: o.stdoutIsTTY ? "inherit" : [
|
|
582
|
+
"inherit",
|
|
583
|
+
2,
|
|
584
|
+
"inherit"
|
|
585
|
+
]
|
|
586
|
+
});
|
|
587
|
+
child.on("error", fail);
|
|
588
|
+
child.on("close", (code, signal) => {
|
|
589
|
+
if (code === 0) resolve(null);
|
|
590
|
+
else resolve(`the editor ${code === null ? `was killed by ${signal ?? "a signal"}` : `exited with status ${code}`}; the draft is staged as rendered`);
|
|
591
|
+
});
|
|
592
|
+
} catch (e) {
|
|
593
|
+
fail(e);
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
// src/cli/new.ts
|
|
598
|
+
const strings$1 = (v) => Array.isArray(v) ? v : [];
|
|
599
|
+
/** `--section <id>=<markdown>` values, split at the first `=`; each id at most once. */
|
|
600
|
+
function sections(values) {
|
|
601
|
+
const out = {};
|
|
602
|
+
for (const value of values) {
|
|
603
|
+
const at = value.indexOf("=");
|
|
604
|
+
const id = at < 0 ? "" : value.slice(0, at).trim();
|
|
605
|
+
if (id === "") throw new UsageError(`--section needs <id>=<markdown>, not ${JSON.stringify(value)}`);
|
|
606
|
+
if (Object.hasOwn(out, id)) throw new UsageError(`--section ${id} is given twice`);
|
|
607
|
+
out[id] = value.slice(at + 1);
|
|
608
|
+
}
|
|
609
|
+
return out;
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* `adr new <title> …` (design §5.6). The editor runs with `--edit`, or without `--no-edit` when
|
|
613
|
+
* the terminal is interactive and an editor is set.
|
|
614
|
+
*/
|
|
615
|
+
const newCommand = {
|
|
616
|
+
name: "new",
|
|
617
|
+
summary: "propose a decision from a template and stage it",
|
|
618
|
+
usage: "new <title> [--template <name>] [--supersedes <decision>]… [--section <id>=<markdown>]… [--edit | --no-edit] [--commit | --no-commit] [-m <message>]",
|
|
619
|
+
options: {
|
|
620
|
+
template: { type: "string" },
|
|
621
|
+
supersedes: {
|
|
622
|
+
type: "string",
|
|
623
|
+
multiple: true
|
|
624
|
+
},
|
|
625
|
+
section: {
|
|
626
|
+
type: "string",
|
|
627
|
+
multiple: true
|
|
628
|
+
},
|
|
629
|
+
edit: { type: "boolean" },
|
|
630
|
+
...COMMIT_OPTIONS
|
|
631
|
+
},
|
|
632
|
+
positionals: {
|
|
633
|
+
min: 1,
|
|
634
|
+
max: 1,
|
|
635
|
+
names: ["title"]
|
|
636
|
+
},
|
|
637
|
+
ref: "reject",
|
|
638
|
+
async run({ io, flags, values, positionals }) {
|
|
639
|
+
const title = positionals[0] ?? "";
|
|
640
|
+
const given = sections(strings$1(values.section));
|
|
641
|
+
const log = await openForCli(io, flags);
|
|
642
|
+
const { actor, notes: attribution } = await resolveAttribution(log.git, { env: io.env });
|
|
643
|
+
const { commit: requested, message } = commitChoice(values, { autoCommit: log.adr.autoCommit });
|
|
644
|
+
const notes = [];
|
|
645
|
+
const editor = editorFor(io.env, { stdoutIsTTY: io.stdoutIsTTY });
|
|
646
|
+
const wantsEdit = values.edit === true || values.edit === void 0 && io.interactive;
|
|
647
|
+
if (values.edit === true && editor === null) notes.push("no editor: set VISUAL or EDITOR");
|
|
648
|
+
const edit = wantsEdit && editor !== null ? async (abs) => {
|
|
649
|
+
const note = await editor(abs);
|
|
650
|
+
if (note !== null) notes.push(note);
|
|
651
|
+
return note;
|
|
652
|
+
} : void 0;
|
|
653
|
+
const doc = await proposeDecision(log, {
|
|
654
|
+
title,
|
|
655
|
+
sections: given,
|
|
656
|
+
supersedes: strings$1(values.supersedes),
|
|
657
|
+
actor,
|
|
658
|
+
...typeof values.template === "string" ? { template: values.template } : {}
|
|
659
|
+
}, {
|
|
660
|
+
commit: requested,
|
|
661
|
+
message,
|
|
662
|
+
notes: attribution,
|
|
663
|
+
...edit === void 0 ? {} : { edit },
|
|
664
|
+
onNote: (note) => notes.push(requested ? `not committed: ${note}` : note)
|
|
665
|
+
});
|
|
666
|
+
const { display } = await displayAfterWrite(log);
|
|
667
|
+
const v = doc.decision;
|
|
668
|
+
const unfilled = unfilledWarning(doc);
|
|
669
|
+
return {
|
|
670
|
+
exit: doc.ok ? 0 : 1,
|
|
671
|
+
json: doc,
|
|
672
|
+
text: writeText(doc, display, {
|
|
673
|
+
subject: title.trim(),
|
|
674
|
+
number: v === null || v.number === null ? null : v.display,
|
|
675
|
+
actor,
|
|
676
|
+
acceptRequiresHuman: log.adr.acceptRequiresHuman
|
|
677
|
+
}),
|
|
678
|
+
notes: [
|
|
679
|
+
...doc.notes,
|
|
680
|
+
...notes,
|
|
681
|
+
...unfilled === null ? [] : [unfilled]
|
|
682
|
+
]
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
// src/cli/note.ts
|
|
687
|
+
/** `adr note <decision> <text>` (design §6.6): a note on a decision in any state. */
|
|
688
|
+
const noteCommand = {
|
|
689
|
+
name: "note",
|
|
690
|
+
summary: "add a note to a decision, in any state",
|
|
691
|
+
usage: "note <decision> <text> [--commit | --no-commit] [-m <message>]",
|
|
692
|
+
options: { ...COMMIT_OPTIONS },
|
|
693
|
+
positionals: {
|
|
694
|
+
min: 2,
|
|
695
|
+
max: 2,
|
|
696
|
+
names: ["decision", "text"]
|
|
697
|
+
},
|
|
698
|
+
ref: "reject",
|
|
699
|
+
async run({ io, flags, values, positionals }) {
|
|
700
|
+
const [input = "", text = ""] = positionals;
|
|
701
|
+
const log = await openForCli(io, flags);
|
|
702
|
+
const { actor, notes } = await resolveAttribution(log.git, { env: io.env });
|
|
703
|
+
const doc = await noteDecision(log, {
|
|
704
|
+
id: input,
|
|
705
|
+
text,
|
|
706
|
+
actor
|
|
707
|
+
}, {
|
|
708
|
+
...commitChoice(values, { autoCommit: log.adr.autoCommit }),
|
|
709
|
+
notes
|
|
710
|
+
});
|
|
711
|
+
const { display } = await displayAfterWrite(log);
|
|
712
|
+
const v = doc.decision;
|
|
713
|
+
return {
|
|
714
|
+
exit: doc.ok ? 0 : 1,
|
|
715
|
+
json: doc,
|
|
716
|
+
text: writeText(doc, display, {
|
|
717
|
+
subject: v === null ? input.trim() : `${display(v.id)}: ${v.title}`,
|
|
718
|
+
number: null
|
|
719
|
+
}),
|
|
720
|
+
notes: doc.notes
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
};
|
|
724
|
+
// src/cli/reject.ts
|
|
725
|
+
const USAGE$3 = "reject <decision> --reason <text> [--commit | --no-commit] [-m <message>]";
|
|
726
|
+
/** `adr reject` (design §6.3): a proposal that won't be adopted. */
|
|
727
|
+
const rejectCommand = {
|
|
728
|
+
name: "reject",
|
|
729
|
+
summary: "reject a proposed decision",
|
|
730
|
+
usage: USAGE$3,
|
|
731
|
+
options: {
|
|
732
|
+
reason: { type: "string" },
|
|
733
|
+
...COMMIT_OPTIONS
|
|
734
|
+
},
|
|
735
|
+
positionals: {
|
|
736
|
+
min: 1,
|
|
737
|
+
max: 1,
|
|
738
|
+
names: ["decision"]
|
|
739
|
+
},
|
|
740
|
+
ref: "reject",
|
|
741
|
+
async run({ io, flags, values, positionals }) {
|
|
742
|
+
const input = positionals[0] ?? "";
|
|
743
|
+
if (typeof values.reason !== "string" || values.reason.trim() === "") throw new UsageError(`missing --reason <text>; usage: adr ${USAGE$3}`);
|
|
744
|
+
const reason = typeof values.reason === "string" ? values.reason.trim() : null;
|
|
745
|
+
const log = await openForCli(io, flags);
|
|
746
|
+
const { actor, notes } = await resolveAttribution(log.git, { env: io.env });
|
|
747
|
+
const doc = await rejectDecision(log, {
|
|
748
|
+
id: input,
|
|
749
|
+
reason,
|
|
750
|
+
actor
|
|
751
|
+
}, {
|
|
752
|
+
...commitChoice(values, { autoCommit: log.adr.autoCommit }),
|
|
753
|
+
notes
|
|
754
|
+
});
|
|
755
|
+
const { display } = await displayAfterWrite(log);
|
|
756
|
+
const v = doc.decision;
|
|
757
|
+
return {
|
|
758
|
+
exit: doc.ok ? 0 : 1,
|
|
759
|
+
json: doc,
|
|
760
|
+
text: writeText(doc, display, {
|
|
761
|
+
subject: v === null ? input.trim() : `${display(v.id)}: ${v.title}`,
|
|
762
|
+
number: null,
|
|
763
|
+
reason
|
|
764
|
+
}),
|
|
765
|
+
notes: doc.notes
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
};
|
|
769
|
+
// src/cli/scope.ts
|
|
770
|
+
const USAGE$2 = "scope <decision> ([--set <glob>]… | --clear | [--add <glob>]… [--remove <glob>]…) [--commit | --no-commit] [-m <message>] | scope [<decision>] --follow-renames";
|
|
771
|
+
/** The flag names `applySetOp` uses in its messages (design §3.2). */
|
|
772
|
+
const NAMES = {
|
|
773
|
+
set: "--set",
|
|
774
|
+
add: "--add",
|
|
775
|
+
remove: "--remove",
|
|
776
|
+
clear: "--clear"
|
|
777
|
+
};
|
|
778
|
+
const strings = (v) => Array.isArray(v) ? v : [];
|
|
779
|
+
/** `--set`/`--clear`/`--add`/`--remove` as a set operation; `--clear` is `{ set: [] }`. */
|
|
780
|
+
function setOp(values) {
|
|
781
|
+
const set = strings(values.set);
|
|
782
|
+
const add = strings(values.add);
|
|
783
|
+
const remove = strings(values.remove);
|
|
784
|
+
if (values.clear === true) {
|
|
785
|
+
if (set.length > 0 || add.length > 0 || remove.length > 0) throw new UsageError("--clear can't be combined with --set, --add or --remove");
|
|
786
|
+
return { set: [] };
|
|
787
|
+
}
|
|
788
|
+
return {
|
|
789
|
+
...set.length > 0 ? { set } : {},
|
|
790
|
+
add,
|
|
791
|
+
remove
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* `--follow-renames` alone: no set operation, no commit and no message, all refused before
|
|
796
|
+
* `commitChoice` so the answer doesn't depend on `auto_commit` (design §6.8).
|
|
797
|
+
*/
|
|
798
|
+
function checkFollowRenames(values) {
|
|
799
|
+
if ([
|
|
800
|
+
"set",
|
|
801
|
+
"add",
|
|
802
|
+
"remove"
|
|
803
|
+
].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");
|
|
804
|
+
if (values.commit === true) throw new UsageError("--follow-renames stages its events for the commit that contains the renames; commit them together");
|
|
805
|
+
if (typeof values.message === "string") throw new UsageError("--follow-renames never commits, so it takes no --message");
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* `adr scope <decision> --set|--clear|--add|--remove …` (design §6.8): the globs of the code a
|
|
809
|
+
* decision governs, written as one whole-set `scoped` event. `adr scope [<decision>]
|
|
810
|
+
* --follow-renames` rewrites them after staged renames and stages the events, never committing.
|
|
811
|
+
*/
|
|
812
|
+
const scopeCommand = {
|
|
813
|
+
name: "scope",
|
|
814
|
+
summary: "set, add or remove the globs of the code a decision governs",
|
|
815
|
+
usage: USAGE$2,
|
|
816
|
+
options: {
|
|
817
|
+
set: {
|
|
818
|
+
type: "string",
|
|
819
|
+
multiple: true
|
|
820
|
+
},
|
|
821
|
+
add: {
|
|
822
|
+
type: "string",
|
|
823
|
+
multiple: true
|
|
824
|
+
},
|
|
825
|
+
remove: {
|
|
826
|
+
type: "string",
|
|
827
|
+
multiple: true
|
|
828
|
+
},
|
|
829
|
+
clear: { type: "boolean" },
|
|
830
|
+
"follow-renames": { type: "boolean" },
|
|
831
|
+
...COMMIT_OPTIONS
|
|
832
|
+
},
|
|
833
|
+
positionals: {
|
|
834
|
+
min: 0,
|
|
835
|
+
max: 1,
|
|
836
|
+
names: ["decision"]
|
|
837
|
+
},
|
|
838
|
+
ref: "reject",
|
|
839
|
+
async run({ io, flags, values, positionals }) {
|
|
840
|
+
const input = positionals[0];
|
|
841
|
+
if (values["follow-renames"] === true) {
|
|
842
|
+
checkFollowRenames(values);
|
|
843
|
+
const log = await openForCli(io, flags);
|
|
844
|
+
const { actor, notes } = await resolveAttribution(log.git, { env: io.env });
|
|
845
|
+
const read = await readDecisions(log);
|
|
846
|
+
const before = new Map([...read.projection.views].map(([id, v]) => [id, v.applies_to]));
|
|
847
|
+
const renamed = (await stagedRenames(log.git)).length > 0;
|
|
848
|
+
const doc = await followScopeRenames(log, {
|
|
849
|
+
...input === void 0 ? {} : { id: input },
|
|
850
|
+
actor
|
|
851
|
+
}, { notes });
|
|
852
|
+
const { display } = await displayAfterWrite(log);
|
|
853
|
+
const v = doc.decision;
|
|
854
|
+
return {
|
|
855
|
+
exit: doc.ok ? 0 : 1,
|
|
856
|
+
json: doc,
|
|
857
|
+
text: writeText(doc, display, {
|
|
858
|
+
subject: v === null ? (input ?? "").trim() : `${display(v.id)}: ${v.title}`,
|
|
859
|
+
number: null,
|
|
860
|
+
followRenames: {
|
|
861
|
+
before,
|
|
862
|
+
renamed
|
|
863
|
+
}
|
|
864
|
+
}),
|
|
865
|
+
notes: doc.notes
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
const op = setOp(values);
|
|
869
|
+
if (input === void 0) throw new UsageError(`missing argument <decision>; usage: adr ${USAGE$2}`);
|
|
870
|
+
const log = await openForCli(io, flags);
|
|
871
|
+
const { actor, notes } = await resolveAttribution(log.git, { env: io.env });
|
|
872
|
+
const choice = commitChoice(values, { autoCommit: log.adr.autoCommit });
|
|
873
|
+
const read = await readDecisions(log);
|
|
874
|
+
const before = read.projection.views.get(resolveDecision(read, input))?.applies_to ?? [];
|
|
875
|
+
const doc = await scopeDecision(log, {
|
|
876
|
+
id: input,
|
|
877
|
+
op,
|
|
878
|
+
actor,
|
|
879
|
+
names: NAMES
|
|
880
|
+
}, {
|
|
881
|
+
...choice,
|
|
882
|
+
notes
|
|
883
|
+
});
|
|
884
|
+
const { display } = await displayAfterWrite(log);
|
|
885
|
+
const v = doc.decision;
|
|
886
|
+
return {
|
|
887
|
+
exit: doc.ok ? 0 : 1,
|
|
888
|
+
json: doc,
|
|
889
|
+
text: writeText(doc, display, {
|
|
890
|
+
subject: v === null ? input.trim() : `${display(v.id)}: ${v.title}`,
|
|
891
|
+
number: null,
|
|
892
|
+
scopeBefore: before
|
|
893
|
+
}),
|
|
894
|
+
notes: doc.notes
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
};
|
|
898
|
+
// src/cli/show.ts
|
|
899
|
+
/** `adr show <decision> [--history]` (design §5.6). */
|
|
900
|
+
const showCommand = {
|
|
901
|
+
name: "show",
|
|
902
|
+
summary: "show a decision with its state header, chain, history and body",
|
|
903
|
+
usage: "show <decision> [--history]",
|
|
904
|
+
options: { history: { type: "boolean" } },
|
|
905
|
+
positionals: {
|
|
906
|
+
min: 1,
|
|
907
|
+
max: 1,
|
|
908
|
+
names: ["decision"]
|
|
909
|
+
},
|
|
910
|
+
ref: "read",
|
|
911
|
+
async run({ io, flags, values, positionals }) {
|
|
912
|
+
const read = await readDecisions(await openForCli(io, flags));
|
|
913
|
+
const id = resolveDecision(read, positionals[0] ?? "");
|
|
914
|
+
const doc = await showDecision(read, id, { history: values.history === true });
|
|
915
|
+
const display = (s) => read.projection.views.get(s)?.display ?? s;
|
|
916
|
+
return {
|
|
917
|
+
exit: 0,
|
|
918
|
+
json: doc,
|
|
919
|
+
text: showText(doc, display),
|
|
920
|
+
notes: doc.notes
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
};
|
|
924
|
+
// src/cli/supersede.ts
|
|
925
|
+
const USAGE$1 = "supersede <decision> --by <decision> [--reason <text>] [--commit | --no-commit] [-m <message>]";
|
|
926
|
+
/** `adr supersede <decision> --by <decision> …` (design §5.6). */
|
|
927
|
+
const supersedeCommand = {
|
|
928
|
+
name: "supersede",
|
|
929
|
+
summary: "mark an accepted decision as superseded by another accepted one",
|
|
930
|
+
usage: USAGE$1,
|
|
931
|
+
options: {
|
|
932
|
+
by: { type: "string" },
|
|
933
|
+
reason: { type: "string" },
|
|
934
|
+
...COMMIT_OPTIONS
|
|
935
|
+
},
|
|
936
|
+
positionals: {
|
|
937
|
+
min: 1,
|
|
938
|
+
max: 1,
|
|
939
|
+
names: ["decision"]
|
|
940
|
+
},
|
|
941
|
+
ref: "reject",
|
|
942
|
+
async run({ io, flags, values, positionals }) {
|
|
943
|
+
const input = positionals[0] ?? "";
|
|
944
|
+
if (typeof values.by !== "string") throw new UsageError(`missing --by <decision>; usage: adr ${USAGE$1}`);
|
|
945
|
+
const log = await openForCli(io, flags);
|
|
946
|
+
const { actor, notes: attribution } = await resolveAttribution(log.git, { env: io.env });
|
|
947
|
+
const doc = await supersedeDecision(log, {
|
|
948
|
+
id: input,
|
|
949
|
+
by: values.by,
|
|
950
|
+
actor,
|
|
951
|
+
...typeof values.reason === "string" ? { reason: values.reason } : {}
|
|
952
|
+
}, {
|
|
953
|
+
...commitChoice(values, { autoCommit: log.adr.autoCommit }),
|
|
954
|
+
notes: attribution
|
|
955
|
+
});
|
|
956
|
+
const { display } = await displayAfterWrite(log);
|
|
957
|
+
const v = doc.decision;
|
|
958
|
+
return {
|
|
959
|
+
exit: doc.ok ? 0 : 1,
|
|
960
|
+
json: doc,
|
|
961
|
+
text: writeText(doc, display, {
|
|
962
|
+
subject: v === null ? input.trim() : `${display(v.id)}: ${v.title}`,
|
|
963
|
+
number: null
|
|
964
|
+
}),
|
|
965
|
+
notes: doc.notes
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
};
|
|
969
|
+
/** `adr withdraw` (design §6.4): a proposal its author takes back. */
|
|
970
|
+
const withdrawCommand = {
|
|
971
|
+
name: "withdraw",
|
|
972
|
+
summary: "withdraw a proposed decision",
|
|
973
|
+
usage: "withdraw <decision> [--reason <text>] [--commit | --no-commit] [-m <message>]",
|
|
974
|
+
options: {
|
|
975
|
+
reason: { type: "string" },
|
|
976
|
+
...COMMIT_OPTIONS
|
|
977
|
+
},
|
|
978
|
+
positionals: {
|
|
979
|
+
min: 1,
|
|
980
|
+
max: 1,
|
|
981
|
+
names: ["decision"]
|
|
982
|
+
},
|
|
983
|
+
ref: "reject",
|
|
984
|
+
async run({ io, flags, values, positionals }) {
|
|
985
|
+
const input = positionals[0] ?? "";
|
|
986
|
+
if (typeof values.reason === "string" && values.reason.trim() === "") throw new UsageError("--reason must not be empty");
|
|
987
|
+
const reason = typeof values.reason === "string" ? values.reason.trim() : null;
|
|
988
|
+
const log = await openForCli(io, flags);
|
|
989
|
+
const { actor, notes } = await resolveAttribution(log.git, { env: io.env });
|
|
990
|
+
const doc = await withdrawDecision(log, {
|
|
991
|
+
id: input,
|
|
992
|
+
reason,
|
|
993
|
+
actor
|
|
994
|
+
}, {
|
|
995
|
+
...commitChoice(values, { autoCommit: log.adr.autoCommit }),
|
|
996
|
+
notes
|
|
997
|
+
});
|
|
998
|
+
const { display } = await displayAfterWrite(log);
|
|
999
|
+
const v = doc.decision;
|
|
1000
|
+
return {
|
|
1001
|
+
exit: doc.ok ? 0 : 1,
|
|
1002
|
+
json: doc,
|
|
1003
|
+
text: writeText(doc, display, {
|
|
1004
|
+
subject: v === null ? input.trim() : `${display(v.id)}: ${v.title}`,
|
|
1005
|
+
number: null,
|
|
1006
|
+
reason
|
|
1007
|
+
}),
|
|
1008
|
+
notes: doc.notes
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
};
|
|
1012
|
+
// src/cli/commands.ts
|
|
1013
|
+
/**
|
|
1014
|
+
* ledger's shared `verify`: the log as the mode reads it (the index for `--staged`, else the
|
|
1015
|
+
* `--ref` tree or the work tree), every phase-1 check, and `[verify] fail_on`.
|
|
1016
|
+
*/
|
|
1017
|
+
const verify = verifyCommand({ async open({ io, flags, mode }) {
|
|
1018
|
+
const log = await openForCli(io, flags, { reader: mode.type === "staged" ? "index" : "worktree" });
|
|
1019
|
+
return {
|
|
1020
|
+
git: log.git,
|
|
1021
|
+
kind: adrKind,
|
|
1022
|
+
layout: log.layout,
|
|
1023
|
+
checks: adrChecks(log, await templateHistoryRef(log.git, mode)),
|
|
1024
|
+
failOn: log.adr.failOn,
|
|
1025
|
+
notes: log.notes
|
|
1026
|
+
};
|
|
1027
|
+
} });
|
|
1028
|
+
/** The `adr` tool: its commands, for ledger's `runCli`. */
|
|
1029
|
+
function adrCli(version) {
|
|
1030
|
+
return {
|
|
1031
|
+
name: "adr",
|
|
1032
|
+
version,
|
|
1033
|
+
summary: "Architecture decision records kept as event logs in git.",
|
|
1034
|
+
commands: [
|
|
1035
|
+
initCommand,
|
|
1036
|
+
newCommand,
|
|
1037
|
+
acceptCommand,
|
|
1038
|
+
rejectCommand,
|
|
1039
|
+
withdrawCommand,
|
|
1040
|
+
deprecateCommand,
|
|
1041
|
+
supersedeCommand,
|
|
1042
|
+
noteCommand,
|
|
1043
|
+
linkCommand,
|
|
1044
|
+
scopeCommand,
|
|
1045
|
+
forCommand,
|
|
1046
|
+
listCommand,
|
|
1047
|
+
showCommand,
|
|
1048
|
+
verify
|
|
1049
|
+
]
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
// src/cli.ts
|
|
1053
|
+
const { version } = createRequire(import.meta.url)("../package.json");
|
|
1054
|
+
process.exitCode = await runCli(adrCli(version), processIO());
|
|
1055
|
+
export {};
|
|
1056
|
+
|
|
1057
|
+
//# sourceMappingURL=cli.js.map
|