@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.
- package/README.md +49 -6
- package/dist/cli.js +168 -39
- package/dist/cli.js.map +1 -1
- package/dist/index.js +4 -2
- package/dist/init-YgW7g9Fx.js +588 -0
- package/dist/init-YgW7g9Fx.js.map +1 -0
- package/dist/list-Dt4KGxvj.js +58 -0
- package/dist/list-Dt4KGxvj.js.map +1 -0
- package/dist/{scope-CLqH-q1y.js → search-CwIPlNGd.js} +379 -487
- package/dist/search-CwIPlNGd.js.map +1 -0
- package/dist/server-CXCO5zfp.js +938 -0
- package/dist/server-CXCO5zfp.js.map +1 -0
- package/dist/types/cli/init.d.ts +5 -2
- package/dist/types/cli/mcp.d.ts +6 -0
- package/dist/types/cli/search.d.ts +3 -0
- package/dist/types/cli/text.d.ts +6 -0
- package/dist/types/commit-actions.d.ts +2 -0
- package/dist/types/index.d.ts +5 -2
- package/dist/types/init.d.ts +16 -1
- package/dist/types/log.d.ts +10 -0
- package/dist/types/mcp/policy.d.ts +33 -0
- package/dist/types/mcp/prompt.d.ts +7 -0
- package/dist/types/mcp/resources.d.ts +16 -0
- package/dist/types/mcp/server.d.ts +16 -0
- package/dist/types/mcp/text.d.ts +42 -0
- package/dist/types/mcp/tools.d.ts +27 -0
- package/dist/types/scope.d.ts +3 -1
- package/dist/types/search.d.ts +61 -0
- package/dist/types/skill.d.ts +5 -0
- package/dist/types/templates/list.d.ts +31 -0
- package/dist/types/views.d.ts +3 -1
- package/dist/types/write.d.ts +39 -1
- package/package.json +5 -2
- package/dist/scope-CLqH-q1y.js.map +0 -1
|
@@ -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,
|
|
3
|
-
import {
|
|
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$
|
|
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$
|
|
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
|
|
859
|
-
const decoder$
|
|
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
|
|
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,
|
|
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
|
|
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$
|
|
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
|
|
983
|
+
const body = encoder.encode(rendered.text);
|
|
1064
984
|
const files = { [bodyPath]: body };
|
|
1065
|
-
if (chosen.materialize !== null) files[chosen.path] = encoder
|
|
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),
|
|
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: [
|
|
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$
|
|
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
|
|
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
|
|
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 {
|
|
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=
|
|
2765
|
+
//# sourceMappingURL=search-CwIPlNGd.js.map
|