@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.
@@ -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,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 { 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,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) 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 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.