@nikovirtala/adr 0.4.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.
@@ -1,5 +1,5 @@
1
- import { t as adrKind } from "./kind-DjvA3dyA.js";
2
- import { CommitFailedError, ConfigError, ENVELOPE_VERSION, EventExistsError, FileExistsError, InvalidArgumentError, MemoryTree, UsageError, applySetOp, bodyFileName, bodySha256, classifyPath, commitWithRecovery, compareEvents, compileKind, diffTrees, escapeMarkdown, eventDir, eventPath, extractSections, finding, foldLog, followRenames, globMatcher, historyTemplates, indexCheck, indexReader, invariantsCheck, isSubjectId, lintDocument, lintTemplate, loadFindings, loadLog, newSubjectId, nextEventIdentity, numberLog, openToolLog, overlayTree, parseCommitTemplate, parseEventFileName, parseGlob, parseTemplate, projectLog, projectNumbering, readCommitTemplate, readIndexNumbers, refReader, relateCheck, renderCommitMessage, renderIndex, renderTemplate, replaceFile, replaceSections, resolveSubjectInput, saveCommitRecovery, scanAnchors, scopeLookup, serializeEvent, slugify, sortFindings, stagedRenames, templateHistoryRef, transitions, verifyLog, worktreeReader, writeEvents, writeIndexFile, writeNewFile } from "@nikovirtala/ledger";
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
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
@@ -45,9 +45,33 @@ const ACTIONS = {
45
45
  names: "display"
46
46
  }
47
47
  };
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";
48
68
  /** `{action}` of a draft update (`adr_update_draft`), whose title slot holds the display. */
49
69
  const DRAFT_ACTION = "update the draft of";
50
- /** The longest `{action}`, which `[commit] subject` must leave room for. */
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
+ */
51
75
  const LONGEST_ACTION = Math.max(...Object.values(ACTIONS).map((a) => a.action.length), 10, 19);
52
76
  /** What a link's title slot names after the display when the target is in another log. */
53
77
  const CROSS_LOG_LINK = "to a decision in another log";
@@ -77,6 +101,10 @@ const COMMIT_NAMES = Object.freeze([
77
101
  {
78
102
  length: 18,
79
103
  vars: { action: 10 }
104
+ },
105
+ {
106
+ length: 13,
107
+ vars: { action: 6 }
80
108
  }
81
109
  ]);
82
110
  // src/config.ts
@@ -224,17 +252,17 @@ async function numberDecisions(log) {
224
252
  } } : {}
225
253
  });
226
254
  }
227
- const compiled$1 = compileKind(adrKind);
255
+ const compiled$2 = compileKind(adrKind);
228
256
  /** The git-free core of `readDecisions`; tests pass a MemoryTree and a hand-made `Numbering`. */
229
257
  async function readDecisionsFrom(tree, o) {
230
258
  const { layout, adr, numbering } = o;
231
259
  const snapshot = await loadLog(tree, {
232
- kind: compiled$1,
260
+ kind: compiled$2,
233
261
  layout
234
262
  });
235
263
  const folded = foldLog(adrKind, snapshot);
236
264
  const ctx = {
237
- kind: compiled$1,
265
+ kind: compiled$2,
238
266
  layout,
239
267
  base: null,
240
268
  head: snapshot,
@@ -246,7 +274,10 @@ async function readDecisionsFrom(tree, o) {
246
274
  base: null
247
275
  }
248
276
  };
249
- const options = { acceptRequiresHuman: adr.acceptRequiresHuman };
277
+ const options = {
278
+ acceptRequiresHuman: adr.acceptRequiresHuman,
279
+ sources: null
280
+ };
250
281
  const findings = [];
251
282
  for (const check of [
252
283
  loadFindings,
@@ -269,16 +300,17 @@ async function readDecisionsFrom(tree, o) {
269
300
  kind: adrKind.name
270
301
  }))),
271
302
  notes: o.notes ?? [],
272
- sources: o.sources ?? []
303
+ sources: o.sources ?? [],
304
+ source: o.source ?? null
273
305
  };
