@assuredloop/cli 0.2.3 → 1.0.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,321 @@
1
+ // The rules that block (#238, design.md 3, 6 and 10, schema.md 5): the few
2
+ // findings that are `not ok` because an exact check of the records, hashes
3
+ // and links decides them, and breaking them lets an unapproved or wrong
4
+ // decision be built. Every other finding of al check is a hint.
5
+ // - unsigned-requirement: the latest version of each requirement has a valid
6
+ // sign-off (R3);
7
+ // - record-evidence: each file that a record lists with a hash still has it (R4);
8
+ // - superseded-cited and superseded-status: a current place that names a
9
+ // superseded ADR names its successor, and the status line names it (R5);
10
+ // - record-unreadable: a request record that cannot be read or is not a YAML
11
+ // mapping, or whose request has no request.md.
12
+ // Each finding is { severity, code, where, id, message }, as in checks.js.
13
+ import { readdirSync, statSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { read, recordPath, sha256 } from './base.js';
16
+ import { SIGNED } from './snapshot.js';
17
+ import { SPEC, linkedDocs, rootNotOk } from './scope.js';
18
+
19
+ const isMap = (x) => !!x && typeof x === 'object' && !Array.isArray(x);
20
+ const text = (x) => typeof x === 'string' && x.length > 0;
21
+ const whole = (x) => Number.isInteger(x) && x > 0;
22
+ const REQ = /^[RQ]\d+$/;
23
+ const SYMLINK = 'it is a symlink, and al does not read through a symlink';
24
+ // A symlink that stands for a current place: to a Markdown file, or to a
25
+ // folder (its type is read from the link, nothing through it).
26
+ const placeLink = (top, path) => {
27
+ if (path.endsWith('.md')) return true;
28
+ try {
29
+ return statSync(join(top, path)).isDirectory();
30
+ } catch {
31
+ return false;
32
+ }
33
+ };
34
+
35
+ // The bytes of a snapshot after its separator line, as al new and al record
36
+ // hashed them: the first line that is `---` (or `--- signed text ---`), or
37
+ // only `--- signed text ---` for a sign-off. Null when there is none.
38
+ function snapshotText(bytes, signedOnly) {
39
+ const seps = signedOnly ? [`\n${SIGNED}\n`] : ['\n---\n', `\n${SIGNED}\n`];
40
+ const at = seps.map((s) => [bytes.indexOf(s), s]).filter(([i]) => i >= 0).sort((a, b) => a[0] - b[0])[0];
41
+ return at ? bytes.subarray(at[0] + at[1].length) : null;
42
+ }
43
+
44
+ // What a record's file name in origin/ gives: { hash } of its snapshot text,
45
+ // or { problem } that says why it has none.
46
+ function evidence(top, r, file, signedOnly) {
47
+ const name = String(file).replace(/^origin\//, '');
48
+ if (!/^[^/\\]+$/.test(name) || name === '.' || name === '..') return { problem: `${file} is not a file in ${r.dir}/origin/` };
49
+ let bytes;
50
+ try {
51
+ bytes = read(top, `${r.dir}/origin/${name}`, null);
52
+ } catch (e) {
53
+ return { problem: `${r.dir}/origin/${name} cannot be read: ${e.message}` };
54
+ }
55
+ if (bytes === null) return { problem: `${r.dir}/origin/${name} is missing` };
56
+ const t = snapshotText(bytes, signedOnly);
57
+ if (t === null) return { problem: `${r.dir}/origin/${name} has no ${signedOnly ? `"${SIGNED}"` : '"---"'} line, so its text cannot be read` };
58
+ return { hash: sha256(t) };
59
+ }
60
+
61
+ // R3, R4 and the unreadable record, for each request with a record, open or archived.
62
+ export function recordBlocks(top, state, add) {
63
+ // A record whose request has no request.md: nothing in it can be checked.
64
+ const folder = '.assuredloop/records/requests';
65
+ let records = [];
66
+ try {
67
+ records = readdirSync(join(top, folder), { withFileTypes: true }).filter((e) => e.name.endsWith('.yaml')).map((e) => e.name.slice(0, -5)).sort();
68
+ } catch (e) {
69
+ records = [];
70
+ if (e.code !== 'ENOENT') add('not ok', 'record-unreadable', folder, '-', `${folder} cannot be read (${e.code ?? e.message}), so no record in it is checked`);
71
+ }
72
+ for (const name of records.filter((n) => !state.requests.has(n))) {
73
+ const at = recordPath(name);
74
+ add('not ok', 'record-unreadable', at, name, `${at} has no request.md in requests/${name}/ or requests/archive/${name}/, so its requirements, sign-offs and evidence are not checked`);
75
+ }
76
+ for (const r of state.requests.values()) {
77
+ const at = recordPath(r.name);
78
+ const data = r.data;
79
+ if (r.recordError) {
80
+ add('not ok', 'record-unreadable', at, r.name, `${at} cannot be read (${r.recordError}), so its requirements, sign-offs and evidence are not checked; fix it by hand`);
81
+ continue;
82
+ }
83
+ if (!data) continue;
84
+ const bad = (code, where, id, message) => add('not ok', code, where, `${r.name}/${id}`, message);
85
+
86
+ // R4: each origin file under sources:, and each sign-off file.
87
+ const sources = data.sources ?? [];
88
+ if (!Array.isArray(sources)) bad('record-evidence', at, 'sources', 'sources: is not a list, so its files cannot be checked');
89
+ for (const s of Array.isArray(sources) ? sources : []) {
90
+ if (!isMap(s) || !text(s.file) || !text(s.sha256)) {
91
+ bad('record-evidence', at, isMap(s) && text(s.file) ? s.file : '-', 'a sources entry with no file or no sha256');
92
+ continue;
93
+ }
94
+ const e = evidence(top, r, s.file, false);
95
+ if (e.problem) bad('record-evidence', at, s.file, e.problem);
96
+ else if (e.hash !== s.sha256) bad('record-evidence', at, s.file, `${r.dir}/origin/${s.file.replace(/^origin\//, '')} changed: its text no longer has the sha256 that the record lists`);
97
+ }
98
+ const signoffs = data.signoff ?? [];
99
+ if (!Array.isArray(signoffs)) bad('unsigned-requirement', at, 'signoff', 'signoff: is not a list, so no sign-off can be read');
100
+ // Each sign-off: why it is not valid (R3), or null.
101
+ const why = new Map();
102
+ for (const s of Array.isArray(signoffs) ? signoffs : []) {
103
+ const id = isMap(s) && text(s.id) ? s.id : '-';
104
+ if (!isMap(s) || !text(s.id) || !text(s.file) || !text(s.sha256) || !Array.isArray(s.covers)) {
105
+ if (isMap(s) && !(text(s.file) && text(s.sha256))) bad('record-evidence', at, id, 'a sign-off with no file or no sha256');
106
+ bad('unsigned-requirement', at, id, 'a sign-off entry that cannot be read: it needs id, file, sha256 and a covers list');
107
+ continue;
108
+ }
109
+ const e = evidence(top, r, s.file, true);
110
+ if (e.problem) bad('record-evidence', at, s.file, e.problem);
111
+ else if (e.hash !== s.sha256) bad('record-evidence', at, s.file, `${r.dir}/origin/${s.file.replace(/^origin\//, '')} changed: its signed text no longer has the sha256 of ${s.id}`);
112
+ const covers = s.covers.filter((c) => isMap(c) && text(c.id) && whole(c.version) && text(c.sha256));
113
+ if (covers.length < s.covers.length) {
114
+ bad('unsigned-requirement', at, s.id, `${s.id} has a covers entry that cannot be read: it needs id, a whole version above 0 and sha256`);
115
+ continue;
116
+ }
117
+ const problem = e.problem ? `its file: ${e.problem}`
118
+ : e.hash !== s.sha256 ? 'its file\'s signed text does not have its sha256'
119
+ : !(text(s.signed) && !Number.isNaN(Date.parse(s.signed))) ? 'it does not record when it was signed'
120
+ : !text(s.words) ? 'it does not record the signer\'s words' : null;
121
+ why.set(s, problem);
122
+ }
123
+
124
+ // R3: only a request whose record has requirements:.
125
+ if (!Object.hasOwn(data, 'requirements')) continue;
126
+ if (!Array.isArray(data.requirements)) {
127
+ bad('unsigned-requirement', at, 'requirements', 'requirements: is not a list, so no requirement can be read');
128
+ continue;
129
+ }
130
+ const latest = new Map();
131
+ for (const v of data.requirements) {
132
+ if (!isMap(v) || !(typeof v.id === 'string' && REQ.test(v.id)) || !whole(v.version) || !text(v.sha256)) {
133
+ bad('unsigned-requirement', at, isMap(v) && text(v.id) ? v.id : '-', 'a requirement entry that cannot be read: it needs an id R<n> or Q<n>, a whole version above 0 and sha256');
134
+ continue;
135
+ }
136
+ if (!latest.has(v.id) || v.version > latest.get(v.id).version) latest.set(v.id, v);
137
+ }
138
+ const md = `${r.dir}/request.md`;
139
+ const next = `run al index, then show it to the owner: al record ${r.name} signoff`;
140
+ const shown = new Set();
141
+ for (const q of r.reqs) {
142
+ const v = latest.get(q.id);
143
+ if (!v) bad('unsigned-requirement', md, q.id, `${q.id} is in request.md, but the record has no version of it, so no sign-off covers it; ${next}`);
144
+ else if (v.sha256 !== q.sha256) bad('unsigned-requirement', md, q.id, `${q.id} in request.md is not its latest recorded version ${v.version}: it is a new version that no sign-off covers; ${next}`);
145
+ else continue;
146
+ shown.add(q.id);
147
+ }
148
+ for (const v of latest.values()) {
149
+ if (shown.has(v.id)) continue;
150
+ const covering = [...why.keys()].filter((s) => s.covers.some((c) => c.id === v.id && c.version === v.version && c.sha256 === v.sha256));
151
+ if (covering.some((s) => why.get(s) === null)) continue;
152
+ const reason = covering.length
153
+ ? covering.map((s) => `${s.id} covers it, but is not valid: ${why.get(s)}`).join('; ')
154
+ : `no sign-off covers version ${v.version} with its sha256`;
155
+ bad('unsigned-requirement', md, v.id, `${v.id} version ${v.version} is not signed: ${reason}; ${next}`);
156
+ }
157
+ }
158
+ }
159
+
160
+ // The ADRs that each ADR supersedes, directly: those its marker names in
161
+ // supersedes:, and those whose status line names it.
162
+ function supersedes(state) {
163
+ const out = new Map([...state.adrs.keys()].map((id) => [id, new Set()]));
164
+ for (const a of state.adrs.values()) {
165
+ for (const x of (a.head?.links.supersedes ?? []).map(adrOf).filter(Boolean)) out.get(a.id).add(x);
166
+ if (a.by && out.has(a.by)) out.get(a.by).add(a.id);
167
+ }
168
+ return out;
169
+ }
170
+
171
+ // What a superseded-ADR finding says (supersededIn).
172
+ export const citedMessage = (c) => (c.next.length
173
+ ? `names ${c.adr}, which is superseded, but not ${c.next.join(' or ')}, which supersedes it, ${c.link ? 'in the same marker' : 'in the same paragraph'}`
174
+ : `names ${c.adr}, which is superseded, and no ADR supersedes it (see superseded-status)`);
175
+
176
+ // The ADR that a link value or word names: `ADR-n` and `ADR-n-k` name ADR-n.
177
+ const ADR_REF = /^ADR-(\d+)(?:-\d+)?$/;
178
+ const adrOf = (ref) => (ADR_REF.test(ref) ? `ADR-${Number(ADR_REF.exec(ref)[1])}` : null);
179
+ const MARKER = /^\s*<!--(.*)-->\s*$/;
180
+
181
+ // The places of a Markdown text: each marker line and each run of text lines,
182
+ // with the ADRs it names and the ID of the marker it falls under. A heading
183
+ // that `skip` matches starts a section that is not read, up to the next
184
+ // heading of level 1 or 2.
185
+ function places(textOf, skip) {
186
+ const lines = textOf.replace(/\r\n?/g, '\n').split('\n');
187
+ const out = [];
188
+ let id = null;
189
+ let skipping = false;
190
+ let run = null;
191
+ const close = () => {
192
+ if (run && run.names.size) out.push(run);
193
+ run = null;
194
+ };
195
+ lines.forEach((l, i) => {
196
+ if (/^#{1,2}\s/.test(l)) skipping = Boolean(skip && skip.test(l));
197
+ if (skipping || !l.trim()) { close(); return; }
198
+ const m = MARKER.exec(l);
199
+ if (m) {
200
+ close();
201
+ const tokens = m[1].trim().split(/\s+/).filter(Boolean);
202
+ if (tokens[0] && !tokens[0].includes(':')) id = tokens[0];
203
+ const names = new Set(tokens.filter((t) => t.indexOf(':') > 0).flatMap((t) => t.slice(t.indexOf(':') + 1).split(',')).map(adrOf).filter(Boolean));
204
+ if (names.size) out.push({ line: i + 1, id, link: true, names });
205
+ return;
206
+ }
207
+ if (!run) run = { line: i + 1, id, link: false, names: new Set() };
208
+ for (const w of l.matchAll(/\bADR-(\d+)(?:-\d+)?\b/g)) run.names.add(`ADR-${Number(w[1])}`);
209
+ });
210
+ close();
211
+ return out;
212
+ }
213
+
214
+ // R5's rule on one text: each place that names a superseded ADR, but no ADR
215
+ // that supersedes it, in the same marker or the same paragraph. Gives
216
+ // (text, { skip, exempt }) => [{ line, id, link, adr, next }]. al check
217
+ // --issues reads the issues with the same rule (issue-hints D7).
218
+ export function supersededIn(state) {
219
+ const down = supersedes(state);
220
+ const up = (id) => [...down].filter(([, xs]) => xs.has(id)).map(([y]) => y);
221
+ const superseded = (id) => state.adrs.get(id)?.status === 'superseded';
222
+ return (textOf, { skip = null, exempt = new Set() } = {}) => places(textOf, skip).flatMap((p) => [...p.names]
223
+ .filter((x) => superseded(x) && !exempt.has(x) && !up(x).some((y) => p.names.has(y)))
224
+ .map((x) => ({ line: p.line, id: p.id, link: p.link, adr: x, next: up(x) })));
225
+ }
226
+
227
+ // The Markdown files of an open request's folder, in its subfolders too, but
228
+ // origin/; a symlink to a Markdown file or a folder, or a subfolder that
229
+ // cannot be read, is given as { unreadable, why }.
230
+ export function requestMarkdown(top, dir, sub = '') {
231
+ let entries = [];
232
+ try {
233
+ entries = readdirSync(join(top, dir, sub), { withFileTypes: true });
234
+ } catch (e) {
235
+ return sub && e.code !== 'ENOENT' ? [{ unreadable: sub, why: e.code ?? e.message }] : [];
236
+ }
237
+ return entries.sort((a, b) => (a.name < b.name ? -1 : 1)).flatMap((e) => {
238
+ const f = sub ? `${sub}/${e.name}` : e.name;
239
+ if (e.isSymbolicLink()) return f !== 'origin' && placeLink(top, `${dir}/${f}`) ? [{ unreadable: f, why: SYMLINK }] : [];
240
+ if (e.isDirectory()) return f === 'origin' ? [] : requestMarkdown(top, dir, f);
241
+ return e.isFile() && e.name.endsWith('.md') ? [f] : [];
242
+ });
243
+ }
244
+
245
+ // R5: a current place that names a superseded ADR names an ADR that
246
+ // supersedes it, in the same marker or the same paragraph; and each
247
+ // superseded ADR's status line names an ADR whose marker supersedes it.
248
+ export function supersededBlocks(top, state, add) {
249
+ const down = supersedes(state);
250
+ const cited = supersededIn(state);
251
+ // The ADRs that `id` supersedes, directly or through a chain.
252
+ const chain = (id) => {
253
+ const seen = new Set();
254
+ const walk = (x) => { for (const y of down.get(x) ?? []) if (!seen.has(y)) { seen.add(y); walk(y); } };
255
+ walk(id);
256
+ return seen;
257
+ };
258
+
259
+ for (const a of state.adrs.values()) {
260
+ if (a.status !== 'superseded') continue;
261
+ const where = `${a.file}:1`;
262
+ if (!a.by) add('not ok', 'superseded-status', where, a.id, `${a.id} is superseded, but its status line does not say by which ADR: write "Status: superseded by ADR-<n>"`);
263
+ else if (!state.adrs.has(a.by)) add('not ok', 'superseded-status', where, a.id, `${a.id} says it is superseded by ${a.by}, which does not exist`);
264
+ else if (!(state.adrs.get(a.by).head?.links.supersedes ?? []).some((x) => adrOf(x) === a.id)) {
265
+ add('not ok', 'superseded-status', where, a.id, `${a.id} says it is superseded by ${a.by}, but the marker of ${a.by} has no supersedes:${a.id}`);
266
+ }
267
+ }
268
+
269
+ const check = (file, textOf, { skip = null, exempt = new Set(), name = null } = {}) => {
270
+ for (const c of cited(textOf, { skip, exempt })) {
271
+ const id = c.id ? (name ? `${name}/${c.id}` : c.id) : '-';
272
+ add('not ok', 'superseded-cited', `${file}:${c.line}`, id, citedMessage(c));
273
+ }
274
+ };
275
+ // A current place that cannot be read: its citations are not checked, and
276
+ // a read failure never hides a block (SCH-37).
277
+ const unreadable = (path, why) => add('not ok', 'superseded-cited', path, '-', `${path} cannot be read (${why}), so its citations of superseded ADRs are not checked`);
278
+ // A symlinked Markdown file or folder under the spec root (the ADRs too) is not read.
279
+ const linked = (dir) => {
280
+ let entries = [];
281
+ try {
282
+ entries = readdirSync(join(top, dir), { withFileTypes: true });
283
+ } catch {
284
+ entries = [];
285
+ }
286
+ return entries.flatMap((e) => (e.isSymbolicLink() ? (placeLink(top, `${dir}/${e.name}`) ? [`${dir}/${e.name}`] : []) : e.isDirectory() ? linked(`${dir}/${e.name}`) : []));
287
+ };
288
+ // Only a root that is in the repo and not through a symlink is walked
289
+ // (scope.js); the docs that config lists are spec docs too.
290
+ const links = new Set([...(rootNotOk(top, state.config) ? [] : linked(state.root)), ...linkedDocs(top, state.config)]);
291
+ for (const f of [...links].sort()) unreadable(f, SYMLINK);
292
+ // The spec docs, each paragraph with its marker.
293
+ for (const d of state.docs ?? []) if (d.scope === SPEC && typeof d.text === 'string') check(d.path, d.text);
294
+ // The Markdown files of each open request, but origin/ and the recorded decisions.
295
+ for (const r of state.requests.values()) {
296
+ if (!r.open) continue;
297
+ for (const f of requestMarkdown(top, r.dir)) {
298
+ if (f.unreadable) {
299
+ unreadable(`${r.dir}/${f.unreadable}`, f.why);
300
+ continue;
301
+ }
302
+ let t;
303
+ let why = 'it is there, but it cannot be read as a file';
304
+ try {
305
+ t = read(top, `${r.dir}/${f}`);
306
+ } catch (e) {
307
+ t = null;
308
+ why = e.message.split('\n')[0];
309
+ }
310
+ if (t === null) unreadable(`${r.dir}/${f}`, why);
311
+ else check(`${r.dir}/${f}`, t, { name: r.name, skip: f === 'request.md' ? /^#{1,2}\s+Decisions\s*$/ : null });
312
+ }
313
+ }
314
+ // An ADR file that cannot be read: neither its status nor its citations are known.
315
+ for (const file of state.unreadableAdrs ?? []) unreadable(file, 'it is there, but it cannot be read as a file');
316
+ // Each proposed or accepted ADR, but its History section and the ADRs it supersedes.
317
+ for (const a of state.adrs.values()) {
318
+ if (!['proposed', 'accepted'].includes(a.status)) continue;
319
+ check(a.file, a.text, { skip: /^#{1,2}\s+History\s*$/, exempt: chain(a.id) });
320
+ }
321
+ }
package/src/v4/check.js CHANGED
@@ -3,9 +3,11 @@
3
3
  // again, one ID in two files), then each paragraph's change (T8); then the
4
4
  // checks that read records (T10, checks.js). The base is the merge-base of
5
5
  // HEAD with main; the head is the working tree. It exits 0; with --strict, 1
6
- // when it prints a `not ok`. --strict raises every marker lint and an invalid
7
- // disposition to `not ok`; the hints that need judgment stay hints. A lost ID
8
- // of an open request's change spec is a hint, also under --strict (#230).
6
+ // when it prints a `not ok`. A finding has the same severity in both modes:
7
+ // the marker and ID lints are hints for the review, and only the exact rules
8
+ // of checks.js are `not ok` (#238). With --issues <folder> it also checks the
9
+ // work items that the caller saved there (issue-hints R1).
10
+ import { relative } from 'node:path';
9
11
  import { parse as parseYaml } from 'yaml';
10
12
  import { read, recordPath } from './base.js';
11
13
  import { loadConfig, loadSchema } from './config.js';
@@ -15,12 +17,30 @@ import { docsInScope, idsEverUsed, isShallow, notRead, parseScopes, rootNotOk, r
15
17
  import { Fail } from './spec.js';
16
18
  import { centralLookup } from './repos.js';
17
19
  import { checks } from './checks.js';
20
+ import { loadPhrases } from './phrases.js';
21
+ import { loadItems } from './issues.js';
18
22
 
19
- export function check({ top, opts }) {
23
+ // A file or folder that al check cannot read, and that no check reports as
24
+ // not ok, stops it with a refusal: never an internal error, never a pass (#238).
25
+ const UNREADABLE = new Set(['EACCES', 'EPERM', 'EISDIR', 'ENOTDIR', 'ELOOP']);
26
+ export function check(args) {
27
+ try {
28
+ return checkAll(args);
29
+ } catch (e) {
30
+ if (e instanceof Fail || !UNREADABLE.has(e.code) || !e.path) throw e;
31
+ throw new Fail(`${relative(args.top, e.path)} cannot be read`, 'make it readable, then run al check again');
32
+ }
33
+ }
34
+
35
+ function checkAll({ top, cwd, opts }) {
20
36
  const config = loadConfig(top);
21
37
  const root = rootProblem(config);
22
38
  if (root) throw new Fail(root, 'fix root: in .assuredloop/config.yaml');
23
39
  const { kinds } = loadSchema(top);
40
+ // A bad phrases: list is a refusal before anything is read (issue-hints D6).
41
+ const phrases = loadPhrases(config);
42
+ // The work items that the caller saved; a folder that is not there is a refusal.
43
+ const issues = opts.issues === undefined ? null : loadItems(cwd ?? top, opts.issues);
24
44
  const base = mergeBase(top);
25
45
  const now = parseScopes(docsInScope(top, config), kinds);
26
46
  const was = base ? parseScopes(docsInScope(top, config, base), kinds) : null;
@@ -28,6 +48,7 @@ export function check({ top, opts }) {
28
48
  // A change spec's removes: names spec paragraphs.
29
49
  const removes = [...now.scopes].filter(([s]) => s !== SPEC).flatMap(([, ps]) => ps.flatMap((p) => p.links.removes));
30
50
 
51
+ // Each marker and ID lint is a hint for the review, in every mode (#238).
31
52
  const lints = [...now.lints];
32
53
  const changes = [];
33
54
  for (const scope of new Set([...now.scopes.keys(), ...(was?.scopes.keys() ?? [])])) {
@@ -41,22 +62,20 @@ export function check({ top, opts }) {
41
62
  changes.push(...diffParagraphs(before, head).filter((c) => c.changes.length));
42
63
  }
43
64
 
44
- const t10 = checks(top, { base, strict: opts.strict });
65
+ const t10 = checks(top, { base, phrases, issues });
45
66
  // An adopted paragraph needs no link while its text is the captured text (D16).
46
- const shown = lints.filter((l) => !(l.code === 'no-link' && t10.adopted.has(l.id) && !l.file.startsWith('requests/')));
67
+ const shown = lints.filter((l) => !(l.code === 'no-link' && t10.adopted.has(l.id) && !l.file.startsWith('requests/'))).map((l) => ({ ...l, severity: 'hint' }));
47
68
  lints.length = 0;
48
69
  lints.push(...shown);
49
- const severity = (l) => (opts.strict && !l.staysHint ? 'not ok' : l.severity);
50
70
  const body = notRead(top, config);
51
71
  // A root that config sets but that is not read is a not ok, as in al index (#218).
52
72
  const rootLine = rootNotOk(top, config);
53
73
  if (rootLine) body.push(rootLine);
54
- body.push(...lints.map((l) => `${severity(l)} ${l.code} ${l.file}:${l.line} ${l.id ?? '-'} ${l.message}`));
74
+ body.push(...lints.map((l) => `${l.severity} ${l.code} ${l.file}:${l.line} ${l.id ?? '-'} ${l.message}`));
55
75
  if (base) body.push(...changes.map((c) => `${c.file} ${c.id} ${c.changes.join('+')}`));
56
76
  if (!lints.length) body.push('ok: no marker lint');
57
77
  body.push(...t10.findings.map((f) => `${f.severity} ${f.code} ${f.where} ${f.id} ${f.message}`));
58
- // Under --strict every marker lint is a not ok.
59
- const notOk = Boolean(rootLine) || lints.some((l) => severity(l) === 'not ok') || t10.findings.some((f) => f.severity === 'not ok');
78
+ const notOk = Boolean(rootLine) || lints.some((l) => l.severity === 'not ok') || t10.findings.some((f) => f.severity === 'not ok');
60
79
  // In an output repo: the central IDs that the branch's changed files cite (T12).
61
80
  const central = centralLookup(top);
62
81
  body.push(...central.body);
@@ -67,8 +86,9 @@ export function check({ top, opts }) {
67
86
  return {
68
87
  body,
69
88
  read: base ? `working tree · base ${base.slice(0, 7)} (merge-base with main)` : 'working tree · no base',
70
- next: nextStep([...(rootLine ? ['not ok'] : []), ...lints.map(severity), ...t10.findings.map((f) => f.severity),
71
- ...central.body.map((l) => (/^not ok /.test(l) ? 'not ok' : /^hint /.test(l) ? 'hint' : 'info'))], lints.some((l) => l.code === 'no-id')),
89
+ next: nextStep([...(rootLine ? ['not ok'] : []), ...lints.map((l) => l.severity), ...t10.findings.map((f) => f.severity),
90
+ ...central.body.map((l) => (/^not ok /.test(l) ? 'not ok' : /^hint /.test(l) ? 'hint' : 'info'))], lints.some((l) => l.code === 'no-id'),
91
+ t10.findings.some((f) => f.code === 'unsigned-requirement')),
72
92
  notKnown,
73
93
  exit: opts.strict && notOk ? 1 : 0,
74
94
  };
@@ -96,15 +116,16 @@ function deleted(top, l) {
96
116
  ...list(rec?.decisions).filter((d) => d?.wording?.source === source).map((d) => `decision ${d.id} names its wording`),
97
117
  ];
98
118
  const message = `${source} is deleted${facts.map((f) => `; ${f}`).join('')}. If that is on purpose, nothing more is needed: the ID is retired and never used again.`;
99
- return { ...l, severity: 'hint', staysHint: true, message };
119
+ return { ...l, message };
100
120
  }
101
121
 
102
122
  // The Next line names what to do for what printed: a not ok, a hint, both, or
103
- // neither (#198).
104
- function nextStep(severities, noId) {
123
+ // neither (#198); and for an unsigned requirement, how to get it signed (#238).
124
+ function nextStep(severities, noId, unsigned) {
105
125
  const notOk = severities.includes('not ok');
106
126
  const hint = severities.includes('hint');
107
- const addIds = noId ? '; al spec --add-ids <file> marks the paragraphs with no ID' : '';
127
+ const addIds = (noId ? '; al spec --add-ids <file> marks the paragraphs with no ID' : '')
128
+ + (unsigned ? '; for an unsigned requirement, run al index, then ask the owner to sign it (al record <name> signoff)' : '');
108
129
  if (notOk && hint) return `fix each not ok, and deal with each hint or say in the PR why it stays${addIds}`;
109
130
  if (notOk) return `fix each not ok${addIds}`;
110
131
  if (hint) return 'deal with each hint, or say in the PR why it stays';
package/src/v4/checks.js CHANGED
@@ -17,6 +17,9 @@ import {
17
17
  import { NEAR, closeness, diffWords, marks, requirementWords, sentencesOf, showHunk } from './words.js';
18
18
  import { groupOf, sameBlocks } from './markers.js';
19
19
  import { isCentral } from './indexer.js';
20
+ import { recordBlocks, supersededBlocks } from './blocks.js';
21
+ import { phraseFindings } from './phrases.js';
22
+ import { issueHints } from './issues.js';
20
23
 
21
24
  const DESIGN = ['component', 'interface', 'data', 'flow', 'choice'];
22
25
  const TIER = /^[ \t]*Tier:[ \t]*(0|1d|1|2|3|S)\b[ \t]*(?:—|–|--?)?[ \t]*(.*)$/m;
@@ -122,7 +125,7 @@ export function branchOf(top, now, was, base) {
122
125
  return { changes, captured, notAdopted, claim, commits, typo, isPromiseChange, uncovered };
123
126
  }
124
127
 
125
- export function checks(top, { base, strict = false }) {
128
+ export function checks(top, { base, phrases = [], issues = null }) {
126
129
  const now = loadState(top);
127
130
  const was = base ? loadState(top, base) : null;
128
131
  const findings = [];
@@ -144,7 +147,7 @@ export function checks(top, { base, strict = false }) {
144
147
  } else if (claim.tier === '0') {
145
148
  for (const c of changes) {
146
149
  if (typo.has(c.name)) continue;
147
- if (isPromiseChange(c)) add('not ok', 'path-claim', c.head ?? c.base, c.name, `path 0, but a ${(c.head ?? c.base).kind} paragraph is ${changeWord(c)} with no typo claim`);
150
+ if (isPromiseChange(c)) add('hint', 'path-claim', c.head ?? c.base, c.name, `path 0, but a ${(c.head ?? c.base).kind} paragraph is ${changeWord(c)} with no typo claim`);
148
151
  else if (!c.request && ['design', 'informative'].includes(groupOf((c.head ?? c.base).kind, now.kinds))) {
149
152
  add('hint', 'path-claim', c.head ?? c.base, c.name, `path 0, but a ${(c.head ?? c.base).kind} paragraph is ${changeWord(c)}: path 1d or 2?`);
150
153
  }
@@ -152,8 +155,8 @@ export function checks(top, { base, strict = false }) {
152
155
  } else if (claim.tier === '1d') {
153
156
  for (const c of changes) {
154
157
  const p = c.head ?? c.base;
155
- if (isPromiseChange(c)) add('not ok', 'path-claim', p, c.name, `path 1d, but a ${p.kind} paragraph is ${changeWord(c)}; 1d is for names and wording of design paragraphs`);
156
- else if (DESIGN.includes(p.kind) && (!c.head || !c.base)) add('not ok', 'path-claim', p, c.name, `path 1d, but a ${p.kind} paragraph is ${changeWord(c)}; 1d adds or removes no design paragraph`);
158
+ if (isPromiseChange(c)) add('hint', 'path-claim', p, c.name, `path 1d, but a ${p.kind} paragraph is ${changeWord(c)}; 1d is for names and wording of design paragraphs`);
159
+ else if (DESIGN.includes(p.kind) && (!c.head || !c.base)) add('hint', 'path-claim', p, c.name, `path 1d, but a ${p.kind} paragraph is ${changeWord(c)}; 1d adds or removes no design paragraph`);
157
160
  }
158
161
  }
159
162
  const byName = new Map(changes.map((c) => [c.name, c]));
@@ -252,7 +255,7 @@ export function checks(top, { base, strict = false }) {
252
255
  const who = named(r?.name ?? null, p.id);
253
256
  if (word === 'removes' && !t.req && !now.spec.has(t.id) && r) continue; // its own removal, done
254
257
  if (!t.req && removerOf(t.id)) add('hint', 'target-removed', p, who, `${ref}: target removed by ${removerOf(t.id)}; align this link`);
255
- else add('not ok', 'unresolved-link', p, who, `${ref}: no such ID${adr ? ` (in ${adr.id})` : ''}`);
258
+ else add('hint', 'unresolved-link', p, who, `${ref}: no such ID${adr ? ` (in ${adr.id})` : ''}`);
256
259
  }
257
260
  }
258
261
  }
@@ -346,19 +349,29 @@ export function checks(top, { base, strict = false }) {
346
349
  const judged = judgeRequest(now, r, { typo, baseSpec });
347
350
  for (const j of judged) {
348
351
  if (j.status === 'pending') continue;
349
- if (!j.valid) add(strict ? 'not ok' : 'hint', 'disposition', j.p, j.id, `${j.status} is not valid: ${j.reason}`);
352
+ if (!j.valid) add('hint', 'disposition', j.p, j.id, `${j.status} is not valid: ${j.reason}`);
350
353
  for (const n of j.notes) add(n.startsWith('near-match') ? 'hint' : 'info', n.startsWith('near-match') ? 'near-match' : 'disposition', j.p, j.id, n.replace(/^near-match /, ''));
351
354
  }
352
355
  const pending = judged.filter((j) => j.status === 'pending').map((j) => j.p.id);
353
356
  if (pending.length) add('info', 'disposition-pending', r.specFile, r.name, `${pending.length} of ${judged.length} paragraphs with a baseline effect have no disposition for their current version: ${pending.join(', ')}`);
354
357
  }
355
358
 
356
- // ADRs (schema.md 5).
359
+ // ADRs (schema.md 5). An ADR's text may be rewritten when its decision
360
+ // stays, so a change is a hint for the review (#238). Links may be added to
361
+ // the heading marker of an accepted ADR: the same kind, each old link kept,
362
+ // the rest unchanged; that gives no finding.
363
+ const linksAdded = (a, h) => a.status === 'accepted' && a.head && h.head && a.rest === h.rest && a.head.kind === h.head.kind
364
+ && Object.entries(a.head.links).every(([k, refs]) => refs.every((x) => h.head.links[k].includes(x)));
357
365
  for (const [id, a] of was?.adrs ?? []) {
358
366
  if (!['accepted', 'superseded'].includes(a.status)) continue;
359
367
  const h = now.adrs.get(id);
360
- if (!h) add('not ok', 'adr-changed', a.file, id, `${id} was ${a.status} and its file is gone; an ADR is superseded, never edited or deleted`);
361
- else if (h.body !== a.body) add('not ok', 'adr-changed', h.head ?? h.file, id, `the text of ${id} changed after it was accepted; write a new ADR that supersedes it`);
368
+ if (!h) add('hint', 'adr-changed', a.file, id, `${id} was ${a.status} and its file is gone: check that its decision is not lost; an ADR that no longer holds is superseded by a new ADR, not deleted`);
369
+ else if (h.body !== a.body && !linksAdded(a, h)) add('hint', 'adr-changed', h.head ?? h.file, id, `${id}'s text changed: check that its decision is unchanged; if the decision changed, write a new ADR that supersedes it`);
370
+ }
371
+ for (const a of now.adrs.values()) {
372
+ if (!['proposed', 'accepted'].includes(a.status)) continue;
373
+ const missing = ['source', 'decides'].filter((w) => !a.head?.links[w].length);
374
+ if (missing.length) add('hint', 'adr-links', a.head ?? a.file, a.id, `${a.id} has ${missing.map((w) => `no ${w}:`).join(' and ')}; add the links to its marker`);
362
375
  }
363
376
  for (const a of now.adrs.values()) {
364
377
  for (const old of a.head?.links.supersedes ?? []) {
@@ -373,6 +386,17 @@ export function checks(top, { base, strict = false }) {
373
386
  for (const a of govern) add('hint', 'adr-governs', p, c.id, `check that ${a.id} still holds`);
374
387
  }
375
388
 
389
+ // The rules that block (#238): unsigned requirements, record evidence,
390
+ // unreadable records and citations of superseded ADRs.
391
+ recordBlocks(top, now, add);
392
+ supersededBlocks(top, now, add);
393
+
394
+ // Phrases for the review (issue-hints R2), and with --issues <folder> the
395
+ // hints from the work items that the caller saved (issue-hints R1); without
396
+ // it, no item is read and nothing is added.
397
+ phraseFindings(top, now, phrases, add);
398
+ if (issues) notKnown.push(...issueHints(top, now, phrases, add, issues));
399
+
376
400
  // Results (design.md 9): whether each one applies to the current text. The
377
401
  // commits they ran at are looked up in one batch.
378
402
  const results = loadResults(top);
@@ -107,11 +107,13 @@ function recordOrigin({ top, cwd, args, opts }) {
107
107
  return { body: [`Wrote ${dir}/origin/${file} and source ${file} in ${rec.path}`], next: `name it in a requirement's marker: <!-- R<n> from:${file} -->` };
108
108
  }
109
109
 
110
- // al record <name> signoff --source <where> [--words <quote>] [--presented <x>] [--transcribed-by <who>] [--yes]
110
+ // al record <name> signoff --source <where> --words <quote> [--presented <x>] [--transcribed-by <who>] [--yes]
111
111
  function recordSignoff({ top, args, opts }) {
112
112
  const [name] = args;
113
113
  oneLine(opts, ['source', 'words', 'presented', 'transcribed-by']);
114
114
  if (!opts.source) throw new Fail('--source <where the owner signed> is missing', `al record ${name} signoff --source <where> --words <quote>`);
115
+ // A sign-off with no words is not valid in al check (DES-89, D10).
116
+ if (typeof opts.words !== 'string' || !opts.words) throw new Fail('--words <the owner\'s words> is missing', `pass the owner's words: al record ${name} signoff --source <where> --words <quote>`);
115
117
  const { dir, rec } = openRequest(top, name);
116
118
  const md = read(top, `${dir}/request.md`);
117
119
  const text = signedText(md);
@@ -6,7 +6,7 @@
6
6
  // change of the branch, against the merge-base with main, counts as al check
7
7
  // counts it (branchOf, #217). With
8
8
  // --yes it writes the Outcome, sets the status and archives the request.
9
- import { mkdirSync, renameSync } from 'node:fs';
9
+ import { mkdirSync, renameSync, statSync } from 'node:fs';
10
10
  import { dirname, join } from 'node:path';
11
11
  import { Fail, day, exists, guard, isName, now, write } from './base.js';
12
12
  import { branchOf } from './checks.js';
@@ -112,13 +112,17 @@ export function conclude({ top, args, opts }) {
112
112
  const block = outcome(state, r, judged, top);
113
113
  const target = `requests/archive/${name}`;
114
114
  if (exists(top, target)) throw new Fail(`${target} already exists; a request name is never reused`, `al context ${name}`);
115
+ // The preview refuses for each reason that --yes refuses (#241).
116
+ const rec = openRecord(top, name);
117
+ if (!rec) throw new Fail(`${name} has no record .assuredloop/records/requests/${name}.yaml`, 'a v4 request is made with al new');
118
+ guard(top, [`${r.dir}/request.md`, `${target}/request.md`, rec.path]);
119
+ if (statSync(join(top, dirname(target)), { throwIfNoEntry: false })?.isDirectory() === false) {
120
+ throw new Fail(`${dirname(target)} is not a folder; nothing was written`, `make ${dirname(target)} a folder, then al conclude ${name} again`);
121
+ }
115
122
  const what = `Status: concluded, the Outcome in ${target}/request.md, status: concluded in the record, and ${r.dir}/ moved to ${target}/`;
116
123
  if (!opts.yes) {
117
124
  return { body: [`Would conclude ${name}: ${what}`, ...block.slice(2).map((l) => ` ${l}`)], read, next: 'run the same command with --yes to do it', notKnown };
118
125
  }
119
- const rec = openRecord(top, name);
120
- if (!rec) throw new Fail(`${name} has no record .assuredloop/records/requests/${name}.yaml`, 'a v4 request is made with al new');
121
- guard(top, [`${r.dir}/request.md`, `${target}/request.md`, rec.path]);
122
126
  const md = withOutcome(withStatus(r.md), block);
123
127
  rec.doc.set('status', 'concluded');
124
128
  mkdirSync(dirname(join(top, target)), { recursive: true });
package/src/v4/indexer.js CHANGED
@@ -6,7 +6,6 @@ import { readdirSync } from 'node:fs';
6
6
  import { join, posix } from 'node:path';
7
7
  import { isMap, parse, stringify } from 'yaml';
8
8
  import { parseMarkdown } from './markers.js';
9
- import { diffParagraphs } from './ids.js';
10
9
  import { git, mergeBase, fileAt, filesAt } from './git.js';
11
10
  import { loadConfig } from './config.js';
12
11
  import { SPEC, docsInScope, symlinkOn } from './scope.js';
@@ -310,11 +309,8 @@ export function index({ top, opts }) {
310
309
  }
311
310
 
312
311
  // The per-doc records: regenerable fields, written fresh; hints kept by ID.
313
- // A change spec's IDs are its request's own, so they are compared as <request>/<ID>.
314
- const qualified = (docs) => docs.flatMap((d) => d.paragraphs.map((p) => (d.request ? { ...p, id: `${d.request}/${p.id}` } : p)));
315
- const diffs = main ? diffParagraphs(qualified(loadState(top, main, config, names).docs), qualified(now.docs)) : null;
316
- const own = (d, id) => (d.request ? `${d.request}/${id}` : id);
317
- const changeOf = new Map((diffs ?? []).filter((x) => x.sha256 !== null).map((x) => [`${x.file}\t${x.id}`, x.changes]));
312
+ // Nothing in them depends on the branch or its base, so main's index
313
+ // rebuilds a merged record as it is (#245).
318
314
  const outputs = [];
319
315
  for (const d of now.docs) {
320
316
  const path = docRecordPath(d.file);
@@ -328,13 +324,11 @@ export function index({ top, opts }) {
328
324
  }
329
325
  const status = d.adr ? d.text.split('\n').find((l) => l.trim())?.match(/^Status:\s*([a-z]+)/i)?.[1].toLowerCase() : undefined;
330
326
  const paragraphs = d.paragraphs.map((p) => {
331
- const change = changeOf.get(`${d.file}\t${own(d, p.id)}`) ?? [];
332
327
  const hint = hints.get(p.id);
333
328
  if (hint && stale(hint, p)) body.push(`refresh hint: ${d.file} ${p.id}`);
334
- return { id: p.id, ...(p.kind ? { kind: p.kind } : {}), text_sha256: p.sha256, display: p.displayNumber, ...(change.length ? { change } : {}), ...(hint ? { hint } : {}) };
329
+ return { id: p.id, ...(p.kind ? { kind: p.kind } : {}), text_sha256: p.sha256, display: p.displayNumber, ...(hint ? { hint } : {}) };
335
330
  });
336
- const removed = (diffs ?? []).filter((x) => x.sha256 === null && x.file === d.file).map((x) => (d.request ? x.id.slice(d.request.length + 1) : x.id));
337
- const rec = { schema: SCHEMA, file: d.file, ...(status ? { status } : {}), paragraphs, ...(removed.length ? { removed } : {}) };
331
+ const rec = { schema: SCHEMA, file: d.file, ...(status ? { status } : {}), paragraphs };
338
332
  outputs.push([path, stringify(rec, YAML_OPTIONS)]);
339
333
  }
340
334
  for (const [, rec] of recs) if (rec.changed) outputs.push([rec.path, recordText(rec)]);