@assuredloop/cli 0.1.0 → 0.2.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.
@@ -0,0 +1,480 @@
1
+ // al context (T11; design.md 5, 8, 9 and 13, schema.md 5): where the project,
2
+ // a request or a paragraph stands; the review view of a diff; the whole trace
3
+ // with --audit; any of them as of a v4 commit with --at. Views never write.
4
+ import { Fail } from './base.js';
5
+ import { applicability, checks } from './checks.js';
6
+ import { judge, judgeRequest } from './dispositions.js';
7
+ import { git, mergeBase, mergesOf } from './git.js';
8
+ import { crossRepo, findCites } from './repos.js';
9
+ import { diffParagraphs } from './ids.js';
10
+ import { loadResults } from './results.js';
11
+ import { latestVersion, loadState, named, qualify, requestOf, resolve, signed } from './state.js';
12
+
13
+ export const line = (label, text) => `${label.length < 10 ? label.padEnd(10) : `${label} `}${text}`;
14
+ const list = (x) => (Array.isArray(x) ? x : []);
15
+ const short = (h) => (h ? String(h).slice(0, 12) : 'none');
16
+ const indent = (text) => text.split('\n').map((l) => ` ${l}`);
17
+
18
+ export const NOT_CHECKED = 'whether each kind is right; whether a requirement is fully covered; whether a test really checks the rule it names; '
19
+ + 'whether a result\'s declared inputs are complete; whether a change is authorized in substance (the review judges these)';
20
+
21
+ // The sign-off state of one requirement or question: "signed in S1",
22
+ // "changed since S1" or "not signed".
23
+ export function signState(state, r, q) {
24
+ if (signed(state, r.name, q.id)) {
25
+ const s = list(r.data?.signoff).filter((x) => list(x?.covers).some((c) => c?.id === q.id && c.sha256 === q.sha256)).at(-1);
26
+ return `signed in ${s.id}`;
27
+ }
28
+ const before = list(r.data?.signoff).filter((x) => list(x?.covers).some((c) => c?.id === q.id)).at(-1);
29
+ return before ? `changed since ${before.id}` : 'not signed';
30
+ }
31
+
32
+ // The version of a requirement's current text, from the record ("new" when not indexed yet).
33
+ const versionOf = (r, q) => {
34
+ const v = list(r.data?.requirements).filter((x) => x?.id === q.id && x.sha256 === q.sha256).at(-1);
35
+ return v ? `version ${v.version}` : `version ${Number(latestVersion(r.data, q.id)?.version ?? 0) + 1} (not indexed)`;
36
+ };
37
+
38
+ // A request's sign-off state in one phrase.
39
+ function signSummary(state, r) {
40
+ if (!r.reqs.length) return 'no requirement';
41
+ const states = r.reqs.map((q) => [q.id, signState(state, r, q)]);
42
+ const not = states.filter(([, s]) => !s.startsWith('signed'));
43
+ if (!not.length) return 'signed';
44
+ if (not.length === states.length) return not.every(([, s]) => s === 'not signed') ? 'not signed' : `not signed (${not.map(([id, s]) => `${id} ${s}`).join(', ')})`;
45
+ return `partly signed (${not.map(([id, s]) => `${id} ${s}`).join(', ')})`;
46
+ }
47
+
48
+ // "<N> paragraphs, <M> with a baseline effect: <a> incorporated, ... [, <k> not valid]".
49
+ export function dispositionLine(r, judged) {
50
+ const counts = ['incorporated', 'pending', 'removed', 'abandoned', 'superseded']
51
+ .map((s) => [s, judged.filter((j) => j.status === s).length]).filter(([, n]) => n);
52
+ const invalid = judged.filter((j) => j.status !== 'pending' && !j.valid).length;
53
+ return `${r.paras.size} paragraphs, ${judged.length} with a baseline effect: ${counts.map(([s, n]) => `${n} ${s}`).join(', ') || 'none'}${invalid ? `, ${invalid} not valid` : ''}`;
54
+ }
55
+
56
+ const judgedLine = (j) => ` ${j.p.id} ${j.p.kind} ${j.status}${j.d?.spec ? ` ${j.d.spec}` : ''}${j.d?.by ? ` by ${j.d.by}` : ''}`
57
+ + `${j.status === 'pending' ? '' : j.valid ? ` · valid${j.reason ? ` (${j.reason})` : ''}` : ` · not valid: ${j.reason}`}`;
58
+
59
+ // The files that name each spec ID (cites, exact): every file outside the
60
+ // spec root, requests/ and .assuredloop/, in the working tree or at `rev`,
61
+ // found by the one cite finder (findCites, D17).
62
+ function citesOf(state) {
63
+ const cites = new Map();
64
+ for (const { file, ids } of findCites(state.top, state.rev ?? null, { qualified: false, exclude: [state.root, 'requests', '.assuredloop', 'node_modules'] })) {
65
+ for (const written of ids) {
66
+ const id = written.replace(/^central:/, '');
67
+ if (!cites.has(id)) cites.set(id, new Set());
68
+ cites.get(id).add(file);
69
+ }
70
+ }
71
+ return cites;
72
+ }
73
+
74
+ // The declared `verifies` outputs of every request record, by target ID: claims.
75
+ function verifiesOf(state) {
76
+ const out = new Map();
77
+ for (const r of state.requests.values()) {
78
+ for (const o of list(r.data?.outputs)) {
79
+ for (const t of [].concat(o?.verifies ?? [])) {
80
+ const id = String(t).replace(/^central:/, '');
81
+ if (!out.has(id)) out.set(id, []);
82
+ out.get(id).push(o.repo && o.repo !== state.config.repo ? `${o.repo}/${o.file}` : o.file);
83
+ }
84
+ }
85
+ }
86
+ return out;
87
+ }
88
+
89
+ // A test file, by its path: under a test/, tests/, spec/ or __tests__/
90
+ // folder, or named *.test.*, *.spec.* or *_test.*.
91
+ const isTest = (file) => /(^|\/)(test|tests|spec|__tests__)\//i.test(file) || /(\.(test|spec)\.[^/]+|_test\.[^/]+)$/i.test(file);
92
+
93
+ // The files that result files name as their check (the working tree only).
94
+ const resultChecks = (state) => new Set(state.rev ? [] : loadResults(state.top).map((r) => String(r.check ?? '')));
95
+
96
+ // A rule's checks (design.md 9): a test file or a result's check that names
97
+ // it (cites, exact), or a declared verifies output (a claim). Any other file
98
+ // that names it, such as code, is listed apart and is no check.
99
+ function checksOf(id, cites, verifies, named, cross = null) {
100
+ const files = [...(cites.get(id) ?? [])].sort();
101
+ const outside = crossCites(cross, id);
102
+ // A file that an output repo's result names as its check is a check there too.
103
+ const outNamed = new Set((cross?.results ?? []).map((res) => `${res.repo}/${res.check}`));
104
+ const isCheck = (c) => isTest(c.file) || outNamed.has(`${c.repo}/${c.file}`);
105
+ return {
106
+ checks: [
107
+ ...files.filter((f) => isTest(f) || named.has(f)).map((file) => ({ file, how: 'cites it' })),
108
+ ...outside.filter(isCheck).map((c) => ({ file: `${c.repo}/${c.file}`, how: `cites it (at ${c.repo}@${c.commit.slice(0, 7)})`, repo: c.repo, path: c.file })),
109
+ ...(verifies.get(id) ?? []).map((file) => ({ file, how: 'verifies it (a claim)' })),
110
+ ],
111
+ other: [...files.filter((f) => !isTest(f) && !named.has(f)), ...outside.filter((c) => !isCheck(c)).map((c) => `${c.repo}/${c.file}`)],
112
+ };
113
+ }
114
+
115
+ // The files of output repos that cite `central:<id>` (T12, exact), from crossRepo.
116
+ const crossCites = (cross, id) => (cross?.links ?? []).filter((l) => l.link === 'cites' && l.target === `central:${id}` && l.commit)
117
+ .map((l) => ({ repo: l.repo, file: l.file, commit: l.commit }));
118
+
119
+ // The cross-repo facts of the working tree, or null at --at (crossRepo reads the working tree's config).
120
+ // One crossRepo for each state, shared by every view of it.
121
+ const crossOfState = new WeakMap();
122
+ const crossOf = (state) => {
123
+ if (state.rev) return null;
124
+ if (!crossOfState.has(state)) crossOfState.set(state, crossRepo(state.top));
125
+ return crossOfState.get(state);
126
+ };
127
+ // The merges of the first-parent history at `rev`, read once for each state (mergesOf).
128
+ const mergesOfState = new WeakMap();
129
+ const mergesAt = (state, rev) => {
130
+ if (!mergesOfState.has(state)) mergesOfState.set(state, new Map());
131
+ const byRev = mergesOfState.get(state);
132
+ if (!byRev.has(rev)) byRev.set(rev, mergesOf(state.top, rev));
133
+ return byRev.get(rev);
134
+ };
135
+ const showChecks = ({ checks, other }) => `${checks.length ? checks.map((c) => `${c.file} ${c.how}`).join(' · ') : 'none'}`
136
+ + `${other.length ? ` · also named in ${other.join(', ')} (not a test: no check)` : ''}`;
137
+
138
+ // The section of a spec paragraph from its display number: "1 Export links".
139
+ const sectionOf = (p) => {
140
+ const m = /\((\d+(?:\.\d+)*):\d+, in (.*)\)$/.exec(p.displayNumber ?? '');
141
+ return m ? `${m[1]} ${m[2]}` : '-';
142
+ };
143
+
144
+ // Coverage lines per section of the spec rules in `rules`, each rule with its checks.
145
+ function coverage(state, rules, perRule) {
146
+ const cites = citesOf(state);
147
+ const verifies = verifiesOf(state);
148
+ const named = resultChecks(state);
149
+ const cross = crossOf(state);
150
+ const sections = new Map();
151
+ for (const p of rules) {
152
+ const key = `${p.file} ${sectionOf(p)}`;
153
+ if (!sections.has(key)) sections.set(key, []);
154
+ sections.get(key).push(p);
155
+ }
156
+ const out = [];
157
+ for (const [key, ps] of sections) {
158
+ const checked = ps.filter((p) => checksOf(p.id, cites, verifies, named, cross).checks.length);
159
+ out.push(line('Coverage', `${key}: ${ps.length} rules, ${checked.length} with a check, ${ps.length - checked.length} without (${Math.round((100 * checked.length) / ps.length)}% have a check)`));
160
+ if (perRule) {
161
+ for (const p of ps) {
162
+ out.push(` ${p.id} rule: ${showChecks(checksOf(p.id, cites, verifies, named, cross))}`);
163
+ }
164
+ }
165
+ }
166
+ if (!out.length) out.push(line('Coverage', 'no rule'));
167
+ return out;
168
+ }
169
+
170
+ // The PRs that name the request, from the commit messages of main (or of the
171
+ // selected commit): "#46 (abc1234)". Exact facts from git.
172
+ // The main branch of this clone: main, else origin/main, else null.
173
+ const mainRef = (top) => ['main', 'origin/main'].find((m) => git(top, ['rev-parse', '--verify', '--quiet', `${m}^{commit}`], { allowFail: true })) ?? null;
174
+
175
+ function prsFromGit(state, name) {
176
+ const rev = state.rev ?? mainRef(state.top) ?? 'HEAD';
177
+ const out = git(state.top, ['log', '--format=%H%x09%B%x00', rev], { allowFail: true }) ?? '';
178
+ const prs = new Map();
179
+ const word = new RegExp(`(?<![\\w-])${name.replace(/[-]/g, '\\-')}(?![\\w-])`);
180
+ for (const entry of out.split('\0')) {
181
+ const [sha, ...rest] = entry.replace(/^\n+/, '').split('\t');
182
+ const msg = rest.join('\t');
183
+ if (!sha || !word.test(msg)) continue;
184
+ for (const m of msg.matchAll(/(?<![\w-])((?:[a-z0-9-]+)?#\d+)\b/g)) if (!prs.has(m[1])) prs.set(m[1], sha.slice(0, 7));
185
+ }
186
+ return [...prs].map(([pr, sha]) => `${pr} (${sha})`);
187
+ }
188
+
189
+ // A git fact for each PR of a task (design.md 8; T12's mergeOf): this repo's
190
+ // `#n` in the first-parent history of main (or of the selected commit); an
191
+ // output repo's from crossRepo. Never "not merged": a merge not found is that.
192
+ function taskPrs(state, r) {
193
+ const main = state.rev ? null : mainRef(state.top);
194
+ const rev = state.rev ?? main ?? 'HEAD';
195
+ // With no main branch, a merge read from HEAD may be the branch's own commit: say so.
196
+ const limit = state.rev || main ? '' : ' (read from HEAD: no main branch, so a commit of this branch may show as merged)';
197
+ const where = state.rev ? state.rev.slice(0, 7) : main ?? 'HEAD';
198
+ const self = new Set(['central', ...(typeof state.config.repo === 'string' ? [state.config.repo] : [])]);
199
+ const cross = crossOf(state);
200
+ const repos = new Map((cross?.repos ?? []).map((x) => [x.name, x]));
201
+ return (task, pr) => {
202
+ const m = /^(?:([A-Za-z0-9][A-Za-z0-9._-]*))?#(\d+)$/.exec(pr);
203
+ if (!m) return '(not a PR reference)';
204
+ if (!m[1] || self.has(m[1])) {
205
+ const merge = mergesAt(state, rev)(m[2]);
206
+ return merge ? `merged at ${merge.slice(0, 7)}${limit}` : `no merge found in ${where}${limit}`;
207
+ }
208
+ if (state.rev) return 'unknown: output repos are read at the working tree only';
209
+ const l = (cross?.links ?? []).find((x) => x.link === 'pr' && x.holder === `${r.name}/${task}` && x.target === pr);
210
+ if (l?.merged) return l.merged;
211
+ if (!repos.has(m[1]) || repos.get(m[1]).unknown) return `unknown: ${l?.unknown ?? repos.get(m[1])?.unknown ?? 'not an output repo in config'}`;
212
+ return l?.unknown ?? 'no merge found';
213
+ };
214
+ }
215
+
216
+ // The findings of al check that concern request `name`.
217
+ const hintsFor = (findings, name) => findings.filter((f) => f.severity !== 'info'
218
+ && (f.id === name || f.id.startsWith(`${name}/`) || f.message.includes(`${name}/`)));
219
+ const findingLine = (f) => `${f.severity} ${f.code} ${f.where} ${f.id} ${f.message}`;
220
+
221
+ function hintsNow(state) {
222
+ if (state.rev) return null;
223
+ return checks(state.top, { base: mergeBase(state.top) }).findings;
224
+ }
225
+
226
+ // --- the views
227
+
228
+ function projectView(state) {
229
+ const body = [];
230
+ const open = [...state.requests.values()].filter((r) => r.open);
231
+ for (const r of open) {
232
+ const tier = r.tier ? `tier ${r.tier}` : 'no tier';
233
+ const d = r.spike ? 'a spike: no dispositions' : dispositionLine(r, judgeRequest(state, r));
234
+ body.push(line('Request', `${r.name} · ${tier} · ${signSummary(state, r)} · ${d}`));
235
+ }
236
+ if (!open.length) body.push(line('Request', 'no open request'));
237
+ body.push(...coverage(state, [...state.spec.values()].filter((p) => p.kind === 'rule'), false));
238
+ const findings = hintsNow(state);
239
+ if (findings) {
240
+ const n = (s) => findings.filter((f) => f.severity === s).length;
241
+ body.push(line('Hints', `${n('hint')} hints, ${n('not ok')} not ok (al check lists them)`));
242
+ }
243
+ body.push(line('Not checked', NOT_CHECKED));
244
+ return { body, next: open.length ? `al context <name>, for example al context ${open[0].name}` : 'al new <name> --from <file|->', findings };
245
+ }
246
+
247
+ function requestView(state, r, audit) {
248
+ const body = [];
249
+ body.push(line('Request', `${r.name} · ${r.tier ? `tier ${r.tier}` : 'no tier'} · ${r.open ? 'open' : 'archived'}${r.spike ? ' · a spike' : ''} · ${r.dir}`));
250
+ for (const q of r.reqs) body.push(` ${q.id} ${versionOf(r, q)} (${q.title}): ${signState(state, r, q)}`);
251
+ if (!r.reqs.length) body.push(' no organized requirement yet');
252
+ for (const d of list(r.data?.decisions)) body.push(line('Decision', `${d.id} (${d.source ?? 'no source'}): ${d.text ?? d.summary ?? ''}`));
253
+ const prFacts = taskPrs(state, r);
254
+ for (const t of list(r.data?.tasks)) {
255
+ const prs = [].concat(t.prs ?? []).map((pr) => `${pr} ${prFacts(t.id, String(pr))}`);
256
+ body.push(line('Task', `${t.id} ${t.ref ?? ''}${t.delivers ? ` · delivers ${[].concat(t.delivers).join(', ')}` : ''}${prs.length ? ` · PRs: ${prs.join(', ')}` : ''}`));
257
+ }
258
+ const prs = prsFromGit(state, r.name);
259
+ body.push(`PRs from git: ${prs.length ? prs.join(' · ') : 'none found'}`);
260
+ const judged = judgeRequest(state, r);
261
+ if (r.spike) body.push(line('Dispositions', 'a spike: its change spec stays as history and needs no disposition'));
262
+ else {
263
+ body.push(line('Dispositions', dispositionLine(r, judged)));
264
+ body.push(...judged.map(judgedLine));
265
+ }
266
+ const rules = [...state.spec.values()].filter((p) => p.kind === 'rule'
267
+ && p.links.serves.some((s) => qualify(s, null).req === r.name));
268
+ body.push(...coverage(state, rules, true));
269
+ if (audit) body.push(...auditLines(state, r));
270
+ const findings = hintsNow(state);
271
+ if (findings) {
272
+ const mine = hintsFor(findings, r.name);
273
+ body.push(line('Hints', mine.length ? `${mine.length}` : 'none'));
274
+ body.push(...mine.map((f) => ` ${findingLine(f)}`));
275
+ }
276
+ body.push(line('Not checked', NOT_CHECKED));
277
+ const pending = judged.filter((j) => j.status === 'pending' || !j.valid);
278
+ return {
279
+ body,
280
+ next: !r.open ? `al context ${r.name} --audit` : pending.length ? `give ${pending.map((j) => j.p.id).join(', ')} a valid disposition in ${`.assuredloop/records/requests/${r.name}.yaml`}, then al index`
281
+ : `al conclude ${r.name}`,
282
+ };
283
+ }
284
+
285
+ // --audit: the whole trace of request `r`.
286
+ function auditLines(state, r) {
287
+ const out = [];
288
+ const data = r.data ?? {};
289
+ for (const s of list(data.sources)) out.push(line('Source', `${s.id ? `${s.id} ` : ''}${s.file} sha256 ${short(s.sha256)}${s.taken ? ` taken ${s.taken}` : ''}${s.url ? ` from ${s.url}` : ''}`));
290
+ for (const v of list(data.requirements)) out.push(line('Version', `${v.id} version ${v.version} sha256 ${short(v.sha256)}${v.title ? ` ${v.title}` : ''}`));
291
+ for (const s of list(data.signoff)) {
292
+ out.push(line('Sign-off', `${s.id} ${s.file ?? ''} sha256 ${short(s.sha256)} covers ${list(s.covers).map((c) => `${c.id} version ${c.version} (${short(c.sha256)})`).join(', ')}`
293
+ + `${s.presented ? ` · presented: ${s.presented}` : ''}${s.transcribed_by ? ` · transcribed by ${s.transcribed_by}` : ''}`));
294
+ }
295
+ for (const o of list(data.outputs)) {
296
+ for (const link of ['implements', 'verifies', 'documents']) {
297
+ const ts = [].concat(o?.[link] ?? []);
298
+ if (ts.length) out.push(line('Output', `${o.repo ? `${o.repo}: ` : ''}${o.file} ${link} ${ts.join(', ')} (a claim, never shown as proven)`));
299
+ }
300
+ }
301
+ for (const b of list(data.bindings)) {
302
+ let st;
303
+ const t = qualify(String(b?.target ?? ''), null);
304
+ if (t.cross) st = 'cross-repo, not read here';
305
+ else {
306
+ const cur = resolve(state, t);
307
+ st = !cur ? 'target not found' : !b.target_sha256 || cur.sha === b.target_sha256 ? 'bound' : 'stale';
308
+ }
309
+ out.push(line('Binding', `${b?.holder} ${b?.link} ${b?.target}${b?.target_version ? ` version ${b.target_version}` : ''} target ${short(b?.target_sha256)} holder ${short(b?.holder_sha256)}: ${st}`));
310
+ }
311
+ for (const d of list(data.dispositions)) {
312
+ const sid = qualify(String(d?.source ?? ''), r.name);
313
+ const p = r.paras.get(sid.id);
314
+ let st;
315
+ if (d?.source === 'adoption') st = 'adoption';
316
+ else if (!r.open) st = 'as recorded at the close; not checked again';
317
+ else if (!p) st = 'source not found';
318
+ else if (d.source_sha256 && d.source_sha256 !== p.sha256) st = 'history (an earlier version)';
319
+ else { const v = judge(state, r, p, d); st = v.valid ? 'valid' : `not valid: ${v.reason}`; }
320
+ out.push(line('Disp.', `${d?.source} ${short(d?.source_sha256)} ${d?.disposition}${d?.spec ? ` ${d.spec} ${short(d.spec_sha256)}` : ''}${d?.by ? ` by ${d.by}` : ''}${d?.decision ? ` decision ${d.decision}` : ''}: ${st}`));
321
+ }
322
+ if (!state.rev) for (const res of loadResults(state.top)) out.push(line('Result', `${res.file} ${res.check ?? ''} ${res.outcome ?? ''}: ${res.problems.length ? `not valid: ${res.problems.join('; ')}` : applicability(state.top, res)}`));
323
+ return out;
324
+ }
325
+
326
+ // The Checks line of a paragraph and the Result lines of its checks: this
327
+ // repo's (a spec ID only, `local`) and the output repos'.
328
+ function checkLines(state, id, local) {
329
+ const cross = crossOf(state);
330
+ const found = checksOf(id, local ? citesOf(state) : new Map(), verifiesOf(state), local ? resultChecks(state) : new Set(), cross);
331
+ const out = [line('Checks', showChecks(found))];
332
+ if (state.rev) return out;
333
+ for (const res of loadResults(state.top)) {
334
+ if (!found.checks.some((c) => !c.repo && c.file === res.check)) continue;
335
+ out.push(line('Result', `${res.file} ${res.check} ${res.outcome}: ${res.problems.length ? `not valid: ${res.problems.join('; ')}` : applicability(state.top, res)}`));
336
+ }
337
+ for (const res of cross?.results ?? []) {
338
+ if (!found.checks.some((c) => c.repo === res.repo && c.path === res.check)) continue;
339
+ out.push(line('Result', `${res.repo}/${res.file} ${res.check} ${res.outcome} at ${res.repo}@${String(res.resolved ?? res.commit ?? 'unknown').slice(0, 7)}: ${res.applies}`));
340
+ }
341
+ return out;
342
+ }
343
+
344
+ function idView(state, id) {
345
+ const t = qualify(id, null);
346
+ const r = t.req ? state.requests.get(t.req) : null;
347
+ const p = t.req ? r?.paras.get(t.id) : state.spec.get(t.id);
348
+ if (!p) return null;
349
+ const who = named(t.req, p.id);
350
+ const body = [line('Paragraph', `${p.displayNumber} · ${p.kind ?? 'no kind'} · ${p.file}:${p.line}`), ...indent(p.text)];
351
+ for (const s of p.links.serves) {
352
+ const q = qualify(s, t.req);
353
+ const rq = q.req && state.requests.get(q.req);
354
+ const req = rq?.reqs.find((x) => x.id === q.id);
355
+ body.push(line('Serves', req ? `${q.key} ${versionOf(rq, req)}: ${signState(state, rq, req)} (declared)` : `${q.key}: not found`));
356
+ }
357
+ for (const [word, refs] of Object.entries(p.links)) {
358
+ if (word === 'serves' || !refs.length) continue;
359
+ body.push(line('Links', `${word} ${refs.join(', ')}`));
360
+ }
361
+ if (!t.req) {
362
+ const govern = [...state.adrs.values()].filter((a) => (a.head?.links.decides ?? []).includes(p.id) || p.links.governedBy.includes(a.id));
363
+ body.push(line('Governed', govern.length ? `by ${govern.map((a) => `${a.id} (${a.status ?? 'no status'}${a.status === 'superseded' ? ': history' : ''})`).join(' · ')}` : 'by no ADR'));
364
+ const changes = [];
365
+ for (const o of [...state.requests.values()].filter((x) => x.open)) {
366
+ for (const q of o.paras.values()) {
367
+ for (const [key, verb] of [['changes', 'changes it'], ['buildsOn', 'builds on it'], ['removes', 'removes it']]) {
368
+ if (q.links[key].map((x) => x.replace(/^central:/, '')).includes(p.id)) changes.push(`${o.name}/${q.id} ${verb}`);
369
+ }
370
+ }
371
+ }
372
+ body.push(line('Open', changes.length ? changes.join(' · ') : 'no open change builds on or changes it'));
373
+ body.push(...checkLines(state, p.id, true));
374
+ for (const o of state.requests.values()) {
375
+ for (const d of list(o.data?.dispositions)) {
376
+ if (d?.spec !== p.id) continue;
377
+ body.push(line('Disp.', `${d.source} ${d.disposition}${d.by ? ` by ${d.by}` : ''}${o.open ? '' : ' (archived)'}`));
378
+ }
379
+ }
380
+ } else {
381
+ body.push(...checkLines(state, who, false));
382
+ const d = judgeRequest(state, r).find((j) => j.p.id === p.id);
383
+ if (d) body.push(line('Disp.', judgedLine(d).trim()));
384
+ }
385
+ const findings = hintsNow(state);
386
+ if (findings) for (const f of findings.filter((x) => x.id === who && x.severity !== 'info')) body.push(` ${findingLine(f)}`);
387
+ return { body, next: t.req ? `al context ${t.req}` : 'al check' };
388
+ }
389
+
390
+ // The review view of a diff (design.md 13): each changed paragraph's text
391
+ // beside its kind, its requirement and the baseline paragraphs it touches.
392
+ function diffView(top, range, review) {
393
+ const m = /^(.*?)(\.\.\.?)(.*)$/.exec(range);
394
+ if (!m) throw new Fail(`--diff ${range}: give a range such as main...HEAD or A..B`, 'al context --diff main...HEAD --for review');
395
+ const head = m[3] || 'HEAD';
396
+ const from = m[1] || 'HEAD';
397
+ const base = m[2] === '...' ? git(top, ['merge-base', from, head], { allowFail: true }) : git(top, ['rev-parse', '--verify', '--quiet', `${from}^{commit}`], { allowFail: true });
398
+ const headSha = git(top, ['rev-parse', '--verify', '--quiet', `${head}^{commit}`], { allowFail: true });
399
+ if (!base || !headSha) throw new Fail(`--diff ${range}: not a range of commits in this clone`, 'al context --diff main...HEAD --for review');
400
+ const now = loadState(top, headSha);
401
+ const was = loadState(top, base);
402
+ const findings = review ? checks(top, { base }).findings : [];
403
+ const body = [];
404
+ for (const scope of new Set([...now.scopes.keys(), ...was.scopes.keys()])) {
405
+ const request = requestOf(scope);
406
+ const h = new Map((now.scopes.get(scope) ?? []).map((p) => [p.id, p]));
407
+ const b = new Map((was.scopes.get(scope) ?? []).map((p) => [p.id, p]));
408
+ for (const c of diffParagraphs(was.scopes.get(scope) ?? [], now.scopes.get(scope) ?? [])) {
409
+ const p = h.get(c.id) ?? b.get(c.id);
410
+ const kindChange = h.get(c.id) && b.get(c.id) && h.get(c.id).kind !== b.get(c.id).kind;
411
+ if (!c.changes.length && !kindChange) continue;
412
+ const who = named(request, c.id);
413
+ const change = [...c.changes, ...(kindChange ? [`kind ${b.get(c.id).kind} -> ${h.get(c.id).kind}`] : [])].join('+');
414
+ body.push(`${who} ${p.kind ?? 'no kind'} ${change} · ${p.file}:${p.line}`);
415
+ if (!review) continue;
416
+ body.push(...indent(p.text).map((l) => ` ${l}`));
417
+ for (const s of p.links.serves) {
418
+ const q = qualify(s, request);
419
+ const rq = q.req && now.requests.get(q.req);
420
+ const req = rq?.reqs.find((x) => x.id === q.id);
421
+ if (!req) { body.push(` serves ${q.key}: not found`); continue; }
422
+ body.push(` serves ${q.key} ${versionOf(rq, req)}: ${signState(now, rq, req)}`);
423
+ body.push(...indent(req.text.replace(/\n$/, '')).map((l) => ` ${l}`));
424
+ }
425
+ for (const [key, word] of [['buildsOn', 'builds on'], ['changes', 'changes'], ['removes', 'removes']]) {
426
+ for (const ref of p.links[key]) {
427
+ const id = ref.replace(/^central:/, '');
428
+ const target = was.spec.get(id) ?? now.spec.get(id);
429
+ body.push(` ${word} ${id}${target ? ` (${target.kind ?? 'no kind'}):` : ': not found'}`);
430
+ if (target) body.push(...indent(target.text).map((l) => ` ${l}`));
431
+ }
432
+ }
433
+ if (!request) {
434
+ for (const a of now.adrs.values()) {
435
+ if ((a.head?.links.decides ?? []).includes(c.id) || p.links.governedBy.includes(a.id)) body.push(` governed by ${a.id} (${a.status ?? 'no status'})`);
436
+ }
437
+ }
438
+ for (const f of findings.filter((x) => x.id === who && x.severity !== 'info')) body.push(` ${findingLine(f)}`);
439
+ }
440
+ }
441
+ if (!body.length) body.push('no paragraph changed in the range');
442
+ return {
443
+ body,
444
+ read: `${range} (base ${base.slice(0, 7)}, head ${headSha.slice(0, 7)})${review ? '; hints from the working tree' : ''}`,
445
+ next: review ? 'review each paragraph against its requirement and the baseline it touches' : `al context --diff ${range} --for review`,
446
+ };
447
+ }
448
+
449
+ export function context({ top, args, opts }) {
450
+ const [what] = args;
451
+ if (opts.for !== undefined && opts.for !== 'review') throw new Fail(`--for ${opts.for}: only --for review`, 'al context --diff main...HEAD --for review');
452
+ if (opts.diff !== undefined) {
453
+ const out = diffView(top, opts.diff, opts.for === 'review');
454
+ return { ...out, notKnown: ['whether each kind is right (the review judges that)', NOT_CHECKED] };
455
+ }
456
+ let rev = null;
457
+ if (opts.at !== undefined) {
458
+ rev = git(top, ['rev-parse', '--verify', '--quiet', `${opts.at}^{commit}`], { allowFail: true });
459
+ if (!rev) throw new Fail(`--at ${opts.at}: no such commit in this clone`, 'al context --at <commit>');
460
+ }
461
+ const state = loadState(top, rev);
462
+ const read = rev ? `commit ${rev.slice(0, 7)} (--at ${opts.at})` : 'working tree';
463
+ const notKnown = [
464
+ ...(rev ? ['the hints and the results at that commit (al check reads the working tree)'] : []),
465
+ 'the PRs that never named the request in a commit message',
466
+ ];
467
+ let out;
468
+ if (what === undefined) {
469
+ if (opts.audit) throw new Fail('--audit needs a request: al context <name> --audit', 'al context <name> --audit');
470
+ out = projectView(state);
471
+ } else if (/^[A-Z][A-Z0-9]*-\d+$/.test(what) || what.includes('/')) {
472
+ out = idView(state, what);
473
+ if (!out) throw new Fail(`${what}: no such paragraph ID${rev ? ` at ${rev.slice(0, 7)}` : ''}`, 'al spec lists the IDs');
474
+ } else {
475
+ const r = state.requests.get(what);
476
+ if (!r) throw new Fail(`${what}: no such request${rev ? ` at ${rev.slice(0, 7)}` : ''}`, 'al context lists the open requests');
477
+ out = requestView(state, r, opts.audit);
478
+ }
479
+ return { body: out.body, read, next: out.next, notKnown };
480
+ }
@@ -0,0 +1,98 @@
1
+ // Word-level reads of paragraph text for the checks (design.md 3 and 6): the
2
+ // changed words of an edit, the meaning-sensitive marks on them, the
3
+ // requirement words, and how close two texts are.
4
+
5
+ const WORD = /[\p{L}\p{N}][\p{L}\p{N}'’_-]*/gu;
6
+ export const words = (text) => text.match(WORD) ?? [];
7
+
8
+ // The changes from `a` to `b` as hunks { old: [words], new: [words] }, from the
9
+ // longest common subsequence of their words.
10
+ export function diffWords(a, b) {
11
+ const x = words(a);
12
+ const y = words(b);
13
+ const n = x.length;
14
+ const m = y.length;
15
+ const len = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
16
+ for (let i = n - 1; i >= 0; i--) {
17
+ for (let j = m - 1; j >= 0; j--) len[i][j] = x[i] === y[j] ? len[i + 1][j + 1] + 1 : Math.max(len[i + 1][j], len[i][j + 1]);
18
+ }
19
+ const hunks = [];
20
+ let cur = null;
21
+ const flush = () => { if (cur) hunks.push(cur); cur = null; };
22
+ let i = 0;
23
+ let j = 0;
24
+ while (i < n || j < m) {
25
+ if (i < n && j < m && x[i] === y[j]) { flush(); i++; j++; continue; }
26
+ cur ??= { old: [], new: [], at: i };
27
+ if (j >= m || (i < n && len[i + 1][j] >= len[i][j + 1])) cur.old.push(x[i++]);
28
+ else cur.new.push(y[j++]);
29
+ }
30
+ flush();
31
+ return hunks.map((h) => ({ ...h, before: x.slice(Math.max(0, h.at - 1), h.at) }));
32
+ }
33
+
34
+ export const showHunk = (h) => `${h.old.join(' ') || '(nothing)'} -> ${h.new.join(' ') || '(nothing)'}`;
35
+
36
+ const NORMATIVE = new Set(['must', 'shall', 'should', 'may', 'required']);
37
+ const NEGATION = new Set(['not', 'never', 'no']);
38
+ const NUMBER_WORDS = new Set([
39
+ 'zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve',
40
+ 'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen', 'twenty', 'half', 'once', 'twice',
41
+ 'first', 'second', 'third', 'fourth', 'fifth', 'sixth', 'seventh', 'eighth', 'ninth', 'tenth',
42
+ ]);
43
+ const QUANTIFIER = new Set(['only', 'all', 'every', 'any', 'each', 'none', 'some']);
44
+
45
+ // What makes a changed word meaning-sensitive (design.md 6), or null.
46
+ function sensitive(word, before) {
47
+ const w = word.toLowerCase();
48
+ if (NORMATIVE.has(w)) return 'a normative word';
49
+ if (NEGATION.has(w)) return 'a negation';
50
+ if (/^\d/.test(w) || w.split('-').some((p) => NUMBER_WORDS.has(p))) return 'a number';
51
+ if (QUANTIFIER.has(w)) return 'a quantifier';
52
+ if ((w === 'least' || w === 'most') && before?.toLowerCase() === 'at') return 'a quantifier';
53
+ return null;
54
+ }
55
+
56
+ // The reasons the hunks are meaning-sensitive, or [] when none is.
57
+ export function marks(hunks) {
58
+ const out = new Set();
59
+ for (const h of hunks) {
60
+ for (const [list, prev] of [[h.old, h.before], [h.new, h.before]]) {
61
+ list.forEach((w, k) => {
62
+ const why = sensitive(w, k ? list[k - 1] : prev?.[0]);
63
+ if (why) out.add(why);
64
+ if (w.toLowerCase() === 'at' && ['least', 'most'].includes(list[k + 1]?.toLowerCase())) out.add('a quantifier');
65
+ });
66
+ }
67
+ }
68
+ return [...out];
69
+ }
70
+
71
+ // The sentences of `text` that hold a word of the hunks' new side, or the
72
+ // whole text when none does (a pure removal).
73
+ export function sentencesOf(text, hunks) {
74
+ const changed = new Set(hunks.flatMap((h) => h.new));
75
+ const sentences = text.replace(/\s+/g, ' ').trim().split(/(?<=[.!?])\s+/);
76
+ const hit = sentences.filter((s) => words(s).some((w) => changed.has(w)));
77
+ return (hit.length ? hit : sentences).join(' ');
78
+ }
79
+
80
+ // Requirement words in a non-promise kind (design.md 3): MUST, SHALL, SHOULD,
81
+ // MAY as written in capitals, and never or always.
82
+ export const requirementWords = (text) => [...new Set([
83
+ ...(text.match(/\b(MUST|SHALL|SHOULD|MAY)\b/g) ?? []),
84
+ ...(text.match(/\b(never|always)\b/gi) ?? []).map((w) => w.toLowerCase()),
85
+ ])];
86
+
87
+ // How close two texts are: the Dice coefficient of their sets of lower-case
88
+ // words. NEAR is the least that counts as a near match (architect, #179).
89
+ export const NEAR = 0.6;
90
+ const wordSet = (text) => new Set(text.toLowerCase().match(/[a-z0-9]+/g) ?? []);
91
+ export function closeness(a, b) {
92
+ const x = wordSet(a);
93
+ const y = wordSet(b);
94
+ if (!x.size || !y.size) return 0;
95
+ let both = 0;
96
+ for (const w of x) if (y.has(w)) both++;
97
+ return (2 * both) / (x.size + y.size);
98
+ }