@nikovirtala/adr 0.3.0 → 0.4.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,6 +1,6 @@
1
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";
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";
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,10 @@ const ACTIONS = {
45
45
  names: "display"
46
46
  }
47
47
  };
48
+ /** `{action}` of a draft update (`adr_update_draft`), whose title slot holds the display. */
49
+ const DRAFT_ACTION = "update the draft of";
48
50
  /** 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);
51
+ const LONGEST_ACTION = Math.max(...Object.values(ACTIONS).map((a) => a.action.length), 10, 19);
50
52
  /** What a link's title slot names after the display when the target is in another log. */
51
53
  const CROSS_LOG_LINK = "to a decision in another log";
52
54
  /** adr's commit-subject placeholders, each with its longest value (design §4.2). */
@@ -206,7 +208,8 @@ async function readDecisions(log) {
206
208
  numbering: await numberDecisions(log),
207
209
  ref: log.ref,
208
210
  commit: log.commit,
209
- notes: log.notes
211
+ notes: log.notes,
212
+ sources: (log.config?.sources ?? []).map((s) => s.name)
210
213
  });
211
214
  }
212
215
  /** The display numbers `readDecisions` uses, with its shallow-clone fallback to `index.md`. */
@@ -265,10 +268,24 @@ async function readDecisionsFrom(tree, o) {
265
268
  ...f,
266
269
  kind: adrKind.name
267
270
  }))),
268
- notes: o.notes ?? []
271
+ notes: o.notes ?? [],
272
+ sources: o.sources ?? []
269
273
  };
270
274
  }
271
275
  /**
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.
278
+ */
279
+ 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`;
282
+ }
283
+ /** `read.notes`, then the sources note when there is one. */
284
+ function listingNotes(read) {
285
+ const note = sourcesNote(read);
286
+ return note === null ? read.notes : [...read.notes, note];
287
+ }
288
+ /**
272
289
  * The note a read command adds about where numbers come from: the shallow-clone index, or why
273
290
  * they are unavailable (`unavailable: false` leaves that one out); `null` for none.
274
291
  */
@@ -489,11 +506,11 @@ function decisionIndexCheck(log) {
489
506
  }
490
507
  };
491
508
  }
492
- const encoder$2 = new TextEncoder();
509
+ const encoder$1 = new TextEncoder();
493
510
  /** Whether `bytes` (null: absent) hold exactly `text`. */
494
511
  function holdsText(bytes, text) {
495
512
  if (bytes === null) return false;
496
- const want = encoder$2.encode(text);
513
+ const want = encoder$1.encode(text);
497
514
  return bytes.byteLength === want.byteLength && bytes.every((x, i) => x === want[i]);
498
515
  }
499
516
  /** HEAD's copy of `path`; `null` when it is absent or HEAD is unborn. */
@@ -645,107 +662,6 @@ async function runDecisionIndex(log, o = {}) {
645
662
  async function indexDecisionLog(log, o = {}) {
646
663
  return (await runDecisionIndex(log, o)).doc;
647
664
  }
648
- // src/verify.ts
649
- /** At most this many paths are named in a `decision/scope-narrowed` message. */
650
- const NARROWED_PATHS = 3;
651
- /** The head's decision views with the read's numbering, for the displays in messages. */
652
- async function headViews(log, ctx) {
653
- const head = ctx.folded?.head;
654
- if (head === void 0) return /* @__PURE__ */ new Map();
655
- return projectLog(adrKind, head, { numbering: await numberDecisions(log) }).views;
656
- }
657
- /**
658
- * `decision/governed-by-inactive` (`[verify] governed_by_inactive`, default warning): a changed
659
- * code file that a superseded or deprecated decision governs, by glob or anchor (01:366).
660
- */
661
- function governedByInactive(log) {
662
- return {
663
- id: "decision-governed-by-inactive",
664
- async run(ctx) {
665
- const changes = await governedChanges(ctx, {
666
- keyword: log.adr.anchorKeyword,
667
- exclude: log.adr.anchorExclude,
668
- statuses: ["superseded", "deprecated"]
669
- });
670
- if (changes.length === 0) return [];
671
- const views = await headViews(log, ctx);
672
- const display = (id) => views.get(id)?.display ?? id;
673
- const message = (c) => {
674
- const what = `${c.path} is governed by ${display(c.subject)}`;
675
- if (c.status === "deprecated") return `${what}, which is deprecated; check whether the change still needs it`;
676
- const next = views.get(c.subject)?.superseded_by ?? null;
677
- return next === null ? `${what}, which is superseded; check the change against its successor` : `${what}, which is superseded by ${display(next)}; check the change against ${display(next)}`;
678
- };
679
- return changes.map((c) => finding({
680
- severity: log.adr.governedByInactive,
681
- rule: "decision/governed-by-inactive",
682
- message: message(c),
683
- file: c.path,
684
- subject: c.subject
685
- }));
686
- }
687
- };
688
- }
689
- /**
690
- * `decision/scope-narrowed` (warning): an accepted decision whose new scope stops covering files
691
- * that still exist (01:367), which calls for a check against its Scope section.
692
- */
693
- function scopeNarrowed(log) {
694
- return {
695
- id: "decision-scope-narrowed",
696
- async run(ctx) {
697
- const narrowed = await narrowedScopes(ctx, { statuses: ["accepted"] });
698
- if (narrowed.length === 0) return [];
699
- const views = await headViews(log, ctx);
700
- return narrowed.map((n) => {
701
- const count = n.lost.length;
702
- const files = count === 1 ? "1 file that still exists" : `${count} files that still exist`;
703
- const named = n.lost.slice(0, NARROWED_PATHS).join(", ");
704
- const more = count > NARROWED_PATHS ? ", …" : "";
705
- return finding({
706
- severity: "warning",
707
- rule: "decision/scope-narrowed",
708
- message: `${views.get(n.subject)?.display ?? n.subject}'s scope no longer covers ${files} (${named}${more}); check the change against its Scope section`,
709
- file: n.file,
710
- subject: n.subject
711
- });
712
- });
713
- }
714
- };
715
- }
716
- /**
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`.
722
- */
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))
747
- });
748
- }
749
665
  /** The built-in templates by name: the file text `adr new` materializes. */
