@nikovirtala/adr 0.3.0 → 0.5.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 +62 -13
- package/dist/cli.js +208 -48
- package/dist/cli.js.map +1 -1
- package/dist/index.js +5 -3
- package/dist/init-CaKKCINh.js +651 -0
- package/dist/init-CaKKCINh.js.map +1 -0
- package/dist/{kind-DjvA3dyA.js → kind-C3DiBuUp.js} +44 -11
- package/dist/kind-C3DiBuUp.js.map +1 -0
- package/dist/kind.js +1 -1
- package/dist/list-CJi9vZqJ.js +58 -0
- package/dist/list-CJi9vZqJ.js.map +1 -0
- package/dist/{scope-CLqH-q1y.js → search-CTspg2pd.js} +644 -550
- package/dist/search-CTspg2pd.js.map +1 -0
- package/dist/server-D3ySzyj6.js +950 -0
- package/dist/server-D3ySzyj6.js.map +1 -0
- package/dist/types/cli/init.d.ts +5 -2
- package/dist/types/cli/list.d.ts +4 -1
- package/dist/types/cli/mcp.d.ts +6 -0
- package/dist/types/cli/search.d.ts +6 -0
- package/dist/types/cli/text.d.ts +6 -0
- package/dist/types/commit-actions.d.ts +23 -1
- package/dist/types/hooks.d.ts +3 -2
- package/dist/types/index.d.ts +7 -3
- package/dist/types/init.d.ts +16 -1
- package/dist/types/kind/rules.d.ts +23 -2
- package/dist/types/kind.d.ts +1 -1
- package/dist/types/log-index.d.ts +0 -18
- package/dist/types/log.d.ts +13 -0
- package/dist/types/mcp/policy.d.ts +33 -0
- package/dist/types/mcp/prompt.d.ts +7 -0
- package/dist/types/mcp/resources.d.ts +16 -0
- package/dist/types/mcp/server.d.ts +16 -0
- package/dist/types/mcp/text.d.ts +42 -0
- package/dist/types/mcp/tools.d.ts +27 -0
- package/dist/types/scope.d.ts +3 -1
- package/dist/types/search.d.ts +63 -0
- package/dist/types/skill.d.ts +5 -0
- package/dist/types/sources.d.ts +35 -0
- package/dist/types/templates/list.d.ts +31 -0
- package/dist/types/verify.d.ts +20 -4
- package/dist/types/views.d.ts +5 -1
- package/dist/types/write.d.ts +76 -5
- package/package.json +5 -2
- package/dist/kind-DjvA3dyA.js.map +0 -1
- package/dist/scope-CLqH-q1y.js.map +0 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { t as adrKind } from "./kind-
|
|
2
|
-
import { CommitFailedError, ConfigError, ENVELOPE_VERSION, EventExistsError, FileExistsError, InvalidArgumentError, MemoryTree,
|
|
3
|
-
import {
|
|
1
|
+
import { t as adrKind } from "./kind-C3DiBuUp.js";
|
|
2
|
+
import { CommitFailedError, ConfigError, ENVELOPE_VERSION, EventExistsError, FileExistsError, InvalidArgumentError, MemoryTree, NO_NUMBERING, UsageError, applySetOp, bodyFileName, bodySha256, classifyPath, commitWithRecovery, compareEvents, compileKind, diffTrees, escapeMarkdown, eventDir, eventPath, eventTrailers, extractSections, finding, firstLine, foldLog, followRenames, globMatcher, headBytes, headTree, historyTemplates, holdsText, indexCheck, indexReader, invariantsCheck, isSubjectId, joinActions, lintDocument, lintTemplate, loadFindings, loadLog, newSubjectId, nextEventIdentity, numberLog, openToolLog, overlayTree, parseCommitTemplate, parseEventFileName, parseGlob, parseTemplate, projectLog, projectNumbering, readCommitTemplate, readIndexNumbers, readSources, refReader, relateCheck, renderCommitMessage, renderIndex, renderTemplate, replaceFile, replaceSections, resolveSubjectInput, saveCommitRecovery, scanAnchors, scopeLookup, serializeEvent, slugify, sortFindings, sourceLogRef, sourcesOfKinds, stagedRenames, templateHistoryRef, transitions, verifyLog, withoutPaths, worktreeReader, writeEvents, writeIndexCopy, writeNewFile } from "@nikovirtala/ledger";
|
|
3
|
+
import { lstat, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import { isAbsolute, join, posix, relative, resolve, sep } from "node:path";
|
|
5
5
|
// src/commit-actions.ts
|
|
6
6
|
/**
|
|
@@ -45,8 +45,34 @@ const ACTIONS = {
|
|
|
45
45
|
names: "display"
|
|
46
46
|
}
|
|
47
47
|
};
|
|
48
|
-
/**
|
|
49
|
-
|
|
48
|
+
/**
|
|
49
|
+
* The verb of each display-named event type in a batch subject (?6, design §4.4), and the order
|
|
50
|
+
* they are joined in: `link and scope ADR-0003`, `add a note to` last so it reads before the name.
|
|
51
|
+
*/
|
|
52
|
+
const BATCH_ACTIONS = {
|
|
53
|
+
deprecated: "deprecate",
|
|
54
|
+
superseded: "supersede",
|
|
55
|
+
linked: "link",
|
|
56
|
+
scoped: "scope",
|
|
57
|
+
noted: "add a note to"
|
|
58
|
+
};
|
|
59
|
+
const BATCH_ORDER = [
|
|
60
|
+
"deprecated",
|
|
61
|
+
"superseded",
|
|
62
|
+
"linked",
|
|
63
|
+
"scoped",
|
|
64
|
+
"noted"
|
|
65
|
+
];
|
|
66
|
+
/** `{action}` of a batch of more than three verbs. */
|
|
67
|
+
const UPDATE_ACTION = "update";
|
|
68
|
+
/** `{action}` of a draft update (`adr_update_draft`), whose title slot holds the display. */
|
|
69
|
+
const DRAFT_ACTION = "update the draft of";
|
|
70
|
+
/**
|
|
71
|
+
* The longest `{action}` that goes with a title, which `[commit] subject` must leave a title room
|
|
72
|
+
* for. A batch action (up to `LONGEST_BATCH`) only ever goes with a display, so `COMMIT_NAMES`
|
|
73
|
+
* covers it instead.
|
|
74
|
+
*/
|
|
75
|
+
const LONGEST_ACTION = Math.max(...Object.values(ACTIONS).map((a) => a.action.length), 10, 19);
|
|
50
76
|
/** What a link's title slot names after the display when the target is in another log. */
|
|
51
77
|
const CROSS_LOG_LINK = "to a decision in another log";
|
|
52
78
|
/** adr's commit-subject placeholders, each with its longest value (design §4.2). */
|
|
@@ -75,6 +101,10 @@ const COMMIT_NAMES = Object.freeze([
|
|
|
75
101
|
{
|
|
76
102
|
length: 18,
|
|
77
103
|
vars: { action: 10 }
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
length: 13,
|
|
107
|
+
vars: { action: 6 }
|
|
78
108
|
}
|
|
79
109
|
]);
|
|
80
110
|
// src/config.ts
|
|
@@ -206,7 +236,8 @@ async function readDecisions(log) {
|
|
|
206
236
|
numbering: await numberDecisions(log),
|
|
207
237
|
ref: log.ref,
|
|
208
238
|
commit: log.commit,
|
|
209
|
-
notes: log.notes
|
|
239
|
+
notes: log.notes,
|
|
240
|
+
sources: (log.config?.sources ?? []).map((s) => s.name)
|
|
210
241
|
});
|
|
211
242
|
}
|
|
212
243
|
/** The display numbers `readDecisions` uses, with its shallow-clone fallback to `index.md`. */
|
|
@@ -221,17 +252,17 @@ async function numberDecisions(log) {
|
|
|
221
252
|
} } : {}
|
|
222
253
|
});
|
|
223
254
|
}
|
|
224
|
-
const compiled$
|
|
255
|
+
const compiled$2 = compileKind(adrKind);
|
|
225
256
|
/** The git-free core of `readDecisions`; tests pass a MemoryTree and a hand-made `Numbering`. */
|
|
226
257
|
async function readDecisionsFrom(tree, o) {
|
|
227
258
|
const { layout, adr, numbering } = o;
|
|
228
259
|
const snapshot = await loadLog(tree, {
|
|
229
|
-
kind: compiled$
|
|
260
|
+
kind: compiled$2,
|
|
230
261
|
layout
|
|
231
262
|
});
|
|
232
263
|
const folded = foldLog(adrKind, snapshot);
|
|
233
264
|
const ctx = {
|
|
234
|
-
kind: compiled$
|
|
265
|
+
kind: compiled$2,
|
|
235
266
|
layout,
|
|
236
267
|
base: null,
|
|
237
268
|
head: snapshot,
|
|
@@ -243,7 +274,10 @@ async function readDecisionsFrom(tree, o) {
|
|
|
243
274
|
base: null
|
|
244
275
|
}
|
|
245
276
|
};
|
|
246
|
-
const options = {
|
|
277
|
+
const options = {
|
|
278
|
+
acceptRequiresHuman: adr.acceptRequiresHuman,
|
|
279
|
+
sources: null
|
|
280
|
+
};
|
|
247
281
|
const findings = [];
|
|
248
282
|
for (const check of [
|
|
249
283
|
loadFindings,
|
|
@@ -265,10 +299,25 @@ async function readDecisionsFrom(tree, o) {
|
|
|
265
299
|
...f,
|
|
266
300
|
kind: adrKind.name
|
|
267
301
|
}))),
|
|
268
|
-
notes: o.notes ?? []
|
|
302
|
+
notes: o.notes ?? [],
|
|
303
|
+
sources: o.sources ?? [],
|
|
304
|
+
source: o.source ?? null
|
|
269
305
|
};
|
|
270
306
|
}
|
|
271
307
|
/**
|
|
308
|
+
* The note listing reads add for a log with `[[sources]]`: the other logs are listed with
|
|
309
|
+
* `--source`, from cached copies. `null` without sources, and for a `--source` read.
|
|
310
|
+
*/
|
|
311
|
+
function sourcesNote(read) {
|
|
312
|
+
if (read.sources.length === 0 || read.source !== null) return null;
|
|
313
|
+
return `the logs in [[sources]] (${read.sources.join(", ")}) are listed with --source <name>, from cached copies; run adr sources fetch to update them`;
|
|
314
|
+
}
|
|
315
|
+
/** `read.notes`, then the sources note when there is one. */
|
|
316
|
+
function listingNotes(read) {
|
|
317
|
+
const note = sourcesNote(read);
|
|
318
|
+
return note === null ? read.notes : [...read.notes, note];
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
272
321
|
* The note a read command adds about where numbers come from: the shallow-clone index, or why
|
|
273
322
|
* they are unavailable (`unavailable: false` leaves that one out); `null` for none.
|
|
274
323
|
*/
|
|
@@ -489,37 +538,6 @@ function decisionIndexCheck(log) {
|
|
|
489
538
|
}
|
|
490
539
|
};
|
|
491
540
|
}
|
|
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]);
|
|
498
|
-
}
|
|
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);
|
|
507
|
-
}
|
|
508
|
-
/**
|
|
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.
|
|
512
|
-
*/
|
|
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)
|
|
519
|
-
};
|
|
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
541
|
/**
|
|
524
542
|
* `adr index` (design 2b §4.5) with what the text output needs; `indexDecisionLog` is the
|
|
525
543
|
* library entry.
|
|
@@ -645,105 +663,115 @@ async function runDecisionIndex(log, o = {}) {
|
|
|
645
663
|
async function indexDecisionLog(log, o = {}) {
|
|
646
664
|
return (await runDecisionIndex(log, o)).doc;
|
|
647
665
|
}
|
|
648
|
-
// src/
|
|
649
|
-
/**
|
|
650
|
-
const
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
if (
|
|
655
|
-
return
|
|
666
|
+
// src/sources.ts
|
|
667
|
+
/** The kinds adr reads from `[[sources]]`: only decision logs. */
|
|
668
|
+
const DECISION_SOURCE_KINDS = ["adr"];
|
|
669
|
+
const compiled$1 = compileKind(adrKind);
|
|
670
|
+
/** Numbers recorded in a source's committed index, as a `Numbering`. */
|
|
671
|
+
function indexNumbering(byId) {
|
|
672
|
+
if (byId === null) return NO_NUMBERING;
|
|
673
|
+
return {
|
|
674
|
+
available: true,
|
|
675
|
+
reason: "index",
|
|
676
|
+
ref: null,
|
|
677
|
+
commit: null,
|
|
678
|
+
byId,
|
|
679
|
+
byNumber: new Map([...byId].map(([id, n]) => [n, id]))
|
|
680
|
+
};
|
|
656
681
|
}
|
|
657
|
-
/**
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
function governedByInactive(log) {
|
|
682
|
+
/** The `[[sources]]` entries adr reads and the ones it skips, in config order. */
|
|
683
|
+
function decisionSourceConfigs(log) {
|
|
684
|
+
const all = log.config?.sources ?? [];
|
|
685
|
+
const sources = sourcesOfKinds(all, DECISION_SOURCE_KINDS);
|
|
662
686
|
return {
|
|
663
|
-
|
|
664
|
-
|
|
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
|
+
sources,
|
|
688
|
+
skipped: all.filter((s) => !sources.includes(s))
|
|
687
689
|
};
|
|
688
690
|
}
|
|
689
691
|
/**
|
|
690
|
-
*
|
|
691
|
-
*
|
|
692
|
+
* Reads the decision logs of the `kind = "adr"` `[[sources]]` entries (other kinds are never
|
|
693
|
+
* fetched): from the local cache only with `fetch: "cache"` (the default), else fetched first
|
|
694
|
+
* (`readSources`; a failed fetch throws `SourceFetchError`). A cached source's decisions are folded
|
|
695
|
+
* with adr's kind and numbered from its committed `index.md` (the cache is a depth-1 clone, so
|
|
696
|
+
* history numbering can't work), else shown by id.
|
|
692
697
|
*/
|
|
693
|
-
function
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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
|
-
});
|
|
698
|
+
async function readDecisionSources(log, o = {}) {
|
|
699
|
+
const { sources } = decisionSourceConfigs(log);
|
|
700
|
+
const reads = await readSources(log.git, sources, { fetch: o.fetch ?? "cache" });
|
|
701
|
+
const logs = /* @__PURE__ */ new Map();
|
|
702
|
+
const notes = [];
|
|
703
|
+
for (const r of reads) {
|
|
704
|
+
if (r.source === null) {
|
|
705
|
+
logs.set(r.config.name, {
|
|
706
|
+
subjects: null,
|
|
707
|
+
commit: null
|
|
712
708
|
});
|
|
709
|
+
continue;
|
|
713
710
|
}
|
|
711
|
+
const ref = sourceLogRef(r.source, {
|
|
712
|
+
layout: adrKind.def.layout,
|
|
713
|
+
defaultRoot: adrKind.def.defaultRoot
|
|
714
|
+
});
|
|
715
|
+
const snapshot = await loadLog(ref.tree, {
|
|
716
|
+
kind: compiled$1,
|
|
717
|
+
layout: ref.layout
|
|
718
|
+
});
|
|
719
|
+
if (snapshot.subjects.size === 0) notes.push(`source ${r.config.name} has no decision log at ${ref.layout.root} in ${r.source.commit.slice(0, 12)}`);
|
|
720
|
+
const numbering = indexNumbering(await readIndexNumbers(ref.tree, `${ref.layout.root}/index.md`));
|
|
721
|
+
const projected = projectLog(adrKind, foldLog(adrKind, snapshot), { numbering }).views;
|
|
722
|
+
logs.set(r.config.name, {
|
|
723
|
+
commit: r.source.commit,
|
|
724
|
+
subjects: new Map([...projected].map(([id, v]) => [id, {
|
|
725
|
+
status: v.status,
|
|
726
|
+
display: v.display,
|
|
727
|
+
title: v.title
|
|
728
|
+
}]))
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
return {
|
|
732
|
+
logs,
|
|
733
|
+
statuses: reads.map((r) => r.status),
|
|
734
|
+
notes
|
|
714
735
|
};
|
|
715
736
|
}
|
|
737
|
+
const SOURCE_NAME$1 = /^[a-z][a-z0-9-]{0,31}$/;
|
|
716
738
|
/**
|
|
717
|
-
*
|
|
718
|
-
*
|
|
719
|
-
*
|
|
720
|
-
*
|
|
721
|
-
* also the freshness of `<root>/index.md`.
|
|
739
|
+
* The cached decision log of the `[[sources]]` entry `name`, as a `DecisionsRead` for `adr list
|
|
740
|
+
* --source` and `adr search --source` (cache only, never the network). An uncached source reads as
|
|
741
|
+
* an empty log with the note `<name> not fetched; run adr sources fetch`. Throws `UsageError` for a
|
|
742
|
+
* name that isn't configured or whose kind adr doesn't read.
|
|
722
743
|
*/
|
|
723
|
-
function
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
...log.
|
|
737
|
-
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
744
|
+
async function readSourceDecisions(log, name) {
|
|
745
|
+
if (!SOURCE_NAME$1.test(name)) throw new UsageError(`--source must match ${SOURCE_NAME$1.source}: ${name}`);
|
|
746
|
+
const { sources, skipped } = decisionSourceConfigs(log);
|
|
747
|
+
const other = skipped.find((s) => s.name === name);
|
|
748
|
+
if (other !== void 0) throw new UsageError(`source ${name} has kind ${other.kind}, which adr doesn't read`);
|
|
749
|
+
const config = sources.find((s) => s.name === name);
|
|
750
|
+
if (config === void 0) {
|
|
751
|
+
const known = [...sources, ...skipped].map((s) => s.name);
|
|
752
|
+
throw new UsageError(known.length === 0 ? `unknown source ${name}; there are no sources` : `unknown source ${name}; the sources are ${known.join(", ")}`);
|
|
753
|
+
}
|
|
754
|
+
const [r] = await readSources(log.git, [config], { fetch: "cache" });
|
|
755
|
+
const base = {
|
|
756
|
+
adr: log.adr,
|
|
757
|
+
notes: [...log.notes],
|
|
758
|
+
source: name
|
|
759
|
+
};
|
|
760
|
+
if (r?.source === void 0 || r.source === null) return readDecisionsFrom(new MemoryTree({}), {
|
|
761
|
+
...base,
|
|
762
|
+
layout: adrKind.layoutAt(config.root ?? adrKind.def.defaultRoot),
|
|
763
|
+
numbering: NO_NUMBERING,
|
|
764
|
+
notes: [...base.notes, `${name} not fetched; run adr sources fetch`]
|
|
765
|
+
});
|
|
766
|
+
const ref = sourceLogRef(r.source, {
|
|
767
|
+
layout: adrKind.def.layout,
|
|
768
|
+
defaultRoot: adrKind.def.defaultRoot
|
|
769
|
+
});
|
|
770
|
+
return readDecisionsFrom(ref.tree, {
|
|
771
|
+
...base,
|
|
772
|
+
layout: ref.layout,
|
|
773
|
+
numbering: indexNumbering(await readIndexNumbers(ref.tree, `${ref.layout.root}/index.md`)),
|
|
774
|
+
commit: r.source.commit
|
|
747
775
|
});
|
|
748
776
|
}
|
|
749
777
|
/** The built-in templates by name: the file text `adr new` materializes. */
|
|
@@ -855,8 +883,8 @@ sections:
|
|
|
855
883
|
`
|
|
856
884
|
});
|
|
857
885
|
// src/write.ts
|
|
858
|
-
const encoder
|
|
859
|
-
const decoder$
|
|
886
|
+
const encoder = new TextEncoder();
|
|
887
|
+
const decoder$2 = new TextDecoder();
|
|
860
888
|
const compiled = compileKind(adrKind);
|
|
861
889
|
const TEMPLATE_NAME = /^[a-z][a-z0-9-]*$/;
|
|
862
890
|
const MAX_TEXT_BYTES = 65536;
|
|
@@ -867,7 +895,7 @@ const templatePath = (read, name) => `${read.root}/templates/${name}.md`;
|
|
|
867
895
|
/** A text argument within the 64 KiB cap and without NUL characters, else `UsageError`. */
|
|
868
896
|
function checkText(what, text) {
|
|
869
897
|
if (text.includes("\0")) throw new UsageError(`${what} must not contain NUL characters`);
|
|
870
|
-
const size = encoder
|
|
898
|
+
const size = encoder.encode(text).byteLength;
|
|
871
899
|
if (size > MAX_TEXT_BYTES) throw new UsageError(`${what} is ${size} bytes; the limit is ${MAX_TEXT_BYTES} (64 KiB)`);
|
|
872
900
|
}
|
|
873
901
|
function checkReason(reason) {
|
|
@@ -947,14 +975,18 @@ const findingKey = (f) => [
|
|
|
947
975
|
* The precheck (§6.3): folds the would-be log and refuses when the write adds any finding. Problems
|
|
948
976
|
* that were already there don't block; the write can only add findings about what it touches.
|
|
949
977
|
*/
|
|
950
|
-
async function precheck(read, files) {
|
|
951
|
-
const after = await readDecisionsFrom(overlayTree(read.tree,
|
|
978
|
+
async function precheck(read, files, replace = {}) {
|
|
979
|
+
const after = await readDecisionsFrom(overlayTree(read.tree, {
|
|
980
|
+
...files,
|
|
981
|
+
...replace
|
|
982
|
+
}), {
|
|
952
983
|
layout: read.layout,
|
|
953
984
|
adr: read.adr,
|
|
954
985
|
numbering: read.numbering,
|
|
955
986
|
ref: read.ref,
|
|
956
987
|
commit: read.commit,
|
|
957
|
-
notes: read.notes
|
|
988
|
+
notes: read.notes,
|
|
989
|
+
sources: read.sources
|
|
958
990
|
});
|
|
959
991
|
const before = new Set(read.findings.map(findingKey));
|
|
960
992
|
return {
|
|
@@ -963,7 +995,7 @@ async function precheck(read, files) {
|
|
|
963
995
|
};
|
|
964
996
|
}
|
|
965
997
|
function filesOf(events, read) {
|
|
966
|
-
return Object.fromEntries(events.map((e) => [eventPath(read.layout, e), encoder
|
|
998
|
+
return Object.fromEntries(events.map((e) => [eventPath(read.layout, e), encoder.encode(serializeEvent(e))]));
|
|
967
999
|
}
|
|
968
1000
|
const pairsOf = (events) => events.flatMap((e) => [["ADR", e.subject], ["ADR-Event", e.type]]);
|
|
969
1001
|
/** The template `new` uses: the repo's `<root>/templates/<name>.md`, else a built-in to copy. */
|
|
@@ -973,7 +1005,7 @@ async function chooseTemplate(read, name, c) {
|
|
|
973
1005
|
const bytes = await read.tree.read(path);
|
|
974
1006
|
let text;
|
|
975
1007
|
let materialize = null;
|
|
976
|
-
if (bytes !== null) text = decoder$
|
|
1008
|
+
if (bytes !== null) text = decoder$2.decode(bytes);
|
|
977
1009
|
else {
|
|
978
1010
|
const builtin = Object.hasOwn(c.builtins, name) ? c.builtins[name] : void 0;
|
|
979
1011
|
if (builtin === void 0) {
|
|
@@ -1060,9 +1092,9 @@ async function planPropose(read, input, c, o = {}) {
|
|
|
1060
1092
|
},
|
|
1061
1093
|
actor: input.actor
|
|
1062
1094
|
});
|
|
1063
|
-
const body = encoder
|
|
1095
|
+
const body = encoder.encode(rendered.text);
|
|
1064
1096
|
const files = { [bodyPath]: body };
|
|
1065
|
-
if (chosen.materialize !== null) files[chosen.path] = encoder
|
|
1097
|
+
if (chosen.materialize !== null) files[chosen.path] = encoder.encode(chosen.materialize);
|
|
1066
1098
|
Object.assign(files, filesOf([event], read));
|
|
1067
1099
|
const { after, added } = await precheck(read, files);
|
|
1068
1100
|
if (added.length > 0) return {
|
|
@@ -1140,6 +1172,126 @@ async function acceptLint(read, view, c) {
|
|
|
1140
1172
|
bytes: body.bytes
|
|
1141
1173
|
};
|
|
1142
1174
|
}
|
|
1175
|
+
const MAX_SECTIONS = 50;
|
|
1176
|
+
/**
|
|
1177
|
+
* Plans a draft update (design §6.6): the given sections of a proposed decision's body replaced in
|
|
1178
|
+
* place, every other line kept, written only if the body still holds the bytes read. Refused
|
|
1179
|
+
* (`ok: false`) when the decision isn't proposed or its body or template version is missing or
|
|
1180
|
+
* unreadable, and with `refuseIncomplete` when the new draft fails its template lint. An unknown
|
|
1181
|
+
* section, a missing heading or a heading in the content is a `UsageError`.
|
|
1182
|
+
*/
|
|
1183
|
+
async function planUpdateDraft(read, input, c, o = {}) {
|
|
1184
|
+
const id = resolveDecision(read, input.id);
|
|
1185
|
+
const view = viewOf(read, id);
|
|
1186
|
+
const refused = (fs) => ({
|
|
1187
|
+
ok: false,
|
|
1188
|
+
decision: view,
|
|
1189
|
+
findings: fs
|
|
1190
|
+
});
|
|
1191
|
+
if (view.status !== "proposed") return refused([refusal(read, "decision/not-a-draft", `${namedView(view)} is ${view.status}; only a proposed decision's draft can change`, id)]);
|
|
1192
|
+
const ids = Object.keys(input.sections);
|
|
1193
|
+
if (ids.length === 0 || ids.length > MAX_SECTIONS) throw new UsageError(`a draft update takes 1 to ${MAX_SECTIONS} sections`);
|
|
1194
|
+
for (const [section, text] of Object.entries(input.sections)) checkText(`section ${section}`, text);
|
|
1195
|
+
const { findings: problems, template, text, body } = await draftOf(read, view, c);
|
|
1196
|
+
if (problems.length > 0 || template === null || text === null || body === null) return refused(problems);
|
|
1197
|
+
const r = replaceSections(template, text, input.sections);
|
|
1198
|
+
if (!r.ok) {
|
|
1199
|
+
const [first] = r.findings;
|
|
1200
|
+
if (first?.rule === "render/unknown-section") {
|
|
1201
|
+
const known = template.sections.map((s) => s.id).join(", ");
|
|
1202
|
+
const unknown = ids.find((s) => !template.sections.some((t) => t.id === s)) ?? "";
|
|
1203
|
+
throw new UsageError(`template ${template.name} has no section ${unknown}; its sections are ${known}`);
|
|
1204
|
+
}
|
|
1205
|
+
if (first?.rule === "render/section-missing") {
|
|
1206
|
+
const gone = new Set(extractSections(template, text).missing);
|
|
1207
|
+
const missing = template.sections.find((s) => ids.includes(s.id) && gone.has(s.id));
|
|
1208
|
+
throw new UsageError(`${body.path} has no '${missing?.heading ?? ""}' heading; edit the file by hand`);
|
|
1209
|
+
}
|
|
1210
|
+
throw new UsageError(r.findings.map((f) => f.message).join("; "));
|
|
1211
|
+
}
|
|
1212
|
+
if (r.text === text) return emptyPlan(id, view, ["the draft already has this content; nothing written"]);
|
|
1213
|
+
const bytes = encoder.encode(r.text);
|
|
1214
|
+
const lint = lintDocument(template, r.text, {
|
|
1215
|
+
file: body.path,
|
|
1216
|
+
subject: id
|
|
1217
|
+
}).map((f) => ({
|
|
1218
|
+
...f,
|
|
1219
|
+
kind: adrKind.name
|
|
1220
|
+
}));
|
|
1221
|
+
if (o.refuseIncomplete === true && lint.length > 0) return refused(lint);
|
|
1222
|
+
const { after, added } = await precheck(read, {}, { [body.path]: bytes });
|
|
1223
|
+
if (added.length > 0) return refused(added);
|
|
1224
|
+
const { scope, eventDirs } = commitScope(read, [id]);
|
|
1225
|
+
const order = template.sections.map((s) => s.id).filter((s) => ids.includes(s));
|
|
1226
|
+
return {
|
|
1227
|
+
ok: true,
|
|
1228
|
+
plan: {
|
|
1229
|
+
command: "event",
|
|
1230
|
+
subject: id,
|
|
1231
|
+
files: {},
|
|
1232
|
+
replace: { [body.path]: {
|
|
1233
|
+
bytes,
|
|
1234
|
+
expected: body.bytes
|
|
1235
|
+
} },
|
|
1236
|
+
events: [],
|
|
1237
|
+
stage: [body.path],
|
|
1238
|
+
involved: [id],
|
|
1239
|
+
commitScope: scope,
|
|
1240
|
+
eventDirs,
|
|
1241
|
+
draft: null,
|
|
1242
|
+
commit: {
|
|
1243
|
+
action: DRAFT_ACTION,
|
|
1244
|
+
title: view.display,
|
|
1245
|
+
id,
|
|
1246
|
+
display: view.display,
|
|
1247
|
+
body: [`Sections: ${order.join(", ")}.`]
|
|
1248
|
+
},
|
|
1249
|
+
trailers: [["ADR", id]],
|
|
1250
|
+
decision: after.projection.views.get(id) ?? view,
|
|
1251
|
+
affected: [],
|
|
1252
|
+
findings: lint
|
|
1253
|
+
}
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
/**
|
|
1257
|
+
* A proposed decision's body, its text and the template version its proposal recorded, or the
|
|
1258
|
+
* refusal findings when one of them is missing or the body isn't UTF-8.
|
|
1259
|
+
*/
|
|
1260
|
+
async function draftOf(read, view, c) {
|
|
1261
|
+
const none = {
|
|
1262
|
+
template: null,
|
|
1263
|
+
text: null,
|
|
1264
|
+
body: null
|
|
1265
|
+
};
|
|
1266
|
+
const body = read.folded.subjects.get(view.id)?.loaded.body ?? null;
|
|
1267
|
+
if (body === null) return {
|
|
1268
|
+
findings: [refusal(read, "body/missing", "body file is missing", view.id)],
|
|
1269
|
+
...none
|
|
1270
|
+
};
|
|
1271
|
+
const { name, version } = view.template;
|
|
1272
|
+
const found = await c.templates.get(name, version);
|
|
1273
|
+
if (!found.ok) {
|
|
1274
|
+
const message = found.reason === "not-found" ? `template ${name} not found at ${found.path}` : `template ${name} has no version ${version} at ${found.path}`;
|
|
1275
|
+
return {
|
|
1276
|
+
findings: [refusal(read, `template/${found.reason}`, message, view.id)],
|
|
1277
|
+
...none
|
|
1278
|
+
};
|
|
1279
|
+
}
|
|
1280
|
+
try {
|
|
1281
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(body.bytes);
|
|
1282
|
+
return {
|
|
1283
|
+
findings: [],
|
|
1284
|
+
template: found.template,
|
|
1285
|
+
text,
|
|
1286
|
+
body
|
|
1287
|
+
};
|
|
1288
|
+
} catch {
|
|
1289
|
+
return {
|
|
1290
|
+
findings: [refusal(read, "body/not-utf8", "body is not valid UTF-8", view.id)],
|
|
1291
|
+
...none
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1143
1295
|
/** Plans `adr accept`: `accepted` with the body hash, and `superseded` on each declared target. */
|
|
1144
1296
|
async function planAccept(read, input, c, o = {}) {
|
|
1145
1297
|
const id = resolveDecision(read, input.id);
|
|
@@ -1210,9 +1362,12 @@ async function planAccept(read, input, c, o = {}) {
|
|
|
1210
1362
|
const involved = sorted([id, ...targets]);
|
|
1211
1363
|
const { scope, eventDirs } = commitScope(read, involved);
|
|
1212
1364
|
const bodyPath = view.body_path ?? "";
|
|
1365
|
+
const globs = copied === null ? view.applies_to : copied.data.set;
|
|
1366
|
+
const unmatched = o.tracked === void 0 ? [] : unmatchedGlobs(globs, o.tracked);
|
|
1213
1367
|
return {
|
|
1214
1368
|
ok: true,
|
|
1215
1369
|
plan: {
|
|
1370
|
+
...unmatched.length === 0 ? {} : { notes: unmatched.map((g) => `${g} matches no tracked file; from this commit verify reports scope/glob-unmatched until a file matches, so commit the files with it or change the scope`) },
|
|
1216
1371
|
command: "accept",
|
|
1217
1372
|
subject: id,
|
|
1218
1373
|
files,
|
|
@@ -1440,8 +1595,9 @@ const LINK_RELS = [
|
|
|
1440
1595
|
const SOURCE_NAME = /^[a-z][a-z0-9-]{0,31}$/;
|
|
1441
1596
|
/**
|
|
1442
1597
|
* Plans `adr link` (design §6.7): `linked` in every state. A qualified target must name one of
|
|
1443
|
-
* `o.sources` (the log's `[[sources]]` names)
|
|
1444
|
-
*
|
|
1598
|
+
* `o.sources` (the log's `[[sources]]` names) of kind `adr` (`o.logs`, from the local cache): a
|
|
1599
|
+
* cached source must hold the id, else `UsageError`; into an uncached one the link is written with
|
|
1600
|
+
* a note that it isn't checked. A link that already exists plans nothing, with a note.
|
|
1445
1601
|
*/
|
|
1446
1602
|
async function planLink(read, input, c, o = {}) {
|
|
1447
1603
|
const rel = input.rel ?? "relates-to";
|
|
@@ -1453,12 +1609,19 @@ async function planLink(read, input, c, o = {}) {
|
|
|
1453
1609
|
let shown;
|
|
1454
1610
|
let described;
|
|
1455
1611
|
let named;
|
|
1612
|
+
let foreignNote = null;
|
|
1456
1613
|
const at = target.indexOf(":");
|
|
1457
1614
|
if (at >= 0) {
|
|
1458
1615
|
const source = target.slice(0, at);
|
|
1459
1616
|
const local = target.slice(at + 1);
|
|
1460
1617
|
if (!SOURCE_NAME.test(source) || !isSubjectId(local)) throw new UsageError(`invalid decision ${JSON.stringify(input.to)}`);
|
|
1461
1618
|
if (!(o.sources ?? []).includes(source)) throw new UsageError(`unknown source ${source}; add a [[sources]] entry named ${source}`);
|
|
1619
|
+
const kind = o.kinds?.get(source);
|
|
1620
|
+
if (kind !== void 0 && kind !== "adr") throw new UsageError(`source ${source} has kind ${kind}, which adr doesn't read`);
|
|
1621
|
+
const cached = o.logs?.get(source);
|
|
1622
|
+
if (cached?.subjects !== null && cached?.subjects !== void 0) {
|
|
1623
|
+
if (!cached.subjects.has(local)) throw new UsageError(`${target} is not in source ${source} (pinned at ${(cached.commit ?? "").slice(0, 12)})`);
|
|
1624
|
+
} else if (o.logs !== void 0) foreignNote = `${source} not fetched; the reference isn't checked`;
|
|
1462
1625
|
to = target;
|
|
1463
1626
|
shown = target;
|
|
1464
1627
|
described = `${rel} ${target}.`;
|
|
@@ -1509,7 +1672,8 @@ async function planLink(read, input, c, o = {}) {
|
|
|
1509
1672
|
...r.plan.commit,
|
|
1510
1673
|
title: named,
|
|
1511
1674
|
body: [described]
|
|
1512
|
-
}
|
|
1675
|
+
},
|
|
1676
|
+
notes: [...r.plan.notes ?? [], ...foreignNote === null ? [] : [foreignNote]]
|
|
1513
1677
|
}
|
|
1514
1678
|
};
|
|
1515
1679
|
}
|
|
@@ -1568,7 +1732,7 @@ async function planScope(read, input, c, o = {}) {
|
|
|
1568
1732
|
}
|
|
1569
1733
|
};
|
|
1570
1734
|
if (o.tracked !== void 0) {
|
|
1571
|
-
const checked = (adrKind.def.scope?.
|
|
1735
|
+
const checked = (adrKind.def.scope?.requireMatchIn ?? []).includes(view.status) ? "; verify (hooks and CI) reports scope/glob-unmatched until a file matches" : view.status === "proposed" ? `; verify reports scope/glob-unmatched once ${view.display} is accepted, until a file matches` : "";
|
|
1572
1736
|
notes.push(...unmatchedGlobs(result.set, o.tracked).map((g) => `${g} matches no tracked file${checked}`));
|
|
1573
1737
|
}
|
|
1574
1738
|
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");
|
|
@@ -1736,13 +1900,6 @@ const isYaml = (path) => /\.ya?ml$/.test(path);
|
|
|
1736
1900
|
*/
|
|
1737
1901
|
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
1902
|
/**
|
|
1739
|
-
* Applies a plan (§5.4): refuses a commit whose scope has unstaged or needed untracked paths,
|
|
1740
|
-
* writes the files (removing them again if a write fails), stages them, runs `afterStage`, and
|
|
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.
|
|
1744
|
-
*/
|
|
1745
|
-
/**
|
|
1746
1903
|
* A write leaves the work tree's render of `index.md` (`staged`) staged, and it lists decision
|
|
1747
1904
|
* files that may not be staged. When some aren't and the index's own render differs, the next
|
|
1748
1905
|
* plain commit's hook fails; the note says so, as its `index/stale` finding does. `null`
|
|
@@ -1755,9 +1912,17 @@ async function unstagedIndexNote(log, at, staged, history, render) {
|
|
|
1755
1912
|
if (own === null || own === staged) return null;
|
|
1756
1913
|
return `${unstagedIndexText(at, files)}; git add them before you commit, or move them out of the work tree and run adr index`;
|
|
1757
1914
|
}
|
|
1915
|
+
/**
|
|
1916
|
+
* Applies a plan (§5.4): refuses a commit whose scope has unstaged or needed untracked paths,
|
|
1917
|
+
* writes the files (removing them again if a write fails), stages them, runs `afterStage`, and
|
|
1918
|
+
* commits `plan.stage` plus the involved subjects' already-staged files with exact trailers. A
|
|
1919
|
+
* failed `git commit` throws `CommitFailedError` with the manual command (design 2b D5) and the
|
|
1920
|
+
* notes made so far.
|
|
1921
|
+
*/
|
|
1758
1922
|
async function applyPlan(log, plan, o) {
|
|
1759
1923
|
const { git } = log;
|
|
1760
|
-
if (o.commit && o.message === void 0) throw new InvalidArgumentError("applyPlan needs a message to commit");
|
|
1924
|
+
if (o.commit && o.message === void 0 && o.compose === void 0) throw new InvalidArgumentError("applyPlan needs a message to commit");
|
|
1925
|
+
if (o.message !== void 0 && o.compose !== void 0) throw new InvalidArgumentError("applyPlan takes a message or compose, not both");
|
|
1761
1926
|
const stage = new Set(plan.stage);
|
|
1762
1927
|
if (o.commit) {
|
|
1763
1928
|
const unstaged = new Set(await git.unstagedPaths(plan.commitScope));
|
|
@@ -1776,7 +1941,7 @@ async function applyPlan(log, plan, o) {
|
|
|
1776
1941
|
const files = {};
|
|
1777
1942
|
const gone = /* @__PURE__ */ new Set();
|
|
1778
1943
|
for (const p of sorted([...plan.stage, ...extra])) {
|
|
1779
|
-
const bytes = plan.files[p] ?? await work.read(p);
|
|
1944
|
+
const bytes = plan.files[p] ?? plan.replace?.[p]?.bytes ?? await work.read(p);
|
|
1780
1945
|
if (bytes === null) gone.add(p);
|
|
1781
1946
|
else files[p] = bytes;
|
|
1782
1947
|
}
|
|
@@ -1785,7 +1950,10 @@ async function applyPlan(log, plan, o) {
|
|
|
1785
1950
|
const extra0 = o.commit && o.index !== void 0 ? await extraOf() : [];
|
|
1786
1951
|
let text = null;
|
|
1787
1952
|
if (o.index !== void 0) {
|
|
1788
|
-
const tree = o.commit ? await commitTree(extra0) : overlayTree(worktreeReader(git),
|
|
1953
|
+
const tree = o.commit ? await commitTree(extra0) : overlayTree(worktreeReader(git), {
|
|
1954
|
+
...plan.files,
|
|
1955
|
+
...replaceBytes(plan)
|
|
1956
|
+
});
|
|
1789
1957
|
text = await o.index(tree, head);
|
|
1790
1958
|
if (text === null) notes.push(`${at} not updated: numbers need history in this shallow clone; run git fetch --unshallow, then adr index`);
|
|
1791
1959
|
}
|
|
@@ -1815,7 +1983,12 @@ async function applyPlan(log, plan, o) {
|
|
|
1815
1983
|
};
|
|
1816
1984
|
const eventPaths = new Set(plan.events.map((e) => eventPath(log.layout, e)));
|
|
1817
1985
|
const written = [];
|
|
1986
|
+
const replaced = [];
|
|
1818
1987
|
try {
|
|
1988
|
+
for (const [path, r] of Object.entries(plan.replace ?? {})) {
|
|
1989
|
+
await replaceFile(git, path, r.bytes, { expected: r.expected });
|
|
1990
|
+
replaced.push(path);
|
|
1991
|
+
}
|
|
1819
1992
|
for (const [path, bytes] of Object.entries(plan.files)) {
|
|
1820
1993
|
if (eventPaths.has(path)) continue;
|
|
1821
1994
|
await writeNewFile(git, path, bytes, { stage: false });
|
|
@@ -1833,6 +2006,10 @@ async function applyPlan(log, plan, o) {
|
|
|
1833
2006
|
else await writeFile(join(top, at), indexOld);
|
|
1834
2007
|
}
|
|
1835
2008
|
for (const path of written) await rm(join(top, path), { force: true });
|
|
2009
|
+
for (const path of replaced) {
|
|
2010
|
+
const r = plan.replace?.[path];
|
|
2011
|
+
if (r !== void 0) await replaceFile(git, path, r.expected, { expected: r.bytes }).catch(() => {});
|
|
2012
|
+
}
|
|
1836
2013
|
throw err;
|
|
1837
2014
|
}
|
|
1838
2015
|
await git.add([...plan.stage, ...toStage]);
|
|
@@ -1850,16 +2027,24 @@ async function applyPlan(log, plan, o) {
|
|
|
1850
2027
|
}
|
|
1851
2028
|
const stale = (err) => `${at} may be stale: ${firstLine(err)}; run adr index`;
|
|
1852
2029
|
let sha = null;
|
|
2030
|
+
let rendered = null;
|
|
1853
2031
|
if (commit) {
|
|
1854
2032
|
const extra = await extraOf();
|
|
1855
|
-
const
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
2033
|
+
const extraRefs = stagedEventRefs(log.layout, extra);
|
|
2034
|
+
const planRefs = plan.events.map((e) => ({
|
|
2035
|
+
subject: e.subject,
|
|
2036
|
+
type: e.type
|
|
2037
|
+
}));
|
|
2038
|
+
rendered = o.compose === void 0 ? null : o.compose({
|
|
2039
|
+
plan: planRefs,
|
|
2040
|
+
extra: extraRefs,
|
|
2041
|
+
tree: await commitTree(extra)
|
|
1859
2042
|
});
|
|
1860
|
-
const message = o.message ?? "";
|
|
2043
|
+
const message = rendered?.message ?? o.message ?? "";
|
|
1861
2044
|
const mergeTrailers = o.mergeTrailers === true;
|
|
1862
|
-
const
|
|
2045
|
+
const pairs = eventTrailers([...extraRefs, ...planRefs], TRAILER_KEYS);
|
|
2046
|
+
const named = new Set(pairs.filter(([k]) => k === "ADR").map(([, v]) => v));
|
|
2047
|
+
const trailers = [...pairs, ...plan.events.length === 0 ? plan.trailers.filter(([k, v]) => k !== "ADR" || !named.has(v)) : []];
|
|
1863
2048
|
const sameEvents = sorted(extra.filter(isYaml)).join("\0") === sorted(extra0.filter(isYaml)).join("\0");
|
|
1864
2049
|
if (text !== null && o.index !== void 0 && o.afterStage !== void 0 && !sameEvents) try {
|
|
1865
2050
|
const again = await o.index(await commitTree(extra), head);
|
|
@@ -1931,23 +2116,47 @@ async function applyPlan(log, plan, o) {
|
|
|
1931
2116
|
if (note !== null) notes.push(note);
|
|
1932
2117
|
}
|
|
1933
2118
|
return {
|
|
1934
|
-
written: [
|
|
2119
|
+
written: [
|
|
2120
|
+
...Object.keys(plan.replace ?? {}),
|
|
2121
|
+
...Object.keys(plan.files),
|
|
2122
|
+
...indexWritten ? [at] : []
|
|
2123
|
+
],
|
|
1935
2124
|
commit: sha,
|
|
1936
2125
|
findings,
|
|
1937
|
-
notes
|
|
2126
|
+
notes,
|
|
2127
|
+
rendered
|
|
1938
2128
|
};
|
|
1939
2129
|
}
|
|
1940
|
-
/**
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
2130
|
+
/** The `ADR`/`ADR-Event` trailer keys. */
|
|
2131
|
+
const TRAILER_KEYS = {
|
|
2132
|
+
subject: "ADR",
|
|
2133
|
+
event: "ADR-Event"
|
|
2134
|
+
};
|
|
2135
|
+
/**
|
|
2136
|
+
* The staged event files among `paths` as (decision, type) refs, in path order (by decision,
|
|
2137
|
+
* then event time), deduplicated by decision and type.
|
|
2138
|
+
*/
|
|
2139
|
+
function stagedEventRefs(layout, paths) {
|
|
2140
|
+
const found = [];
|
|
2141
|
+
for (const p of sorted(paths.filter(isYaml))) {
|
|
2142
|
+
const c = classifyPath(layout, p);
|
|
2143
|
+
if (c.area !== "event" || c.subject === null || c.file.includes("/")) continue;
|
|
2144
|
+
const name = parseEventFileName(c.file);
|
|
2145
|
+
if (name !== null) found.push({
|
|
2146
|
+
subject: c.subject,
|
|
2147
|
+
type: name.type
|
|
2148
|
+
});
|
|
2149
|
+
}
|
|
2150
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2151
|
+
return found.filter((r) => {
|
|
2152
|
+
const key = `${r.subject}\0${r.type}`;
|
|
2153
|
+
if (seen.has(key)) return false;
|
|
2154
|
+
seen.add(key);
|
|
2155
|
+
return true;
|
|
2156
|
+
});
|
|
1950
2157
|
}
|
|
2158
|
+
/** The new bytes of each file a plan replaces. */
|
|
2159
|
+
const replaceBytes = (plan) => Object.fromEntries(Object.entries(plan.replace ?? {}).map(([p, r]) => [p, r.bytes]));
|
|
1951
2160
|
const DEFAULT_DEPS = {
|
|
1952
2161
|
now: Date.now,
|
|
1953
2162
|
apply: applyPlan
|
|
@@ -1973,7 +2182,6 @@ async function viewsAfter(log, plan, sha) {
|
|
|
1973
2182
|
affected: plan.affected.map(view)
|
|
1974
2183
|
};
|
|
1975
2184
|
}
|
|
1976
|
-
const SHORTENED_NOTE = "commit subject shortened to 72 characters; the full title is in the commit body";
|
|
1977
2185
|
/**
|
|
1978
2186
|
* `--commit` needs an author and a committer identity; checked before anything is written, since
|
|
1979
2187
|
* `GIT_AUTHOR_*` alone satisfies the author while `git commit` still fails on the committer.
|
|
@@ -1981,24 +2189,63 @@ const SHORTENED_NOTE = "commit subject shortened to 72 characters; the full titl
|
|
|
1981
2189
|
async function checkCommitIdentity(log) {
|
|
1982
2190
|
if (await log.git.identity() === null || await log.git.committerIdentity() === null) throw new ConfigError("--commit needs a git identity; set git user.name and user.email");
|
|
1983
2191
|
}
|
|
1984
|
-
|
|
1985
|
-
|
|
2192
|
+
const STATUS_TYPES = /* @__PURE__ */ new Set([
|
|
2193
|
+
"proposed",
|
|
2194
|
+
"accepted",
|
|
2195
|
+
"rejected",
|
|
2196
|
+
"withdrawn"
|
|
2197
|
+
]);
|
|
2198
|
+
/**
|
|
2199
|
+
* The commit's action and title slot (ADR-0008, ?6), chosen once the staged events the commit
|
|
2200
|
+
* sweeps in are known: when `plan` or `extra` holds a status event (proposed, accepted, rejected,
|
|
2201
|
+
* withdrawn), the title form of the last one, since that is what the commit decides. Otherwise
|
|
2202
|
+
* the plan's own subject when the extra events add no other verb; with two or three verbs those
|
|
2203
|
+
* joined (`link and scope ADR-0003`), and beyond three (or where the joined verbs don't fit a
|
|
2204
|
+
* custom template) `update ADR-0003`. Every single-action subject stays the plan's own, byte for
|
|
2205
|
+
* byte.
|
|
2206
|
+
*/
|
|
2207
|
+
function composeSubject(read, parts, c) {
|
|
2208
|
+
const status = [...c.extra, ...c.plan].filter((e) => STATUS_TYPES.has(e.type)).at(-1);
|
|
2209
|
+
if (status !== void 0) {
|
|
2210
|
+
if (c.plan.some((e) => e.subject === status.subject && e.type === status.type)) return {
|
|
2211
|
+
action: parts.action,
|
|
2212
|
+
title: parts.title
|
|
2213
|
+
};
|
|
2214
|
+
return {
|
|
2215
|
+
action: ACTIONS[status.type].action,
|
|
2216
|
+
title: read.projection.views.get(status.subject)?.title ?? status.subject
|
|
2217
|
+
};
|
|
2218
|
+
}
|
|
2219
|
+
const primary = c.plan[0]?.type;
|
|
2220
|
+
const verbs = /* @__PURE__ */ new Set();
|
|
2221
|
+
for (const t of [primary, ...c.extra.map((e) => e.type)]) if (t !== void 0 && Object.hasOwn(BATCH_ACTIONS, t)) verbs.add(t);
|
|
2222
|
+
if (verbs.size <= 1 || primary === void 0 || !verbs.has(primary)) return {
|
|
2223
|
+
action: parts.action,
|
|
2224
|
+
title: parts.title
|
|
2225
|
+
};
|
|
2226
|
+
const ordered = BATCH_ORDER.filter((t) => verbs.has(t)).map((t) => BATCH_ACTIONS[t]);
|
|
2227
|
+
return {
|
|
2228
|
+
action: ordered.length > 3 ? UPDATE_ACTION : joinActions(ordered),
|
|
2229
|
+
title: parts.display
|
|
2230
|
+
};
|
|
2231
|
+
}
|
|
2232
|
+
/** The plan's commit message from `[commit] subject` (design §4), with `composeSubject`'s slot. */
|
|
2233
|
+
function commitMessage(log, command, read, plan, c) {
|
|
1986
2234
|
const parts = plan.commit;
|
|
1987
2235
|
if (parts === null) throw new InvalidArgumentError("this write can't be committed");
|
|
1988
|
-
const
|
|
1989
|
-
|
|
2236
|
+
const { action, title } = composeSubject(read, parts, c);
|
|
2237
|
+
const render = (a) => renderCommitMessage(log.adr.commitSubject, {
|
|
2238
|
+
title,
|
|
1990
2239
|
vars: {
|
|
1991
|
-
action:
|
|
2240
|
+
action: a,
|
|
1992
2241
|
id: parts.id,
|
|
1993
2242
|
display: parts.display,
|
|
1994
2243
|
command
|
|
1995
2244
|
},
|
|
1996
2245
|
body: parts.body
|
|
1997
2246
|
});
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
shortened: r.shortened
|
|
2001
|
-
};
|
|
2247
|
+
const r = render(action);
|
|
2248
|
+
return r.shortened && title === parts.display && action !== parts.action ? render(UPDATE_ACTION) : r;
|
|
2002
2249
|
}
|
|
2003
2250
|
/**
|
|
2004
2251
|
* Plan, then apply, with the single `EventExistsError` retry (§5.4 steps 1–5). `o.message`
|
|
@@ -2032,7 +2279,7 @@ async function write(log, command, plan, o, deps, afterStage) {
|
|
|
2032
2279
|
findings: r.findings,
|
|
2033
2280
|
notes
|
|
2034
2281
|
};
|
|
2035
|
-
if (r.plan.events.length === 0 && Object.keys(r.plan.files).length === 0 || o.dryRun === true) return {
|
|
2282
|
+
if (r.plan.events.length === 0 && Object.keys(r.plan.files).length === 0 && Object.keys(r.plan.replace ?? {}).length === 0 || o.dryRun === true) return {
|
|
2036
2283
|
format: "adr-write/v1",
|
|
2037
2284
|
command,
|
|
2038
2285
|
ok: true,
|
|
@@ -2043,15 +2290,15 @@ async function write(log, command, plan, o, deps, afterStage) {
|
|
|
2043
2290
|
findings: r.plan.findings,
|
|
2044
2291
|
notes: [...notes, ...r.plan.notes ?? []]
|
|
2045
2292
|
};
|
|
2046
|
-
|
|
2293
|
+
if (commit && r.plan.commit === null) throw new InvalidArgumentError("this write can't be committed");
|
|
2047
2294
|
const given = o.message ?? null;
|
|
2048
2295
|
try {
|
|
2049
2296
|
const applied = await deps.apply(log, r.plan, {
|
|
2050
2297
|
commit,
|
|
2051
|
-
|
|
2052
|
-
message: given
|
|
2053
|
-
mergeTrailers:
|
|
2054
|
-
},
|
|
2298
|
+
...!commit ? {} : given !== null ? {
|
|
2299
|
+
message: given,
|
|
2300
|
+
mergeTrailers: true
|
|
2301
|
+
} : { compose: (c) => commitMessage(log, command, read, r.plan, c) },
|
|
2055
2302
|
...afterStage === void 0 ? {} : { afterStage: afterStage(r.plan, commit) },
|
|
2056
2303
|
...log.adr.index === "committed" && o.index !== "skip" ? { index: (tree, history) => renderDecisionIndex(log.git, {
|
|
2057
2304
|
layout: log.layout,
|
|
@@ -2064,7 +2311,7 @@ async function write(log, command, plan, o, deps, afterStage) {
|
|
|
2064
2311
|
const own = [
|
|
2065
2312
|
...r.plan.notes ?? [],
|
|
2066
2313
|
...applied.notes,
|
|
2067
|
-
...applied.commit !== null &&
|
|
2314
|
+
...applied.commit !== null && applied.rendered?.note != null ? [applied.rendered.note] : []
|
|
2068
2315
|
];
|
|
2069
2316
|
return {
|
|
2070
2317
|
format: "adr-write/v1",
|
|
@@ -2128,7 +2375,7 @@ function editorStep(log, edit, onNote) {
|
|
|
2128
2375
|
onNote?.(`${first} changed while the editor was open; review it, then git add it and commit`);
|
|
2129
2376
|
}
|
|
2130
2377
|
}
|
|
2131
|
-
const text = decoder$
|
|
2378
|
+
const text = decoder$2.decode(bytes ?? d.rendered);
|
|
2132
2379
|
const findings = lintDocument(d.template, text, {
|
|
2133
2380
|
file: d.path,
|
|
2134
2381
|
subject: plan.subject
|
|
@@ -2167,14 +2414,18 @@ async function proposeDecision(log, input, o = {}, deps = DEFAULT_DEPS) {
|
|
|
2167
2414
|
async function acceptDecision(log, input, o = {}, deps = DEFAULT_DEPS) {
|
|
2168
2415
|
const copyScope = o.copyScope ?? "suggest";
|
|
2169
2416
|
let copy = null;
|
|
2417
|
+
const tracked = await trackedFiles(log);
|
|
2170
2418
|
return write(log, "accept", async (read, c) => {
|
|
2171
|
-
const plain = await planAccept(read, input, c);
|
|
2419
|
+
const plain = await planAccept(read, input, c, { tracked });
|
|
2172
2420
|
if (!plain.ok) return plain;
|
|
2173
2421
|
const id = plain.plan.subject;
|
|
2174
2422
|
const offer = scopeToCopy(read, id);
|
|
2175
2423
|
if (offer.globs.length === 0) return plain;
|
|
2176
2424
|
if (copy === null) copy = typeof copyScope === "function" ? await copyScope(offer) : copyScope === "copy";
|
|
2177
|
-
if (copy) return planAccept(read, input, c, {
|
|
2425
|
+
if (copy) return planAccept(read, input, c, {
|
|
2426
|
+
copyGlobs: offer.globs,
|
|
2427
|
+
tracked
|
|
2428
|
+
});
|
|
2178
2429
|
if (copyScope === "skip") return plain;
|
|
2179
2430
|
const notes = [...plain.plan.notes ?? [], copyScopeNote(id, offer)];
|
|
2180
2431
|
return {
|
|
@@ -2217,8 +2468,15 @@ async function noteDecision(log, input, o = {}, deps = DEFAULT_DEPS) {
|
|
|
2217
2468
|
* Qualified targets name the log's `[[sources]]`; a link that exists already writes nothing.
|
|
2218
2469
|
*/
|
|
2219
2470
|
async function linkDecision(log, input, o = {}, deps = DEFAULT_DEPS) {
|
|
2220
|
-
const
|
|
2221
|
-
|
|
2471
|
+
const configs = log.config?.sources ?? [];
|
|
2472
|
+
const sources = configs.map((s) => s.name);
|
|
2473
|
+
const kinds = new Map(configs.map((s) => [s.name, s.kind]));
|
|
2474
|
+
const logs = input.to.includes(":") ? (await readDecisionSources(log, { fetch: "cache" })).logs : void 0;
|
|
2475
|
+
return write(log, "link", (read, c) => planLink(read, input, c, {
|
|
2476
|
+
sources,
|
|
2477
|
+
kinds,
|
|
2478
|
+
...logs === void 0 ? {} : { logs }
|
|
2479
|
+
}), o, deps);
|
|
2222
2480
|
}
|
|
2223
2481
|
/**
|
|
2224
2482
|
* `adr scope <id> --set|--add|--remove …`: writes one `scoped` event with the new set, stages it,
|
|
@@ -2228,6 +2486,19 @@ async function scopeDecision(log, input, o = {}, deps = DEFAULT_DEPS) {
|
|
|
2228
2486
|
const tracked = await trackedFiles(log);
|
|
2229
2487
|
return write(log, "scope", (read, c) => planScope(read, input, c, { tracked }), o, deps);
|
|
2230
2488
|
}
|
|
2489
|
+
/**
|
|
2490
|
+
* Replaces sections of a proposed decision's draft (design §6.6), stages the body, and commits
|
|
2491
|
+
* with `commit` (default `[policy] auto_commit`). A body with unstaged changes is a `UsageError`,
|
|
2492
|
+
* so nobody's unsaved edit is folded into this write; a body changed between plan and write is
|
|
2493
|
+
* `FileChangedError`. A commit is never made of a draft that fails its template lint: it is
|
|
2494
|
+
* refused (`ok: false`, nothing written), as for `proposeDecision` without an editor.
|
|
2495
|
+
*/
|
|
2496
|
+
async function updateDraft(log, input, o = {}, deps = DEFAULT_DEPS) {
|
|
2497
|
+
const read = await readDecisions(log);
|
|
2498
|
+
const view = viewOf(read, resolveDecision(read, input.id));
|
|
2499
|
+
if (view.body_path !== null && (await log.git.unstagedPaths([view.body_path])).length > 0) throw new UsageError(`${view.body_path} has unstaged changes; stage or discard them, or edit the draft by hand`);
|
|
2500
|
+
return write(log, "update", (read, c) => planUpdateDraft(read, input, c, { refuseIncomplete: o.commit ?? log.adr.autoCommit }), o, deps);
|
|
2501
|
+
}
|
|
2231
2502
|
const FOLLOW_NO_COMMIT_NOTE = "--follow-renames never commits; commit the scoped events with the renames";
|
|
2232
2503
|
/**
|
|
2233
2504
|
* `adr scope [<decision>] --follow-renames` (design §6.8): rewrites the scope globs of the given
|
|
@@ -2261,357 +2532,6 @@ async function followScopeRenames(log, input, o = {}, deps = DEFAULT_DEPS) {
|
|
|
2261
2532
|
notes: [...doc.notes, FOLLOW_NO_COMMIT_NOTE]
|
|
2262
2533
|
} : doc;
|
|
2263
2534
|
}
|
|
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
2535
|
// src/views.ts
|
|
2616
2536
|
/** The `numbering` key of `adr-list/v1`, `adr-show/v1` and `adr-for/v1`. */
|
|
2617
2537
|
const numberingJson = (read) => ({
|
|
@@ -2642,6 +2562,7 @@ function listDecisions(read, o = {}) {
|
|
|
2642
2562
|
const listed = new Set(decisions.map((v) => v.id));
|
|
2643
2563
|
return {
|
|
2644
2564
|
format: "adr-list/v1",
|
|
2565
|
+
source: read.source,
|
|
2645
2566
|
root: read.root,
|
|
2646
2567
|
ref: read.ref,
|
|
2647
2568
|
commit: read.commit,
|
|
@@ -2651,10 +2572,10 @@ function listDecisions(read, o = {}) {
|
|
|
2651
2572
|
unreadable: read.projection.unreadable,
|
|
2652
2573
|
unfoldable: read.projection.unfoldable,
|
|
2653
2574
|
findings: read.findings.filter((f) => f.subject === null || listed.has(f.subject)),
|
|
2654
|
-
notes: read
|
|
2575
|
+
notes: listingNotes(read)
|
|
2655
2576
|
};
|
|
2656
2577
|
}
|
|
2657
|
-
const decoder = new TextDecoder();
|
|
2578
|
+
const decoder$1 = new TextDecoder();
|
|
2658
2579
|
/**
|
|
2659
2580
|
* The `adr-show/v1` document of the decision `id` (resolve it with `resolveDecision` first).
|
|
2660
2581
|
* Throws `UsageError` for an unknown, unreadable or unfoldable decision.
|
|
@@ -2686,7 +2607,7 @@ async function showDecision(read, id, o = {}) {
|
|
|
2686
2607
|
numbering: numberingJson(read),
|
|
2687
2608
|
header: stateHeader(view, read),
|
|
2688
2609
|
decision: view,
|
|
2689
|
-
body: body === null ? null : decoder.decode(body.bytes),
|
|
2610
|
+
body: body === null ? null : decoder$1.decode(body.bytes),
|
|
2690
2611
|
events,
|
|
2691
2612
|
findings: read.findings.filter((f) => f.subject === id),
|
|
2692
2613
|
notes: read.notes
|
|
@@ -2744,6 +2665,7 @@ const GLOBS_APPLY = /* @__PURE__ */ new Set([
|
|
|
2744
2665
|
"deprecated",
|
|
2745
2666
|
"superseded"
|
|
2746
2667
|
]);
|
|
2668
|
+
/** The order `adr for` and `adr search` list statuses in: in force first. */
|
|
2747
2669
|
const STATUS_ORDER = [
|
|
2748
2670
|
"accepted",
|
|
2749
2671
|
"proposed",
|
|
@@ -2865,9 +2787,181 @@ async function decisionsFor(read, log, o) {
|
|
|
2865
2787
|
numbering: numberingJson(read),
|
|
2866
2788
|
paths: out,
|
|
2867
2789
|
findings: read.findings.filter((f) => f.subject === null || listed.has(f.subject)),
|
|
2868
|
-
notes: read
|
|
2790
|
+
notes: listingNotes(read)
|
|
2791
|
+
};
|
|
2792
|
+
}
|
|
2793
|
+
// src/search.ts
|
|
2794
|
+
const MAX_QUERY = 200;
|
|
2795
|
+
const MAX_TERMS = 10;
|
|
2796
|
+
const EXCERPT = 160;
|
|
2797
|
+
const LEAD = 60;
|
|
2798
|
+
const WORD_SLACK = 15;
|
|
2799
|
+
const ACTIVE = /* @__PURE__ */ new Set(["proposed", "accepted"]);
|
|
2800
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
2801
|
+
/**
|
|
2802
|
+
* Case- and accent-insensitive form: per code point NFKD, combining marks dropped, lower-cased.
|
|
2803
|
+
* `Café` → `cafe` and `fi` → `fi`; `ß` and `ø` have no decomposition and stay as they are.
|
|
2804
|
+
*/
|
|
2805
|
+
function fold(s) {
|
|
2806
|
+
let text = "";
|
|
2807
|
+
const at = [];
|
|
2808
|
+
let offset = 0;
|
|
2809
|
+
for (const c of s) {
|
|
2810
|
+
const f = c.normalize("NFKD").replace(/\p{M}/gu, "").toLowerCase();
|
|
2811
|
+
text += f;
|
|
2812
|
+
for (let i = 0; i < f.length; i++) at.push(offset);
|
|
2813
|
+
offset += c.length;
|
|
2814
|
+
}
|
|
2815
|
+
at.push(offset);
|
|
2816
|
+
return {
|
|
2817
|
+
text,
|
|
2818
|
+
at
|
|
2819
|
+
};
|
|
2820
|
+
}
|
|
2821
|
+
const count = (hay, needle) => {
|
|
2822
|
+
let n = 0;
|
|
2823
|
+
for (let i = hay.indexOf(needle); i >= 0; i = hay.indexOf(needle, i + needle.length)) n++;
|
|
2824
|
+
return n;
|
|
2825
|
+
};
|
|
2826
|
+
/** The query's terms, or a `UsageError` for an empty, long, control-character or many-word query. */
|
|
2827
|
+
function queryTerms(query) {
|
|
2828
|
+
const q = query.trim();
|
|
2829
|
+
if (q === "" || q.length > MAX_QUERY || /[\u0000-\u001f\u007f]/.test(q)) throw new UsageError(`the search query must be 1 to ${MAX_QUERY} characters without control characters`);
|
|
2830
|
+
const terms = [...new Set(fold(q).text.split(/\s+/).filter((t) => t !== ""))];
|
|
2831
|
+
if (terms.length > MAX_TERMS) throw new UsageError(`a search takes at most ${MAX_TERMS} words`);
|
|
2832
|
+
if (terms.length === 0) throw new UsageError("the search query must contain a letter, digit or symbol");
|
|
2833
|
+
return terms;
|
|
2834
|
+
}
|
|
2835
|
+
/** The body text without its first `# ` title line; `""` when missing or not UTF-8. */
|
|
2836
|
+
function bodyText(read, id) {
|
|
2837
|
+
const bytes = read.folded.subjects.get(id)?.loaded?.body?.bytes;
|
|
2838
|
+
if (bytes === void 0) return "";
|
|
2839
|
+
let text;
|
|
2840
|
+
try {
|
|
2841
|
+
text = decoder.decode(bytes).replace(/\r\n/g, "\n");
|
|
2842
|
+
} catch {
|
|
2843
|
+
return "";
|
|
2844
|
+
}
|
|
2845
|
+
const lines = text.split("\n");
|
|
2846
|
+
const title = lines.findIndex((l) => l.startsWith("# "));
|
|
2847
|
+
if (title >= 0) lines.splice(title, 1);
|
|
2848
|
+
return lines.join("\n");
|
|
2849
|
+
}
|
|
2850
|
+
function noteTexts(read, id) {
|
|
2851
|
+
return (read.folded.subjects.get(id)?.applied ?? []).flatMap((a) => {
|
|
2852
|
+
const text = a.event.data?.text;
|
|
2853
|
+
return a.event.type === "noted" && typeof text === "string" ? [text] : [];
|
|
2854
|
+
});
|
|
2855
|
+
}
|
|
2856
|
+
/** The excerpt of the first line with the most distinct terms, windowed around the first hit. */
|
|
2857
|
+
function excerpt(lines, terms) {
|
|
2858
|
+
let best = null;
|
|
2859
|
+
for (const raw of lines) {
|
|
2860
|
+
const line = raw.replace(/\s+/g, " ").trim();
|
|
2861
|
+
const folded = fold(line).text;
|
|
2862
|
+
const hits = terms.filter((t) => folded.includes(t)).length;
|
|
2863
|
+
if (hits > 0 && (best === null || hits > best.hits)) best = {
|
|
2864
|
+
line,
|
|
2865
|
+
hits
|
|
2866
|
+
};
|
|
2867
|
+
}
|
|
2868
|
+
if (best === null) return null;
|
|
2869
|
+
const { line } = best;
|
|
2870
|
+
if (line.length <= EXCERPT) return line;
|
|
2871
|
+
const f = fold(line);
|
|
2872
|
+
const first = Math.min(...terms.map((t) => f.text.indexOf(t)).filter((i) => i >= 0));
|
|
2873
|
+
let start = Math.max(0, (f.at[first] ?? 0) - LEAD);
|
|
2874
|
+
if (start > 0 && line[start - 1] !== " ") {
|
|
2875
|
+
const space = line.indexOf(" ", start);
|
|
2876
|
+
if (space >= 0 && space - start <= WORD_SLACK) start = space + 1;
|
|
2877
|
+
}
|
|
2878
|
+
let end = Math.min(line.length, start + EXCERPT);
|
|
2879
|
+
if (end < line.length && line[end] !== " ") {
|
|
2880
|
+
const space = line.lastIndexOf(" ", end);
|
|
2881
|
+
if (space > start && end - space <= WORD_SLACK) end = space;
|
|
2882
|
+
}
|
|
2883
|
+
const cut = line.slice(start, end).trim();
|
|
2884
|
+
return `${start > 0 ? "…" : ""}${cut}${end < line.length ? "…" : ""}`;
|
|
2885
|
+
}
|
|
2886
|
+
/**
|
|
2887
|
+
* Full-text search of the log (design §8.1): a decision matches when every term of the folded
|
|
2888
|
+
* query is a substring of its folded id, display number, title, body or note texts. Ranked by
|
|
2889
|
+
* score (10 for a term equal to the id or number, 5 in the title, up to 3 for body hits and 2
|
|
2890
|
+
* for note hits), then status as `adr for` orders them, then list order. Only proposed and
|
|
2891
|
+
* accepted decisions unless `includeInactive`. Throws `UsageError` for an invalid query.
|
|
2892
|
+
*/
|
|
2893
|
+
function searchDecisions(read, o) {
|
|
2894
|
+
const terms = queryTerms(o.query);
|
|
2895
|
+
if (o.limit !== void 0 && (!Number.isInteger(o.limit) || o.limit < 1)) throw new UsageError("the search limit must be a positive integer");
|
|
2896
|
+
const includeInactive = o.includeInactive === true;
|
|
2897
|
+
const found = [];
|
|
2898
|
+
let inactive = 0;
|
|
2899
|
+
for (const view of read.projection.views.values()) {
|
|
2900
|
+
const body = bodyText(read, view.id);
|
|
2901
|
+
const notes = noteTexts(read, view.id);
|
|
2902
|
+
const id = fold(view.id).text;
|
|
2903
|
+
const display = fold(view.display).text;
|
|
2904
|
+
const title = fold(view.title).text;
|
|
2905
|
+
const foldedBody = fold(body).text;
|
|
2906
|
+
const foldedNotes = notes.map((n) => fold(n).text).join("\n");
|
|
2907
|
+
const hit = /* @__PURE__ */ new Set();
|
|
2908
|
+
let score = 0;
|
|
2909
|
+
let all = true;
|
|
2910
|
+
for (const t of terms) {
|
|
2911
|
+
const inId = id.includes(t) || display.includes(t);
|
|
2912
|
+
const inTitle = title.includes(t);
|
|
2913
|
+
const inBody = count(foldedBody, t);
|
|
2914
|
+
const inNotes = count(foldedNotes, t);
|
|
2915
|
+
if (!inId && !inTitle && inBody === 0 && inNotes === 0) {
|
|
2916
|
+
all = false;
|
|
2917
|
+
break;
|
|
2918
|
+
}
|
|
2919
|
+
if (inId) hit.add("id");
|
|
2920
|
+
if (inTitle) hit.add("title");
|
|
2921
|
+
if (inBody > 0) hit.add("body");
|
|
2922
|
+
if (inNotes > 0) hit.add("notes");
|
|
2923
|
+
score += (t === id || t === display ? 10 : 0) + (inTitle ? 5 : 0) + Math.min(3, inBody) + Math.min(2, inNotes);
|
|
2924
|
+
}
|
|
2925
|
+
if (!all) continue;
|
|
2926
|
+
if (!includeInactive && !ACTIVE.has(view.status)) {
|
|
2927
|
+
inactive++;
|
|
2928
|
+
continue;
|
|
2929
|
+
}
|
|
2930
|
+
const lines = [...body.split("\n").filter((l) => l.trim() !== "" && !l.startsWith("#")), ...notes.flatMap((n) => n.split("\n"))];
|
|
2931
|
+
found.push({
|
|
2932
|
+
view,
|
|
2933
|
+
decision: view,
|
|
2934
|
+
header: stateHeader(view, read),
|
|
2935
|
+
score,
|
|
2936
|
+
matched: [
|
|
2937
|
+
"id",
|
|
2938
|
+
"title",
|
|
2939
|
+
"body",
|
|
2940
|
+
"notes"
|
|
2941
|
+
].filter((f) => hit.has(f)),
|
|
2942
|
+
excerpt: excerpt(lines, terms)
|
|
2943
|
+
});
|
|
2944
|
+
}
|
|
2945
|
+
found.sort((a, b) => b.score - a.score || STATUS_ORDER.indexOf(a.view.status) - STATUS_ORDER.indexOf(b.view.status) || compareViews(a.view, b.view));
|
|
2946
|
+
const results = found.slice(0, o.limit ?? Number.POSITIVE_INFINITY).map(({ view: _, ...r }) => r);
|
|
2947
|
+
const listed = new Set(results.map((r) => r.decision.id));
|
|
2948
|
+
return {
|
|
2949
|
+
format: "adr-search/v1",
|
|
2950
|
+
source: read.source,
|
|
2951
|
+
root: read.root,
|
|
2952
|
+
ref: read.ref,
|
|
2953
|
+
commit: read.commit,
|
|
2954
|
+
numbering: numberingJson(read),
|
|
2955
|
+
query: o.query.trim(),
|
|
2956
|
+
terms,
|
|
2957
|
+
include_inactive: includeInactive,
|
|
2958
|
+
total: found.length,
|
|
2959
|
+
inactive_omitted: inactive,
|
|
2960
|
+
results,
|
|
2961
|
+
findings: read.findings.filter((f) => f.subject === null || listed.has(f.subject)),
|
|
2962
|
+
notes: listingNotes(read)
|
|
2869
2963
|
};
|
|
2870
2964
|
}
|
|
2871
|
-
export {
|
|
2965
|
+
export { numberDecisions as A, INDEX_AFTER as C, renderDecisionIndex as D, indexPath as E, resolveDecision as F, sourcesNote as I, DEFAULT_ADR_CONFIG as L, openDecisionLog as M, readDecisions as N, runDecisionIndex as O, readDecisionsFrom as P, parseAdrConfig as R, DECISION_INDEX as S, indexDecisionLog as T, withdrawDecision as _, stateHeader as a, readDecisionSources as b, followScopeRenames as c, proposeDecision as d, rejectDecision as f, updateDraft as g, supersedeDecision as h, showDecision as i, numberingNote as j, changedSince as k, linkDecision as l, scopeToCopy as m, decisionsFor as n, acceptDecision as o, scopeDecision as p, listDecisions as r, deprecateDecision as s, searchDecisions as t, noteDecision as u, BUILTIN_TEMPLATES as v, decisionIndexCheck as w, readSourceDecisions as x, decisionSourceConfigs as y };
|
|
2872
2966
|
|
|
2873
|
-
//# sourceMappingURL=
|
|
2967
|
+
//# sourceMappingURL=search-CTspg2pd.js.map
|