274
306
  }
275
307
  /**
276
- * The note listing reads add while only the local log is read: the logs of `[[sources]]` aren't
277
- * listed or searched until ark's step 4. `null` without sources.
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.
278
310
  */
279
311
  function sourcesNote(read) {
280
- if (read.sources.length === 0) return null;
281
- return `only this repository's decision log is read; the logs in [[sources]] (${read.sources.join(", ")}) aren't listed or searched until ark's step 4`;
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`;
282
314
  }
283
315
  /** `read.notes`, then the sources note when there is one. */
284
316
  function listingNotes(read) {
@@ -506,37 +538,6 @@ function decisionIndexCheck(log) {
506
538
  }
507
539
  };
508
540
  }
509
- const encoder$1 = new TextEncoder();
510
- /** Whether `bytes` (null: absent) hold exactly `text`. */
511
- function holdsText(bytes, text) {
512
- if (bytes === null) return false;
513
- const want = encoder$1.encode(text);
514
- return bytes.byteLength === want.byteLength && bytes.every((x, i) => x === want[i]);
515
- }
516
- /** HEAD's copy of `path`; `null` when it is absent or HEAD is unborn. */
517
- async function headBytes(git, path) {
518
- const head = await git.resolveCommit("HEAD");
519
- return head === null ? null : (await refReader(git, head)).read(path);
520
- }
521
- /** HEAD's tree, or an empty tree on an unborn HEAD. */
522
- async function headTree(git, head) {
523
- return head === null ? new MemoryTree({}) : refReader(git, head);
524
- }
525
- /**
526
- * Brings the work-tree copy of `path` to `text` (atomically, when it differs) and reports whether
527
- * the staged copy differs, so the caller stages it. `old` is the work-tree copy before the write
528
- * (`null` when absent), for rollback.
529
- */
530
- async function writeIndexCopy(git, path, text) {
531
- const old = await worktreeReader(git).read(path);
532
- return {
533
- wrote: !holdsText(old, text) && await writeIndexFile(git, path, text),
534
- old,
535
- stage: !holdsText(await (await indexReader(git)).read(path), text)
536
- };
537
- }
538
- /** The first line of an error's message, for notes and headlines. */
539
- const firstLine = (err) => (err instanceof Error ? err.message : String(err)).split("\n")[0] ?? "";
540
541
  /**
541
542
  * `adr index` (design 2b §4.5) with what the text output needs; `indexDecisionLog` is the
542
543
  * library entry.
@@ -662,6 +663,117 @@ async function runDecisionIndex(log, o = {}) {
662
663
  async function indexDecisionLog(log, o = {}) {
663
664
  return (await runDecisionIndex(log, o)).doc;
664
665
  }
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
+ };
681
+ }
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);
686
+ return {
687
+ sources,
688
+ skipped: all.filter((s) => !sources.includes(s))
689
+ };
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.
697
+ */
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
708
+ });
709
+ continue;
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
735
+ };
736
+ }
737
+ const SOURCE_NAME$1 = /^[a-z][a-z0-9-]{0,31}$/;
738
+ /**
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.
743
+ */
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
775
+ });
776
+ }
665
777
  /** The built-in templates by name: the file text `adr new` materializes. */
666
778
  const BUILTIN_TEMPLATES = Object.freeze({
667
779
  nygard: `---
@@ -1250,9 +1362,12 @@ async function planAccept(read, input, c, o = {}) {
1250
1362
  const involved = sorted([id, ...targets]);
1251
1363
  const { scope, eventDirs } = commitScope(read, involved);
1252
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);
1253
1367
  return {
1254
1368
  ok: true,
1255
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`) },
1256
1371
  command: "accept",
1257
1372
  subject: id,
1258
1373
  files,
@@ -1480,8 +1595,9 @@ const LINK_RELS = [
1480
1595
  const SOURCE_NAME = /^[a-z][a-z0-9-]{0,31}$/;
1481
1596
  /**
1482
1597
  * Plans `adr link` (design §6.7): `linked` in every state. A qualified target must name one of
1483
- * `o.sources` (the log's `[[sources]]` names); it isn't looked up in the other log. A link that
1484
- * 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.
1485
1601
  */
1486
1602
  async function planLink(read, input, c, o = {}) {
1487
1603
  const rel = input.rel ?? "relates-to";
@@ -1493,12 +1609,19 @@ async function planLink(read, input, c, o = {}) {
1493
1609
  let shown;
1494
1610
  let described;
1495
1611
  let named;
1612
+ let foreignNote = null;
1496
1613
  const at = target.indexOf(":");
1497
1614
  if (at >= 0) {
1498
1615
  const source = target.slice(0, at);
1499
1616
  const local = target.slice(at + 1);
1500
1617
  if (!SOURCE_NAME.test(source) || !isSubjectId(local)) throw new UsageError(`invalid decision ${JSON.stringify(input.to)}`);
1501
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`;
1502
1625
  to = target;
1503
1626
  shown = target;
1504
1627
  described = `${rel} ${target}.`;
@@ -1549,7 +1672,8 @@ async function planLink(read, input, c, o = {}) {
1549
1672
  ...r.plan.commit,
1550
1673
  title: named,
1551
1674
  body: [described]
1552
- }
1675
+ },
1676
+ notes: [...r.plan.notes ?? [], ...foreignNote === null ? [] : [foreignNote]]
1553
1677
  }
