@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.
- package/README.md +147 -14
- package/bin/al.js +8 -2
- package/package.json +1 -1
- package/skills/assuredloop/SKILL.md +39 -13
- package/src/v4/blocks.js +321 -0
- package/src/v4/check.js +37 -16
- package/src/v4/checks.js +33 -9
- package/src/v4/commands.js +3 -1
- package/src/v4/conclude.js +8 -4
- package/src/v4/indexer.js +4 -10
- package/src/v4/issues.js +256 -0
- package/src/v4/phrases.js +78 -0
- package/src/v4/scope.js +13 -1
- package/src/v4/state.js +34 -8
- package/src/v4/views.js +5 -2
package/src/v4/issues.js
ADDED
|
@@ -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
|
-
|
|
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';
|
|
@@ -33,6 +33,10 @@ function names(top, rev, dir, folders) {
|
|
|
33
33
|
// The text after an ADR's Status line: what must not change once it is accepted.
|
|
34
34
|
const adrBody = (text) => text.replace(/\r\n?/g, '\n').replace(/^\s*Status:[^\n]*\n?/, '').replace(/^\n+/, '');
|
|
35
35
|
|
|
36
|
+
// The same with the line of the heading marker left out: what must not change
|
|
37
|
+
// when links are added to that marker (schema.md 5).
|
|
38
|
+
const adrRest = (text, head) => adrBody(text.replace(/\r\n?/g, '\n').split('\n').map((l, i) => (i === head.line - 1 ? '<!-- -->' : l)).join('\n'));
|
|
39
|
+
|
|
36
40
|
// The settings of a commit: its own config.yaml and schema.yaml, with the
|
|
37
41
|
// defaults of config.js when a file is absent or not a mapping.
|
|
38
42
|
export function settingsAt(top, rev) {
|
|
@@ -63,7 +67,8 @@ export function loadState(top, rev = null) {
|
|
|
63
67
|
...adrFiles.map((f) => `${root}/adr/${f}`),
|
|
64
68
|
]) : null;
|
|
65
69
|
const get = rev ? (p) => (atRev.has(p) ? atRev.get(p) : fileAt(top, rev, p)) : (p) => read(top, p);
|
|
66
|
-
const
|
|
70
|
+
const docs = docsInScope(top, config, rev);
|
|
71
|
+
const { scopes, lints } = parseScopes(docs, kinds);
|
|
67
72
|
const spec = new Map((scopes.get(SPEC) ?? []).map((p) => [p.id, p]));
|
|
68
73
|
|
|
69
74
|
const requests = new Map();
|
|
@@ -74,20 +79,30 @@ export function loadState(top, rev = null) {
|
|
|
74
79
|
// The record is read now and parsed on first use (design.md 11): a check
|
|
75
80
|
// that does not need an archived record never parses it.
|
|
76
81
|
let text = null;
|
|
82
|
+
let readError = null;
|
|
77
83
|
try {
|
|
78
84
|
text = get(recordPath(name));
|
|
79
|
-
} catch {
|
|
85
|
+
} catch (e) {
|
|
80
86
|
text = null;
|
|
87
|
+
readError = e.message.split('\n')[0];
|
|
81
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';
|
|
82
92
|
let data;
|
|
93
|
+
let error = readError;
|
|
83
94
|
const dataOf = () => {
|
|
84
95
|
if (data !== undefined) return data;
|
|
85
96
|
try {
|
|
86
97
|
data = parse(text ?? 'null');
|
|
87
|
-
} 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';
|
|
88
104
|
data = null;
|
|
89
105
|
}
|
|
90
|
-
if (!data || typeof data !== 'object' || Array.isArray(data)) data = null;
|
|
91
106
|
return data;
|
|
92
107
|
};
|
|
93
108
|
const specFile = `${dir}/spec.md`;
|
|
@@ -119,6 +134,8 @@ export function loadState(top, rev = null) {
|
|
|
119
134
|
requests.set(name, {
|
|
120
135
|
name, open, dir, md, specFile,
|
|
121
136
|
get data() { return dataOf(); },
|
|
137
|
+
// Why a record that is there cannot be read, or null (#238).
|
|
138
|
+
get recordError() { dataOf(); return error; },
|
|
122
139
|
get tier() { return tierOf(); },
|
|
123
140
|
get spike() { return tierOf() === 'S' || /^##\s+Organized question\s*$/m.test(md); },
|
|
124
141
|
// Whether the record's text names `x`, so that a reader parses only the
|
|
@@ -134,18 +151,27 @@ export function loadState(top, rev = null) {
|
|
|
134
151
|
for (const n of archivedNames) if (!requests.has(n)) add(n, false);
|
|
135
152
|
|
|
136
153
|
const adrs = new Map();
|
|
154
|
+
// The ADR files that are there but cannot be read (#238): R5 reports them.
|
|
155
|
+
const unreadableAdrs = [];
|
|
137
156
|
for (const f of adrFiles) {
|
|
138
157
|
const m = ADR_FILE.exec(f);
|
|
139
158
|
if (!m) continue;
|
|
140
159
|
const file = `${root}/adr/${f}`;
|
|
141
160
|
const text = get(file);
|
|
142
|
-
if (text === null || text === undefined)
|
|
161
|
+
if (text === null || text === undefined) {
|
|
162
|
+
if (!rev && exists(top, file)) unreadableAdrs.push(file);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
143
165
|
const paragraphs = parseMarkdown(text, file, { kinds }).paragraphs;
|
|
144
166
|
const id = `ADR-${Number(m[1])}`;
|
|
145
167
|
const status = /^\s*Status:\s*([a-z]+)/i.exec(text)?.[1].toLowerCase() ?? null;
|
|
146
|
-
|
|
168
|
+
const head = paragraphs.find((p) => p.id === id) ?? null;
|
|
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 });
|
|
147
173
|
}
|
|
148
|
-
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 };
|
|
149
175
|
}
|
|
150
176
|
|
|
151
177
|
// The kind groups a check asks about.
|
package/src/v4/views.js
CHANGED
|
@@ -419,6 +419,9 @@ function diffView(top, range, review) {
|
|
|
419
419
|
const now = loadState(top, headSha);
|
|
420
420
|
const was = loadState(top, base);
|
|
421
421
|
const findings = review ? checks(top, { base }).findings : [];
|
|
422
|
+
// The ADRs with no source link, which the view marks where it names them (schema.md 5).
|
|
423
|
+
const unsourced = new Set([...now.adrs.values()].filter((a) => !a.head?.links.source.length).map((a) => a.id));
|
|
424
|
+
const marks = (message) => [...new Set(message.match(/\bADR-\d+\b/g) ?? [])].filter((id) => unsourced.has(id)).map((id) => `; ${id} has no source:`).join('');
|
|
422
425
|
const body = [];
|
|
423
426
|
for (const scope of new Set([...now.scopes.keys(), ...was.scopes.keys()])) {
|
|
424
427
|
const request = requestOf(scope);
|
|
@@ -451,10 +454,10 @@ function diffView(top, range, review) {
|
|
|
451
454
|
}
|
|
452
455
|
if (!request) {
|
|
453
456
|
for (const a of now.adrs.values()) {
|
|
454
|
-
if ((a.head?.links.decides ?? []).includes(c.id) || p.links.governedBy.includes(a.id)) body.push(` governed by ${a.id} (${a.status ?? 'no status'})`);
|
|
457
|
+
if ((a.head?.links.decides ?? []).includes(c.id) || p.links.governedBy.includes(a.id)) body.push(` governed by ${a.id} (${a.status ?? 'no status'}${unsourced.has(a.id) ? '; no source:' : ''})`);
|
|
455
458
|
}
|
|
456
459
|
}
|
|
457
|
-
for (const f of findings.filter((x) => x.id === who && x.severity !== 'info')) body.push(` ${findingLine(f)}`);
|
|
460
|
+
for (const f of findings.filter((x) => x.id === who && x.severity !== 'info')) body.push(` ${findingLine(f)}${marks(f.message)}`);
|
|
458
461
|
}
|
|
459
462
|
}
|
|
460
463
|
if (!body.length) body.push('no paragraph changed in the range');
|