@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
|
@@ -0,0 +1,1986 @@
|
|
|
1
|
+
import { t as adrKind } from "./kind-BcHjCHvi.js";
|
|
2
|
+
import { ConfigError, ENVELOPE_VERSION, EventExistsError, FileExistsError, InvalidArgumentError, UsageError, applySetOp, bodyFileName, bodySha256, classifyPath, compareEvents, compileKind, eventDir, eventPath, finding, foldChecks, foldLog, followRenames, gitTemplates, globMatcher, historyTemplates, indexReader, invariantsCheck, isSubjectId, lintDocument, lintTemplate, loadFindings, loadLog, newSubjectId, nextEventIdentity, numberLog, openToolLog, overlayTree, parseCommitTemplate, parseEventFileName, parseGlob, parseTemplate, projectLog, readCommitTemplate, readToolConfig, refReader, relateCheck, renderCommitMessage, renderTemplate, resolveSubjectInput, scanAnchors, scopeLookup, serializeEvent, slugify, sortFindings, stagedRenames, templateHistoryRef, transitions, verifyLog, writeEvents, writeNewFile } from "@nikovirtala/ledger";
|
|
3
|
+
import { lstat, readFile, realpath, rm } from "node:fs/promises";
|
|
4
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
// src/config.ts
|
|
6
|
+
/** adr's commit-subject placeholders, each with its longest value (design §4.2). */
|
|
7
|
+
const COMMIT_VARS = Object.freeze({
|
|
8
|
+
action: 24,
|
|
9
|
+
id: 8,
|
|
10
|
+
display: 13,
|
|
11
|
+
command: 9
|
|
12
|
+
});
|
|
13
|
+
/** The default `[commit] subject`. */
|
|
14
|
+
const DEFAULT_COMMIT_SUBJECT = "docs(adr): {action} {title}";
|
|
15
|
+
/**
|
|
16
|
+
* The names the title placeholder holds instead of a title, which are never shortened, each with
|
|
17
|
+
* its action: supersede's `<display> with <display>` and a link's `<display> to <display>` or
|
|
18
|
+
* `<display> to a decision in another log` (`planLink` in write.ts). The shorter names (a display,
|
|
19
|
+
* at most 13 characters) always fit in the room a title gets.
|
|
20
|
+
*/
|
|
21
|
+
const COMMIT_NAMES = Object.freeze([{
|
|
22
|
+
length: COMMIT_VARS.display * 2 + 6,
|
|
23
|
+
vars: { action: 9 }
|
|
24
|
+
}, {
|
|
25
|
+
length: COMMIT_VARS.display + 29,
|
|
26
|
+
vars: { action: 4 }
|
|
27
|
+
}]);
|
|
28
|
+
const DEFAULT_ADR_CONFIG = Object.freeze({
|
|
29
|
+
defaultTemplate: "nygard",
|
|
30
|
+
mainBranch: null,
|
|
31
|
+
index: "none",
|
|
32
|
+
acceptRequiresHuman: true,
|
|
33
|
+
autoCommit: false,
|
|
34
|
+
mcpCanDecide: false,
|
|
35
|
+
failOn: "error",
|
|
36
|
+
commitSubject: parseCommitTemplate(DEFAULT_COMMIT_SUBJECT, {
|
|
37
|
+
vars: COMMIT_VARS,
|
|
38
|
+
where: "commit.subject",
|
|
39
|
+
names: COMMIT_NAMES
|
|
40
|
+
}),
|
|
41
|
+
anchorKeyword: adrKind.anchorKeyword,
|
|
42
|
+
anchorExclude: Object.freeze([])
|
|
43
|
+
});
|
|
44
|
+
const TEMPLATE = /^[a-z][a-z0-9-]*$/;
|
|
45
|
+
const KEYWORD = /^[a-z][a-z0-9-]{0,31}$/;
|
|
46
|
+
const MAX_ANCHOR_EXCLUDE = 100;
|
|
47
|
+
function isTable(v) {
|
|
48
|
+
return typeof v === "object" && v !== null && !Array.isArray(v) && !(v instanceof Date);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Validates `adr.toml` (ledger's parsed `ToolConfig`; `null` when there is none). A wrong type or
|
|
52
|
+
* value throws `ConfigError` naming the file and key. Unknown keys are ignored, so newer files
|
|
53
|
+
* still load. `[log] root` is ledger's (`openToolLog` already checked it).
|
|
54
|
+
*/
|
|
55
|
+
function parseAdrConfig(config, file) {
|
|
56
|
+
if (config === null) return DEFAULT_ADR_CONFIG;
|
|
57
|
+
const where = file ?? config.file ?? "adr.toml";
|
|
58
|
+
const fail = (message) => new ConfigError(`${where}: ${message}`, { file: where });
|
|
59
|
+
const table = (name) => {
|
|
60
|
+
const t = config.raw[name];
|
|
61
|
+
if (t === void 0) return {};
|
|
62
|
+
if (!isTable(t)) throw fail(`${name} must be a table`);
|
|
63
|
+
return t;
|
|
64
|
+
};
|
|
65
|
+
const log = table("log");
|
|
66
|
+
const policy = table("policy");
|
|
67
|
+
const verify = table("verify");
|
|
68
|
+
const scope = table("scope");
|
|
69
|
+
const bool = (t, section, key, fallback) => {
|
|
70
|
+
const v = t[key];
|
|
71
|
+
if (v === void 0) return fallback;
|
|
72
|
+
if (typeof v !== "boolean") throw fail(`${section}.${key} must be true or false`);
|
|
73
|
+
return v;
|
|
74
|
+
};
|
|
75
|
+
const oneOf = (t, section, key, values, fallback) => {
|
|
76
|
+
const v = t[key];
|
|
77
|
+
if (v === void 0) return fallback;
|
|
78
|
+
if (typeof v !== "string" || !values.includes(v)) throw fail(`${section}.${key} must be ${values.map((x) => JSON.stringify(x)).join(" or ")}`);
|
|
79
|
+
return v;
|
|
80
|
+
};
|
|
81
|
+
const template = log.default_template;
|
|
82
|
+
if (template !== void 0 && (typeof template !== "string" || template.length > 64 || !TEMPLATE.test(template))) throw fail(`log.default_template must be a template name matching ${TEMPLATE.source}`);
|
|
83
|
+
const main = log.main_branch;
|
|
84
|
+
if (main !== void 0 && (typeof main !== "string" || main.trim() === "" || main.startsWith("-") || main.includes("\0"))) throw fail("log.main_branch must be a ref name that doesn't start with -");
|
|
85
|
+
const keyword = scope.anchor_keyword;
|
|
86
|
+
if (keyword !== void 0 && (typeof keyword !== "string" || !KEYWORD.test(keyword))) throw fail(`scope.anchor_keyword must match ${KEYWORD.source}`);
|
|
87
|
+
const exclude = scope.anchor_exclude;
|
|
88
|
+
if (exclude !== void 0) {
|
|
89
|
+
if (!Array.isArray(exclude) || exclude.length > MAX_ANCHOR_EXCLUDE) throw fail(`scope.anchor_exclude must be an array of at most ${MAX_ANCHOR_EXCLUDE} globs`);
|
|
90
|
+
exclude.forEach((g, i) => {
|
|
91
|
+
if (typeof g !== "string") throw fail(`scope.anchor_exclude[${i}] must be a string`);
|
|
92
|
+
const parsed = parseGlob(g);
|
|
93
|
+
if (!parsed.ok) throw fail(`scope.anchor_exclude[${i}]: ${parsed.message}`);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
defaultTemplate: typeof template === "string" ? template : DEFAULT_ADR_CONFIG.defaultTemplate,
|
|
98
|
+
mainBranch: typeof main === "string" ? main : null,
|
|
99
|
+
index: oneOf(log, "log", "index", ["committed", "none"], "none"),
|
|
100
|
+
acceptRequiresHuman: bool(policy, "policy", "accept_requires_human", true),
|
|
101
|
+
autoCommit: bool(policy, "policy", "auto_commit", false),
|
|
102
|
+
mcpCanDecide: bool(policy, "policy", "mcp_can_decide", false),
|
|
103
|
+
failOn: oneOf(verify, "verify", "fail_on", ["error", "warning"], "error"),
|
|
104
|
+
commitSubject: readCommitTemplate(config, {
|
|
105
|
+
file: where,
|
|
106
|
+
vars: COMMIT_VARS,
|
|
107
|
+
defaultSubject: DEFAULT_COMMIT_SUBJECT,
|
|
108
|
+
names: COMMIT_NAMES
|
|
109
|
+
}),
|
|
110
|
+
anchorKeyword: typeof keyword === "string" ? keyword : DEFAULT_ADR_CONFIG.anchorKeyword,
|
|
111
|
+
anchorExclude: Array.isArray(exclude) ? exclude : []
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/** The built-in templates by name: the file text `adr new` materializes. */
|
|
115
|
+
const BUILTIN_TEMPLATES = Object.freeze({
|
|
116
|
+
nygard: `---
|
|
117
|
+
template: nygard
|
|
118
|
+
version: 1
|
|
119
|
+
sections:
|
|
120
|
+
- id: context
|
|
121
|
+
heading: Context
|
|
122
|
+
required: true
|
|
123
|
+
guidance: What is the issue that motivates this decision? Describe the forces at play.
|
|
124
|
+
- id: decision
|
|
125
|
+
heading: Decision
|
|
126
|
+
required: true
|
|
127
|
+
guidance: What is the change that we're proposing or doing? Write it in the active voice.
|
|
128
|
+
- id: scope
|
|
129
|
+
heading: Scope
|
|
130
|
+
required: true
|
|
131
|
+
guidance: What does this decision govern? Name the systems, code paths or teams it applies to.
|
|
132
|
+
- id: consequences
|
|
133
|
+
heading: Consequences
|
|
134
|
+
required: true
|
|
135
|
+
guidance: What becomes easier or harder because of this change? Include the risks.
|
|
136
|
+
---
|
|
137
|
+
# {{title}}
|
|
138
|
+
|
|
139
|
+
## Context
|
|
140
|
+
{{context}}
|
|
141
|
+
|
|
142
|
+
## Decision
|
|
143
|
+
{{decision}}
|
|
144
|
+
|
|
145
|
+
## Scope
|
|
146
|
+
{{scope}}
|
|
147
|
+
|
|
148
|
+
## Consequences
|
|
149
|
+
{{consequences}}
|
|
150
|
+
`,
|
|
151
|
+
madr: `---
|
|
152
|
+
template: madr
|
|
153
|
+
version: 1
|
|
154
|
+
sections:
|
|
155
|
+
- id: context
|
|
156
|
+
heading: Context and Problem Statement
|
|
157
|
+
required: true
|
|
158
|
+
guidance: Describe the context and the problem in two or three sentences, or as a question.
|
|
159
|
+
- id: drivers
|
|
160
|
+
heading: Decision Drivers
|
|
161
|
+
required: false
|
|
162
|
+
guidance: List the forces and concerns that shape the decision.
|
|
163
|
+
- id: options
|
|
164
|
+
heading: Considered Options
|
|
165
|
+
required: true
|
|
166
|
+
guidance: List the options that were considered, one per line.
|
|
167
|
+
- id: decision
|
|
168
|
+
heading: Decision Outcome
|
|
169
|
+
required: true
|
|
170
|
+
guidance: Name the chosen option and say why it was chosen.
|
|
171
|
+
- id: scope
|
|
172
|
+
heading: Scope
|
|
173
|
+
required: true
|
|
174
|
+
guidance: What does this decision govern? Name the systems, code paths or teams it applies to.
|
|
175
|
+
- id: consequences
|
|
176
|
+
heading: Consequences
|
|
177
|
+
required: true
|
|
178
|
+
guidance: Describe the good and the bad consequences of the chosen option.
|
|
179
|
+
---
|
|
180
|
+
# {{title}}
|
|
181
|
+
|
|
182
|
+
## Context and Problem Statement
|
|
183
|
+
{{context}}
|
|
184
|
+
|
|
185
|
+
## Decision Drivers
|
|
186
|
+
{{drivers}}
|
|
187
|
+
|
|
188
|
+
## Considered Options
|
|
189
|
+
{{options}}
|
|
190
|
+
|
|
191
|
+
## Decision Outcome
|
|
192
|
+
{{decision}}
|
|
193
|
+
|
|
194
|
+
## Scope
|
|
195
|
+
{{scope}}
|
|
196
|
+
|
|
197
|
+
## Consequences
|
|
198
|
+
{{consequences}}
|
|
199
|
+
`,
|
|
200
|
+
"y-statement": `---
|
|
201
|
+
template: y-statement
|
|
202
|
+
version: 1
|
|
203
|
+
sections:
|
|
204
|
+
- id: statement
|
|
205
|
+
heading: Statement
|
|
206
|
+
required: true
|
|
207
|
+
guidance: In the context of <use case>, facing <concern>, we decided for <option>, to achieve <quality>, accepting <downside>.
|
|
208
|
+
- id: scope
|
|
209
|
+
heading: Scope
|
|
210
|
+
required: true
|
|
211
|
+
guidance: What does this decision govern? Name the systems, code paths or teams it applies to.
|
|
212
|
+
---
|
|
213
|
+
# {{title}}
|
|
214
|
+
|
|
215
|
+
## Statement
|
|
216
|
+
{{statement}}
|
|
217
|
+
|
|
218
|
+
## Scope
|
|
219
|
+
{{scope}}
|
|
220
|
+
`
|
|
221
|
+
});
|
|
222
|
+
// src/init.ts
|
|
223
|
+
const CONFIG = "adr.toml";
|
|
224
|
+
const DEFAULT_ROOT = adrKind.def.defaultRoot;
|
|
225
|
+
const encoder$1 = new TextEncoder();
|
|
226
|
+
/** The starter config; `root` only for a repo-level file naming a log elsewhere. */
|
|
227
|
+
function configText(o) {
|
|
228
|
+
return [
|
|
229
|
+
"# adr configuration. Every key is optional: https://github.com/nikovirtala/ark/tree/main/packages/adr#config-adrtoml",
|
|
230
|
+
"[log]",
|
|
231
|
+
...o.root === null ? [] : [`root = ${JSON.stringify(o.root)}`],
|
|
232
|
+
`default_template = ${JSON.stringify(o.template)}`,
|
|
233
|
+
"",
|
|
234
|
+
"[commit]",
|
|
235
|
+
"# subject = \"docs(adr): {action} {title}\"",
|
|
236
|
+
""
|
|
237
|
+
].join("\n");
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* `adr init` (design §6.2): writes a config for the log, unless one is found, and each built-in
|
|
241
|
+
* template in `o.templates` (default `nygard`) to `<root>/templates/`, never overwriting a file.
|
|
242
|
+
* A `--root` other than `docs/adr` gets a repo-level `adr.toml` naming it, so later commands find
|
|
243
|
+
* the log without `--root`; when that file exists for another log, the config goes in the root.
|
|
244
|
+
* With `commit` (default `[policy] auto_commit`) the written files are committed on their own;
|
|
245
|
+
* nothing written means nothing committed. No events are written.
|
|
246
|
+
*/
|
|
247
|
+
async function initDecisionLog(log, o = {}) {
|
|
248
|
+
if (log.ref !== null || log.tree.label !== "worktree") throw new InvalidArgumentError("init writes the work tree; open the log without a ref");
|
|
249
|
+
const known = Object.keys(BUILTIN_TEMPLATES);
|
|
250
|
+
const templates = [...new Set(o.templates === void 0 ? ["nygard"] : o.templates)];
|
|
251
|
+
for (const name of templates) if (!known.includes(name)) throw new UsageError(`unknown built-in template ${name}; use nygard, madr or y-statement`);
|
|
252
|
+
const root = log.layout.root;
|
|
253
|
+
const notes = [...log.notes];
|
|
254
|
+
const files = [];
|
|
255
|
+
const kept = [];
|
|
256
|
+
let config = log.configPath;
|
|
257
|
+
if (config !== null) kept.push(config);
|
|
258
|
+
else {
|
|
259
|
+
const template = templates[0] ?? "nygard";
|
|
260
|
+
const repo = await readToolConfig(log.tree, CONFIG);
|
|
261
|
+
if (root === DEFAULT_ROOT) {
|
|
262
|
+
config = `${root}/${CONFIG}`;
|
|
263
|
+
files.push([config, configText({
|
|
264
|
+
root: null,
|
|
265
|
+
template
|
|
266
|
+
})]);
|
|
267
|
+
} else if (repo === null) {
|
|
268
|
+
config = CONFIG;
|
|
269
|
+
files.push([config, configText({
|
|
270
|
+
root,
|
|
271
|
+
template
|
|
272
|
+
})]);
|
|
273
|
+
} else {
|
|
274
|
+
const section = repo.raw.log;
|
|
275
|
+
const other = typeof section?.root === "string" ? section.root : DEFAULT_ROOT;
|
|
276
|
+
config = `${root}/${CONFIG}`;
|
|
277
|
+
files.push([config, configText({
|
|
278
|
+
root: null,
|
|
279
|
+
template
|
|
280
|
+
})]);
|
|
281
|
+
notes.push(`${CONFIG} names ${other}; pass --root ${root} to use this log`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
for (const name of templates) {
|
|
285
|
+
const path = `${root}/templates/${name}.md`;
|
|
286
|
+
if (await log.tree.read(path) !== null) kept.push(path);
|
|
287
|
+
else files.push([path, BUILTIN_TEMPLATES[name] ?? ""]);
|
|
288
|
+
}
|
|
289
|
+
const commit = files.length > 0 && (o.commit ?? log.adr.autoCommit);
|
|
290
|
+
if (commit && (await log.git.identity() === null || await log.git.committerIdentity() === null)) throw new ConfigError("--commit needs a git identity; set git user.name and user.email");
|
|
291
|
+
const written = [];
|
|
292
|
+
try {
|
|
293
|
+
for (const [path, text] of files) {
|
|
294
|
+
await writeNewFile(log.git, path, encoder$1.encode(text));
|
|
295
|
+
written.push(path);
|
|
296
|
+
}
|
|
297
|
+
} catch (err) {
|
|
298
|
+
const top = log.git.root ?? "";
|
|
299
|
+
for (const path of written) await rm(join(top, path), { force: true });
|
|
300
|
+
throw err;
|
|
301
|
+
}
|
|
302
|
+
if (written.length > 0) await log.git.add(written);
|
|
303
|
+
let sha = null;
|
|
304
|
+
if (commit) {
|
|
305
|
+
const message = o.message ?? renderCommitMessage(log.adr.commitSubject, {
|
|
306
|
+
title: "the decision log",
|
|
307
|
+
vars: {
|
|
308
|
+
action: "set up",
|
|
309
|
+
id: "",
|
|
310
|
+
display: "",
|
|
311
|
+
command: "init"
|
|
312
|
+
}
|
|
313
|
+
}).message;
|
|
314
|
+
sha = await log.git.commit({
|
|
315
|
+
message,
|
|
316
|
+
only: written
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
return {
|
|
320
|
+
format: "adr-init/v1",
|
|
321
|
+
root,
|
|
322
|
+
config,
|
|
323
|
+
written,
|
|
324
|
+
kept,
|
|
325
|
+
commit: sha,
|
|
326
|
+
notes
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
// src/log.ts
|
|
330
|
+
/**
|
|
331
|
+
* Opens the decision log (design D3): `--root`, else `adr.toml`'s `[log] root`, else `docs/adr`;
|
|
332
|
+
* read from the `ref` tree, the index or the work tree.
|
|
333
|
+
*/
|
|
334
|
+
async function openDecisionLog(o) {
|
|
335
|
+
const log = await openToolLog({
|
|
336
|
+
...o,
|
|
337
|
+
tool: "adr",
|
|
338
|
+
kind: adrKind
|
|
339
|
+
});
|
|
340
|
+
return {
|
|
341
|
+
...log,
|
|
342
|
+
adr: parseAdrConfig(log.config, log.configPath)
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
/** `numberLog` on the repository, then `readDecisionsFrom(log.tree, …)`. */
|
|
346
|
+
async function readDecisions(log) {
|
|
347
|
+
const numbering = await numberLog(log.git, {
|
|
348
|
+
kind: adrKind,
|
|
349
|
+
layout: log.layout,
|
|
350
|
+
mainRef: log.adr.mainBranch
|
|
351
|
+
});
|
|
352
|
+
return readDecisionsFrom(log.tree, {
|
|
353
|
+
layout: log.layout,
|
|
354
|
+
adr: log.adr,
|
|
355
|
+
numbering,
|
|
356
|
+
ref: log.ref,
|
|
357
|
+
commit: log.commit,
|
|
358
|
+
notes: log.notes
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
const compiled$1 = compileKind(adrKind);
|
|
362
|
+
/** The git-free core of `readDecisions`; tests pass a MemoryTree and a hand-made `Numbering`. */
|
|
363
|
+
async function readDecisionsFrom(tree, o) {
|
|
364
|
+
const { layout, adr, numbering } = o;
|
|
365
|
+
const snapshot = await loadLog(tree, {
|
|
366
|
+
kind: compiled$1,
|
|
367
|
+
layout
|
|
368
|
+
});
|
|
369
|
+
const folded = foldLog(adrKind, snapshot);
|
|
370
|
+
const ctx = {
|
|
371
|
+
kind: compiled$1,
|
|
372
|
+
layout,
|
|
373
|
+
base: null,
|
|
374
|
+
head: snapshot,
|
|
375
|
+
changes: [],
|
|
376
|
+
scope: "all",
|
|
377
|
+
inScope: new Set(snapshot.subjects.keys()),
|
|
378
|
+
folded: {
|
|
379
|
+
head: folded,
|
|
380
|
+
base: null
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
const options = { acceptRequiresHuman: adr.acceptRequiresHuman };
|
|
384
|
+
const findings = [];
|
|
385
|
+
for (const check of [
|
|
386
|
+
loadFindings,
|
|
387
|
+
transitions,
|
|
388
|
+
invariantsCheck(options),
|
|
389
|
+
relateCheck(options)
|
|
390
|
+
]) findings.push(...await check.run(ctx));
|
|
391
|
+
return {
|
|
392
|
+
tree,
|
|
393
|
+
layout,
|
|
394
|
+
adr,
|
|
395
|
+
root: layout.root,
|
|
396
|
+
ref: o.ref ?? null,
|
|
397
|
+
commit: o.commit ?? null,
|
|
398
|
+
folded,
|
|
399
|
+
projection: projectLog(adrKind, folded, { numbering }),
|
|
400
|
+
numbering,
|
|
401
|
+
findings: sortFindings(findings.map((f) => ({
|
|
402
|
+
...f,
|
|
403
|
+
kind: adrKind.name
|
|
404
|
+
}))),
|
|
405
|
+
notes: o.notes ?? []
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
/** Why numbers are unavailable, in words: `shallow clone` or `no main branch`. */
|
|
409
|
+
function unavailableReason(n) {
|
|
410
|
+
return n.reason === "shallow" ? "shallow clone" : "no main branch";
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* The id a decision argument names: an id, or a number in any form `parseDecisionNumber` takes.
|
|
414
|
+
* Throws `UsageError` (exit 2) naming the reason when it names nothing.
|
|
415
|
+
*/
|
|
416
|
+
function resolveDecision(read, input) {
|
|
417
|
+
const r = resolveSubjectInput(input, {
|
|
418
|
+
subjects: new Set(read.folded.subjects.keys()),
|
|
419
|
+
kind: adrKind,
|
|
420
|
+
numbering: read.numbering
|
|
421
|
+
});
|
|
422
|
+
if (r.ok) return r.id;
|
|
423
|
+
const shown = JSON.stringify(input.trim());
|
|
424
|
+
switch (r.reason) {
|
|
425
|
+
case "malformed": throw new UsageError(`invalid decision ${JSON.stringify(input)}`);
|
|
426
|
+
case "qualified": throw new UsageError(`decision ${shown} is in another log; cross-log references aren't supported yet`);
|
|
427
|
+
case "numbers-unavailable": throw new UsageError(`decision numbers are unavailable (${unavailableReason(read.numbering)}); use the decision's id instead of ${shown}`);
|
|
428
|
+
case "not-found": throw new UsageError(`unknown decision ${input.trim()}`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
// src/views.ts
|
|
432
|
+
/** The `numbering` key of `adr-list/v1`, `adr-show/v1` and `adr-for/v1`. */
|
|
433
|
+
const numberingJson = (read) => ({
|
|
434
|
+
available: read.numbering.available,
|
|
435
|
+
reason: read.numbering.reason,
|
|
436
|
+
ref: read.numbering.ref
|
|
437
|
+
});
|
|
438
|
+
/** Numbered decisions by number, then unnumbered ones by `proposed_at`, then id. */
|
|
439
|
+
function compareViews(a, b) {
|
|
440
|
+
if (a.number !== null || b.number !== null) {
|
|
441
|
+
if (a.number === null) return 1;
|
|
442
|
+
if (b.number === null) return -1;
|
|
443
|
+
return a.number - b.number;
|
|
444
|
+
}
|
|
445
|
+
if (a.proposed_at !== b.proposed_at) return a.proposed_at < b.proposed_at ? -1 : 1;
|
|
446
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* The `adr-list/v1` document: decisions in list order, filtered by `status` and truncated to
|
|
450
|
+
* `limit` after ordering. `findings` are those of the listed decisions and of the log as a whole.
|
|
451
|
+
*/
|
|
452
|
+
function listDecisions(read, o = {}) {
|
|
453
|
+
const statuses = o.status === void 0 || o.status.length === 0 ? null : new Set(o.status);
|
|
454
|
+
const decisions = [...read.projection.views.values()].filter((v) => statuses === null || statuses.has(v.status)).sort(compareViews).slice(0, o.limit ?? Number.POSITIVE_INFINITY);
|
|
455
|
+
const listed = new Set(decisions.map((v) => v.id));
|
|
456
|
+
return {
|
|
457
|
+
format: "adr-list/v1",
|
|
458
|
+
root: read.root,
|
|
459
|
+
ref: read.ref,
|
|
460
|
+
commit: read.commit,
|
|
461
|
+
numbering: numberingJson(read),
|
|
462
|
+
decisions,
|
|
463
|
+
unreadable: read.projection.unreadable,
|
|
464
|
+
unfoldable: read.projection.unfoldable,
|
|
465
|
+
findings: read.findings.filter((f) => f.subject === null || listed.has(f.subject)),
|
|
466
|
+
notes: read.notes
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
const decoder$1 = new TextDecoder();
|
|
470
|
+
/**
|
|
471
|
+
* The `adr-show/v1` document of the decision `id` (resolve it with `resolveDecision` first).
|
|
472
|
+
* Throws `UsageError` for an unknown, unreadable or unfoldable decision.
|
|
473
|
+
*/
|
|
474
|
+
async function showDecision(read, id, o = {}) {
|
|
475
|
+
const view = read.projection.views.get(id);
|
|
476
|
+
if (view === void 0) {
|
|
477
|
+
if (read.projection.unreadable.includes(id)) throw new UsageError(`decision ${id} uses a newer format; upgrade adr`);
|
|
478
|
+
if (read.projection.unfoldable.includes(id)) throw new UsageError(`decision ${id} can't be read; run adr verify`);
|
|
479
|
+
throw new UsageError(`unknown decision ${id}`);
|
|
480
|
+
}
|
|
481
|
+
const loaded = read.folded.subjects.get(id)?.loaded;
|
|
482
|
+
const body = loaded?.body ?? null;
|
|
483
|
+
const events = o.history === true ? (loaded?.events ?? []).flatMap((e) => e.event === null ? [] : [{
|
|
484
|
+
path: e.path,
|
|
485
|
+
event: e.event
|
|
486
|
+
}]).sort((a, b) => compareEvents({
|
|
487
|
+
...a.event,
|
|
488
|
+
path: a.path
|
|
489
|
+
}, {
|
|
490
|
+
...b.event,
|
|
491
|
+
path: b.path
|
|
492
|
+
})).map((e) => e.event) : null;
|
|
493
|
+
return {
|
|
494
|
+
format: "adr-show/v1",
|
|
495
|
+
root: read.root,
|
|
496
|
+
ref: read.ref,
|
|
497
|
+
commit: read.commit,
|
|
498
|
+
numbering: numberingJson(read),
|
|
499
|
+
header: stateHeader(view, read),
|
|
500
|
+
decision: view,
|
|
501
|
+
body: body === null ? null : decoder$1.decode(body.bytes),
|
|
502
|
+
events,
|
|
503
|
+
findings: read.findings.filter((f) => f.subject === id),
|
|
504
|
+
notes: read.notes
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
const day = (at) => (at ?? "").slice(0, 10);
|
|
508
|
+
/** The reason on the applied event that produced the decision's current status, if any. */
|
|
509
|
+
function statusReason(read, id) {
|
|
510
|
+
const reason = (read.folded.subjects.get(id)?.applied ?? []).findLast((a) => a.from !== a.to)?.event.reason;
|
|
511
|
+
return typeof reason === "string" && reason.trim() !== "" ? reason.trim() : null;
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* The state header (01:258–259): `<display> (<id>) · <status>`, and for a decision that isn't in
|
|
515
|
+
* force a warning saying what to read instead. `refer` names where to read another decision. CLI
|
|
516
|
+
* default: `display(id)`. Phase 3 MCP passes `id => "adr://decision/" + display(id)`, e.g.
|
|
517
|
+
* `adr://decision/ADR-0012`, or the id when unnumbered.
|
|
518
|
+
*/
|
|
519
|
+
function stateHeader(view, read, o = {}) {
|
|
520
|
+
const display = (id) => read.projection.views.get(id)?.display ?? id;
|
|
521
|
+
const refer = o.refer ?? display;
|
|
522
|
+
const line = view.number === null ? `${view.id} · ${view.status}` : `${view.display} (${view.id}) · ${view.status}`;
|
|
523
|
+
const parts = [];
|
|
524
|
+
const reason = statusReason(read, view.id);
|
|
525
|
+
const when = day(view.status_at);
|
|
526
|
+
switch (view.status) {
|
|
527
|
+
case "superseded": {
|
|
528
|
+
const target = view.effective ?? view.superseded_by ?? view.id;
|
|
529
|
+
const accepted = read.projection.views.get(target)?.decided_at ?? null;
|
|
530
|
+
const date = accepted === null ? "" : ` (accepted ${day(accepted)})`;
|
|
531
|
+
parts.push(`SUPERSEDED by ${display(target)}${date}. Do not apply this decision; read ${refer(target)} instead.`);
|
|
532
|
+
if (view.chain.length > 2) parts.push(`Chain: ${view.chain.map(display).join(" → ")}.`);
|
|
533
|
+
break;
|
|
534
|
+
}
|
|
535
|
+
case "deprecated":
|
|
536
|
+
parts.push(`DEPRECATED on ${when}: ${reason ?? "no reason given"}. No decision replaces it.`);
|
|
537
|
+
break;
|
|
538
|
+
case "rejected":
|
|
539
|
+
parts.push(`REJECTED on ${when}: ${reason ?? "no reason given"}. This decision was never in force.`);
|
|
540
|
+
break;
|
|
541
|
+
case "withdrawn": parts.push(`WITHDRAWN on ${when}${reason === null ? "" : `: ${reason}`}. This decision was never in force.`);
|
|
542
|
+
}
|
|
543
|
+
if (view.body_intact === false) parts.push("WARNING: the body changed after acceptance; run adr verify.");
|
|
544
|
+
return {
|
|
545
|
+
line,
|
|
546
|
+
warning: parts.length === 0 ? null : parts.join(" ")
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
// src/scope.ts
|
|
550
|
+
const MAX_PATHS = 100;
|
|
551
|
+
const MAX_PATH_LENGTH = 4096;
|
|
552
|
+
/** Statuses whose globs govern code; rejected and withdrawn decisions were never in force. */
|
|
553
|
+
const GLOBS_APPLY = /* @__PURE__ */ new Set([
|
|
554
|
+
"proposed",
|
|
555
|
+
"accepted",
|
|
556
|
+
"deprecated",
|
|
557
|
+
"superseded"
|
|
558
|
+
]);
|
|
559
|
+
const STATUS_ORDER = [
|
|
560
|
+
"accepted",
|
|
561
|
+
"proposed",
|
|
562
|
+
"deprecated",
|
|
563
|
+
"superseded",
|
|
564
|
+
"rejected",
|
|
565
|
+
"withdrawn"
|
|
566
|
+
];
|
|
567
|
+
/** Accepted first (design §6.9 step 5); within a status by number, then by id. */
|
|
568
|
+
function compareFor(a, b) {
|
|
569
|
+
const s = STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status);
|
|
570
|
+
if (s !== 0) return s;
|
|
571
|
+
if (a.number !== null || b.number !== null) {
|
|
572
|
+
if (a.number === null) return 1;
|
|
573
|
+
if (b.number === null) return -1;
|
|
574
|
+
return a.number - b.number;
|
|
575
|
+
}
|
|
576
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
577
|
+
}
|
|
578
|
+
/** `path` (relative to `cwd`) as a repo-relative POSIX path; `UsageError` outside the work tree. */
|
|
579
|
+
async function repoPath(root, cwd, path) {
|
|
580
|
+
if (path.length > MAX_PATH_LENGTH) throw new UsageError(`a path can be at most ${MAX_PATH_LENGTH} characters`);
|
|
581
|
+
const base = await realpath(cwd).catch(() => cwd);
|
|
582
|
+
const rel = relative(root, resolve(base, path));
|
|
583
|
+
if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) throw new UsageError(`${path} is outside the repository`);
|
|
584
|
+
return rel.split(sep).join("/");
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Which decisions govern each path (design §6.9): globs of decisions that were ever in force, and
|
|
588
|
+
* comment anchors of any decision, read from the work tree or, with a `--ref` log, that commit.
|
|
589
|
+
* Paths are relative to `o.cwd` (default the repository root); directories and paths outside the
|
|
590
|
+
* repository are `UsageError`s. Paths need not be tracked.
|
|
591
|
+
*/
|
|
592
|
+
async function decisionsFor(read, log, o) {
|
|
593
|
+
const root = log.git.root;
|
|
594
|
+
if (root === null) throw new UsageError("adr for needs a work tree");
|
|
595
|
+
if (o.paths.length === 0 || o.paths.length > MAX_PATHS) throw new UsageError(`adr for takes 1 to ${MAX_PATHS} paths`);
|
|
596
|
+
let index = null;
|
|
597
|
+
const indexed = () => {
|
|
598
|
+
index ??= indexReader(log.git);
|
|
599
|
+
return index;
|
|
600
|
+
};
|
|
601
|
+
const hasUnder = async (tree, path) => (await tree.list(path)).some((e) => e.path !== path);
|
|
602
|
+
const paths = [];
|
|
603
|
+
for (const given of o.paths) {
|
|
604
|
+
const path = await repoPath(root, o.cwd ?? root, given);
|
|
605
|
+
let directory = path === "";
|
|
606
|
+
if (!directory && read.ref === null) {
|
|
607
|
+
const st = await lstat(join(root, path)).catch(() => null);
|
|
608
|
+
directory = st === null ? await hasUnder(await indexed(), path) : st.isDirectory();
|
|
609
|
+
} else if (!directory) directory = await hasUnder(read.tree, path);
|
|
610
|
+
if (directory) throw new UsageError(`${given} is a directory; adr for takes file paths, for example adr for $(git ls-files ${given})`);
|
|
611
|
+
if (!paths.includes(path)) paths.push(path);
|
|
612
|
+
}
|
|
613
|
+
const atRef = read.ref !== null && read.commit !== null;
|
|
614
|
+
const trackedIn = atRef ? read.tree : await indexed();
|
|
615
|
+
const tracked = /* @__PURE__ */ new Set();
|
|
616
|
+
for (const p of paths) if ((await trackedIn.list(p)).some((e) => e.path === p)) tracked.add(p);
|
|
617
|
+
const anchors = await scanAnchors(log.git, {
|
|
618
|
+
keyword: read.adr.anchorKeyword,
|
|
619
|
+
tree: atRef ? { commit: read.commit } : "worktree",
|
|
620
|
+
paths,
|
|
621
|
+
excludeDirs: [read.layout.root],
|
|
622
|
+
exclude: read.adr.anchorExclude
|
|
623
|
+
});
|
|
624
|
+
const views = read.projection.views;
|
|
625
|
+
const lookup = scopeLookup({
|
|
626
|
+
subjects: [...views.values()].filter((v) => GLOBS_APPLY.has(v.status)).map((v) => ({
|
|
627
|
+
id: v.id,
|
|
628
|
+
globs: v.applies_to
|
|
629
|
+
})),
|
|
630
|
+
anchors
|
|
631
|
+
});
|
|
632
|
+
const listed = /* @__PURE__ */ new Set();
|
|
633
|
+
const out = paths.map((path) => {
|
|
634
|
+
const decisions = [];
|
|
635
|
+
const unresolved = [];
|
|
636
|
+
for (const m of lookup(path)) {
|
|
637
|
+
const view = m.source === null ? views.get(m.id) : void 0;
|
|
638
|
+
if (view === void 0) {
|
|
639
|
+
for (const v of m.via) {
|
|
640
|
+
if (v.type !== "anchor") continue;
|
|
641
|
+
unresolved.push({
|
|
642
|
+
ref: m.source === null ? m.id : `${m.source}:${m.id}`,
|
|
643
|
+
line: v.line,
|
|
644
|
+
reason: m.source === null ? "not-found" : "other-log"
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
decisions.push({
|
|
650
|
+
view,
|
|
651
|
+
via: m.via
|
|
652
|
+
});
|
|
653
|
+
listed.add(view.id);
|
|
654
|
+
}
|
|
655
|
+
decisions.sort((a, b) => compareFor(a.view, b.view));
|
|
656
|
+
unresolved.sort((a, b) => a.line - b.line);
|
|
657
|
+
return {
|
|
658
|
+
path,
|
|
659
|
+
tracked: tracked.has(path),
|
|
660
|
+
decisions: decisions.map(({ view: v, via }) => ({
|
|
661
|
+
id: v.id,
|
|
662
|
+
display: v.display,
|
|
663
|
+
title: v.title,
|
|
664
|
+
status: v.status,
|
|
665
|
+
effective: v.effective,
|
|
666
|
+
warning: stateHeader(v, read).warning,
|
|
667
|
+
via
|
|
668
|
+
})),
|
|
669
|
+
unresolved
|
|
670
|
+
};
|
|
671
|
+
});
|
|
672
|
+
return {
|
|
673
|
+
format: "adr-for/v1",
|
|
674
|
+
root: read.root,
|
|
675
|
+
ref: read.ref,
|
|
676
|
+
commit: read.commit,
|
|
677
|
+
numbering: numberingJson(read),
|
|
678
|
+
paths: out,
|
|
679
|
+
findings: read.findings.filter((f) => f.subject === null || listed.has(f.subject)),
|
|
680
|
+
notes: read.notes
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
// src/verify.ts
|
|
684
|
+
/**
|
|
685
|
+
* Every phase-1 check for the log: ledger's mechanical and fold checks with adr's actor policy,
|
|
686
|
+
* and template lint against the templates in the head tree and, for older versions, the history
|
|
687
|
+
* of `historyRef`.
|
|
688
|
+
*/
|
|
689
|
+
function adrChecks(log, historyRef) {
|
|
690
|
+
return foldChecks(adrKind, {
|
|
691
|
+
options: { acceptRequiresHuman: log.adr.acceptRequiresHuman },
|
|
692
|
+
templates: gitTemplates(log.git, { historyRef })
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
/** Verifies the decision log in `mode` (staged, range or full) with `adrChecks`. */
|
|
696
|
+
async function verifyDecisions(log, mode) {
|
|
697
|
+
return verifyLog({
|
|
698
|
+
git: log.git,
|
|
699
|
+
kind: adrKind,
|
|
700
|
+
layout: log.layout,
|
|
701
|
+
mode,
|
|
702
|
+
checks: adrChecks(log, await templateHistoryRef(log.git, mode))
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
// src/write.ts
|
|
706
|
+
const encoder = new TextEncoder();
|
|
707
|
+
const decoder = new TextDecoder();
|
|
708
|
+
const compiled = compileKind(adrKind);
|
|
709
|
+
const TEMPLATE_NAME = /^[a-z][a-z0-9-]*$/;
|
|
710
|
+
const MAX_TEXT_BYTES = 65536;
|
|
711
|
+
const SUBJECT_ATTEMPTS = 5;
|
|
712
|
+
const QUALIFIED_SUPERSESSION = "cross-log supersession isn't supported yet";
|
|
713
|
+
const sorted = (xs) => [...new Set(xs)].sort();
|
|
714
|
+
const templatePath = (read, name) => `${read.root}/templates/${name}.md`;
|
|
715
|
+
/** A text argument within the 64 KiB cap and without NUL characters, else `UsageError`. */
|
|
716
|
+
function checkText(what, text) {
|
|
717
|
+
if (text.includes("\0")) throw new UsageError(`${what} must not contain NUL characters`);
|
|
718
|
+
const size = encoder.encode(text).byteLength;
|
|
719
|
+
if (size > MAX_TEXT_BYTES) throw new UsageError(`${what} is ${size} bytes; the limit is ${MAX_TEXT_BYTES} (64 KiB)`);
|
|
720
|
+
}
|
|
721
|
+
function checkReason(reason) {
|
|
722
|
+
if (reason === void 0 || reason === null) return null;
|
|
723
|
+
const r = reason.trim();
|
|
724
|
+
if (r === "") throw new UsageError("--reason must not be empty");
|
|
725
|
+
checkText("--reason", r);
|
|
726
|
+
return r;
|
|
727
|
+
}
|
|
728
|
+
/** The view of a folded decision, else `UsageError` naming why it can't be used. */
|
|
729
|
+
function viewOf(read, id) {
|
|
730
|
+
const view = read.projection.views.get(id);
|
|
731
|
+
if (view !== void 0) return view;
|
|
732
|
+
if (read.projection.unreadable.includes(id)) throw new UsageError(`decision ${id} uses a newer format; upgrade adr`);
|
|
733
|
+
throw new UsageError(`decision ${id} can't be read; run adr verify`);
|
|
734
|
+
}
|
|
735
|
+
/** A refusal finding about decision `subject`, on its body (or events directory). */
|
|
736
|
+
function refusal(read, rule, message, subject) {
|
|
737
|
+
const loaded = read.folded.subjects.get(subject)?.loaded;
|
|
738
|
+
return finding({
|
|
739
|
+
severity: "error",
|
|
740
|
+
rule,
|
|
741
|
+
message,
|
|
742
|
+
file: loaded?.bodyPaths[0] ?? eventDir(read.layout, subject),
|
|
743
|
+
subject,
|
|
744
|
+
kind: adrKind.name
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
/** `"<type> is not allowed while <status>[; <status> is terminal]"`, as the fold words it. */
|
|
748
|
+
function illegal(type, status) {
|
|
749
|
+
return `${type} is not allowed while ${status}${adrKind.machine.terminal.includes(status) ? `; ${status} is terminal` : ""}`;
|
|
750
|
+
}
|
|
751
|
+
/** A new event of `subject`, sorting after its existing ones. */
|
|
752
|
+
function newEvent(read, c, o) {
|
|
753
|
+
const existing = (read.folded.subjects.get(o.subject)?.loaded.events ?? []).flatMap((e) => e.event === null ? [] : [e.event]);
|
|
754
|
+
const { at, id } = nextEventIdentity([...existing, ...o.after ?? []], {
|
|
755
|
+
now: c.now,
|
|
756
|
+
...c.random === void 0 ? {} : { random: c.random }
|
|
757
|
+
});
|
|
758
|
+
return {
|
|
759
|
+
envelope: ENVELOPE_VERSION,
|
|
760
|
+
kind: adrKind.name,
|
|
761
|
+
schema: adrKind.schema,
|
|
762
|
+
id,
|
|
763
|
+
subject: o.subject,
|
|
764
|
+
type: o.type,
|
|
765
|
+
at,
|
|
766
|
+
actor: o.actor,
|
|
767
|
+
...o.reason === void 0 || o.reason === null ? {} : { reason: o.reason },
|
|
768
|
+
data: o.data
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
/** Body, events directory and recorded template of each involved subject (D6). */
|
|
772
|
+
function commitScope(read, involved, extra = {}) {
|
|
773
|
+
const paths = [];
|
|
774
|
+
const dirs = [];
|
|
775
|
+
for (const id of involved) {
|
|
776
|
+
const view = read.projection.views.get(id);
|
|
777
|
+
if (view?.body_path !== null && view?.body_path !== void 0) paths.push(view.body_path);
|
|
778
|
+
if (view !== void 0) paths.push(templatePath(read, view.template.name));
|
|
779
|
+
dirs.push(eventDir(read.layout, id));
|
|
780
|
+
}
|
|
781
|
+
if (extra.body !== void 0) paths.push(extra.body);
|
|
782
|
+
if (extra.template !== void 0) paths.push(extra.template);
|
|
783
|
+
return {
|
|
784
|
+
scope: sorted([...paths, ...dirs]),
|
|
785
|
+
eventDirs: sorted(dirs)
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
const findingKey = (f) => [
|
|
789
|
+
f.rule,
|
|
790
|
+
f.subject ?? "",
|
|
791
|
+
f.file ?? "",
|
|
792
|
+
f.message
|
|
793
|
+
].join("\0");
|
|
794
|
+
/**
|
|
795
|
+
* The precheck (§6.3): folds the would-be log and refuses when the write adds any finding. Problems
|
|
796
|
+
* that were already there don't block; the write can only add findings about what it touches.
|
|
797
|
+
*/
|
|
798
|
+
async function precheck(read, files) {
|
|
799
|
+
const after = await readDecisionsFrom(overlayTree(read.tree, files), {
|
|
800
|
+
layout: read.layout,
|
|
801
|
+
adr: read.adr,
|
|
802
|
+
numbering: read.numbering,
|
|
803
|
+
ref: read.ref,
|
|
804
|
+
commit: read.commit,
|
|
805
|
+
notes: read.notes
|
|
806
|
+
});
|
|
807
|
+
const before = new Set(read.findings.map(findingKey));
|
|
808
|
+
return {
|
|
809
|
+
after,
|
|
810
|
+
added: after.findings.filter((f) => !before.has(findingKey(f)))
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
function filesOf(events, read) {
|
|
814
|
+
return Object.fromEntries(events.map((e) => [eventPath(read.layout, e), encoder.encode(serializeEvent(e))]));
|
|
815
|
+
}
|
|
816
|
+
const pairsOf = (events) => events.flatMap((e) => [["ADR", e.subject], ["ADR-Event", e.type]]);
|
|
817
|
+
/** The template `new` uses: the repo's `<root>/templates/<name>.md`, else a built-in to copy. */
|
|
818
|
+
async function chooseTemplate(read, name, c) {
|
|
819
|
+
if (name.length > 64 || !TEMPLATE_NAME.test(name)) throw new UsageError(`invalid template name ${JSON.stringify(name)}`);
|
|
820
|
+
const path = templatePath(read, name);
|
|
821
|
+
const bytes = await read.tree.read(path);
|
|
822
|
+
let text;
|
|
823
|
+
let materialize = null;
|
|
824
|
+
if (bytes !== null) text = decoder.decode(bytes);
|
|
825
|
+
else {
|
|
826
|
+
const builtin = Object.hasOwn(c.builtins, name) ? c.builtins[name] : void 0;
|
|
827
|
+
if (builtin === void 0) {
|
|
828
|
+
const known = Object.keys(c.builtins).sort().join(", ");
|
|
829
|
+
throw new UsageError(`unknown template ${name}; add ${path} or use one of ${known}`);
|
|
830
|
+
}
|
|
831
|
+
text = builtin;
|
|
832
|
+
materialize = builtin;
|
|
833
|
+
}
|
|
834
|
+
const parsed = parseTemplate(text, { path });
|
|
835
|
+
if (!parsed.ok) return {
|
|
836
|
+
ok: false,
|
|
837
|
+
findings: parsed.findings
|
|
838
|
+
};
|
|
839
|
+
const lint = lintTemplate(parsed.template, { requireSections: adrKind.def.templates?.requireSections ?? [] });
|
|
840
|
+
if (lint.length > 0) return {
|
|
841
|
+
ok: false,
|
|
842
|
+
findings: lint
|
|
843
|
+
};
|
|
844
|
+
return {
|
|
845
|
+
ok: true,
|
|
846
|
+
template: parsed.template,
|
|
847
|
+
materialize,
|
|
848
|
+
path
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* Plans `adr new`: a rendered body, a materialized template if needed, and `proposed`. With
|
|
853
|
+
* `refuseIncomplete` (a commit with no editor to fill the draft in, design §7.2), a draft that
|
|
854
|
+
* fails the template lint is refused with those findings before any file is planned.
|
|
855
|
+
*/
|
|
856
|
+
async function planPropose(read, input, c, o = {}) {
|
|
857
|
+
const title = input.title.trim();
|
|
858
|
+
if (title === "" || title.length > 200 || /[\r\n]/.test(title)) throw new UsageError("the title must be a single line of 1 to 200 characters");
|
|
859
|
+
const supersedes = sorted((input.supersedes ?? []).map((s) => {
|
|
860
|
+
if (s.includes(":")) throw new UsageError(`cannot supersede ${s.trim()}: ${QUALIFIED_SUPERSESSION}`);
|
|
861
|
+
return resolveDecision(read, s);
|
|
862
|
+
}));
|
|
863
|
+
const chosen = await chooseTemplate(read, input.template ?? read.adr.defaultTemplate, c);
|
|
864
|
+
if (!chosen.ok) return {
|
|
865
|
+
ok: false,
|
|
866
|
+
decision: null,
|
|
867
|
+
findings: chosen.findings
|
|
868
|
+
};
|
|
869
|
+
const t = chosen.template;
|
|
870
|
+
const sections = input.sections ?? {};
|
|
871
|
+
for (const [id, text] of Object.entries(sections)) {
|
|
872
|
+
if (!t.sections.some((s) => s.id === id)) {
|
|
873
|
+
const ids = t.sections.map((s) => s.id).join(", ");
|
|
874
|
+
throw new UsageError(`template ${t.name} has no section ${id}; its sections are ${ids}`);
|
|
875
|
+
}
|
|
876
|
+
checkText(`section ${id}`, text);
|
|
877
|
+
}
|
|
878
|
+
const rendered = renderTemplate(t, {
|
|
879
|
+
title,
|
|
880
|
+
sections
|
|
881
|
+
}, { missing: "guidance" });
|
|
882
|
+
if (!rendered.ok) throw new UsageError(rendered.findings.map((f) => f.message).join("; "));
|
|
883
|
+
let id = "";
|
|
884
|
+
let bodyPath = "";
|
|
885
|
+
for (let attempt = 0;; attempt++) {
|
|
886
|
+
id = newSubjectId(c.random);
|
|
887
|
+
bodyPath = `${read.root}/${read.layout.bodies}/${bodyFileName(id, slugify(title))}`;
|
|
888
|
+
if (!read.folded.subjects.has(id) && await read.tree.read(bodyPath) === null) break;
|
|
889
|
+
if (attempt + 1 >= SUBJECT_ATTEMPTS) throw new FileExistsError(bodyPath);
|
|
890
|
+
}
|
|
891
|
+
const lint = lintDocument(t, rendered.text, {
|
|
892
|
+
file: bodyPath,
|
|
893
|
+
subject: id
|
|
894
|
+
});
|
|
895
|
+
if (o.refuseIncomplete === true && lint.length > 0) return {
|
|
896
|
+
ok: false,
|
|
897
|
+
decision: null,
|
|
898
|
+
findings: lint
|
|
899
|
+
};
|
|
900
|
+
const event = newEvent(read, c, {
|
|
901
|
+
subject: id,
|
|
902
|
+
type: "proposed",
|
|
903
|
+
data: {
|
|
904
|
+
title,
|
|
905
|
+
template: t.name,
|
|
906
|
+
template_version: t.version,
|
|
907
|
+
...supersedes.length === 0 ? {} : { supersedes }
|
|
908
|
+
},
|
|
909
|
+
actor: input.actor
|
|
910
|
+
});
|
|
911
|
+
const body = encoder.encode(rendered.text);
|
|
912
|
+
const files = { [bodyPath]: body };
|
|
913
|
+
if (chosen.materialize !== null) files[chosen.path] = encoder.encode(chosen.materialize);
|
|
914
|
+
Object.assign(files, filesOf([event], read));
|
|
915
|
+
const { after, added } = await precheck(read, files);
|
|
916
|
+
if (added.length > 0) return {
|
|
917
|
+
ok: false,
|
|
918
|
+
decision: null,
|
|
919
|
+
findings: added
|
|
920
|
+
};
|
|
921
|
+
const { scope, eventDirs } = commitScope(read, [id], {
|
|
922
|
+
body: bodyPath,
|
|
923
|
+
template: chosen.path
|
|
924
|
+
});
|
|
925
|
+
return {
|
|
926
|
+
ok: true,
|
|
927
|
+
plan: {
|
|
928
|
+
command: "new",
|
|
929
|
+
subject: id,
|
|
930
|
+
files,
|
|
931
|
+
events: [event],
|
|
932
|
+
stage: Object.keys(files),
|
|
933
|
+
involved: [id],
|
|
934
|
+
commitScope: scope,
|
|
935
|
+
eventDirs,
|
|
936
|
+
draft: {
|
|
937
|
+
path: bodyPath,
|
|
938
|
+
rendered: body,
|
|
939
|
+
template: t
|
|
940
|
+
},
|
|
941
|
+
commit: {
|
|
942
|
+
action: ACTIONS.proposed.action,
|
|
943
|
+
title,
|
|
944
|
+
id,
|
|
945
|
+
display: id,
|
|
946
|
+
body: []
|
|
947
|
+
},
|
|
948
|
+
trailers: pairsOf([event]),
|
|
949
|
+
decision: viewOf(after, id),
|
|
950
|
+
affected: [],
|
|
951
|
+
findings: lint
|
|
952
|
+
}
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
/** The draft lint of an `accept`: the body against the template version its proposal recorded. */
|
|
956
|
+
async function acceptLint(read, view, c) {
|
|
957
|
+
const body = read.folded.subjects.get(view.id)?.loaded.body ?? null;
|
|
958
|
+
if (body === null) return {
|
|
959
|
+
findings: [refusal(read, "body/missing", "body file is missing", view.id)],
|
|
960
|
+
bytes: null
|
|
961
|
+
};
|
|
962
|
+
const { name, version } = view.template;
|
|
963
|
+
const found = await c.templates.get(name, version);
|
|
964
|
+
if (!found.ok) {
|
|
965
|
+
const message = found.reason === "not-found" ? `template ${name} not found at ${found.path}` : `template ${name} has no version ${version} at ${found.path}`;
|
|
966
|
+
return {
|
|
967
|
+
findings: [refusal(read, `template/${found.reason}`, message, view.id)],
|
|
968
|
+
bytes: body.bytes
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
let text;
|
|
972
|
+
try {
|
|
973
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(body.bytes);
|
|
974
|
+
} catch {
|
|
975
|
+
return {
|
|
976
|
+
findings: [refusal(read, "body/not-utf8", "body is not valid UTF-8", view.id)],
|
|
977
|
+
bytes: body.bytes
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
return {
|
|
981
|
+
findings: lintDocument(found.template, text, {
|
|
982
|
+
file: body.path,
|
|
983
|
+
subject: view.id
|
|
984
|
+
}).map((f) => ({
|
|
985
|
+
...f,
|
|
986
|
+
kind: adrKind.name
|
|
987
|
+
})),
|
|
988
|
+
bytes: body.bytes
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
/** Plans `adr accept`: `accepted` with the body hash, and `superseded` on each declared target. */
|
|
992
|
+
async function planAccept(read, input, c, o = {}) {
|
|
993
|
+
const id = resolveDecision(read, input.id);
|
|
994
|
+
const view = viewOf(read, id);
|
|
995
|
+
const refused = (findings) => ({
|
|
996
|
+
ok: false,
|
|
997
|
+
decision: view,
|
|
998
|
+
findings
|
|
999
|
+
});
|
|
1000
|
+
const findings = [];
|
|
1001
|
+
if (view.status !== "proposed") findings.push(refusal(read, "transition/illegal", illegal("accepted", view.status), id));
|
|
1002
|
+
const lint = await acceptLint(read, view, c);
|
|
1003
|
+
findings.push(...lint.findings);
|
|
1004
|
+
if (read.adr.acceptRequiresHuman && input.actor.kind === "agent") findings.push(refusal(read, "decision/agent-accepted", `accepted by agent ${input.actor.name}; accept_requires_human needs a human`, id));
|
|
1005
|
+
const targets = [];
|
|
1006
|
+
for (const target of view.declared_supersedes) {
|
|
1007
|
+
if (target.includes(":")) {
|
|
1008
|
+
findings.push(refusal(read, "decision/reference-missing", `supersedes ${target}: ${QUALIFIED_SUPERSESSION}`, id));
|
|
1009
|
+
continue;
|
|
1010
|
+
}
|
|
1011
|
+
const t = read.projection.views.get(target);
|
|
1012
|
+
if (t === void 0) findings.push(refusal(read, "decision/reference-missing", `supersedes ${target}, which is not in the log`, id));
|
|
1013
|
+
else if (target === id) findings.push(refusal(read, "decision/supersession-cycle", `supersession cycle: ${id} → ${id}`, id));
|
|
1014
|
+
else if (t.status !== "accepted" && t.status !== "deprecated") findings.push(refusal(read, "transition/illegal", illegal("superseded", t.status), target));
|
|
1015
|
+
else targets.push(target);
|
|
1016
|
+
}
|
|
1017
|
+
if (findings.length > 0 || lint.bytes === null) return refused(findings);
|
|
1018
|
+
const accepted = newEvent(read, c, {
|
|
1019
|
+
subject: id,
|
|
1020
|
+
type: "accepted",
|
|
1021
|
+
data: { body_sha256: bodySha256(lint.bytes) },
|
|
1022
|
+
actor: input.actor
|
|
1023
|
+
});
|
|
1024
|
+
const cascade = targets.map((target) => newEvent(read, c, {
|
|
1025
|
+
subject: target,
|
|
1026
|
+
type: "superseded",
|
|
1027
|
+
data: { by: id },
|
|
1028
|
+
actor: input.actor
|
|
1029
|
+
}));
|
|
1030
|
+
const copyGlobs = o.copyGlobs ?? [];
|
|
1031
|
+
const copied = copyGlobs.length === 0 ? null : newEvent(read, c, {
|
|
1032
|
+
subject: id,
|
|
1033
|
+
type: "scoped",
|
|
1034
|
+
data: { set: [...applySetOp(view.applies_to, { add: copyGlobs }, {
|
|
1035
|
+
what: "glob",
|
|
1036
|
+
maxItems: 200
|
|
1037
|
+
}).set] },
|
|
1038
|
+
actor: input.actor,
|
|
1039
|
+
after: [accepted]
|
|
1040
|
+
});
|
|
1041
|
+
if (copied !== null) {
|
|
1042
|
+
const valid = compiled.validate(copied, { file: null });
|
|
1043
|
+
if (!valid.ok) return refused(valid.findings.map((f) => ({
|
|
1044
|
+
...f,
|
|
1045
|
+
subject: id,
|
|
1046
|
+
kind: adrKind.name
|
|
1047
|
+
})));
|
|
1048
|
+
}
|
|
1049
|
+
const sources = copied === null ? [] : scopeToCopy(read, id).sources;
|
|
1050
|
+
const events = [
|
|
1051
|
+
accepted,
|
|
1052
|
+
...copied === null ? [] : [copied],
|
|
1053
|
+
...cascade
|
|
1054
|
+
];
|
|
1055
|
+
const files = filesOf(events, read);
|
|
1056
|
+
const { after, added } = await precheck(read, files);
|
|
1057
|
+
if (added.length > 0) return refused(added);
|
|
1058
|
+
const involved = sorted([id, ...targets]);
|
|
1059
|
+
const { scope, eventDirs } = commitScope(read, involved);
|
|
1060
|
+
const bodyPath = view.body_path ?? "";
|
|
1061
|
+
return {
|
|
1062
|
+
ok: true,
|
|
1063
|
+
plan: {
|
|
1064
|
+
command: "accept",
|
|
1065
|
+
subject: id,
|
|
1066
|
+
files,
|
|
1067
|
+
events,
|
|
1068
|
+
stage: [...Object.keys(files), bodyPath],
|
|
1069
|
+
involved,
|
|
1070
|
+
commitScope: scope,
|
|
1071
|
+
eventDirs,
|
|
1072
|
+
draft: null,
|
|
1073
|
+
commit: {
|
|
1074
|
+
action: ACTIONS.accepted.action,
|
|
1075
|
+
title: view.title,
|
|
1076
|
+
id,
|
|
1077
|
+
display: view.display,
|
|
1078
|
+
body: [...targets.map((t) => {
|
|
1079
|
+
const v = viewOf(read, t);
|
|
1080
|
+
return `Supersedes ${v.display}: ${sentenceTitle(v.title)}.`;
|
|
1081
|
+
}), ...sources.map((t) => `Copies the scope of ${viewOf(read, t).display}.`)]
|
|
1082
|
+
},
|
|
1083
|
+
trailers: pairsOf(events),
|
|
1084
|
+
decision: viewOf(after, id),
|
|
1085
|
+
affected: targets.map((t) => viewOf(after, t)),
|
|
1086
|
+
findings: []
|
|
1087
|
+
}
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
/**
|
|
1091
|
+
* What accepting `id` could copy: the declared targets it will supersede (accepted or
|
|
1092
|
+
* deprecated, as `planAccept` selects them) and the globs of theirs it doesn't have yet.
|
|
1093
|
+
*/
|
|
1094
|
+
function scopeToCopy(read, id) {
|
|
1095
|
+
const own = new Set(viewOf(read, id).applies_to);
|
|
1096
|
+
const sources = [];
|
|
1097
|
+
const globs = /* @__PURE__ */ new Set();
|
|
1098
|
+
for (const target of sorted(viewOf(read, id).declared_supersedes)) {
|
|
1099
|
+
const t = target === id ? void 0 : read.projection.views.get(target);
|
|
1100
|
+
if (t === void 0 || t.status !== "accepted" && t.status !== "deprecated") continue;
|
|
1101
|
+
const extra = t.applies_to.filter((g) => !own.has(g));
|
|
1102
|
+
if (extra.length === 0) continue;
|
|
1103
|
+
sources.push(target);
|
|
1104
|
+
for (const g of extra) globs.add(g);
|
|
1105
|
+
}
|
|
1106
|
+
return {
|
|
1107
|
+
id,
|
|
1108
|
+
sources,
|
|
1109
|
+
displays: sources.map((t) => viewOf(read, t).display),
|
|
1110
|
+
globs: sorted(globs)
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
const shQuote = (s) => `'${s.replaceAll("'", "'\\''")}'`;
|
|
1114
|
+
const andList = (xs) => xs.length <= 1 ? xs.join("") : `${xs.slice(0, -1).join(", ")} and ${xs.at(-1)}`;
|
|
1115
|
+
/** The note of an offer not taken: the `adr scope` command that copies it. */
|
|
1116
|
+
function copyScopeNote(id, offer) {
|
|
1117
|
+
const n = offer.globs.length;
|
|
1118
|
+
const adds = offer.globs.map((g) => `--add ${shQuote(g)}`).join(" ");
|
|
1119
|
+
return `not copying the scope of ${andList(offer.displays)} (${n} ${n === 1 ? "glob" : "globs"}); to copy it: adr scope ${id} ${adds}`;
|
|
1120
|
+
}
|
|
1121
|
+
/** Plans `adr supersede <id> --by <by>`: one `superseded` event on `id`. */
|
|
1122
|
+
async function planSupersede(read, input, c) {
|
|
1123
|
+
if (input.by.includes(":")) throw new UsageError(`--by ${input.by.trim()}: ${QUALIFIED_SUPERSESSION}`);
|
|
1124
|
+
const reason = checkReason(input.reason);
|
|
1125
|
+
const id = resolveDecision(read, input.id);
|
|
1126
|
+
const by = resolveDecision(read, input.by);
|
|
1127
|
+
const view = viewOf(read, id);
|
|
1128
|
+
const target = viewOf(read, by);
|
|
1129
|
+
const findings = [];
|
|
1130
|
+
if (view.status !== "accepted" && view.status !== "deprecated") findings.push(refusal(read, "transition/illegal", illegal("superseded", view.status), id));
|
|
1131
|
+
if (by === id || target.chain.includes(id)) {
|
|
1132
|
+
const cycle = by === id ? [id, id] : [id, ...target.chain.slice(0, target.chain.indexOf(id) + 1)];
|
|
1133
|
+
findings.push(refusal(read, "decision/supersession-cycle", `supersession cycle: ${cycle.join(" → ")}`, id));
|
|
1134
|
+
} else if (target.status !== "accepted") findings.push(refusal(read, "decision/superseded-by-unaccepted", `superseded by ${by}, which is not accepted`, id));
|
|
1135
|
+
const refused = (fs) => ({
|
|
1136
|
+
ok: false,
|
|
1137
|
+
decision: view,
|
|
1138
|
+
findings: fs
|
|
1139
|
+
});
|
|
1140
|
+
if (findings.length > 0) return refused(findings);
|
|
1141
|
+
const event = newEvent(read, c, {
|
|
1142
|
+
subject: id,
|
|
1143
|
+
type: "superseded",
|
|
1144
|
+
data: { by },
|
|
1145
|
+
reason,
|
|
1146
|
+
actor: input.actor
|
|
1147
|
+
});
|
|
1148
|
+
const files = filesOf([event], read);
|
|
1149
|
+
const { after, added } = await precheck(read, files);
|
|
1150
|
+
if (added.length > 0) return refused(added);
|
|
1151
|
+
const involved = sorted([id, by]);
|
|
1152
|
+
const { scope, eventDirs } = commitScope(read, involved);
|
|
1153
|
+
return {
|
|
1154
|
+
ok: true,
|
|
1155
|
+
plan: {
|
|
1156
|
+
command: "supersede",
|
|
1157
|
+
subject: id,
|
|
1158
|
+
files,
|
|
1159
|
+
events: [event],
|
|
1160
|
+
stage: Object.keys(files),
|
|
1161
|
+
involved,
|
|
1162
|
+
commitScope: scope,
|
|
1163
|
+
eventDirs,
|
|
1164
|
+
draft: null,
|
|
1165
|
+
commit: {
|
|
1166
|
+
action: ACTIONS.superseded.action,
|
|
1167
|
+
title: `${view.display} with ${target.display}`,
|
|
1168
|
+
id,
|
|
1169
|
+
display: view.display,
|
|
1170
|
+
body: [`Superseded by ${target.display}: ${sentenceTitle(target.title)}.`]
|
|
1171
|
+
},
|
|
1172
|
+
trailers: pairsOf([event]),
|
|
1173
|
+
decision: viewOf(after, id),
|
|
1174
|
+
affected: [viewOf(after, id)],
|
|
1175
|
+
findings: []
|
|
1176
|
+
}
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
/**
|
|
1180
|
+
* `{action}` of a commit that writes one event of each type (design §4.3), and what the title slot
|
|
1181
|
+
* names: the decision's title for the events that change whether it is decided, else its display.
|
|
1182
|
+
*/
|
|
1183
|
+
const ACTIONS = {
|
|
1184
|
+
proposed: {
|
|
1185
|
+
action: "propose to",
|
|
1186
|
+
names: "title"
|
|
1187
|
+
},
|
|
1188
|
+
accepted: {
|
|
1189
|
+
action: "decide to",
|
|
1190
|
+
names: "title"
|
|
1191
|
+
},
|
|
1192
|
+
rejected: {
|
|
1193
|
+
action: "decide not to",
|
|
1194
|
+
names: "title"
|
|
1195
|
+
},
|
|
1196
|
+
withdrawn: {
|
|
1197
|
+
action: "withdraw the proposal to",
|
|
1198
|
+
names: "title"
|
|
1199
|
+
},
|
|
1200
|
+
deprecated: {
|
|
1201
|
+
action: "deprecate",
|
|
1202
|
+
names: "display"
|
|
1203
|
+
},
|
|
1204
|
+
superseded: {
|
|
1205
|
+
action: "supersede",
|
|
1206
|
+
names: "display"
|
|
1207
|
+
},
|
|
1208
|
+
noted: {
|
|
1209
|
+
action: "add a note to",
|
|
1210
|
+
names: "display"
|
|
1211
|
+
},
|
|
1212
|
+
linked: {
|
|
1213
|
+
action: "link",
|
|
1214
|
+
names: "display"
|
|
1215
|
+
},
|
|
1216
|
+
scoped: {
|
|
1217
|
+
action: "set the scope of",
|
|
1218
|
+
names: "display"
|
|
1219
|
+
}
|
|
1220
|
+
};
|
|
1221
|
+
const actionOf = (type) => Object.hasOwn(ACTIONS, type) ? ACTIONS[type] : {
|
|
1222
|
+
action: type,
|
|
1223
|
+
names: "display"
|
|
1224
|
+
};
|
|
1225
|
+
/** A title in a body sentence: whitespace collapsed and trailing dots removed, as in a subject. */
|
|
1226
|
+
const sentenceTitle = (title) => title.replace(/\s+/g, " ").replace(/[. ]+$/, "").trim();
|
|
1227
|
+
/**
|
|
1228
|
+
* Plans any one event on an existing decision: the state machine and the event's validation
|
|
1229
|
+
* decide, then the precheck. Phase 2's reject, withdraw, deprecate, note, link and scope wrap it.
|
|
1230
|
+
*/
|
|
1231
|
+
async function planEvent(read, input, c) {
|
|
1232
|
+
const id = resolveDecision(read, input.subject);
|
|
1233
|
+
const view = viewOf(read, id);
|
|
1234
|
+
if (!adrKind.machine.types.has(input.type) || input.type === adrKind.machine.initial.type) throw new InvalidArgumentError(`planEvent can't write a ${input.type} event`);
|
|
1235
|
+
const reason = checkReason(input.reason);
|
|
1236
|
+
const refused = (fs) => ({
|
|
1237
|
+
ok: false,
|
|
1238
|
+
decision: view,
|
|
1239
|
+
findings: fs
|
|
1240
|
+
});
|
|
1241
|
+
if (!adrKind.machine.allowed(view.status, input.type)) return refused([refusal(read, "transition/illegal", illegal(input.type, view.status), id)]);
|
|
1242
|
+
const event = newEvent(read, c, {
|
|
1243
|
+
subject: id,
|
|
1244
|
+
type: input.type,
|
|
1245
|
+
data: input.data,
|
|
1246
|
+
reason,
|
|
1247
|
+
actor: input.actor
|
|
1248
|
+
});
|
|
1249
|
+
const valid = compiled.validate(event, { file: null });
|
|
1250
|
+
if (!valid.ok) return refused(valid.findings.map((f) => ({
|
|
1251
|
+
...f,
|
|
1252
|
+
subject: id,
|
|
1253
|
+
kind: adrKind.name
|
|
1254
|
+
})));
|
|
1255
|
+
const files = filesOf([event], read);
|
|
1256
|
+
const { after, added } = await precheck(read, files);
|
|
1257
|
+
if (added.length > 0) return refused(added);
|
|
1258
|
+
const by = input.type === "superseded" ? String(input.data.by) : null;
|
|
1259
|
+
const involved = sorted([id, ...by !== null && isSubjectId(by) ? [by] : []]);
|
|
1260
|
+
const { scope, eventDirs } = commitScope(read, involved);
|
|
1261
|
+
return {
|
|
1262
|
+
ok: true,
|
|
1263
|
+
plan: {
|
|
1264
|
+
command: "event",
|
|
1265
|
+
subject: id,
|
|
1266
|
+
files,
|
|
1267
|
+
events: [event],
|
|
1268
|
+
stage: Object.keys(files),
|
|
1269
|
+
involved,
|
|
1270
|
+
commitScope: scope,
|
|
1271
|
+
eventDirs,
|
|
1272
|
+
draft: null,
|
|
1273
|
+
commit: {
|
|
1274
|
+
action: actionOf(input.type).action,
|
|
1275
|
+
title: actionOf(input.type).names === "title" ? view.title : view.display,
|
|
1276
|
+
id,
|
|
1277
|
+
display: view.display,
|
|
1278
|
+
body: reason === null ? [] : [`Reason: ${reason}`]
|
|
1279
|
+
},
|
|
1280
|
+
trailers: pairsOf([event]),
|
|
1281
|
+
decision: viewOf(after, id),
|
|
1282
|
+
affected: input.type === "superseded" ? [viewOf(after, id)] : [],
|
|
1283
|
+
findings: []
|
|
1284
|
+
}
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
/** Plans `adr reject`: `rejected` with its required reason, while proposed. */
|
|
1288
|
+
function planReject(read, input, c) {
|
|
1289
|
+
return planEvent(read, {
|
|
1290
|
+
...input,
|
|
1291
|
+
subject: input.id,
|
|
1292
|
+
type: "rejected",
|
|
1293
|
+
data: {}
|
|
1294
|
+
}, c);
|
|
1295
|
+
}
|
|
1296
|
+
/** Plans `adr withdraw`: `withdrawn`, with a reason when given, while proposed. */
|
|
1297
|
+
function planWithdraw(read, input, c) {
|
|
1298
|
+
return planEvent(read, {
|
|
1299
|
+
...input,
|
|
1300
|
+
subject: input.id,
|
|
1301
|
+
type: "withdrawn",
|
|
1302
|
+
data: {}
|
|
1303
|
+
}, c);
|
|
1304
|
+
}
|
|
1305
|
+
/** Plans `adr deprecate`: `deprecated` with its required reason, while accepted. */
|
|
1306
|
+
function planDeprecate(read, input, c) {
|
|
1307
|
+
return planEvent(read, {
|
|
1308
|
+
...input,
|
|
1309
|
+
subject: input.id,
|
|
1310
|
+
type: "deprecated",
|
|
1311
|
+
data: {}
|
|
1312
|
+
}, c);
|
|
1313
|
+
}
|
|
1314
|
+
/** Plans `adr note`: `noted` with the text as given, in every state. */
|
|
1315
|
+
function planNote(read, input, c) {
|
|
1316
|
+
if (input.text.trim() === "") throw new UsageError("the note text must not be empty");
|
|
1317
|
+
checkText("the note text", input.text);
|
|
1318
|
+
return planEvent(read, {
|
|
1319
|
+
subject: input.id,
|
|
1320
|
+
type: "noted",
|
|
1321
|
+
data: { text: input.text },
|
|
1322
|
+
actor: input.actor
|
|
1323
|
+
}, c);
|
|
1324
|
+
}
|
|
1325
|
+
const LINK_RELS = [
|
|
1326
|
+
"relates-to",
|
|
1327
|
+
"refines",
|
|
1328
|
+
"depends-on"
|
|
1329
|
+
];
|
|
1330
|
+
const SOURCE_NAME = /^[a-z][a-z0-9-]{0,31}$/;
|
|
1331
|
+
/**
|
|
1332
|
+
* Plans `adr link` (design §6.7): `linked` in every state. A qualified target must name one of
|
|
1333
|
+
* `o.sources` (the log's `[[sources]]` names); it isn't looked up in the other log. A link that
|
|
1334
|
+
* already exists plans nothing, with a note.
|
|
1335
|
+
*/
|
|
1336
|
+
async function planLink(read, input, c, o = {}) {
|
|
1337
|
+
const rel = input.rel ?? "relates-to";
|
|
1338
|
+
if (!LINK_RELS.includes(rel)) throw new UsageError(`unknown --rel ${rel}; use relates-to, refines or depends-on`);
|
|
1339
|
+
const id = resolveDecision(read, input.id);
|
|
1340
|
+
const view = viewOf(read, id);
|
|
1341
|
+
const target = input.to.trim();
|
|
1342
|
+
let to;
|
|
1343
|
+
let shown;
|
|
1344
|
+
let described;
|
|
1345
|
+
let named;
|
|
1346
|
+
const at = target.indexOf(":");
|
|
1347
|
+
if (at >= 0) {
|
|
1348
|
+
const source = target.slice(0, at);
|
|
1349
|
+
const local = target.slice(at + 1);
|
|
1350
|
+
if (!SOURCE_NAME.test(source) || !isSubjectId(local)) throw new UsageError(`invalid decision ${JSON.stringify(input.to)}`);
|
|
1351
|
+
if (!(o.sources ?? []).includes(source)) throw new UsageError(`unknown source ${source}; add a [[sources]] entry named ${source}`);
|
|
1352
|
+
to = target;
|
|
1353
|
+
shown = target;
|
|
1354
|
+
described = `${rel} ${target}.`;
|
|
1355
|
+
named = `${view.display} to a decision in another log`;
|
|
1356
|
+
} else {
|
|
1357
|
+
to = resolveDecision(read, target);
|
|
1358
|
+
if (to === id) throw new UsageError("a decision can't link to itself");
|
|
1359
|
+
const t = viewOf(read, to);
|
|
1360
|
+
shown = t.display;
|
|
1361
|
+
described = `${rel} ${t.display}: ${sentenceTitle(t.title)}.`;
|
|
1362
|
+
named = `${view.display} to ${t.display}`;
|
|
1363
|
+
}
|
|
1364
|
+
if (view.links.some((l) => l.to === to && l.rel === rel)) return {
|
|
1365
|
+
ok: true,
|
|
1366
|
+
plan: {
|
|
1367
|
+
command: "event",
|
|
1368
|
+
subject: id,
|
|
1369
|
+
files: {},
|
|
1370
|
+
events: [],
|
|
1371
|
+
stage: [],
|
|
1372
|
+
involved: [id],
|
|
1373
|
+
commitScope: [],
|
|
1374
|
+
eventDirs: [],
|
|
1375
|
+
draft: null,
|
|
1376
|
+
commit: null,
|
|
1377
|
+
trailers: [],
|
|
1378
|
+
decision: view,
|
|
1379
|
+
affected: [],
|
|
1380
|
+
findings: [],
|
|
1381
|
+
notes: [`already linked: ${view.display} ${rel} ${shown}; nothing written`]
|
|
1382
|
+
}
|
|
1383
|
+
};
|
|
1384
|
+
const r = await planEvent(read, {
|
|
1385
|
+
subject: id,
|
|
1386
|
+
type: "linked",
|
|
1387
|
+
data: {
|
|
1388
|
+
to,
|
|
1389
|
+
rel
|
|
1390
|
+
},
|
|
1391
|
+
actor: input.actor
|
|
1392
|
+
}, c);
|
|
1393
|
+
if (!r.ok || r.plan.commit === null) return r;
|
|
1394
|
+
return {
|
|
1395
|
+
ok: true,
|
|
1396
|
+
plan: {
|
|
1397
|
+
...r.plan,
|
|
1398
|
+
commit: {
|
|
1399
|
+
...r.plan.commit,
|
|
1400
|
+
title: named,
|
|
1401
|
+
body: [described]
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
};
|
|
1405
|
+
}
|
|
1406
|
+
/** `ADR-0003 (k3f9q8a1)`, or the id alone when the decision has no number. */
|
|
1407
|
+
const namedView = (v) => v.display === v.id ? v.id : `${v.display} (${v.id})`;
|
|
1408
|
+
/** The globs of `globs` that match none of `files`. */
|
|
1409
|
+
function unmatchedGlobs(globs, files) {
|
|
1410
|
+
const matches = globMatcher(globs);
|
|
1411
|
+
const hit = new Set(files.flatMap((f) => matches(f)));
|
|
1412
|
+
return globs.filter((g) => !hit.has(g));
|
|
1413
|
+
}
|
|
1414
|
+
/**
|
|
1415
|
+
* Plans `adr scope` with a set operation (design §6.8): one `scoped` event with the whole new set
|
|
1416
|
+
* while proposed, accepted or deprecated, or nothing when the set doesn't change. Invalid globs,
|
|
1417
|
+
* conflicting operations and more than 200 globs are `UsageError`s. Notes name removed globs that
|
|
1418
|
+
* weren't there, added ones that were, resulting globs matching none of `o.tracked` (when given)
|
|
1419
|
+
* and a narrowed accepted scope.
|
|
1420
|
+
*/
|
|
1421
|
+
async function planScope(read, input, c, o = {}) {
|
|
1422
|
+
const id = resolveDecision(read, input.id);
|
|
1423
|
+
const view = viewOf(read, id);
|
|
1424
|
+
const result = applySetOp(view.applies_to, input.op, {
|
|
1425
|
+
what: "glob",
|
|
1426
|
+
validate: (g) => {
|
|
1427
|
+
const parsed = parseGlob(g);
|
|
1428
|
+
return parsed.ok ? null : parsed.message;
|
|
1429
|
+
},
|
|
1430
|
+
maxItems: 200,
|
|
1431
|
+
...input.names === void 0 ? {} : { names: input.names }
|
|
1432
|
+
});
|
|
1433
|
+
if (!adrKind.machine.allowed(view.status, "scoped")) return {
|
|
1434
|
+
ok: false,
|
|
1435
|
+
decision: view,
|
|
1436
|
+
findings: [refusal(read, "transition/illegal", illegal("scoped", view.status), id)]
|
|
1437
|
+
};
|
|
1438
|
+
const named = namedView(view);
|
|
1439
|
+
const notes = [...result.absent.map((g) => `${g} is not in the scope of ${named}`), ...result.present.map((g) => `${g} is already in the scope of ${named}`)];
|
|
1440
|
+
if (!result.changed) return {
|
|
1441
|
+
ok: true,
|
|
1442
|
+
plan: {
|
|
1443
|
+
command: "event",
|
|
1444
|
+
subject: id,
|
|
1445
|
+
files: {},
|
|
1446
|
+
events: [],
|
|
1447
|
+
stage: [],
|
|
1448
|
+
involved: [id],
|
|
1449
|
+
commitScope: [],
|
|
1450
|
+
eventDirs: [],
|
|
1451
|
+
draft: null,
|
|
1452
|
+
commit: null,
|
|
1453
|
+
trailers: [],
|
|
1454
|
+
decision: view,
|
|
1455
|
+
affected: [],
|
|
1456
|
+
findings: [],
|
|
1457
|
+
notes
|
|
1458
|
+
}
|
|
1459
|
+
};
|
|
1460
|
+
if (o.tracked !== void 0) notes.push(...unmatchedGlobs(result.set, o.tracked).map((g) => `${g} matches no tracked file`));
|
|
1461
|
+
if (result.removed.length > 0 && (view.status === "accepted" || view.status === "deprecated")) notes.push("this narrows the scope of an accepted decision; if its Scope section no longer reads true, propose a superseding decision instead");
|
|
1462
|
+
const r = await planEvent(read, {
|
|
1463
|
+
subject: id,
|
|
1464
|
+
type: "scoped",
|
|
1465
|
+
data: { set: [...result.set] },
|
|
1466
|
+
actor: input.actor
|
|
1467
|
+
}, c);
|
|
1468
|
+
return r.ok ? {
|
|
1469
|
+
ok: true,
|
|
1470
|
+
plan: {
|
|
1471
|
+
...r.plan,
|
|
1472
|
+
notes
|
|
1473
|
+
}
|
|
1474
|
+
} : r;
|
|
1475
|
+
}
|
|
1476
|
+
/**
|
|
1477
|
+
* Plans one `scoped` event per change, in one batch (design §6.8 step 4): each decision's status
|
|
1478
|
+
* must allow `scoped` and each event must validate, else the first failing decision is refused.
|
|
1479
|
+
* The plan has no commit message, because `--follow-renames` never commits. `decision` is
|
|
1480
|
+
* `input.id`'s view after the write, or `null` without one; `affected` are the changed views.
|
|
1481
|
+
*/
|
|
1482
|
+
async function planScopeEvents(read, changes, input, c) {
|
|
1483
|
+
const given = input.id === null ? null : viewOf(read, input.id);
|
|
1484
|
+
const events = [];
|
|
1485
|
+
for (const change of changes) {
|
|
1486
|
+
const view = viewOf(read, change.id);
|
|
1487
|
+
const refused = (findings) => ({
|
|
1488
|
+
ok: false,
|
|
1489
|
+
decision: given,
|
|
1490
|
+
findings
|
|
1491
|
+
});
|
|
1492
|
+
if (!adrKind.machine.allowed(view.status, "scoped")) return refused([refusal(read, "transition/illegal", illegal("scoped", view.status), change.id)]);
|
|
1493
|
+
const event = newEvent(read, c, {
|
|
1494
|
+
subject: change.id,
|
|
1495
|
+
type: "scoped",
|
|
1496
|
+
data: { set: [...change.set] },
|
|
1497
|
+
actor: input.actor
|
|
1498
|
+
});
|
|
1499
|
+
const valid = compiled.validate(event, { file: null });
|
|
1500
|
+
if (!valid.ok) return refused(valid.findings.map((f) => ({
|
|
1501
|
+
...f,
|
|
1502
|
+
subject: change.id,
|
|
1503
|
+
kind: adrKind.name
|
|
1504
|
+
})));
|
|
1505
|
+
events.push(event);
|
|
1506
|
+
}
|
|
1507
|
+
const files = filesOf(events, read);
|
|
1508
|
+
const { after, added } = await precheck(read, files);
|
|
1509
|
+
if (added.length > 0) return {
|
|
1510
|
+
ok: false,
|
|
1511
|
+
decision: given,
|
|
1512
|
+
findings: added
|
|
1513
|
+
};
|
|
1514
|
+
const involved = sorted(changes.map((ch) => ch.id));
|
|
1515
|
+
const { scope, eventDirs } = commitScope(read, involved);
|
|
1516
|
+
return {
|
|
1517
|
+
ok: true,
|
|
1518
|
+
plan: {
|
|
1519
|
+
command: "event",
|
|
1520
|
+
subject: input.id ?? involved[0] ?? "",
|
|
1521
|
+
files,
|
|
1522
|
+
events,
|
|
1523
|
+
stage: Object.keys(files),
|
|
1524
|
+
involved,
|
|
1525
|
+
commitScope: scope,
|
|
1526
|
+
eventDirs,
|
|
1527
|
+
draft: null,
|
|
1528
|
+
commit: null,
|
|
1529
|
+
trailers: pairsOf(events),
|
|
1530
|
+
decision: input.id === null ? null : viewOf(after, input.id),
|
|
1531
|
+
affected: changes.map((ch) => viewOf(after, ch.id)),
|
|
1532
|
+
findings: []
|
|
1533
|
+
}
|
|
1534
|
+
};
|
|
1535
|
+
}
|
|
1536
|
+
/** A plan that writes nothing: the views as they are, and `notes`. */
|
|
1537
|
+
function emptyPlan(subject, decision, notes) {
|
|
1538
|
+
return {
|
|
1539
|
+
ok: true,
|
|
1540
|
+
plan: {
|
|
1541
|
+
command: "event",
|
|
1542
|
+
subject,
|
|
1543
|
+
files: {},
|
|
1544
|
+
events: [],
|
|
1545
|
+
stage: [],
|
|
1546
|
+
involved: decision === null ? [] : [decision.id],
|
|
1547
|
+
commitScope: [],
|
|
1548
|
+
eventDirs: [],
|
|
1549
|
+
draft: null,
|
|
1550
|
+
commit: null,
|
|
1551
|
+
trailers: [],
|
|
1552
|
+
decision,
|
|
1553
|
+
affected: [],
|
|
1554
|
+
findings: [],
|
|
1555
|
+
notes
|
|
1556
|
+
}
|
|
1557
|
+
};
|
|
1558
|
+
}
|
|
1559
|
+
/**
|
|
1560
|
+
* Plans `adr scope [<decision>] --follow-renames` (design §6.8 steps 1–4): the given decision's
|
|
1561
|
+
* globs, or every decision's, rewritten by `followRenames`. Decisions whose status forbids
|
|
1562
|
+
* `scoped` are skipped, with a note when the renames would have changed their scope; an explicit
|
|
1563
|
+
* one is refused.
|
|
1564
|
+
*/
|
|
1565
|
+
async function planFollowRenames(read, input, staged, c) {
|
|
1566
|
+
const id = input.id === null ? null : resolveDecision(read, input.id);
|
|
1567
|
+
const given = id === null ? null : viewOf(read, id);
|
|
1568
|
+
if (staged.renames.length === 0) return emptyPlan(id ?? "", given, []);
|
|
1569
|
+
if (given !== null && !adrKind.machine.allowed(given.status, "scoped")) return {
|
|
1570
|
+
ok: false,
|
|
1571
|
+
decision: given,
|
|
1572
|
+
findings: [refusal(read, "transition/illegal", illegal("scoped", given.status), given.id)]
|
|
1573
|
+
};
|
|
1574
|
+
const views = given !== null ? [given] : [...read.projection.views.values()];
|
|
1575
|
+
const notes = [];
|
|
1576
|
+
const changes = [];
|
|
1577
|
+
for (const view of [...views].sort((a, b) => a.id < b.id ? -1 : 1)) {
|
|
1578
|
+
const result = followRenames({
|
|
1579
|
+
globs: view.applies_to,
|
|
1580
|
+
...staged
|
|
1581
|
+
});
|
|
1582
|
+
if (!result.changed) continue;
|
|
1583
|
+
const named = namedView(view);
|
|
1584
|
+
if (!adrKind.machine.allowed(view.status, "scoped")) {
|
|
1585
|
+
const covers = globMatcher(view.applies_to);
|
|
1586
|
+
const leaving = staged.renames.filter((r) => covers(r.from).length > 0).length;
|
|
1587
|
+
notes.push(`${named} is ${view.status}; its scope can't follow renames (${leaving} renamed files leave it)`);
|
|
1588
|
+
continue;
|
|
1589
|
+
}
|
|
1590
|
+
if (result.globs.length > 200) throw new UsageError(`following the renames gives ${named} ${result.globs.length} globs; the limit is 200`);
|
|
1591
|
+
for (const s of result.skipped) notes.push(`${s.to} can't be written as a scope glob (${s.problem}); ${named} no longer covers it`);
|
|
1592
|
+
for (const path of result.gained) notes.push(`${path} is now covered by ${named}'s scope but wasn't before the rename; check it`);
|
|
1593
|
+
changes.push({
|
|
1594
|
+
id: view.id,
|
|
1595
|
+
set: result.globs
|
|
1596
|
+
});
|
|
1597
|
+
}
|
|
1598
|
+
if (changes.length === 0) return emptyPlan(id ?? "", given, notes);
|
|
1599
|
+
const r = await planScopeEvents(read, changes, {
|
|
1600
|
+
id,
|
|
1601
|
+
actor: input.actor
|
|
1602
|
+
}, c);
|
|
1603
|
+
return r.ok ? {
|
|
1604
|
+
ok: true,
|
|
1605
|
+
plan: {
|
|
1606
|
+
...r.plan,
|
|
1607
|
+
notes
|
|
1608
|
+
}
|
|
1609
|
+
} : r;
|
|
1610
|
+
}
|
|
1611
|
+
/** The paths of every file in `tree`. */
|
|
1612
|
+
async function filePaths(tree) {
|
|
1613
|
+
return (await tree.list("")).filter((e) => e.type === "file").map((e) => e.path);
|
|
1614
|
+
}
|
|
1615
|
+
/** Every tracked file in the index, for the "matches no tracked file" notes. */
|
|
1616
|
+
async function trackedFiles(log) {
|
|
1617
|
+
return filePaths(await indexReader(log.git));
|
|
1618
|
+
}
|
|
1619
|
+
const isYaml = (path) => /\.ya?ml$/.test(path);
|
|
1620
|
+
/**
|
|
1621
|
+
* Applies a plan (§5.4): refuses a commit whose scope has unstaged or needed untracked paths,
|
|
1622
|
+
* writes the files (removing them again if a write fails), stages them, runs `afterStage`, and
|
|
1623
|
+
* commits `plan.stage` plus the involved subjects' already-staged files with exact trailers.
|
|
1624
|
+
*/
|
|
1625
|
+
async function applyPlan(log, plan, o) {
|
|
1626
|
+
const { git } = log;
|
|
1627
|
+
if (o.commit && o.message === void 0) throw new InvalidArgumentError("applyPlan needs a message to commit");
|
|
1628
|
+
const stage = new Set(plan.stage);
|
|
1629
|
+
if (o.commit) {
|
|
1630
|
+
const unstaged = new Set(await git.unstagedPaths(plan.commitScope));
|
|
1631
|
+
const dirs = new Set(plan.eventDirs);
|
|
1632
|
+
const untracked = /* @__PURE__ */ new Set([...await git.untrackedPaths(plan.commitScope.filter((p) => !dirs.has(p))), ...(await git.untrackedPaths(plan.eventDirs)).filter(isYaml)]);
|
|
1633
|
+
const [first] = sorted([...unstaged, ...untracked]).filter((p) => !stage.has(p));
|
|
1634
|
+
if (first !== void 0) throw new UsageError(unstaged.has(first) ? `${first} has unstaged changes; stage or discard them, or run without --commit` : `${first} is not tracked; git add it, or run without --commit`);
|
|
1635
|
+
}
|
|
1636
|
+
const eventPaths = new Set(plan.events.map((e) => eventPath(log.layout, e)));
|
|
1637
|
+
const written = [];
|
|
1638
|
+
try {
|
|
1639
|
+
for (const [path, bytes] of Object.entries(plan.files)) {
|
|
1640
|
+
if (eventPaths.has(path)) continue;
|
|
1641
|
+
await writeNewFile(git, path, bytes, { stage: false });
|
|
1642
|
+
written.push(path);
|
|
1643
|
+
}
|
|
1644
|
+
await writeEvents(git, log.layout, plan.events, {
|
|
1645
|
+
kind: log.compiled,
|
|
1646
|
+
stage: false
|
|
1647
|
+
});
|
|
1648
|
+
} catch (err) {
|
|
1649
|
+
const root = git.root ?? "";
|
|
1650
|
+
for (const path of written) await rm(join(root, path), { force: true });
|
|
1651
|
+
throw err;
|
|
1652
|
+
}
|
|
1653
|
+
await git.add(plan.stage);
|
|
1654
|
+
let findings = plan.findings;
|
|
1655
|
+
let commit = o.commit;
|
|
1656
|
+
if (o.afterStage !== void 0) {
|
|
1657
|
+
const after = await o.afterStage();
|
|
1658
|
+
findings = after.findings ?? plan.findings;
|
|
1659
|
+
commit = commit && after.commit;
|
|
1660
|
+
}
|
|
1661
|
+
let sha = null;
|
|
1662
|
+
if (commit) {
|
|
1663
|
+
const extra = (await git.stagedPaths(plan.commitScope)).filter((p) => !stage.has(p));
|
|
1664
|
+
const staged = extra.filter(isYaml).flatMap((p) => {
|
|
1665
|
+
const c = classifyPath(log.layout, p);
|
|
1666
|
+
const name = c.area === "event" && !c.file.includes("/") ? parseEventFileName(c.file) : null;
|
|
1667
|
+
return c.area === "event" && c.subject !== null && name !== null ? [["ADR", c.subject], ["ADR-Event", name.type]] : [];
|
|
1668
|
+
});
|
|
1669
|
+
sha = await git.commit({
|
|
1670
|
+
message: o.message ?? "",
|
|
1671
|
+
mergeTrailers: o.mergeTrailers === true,
|
|
1672
|
+
trailers: [...staged, ...plan.trailers],
|
|
1673
|
+
only: sorted([...plan.stage, ...extra])
|
|
1674
|
+
});
|
|
1675
|
+
}
|
|
1676
|
+
return {
|
|
1677
|
+
written: Object.keys(plan.files),
|
|
1678
|
+
commit: sha,
|
|
1679
|
+
findings
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1682
|
+
const DEFAULT_DEPS = {
|
|
1683
|
+
now: Date.now,
|
|
1684
|
+
apply: applyPlan
|
|
1685
|
+
};
|
|
1686
|
+
/**
|
|
1687
|
+
* The views `adr-write/v1` reports. Without a commit they are the plan's, folded on the overlay.
|
|
1688
|
+
* After a commit they come from a new read of the log, so a commit on the main ref shows the
|
|
1689
|
+
* numbers it assigned, as the CLI text and phase 3's MCP tools do. A commit that failed threw in
|
|
1690
|
+
* `applyPlan`, so nothing is read for it.
|
|
1691
|
+
*/
|
|
1692
|
+
async function viewsAfter(log, plan, sha) {
|
|
1693
|
+
if (sha === null) return {
|
|
1694
|
+
decision: plan.decision,
|
|
1695
|
+
affected: plan.affected
|
|
1696
|
+
};
|
|
1697
|
+
const read = await readDecisions({
|
|
1698
|
+
...log,
|
|
1699
|
+
commit: sha
|
|
1700
|
+
});
|
|
1701
|
+
const view = (v) => read.projection.views.get(v.id) ?? v;
|
|
1702
|
+
return {
|
|
1703
|
+
decision: plan.decision === null ? null : view(plan.decision),
|
|
1704
|
+
affected: plan.affected.map(view)
|
|
1705
|
+
};
|
|
1706
|
+
}
|
|
1707
|
+
const SHORTENED_NOTE = "commit subject shortened to 72 characters; the full title is in the commit body";
|
|
1708
|
+
/**
|
|
1709
|
+
* `--commit` needs an author and a committer identity; checked before anything is written, since
|
|
1710
|
+
* `GIT_AUTHOR_*` alone satisfies the author while `git commit` still fails on the committer.
|
|
1711
|
+
*/
|
|
1712
|
+
async function checkCommitIdentity(log) {
|
|
1713
|
+
if (await log.git.identity() === null || await log.git.committerIdentity() === null) throw new ConfigError("--commit needs a git identity; set git user.name and user.email");
|
|
1714
|
+
}
|
|
1715
|
+
/** The plan's commit message from `[commit] subject` (design §4), and whether it was shortened. */
|
|
1716
|
+
function commitMessage(log, command, plan) {
|
|
1717
|
+
const parts = plan.commit;
|
|
1718
|
+
if (parts === null) throw new InvalidArgumentError("this write can't be committed");
|
|
1719
|
+
const r = renderCommitMessage(log.adr.commitSubject, {
|
|
1720
|
+
title: parts.title,
|
|
1721
|
+
vars: {
|
|
1722
|
+
action: parts.action,
|
|
1723
|
+
id: parts.id,
|
|
1724
|
+
display: parts.display,
|
|
1725
|
+
command
|
|
1726
|
+
},
|
|
1727
|
+
body: parts.body
|
|
1728
|
+
});
|
|
1729
|
+
return {
|
|
1730
|
+
message: r.message,
|
|
1731
|
+
shortened: r.shortened
|
|
1732
|
+
};
|
|
1733
|
+
}
|
|
1734
|
+
/**
|
|
1735
|
+
* Plan, then apply, with the single `EventExistsError` retry (§5.4 steps 1–5). `o.message`
|
|
1736
|
+
* replaces the rendered commit message; `o.notes` come before the write's own notes. A plan with
|
|
1737
|
+
* no files and no events is reported as written without touching the repository, and so is
|
|
1738
|
+
* every plan with `dryRun` (its views are the plan's).
|
|
1739
|
+
*/
|
|
1740
|
+
async function write(log, command, plan, o, deps, afterStage) {
|
|
1741
|
+
if (log.ref !== null) throw new InvalidArgumentError("writes read the work tree; open the log without a ref");
|
|
1742
|
+
if (log.tree.label !== "worktree") throw new InvalidArgumentError("writes read the work tree; open the log with the worktree reader");
|
|
1743
|
+
const commit = o.commit ?? log.adr.autoCommit;
|
|
1744
|
+
if (commit) await checkCommitIdentity(log);
|
|
1745
|
+
const notes = [...log.notes, ...o.notes ?? []];
|
|
1746
|
+
for (let attempt = 0;; attempt++) {
|
|
1747
|
+
const read = await readDecisions(log);
|
|
1748
|
+
const ref = await templateHistoryRef(log.git, { type: "full" });
|
|
1749
|
+
const r = await plan(read, {
|
|
1750
|
+
now: deps.now,
|
|
1751
|
+
...deps.random === void 0 ? {} : { random: deps.random },
|
|
1752
|
+
templates: historyTemplates(log.git, log.tree, `${read.root}/templates`, { ref }),
|
|
1753
|
+
builtins: BUILTIN_TEMPLATES
|
|
1754
|
+
});
|
|
1755
|
+
if (!r.ok) return {
|
|
1756
|
+
format: "adr-write/v1",
|
|
1757
|
+
command,
|
|
1758
|
+
ok: false,
|
|
1759
|
+
decision: r.decision,
|
|
1760
|
+
affected: [],
|
|
1761
|
+
written: [],
|
|
1762
|
+
commit: null,
|
|
1763
|
+
findings: r.findings,
|
|
1764
|
+
notes
|
|
1765
|
+
};
|
|
1766
|
+
if (r.plan.events.length === 0 && Object.keys(r.plan.files).length === 0 || o.dryRun === true) return {
|
|
1767
|
+
format: "adr-write/v1",
|
|
1768
|
+
command,
|
|
1769
|
+
ok: true,
|
|
1770
|
+
decision: r.plan.decision,
|
|
1771
|
+
affected: r.plan.affected,
|
|
1772
|
+
written: [],
|
|
1773
|
+
commit: null,
|
|
1774
|
+
findings: r.plan.findings,
|
|
1775
|
+
notes: [...notes, ...r.plan.notes ?? []]
|
|
1776
|
+
};
|
|
1777
|
+
const rendered = commit ? commitMessage(log, command, r.plan) : null;
|
|
1778
|
+
const given = o.message ?? null;
|
|
1779
|
+
try {
|
|
1780
|
+
const applied = await deps.apply(log, r.plan, {
|
|
1781
|
+
commit,
|
|
1782
|
+
...rendered === null ? {} : {
|
|
1783
|
+
message: given ?? rendered.message,
|
|
1784
|
+
mergeTrailers: given !== null
|
|
1785
|
+
},
|
|
1786
|
+
...afterStage === void 0 ? {} : { afterStage: afterStage(r.plan, commit) }
|
|
1787
|
+
});
|
|
1788
|
+
const views = await viewsAfter(log, r.plan, applied.commit);
|
|
1789
|
+
const own = [...r.plan.notes ?? [], ...applied.commit !== null && given === null && rendered?.shortened === true ? [SHORTENED_NOTE] : []];
|
|
1790
|
+
return {
|
|
1791
|
+
format: "adr-write/v1",
|
|
1792
|
+
command,
|
|
1793
|
+
ok: true,
|
|
1794
|
+
decision: views.decision,
|
|
1795
|
+
affected: views.affected,
|
|
1796
|
+
written: applied.written,
|
|
1797
|
+
commit: applied.commit,
|
|
1798
|
+
findings: applied.findings,
|
|
1799
|
+
notes: [...notes, ...own]
|
|
1800
|
+
};
|
|
1801
|
+
} catch (err) {
|
|
1802
|
+
if (!(err instanceof EventExistsError) || attempt > 0) throw err;
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
const sameBytes = (a, b) => a.byteLength === b.byteLength && a.every((x, i) => x === b[i]);
|
|
1807
|
+
async function readIfPresent(path) {
|
|
1808
|
+
try {
|
|
1809
|
+
return await readFile(path);
|
|
1810
|
+
} catch (err) {
|
|
1811
|
+
if (err.code === "ENOENT") return null;
|
|
1812
|
+
throw err;
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
/**
|
|
1816
|
+
* The editor step (§5.4): runs `edit` on the staged draft, re-stages the edited body when the
|
|
1817
|
+
* editor exited 0, and skips the commit when committing would take content nobody confirmed: the
|
|
1818
|
+
* body changed while the editor failed, the body is gone, or another path in the commit scope (a
|
|
1819
|
+
* materialized template, say) changed while the editor was open (design review NIT 2). The
|
|
1820
|
+
* findings are the lint of the draft as it now is.
|
|
1821
|
+
*/
|
|
1822
|
+
function editorStep(log, edit, onNote) {
|
|
1823
|
+
return (plan, commitRequested) => async () => {
|
|
1824
|
+
const d = plan.draft;
|
|
1825
|
+
if (d === null) return {
|
|
1826
|
+
findings: null,
|
|
1827
|
+
commit: true
|
|
1828
|
+
};
|
|
1829
|
+
const abs = join(log.git.root ?? "", d.path);
|
|
1830
|
+
const note = await edit(abs);
|
|
1831
|
+
const bytes = await readIfPresent(abs);
|
|
1832
|
+
let commit;
|
|
1833
|
+
if (bytes === null) {
|
|
1834
|
+
commit = false;
|
|
1835
|
+
onNote?.(`${d.path} was removed while editing; the rendered draft is still staged. Restore it with \`git checkout -- ${d.path}\``);
|
|
1836
|
+
} else if (note === null) {
|
|
1837
|
+
await log.git.add([d.path]);
|
|
1838
|
+
commit = true;
|
|
1839
|
+
} else if (sameBytes(bytes, d.rendered)) commit = true;
|
|
1840
|
+
else {
|
|
1841
|
+
commit = false;
|
|
1842
|
+
if (commitRequested) onNote?.(`${d.path} changed while the editor failed; review it, then git add it and commit`);
|
|
1843
|
+
}
|
|
1844
|
+
if (commit && commitRequested) {
|
|
1845
|
+
const [first] = (await log.git.unstagedPaths(plan.commitScope)).filter((p) => p !== d.path);
|
|
1846
|
+
if (first !== void 0) {
|
|
1847
|
+
commit = false;
|
|
1848
|
+
onNote?.(`${first} changed while the editor was open; review it, then git add it and commit`);
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
const text = decoder.decode(bytes ?? d.rendered);
|
|
1852
|
+
const findings = lintDocument(d.template, text, {
|
|
1853
|
+
file: d.path,
|
|
1854
|
+
subject: plan.subject
|
|
1855
|
+
});
|
|
1856
|
+
if (commit && commitRequested && findings.length > 0) {
|
|
1857
|
+
commit = false;
|
|
1858
|
+
onNote?.(`${d.path} still has unfilled required sections; fill them in, then commit`);
|
|
1859
|
+
}
|
|
1860
|
+
return {
|
|
1861
|
+
findings,
|
|
1862
|
+
commit
|
|
1863
|
+
};
|
|
1864
|
+
};
|
|
1865
|
+
}
|
|
1866
|
+
/**
|
|
1867
|
+
* `adr new`: renders a draft, writes the body, any materialized template and the `proposed`
|
|
1868
|
+
* event, stages them, and commits with `commit` (default `[policy] auto_commit`).
|
|
1869
|
+
*
|
|
1870
|
+
* `edit` (CLI only; MCP never passes it) runs between staging and committing, on the absolute
|
|
1871
|
+
* body path. It resolves to `null` when the editor exited 0, else to a note for the user (spawn
|
|
1872
|
+
* error, exit status). `onNote` receives the notes about a skipped commit or a removed body,
|
|
1873
|
+
* without the "not committed: " prefix the CLI adds when a commit was requested.
|
|
1874
|
+
*
|
|
1875
|
+
* A draft with unfilled sections is never committed (design §7.2): without `edit` a commit is
|
|
1876
|
+
* refused (`ok: false`, nothing written); after `edit` the commit is skipped with a note.
|
|
1877
|
+
*/
|
|
1878
|
+
async function proposeDecision(log, input, o = {}, deps = DEFAULT_DEPS) {
|
|
1879
|
+
return write(log, "new", (read, c) => planPropose(read, input, c, { refuseIncomplete: (o.commit ?? log.adr.autoCommit) && o.edit === void 0 }), o, deps, o.edit === void 0 ? void 0 : editorStep(log, o.edit, o.onNote));
|
|
1880
|
+
}
|
|
1881
|
+
/**
|
|
1882
|
+
* `adr accept`: lints the draft, hashes the body, writes `accepted` and a `superseded` event on
|
|
1883
|
+
* each declared target, stages them with the body, and commits with `commit`. When the targets
|
|
1884
|
+
* have globs the decision lacks, `copyScope` decides whether a `scoped` event copies them
|
|
1885
|
+
* (default `"suggest"`: no copy, and a note with the `adr scope` command that would).
|
|
1886
|
+
*/
|
|
1887
|
+
async function acceptDecision(log, input, o = {}, deps = DEFAULT_DEPS) {
|
|
1888
|
+
const copyScope = o.copyScope ?? "suggest";
|
|
1889
|
+
let copy = null;
|
|
1890
|
+
return write(log, "accept", async (read, c) => {
|
|
1891
|
+
const plain = await planAccept(read, input, c);
|
|
1892
|
+
if (!plain.ok) return plain;
|
|
1893
|
+
const id = plain.plan.subject;
|
|
1894
|
+
const offer = scopeToCopy(read, id);
|
|
1895
|
+
if (offer.globs.length === 0) return plain;
|
|
1896
|
+
if (copy === null) copy = typeof copyScope === "function" ? await copyScope(offer) : copyScope === "copy";
|
|
1897
|
+
if (copy) return planAccept(read, input, c, { copyGlobs: offer.globs });
|
|
1898
|
+
if (copyScope === "skip") return plain;
|
|
1899
|
+
const notes = [...plain.plan.notes ?? [], copyScopeNote(id, offer)];
|
|
1900
|
+
return {
|
|
1901
|
+
ok: true,
|
|
1902
|
+
plan: {
|
|
1903
|
+
...plain.plan,
|
|
1904
|
+
notes
|
|
1905
|
+
}
|
|
1906
|
+
};
|
|
1907
|
+
}, o, deps);
|
|
1908
|
+
}
|
|
1909
|
+
/** `adr supersede <id> --by <by>`: writes `superseded`, stages it, and commits with `commit`. */
|
|
1910
|
+
async function supersedeDecision(log, input, o = {}, deps = DEFAULT_DEPS) {
|
|
1911
|
+
return write(log, "supersede", (read, c) => planSupersede(read, input, c), o, deps);
|
|
1912
|
+
}
|
|
1913
|
+
/**
|
|
1914
|
+
* `adr reject <id> --reason <text>`: writes `rejected`, stages it, and commits with `commit`. The
|
|
1915
|
+
* kind requires the reason; without one the write is refused (findings, nothing written).
|
|
1916
|
+
*/
|
|
1917
|
+
async function rejectDecision(log, input, o = {}, deps = DEFAULT_DEPS) {
|
|
1918
|
+
return write(log, "reject", (read, c) => planReject(read, input, c), o, deps);
|
|
1919
|
+
}
|
|
1920
|
+
/** `adr withdraw <id> [--reason <text>]`: writes `withdrawn`, stages it, and commits with `commit`. */
|
|
1921
|
+
async function withdrawDecision(log, input, o = {}, deps = DEFAULT_DEPS) {
|
|
1922
|
+
return write(log, "withdraw", (read, c) => planWithdraw(read, input, c), o, deps);
|
|
1923
|
+
}
|
|
1924
|
+
/**
|
|
1925
|
+
* `adr deprecate <id> --reason <text>`: writes `deprecated`, stages it, and commits with `commit`.
|
|
1926
|
+
* The kind requires the reason, as for `rejectDecision`.
|
|
1927
|
+
*/
|
|
1928
|
+
async function deprecateDecision(log, input, o = {}, deps = DEFAULT_DEPS) {
|
|
1929
|
+
return write(log, "deprecate", (read, c) => planDeprecate(read, input, c), o, deps);
|
|
1930
|
+
}
|
|
1931
|
+
/** `adr note <id> <text>`: writes `noted`, stages it, and commits with `commit`. */
|
|
1932
|
+
async function noteDecision(log, input, o = {}, deps = DEFAULT_DEPS) {
|
|
1933
|
+
return write(log, "note", (read, c) => planNote(read, input, c), o, deps);
|
|
1934
|
+
}
|
|
1935
|
+
/**
|
|
1936
|
+
* `adr link <id> <target> [--rel <rel>]`: writes `linked`, stages it, and commits with `commit`.
|
|
1937
|
+
* Qualified targets name the log's `[[sources]]`; a link that exists already writes nothing.
|
|
1938
|
+
*/
|
|
1939
|
+
async function linkDecision(log, input, o = {}, deps = DEFAULT_DEPS) {
|
|
1940
|
+
const sources = (log.config?.sources ?? []).map((s) => s.name);
|
|
1941
|
+
return write(log, "link", (read, c) => planLink(read, input, c, { sources }), o, deps);
|
|
1942
|
+
}
|
|
1943
|
+
/**
|
|
1944
|
+
* `adr scope <id> --set|--add|--remove …`: writes one `scoped` event with the new set, stages it,
|
|
1945
|
+
* and commits with `commit`. An unchanged set writes nothing.
|
|
1946
|
+
*/
|
|
1947
|
+
async function scopeDecision(log, input, o = {}, deps = DEFAULT_DEPS) {
|
|
1948
|
+
const tracked = await trackedFiles(log);
|
|
1949
|
+
return write(log, "scope", (read, c) => planScope(read, input, c, { tracked }), o, deps);
|
|
1950
|
+
}
|
|
1951
|
+
const FOLLOW_NO_COMMIT_NOTE = "--follow-renames never commits; commit the scoped events with the renames";
|
|
1952
|
+
/**
|
|
1953
|
+
* `adr scope [<decision>] --follow-renames` (design §6.8): rewrites the scope globs of the given
|
|
1954
|
+
* decision, or of every decision, after the renames staged against `HEAD`, and stages one
|
|
1955
|
+
* `scoped` event per changed decision. It never commits, whatever `[policy] auto_commit` says
|
|
1956
|
+
* (the events belong in the commit with the renames), so `o.commit: true` and a message are
|
|
1957
|
+
* `InvalidArgumentError`s. `dryRun` plans the same events and notes without writing anything.
|
|
1958
|
+
*/
|
|
1959
|
+
async function followScopeRenames(log, input, o = {}, deps = DEFAULT_DEPS) {
|
|
1960
|
+
if (o.commit === true) throw new InvalidArgumentError("followScopeRenames never commits; don't pass commit: true");
|
|
1961
|
+
if (o.message !== void 0 && o.message !== null) throw new InvalidArgumentError("followScopeRenames never commits, so it takes no message");
|
|
1962
|
+
const renames = await stagedRenames(log.git);
|
|
1963
|
+
const staged = renames.length === 0 ? {
|
|
1964
|
+
renames,
|
|
1965
|
+
before: [],
|
|
1966
|
+
after: []
|
|
1967
|
+
} : {
|
|
1968
|
+
renames,
|
|
1969
|
+
before: await filePaths(await refReader(log.git, "HEAD")),
|
|
1970
|
+
after: await filePaths(await indexReader(log.git))
|
|
1971
|
+
};
|
|
1972
|
+
const doc = await write(log, "scope", (read, c) => planFollowRenames(read, {
|
|
1973
|
+
id: input.id ?? null,
|
|
1974
|
+
actor: input.actor
|
|
1975
|
+
}, staged, c), {
|
|
1976
|
+
...o,
|
|
1977
|
+
commit: false
|
|
1978
|
+
}, deps);
|
|
1979
|
+
return log.adr.autoCommit && doc.ok && doc.affected.length > 0 ? {
|
|
1980
|
+
...doc,
|
|
1981
|
+
notes: [...doc.notes, FOLLOW_NO_COMMIT_NOTE]
|
|
1982
|
+
} : doc;
|
|
1983
|
+
}
|
|
1984
|
+
export { initDecisionLog as C, unavailableReason as S, parseAdrConfig as T, stateHeader as _, noteDecision as a, readDecisionsFrom as b, scopeDecision as c, withdrawDecision as d, adrChecks as f, showDecision as g, listDecisions as h, linkDecision as i, scopeToCopy as l, decisionsFor as m, deprecateDecision as n, proposeDecision as o, verifyDecisions as p, followScopeRenames as r, rejectDecision as s, acceptDecision as t, supersedeDecision as u, openDecisionLog as v, DEFAULT_ADR_CONFIG as w, resolveDecision as x, readDecisions as y };
|
|
1985
|
+
|
|
1986
|
+
//# sourceMappingURL=write-9IaqHRrY.js.map
|