1554
1678
  };
1555
1679
  }
@@ -1608,7 +1732,7 @@ async function planScope(read, input, c, o = {}) {
1608
1732
  }
1609
1733
  };
1610
1734
  if (o.tracked !== void 0) {
1611
- 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` : "";
1612
1736
  notes.push(...unmatchedGlobs(result.set, o.tracked).map((g) => `${g} matches no tracked file${checked}`));
1613
1737
  }
1614
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");
@@ -1797,7 +1921,8 @@ async function unstagedIndexNote(log, at, staged, history, render) {
1797
1921
  */
1798
1922
  async function applyPlan(log, plan, o) {
1799
1923
  const { git } = log;
1800
- 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");
1801
1926
  const stage = new Set(plan.stage);
1802
1927
  if (o.commit) {
1803
1928
  const unstaged = new Set(await git.unstagedPaths(plan.commitScope));
@@ -1902,16 +2027,24 @@ async function applyPlan(log, plan, o) {
1902
2027
  }
1903
2028
  const stale = (err) => `${at} may be stale: ${firstLine(err)}; run adr index`;
1904
2029
  let sha = null;
2030
+ let rendered = null;
1905
2031
  if (commit) {
1906
2032
  const extra = await extraOf();
1907
- const staged = extra.filter(isYaml).flatMap((p) => {
1908
- const c = classifyPath(log.layout, p);
1909
- const name = c.area === "event" && !c.file.includes("/") ? parseEventFileName(c.file) : null;
1910
- 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)
1911
2042
  });
1912
- const message = o.message ?? "";
2043
+ const message = rendered?.message ?? o.message ?? "";
1913
2044
  const mergeTrailers = o.mergeTrailers === true;
1914
- 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)) : []];
1915
2048
  const sameEvents = sorted(extra.filter(isYaml)).join("\0") === sorted(extra0.filter(isYaml)).join("\0");
1916
2049
  if (text !== null && o.index !== void 0 && o.afterStage !== void 0 && !sameEvents) try {
1917
2050
  const again = await o.index(await commitTree(extra), head);
@@ -1990,22 +2123,40 @@ async function applyPlan(log, plan, o) {
1990
2123
  ],
1991
2124
  commit: sha,
1992
2125
  findings,
1993
- notes
2126
+ notes,
2127
+ rendered
1994
2128
  };
1995
2129
  }
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
+ });
2157
+ }
1996
2158
  /** The new bytes of each file a plan replaces. */
1997
2159
  const replaceBytes = (plan) => Object.fromEntries(Object.entries(plan.replace ?? {}).map(([p, r]) => [p, r.bytes]));
1998
- /** `tree` without the paths in `gone` (a staged deletion in a commit's `only` list). */
1999
- function withoutPaths(tree, gone) {
2000
- if (gone.size === 0) return tree;
2001
- return {
2002
- label: tree.label,
2003
- commit: null,
2004
- list: async (prefix) => (await tree.list(prefix)).filter((e) => !gone.has(e.path)),
2005
- read: async (path) => gone.has(path) ? null : tree.read(path),
2006
- readMany: (paths) => tree.readMany(paths.filter((p) => !gone.has(p)))
2007
- };
2008
- }
2009
2160
  const DEFAULT_DEPS = {
2010
2161
  now: Date.now,
2011
2162
  apply: applyPlan
@@ -2031,7 +2182,6 @@ async function viewsAfter(log, plan, sha) {
2031
2182
  affected: plan.affected.map(view)
2032
2183
  };
2033
2184
  }
2034
- const SHORTENED_NOTE = "commit subject shortened to 72 characters; the full title is in the commit body";
2035
2185
  /**
2036
2186
  * `--commit` needs an author and a committer identity; checked before anything is written, since
2037
2187
  * `GIT_AUTHOR_*` alone satisfies the author while `git commit` still fails on the committer.
@@ -2039,24 +2189,63 @@ const SHORTENED_NOTE = "commit subject shortened to 72 characters; the full titl
2039
2189
  async function checkCommitIdentity(log) {
2040
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");
2041
2191
  }
2042
- /** The plan's commit message from `[commit] subject` (design §4), and whether it was shortened. */
2043
- 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) {
2044
2234
  const parts = plan.commit;
2045
2235
  if (parts === null) throw new InvalidArgumentError("this write can't be committed");
2046
- const r = renderCommitMessage(log.adr.commitSubject, {
2047
- title: parts.title,
2236
+ const { action, title } = composeSubject(read, parts, c);
2237
+ const render = (a) => renderCommitMessage(log.adr.commitSubject, {
2238
+ title,
2048
2239
  vars: {
2049
- action: parts.action,
2240
+ action: a,
2050
2241
  id: parts.id,
2051
2242
  display: parts.display,
2052
2243
  command
2053
2244
  },
2054
2245
  body: parts.body
2055
2246
  });
2056
- return {
2057
- message: r.message,
2058
- shortened: r.shortened
2059
- };
2247
+ const r = render(action);
2248
+ return r.shortened && title === parts.display && action !== parts.action ? render(UPDATE_ACTION) : r;
2060
2249
  }
2061
2250
  /**
2062
2251
  * Plan, then apply, with the single `EventExistsError` retry (§5.4 steps 1–5). `o.message`
@@ -2101,15 +2290,15 @@ async function write(log, command, plan, o, deps, afterStage) {
2101
2290
  findings: r.plan.findings,
2102
2291
  notes: [...notes, ...r.plan.notes ?? []]
2103
2292
  };
2104
- 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");
2105
2294
  const given = o.message ?? null;
2106
2295
  try {
2107
2296
  const applied = await deps.apply(log, r.plan, {
2108
2297
  commit,
2109
- ...rendered === null ? {} : {
2110
- message: given ?? rendered.message,
2111
- mergeTrailers: given !== null
2112
- },
2298
+ ...!commit ? {} : given !== null ? {
2299
+ message: given,
2300
+ mergeTrailers: true
2301
+ } : { compose: (c) => commitMessage(log, command, read, r.plan, c) },
2113
2302
  ...afterStage === void 0 ? {} : { afterStage: afterStage(r.plan, commit) },
2114
2303
  ...log.adr.index === "committed" && o.index !== "skip" ? { index: (tree, history) => renderDecisionIndex(log.git, {
2115
2304
  layout: log.layout,
@@ -2122,7 +2311,7 @@ async function write(log, command, plan, o, deps, afterStage) {
2122
2311
  const own = [
2123
2312
  ...r.plan.notes ?? [],
2124
2313
  ...applied.notes,
2125
- ...applied.commit !== null && given === null && rendered?.shortened === true ? [SHORTENED_NOTE] : []
2314
+ ...applied.commit !== null && applied.rendered?.note != null ? [applied.rendered.note] : []
2126
2315
  ];
2127
2316
  return {
2128
2317
  format: "adr-write/v1",
@@ -2225,14 +2414,18 @@ async function proposeDecision(log, input, o = {}, deps = DEFAULT_DEPS) {
2225
2414
  async function acceptDecision(log, input, o = {}, deps = DEFAULT_DEPS) {
2226
2415
  const copyScope = o.copyScope ?? "suggest";
2227
2416
  let copy = null;
2417
+ const tracked = await trackedFiles(log);
2228
2418
  return write(log, "accept", async (read, c) => {
2229
- const plain = await planAccept(read, input, c);
2419
+ const plain = await planAccept(read, input, c, { tracked });
2230
2420
  if (!plain.ok) return plain;
2231
2421
  const id = plain.plan.subject;
2232
2422
  const offer = scopeToCopy(read, id);
2233
2423
  if (offer.globs.length === 0) return plain;
2234
2424
  if (copy === null) copy = typeof copyScope === "function" ? await copyScope(offer) : copyScope === "copy";
2235
- 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
+ });
2236
2429
  if (copyScope === "skip") return plain;
2237
2430
  const notes = [...plain.plan.notes ?? [], copyScopeNote(id, offer)];
2238
2431
  return {
@@ -2275,8 +2468,15 @@ async function noteDecision(log, input, o = {}, deps = DEFAULT_DEPS) {
2275
2468
  * Qualified targets name the log's `[[sources]]`; a link that exists already writes nothing.
2276
2469
  */
2277
2470
  async function linkDecision(log, input, o = {}, deps = DEFAULT_DEPS) {
2278
- const sources = (log.config?.sources ?? []).map((s) => s.name);
2279
- 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);
2280
2480
  }
2281
2481
  /**
2282
2482
  * `adr scope <id> --set|--add|--remove …`: writes one `scoped` event with the new set, stages it,
@@ -2362,6 +2562,7 @@ function listDecisions(read, o = {}) {
2362
2562
  const listed = new Set(decisions.map((v) => v.id));
2363
2563
  return {
2364
2564
  format: "adr-list/v1",
2565
+ source: read.source,
2365
2566
  root: read.root,
2366
2567
  ref: read.ref,
2367
2568
  commit: read.commit,
@@ -2746,6 +2947,7 @@ function searchDecisions(read, o) {
2746
2947
  const listed = new Set(results.map((r) => r.decision.id));
2747
2948
  return {
2748
2949
  format: "adr-search/v1",
2950
+ source: read.source,
2749
2951
  root: read.root,
2750
2952
  ref: read.ref,
2751
2953
  commit: read.commit,
@@ -2760,6 +2962,6 @@ function searchDecisions(read, o) {
2760
2962
  notes: listingNotes(read)
2761
2963
  };
2762
2964
  }
2763
- export { changedSince as A, headTree as C, renderDecisionIndex as D, indexPath as E, readDecisionsFrom as F, resolveDecision as I, sourcesNote as L, numberingNote as M, openDecisionLog as N, runDecisionIndex as O, readDecisions as P, DEFAULT_ADR_CONFIG as R, firstLine as S, indexDecisionLog as T, withdrawDecision as _, stateHeader as a, INDEX_AFTER as b, followScopeRenames as c, proposeDecision as d, rejectDecision as f, updateDraft as g, supersedeDecision as h, showDecision as i, numberDecisions as j, writeIndexCopy 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, holdsText as w, decisionIndexCheck as x, DECISION_INDEX as y, parseAdrConfig as z };
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 };
2764
2966
 
2765
- //# sourceMappingURL=search-CwIPlNGd.js.map
2967
+ //# sourceMappingURL=search-CTspg2pd.js.map