@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.
Files changed (45) hide show
  1. package/README.md +62 -13
  2. package/dist/cli.js +208 -48
  3. package/dist/cli.js.map +1 -1
  4. package/dist/index.js +5 -3
  5. package/dist/init-CaKKCINh.js +651 -0
  6. package/dist/init-CaKKCINh.js.map +1 -0
  7. package/dist/{kind-DjvA3dyA.js → kind-C3DiBuUp.js} +44 -11
  8. package/dist/kind-C3DiBuUp.js.map +1 -0
  9. package/dist/kind.js +1 -1
  10. package/dist/list-CJi9vZqJ.js +58 -0
  11. package/dist/list-CJi9vZqJ.js.map +1 -0
  12. package/dist/{scope-CLqH-q1y.js → search-CTspg2pd.js} +644 -550
  13. package/dist/search-CTspg2pd.js.map +1 -0
  14. package/dist/server-D3ySzyj6.js +950 -0
  15. package/dist/server-D3ySzyj6.js.map +1 -0
  16. package/dist/types/cli/init.d.ts +5 -2
  17. package/dist/types/cli/list.d.ts +4 -1
  18. package/dist/types/cli/mcp.d.ts +6 -0
  19. package/dist/types/cli/search.d.ts +6 -0
  20. package/dist/types/cli/text.d.ts +6 -0
  21. package/dist/types/commit-actions.d.ts +23 -1
  22. package/dist/types/hooks.d.ts +3 -2
  23. package/dist/types/index.d.ts +7 -3
  24. package/dist/types/init.d.ts +16 -1
  25. package/dist/types/kind/rules.d.ts +23 -2
  26. package/dist/types/kind.d.ts +1 -1
  27. package/dist/types/log-index.d.ts +0 -18
  28. package/dist/types/log.d.ts +13 -0
  29. package/dist/types/mcp/policy.d.ts +33 -0
  30. package/dist/types/mcp/prompt.d.ts +7 -0
  31. package/dist/types/mcp/resources.d.ts +16 -0
  32. package/dist/types/mcp/server.d.ts +16 -0
  33. package/dist/types/mcp/text.d.ts +42 -0
  34. package/dist/types/mcp/tools.d.ts +27 -0
  35. package/dist/types/scope.d.ts +3 -1
  36. package/dist/types/search.d.ts +63 -0
  37. package/dist/types/skill.d.ts +5 -0
  38. package/dist/types/sources.d.ts +35 -0
  39. package/dist/types/templates/list.d.ts +31 -0
  40. package/dist/types/verify.d.ts +20 -4
  41. package/dist/types/views.d.ts +5 -1
  42. package/dist/types/write.d.ts +76 -5
  43. package/package.json +5 -2
  44. package/dist/kind-DjvA3dyA.js.map +0 -1
  45. package/dist/scope-CLqH-q1y.js.map +0 -1
@@ -1,6 +1,6 @@
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";
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
- /** 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);
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$1 = compileKind(adrKind);
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$1,
260
+ kind: compiled$2,
230
261
  layout
231
262
  });
232
263
  const folded = foldLog(adrKind, snapshot);
233
264
  const ctx = {
234
- kind: compiled$1,
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 = { acceptRequiresHuman: adr.acceptRequiresHuman };
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/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;
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
- * `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) {
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
- 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
+ sources,
688
+ skipped: all.filter((s) => !sources.includes(s))
687
689
  };
688
690
  }
689
691
  /**
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
+ * 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 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
- });
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
- * 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`.
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 adrChecks(log, historyRef) {
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
- ];
738
- }
739
- /** Verifies the decision log in `mode` (staged, range or full) with `adrChecks`. */
740
- async function verifyDecisions(log, mode) {
741
- return verifyLog({
742
- git: log.git,
743
- kind: adrKind,
744
- layout: log.layout,
745
- mode,
746
- checks: adrChecks(log, await templateHistoryRef(log.git, mode))
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$1 = new TextEncoder();
859
- const decoder$1 = new TextDecoder();
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$1.encode(text).byteLength;
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, files), {
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$1.encode(serializeEvent(e))]));
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$1.decode(bytes);
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$1.encode(rendered.text);
1095
+ const body = encoder.encode(rendered.text);
1064
1096
  const files = { [bodyPath]: body };
1065
- if (chosen.materialize !== null) files[chosen.path] = encoder$1.encode(chosen.materialize);
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); it isn't looked up in the other log. A link that
1444
- * already exists plans nothing, with a note.
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?.activeIn ?? []).includes(view.status) ? "; verify (hooks and CI) reports scope/glob-unmatched until a file matches" : "";
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), plan.files);
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 staged = extra.filter(isYaml).flatMap((p) => {
1856
- const c = classifyPath(log.layout, p);
1857
- const name = c.area === "event" && !c.file.includes("/") ? parseEventFileName(c.file) : null;
1858
- return c.area === "event" && c.subject !== null && name !== null ? [["ADR", c.subject], ["ADR-Event", name.type]] : [];
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 trailers = [...staged, ...plan.trailers];
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: [...Object.keys(plan.files), ...indexWritten ? [at] : []],
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
- /** `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)))
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
- /** The plan's commit message from `[commit] subject` (design §4), and whether it was shortened. */
1985
- function commitMessage(log, command, plan) {
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 r = renderCommitMessage(log.adr.commitSubject, {
1989
- title: parts.title,
2236
+ const { action, title } = composeSubject(read, parts, c);
2237
+ const render = (a) => renderCommitMessage(log.adr.commitSubject, {
2238
+ title,
1990
2239
  vars: {
1991
- action: parts.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
- return {
1999
- message: r.message,
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
- const rendered = commit ? commitMessage(log, command, r.plan) : null;
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
- ...rendered === null ? {} : {
2052
- message: given ?? rendered.message,
2053
- mergeTrailers: given !== null
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 && given === null && rendered?.shortened === true ? [SHORTENED_NOTE] : []
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$1.decode(bytes ?? d.rendered);
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, { copyGlobs: offer.globs });
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 sources = (log.config?.sources ?? []).map((s) => s.name);
2221
- return write(log, "link", (read, c) => planLink(read, input, c, { sources }), o, deps);
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.notes
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.notes
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 { 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 };
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=scope-CLqH-q1y.js.map
2967
+ //# sourceMappingURL=search-CTspg2pd.js.map