@assuredloop/cli 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +260 -2
- package/bin/al.js +111 -0
- package/package.json +32 -4
- package/skills/assuredloop/SKILL.md +48 -0
- package/src/adrs.js +110 -0
- package/src/audit.js +176 -0
- package/src/check.js +127 -0
- package/src/commands.js +456 -0
- package/src/conclude.js +293 -0
- package/src/consolidate.js +279 -0
- package/src/git.js +155 -0
- package/src/hints.js +433 -0
- package/src/links.js +181 -0
- package/src/record-section.js +126 -0
- package/src/sections.js +125 -0
- package/src/signoff.js +166 -0
- package/src/snapshot.js +63 -0
- package/src/spec.js +228 -0
- package/src/states.js +227 -0
- package/src/tests.js +267 -0
- package/src/tree.js +113 -0
- package/src/views.js +390 -0
package/src/views.js
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
// al context <ID> ([VW-3]), al context --diff <range> [--for review] ([VW-4]),
|
|
2
|
+
// an archived request's sections ([VW-6]), and the code still live for
|
|
3
|
+
// dropped work ([REC-9]), all from the rough links ([LNK-1], [LNK-2]).
|
|
4
|
+
import { Fail, HISTORY, cutOffNotes, day, gapFail, git, historyGap, isShallow, mainCommit, ownCommits, resolveCommit } from './git.js';
|
|
5
|
+
import { openTree } from './tree.js';
|
|
6
|
+
import { rootOf, rootLine, baseline, configured } from './spec.js';
|
|
7
|
+
import { allBlocks, changeStates, stateText, statesOf } from './states.js';
|
|
8
|
+
import { TITLES, headingAt, sameSection, sectionsById } from './sections.js';
|
|
9
|
+
import { follows, isDropped, organized, parts, samePart, signoffState } from './signoff.js';
|
|
10
|
+
import { line, lines, grouped, decisionList, entriesOf, concluding } from './commands.js';
|
|
11
|
+
import { hintLines, hintsOf, ranked, readBranch } from './hints.js';
|
|
12
|
+
import { WIDE, blame, changedWith, cites, describe, filesOf, idNear, idsOn, mayTouch, movesOf, ownFiles, paths, ranges, requestCommits, requestOf, requestsIn, sectionsChanged, wordsOf } from './links.js';
|
|
13
|
+
import { headNote, resultLines, testLines, testMatcher } from './tests.js';
|
|
14
|
+
import { adrFolders, governing } from './adrs.js';
|
|
15
|
+
import { amends, noChangeMd, outcomeFacts, ownIds } from './conclude.js';
|
|
16
|
+
|
|
17
|
+
const isCode = (root) => (p) => !p.startsWith('requests/') && !p.startsWith(`${root}/`);
|
|
18
|
+
const stateOf = (e) => `${e.block} ${stateText(e)}`;
|
|
19
|
+
const lineCount = (text) => text.replace(/\n+$/, '').split('\n').length;
|
|
20
|
+
const skippedLine = (n) => line('Skipped', `${n} commit${n === 1 ? '' : 's'} over ${WIDE} files, not read for co-change`);
|
|
21
|
+
const titles = (files) => new Map(files.flatMap((f) => f.sections).filter((s) => s.id).map((s) => [s.id, s.title]));
|
|
22
|
+
|
|
23
|
+
// al context <ID>: the section, who holds it, who shaped it, the decisions
|
|
24
|
+
// and ADRs citing it, and the code and tests linked to it, each link with its
|
|
25
|
+
// reason.
|
|
26
|
+
// Under --at, everything is read at that commit, never the working tree.
|
|
27
|
+
export function sectionView(top, id, at) {
|
|
28
|
+
const tree = openTree(top, at);
|
|
29
|
+
const rev = at ? resolveCommit(top, at) : null;
|
|
30
|
+
const root = rootOf(top, tree, at);
|
|
31
|
+
const file = baseline(tree, root).find((f) => f.sections.some((s) => s.id === id));
|
|
32
|
+
const s = file?.sections.find((x) => x.id === id);
|
|
33
|
+
const requests = requestsIn(tree);
|
|
34
|
+
const held = changeStates(top, { at }).filter((e) => e.id === id);
|
|
35
|
+
const indent = (text) => text.replace(/\n+$/, '').split('\n').map((l) => ` ${l}`);
|
|
36
|
+
const body = s ? [`[${id}] ${s.title} in ${file.path}`, ...indent(s.text)] : [`[${id}] not in the baseline`];
|
|
37
|
+
body.push(line('Held', held.length ? held.map(stateOf).join(' · ') : 'by no open change'));
|
|
38
|
+
// Not in the baseline yet: the design it will have is each holder's "now".
|
|
39
|
+
const blocks = allBlocks(tree);
|
|
40
|
+
if (!s) for (const e of held) if (blocks.get(e.block).now) body.push(`${e.block} now:`, ...indent(blocks.get(e.block).now));
|
|
41
|
+
const links = [];
|
|
42
|
+
let skipped = 0;
|
|
43
|
+
if (s && isShallow(top)) body.push(line('Shaped by', `${HISTORY}; co-change not read`));
|
|
44
|
+
else if (s) {
|
|
45
|
+
const seen = new Map();
|
|
46
|
+
const commits = [...new Set(blame(top, rev, file.path, [[s.line, s.line + lineCount(s.text) - 1]], { code: false })
|
|
47
|
+
.map((b) => b.sha).filter((sha) => !/^0+$/.test(sha)))].map((sha) => requestOf(top, sha, requests, seen));
|
|
48
|
+
body.push(line('Shaped by', commits.map((c) => (c.names.length ? `${c.names.join(', ')} (${c.how}, ${day(c.when)})` : describe(c))).join(' · ') || 'not committed yet'));
|
|
49
|
+
for (const c of commits) {
|
|
50
|
+
const files = filesOf(top, c.sha);
|
|
51
|
+
if (files.length > WIDE) skipped++;
|
|
52
|
+
else links.push(...files.filter(isCode(root)).map((p) => [p, `${p} changed together with [${id}] (${day(c.when)}, ${describe(c)})`]));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const named = git(top, ['grep', '-n', '-z', '-F', `[${id}]`, ...(rev ? [rev] : []), '--', '.', ':!requests', `:!${root}`], { allowFail: true, worktree: !rev }) ?? '';
|
|
56
|
+
const byName = named.split('\n').filter(Boolean).map((l) => l.split('\0')).map(([p, n]) => [p.replace(`${rev}:`, ''), n])
|
|
57
|
+
.map(([p, n]) => [p, `${p}:${n} names [${id}]`]);
|
|
58
|
+
const heading = s ? wordsOf(s.title) : new Set();
|
|
59
|
+
const byWord = paths(git(top, rev ? ['ls-tree', '-r', '-z', '--name-only', rev] : ['ls-files', '-z'], { worktree: !rev })).filter(isCode(root)).flatMap((p) => {
|
|
60
|
+
const w = [...wordsOf(p)].find((x) => heading.has(x));
|
|
61
|
+
return w ? [[p, `${p} shares the word "${w}"`]] : [];
|
|
62
|
+
});
|
|
63
|
+
const decided = requests.flatMap((r) => entriesOf(r.md, TITLES.decisions).filter((e) => /^- D\d+/.test(e) && cites(e, id))
|
|
64
|
+
.map((e) => `${r.name} ${e.match(/^- (D\d+)/)[1]}`));
|
|
65
|
+
body.push(line('Decisions', decided.length ? decided.join(' · ') : 'none cite it'));
|
|
66
|
+
const adrs = governing(top, tree, id, at);
|
|
67
|
+
body.push(line('ADRs', adrs.length ? adrs.join(' · ') : 'none cite it'));
|
|
68
|
+
// An ADR file shows on the ADRs line; a test file under Tests ([LNK-3]).
|
|
69
|
+
const isTest = testMatcher(root, configured(top, tree, at, 'tests'));
|
|
70
|
+
const adr = adrFolders(top, tree, at);
|
|
71
|
+
const all = [...byName, ...links, ...byWord].filter(([p]) => !adr.some((d) => p.startsWith(`${d}/`)));
|
|
72
|
+
const tests = all.filter(([p]) => isTest(p)).map(([, t]) => t);
|
|
73
|
+
body.push(...lines('Links', all.filter(([p]) => !isTest(p)).map(([, t]) => t)));
|
|
74
|
+
body.push(...lines('Tests', tests.length ? tests : ['none linked']));
|
|
75
|
+
if (skipped) body.push(skippedLine(skipped));
|
|
76
|
+
return {
|
|
77
|
+
body,
|
|
78
|
+
next: held.length ? `al context ${held[0].request}` : 'al spec --list',
|
|
79
|
+
notKnown: ['whether the linked code does what the section says (tests and review judge that)'],
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The hunks of `path` between two revs (the working tree when `head` is null): old and new start and count.
|
|
84
|
+
function hunks(top, base, head, path) {
|
|
85
|
+
return git(top, ['diff', '-U0', '--no-renames', base, ...(head ? [head] : []), '--', path], { worktree: !head }).split('\n').map((l) => l.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/))
|
|
86
|
+
.filter(Boolean).map((m) => ({ a: Number(m[1]), b: Number(m[2] ?? 1), c: Number(m[3]), d: Number(m[4] ?? 1) }));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The links of one changed code file ([LNK-1]): lines of text, the section
|
|
90
|
+
// IDs they reach, and the requests blame names. A null `head` reads the working tree.
|
|
91
|
+
export function fileLinks(top, base, head, path, { root, requests, seen, shallow, headings }) {
|
|
92
|
+
const text = (head ? git(top, ['show', `${head}:${path}`], { allowFail: true }) : openTree(top).read(path)?.toString('utf8')) ?? '';
|
|
93
|
+
const fileLines = text.split('\n');
|
|
94
|
+
const out = [];
|
|
95
|
+
const ids = new Map();
|
|
96
|
+
const reach = (id, reason) => ids.has(id) || ids.set(id, reason);
|
|
97
|
+
const hs = hunks(top, base, head, path);
|
|
98
|
+
for (const h of hs) {
|
|
99
|
+
const at = h.d ? h.c : h.c + 1;
|
|
100
|
+
for (const { id, line, reason } of idNear(fileLines, at, Array.from({ length: h.d }, (_, i) => h.c + i))) {
|
|
101
|
+
out.push(`${path}:${line} ${reason}`);
|
|
102
|
+
reach(id, `${path}:${line} ${reason}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const blamed = new Set();
|
|
106
|
+
let skipped = 0;
|
|
107
|
+
if (shallow) out.push(`${path} blame and co-change: ${HISTORY}`);
|
|
108
|
+
else {
|
|
109
|
+
const old = hs.filter((h) => h.b > 0).map((h) => [h.a, h.a + h.b - 1]);
|
|
110
|
+
const found = new Map();
|
|
111
|
+
for (const b of blame(top, base, path, old, { code: true, ignoreAt: head })) {
|
|
112
|
+
const c = requestOf(top, b.sha, requests, seen);
|
|
113
|
+
found.set(describe(c).replace(/ \(\w{7} .*\)$/, ''), c);
|
|
114
|
+
c.names.forEach((n) => blamed.add(n));
|
|
115
|
+
}
|
|
116
|
+
if (!old.length) out.push(`${path} no old side: only new lines, not blamed`);
|
|
117
|
+
for (const c of found.values()) out.push(`${path} last changed by ${c.names.length ? `${c.names.join(', ')} (${c.how})` : `a commit with ${describe(c)}`}`);
|
|
118
|
+
const co = changedWith(top, base, path);
|
|
119
|
+
skipped = co.skipped;
|
|
120
|
+
for (const { sha, files } of co.commits) {
|
|
121
|
+
const ids = sectionsChanged(top, `${sha}^`, sha, root, files);
|
|
122
|
+
const c = requestOf(top, sha, requests, seen);
|
|
123
|
+
const reason = `${path} ${ids.map((id) => `[${id}]`).join(' ')} changed together (${day(c.when)}, ${describe(c)})`;
|
|
124
|
+
if (ids.length) out.push(reason);
|
|
125
|
+
ids.forEach((id) => reach(id, reason));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const words = wordsOf(path);
|
|
129
|
+
for (const [id, title] of headings) {
|
|
130
|
+
const w = [...wordsOf(title)].find((x) => words.has(x));
|
|
131
|
+
if (w) {
|
|
132
|
+
out.push(`${path} [${id}] shares the word "${w}"`);
|
|
133
|
+
reach(id, `${path} [${id}] shares the word "${w}"`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return { out, ids, blamed, skipped };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// al context --diff <range> [--for review]: A...B from merge-base(A, B),
|
|
140
|
+
// A..B from A, a single rev X as X...HEAD; commits only.
|
|
141
|
+
// Under --at X, HEAD and an omitted side read as X.
|
|
142
|
+
export function diffView(top, range, forReview, all, at) {
|
|
143
|
+
const m = range.match(/^(.*?)(\.\.\.?)(.*)$/);
|
|
144
|
+
// A bare `main` is the main the tool reads ([VW-9]).
|
|
145
|
+
const side = (x) => (!x || x === 'HEAD' ? at ?? 'HEAD' : x === 'main' ? mainCommit(top) ?? x : x);
|
|
146
|
+
const [a, dots, b] = m ? [side(m[1]), m[2], side(m[3])] : [side(range), '...', side()];
|
|
147
|
+
const head = resolveCommit(top, b);
|
|
148
|
+
const from = resolveCommit(top, a);
|
|
149
|
+
const base = dots === '...' ? git(top, ['merge-base', from, head], { allowFail: true }) : from;
|
|
150
|
+
const gap = historyGap(top);
|
|
151
|
+
if (!base && gap) throw gapFail(gap, `, and the merge-base of ${a} and ${b} is not in it`);
|
|
152
|
+
if (!base) throw new Fail(`${range}: ${a} and ${b} share no history`, 'al context --diff main...HEAD');
|
|
153
|
+
const shallow = isShallow(top);
|
|
154
|
+
const tree = openTree(top, head);
|
|
155
|
+
const root = rootOf(top, tree, head);
|
|
156
|
+
const requests = requestsIn(tree);
|
|
157
|
+
const seen = new Map();
|
|
158
|
+
const files = baseline(tree, root);
|
|
159
|
+
const headings = titles(files);
|
|
160
|
+
const commits = ownCommits(top, base, head).map((c) => requestOf(top, c, requests, seen, ownFiles));
|
|
161
|
+
// The hints check makes, read at the head ([HNT-1]); and what it serves, a request folder the branch changes included.
|
|
162
|
+
const branch = readBranch(top, { base, commits: commits.map((c) => c.sha), tree, at: head, range: `${base.slice(0, 7)}..${head.slice(0, 7)}` });
|
|
163
|
+
const served = new Map();
|
|
164
|
+
for (const c of commits) for (const n of c.names) if (!served.has(n)) served.set(n, c.how);
|
|
165
|
+
for (const n of branch.served) if (!served.has(n)) served.set(n, 'folder');
|
|
166
|
+
const none = commits.filter((c) => !c.names.length && c.how !== HISTORY).length;
|
|
167
|
+
const unknown = commits.filter((c) => !c.names.length && c.how === HISTORY).map((c) => c.sha.slice(0, 7));
|
|
168
|
+
const serves = [...[...served].map(([n, how]) => `${n} (${how})`), ...(none ? [`${none} commit${none === 1 ? '' : 's'} with no request`] : []),
|
|
169
|
+
...(unknown.length ? [`${unknown.join(', ')}: request ${HISTORY}`] : [])];
|
|
170
|
+
const changed = paths(git(top, ['diff', '--name-only', '-z', '--no-renames', base, head]));
|
|
171
|
+
const states = changeStates(top, { at: head });
|
|
172
|
+
// A section moved to another file with its text unchanged shows once, as a move ([REC-10]).
|
|
173
|
+
const moves = movesOf(branch.before, files);
|
|
174
|
+
const moved = (id) => moves.find((m) => m.id === id);
|
|
175
|
+
const changes = [...new Set(sectionsChanged(top, base, head, root, changed))].map((id) => {
|
|
176
|
+
const holders = states.filter((e) => e.id === id);
|
|
177
|
+
const m = moved(id);
|
|
178
|
+
return `[${id}] ${headings.get(id) ?? '(removed)'}${m ? ` · moved ${m.from} → ${m.to}, text unchanged` : ''} · ${holders.length ? holders.map(stateOf).join(' · ') : 'held by no open change'}`;
|
|
179
|
+
});
|
|
180
|
+
const ctx = { root, requests, seen, shallow, headings };
|
|
181
|
+
const code = changed.filter(isCode(root)).map((path) => ({ path, ...fileLinks(top, base, head, path, ctx) }));
|
|
182
|
+
const nearby = [...new Set(code.flatMap((f) => [...f.ids.keys()]))];
|
|
183
|
+
const skipped = code.reduce((n, f) => n + f.skipped, 0);
|
|
184
|
+
const cited = [...new Set([...sectionsChanged(top, base, head, root, changed), ...nearby])];
|
|
185
|
+
const related = requests.filter((r) => !served.has(r.name) && cited.some((id) => cites(r.md, id) || cites(r.change, id))).map((r) => {
|
|
186
|
+
const rejects = entriesOf(r.md, TITLES.decisions).find((e) => cited.some((id) => cites(e, id)) && /rejected/i.test(e));
|
|
187
|
+
const why = isDropped(r.md) ? 'dropped' : rejects ? `rejected: ${rejects.match(/^- (D\d+)/)?.[1] ?? 'a decision'}` : '';
|
|
188
|
+
return { text: `${r.name}${why ? ` (${why})` : ''}`, rejected: Boolean(why) };
|
|
189
|
+
}).sort((x, y) => y.rejected - x.rejected).map((r) => r.text);
|
|
190
|
+
|
|
191
|
+
const body = [line('Serves', serves.join(' · ') || 'no commits in the range')];
|
|
192
|
+
branch.readResults = at ? branch : { tree: openTree(top) };
|
|
193
|
+
const list = ranked(hintsOf(top, branch, { main: at ? null : mainCommit(top) }), branch);
|
|
194
|
+
const hints = hintLines(list, all ? list.length : 3);
|
|
195
|
+
const tests = [...testLines(top, branch, true), ...resultLines(top, branch)];
|
|
196
|
+
if (forReview) body.push(...review(top, { tree, files, served, code, commits, moves, branch }), ...tests, ...hints);
|
|
197
|
+
else {
|
|
198
|
+
body.push(...lines('Changes', changes.length ? changes : ['no baseline section']));
|
|
199
|
+
body.push(...lines('Links', code.flatMap((f) => f.out)));
|
|
200
|
+
body.push(line('Nearby', nearby.length ? nearby.map((id) => `[${id}] ${headings.get(id) ?? ''}`.trim()).join(' · ') : 'none'), ...tests);
|
|
201
|
+
body.push(line('Related', related.length ? related.join(' · ') : 'none'), ...hints);
|
|
202
|
+
}
|
|
203
|
+
if (skipped) body.push(skippedLine(skipped));
|
|
204
|
+
return {
|
|
205
|
+
tree: { label: `commits ${base.slice(0, 7)}..${head.slice(0, 7)}${at ? '' : '; test results from the working tree'}` },
|
|
206
|
+
body,
|
|
207
|
+
next: forReview ? 'al context <ID> for any section named here' : `al context --diff ${range} --for review`,
|
|
208
|
+
notKnown: ['uncommitted changes (the range reads commits only)', ...headNote(top, branch), ...(shallow ? [`${HISTORY}: commits before the shallow boundary`, ...cutOffNotes(top, branch.commits)] : []),
|
|
209
|
+
...(at ? ['the hints that compare with main (not read under --at)'] : [])],
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// --for review: the intent (each served request's signed text verbatim, its
|
|
214
|
+
// blocks and its decisions, agent rulings apart), then the evidence (each R's
|
|
215
|
+
// linked files, or what was searched; then the files linked to no served request).
|
|
216
|
+
function review(top, { tree, files, served, code, commits, moves, branch }) {
|
|
217
|
+
const { tier, changedIds, base, at, root, requests } = branch;
|
|
218
|
+
const mine = requestsIn(tree).filter((r) => served.has(r.name));
|
|
219
|
+
const all = allBlocks(tree);
|
|
220
|
+
const blocks = [...all.values()];
|
|
221
|
+
// A served request the branch archives still shows its blocks.
|
|
222
|
+
const states = statesOf(files, all, (b) => served.has(b.request));
|
|
223
|
+
// The sections each R names: its blocks' `for R<n>`, and its Amends: ([REC-4]).
|
|
224
|
+
const rIds = (r) => {
|
|
225
|
+
const org = organized(r.md);
|
|
226
|
+
const named = new Map((org ? outcomeFacts(org, []).rs : []).map((x) => [x.key, x.ids.map((i) => i.slice(1, -1))]));
|
|
227
|
+
return (key) => new Set([...blocks.filter((b) => b.request === r.name && b.forR.includes(key)).map((b) => b.id), ...(named.get(key) ?? [])]);
|
|
228
|
+
};
|
|
229
|
+
const intent = ['Intent', ...(tier ? [line('Tier', tier)] : []),
|
|
230
|
+
...lines('Baseline', moves.map((m) => `[${m.id}] moved ${m.from} → ${m.to}, text unchanged`))];
|
|
231
|
+
const evidence = ['Evidence'];
|
|
232
|
+
for (const r of mine) {
|
|
233
|
+
// [REC-5]: signed by its own sign-off, or through its parent's for the parts it copies.
|
|
234
|
+
const s = signoffState(tree, r.dir, r.name);
|
|
235
|
+
const asked = organized(r.md);
|
|
236
|
+
// [VW-4]: what the owner signed stays in view, verbatim with its file, while the draft is blocked.
|
|
237
|
+
const status = s.blocked ? `blocked (${s.reason}), not signed off as it stands` : 'signed off';
|
|
238
|
+
if (!asked) intent.push(`${r.name} not signed off (${s.reason ?? 'no organized requirement'})`);
|
|
239
|
+
else if (!s.parent) {
|
|
240
|
+
intent.push(`${r.name} ${status}${s.signoff ? `; signed text, origin/${s.signoff.file}:` : ''}`);
|
|
241
|
+
if (s.signoff) intent.push(...s.signoff.text.replace(/\n+$/, '').split('\n').map((l) => ` ${l}`));
|
|
242
|
+
} else {
|
|
243
|
+
intent.push(`${r.name} ${status}, part by part:`);
|
|
244
|
+
for (const p of parts(asked.text, asked.oneLine)) {
|
|
245
|
+
const own = s.signoff && parts(s.signoff.text, s.signoff.oneLine).some((q) => samePart(p, q));
|
|
246
|
+
const inherited = s.parentSignoff && parts(s.parentSignoff.text, s.parentSignoff.oneLine).some((q) => samePart(p, q));
|
|
247
|
+
intent.push(` ${p.key}: ${own ? `origin/${s.signoff.file}` : inherited ? `through ${s.parent}, origin/${s.parentSignoff.file}` : 'not signed'}`,
|
|
248
|
+
...p.text.replace(/\n+$/, '').split('\n').map((l) => ` ${l}`));
|
|
249
|
+
}
|
|
250
|
+
// Blocked with its own sign-off: what it signed, changed or removed parts included.
|
|
251
|
+
if (s.blocked && s.signoff) intent.push(` signed text, origin/${s.signoff.file}:`, ...s.signoff.text.replace(/\n+$/, '').split('\n').map((l) => ` ${l}`));
|
|
252
|
+
}
|
|
253
|
+
// The spec changes: the blocks whose sections this branch changes, grouped by state when they
|
|
254
|
+
// do not fit on one line, none left out ([VW-2]); with no change.md, the sections its own work changes.
|
|
255
|
+
const changing = states.filter((e) => e.request === r.name && changedIds.includes(e.id));
|
|
256
|
+
const twice = (e) => changing.filter((x) => x.id === e.id).length > 1;
|
|
257
|
+
const full = line('Blocks', changing.map(stateOf).join(' · '));
|
|
258
|
+
const own = r.change ? [] : [...ownIds(top, r.name, base, root, requests, false, at)].filter((id) => changedIds.includes(id));
|
|
259
|
+
intent.push(changing.length ? (full.length <= 100 ? full : grouped('Blocks', changing.map((e) => (e.state === 'waiting'
|
|
260
|
+
? { state: e.state, text: `${e.id}@${e.n} on ${e.by}`, alone: true } : { state: e.state, text: twice(e) ? `${e.id}@${e.n}` : e.id, alone: twice(e) }))))
|
|
261
|
+
: own.length ? line('Blocks', `no change.md; this branch changes ${own.map((id) => `[${id}]`).join(', ')} for it`)
|
|
262
|
+
: line('Blocks', 'none changed by this branch'));
|
|
263
|
+
const decided = decisionList(r.md);
|
|
264
|
+
intent.push(line('Decided', decided.filter((d) => !d.agent).map((d) => `${d.id} ${d.date}`).join(' · ') || 'none'));
|
|
265
|
+
if (decided.some((d) => d.agent)) intent.push(line('Agent', `${decided.filter((d) => d.agent).map((d) => `${d.id} ${d.date}`).join(' · ')} (agent rulings)`));
|
|
266
|
+
evidence.push(r.name);
|
|
267
|
+
const org = organized(r.md);
|
|
268
|
+
const idsOf = rIds(r);
|
|
269
|
+
for (const p of org ? parts(org.text, org.oneLine).filter((x) => /^R\d+$/.test(x.key)) : []) {
|
|
270
|
+
const ids = idsOf(p.key);
|
|
271
|
+
const found = code.flatMap((f) => [...f.ids].filter(([id]) => ids.has(id)).map(([, reason]) => reason));
|
|
272
|
+
evidence.push(`${p.text.split('\n')[0].replace(/^\s*#+\s+/, '')}`, ...(found.length ? found.map((f) => ` ${f}`) : [' none: no linked code or test file among the changed files']));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
// Linked: a file a served request's blocks or Amends: reach, blame names, or a range commit of it changed ([LNK-2]).
|
|
276
|
+
const named = new Set(mine.flatMap((r) => { const org = organized(r.md); return org ? amends(org.text).map((i) => i.slice(1, -1)) : []; }));
|
|
277
|
+
const held = (id) => named.has(id) || blocks.some((b) => b.id === id && served.has(b.request));
|
|
278
|
+
const theirs = new Set(commits.filter((c) => c.names.some((n) => served.has(n))).flatMap((c) => mayTouch(top, c.sha, base)));
|
|
279
|
+
const unlinked = code.filter((f) => !theirs.has(f.path) && ![...f.blamed].some((n) => served.has(n)) && ![...f.ids.keys()].some(held)).map((f) => f.path);
|
|
280
|
+
// A commit whose files are unknown may have linked any of them ([VW-9]).
|
|
281
|
+
const cut = commits.filter((c) => ownFiles(top, c.sha) === null).map((c) => c.sha.slice(0, 7));
|
|
282
|
+
evidence.push(line('Unlinked', cut.length && unlinked.length ? `${HISTORY}: the files ${cut.join(', ')} touched`
|
|
283
|
+
: unlinked.length ? `${unlinked.join(' · ')} (linked to no served request)` : 'none'));
|
|
284
|
+
return [...intent, ...evidence];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// The generated lines of request.md's `## Outcome` ([REC-9]): up to its
|
|
288
|
+
// `Notes:` line or the next heading, so nothing people add counts.
|
|
289
|
+
function outcomeLines(md) {
|
|
290
|
+
const lines = md.split('\n');
|
|
291
|
+
const at = headingAt(lines, TITLES.outcome);
|
|
292
|
+
if (at < 0) return [];
|
|
293
|
+
const end = lines.findIndex((l, i) => i > at && (/^Notes:/.test(l) || /^#{1,6}\s/.test(l)));
|
|
294
|
+
return lines.slice(at + 1, end < 0 ? lines.length : end);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// [VW-6]: each section an archived request held, "as at conclusion" or what
|
|
298
|
+
// changed it since, and the requests that follow it.
|
|
299
|
+
// Under --at, everything is read at that commit, and the conclusion counts
|
|
300
|
+
// only when that commit's history holds it.
|
|
301
|
+
export function archivedLines(top, name, at, all) {
|
|
302
|
+
const tree = openTree(top, at);
|
|
303
|
+
const { ids, say } = sinceConclusion(top, name, at);
|
|
304
|
+
// [VW-6]: grouped by what each says, none left out; one per line with --all.
|
|
305
|
+
const said = ids.map((id) => ({ text: id, state: say(id) }));
|
|
306
|
+
const out = !ids.length ? [] : all ? lines('Sections', said.map((x) => `${x.text} ${x.state}`)) : [grouped('Sections', said)];
|
|
307
|
+
const followers = requestsIn(tree).filter((r) => follows(r.md).includes(name)).map((r) => r.name);
|
|
308
|
+
if (followers.length) out.push(`Followed by ${followers.join(' · ')}`);
|
|
309
|
+
return out;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// The sections archived request `name` held, and what each says since its
|
|
313
|
+
// conclusion ([STA-8], [VW-6]): "as at conclusion", or what changed it since.
|
|
314
|
+
export function sinceConclusion(top, name, at) {
|
|
315
|
+
const tree = openTree(top, at);
|
|
316
|
+
const rev = at ? resolveCommit(top, at) : null;
|
|
317
|
+
const dir = `requests/archive/${name}`;
|
|
318
|
+
// A request with no change.md (tier 1): the sections its Outcome lists as
|
|
319
|
+
// added, modified or removed, which conclude derived from its own work ([REC-9]).
|
|
320
|
+
const ids = noChangeMd(tree, dir)
|
|
321
|
+
? [...new Set(outcomeLines(tree.read(`${dir}/request.md`)?.toString('utf8') ?? '')
|
|
322
|
+
.filter((l) => /^- (Added|Modified|Removed):/.test(l)).flatMap(idsOn))]
|
|
323
|
+
: [...new Set([...allBlocks(tree).values()].filter((b) => b.request === name).map((b) => b.id))];
|
|
324
|
+
const c = concluding(top, name, at);
|
|
325
|
+
const root = rootOf(top, tree, at);
|
|
326
|
+
const now = baseline(tree, root);
|
|
327
|
+
const nowById = sectionsById(now, (s, f) => ({ ...s, path: f.path }));
|
|
328
|
+
const then = c.sha ? byIdAt(top, c.sha, root) : null;
|
|
329
|
+
const requests = requestsIn(tree);
|
|
330
|
+
const seen = new Map();
|
|
331
|
+
const say = (id) => {
|
|
332
|
+
if (!c.sha) return c.gap ?? 'as at conclusion';
|
|
333
|
+
const [was, is] = [then.get(id), nowById.get(id)];
|
|
334
|
+
if (!was && !is) return 'as at conclusion';
|
|
335
|
+
if (was && is && sameSection(was, is.text)) return 'as at conclusion';
|
|
336
|
+
if (!is) return 'removed since conclusion';
|
|
337
|
+
const later = [...new Set(blame(top, rev, is.path, [[is.line, is.line + lineCount(is.text) - 1]], { code: false }).map((b) => b.sha))]
|
|
338
|
+
.filter((sha) => !/^0+$/.test(sha) && git(top, ['merge-base', '--is-ancestor', sha, c.sha], { allowFail: true }) === null)
|
|
339
|
+
.map((sha) => requestOf(top, sha, requests, seen)).sort((x, y) => y.when - x.when);
|
|
340
|
+
return later.length ? `since changed by ${later.map((x) => `${x.names.join(', ') || 'no request'} (${day(x.when)})`).join(', ')}` : 'since changed (not committed yet)';
|
|
341
|
+
};
|
|
342
|
+
return { ids, say };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// The baseline's sections by ID at a past commit: its root: line alone, or `root` ([VW-8]).
|
|
346
|
+
function byIdAt(top, sha, root) {
|
|
347
|
+
const tree = openTree(top, sha);
|
|
348
|
+
return sectionsById(baseline(tree, rootLine(top, tree, sha) ?? root));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// [REC-9]: the code still live from the commits that map to `name` by a
|
|
352
|
+
// Request: line or its folder, as `path:a-b`; only under the markers of
|
|
353
|
+
// `onlyIds` when given. `at` is the commit read; without it, the working
|
|
354
|
+
// tree, its records and its files. null in a shallow clone, where blame
|
|
355
|
+
// gives the boundary commit every line it cannot trace ([VW-9]).
|
|
356
|
+
export function liveCode(top, name, root, onlyIds, at) {
|
|
357
|
+
if (isShallow(top)) return null;
|
|
358
|
+
const tree = openTree(top, at);
|
|
359
|
+
const requests = requestsIn(tree);
|
|
360
|
+
// Every route [LNK-2] maps by, the issue numbers in its owner's words included.
|
|
361
|
+
const md = requests.find((r) => r.name === name)?.md ?? '';
|
|
362
|
+
const issues = [...new Set(entriesOf(md, TITLES.words).join('\n').match(/#\d+/g) ?? [])];
|
|
363
|
+
const mine = new Set(requestCommits(top, name, issues, at ?? 'HEAD', requests).map((c) => c.sha));
|
|
364
|
+
const found = new Set([...mine].flatMap((sha) => filesOf(top, sha)).filter(isCode(root)));
|
|
365
|
+
// Code moves: a later commit that changed one of these files may have taken
|
|
366
|
+
// its lines into its other files, so those are read too.
|
|
367
|
+
const history = git(top, ['rev-list', '--reverse', at ?? 'HEAD']).split('\n').filter(Boolean);
|
|
368
|
+
for (const sha of mine.size ? history.slice(history.findIndex((s) => mine.has(s)) + 1) : []) {
|
|
369
|
+
const files = filesOf(top, sha).filter(isCode(root));
|
|
370
|
+
if (files.some((f) => found.has(f))) files.forEach((f) => found.add(f));
|
|
371
|
+
}
|
|
372
|
+
// The working tree's uncommitted changes count as one more such change: a git mv takes the lines to its new path.
|
|
373
|
+
const working = at ? [] : paths(git(top, ['diff', '--name-only', '-z', '--no-renames', 'HEAD'], { allowFail: true, worktree: true })).filter(isCode(root));
|
|
374
|
+
if (working.some((f) => found.has(f))) working.forEach((f) => found.add(f));
|
|
375
|
+
const touched = [...found].sort();
|
|
376
|
+
const out = [];
|
|
377
|
+
for (const path of touched) {
|
|
378
|
+
const text = tree.read(path)?.toString('utf8') ?? null;
|
|
379
|
+
if (text === null) continue;
|
|
380
|
+
const fileLines = text.split('\n');
|
|
381
|
+
const under = (n) => {
|
|
382
|
+
for (let i = n; i >= 1; i--) if (idsOn(fileLines[i - 1]).length) return idsOn(fileLines[i - 1]);
|
|
383
|
+
return [];
|
|
384
|
+
};
|
|
385
|
+
const live = blame(top, at ?? null, path, [[1, lineCount(text)]], { code: true })
|
|
386
|
+
.filter((b) => mine.has(b.sha) && (!onlyIds || under(b.line).some((id) => onlyIds.includes(id)))).map((b) => b.line);
|
|
387
|
+
out.push(...ranges(live).map((r) => `${path}:${r}`));
|
|
388
|
+
}
|
|
389
|
+
return out;
|
|
390
|
+
}
|