@nikovirtala/adr 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -9
- package/dist/cli.js +195 -22
- package/dist/cli.js.map +1 -1
- package/dist/index.js +3 -3
- package/dist/{kind-BcHjCHvi.js → kind-DjvA3dyA.js} +2 -6
- package/dist/{kind-BcHjCHvi.js.map → kind-DjvA3dyA.js.map} +1 -1
- package/dist/kind.js +1 -1
- package/dist/{write-9IaqHRrY.js → scope-CLqH-q1y.js} +1439 -552
- package/dist/scope-CLqH-q1y.js.map +1 -0
- package/dist/types/cli/hooks.d.ts +6 -0
- package/dist/types/cli/index-command.d.ts +3 -0
- package/dist/types/cli/list.d.ts +1 -1
- package/dist/types/cli/open.d.ts +2 -0
- package/dist/types/cli/text.d.ts +6 -0
- package/dist/types/commit-actions.d.ts +72 -0
- package/dist/types/config.d.ts +7 -1
- package/dist/types/hooks.d.ts +59 -0
- package/dist/types/index.d.ts +3 -1
- package/dist/types/init.d.ts +7 -1
- package/dist/types/log-index.d.ts +117 -0
- package/dist/types/log.d.ts +34 -1
- package/dist/types/verify.d.ts +6 -4
- package/dist/types/views.d.ts +7 -3
- package/dist/types/write.d.ts +28 -10
- package/package.json +2 -2
- package/dist/write-9IaqHRrY.js.map +0 -1
|
@@ -1,30 +1,85 @@
|
|
|
1
|
-
import { t as adrKind } from "./kind-
|
|
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/
|
|
1
|
+
import { t as adrKind } from "./kind-DjvA3dyA.js";
|
|
2
|
+
import { CommitFailedError, ConfigError, ENVELOPE_VERSION, EventExistsError, FileExistsError, InvalidArgumentError, MemoryTree, UsageError, anchorCheck, applySetOp, assertNoHookConflicts, bodyFileName, bodySha256, classifyPath, commitWithRecovery, compareEvents, compileKind, diffTrees, escapeMarkdown, eventDir, eventPath, exitCode, finding, foldChecks, foldLog, followRenames, gitTemplates, globMatcher, governedChanges, historyTemplates, hookSnippet, indexCheck, indexReader, installHooks, invariantsCheck, isSubjectId, lintDocument, lintTemplate, loadFindings, loadLog, narrowedScopes, newSubjectId, nextEventIdentity, numberLog, openToolLog, overlayTree, parseCommitTemplate, parseEventFileName, parseGlob, parseTemplate, projectLog, projectNumbering, readCommitTemplate, readIndexNumbers, readToolConfig, refReader, relateCheck, renderCommitMessage, renderIndex, renderTemplate, resolveAttribution, resolveSubjectInput, saveCommitRecovery, scanAnchors, scopeGlobCheck, scopeLookup, serializeEvent, slugify, sortFindings, stagedRenames, templateHistoryRef, transitions, uninstallHooks, verifyLog, worktreeReader, writeEvents, writeIndexFile, writeNewFile } from "@nikovirtala/ledger";
|
|
3
|
+
import { access, constants, lstat, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { isAbsolute, join, posix, relative, resolve, sep } from "node:path";
|
|
5
|
+
// src/commit-actions.ts
|
|
6
|
+
/**
|
|
7
|
+
* `{action}` of a commit that writes one event of each type (design §4.3), and what the title slot
|
|
8
|
+
* names: the decision's title for the events that change whether it is decided, else its display.
|
|
9
|
+
*/
|
|
10
|
+
const ACTIONS = {
|
|
11
|
+
proposed: {
|
|
12
|
+
action: "propose to",
|
|
13
|
+
names: "title"
|
|
14
|
+
},
|
|
15
|
+
accepted: {
|
|
16
|
+
action: "decide to",
|
|
17
|
+
names: "title"
|
|
18
|
+
},
|
|
19
|
+
rejected: {
|
|
20
|
+
action: "decide not to",
|
|
21
|
+
names: "title"
|
|
22
|
+
},
|
|
23
|
+
withdrawn: {
|
|
24
|
+
action: "withdraw the proposal to",
|
|
25
|
+
names: "title"
|
|
26
|
+
},
|
|
27
|
+
deprecated: {
|
|
28
|
+
action: "deprecate",
|
|
29
|
+
names: "display"
|
|
30
|
+
},
|
|
31
|
+
superseded: {
|
|
32
|
+
action: "supersede",
|
|
33
|
+
names: "display"
|
|
34
|
+
},
|
|
35
|
+
noted: {
|
|
36
|
+
action: "add a note to",
|
|
37
|
+
names: "display"
|
|
38
|
+
},
|
|
39
|
+
linked: {
|
|
40
|
+
action: "link",
|
|
41
|
+
names: "display"
|
|
42
|
+
},
|
|
43
|
+
scoped: {
|
|
44
|
+
action: "set the scope of",
|
|
45
|
+
names: "display"
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
/** The longest `{action}`, which `[commit] subject` must leave room for. */
|
|
49
|
+
const LONGEST_ACTION = Math.max(...Object.values(ACTIONS).map((a) => a.action.length), 10);
|
|
50
|
+
/** What a link's title slot names after the display when the target is in another log. */
|
|
51
|
+
const CROSS_LOG_LINK = "to a decision in another log";
|
|
6
52
|
/** adr's commit-subject placeholders, each with its longest value (design §4.2). */
|
|
7
53
|
const COMMIT_VARS = Object.freeze({
|
|
8
|
-
action:
|
|
54
|
+
action: LONGEST_ACTION,
|
|
9
55
|
id: 8,
|
|
10
56
|
display: 13,
|
|
11
57
|
command: 9
|
|
12
58
|
});
|
|
13
|
-
/** The default `[commit] subject`. */
|
|
14
|
-
const DEFAULT_COMMIT_SUBJECT = "docs(adr): {action} {title}";
|
|
15
59
|
/**
|
|
16
60
|
* The names the title placeholder holds instead of a title, which are never shortened, each with
|
|
17
61
|
* 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)
|
|
19
|
-
* at most 13 characters) always fit in the
|
|
62
|
+
* `<display> to a decision in another log` (`planLink` in write.ts), and `adr index`'s
|
|
63
|
+
* `the decision index`. The shorter names (a display, at most 13 characters) always fit in the
|
|
64
|
+
* room a title gets.
|
|
20
65
|
*/
|
|
21
|
-
const COMMIT_NAMES = Object.freeze([
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
66
|
+
const COMMIT_NAMES = Object.freeze([
|
|
67
|
+
{
|
|
68
|
+
length: 32,
|
|
69
|
+
vars: { action: ACTIONS.superseded.action.length }
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
length: 42,
|
|
73
|
+
vars: { action: ACTIONS.linked.action.length }
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
length: 18,
|
|
77
|
+
vars: { action: 10 }
|
|
78
|
+
}
|
|
79
|
+
]);
|
|
80
|
+
// src/config.ts
|
|
81
|
+
/** The default `[commit] subject`. */
|
|
82
|
+
const DEFAULT_COMMIT_SUBJECT = "docs(adr): {action} {title}";
|
|
28
83
|
const DEFAULT_ADR_CONFIG = Object.freeze({
|
|
29
84
|
defaultTemplate: "nygard",
|
|
30
85
|
mainBranch: null,
|
|
@@ -33,6 +88,9 @@ const DEFAULT_ADR_CONFIG = Object.freeze({
|
|
|
33
88
|
autoCommit: false,
|
|
34
89
|
mcpCanDecide: false,
|
|
35
90
|
failOn: "error",
|
|
91
|
+
governedByInactive: "warning",
|
|
92
|
+
scopeFollowRenames: "stage",
|
|
93
|
+
hookIndex: "check",
|
|
36
94
|
commitSubject: parseCommitTemplate(DEFAULT_COMMIT_SUBJECT, {
|
|
37
95
|
vars: COMMIT_VARS,
|
|
38
96
|
where: "commit.subject",
|
|
@@ -66,6 +124,7 @@ function parseAdrConfig(config, file) {
|
|
|
66
124
|
const policy = table("policy");
|
|
67
125
|
const verify = table("verify");
|
|
68
126
|
const scope = table("scope");
|
|
127
|
+
const hooks = table("hooks");
|
|
69
128
|
const bool = (t, section, key, fallback) => {
|
|
70
129
|
const v = t[key];
|
|
71
130
|
if (v === void 0) return fallback;
|
|
@@ -101,6 +160,13 @@ function parseAdrConfig(config, file) {
|
|
|
101
160
|
autoCommit: bool(policy, "policy", "auto_commit", false),
|
|
102
161
|
mcpCanDecide: bool(policy, "policy", "mcp_can_decide", false),
|
|
103
162
|
failOn: oneOf(verify, "verify", "fail_on", ["error", "warning"], "error"),
|
|
163
|
+
governedByInactive: oneOf(verify, "verify", "governed_by_inactive", ["warning", "error"], "warning"),
|
|
164
|
+
scopeFollowRenames: oneOf(hooks, "hooks", "scope_follow_renames", [
|
|
165
|
+
"stage",
|
|
166
|
+
"warn",
|
|
167
|
+
"off"
|
|
168
|
+
], "stage"),
|
|
169
|
+
hookIndex: oneOf(hooks, "hooks", "index", ["check", "stage"], "check"),
|
|
104
170
|
commitSubject: readCommitTemplate(config, {
|
|
105
171
|
file: where,
|
|
106
172
|
vars: COMMIT_VARS,
|
|
@@ -111,253 +177,50 @@ function parseAdrConfig(config, file) {
|
|
|
111
177
|
anchorExclude: Array.isArray(exclude) ? exclude : []
|
|
112
178
|
};
|
|
113
179
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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");
|
|
180
|
+
// src/log.ts
|
|
181
|
+
/**
|
|
182
|
+
* Opens the decision log (design D3): `--root`, else `adr.toml`'s `[log] root`, else `docs/adr`;
|
|
183
|
+
* read from the `ref` tree, the index or the work tree.
|
|
184
|
+
*/
|
|
185
|
+
async function openDecisionLog(o) {
|
|
186
|
+
const log = await openToolLog({
|
|
187
|
+
...o,
|
|
188
|
+
tool: "adr",
|
|
189
|
+
kind: adrKind
|
|
190
|
+
});
|
|
191
|
+
return {
|
|
192
|
+
...log,
|
|
193
|
+
adr: parseAdrConfig(log.config, log.configPath)
|
|
194
|
+
};
|
|
238
195
|
}
|
|
239
196
|
/**
|
|
240
|
-
* `
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
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.
|
|
197
|
+
* `numberLog` on the repository, then `readDecisionsFrom(log.tree, …)`. In a shallow clone of a
|
|
198
|
+
* log with `[log] index = "committed"`, the numbers come from `<root>/index.md` in the commit
|
|
199
|
+
* being read (`--ref`, else HEAD, resolved when the numbers are needed), never from the work tree's
|
|
200
|
+
* copy, which may number acceptances that exist only there (design 2b D1, §4.6).
|
|
246
201
|
*/
|
|
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
202
|
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
203
|
return readDecisionsFrom(log.tree, {
|
|
353
204
|
layout: log.layout,
|
|
354
205
|
adr: log.adr,
|
|
355
|
-
numbering,
|
|
206
|
+
numbering: await numberDecisions(log),
|
|
356
207
|
ref: log.ref,
|
|
357
208
|
commit: log.commit,
|
|
358
209
|
notes: log.notes
|
|
359
210
|
});
|
|
360
211
|
}
|
|
212
|
+
/** The display numbers `readDecisions` uses, with its shallow-clone fallback to `index.md`. */
|
|
213
|
+
async function numberDecisions(log) {
|
|
214
|
+
return numberLog(log.git, {
|
|
215
|
+
kind: adrKind,
|
|
216
|
+
layout: log.layout,
|
|
217
|
+
mainRef: log.adr.mainBranch,
|
|
218
|
+
...log.adr.index === "committed" ? { fallback: async () => {
|
|
219
|
+
const c = await log.git.resolveCommit(log.ref ?? "HEAD");
|
|
220
|
+
return c === null ? null : readIndexNumbers(await refReader(log.git, c), `${log.layout.root}/index.md`);
|
|
221
|
+
} } : {}
|
|
222
|
+
});
|
|
223
|
+
}
|
|
361
224
|
const compiled$1 = compileKind(adrKind);
|
|
362
225
|
/** The git-free core of `readDecisions`; tests pass a MemoryTree and a hand-made `Numbering`. */
|
|
363
226
|
async function readDecisionsFrom(tree, o) {
|
|
@@ -405,6 +268,16 @@ async function readDecisionsFrom(tree, o) {
|
|
|
405
268
|
notes: o.notes ?? []
|
|
406
269
|
};
|
|
407
270
|
}
|
|
271
|
+
/**
|
|
272
|
+
* The note a read command adds about where numbers come from: the shallow-clone index, or why
|
|
273
|
+
* they are unavailable (`unavailable: false` leaves that one out); `null` for none.
|
|
274
|
+
*/
|
|
275
|
+
function numberingNote(read, o) {
|
|
276
|
+
const n = read.numbering;
|
|
277
|
+
if (n.reason === "index") return `numbers come from ${read.root}/index.md (shallow clone)`;
|
|
278
|
+
if (n.available || !o.unavailable) return null;
|
|
279
|
+
return n.reason === "shallow" ? "numbers are unavailable (shallow clone); run git fetch --unshallow, or commit an index with [log] index = \"committed\"" : `numbers are unavailable (${unavailableReason(n)}); decisions display by id`;
|
|
280
|
+
}
|
|
408
281
|
/** Why numbers are unavailable, in words: `shallow clone` or `no main branch`. */
|
|
409
282
|
function unavailableReason(n) {
|
|
410
283
|
return n.reason === "shallow" ? "shallow clone" : "no main branch";
|
|
@@ -428,269 +301,440 @@ function resolveDecision(read, input) {
|
|
|
428
301
|
case "not-found": throw new UsageError(`unknown decision ${input.trim()}`);
|
|
429
302
|
}
|
|
430
303
|
}
|
|
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
304
|
/**
|
|
449
|
-
* The
|
|
450
|
-
*
|
|
305
|
+
* The event files in `--ref`'s tree (else HEAD's) that `rev`'s tree lacks (design 2b D7). Diffing
|
|
306
|
+
* trees answers "what did this pull bring" for fast-forwards and merges (`ORIG_HEAD` is the tip
|
|
307
|
+
* before them), and lists nothing for a rebase of your own work, whose events are in both trees.
|
|
308
|
+
* Throws `RefNotFoundError` when either revision doesn't resolve.
|
|
451
309
|
*/
|
|
452
|
-
function
|
|
453
|
-
const
|
|
454
|
-
const
|
|
455
|
-
const
|
|
310
|
+
async function changedSince(log, rev) {
|
|
311
|
+
const base = await refReader(log.git, rev);
|
|
312
|
+
const head = await refReader(log.git, log.ref ?? "HEAD");
|
|
313
|
+
const events = (await diffTrees(base, head, `${log.layout.root}/${log.layout.events}`)).flatMap((c) => {
|
|
314
|
+
if (c.status !== "added") return [];
|
|
315
|
+
const p = classifyPath(log.layout, c.path);
|
|
316
|
+
if (p.area !== "event" || p.subject === null || p.file.includes("/")) return [];
|
|
317
|
+
const name = parseEventFileName(p.file);
|
|
318
|
+
return name === null ? [] : [{
|
|
319
|
+
subject: p.subject,
|
|
320
|
+
type: name.type,
|
|
321
|
+
path: c.path
|
|
322
|
+
}];
|
|
323
|
+
});
|
|
324
|
+
events.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
456
325
|
return {
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
|
326
|
+
rev,
|
|
327
|
+
commit: base.commit ?? "",
|
|
328
|
+
events
|
|
467
329
|
};
|
|
468
330
|
}
|
|
469
|
-
|
|
331
|
+
// src/log-index.ts
|
|
470
332
|
/**
|
|
471
|
-
*
|
|
472
|
-
*
|
|
333
|
+
* What the recovery hint says about rerunning `adr index --commit` after `git commit` failed
|
|
334
|
+
* (design 2b D5). It names the file, not the path, which the hint lists above it.
|
|
473
335
|
*/
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
336
|
+
const INDEX_RERUN = "You can also run adr index --commit again once git accepts the commit; it commits index.md while it differs from HEAD's.";
|
|
337
|
+
/**
|
|
338
|
+
* The step before the manual commit when a write's second index render failed after staging
|
|
339
|
+
* (design 2b §4.5 step 4).
|
|
340
|
+
*/
|
|
341
|
+
const INDEX_BEFORE = (root) => `Run adr index --no-commit first; it writes and stages ${root}/index.md from the work tree. If other decisions in ${root} have uncommitted changes, the work tree's index includes them; append their paths after -- in the command below, so the commit matches the index.`;
|
|
342
|
+
/**
|
|
343
|
+
* The step after the manual commit when the commit carried the index: the commit's render leaves
|
|
344
|
+
* out other decisions' uncommitted entries (design 2b D5, §4.5 step 5).
|
|
345
|
+
*/
|
|
346
|
+
const INDEX_AFTER = (root) => `Then run adr index --no-commit to restore other decisions' entries in ${root}/index.md.`;
|
|
347
|
+
/** The repo-relative path of a log's generated index. */
|
|
348
|
+
const indexPath = (layout) => `${layout.root}/index.md`;
|
|
349
|
+
const GROUPS = [
|
|
350
|
+
{
|
|
351
|
+
heading: "Accepted",
|
|
352
|
+
statuses: ["accepted"]
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
heading: "Proposed",
|
|
356
|
+
statuses: ["proposed"]
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
heading: "Superseded and deprecated",
|
|
360
|
+
statuses: ["superseded", "deprecated"]
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
heading: "Rejected and withdrawn",
|
|
364
|
+
statuses: ["rejected", "withdrawn"]
|
|
480
365
|
}
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
366
|
+
];
|
|
367
|
+
const cmp = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
368
|
+
/**
|
|
369
|
+
* adr's index spec (01:209: active decisions first), built per render so that a superseded
|
|
370
|
+
* decision's status can name its successor's display from `views`.
|
|
371
|
+
*/
|
|
372
|
+
const DECISION_INDEX = (root, views) => ({
|
|
373
|
+
tool: "adr",
|
|
374
|
+
title: "Architecture decisions",
|
|
375
|
+
groups: GROUPS.map((g) => ({
|
|
376
|
+
heading: g.heading,
|
|
377
|
+
filter: (v) => g.statuses.includes(v.status)
|
|
378
|
+
})),
|
|
379
|
+
columns: [
|
|
380
|
+
{
|
|
381
|
+
heading: "Decision",
|
|
382
|
+
cell: (v) => escapeMarkdown(v.display)
|
|
383
|
+
},
|
|
384
|
+
{
|
|
385
|
+
heading: "Title",
|
|
386
|
+
cell: (v) => v.body_path === null ? escapeMarkdown(v.title) : `[${escapeMarkdown(v.title)}](${encodeURI(posix.relative(root, v.body_path))})`
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
heading: "Status",
|
|
390
|
+
cell: (v) => v.status === "superseded" && v.superseded_by !== null ? `superseded by ${escapeMarkdown(views.get(v.superseded_by)?.display ?? v.superseded_by)}` : v.status
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
heading: "Since",
|
|
394
|
+
cell: (v) => v.status_at.slice(0, 10)
|
|
395
|
+
}
|
|
396
|
+
],
|
|
397
|
+
order: (a, b) => (a.number ?? Number.POSITIVE_INFINITY) - (b.number ?? Number.POSITIVE_INFINITY) || cmp(a.status_at, b.status_at) || cmp(a.id, b.id)
|
|
398
|
+
});
|
|
399
|
+
/**
|
|
400
|
+
* The index `tree` must hold: its decisions, numbered as they will be once the commit holding
|
|
401
|
+
* `tree` (which builds on `history`: HEAD, a commit, or null on an unborn HEAD) is on main
|
|
402
|
+
* (design 2b D2), with `[log] main_branch`'s numbers first. A tree that isn't a commit's (the work
|
|
403
|
+
* tree, the index) also merges `MERGE_HEAD` while a merge is in progress. In a shallow clone the
|
|
404
|
+
* numbers continue from those `history`'s index recorded; `null` when there are none, so the
|
|
405
|
+
* numbers can't be projected.
|
|
406
|
+
*/
|
|
407
|
+
async function renderDecisionIndex(git, o) {
|
|
408
|
+
const { layout, history } = o;
|
|
409
|
+
const mergeHead = o.tree.commit === null && history !== null ? await git.resolveCommit("MERGE_HEAD") : null;
|
|
410
|
+
const numbering = await projectNumbering(git, {
|
|
411
|
+
kind: adrKind,
|
|
412
|
+
layout,
|
|
413
|
+
history,
|
|
414
|
+
tree: o.tree,
|
|
415
|
+
mainRef: o.adr.mainBranch,
|
|
416
|
+
merging: mergeHead === null ? [] : [mergeHead],
|
|
417
|
+
recorded: async () => history === null ? null : readIndexNumbers(await refReader(git, history), indexPath(layout))
|
|
418
|
+
});
|
|
419
|
+
if (!numbering.available) return null;
|
|
420
|
+
const views = (await readDecisionsFrom(o.tree, {
|
|
421
|
+
layout,
|
|
422
|
+
adr: o.adr,
|
|
423
|
+
numbering
|
|
424
|
+
})).projection.views;
|
|
425
|
+
return renderIndex(DECISION_INDEX(layout.root, views), [...views.values()], numbering);
|
|
506
426
|
}
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
427
|
+
/** At most this many paths are named in the unstaged-files `index/stale` message. */
|
|
428
|
+
const UNSTAGED_NAMED = 5;
|
|
429
|
+
/**
|
|
430
|
+
* What the unstaged-files `index/stale` finding and the matching write note both start with:
|
|
431
|
+
* `path` lists `files` (some of them), which aren't staged.
|
|
432
|
+
*/
|
|
433
|
+
function unstagedIndexText(path, files) {
|
|
434
|
+
return `${path} lists decision files that aren't staged (${files.slice(0, UNSTAGED_NAMED).join(", ")}${files.length > UNSTAGED_NAMED ? ", …" : ""})`;
|
|
512
435
|
}
|
|
513
436
|
/**
|
|
514
|
-
* The
|
|
515
|
-
*
|
|
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.
|
|
437
|
+
* The log's event and body files whose work-tree content isn't staged (untracked or modified),
|
|
438
|
+
* sorted: the cause of a staged `index.md` that only the work tree's render matches.
|
|
518
439
|
*/
|
|
519
|
-
function
|
|
520
|
-
const
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
440
|
+
async function unstagedLogFiles(git, layout) {
|
|
441
|
+
const paths = [...await git.unstagedPaths([layout.root]), ...await git.untrackedPaths([layout.root])];
|
|
442
|
+
return [...new Set(paths)].filter((p) => {
|
|
443
|
+
const area = classifyPath(layout, p).area;
|
|
444
|
+
return area === "event" || area === "body";
|
|
445
|
+
}).sort();
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* The verify check that `<root>/index.md` is current (design 2b §4.5, "Freshness in verify"). In
|
|
449
|
+
* staged mode (the pre-commit hook), a stale staged copy that equals the work tree's render
|
|
450
|
+
* means another decision's files aren't staged: writes stage the work tree's render (D2), and
|
|
451
|
+
* `adr index` would change nothing. The finding then names those files and says to stage them.
|
|
452
|
+
*/
|
|
453
|
+
function decisionIndexCheck(log) {
|
|
454
|
+
const path = indexPath(log.layout);
|
|
455
|
+
const check = indexCheck({
|
|
456
|
+
path,
|
|
457
|
+
fix: "run adr index",
|
|
458
|
+
async expected(ctx) {
|
|
459
|
+
const head = ctx.repo?.head;
|
|
460
|
+
const history = head === void 0 ? null : typeof head === "object" ? head.commit : await log.git.resolveCommit("HEAD");
|
|
461
|
+
return renderDecisionIndex(log.git, {
|
|
462
|
+
layout: log.layout,
|
|
463
|
+
adr: log.adr,
|
|
464
|
+
tree: ctx.head.tree,
|
|
465
|
+
history
|
|
466
|
+
});
|
|
534
467
|
}
|
|
535
|
-
|
|
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.");
|
|
468
|
+
});
|
|
544
469
|
return {
|
|
545
|
-
|
|
546
|
-
|
|
470
|
+
id: check.id,
|
|
471
|
+
async run(ctx) {
|
|
472
|
+
const findings = await check.run(ctx);
|
|
473
|
+
const stale = findings.find((f) => f.rule === "index/stale");
|
|
474
|
+
if (stale === void 0 || ctx.repo?.head !== "index") return findings;
|
|
475
|
+
const files = await unstagedLogFiles(log.git, log.layout);
|
|
476
|
+
if (files.length === 0) return findings;
|
|
477
|
+
const worktree = await renderDecisionIndex(log.git, {
|
|
478
|
+
layout: log.layout,
|
|
479
|
+
adr: log.adr,
|
|
480
|
+
tree: worktreeReader(log.git),
|
|
481
|
+
history: await log.git.resolveCommit("HEAD")
|
|
482
|
+
});
|
|
483
|
+
if (worktree === null || !holdsText(await ctx.head.tree.read(path), worktree)) return findings;
|
|
484
|
+
const message = `${unstagedIndexText(path, files)}; git add them and commit again, or move them out of the work tree and run adr index`;
|
|
485
|
+
return findings.map((f) => f === stale ? {
|
|
486
|
+
...f,
|
|
487
|
+
message
|
|
488
|
+
} : f);
|
|
489
|
+
}
|
|
547
490
|
};
|
|
548
491
|
}
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
const
|
|
554
|
-
|
|
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;
|
|
492
|
+
const encoder$2 = new TextEncoder();
|
|
493
|
+
/** Whether `bytes` (null: absent) hold exactly `text`. */
|
|
494
|
+
function holdsText(bytes, text) {
|
|
495
|
+
if (bytes === null) return false;
|
|
496
|
+
const want = encoder$2.encode(text);
|
|
497
|
+
return bytes.byteLength === want.byteLength && bytes.every((x, i) => x === want[i]);
|
|
577
498
|
}
|
|
578
|
-
/**
|
|
579
|
-
async function
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
499
|
+
/** HEAD's copy of `path`; `null` when it is absent or HEAD is unborn. */
|
|
500
|
+
async function headBytes(git, path) {
|
|
501
|
+
const head = await git.resolveCommit("HEAD");
|
|
502
|
+
return head === null ? null : (await refReader(git, head)).read(path);
|
|
503
|
+
}
|
|
504
|
+
/** HEAD's tree, or an empty tree on an unborn HEAD. */
|
|
505
|
+
async function headTree(git, head) {
|
|
506
|
+
return head === null ? new MemoryTree({}) : refReader(git, head);
|
|
585
507
|
}
|
|
586
508
|
/**
|
|
587
|
-
*
|
|
588
|
-
*
|
|
589
|
-
*
|
|
590
|
-
* repository are `UsageError`s. Paths need not be tracked.
|
|
509
|
+
* Brings the work-tree copy of `path` to `text` (atomically, when it differs) and reports whether
|
|
510
|
+
* the staged copy differs, so the caller stages it. `old` is the work-tree copy before the write
|
|
511
|
+
* (`null` when absent), for rollback.
|
|
591
512
|
*/
|
|
592
|
-
async function
|
|
593
|
-
const
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
index ??= indexReader(log.git);
|
|
599
|
-
return index;
|
|
513
|
+
async function writeIndexCopy(git, path, text) {
|
|
514
|
+
const old = await worktreeReader(git).read(path);
|
|
515
|
+
return {
|
|
516
|
+
wrote: !holdsText(old, text) && await writeIndexFile(git, path, text),
|
|
517
|
+
old,
|
|
518
|
+
stage: !holdsText(await (await indexReader(git)).read(path), text)
|
|
600
519
|
};
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
520
|
+
}
|
|
521
|
+
/** The first line of an error's message, for notes and headlines. */
|
|
522
|
+
const firstLine = (err) => (err instanceof Error ? err.message : String(err)).split("\n")[0] ?? "";
|
|
523
|
+
/**
|
|
524
|
+
* `adr index` (design 2b §4.5) with what the text output needs; `indexDecisionLog` is the
|
|
525
|
+
* library entry.
|
|
526
|
+
*/
|
|
527
|
+
async function runDecisionIndex(log, o = {}) {
|
|
528
|
+
const path = indexPath(log.layout);
|
|
529
|
+
const root = log.layout.root;
|
|
530
|
+
const check = o.check === true;
|
|
531
|
+
const notes = [...log.notes, ...o.notes ?? []];
|
|
532
|
+
if (log.adr.index !== "committed") throw new UsageError("adr index needs [log] index = \"committed\" in adr.toml");
|
|
533
|
+
if (check) {
|
|
534
|
+
if (o.commit === true || o.message !== void 0 && o.message !== null) throw new UsageError("--check writes nothing; drop --commit and --message");
|
|
535
|
+
const findings = await verifyLog({
|
|
536
|
+
git: log.git,
|
|
537
|
+
kind: adrKind,
|
|
538
|
+
layout: log.layout,
|
|
539
|
+
mode: {
|
|
540
|
+
type: "full",
|
|
541
|
+
...log.ref === null ? {} : { head: log.ref }
|
|
542
|
+
},
|
|
543
|
+
checks: [decisionIndexCheck(log)]
|
|
544
|
+
});
|
|
545
|
+
return {
|
|
546
|
+
doc: {
|
|
547
|
+
format: "adr-index/v1",
|
|
548
|
+
path,
|
|
549
|
+
check: true,
|
|
550
|
+
fresh: findings.length === 0,
|
|
551
|
+
written: [],
|
|
552
|
+
commit: null,
|
|
553
|
+
findings,
|
|
554
|
+
notes
|
|
555
|
+
},
|
|
556
|
+
refreshed: false
|
|
557
|
+
};
|
|
612
558
|
}
|
|
613
|
-
|
|
614
|
-
const
|
|
615
|
-
const
|
|
616
|
-
|
|
617
|
-
const
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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
|
|
559
|
+
if (log.ref !== null || log.tree.label !== "worktree") throw new UsageError("adr index writes the work tree; use --ref only with --check");
|
|
560
|
+
const { git } = log;
|
|
561
|
+
const commit = o.commit ?? log.adr.autoCommit;
|
|
562
|
+
if (commit && (await git.identity() === null || await git.committerIdentity() === null)) throw new ConfigError("--commit needs a git identity; set git user.name and user.email");
|
|
563
|
+
const head = await git.resolveCommit("HEAD");
|
|
564
|
+
const render = (tree, history) => renderDecisionIndex(git, {
|
|
565
|
+
layout: log.layout,
|
|
566
|
+
adr: log.adr,
|
|
567
|
+
tree,
|
|
568
|
+
history
|
|
631
569
|
});
|
|
632
|
-
const
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
const
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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);
|
|
570
|
+
const shallow = () => new UsageError("numbers need history in this shallow clone; run git fetch --unshallow");
|
|
571
|
+
let written = false;
|
|
572
|
+
const place = async (text) => {
|
|
573
|
+
const r = await writeIndexCopy(git, path, text);
|
|
574
|
+
if (r.stage) await git.add([path]);
|
|
575
|
+
written ||= r.wrote;
|
|
576
|
+
return r.wrote || r.stage;
|
|
577
|
+
};
|
|
578
|
+
if (!commit) {
|
|
579
|
+
const text = await render(worktreeReader(git), head);
|
|
580
|
+
if (text === null) throw shallow();
|
|
657
581
|
return {
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
unresolved
|
|
582
|
+
doc: {
|
|
583
|
+
format: "adr-index/v1",
|
|
584
|
+
path,
|
|
585
|
+
check: false,
|
|
586
|
+
fresh: !await place(text),
|
|
587
|
+
written: written ? [path] : [],
|
|
588
|
+
commit: null,
|
|
589
|
+
findings: [],
|
|
590
|
+
notes
|
|
591
|
+
},
|
|
592
|
+
refreshed: false
|
|
670
593
|
};
|
|
671
|
-
}
|
|
594
|
+
}
|
|
595
|
+
const text = await render(await headTree(git, head), head);
|
|
596
|
+
if (text === null) throw shallow();
|
|
597
|
+
const fresh = holdsText(await headBytes(git, path), text);
|
|
598
|
+
let sha = null;
|
|
599
|
+
if (!fresh) {
|
|
600
|
+
await place(text);
|
|
601
|
+
const message = o.message ?? renderCommitMessage(log.adr.commitSubject, {
|
|
602
|
+
title: "the decision index",
|
|
603
|
+
vars: {
|
|
604
|
+
action: "regenerate",
|
|
605
|
+
id: "index.md",
|
|
606
|
+
display: "index.md",
|
|
607
|
+
command: "index"
|
|
608
|
+
}
|
|
609
|
+
}).message;
|
|
610
|
+
sha = await commitWithRecovery(git, {
|
|
611
|
+
message,
|
|
612
|
+
only: [path],
|
|
613
|
+
rerun: INDEX_RERUN,
|
|
614
|
+
after: INDEX_AFTER(root)
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
let refreshed = false;
|
|
618
|
+
try {
|
|
619
|
+
const again = await render(worktreeReader(git), sha ?? head);
|
|
620
|
+
if (again !== null) refreshed = await place(again);
|
|
621
|
+
} catch (err) {
|
|
622
|
+
notes.push(`${path} may be stale: ${firstLine(err)}; run adr index`);
|
|
623
|
+
}
|
|
672
624
|
return {
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
625
|
+
doc: {
|
|
626
|
+
format: "adr-index/v1",
|
|
627
|
+
path,
|
|
628
|
+
check: false,
|
|
629
|
+
fresh,
|
|
630
|
+
written: written ? [path] : [],
|
|
631
|
+
commit: sha,
|
|
632
|
+
findings: [],
|
|
633
|
+
notes
|
|
634
|
+
},
|
|
635
|
+
refreshed
|
|
681
636
|
};
|
|
682
637
|
}
|
|
638
|
+
/**
|
|
639
|
+
* `adr index [--check] [--commit | --no-commit] [-m <message>]` (design 2b §4.5): renders
|
|
640
|
+
* `<root>/index.md` and writes and stages it where the work tree or the index differ. With
|
|
641
|
+
* `commit` (default `[policy] auto_commit`) it renders HEAD's tree and commits the file alone when
|
|
642
|
+
* HEAD's copy differs, then refreshes the work tree's copy, which may include other decisions'
|
|
643
|
+
* uncommitted entries. `check` writes nothing and reports `index/*` findings.
|
|
644
|
+
*/
|
|
645
|
+
async function indexDecisionLog(log, o = {}) {
|
|
646
|
+
return (await runDecisionIndex(log, o)).doc;
|
|
647
|
+
}
|
|
683
648
|
// src/verify.ts
|
|
649
|
+
/** At most this many paths are named in a `decision/scope-narrowed` message. */
|
|
650
|
+
const NARROWED_PATHS = 3;
|
|
651
|
+
/** The head's decision views with the read's numbering, for the displays in messages. */
|
|
652
|
+
async function headViews(log, ctx) {
|
|
653
|
+
const head = ctx.folded?.head;
|
|
654
|
+
if (head === void 0) return /* @__PURE__ */ new Map();
|
|
655
|
+
return projectLog(adrKind, head, { numbering: await numberDecisions(log) }).views;
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* `decision/governed-by-inactive` (`[verify] governed_by_inactive`, default warning): a changed
|
|
659
|
+
* code file that a superseded or deprecated decision governs, by glob or anchor (01:366).
|
|
660
|
+
*/
|
|
661
|
+
function governedByInactive(log) {
|
|
662
|
+
return {
|
|
663
|
+
id: "decision-governed-by-inactive",
|
|
664
|
+
async run(ctx) {
|
|
665
|
+
const changes = await governedChanges(ctx, {
|
|
666
|
+
keyword: log.adr.anchorKeyword,
|
|
667
|
+
exclude: log.adr.anchorExclude,
|
|
668
|
+
statuses: ["superseded", "deprecated"]
|
|
669
|
+
});
|
|
670
|
+
if (changes.length === 0) return [];
|
|
671
|
+
const views = await headViews(log, ctx);
|
|
672
|
+
const display = (id) => views.get(id)?.display ?? id;
|
|
673
|
+
const message = (c) => {
|
|
674
|
+
const what = `${c.path} is governed by ${display(c.subject)}`;
|
|
675
|
+
if (c.status === "deprecated") return `${what}, which is deprecated; check whether the change still needs it`;
|
|
676
|
+
const next = views.get(c.subject)?.superseded_by ?? null;
|
|
677
|
+
return next === null ? `${what}, which is superseded; check the change against its successor` : `${what}, which is superseded by ${display(next)}; check the change against ${display(next)}`;
|
|
678
|
+
};
|
|
679
|
+
return changes.map((c) => finding({
|
|
680
|
+
severity: log.adr.governedByInactive,
|
|
681
|
+
rule: "decision/governed-by-inactive",
|
|
682
|
+
message: message(c),
|
|
683
|
+
file: c.path,
|
|
684
|
+
subject: c.subject
|
|
685
|
+
}));
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* `decision/scope-narrowed` (warning): an accepted decision whose new scope stops covering files
|
|
691
|
+
* that still exist (01:367), which calls for a check against its Scope section.
|
|
692
|
+
*/
|
|
693
|
+
function scopeNarrowed(log) {
|
|
694
|
+
return {
|
|
695
|
+
id: "decision-scope-narrowed",
|
|
696
|
+
async run(ctx) {
|
|
697
|
+
const narrowed = await narrowedScopes(ctx, { statuses: ["accepted"] });
|
|
698
|
+
if (narrowed.length === 0) return [];
|
|
699
|
+
const views = await headViews(log, ctx);
|
|
700
|
+
return narrowed.map((n) => {
|
|
701
|
+
const count = n.lost.length;
|
|
702
|
+
const files = count === 1 ? "1 file that still exists" : `${count} files that still exist`;
|
|
703
|
+
const named = n.lost.slice(0, NARROWED_PATHS).join(", ");
|
|
704
|
+
const more = count > NARROWED_PATHS ? ", …" : "";
|
|
705
|
+
return finding({
|
|
706
|
+
severity: "warning",
|
|
707
|
+
rule: "decision/scope-narrowed",
|
|
708
|
+
message: `${views.get(n.subject)?.display ?? n.subject}'s scope no longer covers ${files} (${named}${more}); check the change against its Scope section`,
|
|
709
|
+
file: n.file,
|
|
710
|
+
subject: n.subject
|
|
711
|
+
});
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
};
|
|
715
|
+
}
|
|
684
716
|
/**
|
|
685
|
-
* Every
|
|
686
|
-
*
|
|
687
|
-
*
|
|
717
|
+
* Every check for the log: ledger's mechanical and fold checks with adr's actor policy, template
|
|
718
|
+
* lint against the templates in the head tree and, for older versions, the history of
|
|
719
|
+
* `historyRef`, the scope-glob and anchor checks, and the decision checks over code
|
|
720
|
+
* (`decision/governed-by-inactive`, `decision/scope-narrowed`). With `[log] index = "committed"`,
|
|
721
|
+
* also the freshness of `<root>/index.md`.
|
|
688
722
|
*/
|
|
689
723
|
function adrChecks(log, historyRef) {
|
|
690
|
-
return
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
724
|
+
return [
|
|
725
|
+
...foldChecks(adrKind, {
|
|
726
|
+
options: { acceptRequiresHuman: log.adr.acceptRequiresHuman },
|
|
727
|
+
templates: gitTemplates(log.git, { historyRef })
|
|
728
|
+
}),
|
|
729
|
+
scopeGlobCheck(),
|
|
730
|
+
anchorCheck({
|
|
731
|
+
keyword: log.adr.anchorKeyword,
|
|
732
|
+
exclude: log.adr.anchorExclude
|
|
733
|
+
}),
|
|
734
|
+
governedByInactive(log),
|
|
735
|
+
scopeNarrowed(log),
|
|
736
|
+
...log.adr.index === "committed" ? [decisionIndexCheck(log)] : []
|
|
737
|
+
];
|
|
694
738
|
}
|
|
695
739
|
/** Verifies the decision log in `mode` (staged, range or full) with `adrChecks`. */
|
|
696
740
|
async function verifyDecisions(log, mode) {
|
|
@@ -702,9 +746,117 @@ async function verifyDecisions(log, mode) {
|
|
|
702
746
|
checks: adrChecks(log, await templateHistoryRef(log.git, mode))
|
|
703
747
|
});
|
|
704
748
|
}
|
|
749
|
+
/** The built-in templates by name: the file text `adr new` materializes. */
|
|
750
|
+
const BUILTIN_TEMPLATES = Object.freeze({
|
|
751
|
+
nygard: `---
|
|
752
|
+
template: nygard
|
|
753
|
+
version: 1
|
|
754
|
+
sections:
|
|
755
|
+
- id: context
|
|
756
|
+
heading: Context
|
|
757
|
+
required: true
|
|
758
|
+
guidance: What is the issue that motivates this decision? Describe the forces at play.
|
|
759
|
+
- id: decision
|
|
760
|
+
heading: Decision
|
|
761
|
+
required: true
|
|
762
|
+
guidance: What is the change that we're proposing or doing? Write it in the active voice.
|
|
763
|
+
- id: scope
|
|
764
|
+
heading: Scope
|
|
765
|
+
required: true
|
|
766
|
+
guidance: What does this decision govern? Name the systems, code paths or teams it applies to.
|
|
767
|
+
- id: consequences
|
|
768
|
+
heading: Consequences
|
|
769
|
+
required: true
|
|
770
|
+
guidance: What becomes easier or harder because of this change? Include the risks.
|
|
771
|
+
---
|
|
772
|
+
# {{title}}
|
|
773
|
+
|
|
774
|
+
## Context
|
|
775
|
+
{{context}}
|
|
776
|
+
|
|
777
|
+
## Decision
|
|
778
|
+
{{decision}}
|
|
779
|
+
|
|
780
|
+
## Scope
|
|
781
|
+
{{scope}}
|
|
782
|
+
|
|
783
|
+
## Consequences
|
|
784
|
+
{{consequences}}
|
|
785
|
+
`,
|
|
786
|
+
madr: `---
|
|
787
|
+
template: madr
|
|
788
|
+
version: 1
|
|
789
|
+
sections:
|
|
790
|
+
- id: context
|
|
791
|
+
heading: Context and Problem Statement
|
|
792
|
+
required: true
|
|
793
|
+
guidance: Describe the context and the problem in two or three sentences, or as a question.
|
|
794
|
+
- id: drivers
|
|
795
|
+
heading: Decision Drivers
|
|
796
|
+
required: false
|
|
797
|
+
guidance: List the forces and concerns that shape the decision.
|
|
798
|
+
- id: options
|
|
799
|
+
heading: Considered Options
|
|
800
|
+
required: true
|
|
801
|
+
guidance: List the options that were considered, one per line.
|
|
802
|
+
- id: decision
|
|
803
|
+
heading: Decision Outcome
|
|
804
|
+
required: true
|
|
805
|
+
guidance: Name the chosen option and say why it was chosen.
|
|
806
|
+
- id: scope
|
|
807
|
+
heading: Scope
|
|
808
|
+
required: true
|
|
809
|
+
guidance: What does this decision govern? Name the systems, code paths or teams it applies to.
|
|
810
|
+
- id: consequences
|
|
811
|
+
heading: Consequences
|
|
812
|
+
required: true
|
|
813
|
+
guidance: Describe the good and the bad consequences of the chosen option.
|
|
814
|
+
---
|
|
815
|
+
# {{title}}
|
|
816
|
+
|
|
817
|
+
## Context and Problem Statement
|
|
818
|
+
{{context}}
|
|
819
|
+
|
|
820
|
+
## Decision Drivers
|
|
821
|
+
{{drivers}}
|
|
822
|
+
|
|
823
|
+
## Considered Options
|
|
824
|
+
{{options}}
|
|
825
|
+
|
|
826
|
+
## Decision Outcome
|
|
827
|
+
{{decision}}
|
|
828
|
+
|
|
829
|
+
## Scope
|
|
830
|
+
{{scope}}
|
|
831
|
+
|
|
832
|
+
## Consequences
|
|
833
|
+
{{consequences}}
|
|
834
|
+
`,
|
|
835
|
+
"y-statement": `---
|
|
836
|
+
template: y-statement
|
|
837
|
+
version: 1
|
|
838
|
+
sections:
|
|
839
|
+
- id: statement
|
|
840
|
+
heading: Statement
|
|
841
|
+
required: true
|
|
842
|
+
guidance: In the context of <use case>, facing <concern>, we decided for <option>, to achieve <quality>, accepting <downside>.
|
|
843
|
+
- id: scope
|
|
844
|
+
heading: Scope
|
|
845
|
+
required: true
|
|
846
|
+
guidance: What does this decision govern? Name the systems, code paths or teams it applies to.
|
|
847
|
+
---
|
|
848
|
+
# {{title}}
|
|
849
|
+
|
|
850
|
+
## Statement
|
|
851
|
+
{{statement}}
|
|
852
|
+
|
|
853
|
+
## Scope
|
|
854
|
+
{{scope}}
|
|
855
|
+
`
|
|
856
|
+
});
|
|
705
857
|
// src/write.ts
|
|
706
|
-
const encoder = new TextEncoder();
|
|
707
|
-
const decoder = new TextDecoder();
|
|
858
|
+
const encoder$1 = new TextEncoder();
|
|
859
|
+
const decoder$1 = new TextDecoder();
|
|
708
860
|
const compiled = compileKind(adrKind);
|
|
709
861
|
const TEMPLATE_NAME = /^[a-z][a-z0-9-]*$/;
|
|
710
862
|
const MAX_TEXT_BYTES = 65536;
|
|
@@ -715,7 +867,7 @@ const templatePath = (read, name) => `${read.root}/templates/${name}.md`;
|
|
|
715
867
|
/** A text argument within the 64 KiB cap and without NUL characters, else `UsageError`. */
|
|
716
868
|
function checkText(what, text) {
|
|
717
869
|
if (text.includes("\0")) throw new UsageError(`${what} must not contain NUL characters`);
|
|
718
|
-
const size = encoder.encode(text).byteLength;
|
|
870
|
+
const size = encoder$1.encode(text).byteLength;
|
|
719
871
|
if (size > MAX_TEXT_BYTES) throw new UsageError(`${what} is ${size} bytes; the limit is ${MAX_TEXT_BYTES} (64 KiB)`);
|
|
720
872
|
}
|
|
721
873
|
function checkReason(reason) {
|
|
@@ -811,7 +963,7 @@ async function precheck(read, files) {
|
|
|
811
963
|
};
|
|
812
964
|
}
|
|
813
965
|
function filesOf(events, read) {
|
|
814
|
-
return Object.fromEntries(events.map((e) => [eventPath(read.layout, e), encoder.encode(serializeEvent(e))]));
|
|
966
|
+
return Object.fromEntries(events.map((e) => [eventPath(read.layout, e), encoder$1.encode(serializeEvent(e))]));
|
|
815
967
|
}
|
|
816
968
|
const pairsOf = (events) => events.flatMap((e) => [["ADR", e.subject], ["ADR-Event", e.type]]);
|
|
817
969
|
/** The template `new` uses: the repo's `<root>/templates/<name>.md`, else a built-in to copy. */
|
|
@@ -821,7 +973,7 @@ async function chooseTemplate(read, name, c) {
|
|
|
821
973
|
const bytes = await read.tree.read(path);
|
|
822
974
|
let text;
|
|
823
975
|
let materialize = null;
|
|
824
|
-
if (bytes !== null) text = decoder.decode(bytes);
|
|
976
|
+
if (bytes !== null) text = decoder$1.decode(bytes);
|
|
825
977
|
else {
|
|
826
978
|
const builtin = Object.hasOwn(c.builtins, name) ? c.builtins[name] : void 0;
|
|
827
979
|
if (builtin === void 0) {
|
|
@@ -908,9 +1060,9 @@ async function planPropose(read, input, c, o = {}) {
|
|
|
908
1060
|
},
|
|
909
1061
|
actor: input.actor
|
|
910
1062
|
});
|
|
911
|
-
const body = encoder.encode(rendered.text);
|
|
1063
|
+
const body = encoder$1.encode(rendered.text);
|
|
912
1064
|
const files = { [bodyPath]: body };
|
|
913
|
-
if (chosen.materialize !== null) files[chosen.path] = encoder.encode(chosen.materialize);
|
|
1065
|
+
if (chosen.materialize !== null) files[chosen.path] = encoder$1.encode(chosen.materialize);
|
|
914
1066
|
Object.assign(files, filesOf([event], read));
|
|
915
1067
|
const { after, added } = await precheck(read, files);
|
|
916
1068
|
if (added.length > 0) return {
|
|
@@ -1176,48 +1328,6 @@ async function planSupersede(read, input, c) {
|
|
|
1176
1328
|
}
|
|
1177
1329
|
};
|
|
1178
1330
|
}
|
|
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
1331
|
const actionOf = (type) => Object.hasOwn(ACTIONS, type) ? ACTIONS[type] : {
|
|
1222
1332
|
action: type,
|
|
1223
1333
|
names: "display"
|
|
@@ -1352,7 +1462,7 @@ async function planLink(read, input, c, o = {}) {
|
|
|
1352
1462
|
to = target;
|
|
1353
1463
|
shown = target;
|
|
1354
1464
|
described = `${rel} ${target}.`;
|
|
1355
|
-
named = `${view.display}
|
|
1465
|
+
named = `${view.display} ${CROSS_LOG_LINK}`;
|
|
1356
1466
|
} else {
|
|
1357
1467
|
to = resolveDecision(read, target);
|
|
1358
1468
|
if (to === id) throw new UsageError("a decision can't link to itself");
|
|
@@ -1457,7 +1567,10 @@ async function planScope(read, input, c, o = {}) {
|
|
|
1457
1567
|
notes
|
|
1458
1568
|
}
|
|
1459
1569
|
};
|
|
1460
|
-
if (o.tracked !== void 0)
|
|
1570
|
+
if (o.tracked !== void 0) {
|
|
1571
|
+
const checked = (adrKind.def.scope?.activeIn ?? []).includes(view.status) ? "; verify (hooks and CI) reports scope/glob-unmatched until a file matches" : "";
|
|
1572
|
+
notes.push(...unmatchedGlobs(result.set, o.tracked).map((g) => `${g} matches no tracked file${checked}`));
|
|
1573
|
+
}
|
|
1461
1574
|
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
1575
|
const r = await planEvent(read, {
|
|
1463
1576
|
subject: id,
|
|
@@ -1618,10 +1731,30 @@ async function trackedFiles(log) {
|
|
|
1618
1731
|
}
|
|
1619
1732
|
const isYaml = (path) => /\.ya?ml$/.test(path);
|
|
1620
1733
|
/**
|
|
1734
|
+
* What the recovery hint says about rerunning a write command after `git commit` failed (design
|
|
1735
|
+
* 2b D5): the batch is already in the log, so a rerun is refused, writes nothing or duplicates it.
|
|
1736
|
+
*/
|
|
1737
|
+
const WRITE_RERUN = "Don't run the command again: the log already holds the change, so a rerun would be refused, do nothing, or write it a second time.";
|
|
1738
|
+
/**
|
|
1621
1739
|
* Applies a plan (§5.4): refuses a commit whose scope has unstaged or needed untracked paths,
|
|
1622
1740
|
* 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.
|
|
1741
|
+
* commits `plan.stage` plus the involved subjects' already-staged files with exact trailers. A
|
|
1742
|
+
* failed `git commit` throws `CommitFailedError` with the manual command (design 2b D5) and the
|
|
1743
|
+
* notes made so far.
|
|
1624
1744
|
*/
|
|
1745
|
+
/**
|
|
1746
|
+
* A write leaves the work tree's render of `index.md` (`staged`) staged, and it lists decision
|
|
1747
|
+
* files that may not be staged. When some aren't and the index's own render differs, the next
|
|
1748
|
+
* plain commit's hook fails; the note says so, as its `index/stale` finding does. `null`
|
|
1749
|
+
* otherwise, or when that render fails.
|
|
1750
|
+
*/
|
|
1751
|
+
async function unstagedIndexNote(log, at, staged, history, render) {
|
|
1752
|
+
const files = await unstagedLogFiles(log.git, log.layout);
|
|
1753
|
+
if (files.length === 0) return null;
|
|
1754
|
+
const own = await render(await indexReader(log.git), history).catch(() => null);
|
|
1755
|
+
if (own === null || own === staged) return null;
|
|
1756
|
+
return `${unstagedIndexText(at, files)}; git add them before you commit, or move them out of the work tree and run adr index`;
|
|
1757
|
+
}
|
|
1625
1758
|
async function applyPlan(log, plan, o) {
|
|
1626
1759
|
const { git } = log;
|
|
1627
1760
|
if (o.commit && o.message === void 0) throw new InvalidArgumentError("applyPlan needs a message to commit");
|
|
@@ -1633,6 +1766,53 @@ async function applyPlan(log, plan, o) {
|
|
|
1633
1766
|
const [first] = sorted([...unstaged, ...untracked]).filter((p) => !stage.has(p));
|
|
1634
1767
|
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
1768
|
}
|
|
1769
|
+
const root = log.layout.root;
|
|
1770
|
+
const at = indexPath(log.layout);
|
|
1771
|
+
const notes = [];
|
|
1772
|
+
const head = await git.resolveCommit("HEAD");
|
|
1773
|
+
const extraOf = async () => (await git.stagedPaths(plan.commitScope)).filter((p) => !stage.has(p));
|
|
1774
|
+
const commitTree = async (extra) => {
|
|
1775
|
+
const work = worktreeReader(git);
|
|
1776
|
+
const files = {};
|
|
1777
|
+
const gone = /* @__PURE__ */ new Set();
|
|
1778
|
+
for (const p of sorted([...plan.stage, ...extra])) {
|
|
1779
|
+
const bytes = plan.files[p] ?? await work.read(p);
|
|
1780
|
+
if (bytes === null) gone.add(p);
|
|
1781
|
+
else files[p] = bytes;
|
|
1782
|
+
}
|
|
1783
|
+
return withoutPaths(overlayTree(await headTree(git, head), files), gone);
|
|
1784
|
+
};
|
|
1785
|
+
const extra0 = o.commit && o.index !== void 0 ? await extraOf() : [];
|
|
1786
|
+
let text = null;
|
|
1787
|
+
if (o.index !== void 0) {
|
|
1788
|
+
const tree = o.commit ? await commitTree(extra0) : overlayTree(worktreeReader(git), plan.files);
|
|
1789
|
+
text = await o.index(tree, head);
|
|
1790
|
+
if (text === null) notes.push(`${at} not updated: numbers need history in this shallow clone; run git fetch --unshallow, then adr index`);
|
|
1791
|
+
}
|
|
1792
|
+
const toStage = [];
|
|
1793
|
+
let indexWritten = false;
|
|
1794
|
+
let indexOld = null;
|
|
1795
|
+
let indexInCommit = false;
|
|
1796
|
+
/** Brings the work-tree copy to `t` and stages it (now, or with the batch) where they differ. */
|
|
1797
|
+
const refresh = async (t, now) => {
|
|
1798
|
+
const r = await writeIndexCopy(git, at, t);
|
|
1799
|
+
if (r.wrote && !indexWritten) indexOld = r.old;
|
|
1800
|
+
indexWritten ||= r.wrote;
|
|
1801
|
+
if (r.stage) {
|
|
1802
|
+
if (now) await git.add([at]);
|
|
1803
|
+
else toStage.push(at);
|
|
1804
|
+
}
|
|
1805
|
+
return r.wrote || r.stage;
|
|
1806
|
+
};
|
|
1807
|
+
/** Step 3's rules: committing, HEAD's copy decides; otherwise the work tree and the index. */
|
|
1808
|
+
const place = async (t, committing, now) => {
|
|
1809
|
+
if (!committing) {
|
|
1810
|
+
await refresh(t, now);
|
|
1811
|
+
return;
|
|
1812
|
+
}
|
|
1813
|
+
indexInCommit = !holdsText(await headBytes(git, at), t);
|
|
1814
|
+
if (indexInCommit) await refresh(t, now);
|
|
1815
|
+
};
|
|
1636
1816
|
const eventPaths = new Set(plan.events.map((e) => eventPath(log.layout, e)));
|
|
1637
1817
|
const written = [];
|
|
1638
1818
|
try {
|
|
@@ -1641,16 +1821,26 @@ async function applyPlan(log, plan, o) {
|
|
|
1641
1821
|
await writeNewFile(git, path, bytes, { stage: false });
|
|
1642
1822
|
written.push(path);
|
|
1643
1823
|
}
|
|
1824
|
+
if (text !== null) await place(text, o.commit, false);
|
|
1644
1825
|
await writeEvents(git, log.layout, plan.events, {
|
|
1645
1826
|
kind: log.compiled,
|
|
1646
1827
|
stage: false
|
|
1647
1828
|
});
|
|
1648
1829
|
} catch (err) {
|
|
1649
|
-
const
|
|
1650
|
-
|
|
1830
|
+
const top = git.root ?? "";
|
|
1831
|
+
if (indexWritten) {
|
|
1832
|
+
if (indexOld === null) await rm(join(top, at), { force: true });
|
|
1833
|
+
else await writeFile(join(top, at), indexOld);
|
|
1834
|
+
}
|
|
1835
|
+
for (const path of written) await rm(join(top, path), { force: true });
|
|
1651
1836
|
throw err;
|
|
1652
1837
|
}
|
|
1653
|
-
await git.add(plan.stage);
|
|
1838
|
+
await git.add([...plan.stage, ...toStage]);
|
|
1839
|
+
/** The work tree's render that ends up staged, and the commit it builds on. */
|
|
1840
|
+
let stagedIndex = !o.commit && text !== null ? {
|
|
1841
|
+
text,
|
|
1842
|
+
history: head
|
|
1843
|
+
} : null;
|
|
1654
1844
|
let findings = plan.findings;
|
|
1655
1845
|
let commit = o.commit;
|
|
1656
1846
|
if (o.afterStage !== void 0) {
|
|
@@ -1658,25 +1848,104 @@ async function applyPlan(log, plan, o) {
|
|
|
1658
1848
|
findings = after.findings ?? plan.findings;
|
|
1659
1849
|
commit = commit && after.commit;
|
|
1660
1850
|
}
|
|
1851
|
+
const stale = (err) => `${at} may be stale: ${firstLine(err)}; run adr index`;
|
|
1661
1852
|
let sha = null;
|
|
1662
1853
|
if (commit) {
|
|
1663
|
-
const extra =
|
|
1854
|
+
const extra = await extraOf();
|
|
1664
1855
|
const staged = extra.filter(isYaml).flatMap((p) => {
|
|
1665
1856
|
const c = classifyPath(log.layout, p);
|
|
1666
1857
|
const name = c.area === "event" && !c.file.includes("/") ? parseEventFileName(c.file) : null;
|
|
1667
1858
|
return c.area === "event" && c.subject !== null && name !== null ? [["ADR", c.subject], ["ADR-Event", name.type]] : [];
|
|
1668
1859
|
});
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1860
|
+
const message = o.message ?? "";
|
|
1861
|
+
const mergeTrailers = o.mergeTrailers === true;
|
|
1862
|
+
const trailers = [...staged, ...plan.trailers];
|
|
1863
|
+
const sameEvents = sorted(extra.filter(isYaml)).join("\0") === sorted(extra0.filter(isYaml)).join("\0");
|
|
1864
|
+
if (text !== null && o.index !== void 0 && o.afterStage !== void 0 && !sameEvents) try {
|
|
1865
|
+
const again = await o.index(await commitTree(extra), head);
|
|
1866
|
+
if (again !== null) await place(again, true, true);
|
|
1867
|
+
} catch (err) {
|
|
1868
|
+
const recovery = await saveCommitRecovery(git, {
|
|
1869
|
+
message,
|
|
1870
|
+
mergeTrailers,
|
|
1871
|
+
trailers,
|
|
1872
|
+
only: sorted([
|
|
1873
|
+
...plan.stage,
|
|
1874
|
+
...extra,
|
|
1875
|
+
at
|
|
1876
|
+
])
|
|
1877
|
+
});
|
|
1878
|
+
throw new CommitFailedError({
|
|
1879
|
+
recovery,
|
|
1880
|
+
headline: `${at} couldn't be refreshed: ${firstLine(err)}`,
|
|
1881
|
+
detail: "",
|
|
1882
|
+
rerun: WRITE_RERUN,
|
|
1883
|
+
before: INDEX_BEFORE(root),
|
|
1884
|
+
after: null,
|
|
1885
|
+
notes
|
|
1886
|
+
}, { cause: err });
|
|
1887
|
+
}
|
|
1888
|
+
try {
|
|
1889
|
+
sha = await commitWithRecovery(git, {
|
|
1890
|
+
message,
|
|
1891
|
+
mergeTrailers,
|
|
1892
|
+
trailers,
|
|
1893
|
+
only: sorted([
|
|
1894
|
+
...plan.stage,
|
|
1895
|
+
...extra,
|
|
1896
|
+
...indexInCommit ? [at] : []
|
|
1897
|
+
]),
|
|
1898
|
+
rerun: WRITE_RERUN,
|
|
1899
|
+
after: indexInCommit ? INDEX_AFTER(root) : null
|
|
1900
|
+
});
|
|
1901
|
+
} catch (err) {
|
|
1902
|
+
throw err instanceof CommitFailedError ? err.withNotes(notes) : err;
|
|
1903
|
+
}
|
|
1904
|
+
if (text !== null && o.index !== void 0) try {
|
|
1905
|
+
const again = await o.index(worktreeReader(git), sha);
|
|
1906
|
+
if (again !== null) {
|
|
1907
|
+
await refresh(again, true);
|
|
1908
|
+
stagedIndex = {
|
|
1909
|
+
text: again,
|
|
1910
|
+
history: sha
|
|
1911
|
+
};
|
|
1912
|
+
}
|
|
1913
|
+
} catch (err) {
|
|
1914
|
+
notes.push(stale(err));
|
|
1915
|
+
}
|
|
1916
|
+
} else if (o.commit && text !== null && o.index !== void 0) try {
|
|
1917
|
+
const again = await o.index(worktreeReader(git), head);
|
|
1918
|
+
if (again !== null) {
|
|
1919
|
+
await refresh(again, true);
|
|
1920
|
+
stagedIndex = {
|
|
1921
|
+
text: again,
|
|
1922
|
+
history: head
|
|
1923
|
+
};
|
|
1924
|
+
}
|
|
1925
|
+
} catch (err) {
|
|
1926
|
+
notes.push(stale(err));
|
|
1927
|
+
}
|
|
1928
|
+
if (stagedIndex !== null && o.index !== void 0) {
|
|
1929
|
+
const { text: t, history } = stagedIndex;
|
|
1930
|
+
const note = await unstagedIndexNote(log, at, t, history, o.index);
|
|
1931
|
+
if (note !== null) notes.push(note);
|
|
1675
1932
|
}
|
|
1676
1933
|
return {
|
|
1677
|
-
written: Object.keys(plan.files),
|
|
1934
|
+
written: [...Object.keys(plan.files), ...indexWritten ? [at] : []],
|
|
1678
1935
|
commit: sha,
|
|
1679
|
-
findings
|
|
1936
|
+
findings,
|
|
1937
|
+
notes
|
|
1938
|
+
};
|
|
1939
|
+
}
|
|
1940
|
+
/** `tree` without the paths in `gone` (a staged deletion in a commit's `only` list). */
|
|
1941
|
+
function withoutPaths(tree, gone) {
|
|
1942
|
+
if (gone.size === 0) return tree;
|
|
1943
|
+
return {
|
|
1944
|
+
label: tree.label,
|
|
1945
|
+
commit: null,
|
|
1946
|
+
list: async (prefix) => (await tree.list(prefix)).filter((e) => !gone.has(e.path)),
|
|
1947
|
+
read: async (path) => gone.has(path) ? null : tree.read(path),
|
|
1948
|
+
readMany: (paths) => tree.readMany(paths.filter((p) => !gone.has(p)))
|
|
1680
1949
|
};
|
|
1681
1950
|
}
|
|
1682
1951
|
const DEFAULT_DEPS = {
|
|
@@ -1783,10 +2052,20 @@ async function write(log, command, plan, o, deps, afterStage) {
|
|
|
1783
2052
|
message: given ?? rendered.message,
|
|
1784
2053
|
mergeTrailers: given !== null
|
|
1785
2054
|
},
|
|
1786
|
-
...afterStage === void 0 ? {} : { afterStage: afterStage(r.plan, commit) }
|
|
2055
|
+
...afterStage === void 0 ? {} : { afterStage: afterStage(r.plan, commit) },
|
|
2056
|
+
...log.adr.index === "committed" && o.index !== "skip" ? { index: (tree, history) => renderDecisionIndex(log.git, {
|
|
2057
|
+
layout: log.layout,
|
|
2058
|
+
adr: log.adr,
|
|
2059
|
+
tree,
|
|
2060
|
+
history
|
|
2061
|
+
}) } : {}
|
|
1787
2062
|
});
|
|
1788
2063
|
const views = await viewsAfter(log, r.plan, applied.commit);
|
|
1789
|
-
const own = [
|
|
2064
|
+
const own = [
|
|
2065
|
+
...r.plan.notes ?? [],
|
|
2066
|
+
...applied.notes,
|
|
2067
|
+
...applied.commit !== null && given === null && rendered?.shortened === true ? [SHORTENED_NOTE] : []
|
|
2068
|
+
];
|
|
1790
2069
|
return {
|
|
1791
2070
|
format: "adr-write/v1",
|
|
1792
2071
|
command,
|
|
@@ -1799,6 +2078,7 @@ async function write(log, command, plan, o, deps, afterStage) {
|
|
|
1799
2078
|
notes: [...notes, ...own]
|
|
1800
2079
|
};
|
|
1801
2080
|
} catch (err) {
|
|
2081
|
+
if (err instanceof CommitFailedError) throw err.withNotes([...notes, ...r.plan.notes ?? []]);
|
|
1802
2082
|
if (!(err instanceof EventExistsError) || attempt > 0) throw err;
|
|
1803
2083
|
}
|
|
1804
2084
|
}
|
|
@@ -1848,7 +2128,7 @@ function editorStep(log, edit, onNote) {
|
|
|
1848
2128
|
onNote?.(`${first} changed while the editor was open; review it, then git add it and commit`);
|
|
1849
2129
|
}
|
|
1850
2130
|
}
|
|
1851
|
-
const text = decoder.decode(bytes ?? d.rendered);
|
|
2131
|
+
const text = decoder$1.decode(bytes ?? d.rendered);
|
|
1852
2132
|
const findings = lintDocument(d.template, text, {
|
|
1853
2133
|
file: d.path,
|
|
1854
2134
|
subject: plan.subject
|
|
@@ -1981,6 +2261,613 @@ async function followScopeRenames(log, input, o = {}, deps = DEFAULT_DEPS) {
|
|
|
1981
2261
|
notes: [...doc.notes, FOLLOW_NO_COMMIT_NOTE]
|
|
1982
2262
|
} : doc;
|
|
1983
2263
|
}
|
|
1984
|
-
|
|
2264
|
+
// src/hooks.ts
|
|
2265
|
+
/** The hooks adr registers, in the order the text output lists them. */
|
|
2266
|
+
const HOOKS = [
|
|
2267
|
+
"pre-commit",
|
|
2268
|
+
"commit-msg",
|
|
2269
|
+
"pre-push",
|
|
2270
|
+
"post-merge"
|
|
2271
|
+
];
|
|
2272
|
+
/**
|
|
2273
|
+
* adr's command per hook (design 2b D4), after `adr`. `--root <root>` comes first only when the
|
|
2274
|
+
* root can't be found without it: no `<repo>/adr.toml` names it and it isn't `docs/adr`. So the
|
|
2275
|
+
* hooks see the same config as a plain `adr` run, and never print the root-override note.
|
|
2276
|
+
*/
|
|
2277
|
+
function decisionHookCommands(log) {
|
|
2278
|
+
const r = log.configPath === "adr.toml" || log.layout.root === adrKind.def.defaultRoot ? [] : ["--root", log.layout.root];
|
|
2279
|
+
return {
|
|
2280
|
+
"pre-commit": [
|
|
2281
|
+
...r,
|
|
2282
|
+
"hooks",
|
|
2283
|
+
"run",
|
|
2284
|
+
"pre-commit"
|
|
2285
|
+
],
|
|
2286
|
+
"commit-msg": [
|
|
2287
|
+
...r,
|
|
2288
|
+
"hooks",
|
|
2289
|
+
"trailers",
|
|
2290
|
+
"{args}"
|
|
2291
|
+
],
|
|
2292
|
+
"pre-push": [
|
|
2293
|
+
...r,
|
|
2294
|
+
"verify",
|
|
2295
|
+
"--base",
|
|
2296
|
+
"@{upstream}",
|
|
2297
|
+
"--ref",
|
|
2298
|
+
"HEAD"
|
|
2299
|
+
],
|
|
2300
|
+
"post-merge": [
|
|
2301
|
+
...r,
|
|
2302
|
+
"list",
|
|
2303
|
+
"--since",
|
|
2304
|
+
"ORIG_HEAD"
|
|
2305
|
+
]
|
|
2306
|
+
};
|
|
2307
|
+
}
|
|
2308
|
+
function registrations(log) {
|
|
2309
|
+
const commands = decisionHookCommands(log);
|
|
2310
|
+
return HOOKS.flatMap((hook) => {
|
|
2311
|
+
const argv = commands[hook];
|
|
2312
|
+
return argv === void 0 ? [] : [{
|
|
2313
|
+
hook,
|
|
2314
|
+
argv
|
|
2315
|
+
}];
|
|
2316
|
+
});
|
|
2317
|
+
}
|
|
2318
|
+
/** A shim path as the user sees it: repo-relative, with `/`. */
|
|
2319
|
+
function shown(git, path) {
|
|
2320
|
+
return relative(git.root ?? git.gitDir, path).split(sep).join("/");
|
|
2321
|
+
}
|
|
2322
|
+
/** The shims in hook order: `HOOKS`' order, as the text output lists them. */
|
|
2323
|
+
function inHookOrder(git, paths) {
|
|
2324
|
+
const rank = (p) => HOOKS.findIndex((h) => p.endsWith(`/${h}`));
|
|
2325
|
+
return paths.map((p) => shown(git, p)).sort((a, b) => rank(a) - rank(b));
|
|
2326
|
+
}
|
|
2327
|
+
function result(log, action, r, extra = {}) {
|
|
2328
|
+
return {
|
|
2329
|
+
format: "adr-hooks/v1",
|
|
2330
|
+
action,
|
|
2331
|
+
written: r === null ? [] : inHookOrder(log.git, r.written),
|
|
2332
|
+
unchanged: r === null ? [] : inHookOrder(log.git, r.unchanged),
|
|
2333
|
+
registrations: registrations(log),
|
|
2334
|
+
snippet: extra.snippet ?? null,
|
|
2335
|
+
trailers: extra.trailers ?? []
|
|
2336
|
+
};
|
|
2337
|
+
}
|
|
2338
|
+
/**
|
|
2339
|
+
* Registers adr's four commands in ledger's shims, one per hook and shared with other ark tools
|
|
2340
|
+
* (design 2b D4). With `core.hooksPath` set or a foreign hook file present, it writes the rest and
|
|
2341
|
+
* then throws `HookConflictError` (exit 2), naming each conflict and the `print` command.
|
|
2342
|
+
*/
|
|
2343
|
+
async function installDecisionHooks(log) {
|
|
2344
|
+
const r = await installHooks(log.git, {
|
|
2345
|
+
tool: "adr",
|
|
2346
|
+
commands: decisionHookCommands(log)
|
|
2347
|
+
});
|
|
2348
|
+
assertNoHookConflicts(r, { tool: "adr" });
|
|
2349
|
+
return result(log, "install", r);
|
|
2350
|
+
}
|
|
2351
|
+
/** Removes adr from every shim; a shim left with no tool is deleted. */
|
|
2352
|
+
async function uninstallDecisionHooks(log) {
|
|
2353
|
+
return result(log, "uninstall", await uninstallHooks(log.git, { tool: "adr" }));
|
|
2354
|
+
}
|
|
2355
|
+
/** The config to paste into a hook manager instead of installing shims. */
|
|
2356
|
+
function printDecisionHooks(log, manager) {
|
|
2357
|
+
const regs = registrations(log).map((r) => ({
|
|
2358
|
+
tool: "adr",
|
|
2359
|
+
...r
|
|
2360
|
+
}));
|
|
2361
|
+
return result(log, "print", null, { snippet: hookSnippet(manager, regs) });
|
|
2362
|
+
}
|
|
2363
|
+
/** The `ADR-Event` trailer key as git compares keys: case-insensitively. */
|
|
2364
|
+
const ADR_EVENT = /^adr-event[ \t]*:/im;
|
|
2365
|
+
/**
|
|
2366
|
+
* The `commit-msg` step (P1 §7.1): appends one `ADR`/`ADR-Event` pair per staged added event file
|
|
2367
|
+
* of the log, by path, keeping repeats (`ifExists: "add"`). It does nothing for a message that
|
|
2368
|
+
* already has an `ADR-Event:` trailer (an `adr … --commit` message), or during a merge, whose
|
|
2369
|
+
* events came with their own commits' trailers. Throws `UsageError` when the file is missing.
|
|
2370
|
+
*/
|
|
2371
|
+
async function addHookTrailers(log, messageFile) {
|
|
2372
|
+
const { git, layout } = log;
|
|
2373
|
+
const file = resolve(git.root ?? git.gitDir, messageFile);
|
|
2374
|
+
try {
|
|
2375
|
+
await access(file, constants.R_OK | constants.W_OK);
|
|
2376
|
+
} catch {
|
|
2377
|
+
throw new UsageError(`no commit message file at ${messageFile}`);
|
|
2378
|
+
}
|
|
2379
|
+
const parsed = await git.run([
|
|
2380
|
+
"interpret-trailers",
|
|
2381
|
+
"--parse",
|
|
2382
|
+
"--",
|
|
2383
|
+
file
|
|
2384
|
+
]);
|
|
2385
|
+
if (ADR_EVENT.test(Buffer.from(parsed.stdout).toString("utf8"))) return result(log, "trailers", null);
|
|
2386
|
+
if (await git.resolveCommit("MERGE_HEAD") !== null) return result(log, "trailers", null);
|
|
2387
|
+
const trailers = (await git.stagedPaths([`${layout.root}/${layout.events}`], { addedOnly: true })).flatMap((p) => {
|
|
2388
|
+
const c = classifyPath(layout, p);
|
|
2389
|
+
const name = c.area === "event" && !c.file.includes("/") ? parseEventFileName(c.file) : null;
|
|
2390
|
+
return c.area === "event" && c.subject !== null && name !== null ? [["ADR", c.subject], ["ADR-Event", name.type]] : [];
|
|
2391
|
+
});
|
|
2392
|
+
await git.appendTrailers(file, trailers, { ifExists: "add" });
|
|
2393
|
+
return result(log, "trailers", null, { trailers });
|
|
2394
|
+
}
|
|
2395
|
+
/**
|
|
2396
|
+
* Whether the hook may stage: only into the commit's real index (design 2b D8). `git commit
|
|
2397
|
+
* <paths>` and `--only`, adr's own `--commit` included, run hooks on a temporary index, and
|
|
2398
|
+
* staging there leaves the user's index showing the file as deleted.
|
|
2399
|
+
*/
|
|
2400
|
+
function stagingSafe(git, env) {
|
|
2401
|
+
const file = env.GIT_INDEX_FILE;
|
|
2402
|
+
if (file === void 0 || file === "") return true;
|
|
2403
|
+
const at = resolve(git.root ?? git.gitDir, file);
|
|
2404
|
+
return at === resolve(git.gitDir, "index") || at === resolve(git.gitDir, "index.lock");
|
|
2405
|
+
}
|
|
2406
|
+
/** `ADR-0003, ADR-0004` for the decisions a rename following changes. */
|
|
2407
|
+
const displays = (views) => views.map((v) => v.display).join(", ");
|
|
2408
|
+
/**
|
|
2409
|
+
* `adr hooks run pre-commit` (design 2b D8): rename following, then the index, then `verify
|
|
2410
|
+
* --staged`, stopping at the first step that fails. `worktree` must be opened with the work-tree
|
|
2411
|
+
* reader, because rename following writes through `write()`; `index` with the index reader, for
|
|
2412
|
+
* the config as staged, the index render and verify. Only the index step stages `index.md`, and
|
|
2413
|
+
* only with `[hooks] index = "stage"`; rename following passes `index: "skip"`. Neither stages
|
|
2414
|
+
* into a temporary index (`stagingSafe`). A step that can't run throws (exit 2).
|
|
2415
|
+
*/
|
|
2416
|
+
async function runPreCommitHook(o) {
|
|
2417
|
+
const { worktree, index, env } = o;
|
|
2418
|
+
const config = index.adr;
|
|
2419
|
+
const failOn = config.failOn;
|
|
2420
|
+
const notes = [...index.notes];
|
|
2421
|
+
const done = (exit, findings) => ({
|
|
2422
|
+
exit,
|
|
2423
|
+
findings,
|
|
2424
|
+
notes: [...new Set(notes)],
|
|
2425
|
+
failOn
|
|
2426
|
+
});
|
|
2427
|
+
const safe = stagingSafe(index.git, env);
|
|
2428
|
+
if (config.scopeFollowRenames !== "off" && (await stagedRenames(worktree.git)).length > 0) {
|
|
2429
|
+
const { actor, notes: attribution } = await resolveAttribution(worktree.git, { env });
|
|
2430
|
+
if (config.scopeFollowRenames === "warn") {
|
|
2431
|
+
const dry = await followScopeRenames(worktree, { actor }, { dryRun: true });
|
|
2432
|
+
for (const v of dry.affected) notes.push(`${v.display}'s globs don't follow the renames in this commit (scope_follow_renames = "warn"); run adr scope --follow-renames`);
|
|
2433
|
+
} else if (safe) {
|
|
2434
|
+
const doc = await followScopeRenames(worktree, { actor }, {
|
|
2435
|
+
index: "skip",
|
|
2436
|
+
notes: attribution
|
|
2437
|
+
});
|
|
2438
|
+
notes.push(...doc.notes);
|
|
2439
|
+
if (!doc.ok) return done(1, doc.findings);
|
|
2440
|
+
} else {
|
|
2441
|
+
const dry = await followScopeRenames(worktree, { actor }, { dryRun: true });
|
|
2442
|
+
if (dry.affected.length > 0) {
|
|
2443
|
+
notes.push(`the renames in this commit change the scope of ${displays(dry.affected)}; git commit <paths> can't take the scoped events. Run adr scope --follow-renames, then commit again including the files it lists, or set [hooks] scope_follow_renames = "warn"`);
|
|
2444
|
+
return done(1, []);
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
if (config.index === "committed" && config.hookIndex === "stage" && safe) {
|
|
2449
|
+
const path = indexPath(index.layout);
|
|
2450
|
+
const text = await renderDecisionIndex(index.git, {
|
|
2451
|
+
layout: index.layout,
|
|
2452
|
+
adr: config,
|
|
2453
|
+
tree: index.tree,
|
|
2454
|
+
history: await index.git.resolveCommit("HEAD")
|
|
2455
|
+
});
|
|
2456
|
+
if (text !== null && !holdsText(await index.tree.read(path), text)) {
|
|
2457
|
+
await writeIndexFile(index.git, path, text);
|
|
2458
|
+
await index.git.add([path]);
|
|
2459
|
+
notes.push(`regenerated and staged ${path}`);
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
const findings = await verifyDecisions(index, { type: "staged" });
|
|
2463
|
+
return done(exitCode(findings, { failOn }), findings);
|
|
2464
|
+
}
|
|
2465
|
+
// src/init.ts
|
|
2466
|
+
/**
|
|
2467
|
+
* What the recovery hint says about rerunning `adr init` after `git commit` failed (design 2b D5):
|
|
2468
|
+
* init keeps existing files, so it would commit nothing.
|
|
2469
|
+
*/
|
|
2470
|
+
const INIT_RERUN = "Running adr init again won't commit them: the files exist now, so it keeps them and commits nothing.";
|
|
2471
|
+
const CONFIG = "adr.toml";
|
|
2472
|
+
const DEFAULT_ROOT = adrKind.def.defaultRoot;
|
|
2473
|
+
const encoder = new TextEncoder();
|
|
2474
|
+
/** The starter config; `root` only for a repo-level file naming a log elsewhere. */
|
|
2475
|
+
function configText(o) {
|
|
2476
|
+
return [
|
|
2477
|
+
"# adr configuration. Every key is optional: https://github.com/nikovirtala/ark/tree/main/packages/adr#config-adrtoml",
|
|
2478
|
+
"[log]",
|
|
2479
|
+
...o.root === null ? [] : [`root = ${JSON.stringify(o.root)}`],
|
|
2480
|
+
`default_template = ${JSON.stringify(o.template)}`,
|
|
2481
|
+
"",
|
|
2482
|
+
"[commit]",
|
|
2483
|
+
"# subject = \"docs(adr): {action} {title}\"",
|
|
2484
|
+
""
|
|
2485
|
+
].join("\n");
|
|
2486
|
+
}
|
|
2487
|
+
/**
|
|
2488
|
+
* `adr init` (design §6.2): writes a config for the log, unless one is found, and each built-in
|
|
2489
|
+
* template in `o.templates` (default `nygard`) to `<root>/templates/`, never overwriting a file.
|
|
2490
|
+
* A `--root` other than `docs/adr` gets a repo-level `adr.toml` naming it, so later commands find
|
|
2491
|
+
* the log without `--root`; when that file exists for another log, the config goes in the root.
|
|
2492
|
+
* With `[log] index = "committed"` it also writes `<root>/index.md` unless one exists. With
|
|
2493
|
+
* `commit` (default `[policy] auto_commit`) the written files are committed on their own;
|
|
2494
|
+
* nothing written means nothing committed. No events are written.
|
|
2495
|
+
*/
|
|
2496
|
+
async function initDecisionLog(log, o = {}) {
|
|
2497
|
+
if (log.ref !== null || log.tree.label !== "worktree") throw new InvalidArgumentError("init writes the work tree; open the log without a ref");
|
|
2498
|
+
const known = Object.keys(BUILTIN_TEMPLATES);
|
|
2499
|
+
const templates = [...new Set(o.templates === void 0 ? ["nygard"] : o.templates)];
|
|
2500
|
+
for (const name of templates) if (!known.includes(name)) throw new UsageError(`unknown built-in template ${name}; use nygard, madr or y-statement`);
|
|
2501
|
+
const root = log.layout.root;
|
|
2502
|
+
const notes = [...log.notes];
|
|
2503
|
+
const files = [];
|
|
2504
|
+
const kept = [];
|
|
2505
|
+
let config = log.configPath;
|
|
2506
|
+
if (config !== null) kept.push(config);
|
|
2507
|
+
else {
|
|
2508
|
+
const template = templates[0] ?? "nygard";
|
|
2509
|
+
const repo = await readToolConfig(log.tree, CONFIG);
|
|
2510
|
+
if (root === DEFAULT_ROOT) {
|
|
2511
|
+
config = `${root}/${CONFIG}`;
|
|
2512
|
+
files.push([config, configText({
|
|
2513
|
+
root: null,
|
|
2514
|
+
template
|
|
2515
|
+
})]);
|
|
2516
|
+
} else if (repo === null) {
|
|
2517
|
+
config = CONFIG;
|
|
2518
|
+
files.push([config, configText({
|
|
2519
|
+
root,
|
|
2520
|
+
template
|
|
2521
|
+
})]);
|
|
2522
|
+
} else {
|
|
2523
|
+
const section = repo.raw.log;
|
|
2524
|
+
const other = typeof section?.root === "string" ? section.root : DEFAULT_ROOT;
|
|
2525
|
+
config = `${root}/${CONFIG}`;
|
|
2526
|
+
files.push([config, configText({
|
|
2527
|
+
root: null,
|
|
2528
|
+
template
|
|
2529
|
+
})]);
|
|
2530
|
+
notes.push(`${CONFIG} names ${other}; pass --root ${root} to use this log`);
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
for (const name of templates) {
|
|
2534
|
+
const path = `${root}/templates/${name}.md`;
|
|
2535
|
+
if (await log.tree.read(path) !== null) kept.push(path);
|
|
2536
|
+
else files.push([path, BUILTIN_TEMPLATES[name] ?? ""]);
|
|
2537
|
+
}
|
|
2538
|
+
const commitRequested = o.commit ?? log.adr.autoCommit;
|
|
2539
|
+
const at = indexPath(log.layout);
|
|
2540
|
+
let indexed = false;
|
|
2541
|
+
if (log.adr.index === "committed") {
|
|
2542
|
+
if (await log.tree.read(at) !== null) kept.push(at);
|
|
2543
|
+
else {
|
|
2544
|
+
const head = await log.git.resolveCommit("HEAD");
|
|
2545
|
+
const added = Object.fromEntries(files.map(([p, t]) => [p, encoder.encode(t)]));
|
|
2546
|
+
const base = commitRequested ? await headTree(log.git, head) : log.tree;
|
|
2547
|
+
const text = await renderDecisionIndex(log.git, {
|
|
2548
|
+
layout: log.layout,
|
|
2549
|
+
adr: log.adr,
|
|
2550
|
+
tree: overlayTree(base, added),
|
|
2551
|
+
history: head
|
|
2552
|
+
});
|
|
2553
|
+
if (text === null) notes.push(`${at} not updated: numbers need history in this shallow clone; run git fetch --unshallow, then adr index`);
|
|
2554
|
+
else {
|
|
2555
|
+
files.push([at, text]);
|
|
2556
|
+
indexed = true;
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2560
|
+
const commit = files.length > 0 && commitRequested;
|
|
2561
|
+
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");
|
|
2562
|
+
const written = [];
|
|
2563
|
+
try {
|
|
2564
|
+
for (const [path, text] of files) {
|
|
2565
|
+
await writeNewFile(log.git, path, encoder.encode(text));
|
|
2566
|
+
written.push(path);
|
|
2567
|
+
}
|
|
2568
|
+
} catch (err) {
|
|
2569
|
+
const top = log.git.root ?? "";
|
|
2570
|
+
for (const path of written) await rm(join(top, path), { force: true });
|
|
2571
|
+
throw err;
|
|
2572
|
+
}
|
|
2573
|
+
if (written.length > 0) await log.git.add(written);
|
|
2574
|
+
let sha = null;
|
|
2575
|
+
if (commit) {
|
|
2576
|
+
const message = o.message ?? renderCommitMessage(log.adr.commitSubject, {
|
|
2577
|
+
title: "the decision log",
|
|
2578
|
+
vars: {
|
|
2579
|
+
action: "set up",
|
|
2580
|
+
id: "",
|
|
2581
|
+
display: "",
|
|
2582
|
+
command: "init"
|
|
2583
|
+
}
|
|
2584
|
+
}).message;
|
|
2585
|
+
sha = await commitWithRecovery(log.git, {
|
|
2586
|
+
message,
|
|
2587
|
+
only: written,
|
|
2588
|
+
rerun: INIT_RERUN,
|
|
2589
|
+
after: indexed ? INDEX_AFTER(root) : null
|
|
2590
|
+
});
|
|
2591
|
+
if (indexed) try {
|
|
2592
|
+
const again = await renderDecisionIndex(log.git, {
|
|
2593
|
+
layout: log.layout,
|
|
2594
|
+
adr: log.adr,
|
|
2595
|
+
tree: worktreeReader(log.git),
|
|
2596
|
+
history: sha
|
|
2597
|
+
});
|
|
2598
|
+
if (again !== null) {
|
|
2599
|
+
if ((await writeIndexCopy(log.git, at, again)).stage) await log.git.add([at]);
|
|
2600
|
+
}
|
|
2601
|
+
} catch (err) {
|
|
2602
|
+
notes.push(`${at} may be stale: ${firstLine(err)}; run adr index`);
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2605
|
+
return {
|
|
2606
|
+
format: "adr-init/v1",
|
|
2607
|
+
root,
|
|
2608
|
+
config,
|
|
2609
|
+
written,
|
|
2610
|
+
kept,
|
|
2611
|
+
commit: sha,
|
|
2612
|
+
notes
|
|
2613
|
+
};
|
|
2614
|
+
}
|
|
2615
|
+
// src/views.ts
|
|
2616
|
+
/** The `numbering` key of `adr-list/v1`, `adr-show/v1` and `adr-for/v1`. */
|
|
2617
|
+
const numberingJson = (read) => ({
|
|
2618
|
+
available: read.numbering.available,
|
|
2619
|
+
reason: read.numbering.reason,
|
|
2620
|
+
ref: read.numbering.ref
|
|
2621
|
+
});
|
|
2622
|
+
/** Numbered decisions by number, then unnumbered ones by `proposed_at`, then id. */
|
|
2623
|
+
function compareViews(a, b) {
|
|
2624
|
+
if (a.number !== null || b.number !== null) {
|
|
2625
|
+
if (a.number === null) return 1;
|
|
2626
|
+
if (b.number === null) return -1;
|
|
2627
|
+
return a.number - b.number;
|
|
2628
|
+
}
|
|
2629
|
+
if (a.proposed_at !== b.proposed_at) return a.proposed_at < b.proposed_at ? -1 : 1;
|
|
2630
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
2631
|
+
}
|
|
2632
|
+
/**
|
|
2633
|
+
* The `adr-list/v1` document: decisions in list order, filtered by `status` and by `since` (only
|
|
2634
|
+
* the decisions with event files added since its revision, from `changedSince`), and truncated to
|
|
2635
|
+
* `limit` after ordering. `findings` are those of the listed decisions and of the log as a whole.
|
|
2636
|
+
*/
|
|
2637
|
+
function listDecisions(read, o = {}) {
|
|
2638
|
+
const statuses = o.status === void 0 || o.status.length === 0 ? null : new Set(o.status);
|
|
2639
|
+
const since = o.since ?? null;
|
|
2640
|
+
const changed = since === null ? null : new Set(since.events.map((e) => e.subject));
|
|
2641
|
+
const decisions = [...read.projection.views.values()].filter((v) => statuses === null || statuses.has(v.status)).filter((v) => changed === null || changed.has(v.id)).sort(compareViews).slice(0, o.limit ?? Number.POSITIVE_INFINITY);
|
|
2642
|
+
const listed = new Set(decisions.map((v) => v.id));
|
|
2643
|
+
return {
|
|
2644
|
+
format: "adr-list/v1",
|
|
2645
|
+
root: read.root,
|
|
2646
|
+
ref: read.ref,
|
|
2647
|
+
commit: read.commit,
|
|
2648
|
+
numbering: numberingJson(read),
|
|
2649
|
+
decisions,
|
|
2650
|
+
since,
|
|
2651
|
+
unreadable: read.projection.unreadable,
|
|
2652
|
+
unfoldable: read.projection.unfoldable,
|
|
2653
|
+
findings: read.findings.filter((f) => f.subject === null || listed.has(f.subject)),
|
|
2654
|
+
notes: read.notes
|
|
2655
|
+
};
|
|
2656
|
+
}
|
|
2657
|
+
const decoder = new TextDecoder();
|
|
2658
|
+
/**
|
|
2659
|
+
* The `adr-show/v1` document of the decision `id` (resolve it with `resolveDecision` first).
|
|
2660
|
+
* Throws `UsageError` for an unknown, unreadable or unfoldable decision.
|
|
2661
|
+
*/
|
|
2662
|
+
async function showDecision(read, id, o = {}) {
|
|
2663
|
+
const view = read.projection.views.get(id);
|
|
2664
|
+
if (view === void 0) {
|
|
2665
|
+
if (read.projection.unreadable.includes(id)) throw new UsageError(`decision ${id} uses a newer format; upgrade adr`);
|
|
2666
|
+
if (read.projection.unfoldable.includes(id)) throw new UsageError(`decision ${id} can't be read; run adr verify`);
|
|
2667
|
+
throw new UsageError(`unknown decision ${id}`);
|
|
2668
|
+
}
|
|
2669
|
+
const loaded = read.folded.subjects.get(id)?.loaded;
|
|
2670
|
+
const body = loaded?.body ?? null;
|
|
2671
|
+
const events = o.history === true ? (loaded?.events ?? []).flatMap((e) => e.event === null ? [] : [{
|
|
2672
|
+
path: e.path,
|
|
2673
|
+
event: e.event
|
|
2674
|
+
}]).sort((a, b) => compareEvents({
|
|
2675
|
+
...a.event,
|
|
2676
|
+
path: a.path
|
|
2677
|
+
}, {
|
|
2678
|
+
...b.event,
|
|
2679
|
+
path: b.path
|
|
2680
|
+
})).map((e) => e.event) : null;
|
|
2681
|
+
return {
|
|
2682
|
+
format: "adr-show/v1",
|
|
2683
|
+
root: read.root,
|
|
2684
|
+
ref: read.ref,
|
|
2685
|
+
commit: read.commit,
|
|
2686
|
+
numbering: numberingJson(read),
|
|
2687
|
+
header: stateHeader(view, read),
|
|
2688
|
+
decision: view,
|
|
2689
|
+
body: body === null ? null : decoder.decode(body.bytes),
|
|
2690
|
+
events,
|
|
2691
|
+
findings: read.findings.filter((f) => f.subject === id),
|
|
2692
|
+
notes: read.notes
|
|
2693
|
+
};
|
|
2694
|
+
}
|
|
2695
|
+
const day = (at) => (at ?? "").slice(0, 10);
|
|
2696
|
+
/** The reason on the applied event that produced the decision's current status, if any. */
|
|
2697
|
+
function statusReason(read, id) {
|
|
2698
|
+
const reason = (read.folded.subjects.get(id)?.applied ?? []).findLast((a) => a.from !== a.to)?.event.reason;
|
|
2699
|
+
return typeof reason === "string" && reason.trim() !== "" ? reason.trim() : null;
|
|
2700
|
+
}
|
|
2701
|
+
/**
|
|
2702
|
+
* The state header (01:258–259): `<display> (<id>) · <status>`, and for a decision that isn't in
|
|
2703
|
+
* force a warning saying what to read instead. `refer` names where to read another decision. CLI
|
|
2704
|
+
* default: `display(id)`. Phase 3 MCP passes `id => "adr://decision/" + display(id)`, e.g.
|
|
2705
|
+
* `adr://decision/ADR-0012`, or the id when unnumbered.
|
|
2706
|
+
*/
|
|
2707
|
+
function stateHeader(view, read, o = {}) {
|
|
2708
|
+
const display = (id) => read.projection.views.get(id)?.display ?? id;
|
|
2709
|
+
const refer = o.refer ?? display;
|
|
2710
|
+
const line = view.number === null ? `${view.id} · ${view.status}` : `${view.display} (${view.id}) · ${view.status}`;
|
|
2711
|
+
const parts = [];
|
|
2712
|
+
const reason = statusReason(read, view.id);
|
|
2713
|
+
const when = day(view.status_at);
|
|
2714
|
+
switch (view.status) {
|
|
2715
|
+
case "superseded": {
|
|
2716
|
+
const target = view.effective ?? view.superseded_by ?? view.id;
|
|
2717
|
+
const accepted = read.projection.views.get(target)?.decided_at ?? null;
|
|
2718
|
+
const date = accepted === null ? "" : ` (accepted ${day(accepted)})`;
|
|
2719
|
+
parts.push(`SUPERSEDED by ${display(target)}${date}. Do not apply this decision; read ${refer(target)} instead.`);
|
|
2720
|
+
if (view.chain.length > 2) parts.push(`Chain: ${view.chain.map(display).join(" → ")}.`);
|
|
2721
|
+
break;
|
|
2722
|
+
}
|
|
2723
|
+
case "deprecated":
|
|
2724
|
+
parts.push(`DEPRECATED on ${when}: ${reason ?? "no reason given"}. No decision replaces it.`);
|
|
2725
|
+
break;
|
|
2726
|
+
case "rejected":
|
|
2727
|
+
parts.push(`REJECTED on ${when}: ${reason ?? "no reason given"}. This decision was never in force.`);
|
|
2728
|
+
break;
|
|
2729
|
+
case "withdrawn": parts.push(`WITHDRAWN on ${when}${reason === null ? "" : `: ${reason}`}. This decision was never in force.`);
|
|
2730
|
+
}
|
|
2731
|
+
if (view.body_intact === false) parts.push("WARNING: the body changed after acceptance; run adr verify.");
|
|
2732
|
+
return {
|
|
2733
|
+
line,
|
|
2734
|
+
warning: parts.length === 0 ? null : parts.join(" ")
|
|
2735
|
+
};
|
|
2736
|
+
}
|
|
2737
|
+
// src/scope.ts
|
|
2738
|
+
const MAX_PATHS = 100;
|
|
2739
|
+
const MAX_PATH_LENGTH = 4096;
|
|
2740
|
+
/** Statuses whose globs govern code; rejected and withdrawn decisions were never in force. */
|
|
2741
|
+
const GLOBS_APPLY = /* @__PURE__ */ new Set([
|
|
2742
|
+
"proposed",
|
|
2743
|
+
"accepted",
|
|
2744
|
+
"deprecated",
|
|
2745
|
+
"superseded"
|
|
2746
|
+
]);
|
|
2747
|
+
const STATUS_ORDER = [
|
|
2748
|
+
"accepted",
|
|
2749
|
+
"proposed",
|
|
2750
|
+
"deprecated",
|
|
2751
|
+
"superseded",
|
|
2752
|
+
"rejected",
|
|
2753
|
+
"withdrawn"
|
|
2754
|
+
];
|
|
2755
|
+
/** Accepted first (design §6.9 step 5); within a status by number, then by id. */
|
|
2756
|
+
function compareFor(a, b) {
|
|
2757
|
+
const s = STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status);
|
|
2758
|
+
if (s !== 0) return s;
|
|
2759
|
+
if (a.number !== null || b.number !== null) {
|
|
2760
|
+
if (a.number === null) return 1;
|
|
2761
|
+
if (b.number === null) return -1;
|
|
2762
|
+
return a.number - b.number;
|
|
2763
|
+
}
|
|
2764
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
2765
|
+
}
|
|
2766
|
+
/** `path` (relative to `cwd`) as a repo-relative POSIX path; `UsageError` outside the work tree. */
|
|
2767
|
+
async function repoPath(root, cwd, path) {
|
|
2768
|
+
if (path.length > MAX_PATH_LENGTH) throw new UsageError(`a path can be at most ${MAX_PATH_LENGTH} characters`);
|
|
2769
|
+
const base = await realpath(cwd).catch(() => cwd);
|
|
2770
|
+
const rel = relative(root, resolve(base, path));
|
|
2771
|
+
if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) throw new UsageError(`${path} is outside the repository`);
|
|
2772
|
+
return rel.split(sep).join("/");
|
|
2773
|
+
}
|
|
2774
|
+
/**
|
|
2775
|
+
* Which decisions govern each path (design §6.9): globs of decisions that were ever in force, and
|
|
2776
|
+
* comment anchors of any decision, read from the work tree or, with a `--ref` log, that commit.
|
|
2777
|
+
* Paths are relative to `o.cwd` (default the repository root); directories and paths outside the
|
|
2778
|
+
* repository are `UsageError`s. Paths need not be tracked.
|
|
2779
|
+
*/
|
|
2780
|
+
async function decisionsFor(read, log, o) {
|
|
2781
|
+
const root = log.git.root;
|
|
2782
|
+
if (root === null) throw new UsageError("adr for needs a work tree");
|
|
2783
|
+
if (o.paths.length === 0 || o.paths.length > MAX_PATHS) throw new UsageError(`adr for takes 1 to ${MAX_PATHS} paths`);
|
|
2784
|
+
let index = null;
|
|
2785
|
+
const indexed = () => {
|
|
2786
|
+
index ??= indexReader(log.git);
|
|
2787
|
+
return index;
|
|
2788
|
+
};
|
|
2789
|
+
const hasUnder = async (tree, path) => (await tree.list(path)).some((e) => e.path !== path);
|
|
2790
|
+
const paths = [];
|
|
2791
|
+
for (const given of o.paths) {
|
|
2792
|
+
const path = await repoPath(root, o.cwd ?? root, given);
|
|
2793
|
+
let directory = path === "";
|
|
2794
|
+
if (!directory && read.ref === null) {
|
|
2795
|
+
const st = await lstat(join(root, path)).catch(() => null);
|
|
2796
|
+
directory = st === null ? await hasUnder(await indexed(), path) : st.isDirectory();
|
|
2797
|
+
} else if (!directory) directory = await hasUnder(read.tree, path);
|
|
2798
|
+
if (directory) throw new UsageError(`${given} is a directory; adr for takes file paths, for example adr for $(git ls-files ${given})`);
|
|
2799
|
+
if (!paths.includes(path)) paths.push(path);
|
|
2800
|
+
}
|
|
2801
|
+
const atRef = read.ref !== null && read.commit !== null;
|
|
2802
|
+
const trackedIn = atRef ? read.tree : await indexed();
|
|
2803
|
+
const tracked = /* @__PURE__ */ new Set();
|
|
2804
|
+
for (const p of paths) if ((await trackedIn.list(p)).some((e) => e.path === p)) tracked.add(p);
|
|
2805
|
+
const anchors = await scanAnchors(log.git, {
|
|
2806
|
+
keyword: read.adr.anchorKeyword,
|
|
2807
|
+
tree: atRef ? { commit: read.commit } : "worktree",
|
|
2808
|
+
paths,
|
|
2809
|
+
excludeDirs: [read.layout.root],
|
|
2810
|
+
exclude: read.adr.anchorExclude
|
|
2811
|
+
});
|
|
2812
|
+
const views = read.projection.views;
|
|
2813
|
+
const lookup = scopeLookup({
|
|
2814
|
+
subjects: [...views.values()].filter((v) => GLOBS_APPLY.has(v.status)).map((v) => ({
|
|
2815
|
+
id: v.id,
|
|
2816
|
+
globs: v.applies_to
|
|
2817
|
+
})),
|
|
2818
|
+
anchors
|
|
2819
|
+
});
|
|
2820
|
+
const listed = /* @__PURE__ */ new Set();
|
|
2821
|
+
const out = paths.map((path) => {
|
|
2822
|
+
const decisions = [];
|
|
2823
|
+
const unresolved = [];
|
|
2824
|
+
for (const m of lookup(path)) {
|
|
2825
|
+
const view = m.source === null ? views.get(m.id) : void 0;
|
|
2826
|
+
if (view === void 0) {
|
|
2827
|
+
for (const v of m.via) {
|
|
2828
|
+
if (v.type !== "anchor") continue;
|
|
2829
|
+
unresolved.push({
|
|
2830
|
+
ref: m.source === null ? m.id : `${m.source}:${m.id}`,
|
|
2831
|
+
line: v.line,
|
|
2832
|
+
reason: m.source === null ? "not-found" : "other-log"
|
|
2833
|
+
});
|
|
2834
|
+
}
|
|
2835
|
+
continue;
|
|
2836
|
+
}
|
|
2837
|
+
decisions.push({
|
|
2838
|
+
view,
|
|
2839
|
+
via: m.via
|
|
2840
|
+
});
|
|
2841
|
+
listed.add(view.id);
|
|
2842
|
+
}
|
|
2843
|
+
decisions.sort((a, b) => compareFor(a.view, b.view));
|
|
2844
|
+
unresolved.sort((a, b) => a.line - b.line);
|
|
2845
|
+
return {
|
|
2846
|
+
path,
|
|
2847
|
+
tracked: tracked.has(path),
|
|
2848
|
+
decisions: decisions.map(({ view: v, via }) => ({
|
|
2849
|
+
id: v.id,
|
|
2850
|
+
display: v.display,
|
|
2851
|
+
title: v.title,
|
|
2852
|
+
status: v.status,
|
|
2853
|
+
effective: v.effective,
|
|
2854
|
+
warning: stateHeader(v, read).warning,
|
|
2855
|
+
via
|
|
2856
|
+
})),
|
|
2857
|
+
unresolved
|
|
2858
|
+
};
|
|
2859
|
+
});
|
|
2860
|
+
return {
|
|
2861
|
+
format: "adr-for/v1",
|
|
2862
|
+
root: read.root,
|
|
2863
|
+
ref: read.ref,
|
|
2864
|
+
commit: read.commit,
|
|
2865
|
+
numbering: numberingJson(read),
|
|
2866
|
+
paths: out,
|
|
2867
|
+
findings: read.findings.filter((f) => f.subject === null || listed.has(f.subject)),
|
|
2868
|
+
notes: read.notes
|
|
2869
|
+
};
|
|
2870
|
+
}
|
|
2871
|
+
export { openDecisionLog as A, adrChecks as C, runDecisionIndex as D, renderDecisionIndex as E, parseAdrConfig as F, readDecisionsFrom as M, resolveDecision as N, changedSince as O, DEFAULT_ADR_CONFIG as P, withdrawDecision as S, indexDecisionLog as T, proposeDecision as _, initDecisionLog as a, scopeToCopy as b, installDecisionHooks as c, uninstallDecisionHooks as d, acceptDecision as f, noteDecision as g, linkDecision as h, stateHeader as i, readDecisions as j, numberingNote as k, printDecisionHooks as l, followScopeRenames as m, listDecisions as n, addHookTrailers as o, deprecateDecision as p, showDecision as r, decisionHookCommands as s, decisionsFor as t, runPreCommitHook as u, rejectDecision as v, verifyDecisions as w, supersedeDecision as x, scopeDecision as y };
|
|
1985
2872
|
|
|
1986
|
-
//# sourceMappingURL=
|
|
2873
|
+
//# sourceMappingURL=scope-CLqH-q1y.js.map
|