750
666
  const BUILTIN_TEMPLATES = Object.freeze({
751
667
  nygard: `---
@@ -855,8 +771,8 @@ sections:
855
771
  `
856
772
  });
857
773
  // src/write.ts
858
- const encoder$1 = new TextEncoder();
859
- const decoder$1 = new TextDecoder();
774
+ const encoder = new TextEncoder();
775
+ const decoder$2 = new TextDecoder();
860
776
  const compiled = compileKind(adrKind);
861
777
  const TEMPLATE_NAME = /^[a-z][a-z0-9-]*$/;
862
778
  const MAX_TEXT_BYTES = 65536;
@@ -867,7 +783,7 @@ const templatePath = (read, name) => `${read.root}/templates/${name}.md`;
867
783
  /** A text argument within the 64 KiB cap and without NUL characters, else `UsageError`. */
868
784
  function checkText(what, text) {
869
785
  if (text.includes("\0")) throw new UsageError(`${what} must not contain NUL characters`);
870
- const size = encoder$1.encode(text).byteLength;
786
+ const size = encoder.encode(text).byteLength;
871
787
  if (size > MAX_TEXT_BYTES) throw new UsageError(`${what} is ${size} bytes; the limit is ${MAX_TEXT_BYTES} (64 KiB)`);
872
788
  }
