@assuredloop/cli 0.2.4 → 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,256 @@
1
+ // Hints from the linked work items (issue-hints R1, design.md 8). `al` never
2
+ // contacts a tracker (D9): the caller saves the items in a folder, and
3
+ // `al check --issues <folder>` reads them. An item is the JSON that
4
+ // `gh issue view --json number,url,body,comments,parent` prints, or the same
5
+ // shape with `ref` and `repository` for another tracker (D10). Only the
6
+ // items that the `tasks:` of the open requests name are checked (D11). Each
7
+ // finding is a hint (R1, hints-and-blocks R2):
8
+ // - issue-missing: a task names an item that is not in the folder;
9
+ // - issue-superseded: the item's text names a superseded ADR without its
10
+ // successor, by R5's own code (supersededIn, D7);
11
+ // - issue-bare-ref: a bare `#n` in an item of another repository;
12
+ // - issue-not-sub: not a child of the request's own item (D3);
13
+ // - phrase: a phrase of the config matches (R2, phrases.js);
14
+ // - issue-unread: a file of the folder that is not valid JSON, or an entry
15
+ // that is not an item.
16
+ // An item's text is its body and its comments that are not minimized (D4).
17
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
18
+ import { isAbsolute, join, resolve } from 'node:path';
19
+ import { recordPath } from './base.js';
20
+ import { citedMessage, supersededIn } from './blocks.js';
21
+ import { git } from './git.js';
22
+ import { phraseHits, phraseMessage } from './phrases.js';
23
+ import { outputRepos } from './repos.js';
24
+ import { Fail } from './spec.js';
25
+
26
+ const ISSUE_URL = /\bhttps?:\/\/[^\s/]+\/([\w.-]+)\/([\w.-]+)\/issues\/(\d+)\b/g;
27
+ const QUALIFIED = /(?<![\w./-])([\w.-]+)\/([\w.-]+)#(\d+)\b/g;
28
+ const NAMED = /(?<![\w./#-])([a-z0-9][a-z0-9._-]*)#(\d+)\b/g;
29
+ const BARE = /(?<![\w/#&-])#(\d+)\b/g;
30
+ const slug = (s) => String(s).toLowerCase();
31
+ const isMap = (x) => !!x && typeof x === 'object' && !Array.isArray(x);
32
+ const once = (re) => new RegExp(re.source);
33
+ // `text` holds `word` as a whole word: no letter, digit, `_` or `-` beside it.
34
+ const holds = (text, word) => new RegExp(`(?<![\\w-])${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w-])`).test(text);
35
+
36
+ // The `owner/repo` of a URL or an `owner/repo#n` (null when it names no item).
37
+ const keyOf = (s) => {
38
+ const t = String(s ?? '');
39
+ const m = once(ISSUE_URL).exec(t) ?? once(QUALIFIED).exec(t);
40
+ return m ? `${m[1]}/${m[2]}#${m[3]}` : null;
41
+ };
42
+
43
+ // The `owner/repo` that `git remote get-url origin` names in `dir`, any host
44
+ // (D12): { repo } or { unknown }.
45
+ function remoteRepo(dir) {
46
+ const url = git(dir, ['remote', 'get-url', 'origin'], { allowFail: true });
47
+ if (!url) return { unknown: 'no remote origin' };
48
+ const m = /^(?:[\w.+-]+:\/\/(?:[^@/]+@)?[^/]+\/|[^@\s/]+@[^:\s/]+:)(.+?)(?:\.git)?\/?$/.exec(url);
49
+ // The URL is not shown: it may hold a user name and a password (SP-22).
50
+ return m && /^[^/]+\/[^/]+$/.test(m[1]) ? { repo: m[1] } : { unknown: 'the remote origin names no owner/repo' };
51
+ }
52
+
53
+ // An entry of an item file: the item, or null when it is not one (SP-24).
54
+ function itemOf(x, file) {
55
+ if (!isMap(x) || typeof x.body !== 'string') return null;
56
+ const at = once(ISSUE_URL).exec(String(x.url ?? ''));
57
+ const repository = typeof x.repository === 'string' && x.repository ? x.repository
58
+ : isMap(x.repository) && typeof x.repository.nameWithOwner === 'string' ? x.repository.nameWithOwner
59
+ : at ? `${at[1]}/${at[2]}` : null;
60
+ const number = Number.isInteger(x.number) ? x.number : at ? Number(at[3]) : null;
61
+ const ref = typeof x.ref === 'string' && x.ref ? x.ref : null;
62
+ const key = repository && number !== null ? `${repository}#${number}` : ref;
63
+ if (!key) return null;
64
+ return { key, ref, url: typeof x.url === 'string' ? x.url : null, repository, number, body: x.body, comments: x.comments, parent: x.parent, file };
65
+ }
66
+
67
+ // The items of the folder that the caller saved (SP-12): { items, unread }.
68
+ // A folder that is not there is a refusal.
69
+ export function loadItems(cwd, folder) {
70
+ const dir = resolve(cwd, folder);
71
+ let isDir = false;
72
+ try {
73
+ isDir = statSync(dir).isDirectory();
74
+ } catch {
75
+ isDir = false;
76
+ }
77
+ if (!isDir) throw new Fail(`--issues ${folder}: no such folder`, 'save the work items in a folder (see the README), then al check --issues <folder>');
78
+ const items = [];
79
+ const unread = [];
80
+ const shown = (name) => `${folder.replace(/\/+$/, '')}/${name}`;
81
+ for (const name of readdirSync(dir).filter((n) => n.endsWith('.json')).sort()) {
82
+ const file = shown(name);
83
+ let data;
84
+ try {
85
+ if (!statSync(join(dir, name)).isFile()) continue;
86
+ data = JSON.parse(readFileSync(join(dir, name), 'utf8'));
87
+ } catch (e) {
88
+ unread.push({ file, why: e instanceof SyntaxError ? 'it is not valid JSON' : `it cannot be read (${e.code ?? e.message})` });
89
+ continue;
90
+ }
91
+ (Array.isArray(data) ? data : [data]).forEach((x, i) => {
92
+ const it = itemOf(x, file);
93
+ if (it) items.push(it);
94
+ else unread.push({ file, why: `${Array.isArray(data) ? `its entry ${i + 1}` : 'it'} is not an item: an item needs a body, and a url, a number with a repository, or a ref` });
95
+ });
96
+ }
97
+ return { folder, items, unread };
98
+ }
99
+
100
+ // The names of the repos that the config names, each with its folder or why it has none.
101
+ function namedRepos(top, config) {
102
+ const out = new Map();
103
+ for (const o of outputRepos(top, config)) if (o.name) out.set(o.name, o.dir ? { dir: o.dir } : { unknown: o.unknown });
104
+ const c = config.central;
105
+ if (isMap(c) && typeof c.path === 'string' && c.path) {
106
+ const dir = isAbsolute(c.path) ? c.path : resolve(top, c.path);
107
+ out.set('central', existsSync(dir) ? { dir } : { unknown: `no clone at ${c.path}` });
108
+ }
109
+ return out;
110
+ }
111
+
112
+ // The references that a task's `ref` writes in the forms of SP-8:
113
+ // [{ written, key } | { written, unknown }].
114
+ function written(ref, names, own, folders) {
115
+ let rest = String(ref);
116
+ const out = [];
117
+ const take = (re, f) => {
118
+ rest = rest.replace(re, (...m) => {
119
+ const r = f(m);
120
+ if (!r) return m[0];
121
+ out.push({ written: m[0], ...r });
122
+ return ' '.repeat(m[0].length);
123
+ });
124
+ };
125
+ take(ISSUE_URL, (m) => ({ key: `${m[1]}/${m[2]}#${m[3]}` }));
126
+ take(QUALIFIED, (m) => ({ key: `${m[1]}/${m[2]}#${m[3]}` }));
127
+ take(NAMED, (m) => {
128
+ if (!names.has(m[1])) return null;
129
+ const r = folders(m[1]);
130
+ return r.repo ? { key: `${r.repo}#${m[2]}` } : { unknown: `${m[1]}: ${r.unknown}` };
131
+ });
132
+ take(BARE, (m) => (own.repo ? { key: `${own.repo}#${m[1]}` } : { unknown: `the request's own repository is not known (${own.unknown})` }));
133
+ return out;
134
+ }
135
+
136
+ export function issueHints(top, state, matchers, add, { folder, items, unread }) {
137
+ const notKnown = [];
138
+ const names = namedRepos(top, state.config);
139
+ const cited = supersededIn(state);
140
+ const own = remoteRepo(top);
141
+ const remotes = new Map();
142
+ const folders = (name) => {
143
+ if (!remotes.has(name)) {
144
+ const n = names.get(name);
145
+ remotes.set(name, n.dir ? remoteRepo(n.dir) : { unknown: n.unknown });
146
+ }
147
+ return remotes.get(name);
148
+ };
149
+ const byKey = new Map();
150
+ for (const it of items) if (!byKey.has(slug(it.key))) byKey.set(slug(it.key), it);
151
+ const withRef = items.filter((it) => it.ref);
152
+
153
+ for (const u of unread) add('hint', 'issue-unread', u.file, '-', `not read: ${u.why}`);
154
+ if (unread.length) notKnown.push(`the files in ${folder} that were not read: ${unread.map((u) => u.file).join(', ')}`);
155
+
156
+ // The items that each task of each open request names, in order (SP-8, SP-9).
157
+ const named = []; // [{ r, task, it }]
158
+ for (const r of state.requests.values()) {
159
+ if (!r.open) continue;
160
+ for (const t of Array.isArray(r.data?.tasks) ? r.data.tasks : []) {
161
+ if (!isMap(t) || t.ref == null) continue;
162
+ const task = String(t.id ?? '-');
163
+ const id = `${r.name}/${task}`;
164
+ const found = [];
165
+ for (const w of written(t.ref, names, own, folders)) {
166
+ const it = w.key && byKey.get(slug(w.key));
167
+ if (it) found.push(it);
168
+ else add('hint', 'issue-missing', w.written, id, w.key ? `${w.written}: no item in ${folder} is ${w.key}` : `${w.written}: ${w.unknown}, so it names no item`);
169
+ }
170
+ for (const it of withRef) if (!found.includes(it) && holds(String(t.ref), it.ref)) found.push(it);
171
+ if (!found.length && !written(t.ref, names, own, folders).length) add('hint', 'issue-missing', recordPath(r.name), id, `its ref names no item in ${folder}: ${JSON.stringify(String(t.ref))}`);
172
+ for (const it of found) named.push({ r, task, it });
173
+ }
174
+ }
175
+
176
+ // Each item once, with the ID of the first task that names it.
177
+ const first = new Map();
178
+ for (const n of named) if (!first.has(n.it)) first.set(n.it, n);
179
+ for (const [it, n] of first) {
180
+ const id = `${n.r.name}/${n.task}`;
181
+ const texts = [{ where: it.key, text: it.body }];
182
+ (Array.isArray(it.comments) ? it.comments : []).forEach((c, i) => {
183
+ if (isMap(c) && c.isMinimized !== true) texts.push({ where: `${it.key}/comment-${i + 1}`, text: c.body });
184
+ });
185
+ const other = own.repo && it.repository && slug(it.repository) !== slug(own.repo);
186
+ for (const { where, text } of texts) {
187
+ if (typeof text !== 'string' || !text) continue;
188
+ for (const c of cited(text)) add('hint', 'issue-superseded', `${where}:${c.line}`, id, citedMessage(c));
189
+ if (other) {
190
+ text.replace(/\r\n?/g, '\n').split('\n').forEach((l, i) => {
191
+ const refs = [...l.matchAll(BARE)].map((m) => `#${m[1]}`);
192
+ if (refs.length) add('hint', 'issue-bare-ref', `${where}:${i + 1}`, id, `${refs.join(', ')} in an item of ${it.repository} points to ${it.repository}, not to ${own.repo}: write ${refs.map((x) => `${own.repo}${x}`).join(', ')} if that is meant`);
193
+ });
194
+ }
195
+ for (const h of phraseHits(text, matchers)) add('hint', 'phrase', `${where}:${h.line}`, id, phraseMessage(h));
196
+ }
197
+ if (!own.repo) continue;
198
+ if (!it.repository) notKnown.push(`the issue-bare-ref check of ${it.key}: its repository is not known`);
199
+ }
200
+ if (!own.repo) notKnown.push(`the request's own repository, for #n and the issue-bare-ref check: ${own.unknown}`);
201
+
202
+ // Children (SP-10): once for each request and item.
203
+ const ownOf = new Map();
204
+ const ownItem = (r) => {
205
+ if (ownOf.has(r.name)) return ownOf.get(r.name);
206
+ let ids = null;
207
+ for (const s of Array.isArray(r.data?.sources) ? r.data.sources : []) {
208
+ const url = isMap(s) && typeof s.url === 'string' ? s.url : null;
209
+ if (!url) continue;
210
+ const k = keyOf(url);
211
+ const it = items.find((x) => (x.url && x.url === url) || (x.ref && holds(url, x.ref)) || (k && slug(x.key) === slug(k)));
212
+ if (it) ids = { keys: new Set([it.key, it.ref].filter(Boolean).map(slug)), urls: new Set([url, it.url].filter(Boolean)), ref: it.ref };
213
+ else if (k) ids = { keys: new Set([slug(k)]), urls: new Set([url]), ref: null };
214
+ if (ids) break;
215
+ }
216
+ ownOf.set(r.name, ids);
217
+ return ids;
218
+ };
219
+ const seen = new Set();
220
+ const noOwn = new Set();
221
+ for (const n of named) {
222
+ const pair = `${n.r.name} ${slug(n.it.key)}`;
223
+ if (seen.has(pair)) continue;
224
+ seen.add(pair);
225
+ const mine = ownItem(n.r);
226
+ if (!mine) {
227
+ noOwn.add(n.r.name);
228
+ continue;
229
+ }
230
+ const it = n.it;
231
+ if (mine.keys.has(slug(it.key)) || (it.ref && mine.keys.has(slug(it.ref)))) continue;
232
+ if (it.parent === undefined) {
233
+ notKnown.push(`whether ${it.key} is a child of the own item of ${n.r.name}: the item has no parent field`);
234
+ continue;
235
+ }
236
+ const p = it.parent;
237
+ // A parent's number is in the item's repository only when no url names its own.
238
+ const parent = isMap(p) ? [keyOf(p.url), typeof p.ref === 'string' ? p.ref : null,
239
+ !keyOf(p.url) && Number.isInteger(p.number) && it.repository ? `${it.repository}#${p.number}` : null].filter(Boolean) : [];
240
+ if (p !== null && !parent.length) {
241
+ notKnown.push(`whether ${it.key} is a child of the own item of ${n.r.name}: its parent names no item`);
242
+ continue;
243
+ }
244
+ // A parent's url names the own item when it is the own item's url or its source's, or holds its ref.
245
+ const url = isMap(p) && typeof p.url === 'string' ? p.url : null;
246
+ if (url && (mine.urls.has(url) || (mine.ref && holds(url, mine.ref)))) continue;
247
+ if (parent.some((x) => mine.keys.has(slug(x)))) continue;
248
+ add('hint', 'issue-not-sub', it.key, `${n.r.name}/${n.task}`, `is not a child of the request's own item (${[...mine.keys][0]})${parent.length ? `; its parent is ${parent[0]}` : ''}`);
249
+ }
250
+ for (const name of noOwn) notKnown.push(`whether the items of ${name} are children of its own item: no origin source of ${name} names an item`);
251
+
252
+ const others = items.filter((it) => !first.has(it)).length;
253
+ if (others) notKnown.push(`${others} item${others === 1 ? '' : 's'} in ${folder} that no task names, not checked`);
254
+ add('info', 'issues', '-', '-', `checked ${first.size} items for the tasks of the open requests, from ${folder}`);
255
+ return notKnown;
256
+ }
@@ -0,0 +1,78 @@
1
+ // Phrases for the review (issue-hints R2, design.md 3): the phrases and
2
+ // patterns that `phrases:` in .assuredloop/config.yaml lists. al check gives
3
+ // the hint `phrase` for each match in the spec docs, the ADRs and the
4
+ // Markdown files of each open request, but origin/; with --issues, in the
5
+ // issues too (issues.js). The list is empty by default.
6
+ import { read } from './base.js';
7
+ import { requestMarkdown } from './blocks.js';
8
+ import { SPEC } from './scope.js';
9
+ import { Fail } from './spec.js';
10
+
11
+ const NEXT = 'fix phrases: in .assuredloop/config.yaml';
12
+
13
+ // The matchers of the config: [{ entry, re }]. A bad list, entry or pattern
14
+ // is a refusal that names it (D6).
15
+ export function loadPhrases(config) {
16
+ const list = config.phrases;
17
+ if (list == null) return [];
18
+ if (!Array.isArray(list)) throw new Fail('.assuredloop/config.yaml: phrases: is not a list', NEXT);
19
+ return list.map((e, i) => {
20
+ const at = `phrases: entry ${i + 1}`;
21
+ if (typeof e === 'string') {
22
+ if (!e.trim()) throw new Fail(`.assuredloop/config.yaml: ${at} is an empty phrase`, NEXT);
23
+ // Ignoring case; a space matches any run of white space, a line break too.
24
+ const source = e.trim().split(/\s+/).map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('\\s+');
25
+ return { entry: e, re: new RegExp(source, 'gi') };
26
+ }
27
+ const pattern = e && typeof e === 'object' && !Array.isArray(e) ? e.pattern : undefined;
28
+ if (typeof pattern !== 'string') throw new Fail(`.assuredloop/config.yaml: ${at} is neither a phrase nor a mapping with a pattern`, NEXT);
29
+ if (!pattern) throw new Fail(`.assuredloop/config.yaml: ${at} is an empty pattern`, NEXT);
30
+ try {
31
+ return { entry: pattern, re: new RegExp(pattern, 'g') };
32
+ } catch (err) {
33
+ throw new Fail(`.assuredloop/config.yaml: ${at}, the pattern ${JSON.stringify(pattern)}, is not a valid regular expression: ${err.message}`, NEXT);
34
+ }
35
+ });
36
+ }
37
+
38
+ // Each match in `text`: [{ line, entry, match }], the line where it starts.
39
+ // A match of no characters is not counted.
40
+ export function phraseHits(text, matchers) {
41
+ const t = text.replace(/\r\n?/g, '\n');
42
+ const hits = [];
43
+ for (const { entry, re } of matchers) {
44
+ re.lastIndex = 0;
45
+ for (const m of t.matchAll(re)) {
46
+ if (!m[0]) continue;
47
+ hits.push({ line: t.slice(0, m.index).split('\n').length, entry, match: m[0] });
48
+ }
49
+ }
50
+ return hits.sort((a, b) => a.line - b.line);
51
+ }
52
+
53
+ export const phraseMessage = (h) => `matches ${JSON.stringify(h.entry)}: ${JSON.stringify(h.match.replace(/\s+/g, ' '))}`;
54
+
55
+ // The hints in the spec docs, the ADRs and the open requests' Markdown files.
56
+ export function phraseFindings(top, state, matchers, add) {
57
+ if (!matchers.length) return;
58
+ const files = [
59
+ ...(state.docs ?? []).filter((d) => d.scope === SPEC && typeof d.text === 'string').map((d) => [d.path, d.text]),
60
+ ...[...state.adrs.values()].map((a) => [a.file, a.text]),
61
+ ];
62
+ for (const r of state.requests.values()) {
63
+ if (!r.open) continue;
64
+ for (const f of requestMarkdown(top, r.dir)) {
65
+ if (f.unreadable) continue;
66
+ let t = null;
67
+ try {
68
+ t = read(top, `${r.dir}/${f}`);
69
+ } catch {
70
+ t = null;
71
+ }
72
+ if (typeof t === 'string') files.push([`${r.dir}/${f}`, t]);
73
+ }
74
+ }
75
+ for (const [file, text] of files) {
76
+ for (const h of phraseHits(text, matchers)) add('hint', 'phrase', `${file}:${h.line}`, '-', phraseMessage(h));
77
+ }
78
+ }
package/src/v4/scope.js CHANGED
@@ -44,6 +44,9 @@ const linked = (top, config) => [
44
44
  ...insideDocs(config).map((d) => String(d.file)).filter((f) => symlinkOn(top, clean(f))),
45
45
  ];
46
46
 
47
+ // The docs that config lists, in the repo, that a symlink reaches (#238).
48
+ export const linkedDocs = (top, config) => insideDocs(config).map((d) => clean(d.file)).filter((f) => symlinkOn(top, f));
49
+
47
50
  // Why config's root cannot be used, or null: the repository top is no spec root.
48
51
  export const rootProblem = (config) => (!outside(config.root) && ['.', ''].includes(clean(config.root))
49
52
  ? `.assuredloop/config.yaml: root: ${JSON.stringify(String(config.root))} is the repository top; give the spec folder, for example specs`
@@ -99,7 +102,16 @@ export function prefixOf(path, config) {
99
102
  // Every file of the working tree under `dir`, symlinks not followed.
100
103
  function walk(top, dir, out = []) {
101
104
  if (!existsSync(join(top, dir)) || !lstatSync(join(top, dir)).isDirectory()) return out;
102
- for (const e of readdirSync(join(top, dir), { withFileTypes: true })) {
105
+ let entries;
106
+ try {
107
+ entries = readdirSync(join(top, dir), { withFileTypes: true });
108
+ } catch (e) {
109
+ // A folder under requests/ that cannot be read: al check reports it, as
110
+ // an unreadable record or current place (#238).
111
+ if (dir.startsWith('requests/')) return out;
112
+ throw e;
113
+ }
114
+ for (const e of entries) {
103
115
  if (e.name === '.git' || e.name === 'node_modules') continue;
104
116
  const p = dir ? `${dir}/${e.name}` : e.name;
105
117
  if (e.isDirectory()) walk(top, p, out);
package/src/v4/state.js CHANGED
@@ -4,7 +4,7 @@
4
4
  import { readdirSync } from 'node:fs';
5
5
  import { join, posix } from 'node:path';
6
6
  import { parse } from 'yaml';
7
- import { read, recordPath, sha256 } from './base.js';
7
+ import { exists, read, recordPath, sha256 } from './base.js';
8
8
  import { loadConfig, loadSchema } from './config.js';
9
9
  import { fileAt, filesAt, git } from './git.js';
10
10
  import { KINDS, groupOf, parseMarkdown } from './markers.js';
@@ -67,7 +67,8 @@ export function loadState(top, rev = null) {
67
67
  ...adrFiles.map((f) => `${root}/adr/${f}`),
68
68
  ]) : null;
69
69
  const get = rev ? (p) => (atRev.has(p) ? atRev.get(p) : fileAt(top, rev, p)) : (p) => read(top, p);
70
- const { scopes, lints } = parseScopes(docsInScope(top, config, rev), kinds);
70
+ const docs = docsInScope(top, config, rev);
71
+ const { scopes, lints } = parseScopes(docs, kinds);
71
72
  const spec = new Map((scopes.get(SPEC) ?? []).map((p) => [p.id, p]));
72
73
 
73
74
  const requests = new Map();
@@ -78,20 +79,30 @@ export function loadState(top, rev = null) {
78
79
  // The record is read now and parsed on first use (design.md 11): a check
79
80
  // that does not need an archived record never parses it.
80
81
  let text = null;
82
+ let readError = null;
81
83
  try {
82
84
  text = get(recordPath(name));
83
- } catch {
85
+ } catch (e) {
84
86
  text = null;
87
+ readError = e.message.split('\n')[0];
85
88
  }
89
+ // read() gives null for a file it cannot read (EACCES, a folder): only a
90
+ // record that is not there at all has no error.
91
+ if (!rev && text == null && !readError && exists(top, recordPath(name))) readError = 'it is there, but it cannot be read as a file';
86
92
  let data;
93
+ let error = readError;
87
94
  const dataOf = () => {
88
95
  if (data !== undefined) return data;
89
96
  try {
90
97
  data = parse(text ?? 'null');
91
- } catch {
98
+ } catch (e) {
99
+ data = null;
100
+ error = e.message.split('\n')[0];
101
+ }
102
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
103
+ if (text !== null && text !== undefined && !error) error = 'not a mapping';
92
104
  data = null;
93
105
  }
94
- if (!data || typeof data !== 'object' || Array.isArray(data)) data = null;
95
106
  return data;
96
107
  };
97
108
  const specFile = `${dir}/spec.md`;
@@ -123,6 +134,8 @@ export function loadState(top, rev = null) {
123
134
  requests.set(name, {
124
135
  name, open, dir, md, specFile,
125
136
  get data() { return dataOf(); },
137
+ // Why a record that is there cannot be read, or null (#238).
138
+ get recordError() { dataOf(); return error; },
126
139
  get tier() { return tierOf(); },
127
140
  get spike() { return tierOf() === 'S' || /^##\s+Organized question\s*$/m.test(md); },
128
141
  // Whether the record's text names `x`, so that a reader parses only the
@@ -138,19 +151,27 @@ export function loadState(top, rev = null) {
138
151
  for (const n of archivedNames) if (!requests.has(n)) add(n, false);
139
152
 
140
153
  const adrs = new Map();
154
+ // The ADR files that are there but cannot be read (#238): R5 reports them.
155
+ const unreadableAdrs = [];
141
156
  for (const f of adrFiles) {
142
157
  const m = ADR_FILE.exec(f);
143
158
  if (!m) continue;
144
159
  const file = `${root}/adr/${f}`;
145
160
  const text = get(file);
146
- if (text === null || text === undefined) continue;
161
+ if (text === null || text === undefined) {
162
+ if (!rev && exists(top, file)) unreadableAdrs.push(file);
163
+ continue;
164
+ }
147
165
  const paragraphs = parseMarkdown(text, file, { kinds }).paragraphs;
148
166
  const id = `ADR-${Number(m[1])}`;
149
167
  const status = /^\s*Status:\s*([a-z]+)/i.exec(text)?.[1].toLowerCase() ?? null;
150
168
  const head = paragraphs.find((p) => p.id === id) ?? null;
151
- adrs.set(id, { id, file, status, body: adrBody(text), rest: head && adrRest(text, head), paragraphs, head });
169
+ // The ADR that its status line names: `Status: superseded by ADR-n` (#238).
170
+ const n = /^\s*Status:[ \t]*superseded[ \t]+by[ \t]+ADR-(\d+)[ \t]*\r?(?:\n|$)/i.exec(text)?.[1];
171
+ const by = n === undefined ? null : `ADR-${Number(n)}`;
172
+ adrs.set(id, { id, file, text, status, by, body: adrBody(text), rest: head && adrRest(text, head), paragraphs, head });
152
173
  }
153
- return { top, rev, config, kinds, root, get, scopes, lints, spec, requests, adrs };
174
+ return { top, rev, config, kinds, root, get, docs, scopes, lints, spec, requests, adrs, unreadableAdrs };
154
175
  }
155
176
 
156
177
  // The kind groups a check asks about.