@assuredloop/cli 0.2.4 → 2.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 +274 -21
- package/bin/al.js +8 -2
- package/package.json +1 -1
- package/skills/assuredloop/SKILL.md +40 -17
- package/src/v4/blocks.js +333 -0
- package/src/v4/check.js +37 -16
- package/src/v4/checks.js +71 -20
- package/src/v4/commands.js +3 -1
- package/src/v4/conclude.js +8 -4
- package/src/v4/indexer.js +121 -15
- package/src/v4/issues.js +297 -0
- package/src/v4/markers.js +63 -0
- package/src/v4/phrases.js +78 -0
- package/src/v4/scope.js +13 -1
- package/src/v4/state.js +36 -9
|
@@ -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,10 +4,10 @@
|
|
|
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
|
-
import { KINDS, groupOf, parseMarkdown } from './markers.js';
|
|
10
|
+
import { KINDS, groupOf, lf, parseMarkdown } from './markers.js';
|
|
11
11
|
import { frameOf, requirements, signedText } from './request-md.js';
|
|
12
12
|
import { SIGNED } from './snapshot.js';
|
|
13
13
|
import { SPEC, docsInScope, parseScopes, symlinkOn } from './scope.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
|
|
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,33 @@ 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)
|
|
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
|
-
|
|
169
|
+
// The status line that al knows: `Status: proposed`, `accepted` or
|
|
170
|
+
// `superseded by ADR-n` (#238), or by a list joined by commas and `and`,
|
|
171
|
+
// `ADR-11, ADR-12 and ADR-13` (#1). `by` is the ADRs that it names;
|
|
172
|
+
// `known` is whether it is the file's first line (#4). One parse gives
|
|
173
|
+
// both, so superseded-status and adr-status agree.
|
|
174
|
+
const s = /^(\s*)Status:[ \t]*(?:proposed|accepted|superseded[ \t]+by[ \t]+(ADR-\d+(?:(?:[ \t]*,[ \t]*(?:and[ \t]+)?|[ \t]+and[ \t]+)ADR-\d+)*))[ \t]*\r?(?:\n|$)/i.exec(text);
|
|
175
|
+
const by = [...new Set([...(s?.[2] ?? '').matchAll(/ADR-(\d+)/gi)].map((m) => `ADR-${Number(m[1])}`))];
|
|
176
|
+
const known = Boolean(s) && !/[\r\n]/.test(s[1]);
|
|
177
|
+
const marker = head && lf(text).split('\n')[head.line - 1];
|
|
178
|
+
adrs.set(id, { id, file, text, status, by, known, body: adrBody(text), rest: head && adrRest(text, head), paragraphs, head, marker });
|
|
152
179
|
}
|
|
153
|
-
return { top, rev, config, kinds, root, get, scopes, lints, spec, requests, adrs };
|
|
180
|
+
return { top, rev, config, kinds, root, get, docs, scopes, lints, spec, requests, adrs, unreadableAdrs };
|
|
154
181
|
}
|
|
155
182
|
|
|
156
183
|
// The kind groups a check asks about.
|