873
789
  function checkReason(reason) {
@@ -947,14 +863,18 @@ const findingKey = (f) => [
947
863
  * The precheck (§6.3): folds the would-be log and refuses when the write adds any finding. Problems
948
864
  * that were already there don't block; the write can only add findings about what it touches.
949
865
  */
950
- async function precheck(read, files) {
951
- const after = await readDecisionsFrom(overlayTree(read.tree, files), {
866
+ async function precheck(read, files, replace = {}) {
867
+ const after = await readDecisionsFrom(overlayTree(read.tree, {
868
+ ...files,
869
+ ...replace
870
+ }), {
952
871
  layout: read.layout,
953
872
  adr: read.adr,
954
873
  numbering: read.numbering,
955
874
  ref: read.ref,
956
875
  commit: read.commit,
957
- notes: read.notes
876
+ notes: read.notes,
877
+ sources: read.sources
958
878
  });
959
879
  const before = new Set(read.findings.map(findingKey));
960
880
  return {
@@ -963,7 +883,7 @@ async function precheck(read, files) {
963
883
  };
964
884
  }
965
885
  function filesOf(events, read) {
966
- return Object.fromEntries(events.map((e) => [eventPath(read.layout, e), encoder$1.encode(serializeEvent(e))]));
886
+ return Object.fromEntries(events.map((e) => [eventPath(read.layout, e), encoder.encode(serializeEvent(e))]));
967
887
  }
968
888
  const pairsOf = (events) => events.flatMap((e) => [["ADR", e.subject], ["ADR-Event", e.type]]);
969
889
  /** The template `new` uses: the repo's `<root>/templates/<name>.md`, else a built-in to copy. */
@@ -973,7 +893,7 @@ async function chooseTemplate(read, name, c) {
973
893
  const bytes = await read.tree.read(path);
974
894
  let text;
975
895
  let materialize = null;
976
- if (bytes !== null) text = decoder$1.decode(bytes);
896
+ if (bytes !== null) text = decoder$2.decode(bytes);
977
897
  else {
978
898
  const builtin = Object.hasOwn(c.builtins, name) ? c.builtins[name] : void 0;
979
899
  if (builtin === void 0) {
@@ -1060,9 +980,9 @@ async function planPropose(read, input, c, o = {}) {
1060
980
  },
1061
981
  actor: input.actor
1062
982
  });
1063
- const body = encoder$1.encode(rendered.text);
983
+ const body = encoder.encode(rendered.text);
1064
984
  const files = { [bodyPath]: body };
1065
- if (chosen.materialize !== null) files[chosen.path] = encoder$1.encode(chosen.materialize);
985
+ if (chosen.materialize !== null) files[chosen.path] = encoder.encode(chosen.materialize);
1066
986
  Object.assign(files, filesOf([event], read));
1067
987
  const { after, added } = await precheck(read, files);
1068
988
  if (added.length > 0) return {
@@ -1140,6 +1060,126 @@ async function acceptLint(read, view, c) {
1140
1060
  bytes: body.bytes
1141
1061
  };
1142
1062
  }
1063
+ const MAX_SECTIONS = 50;
1064
+ /**
1065
+ * Plans a draft update (design §6.6): the given sections of a proposed decision's body replaced in
1066
+ * place, every other line kept, written only if the body still holds the bytes read. Refused
1067
+ * (`ok: false`) when the decision isn't proposed or its body or template version is missing or
1068
+ * unreadable, and with `refuseIncomplete` when the new draft fails its template lint. An unknown
1069
+ * section, a missing heading or a heading in the content is a `UsageError`.
1070
+ */
1071
+ async function planUpdateDraft(read, input, c, o = {}) {
1072
+ const id = resolveDecision(read, input.id);
1073
+ const view = viewOf(read, id);
1074
+ const refused = (fs) => ({
1075
+ ok: false,
1076
+ decision: view,
1077
+ findings: fs
1078
+ });
1079
+ 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)]);
1080
+ const ids = Object.keys(input.sections);
1081
+ if (ids.length === 0 || ids.length > MAX_SECTIONS) throw new UsageError(`a draft update takes 1 to ${MAX_SECTIONS} sections`);
1082
+ for (const [section, text] of Object.entries(input.sections)) checkText(`section ${section}`, text);
1083
+ const { findings: problems, template, text, body } = await draftOf(read, view, c);
1084
+ if (problems.length > 0 || template === null || text === null || body === null) return refused(problems);
1085
+ const r = replaceSections(template, text, input.sections);
1086
+ if (!r.ok) {
1087
+ const [first] = r.findings;
1088
+ if (first?.rule === "render/unknown-section") {
1089
+ const known = template.sections.map((s) => s.id).join(", ");
1090
+ const unknown = ids.find((s) => !template.sections.some((t) => t.id === s)) ?? "";
1091
+ throw new UsageError(`template ${template.name} has no section ${unknown}; its sections are ${known}`);
1092
+ }
1093
+ if (first?.rule === "render/section-missing") {
1094
+ const gone = new Set(extractSections(template, text).missing);
1095
+ const missing = template.sections.find((s) => ids.includes(s.id) && gone.has(s.id));
1096
+ throw new UsageError(`${body.path} has no '${missing?.heading ?? ""}' heading; edit the file by hand`);
1097
+ }
1098
+ throw new UsageError(r.findings.map((f) => f.message).join("; "));
1099
+ }
1100
+ if (r.text === text) return emptyPlan(id, view, ["the draft already has this content; nothing written"]);
1101
+ const bytes = encoder.encode(r.text);
1102
+ const lint = lintDocument(template, r.text, {
1103
+ file: body.path,
1104
+ subject: id
1105
+ }).map((f) => ({
1106
+ ...f,
1107
+ kind: adrKind.name
1108
+ }));
1109
+ if (o.refuseIncomplete === true && lint.length > 0) return refused(lint);
1110
+ const { after, added } = await precheck(read, {}, { [body.path]: bytes });
1111
+ if (added.length > 0) return refused(added);
1112
+ const { scope, eventDirs } = commitScope(read, [id]);
1113
+ const order = template.sections.map((s) => s.id).filter((s) => ids.includes(s));
1114
+ return {
1115
+ ok: true,
1116
+ plan: {
1117
+ command: "event",
1118
+ subject: id,
1119
+ files: {},
1120
+ replace: { [body.path]: {
1121
+ bytes,
1122
+ expected: body.bytes
1123
+ } },
1124
+ events: [],
1125
+ stage: [body.path],
1126
+ involved: [id],
1127
+ commitScope: scope,
1128
+ eventDirs,
1129
+ draft: null,
1130
+ commit: {
1131
+ action: DRAFT_ACTION,
1132
+ title: view.display,
1133
+ id,
1134
+ display: view.display,
1135
+ body: [`Sections: ${order.join(", ")}.`]
1136
+ },
1137
+ trailers: [["ADR", id]],
1138
+ decision: after.projection.views.get(id) ?? view,
1139
+ affected: [],
1140
+ findings: lint
1141
+ }
1142
+ };
1143
+ }
1144
+ /**
1145
+ * A proposed decision's body, its text and the template version its proposal recorded, or the
1146
+ * refusal findings when one of them is missing or the body isn't UTF-8.
1147
+ */
1148
+ async function draftOf(read, view, c) {
1149
+ const none = {
1150
+ template: null,
1151
+ text: null,
1152
+ body: null
1153
+ };
1154
+ const body = read.folded.subjects.get(view.id)?.loaded.body ?? null;
1155
+ if (body === null) return {
1156
+ findings: [refusal(read, "body/missing", "body file is missing", view.id)],
1157
+ ...none
1158
+ };
1159
+ const { name, version } = view.template;
1160
+ const found = await c.templates.get(name, version);
1161
+ if (!found.ok) {
1162
+ const message = found.reason === "not-found" ? `template ${name} not found at ${found.path}` : `template ${name} has no version ${version} at ${found.path}`;
1163
+ return {
1164
+ findings: [refusal(read, `template/${found.reason}`, message, view.id)],
1165
+ ...none
1166
+ };
1167
+ }
1168
+ try {
1169
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(body.bytes);
1170
+ return {
1171
+ findings: [],
1172
+ template: found.template,
1173
+ text,
1174
+ body
1175
+ };
1176
+ } catch {
1177
+ return {
1178
+ findings: [refusal(read, "body/not-utf8", "body is not valid UTF-8", view.id)],
1179
+ ...none
1180
+ };
1181
+ }
1182
+ }
1143
1183
  /** Plans `adr accept`: `accepted` with the body hash, and `superseded` on each declared target. */
1144
1184
  async function planAccept(read, input, c, o = {}) {
1145
1185
  const id = resolveDecision(read, input.id);
@@ -1736,13 +1776,6 @@ const isYaml = (path) => /\.ya?ml$/.test(path);
1736
1776
  */
1737
1777
  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
1778
  /**
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
1779
  * A write leaves the work tree's render of `index.md` (`staged`) staged, and it lists decision
1747
1780
  * files that may not be staged. When some aren't and the index's own render differs, the next
1748
1781
  * plain commit's hook fails; the note says so, as its `index/stale` finding does. `null`
@@ -1755,6 +1788,13 @@ async function unstagedIndexNote(log, at, staged, history, render) {
1755
1788
  if (own === null || own === staged) return null;
1756
1789
  return `${unstagedIndexText(at, files)}; git add them before you commit, or move them out of the work tree and run adr index`;
1757
1790
  }
1791
+ /**
1792
+ * Applies a plan (§5.4): refuses a commit whose scope has unstaged or needed untracked paths,
1793
+ * writes the files (removing them again if a write fails), stages them, runs `afterStage`, and
1794
+ * commits `plan.stage` plus the involved subjects' already-staged files with exact trailers. A
1795
+ * failed `git commit` throws `CommitFailedError` with the manual command (design 2b D5) and the
1796
+ * notes made so far.
1797
+ */
1758
1798
  async function applyPlan(log, plan, o) {
1759
1799
  const { git } = log;
1760
1800
  if (o.commit && o.message === void 0) throw new InvalidArgumentError("applyPlan needs a message to commit");
@@ -1776,7 +1816,7 @@ async function applyPlan(log, plan, o) {
1776
1816
  const files = {};
1777
1817
  const gone = /* @__PURE__ */ new Set();
1778
1818
  for (const p of sorted([...plan.stage, ...extra])) {
1779
- const bytes = plan.files[p] ?? await work.read(p);
1819
+ const bytes = plan.files[p] ?? plan.replace?.[p]?.bytes ?? await work.read(p);
1780
1820
  if (bytes === null) gone.add(p);
1781
1821
  else files[p] = bytes;
1782
1822
  }
@@ -1785,7 +1825,10 @@ async function applyPlan(log, plan, o) {
1785
1825
  const extra0 = o.commit && o.index !== void 0 ? await extraOf() : [];
1786
1826
  let text = null;
1787
1827
  if (o.index !== void 0) {
1788
- const tree = o.commit ? await commitTree(extra0) : overlayTree(worktreeReader(git), plan.files);
1828
+ const tree = o.commit ? await commitTree(extra0) : overlayTree(worktreeReader(git), {
1829
+ ...plan.files,
1830
+ ...replaceBytes(plan)
1831
+ });
1789
1832
  text = await o.index(tree, head);
1790
1833
  if (text === null) notes.push(`${at} not updated: numbers need history in this shallow clone; run git fetch --unshallow, then adr index`);
1791
1834
  }
@@ -1815,7 +1858,12 @@ async function applyPlan(log, plan, o) {
1815
1858
  };
1816
1859
  const eventPaths = new Set(plan.events.map((e) => eventPath(log.layout, e)));
1817
1860
  const written = [];
1861
+ const replaced = [];
1818
1862
  try {
1863
+ for (const [path, r] of Object.entries(plan.replace ?? {})) {
1864
+ await replaceFile(git, path, r.bytes, { expected: r.expected });
1865
+ replaced.push(path);
1866
+ }
1819
1867
  for (const [path, bytes] of Object.entries(plan.files)) {
1820
1868
  if (eventPaths.has(path)) continue;
1821
1869
  await writeNewFile(git, path, bytes, { stage: false });
@@ -1833,6 +1881,10 @@ async function applyPlan(log, plan, o) {
1833
1881
  else await writeFile(join(top, at), indexOld);
1834
1882
  }
1835
1883
  for (const path of written) await rm(join(top, path), { force: true });
1884
+ for (const path of replaced) {
1885
+ const r = plan.replace?.[path];
1886
+ if (r !== void 0) await replaceFile(git, path, r.expected, { expected: r.bytes }).catch(() => {});
1887
+ }
1836
1888
  throw err;
1837
1889
  }
1838
1890
  await git.add([...plan.stage, ...toStage]);
@@ -1931,12 +1983,18 @@ async function applyPlan(log, plan, o) {
1931
1983
  if (note !== null) notes.push(note);
1932
1984
  }
1933
1985
  return {
1934
- written: [...Object.keys(plan.files), ...indexWritten ? [at] : []],
1986
+ written: [
1987
+ ...Object.keys(plan.replace ?? {}),
1988
+ ...Object.keys(plan.files),
1989
+ ...indexWritten ? [at] : []
1990
+ ],
1935
1991
  commit: sha,
1936
1992
  findings,
1937
1993
  notes
1938
1994
  };
1939
1995
  }
1996
+ /** The new bytes of each file a plan replaces. */
1997
+ const replaceBytes = (plan) => Object.fromEntries(Object.entries(plan.replace ?? {}).map(([p, r]) => [p, r.bytes]));
1940
1998
  /** `tree` without the paths in `gone` (a staged deletion in a commit's `only` list). */
1941
1999
  function withoutPaths(tree, gone) {
1942
2000
  if (gone.size === 0) return tree;
@@ -2032,7 +2090,7 @@ async function write(log, command, plan, o, deps, afterStage) {
2032
2090
  findings: r.findings,
2033
2091
  notes
2034
2092
  };
2035
- if (r.plan.events.length === 0 && Object.keys(r.plan.files).length === 0 || o.dryRun === true) return {
2093
+ 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
2094
  format: "adr-write/v1",
2037
2095
  command,
2038
2096
  ok: true,
@@ -2128,7 +2186,7 @@ function editorStep(log, edit, onNote) {
2128
2186
  onNote?.(`${first} changed while the editor was open; review it, then git add it and commit`);
2129
2187
  }
2130
2188
  }
2131
- const text = decoder$1.decode(bytes ?? d.rendered);
2189
+ const text = decoder$2.decode(bytes ?? d.rendered);
2132
2190
  const findings = lintDocument(d.template, text, {
2133
2191
  file: d.path,
2134
2192
  subject: plan.subject
@@ -2228,6 +2286,19 @@ async function scopeDecision(log, input, o = {}, deps = DEFAULT_DEPS) {
2228
2286
  const tracked = await trackedFiles(log);
2229
2287
  return write(log, "scope", (read, c) => planScope(read, input, c, { tracked }), o, deps);
2230
2288
  }
2289
+ /**
2290
+ * Replaces sections of a proposed decision's draft (design §6.6), stages the body, and commits
2291
+ * with `commit` (default `[policy] auto_commit`). A body with unstaged changes is a `UsageError`,
2292
+ * so nobody's unsaved edit is folded into this write; a body changed between plan and write is
2293
+ * `FileChangedError`. A commit is never made of a draft that fails its template lint: it is
2294
+ * refused (`ok: false`, nothing written), as for `proposeDecision` without an editor.
2295
+ */
2296
+ async function updateDraft(log, input, o = {}, deps = DEFAULT_DEPS) {
2297
+ const read = await readDecisions(log);
2298
+ const view = viewOf(read, resolveDecision(read, input.id));
2299
+ 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`);
2300
+ return write(log, "update", (read, c) => planUpdateDraft(read, input, c, { refuseIncomplete: o.commit ?? log.adr.autoCommit }), o, deps);
2301
+ }
2231
2302
  const FOLLOW_NO_COMMIT_NOTE = "--follow-renames never commits; commit the scoped events with the renames";
2232
2303
  /**
2233
2304
  * `adr scope [<decision>] --follow-renames` (design §6.8): rewrites the scope globs of the given
@@ -2261,357 +2332,6 @@ async function followScopeRenames(log, input, o = {}, deps = DEFAULT_DEPS) {
2261
2332
  notes: [...doc.notes, FOLLOW_NO_COMMIT_NOTE]
2262
2333
  } : doc;
2263
2334
  }
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
2335
  // src/views.ts
2616
2336
  /** The `numbering` key of `adr-list/v1`, `adr-show/v1` and `adr-for/v1`. */
2617
2337
  const numberingJson = (read) => ({
@@ -2651,10 +2371,10 @@ function listDecisions(read, o = {}) {
2651
2371
  unreadable: read.projection.unreadable,
2652
2372
  unfoldable: read.projection.unfoldable,
2653
2373
  findings: read.findings.filter((f) => f.subject === null || listed.has(f.subject)),
2654
- notes: read.notes
2374
+ notes: listingNotes(read)
2655
2375
  };
2656
2376
  }
2657
- const decoder = new TextDecoder();
2377
+ const decoder$1 = new TextDecoder();
2658
2378
  /**
2659
2379
  * The `adr-show/v1` document of the decision `id` (resolve it with `resolveDecision` first).
2660
2380
  * Throws `UsageError` for an unknown, unreadable or unfoldable decision.
@@ -2686,7 +2406,7 @@ async function showDecision(read, id, o = {}) {
2686
2406
  numbering: numberingJson(read),
2687
2407
  header: stateHeader(view, read),
2688
2408
  decision: view,
2689
- body: body === null ? null : decoder.decode(body.bytes),
2409
+ body: body === null ? null : decoder$1.decode(body.bytes),
2690
2410
  events,
2691
2411
  findings: read.findings.filter((f) => f.subject === id),
2692
2412
  notes: read.notes
@@ -2744,6 +2464,7 @@ const GLOBS_APPLY = /* @__PURE__ */ new Set([
2744
2464
  "deprecated",
2745
2465
  "superseded"
2746
2466
  ]);
2467
+ /** The order `adr for` and `adr search` list statuses in: in force first. */
2747
2468
  const STATUS_ORDER = [
2748
2469
  "accepted",
2749
2470
  "proposed",
@@ -2865,9 +2586,180 @@ async function decisionsFor(read, log, o) {
2865
2586
  numbering: numberingJson(read),
2866
2587
  paths: out,
2867
2588
  findings: read.findings.filter((f) => f.subject === null || listed.has(f.subject)),
2868
- notes: read.notes
2589
+ notes: listingNotes(read)
2590
+ };
2591
+ }
2592
+ // src/search.ts
2593
+ const MAX_QUERY = 200;
2594
+ const MAX_TERMS = 10;
2595
+ const EXCERPT = 160;
2596
+ const LEAD = 60;
2597
+ const WORD_SLACK = 15;
2598
+ const ACTIVE = /* @__PURE__ */ new Set(["proposed", "accepted"]);
2599
+ const decoder = new TextDecoder("utf-8", { fatal: true });
2600
+ /**
2601
+ * Case- and accent-insensitive form: per code point NFKD, combining marks dropped, lower-cased.
2602
+ * `Café` → `cafe` and `fi` → `fi`; `ß` and `ø` have no decomposition and stay as they are.
2603
+ */
2604
+ function fold(s) {
2605
+ let text = "";
2606
+ const at = [];
2607
+ let offset = 0;
2608
+ for (const c of s) {
2609
+ const f = c.normalize("NFKD").replace(/\p{M}/gu, "").toLowerCase();
2610
+ text += f;
2611
+ for (let i = 0; i < f.length; i++) at.push(offset);
2612
+ offset += c.length;
2613
+ }
2614
+ at.push(offset);
2615
+ return {
2616
+ text,
2617
+ at
2618
+ };
2619
+ }
2620
+ const count = (hay, needle) => {
2621
+ let n = 0;
2622
+ for (let i = hay.indexOf(needle); i >= 0; i = hay.indexOf(needle, i + needle.length)) n++;
2623
+ return n;
2624
+ };
2625
+ /** The query's terms, or a `UsageError` for an empty, long, control-character or many-word query. */
2626
+ function queryTerms(query) {
2627
+ const q = query.trim();
2628
+ 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`);
2629
+ const terms = [...new Set(fold(q).text.split(/\s+/).filter((t) => t !== ""))];
2630
+ if (terms.length > MAX_TERMS) throw new UsageError(`a search takes at most ${MAX_TERMS} words`);
2631
+ if (terms.length === 0) throw new UsageError("the search query must contain a letter, digit or symbol");
2632
+ return terms;
2633
+ }
2634
+ /** The body text without its first `# ` title line; `""` when missing or not UTF-8. */
2635
+ function bodyText(read, id) {
2636
+ const bytes = read.folded.subjects.get(id)?.loaded?.body?.bytes;
2637
+ if (bytes === void 0) return "";
2638
+ let text;
2639
+ try {
2640
+ text = decoder.decode(bytes).replace(/\r\n/g, "\n");
2641
+ } catch {
2642
+ return "";
2643
+ }
2644
+ const lines = text.split("\n");
2645
+ const title = lines.findIndex((l) => l.startsWith("# "));
2646
+ if (title >= 0) lines.splice(title, 1);
2647
+ return lines.join("\n");
2648
+ }
2649
+ function noteTexts(read, id) {
2650
+ return (read.folded.subjects.get(id)?.applied ?? []).flatMap((a) => {
2651
+ const text = a.event.data?.text;
2652
+ return a.event.type === "noted" && typeof text === "string" ? [text] : [];
2653
+ });
2654
+ }
2655
+ /** The excerpt of the first line with the most distinct terms, windowed around the first hit. */
2656
+ function excerpt(lines, terms) {
2657
+ let best = null;
2658
+ for (const raw of lines) {
2659
+ const line = raw.replace(/\s+/g, " ").trim();
2660
+ const folded = fold(line).text;
2661
+ const hits = terms.filter((t) => folded.includes(t)).length;
2662
+ if (hits > 0 && (best === null || hits > best.hits)) best = {
2663
+ line,
2664
+ hits
2665
+ };
2666
+ }
2667
+ if (best === null) return null;
2668
+ const { line } = best;
2669
+ if (line.length <= EXCERPT) return line;
2670
+ const f = fold(line);
2671
+ const first = Math.min(...terms.map((t) => f.text.indexOf(t)).filter((i) => i >= 0));
2672
+ let start = Math.max(0, (f.at[first] ?? 0) - LEAD);
2673
+ if (start > 0 && line[start - 1] !== " ") {
2674
+ const space = line.indexOf(" ", start);
2675
+ if (space >= 0 && space - start <= WORD_SLACK) start = space + 1;
2676
+ }
2677
+ let end = Math.min(line.length, start + EXCERPT);
2678
+ if (end < line.length && line[end] !== " ") {
2679
+ const space = line.lastIndexOf(" ", end);
2680
+ if (space > start && end - space <= WORD_SLACK) end = space;
2681
+ }
2682
+ const cut = line.slice(start, end).trim();
2683
+ return `${start > 0 ? "…" : ""}${cut}${end < line.length ? "…" : ""}`;
2684
+ }
2685
+ /**
2686
+ * Full-text search of the log (design §8.1): a decision matches when every term of the folded
2687
+ * query is a substring of its folded id, display number, title, body or note texts. Ranked by
2688
+ * score (10 for a term equal to the id or number, 5 in the title, up to 3 for body hits and 2
2689
+ * for note hits), then status as `adr for` orders them, then list order. Only proposed and
2690
+ * accepted decisions unless `includeInactive`. Throws `UsageError` for an invalid query.
2691
+ */
2692
+ function searchDecisions(read, o) {
2693
+ const terms = queryTerms(o.query);
2694
+ if (o.limit !== void 0 && (!Number.isInteger(o.limit) || o.limit < 1)) throw new UsageError("the search limit must be a positive integer");
2695
+ const includeInactive = o.includeInactive === true;
2696
+ const found = [];
2697
+ let inactive = 0;
2698
+ for (const view of read.projection.views.values()) {
2699
+ const body = bodyText(read, view.id);
2700
+ const notes = noteTexts(read, view.id);
2701
+ const id = fold(view.id).text;
2702
+ const display = fold(view.display).text;
2703
+ const title = fold(view.title).text;
2704
+ const foldedBody = fold(body).text;
2705
+ const foldedNotes = notes.map((n) => fold(n).text).join("\n");
2706
+ const hit = /* @__PURE__ */ new Set();
2707
+ let score = 0;
2708
+ let all = true;
2709
+ for (const t of terms) {
2710
+ const inId = id.includes(t) || display.includes(t);
2711
+ const inTitle = title.includes(t);
2712
+ const inBody = count(foldedBody, t);
2713
+ const inNotes = count(foldedNotes, t);
2714
+ if (!inId && !inTitle && inBody === 0 && inNotes === 0) {
2715
+ all = false;
2716
+ break;
2717
+ }
2718
+ if (inId) hit.add("id");
2719
+ if (inTitle) hit.add("title");
2720
+ if (inBody > 0) hit.add("body");
2721
+ if (inNotes > 0) hit.add("notes");
2722
+ score += (t === id || t === display ? 10 : 0) + (inTitle ? 5 : 0) + Math.min(3, inBody) + Math.min(2, inNotes);
2723
+ }
2724
+ if (!all) continue;
2725
+ if (!includeInactive && !ACTIVE.has(view.status)) {
2726
+ inactive++;
2727
+ continue;
2728
+ }
2729
+ const lines = [...body.split("\n").filter((l) => l.trim() !== "" && !l.startsWith("#")), ...notes.flatMap((n) => n.split("\n"))];
2730
+ found.push({
2731
+ view,
2732
+ decision: view,
2733
+ header: stateHeader(view, read),
2734
+ score,
2735
+ matched: [
2736
+ "id",
2737
+ "title",
2738
+ "body",
2739
+ "notes"
2740
+ ].filter((f) => hit.has(f)),
2741
+ excerpt: excerpt(lines, terms)
2742
+ });
2743
+ }
2744
+ 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));
2745
+ const results = found.slice(0, o.limit ?? Number.POSITIVE_INFINITY).map(({ view: _, ...r }) => r);
2746
+ const listed = new Set(results.map((r) => r.decision.id));
2747
+ return {
2748
+ format: "adr-search/v1",
2749
+ root: read.root,
2750
+ ref: read.ref,
2751
+ commit: read.commit,
2752
+ numbering: numberingJson(read),
2753
+ query: o.query.trim(),
2754
+ terms,
2755
+ include_inactive: includeInactive,
2756
+ total: found.length,
2757
+ inactive_omitted: inactive,
2758
+ results,
2759
+ findings: read.findings.filter((f) => f.subject === null || listed.has(f.subject)),
2760
+ notes: listingNotes(read)
2869
2761
  };
2870
2762
  }
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 };
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 };
2872
2764
 
2873
- //# sourceMappingURL=scope-CLqH-q1y.js.map
2765
+ //# sourceMappingURL=search-CwIPlNGd.js.map