@assuredloop/cli 0.1.0 → 0.2.1

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,290 @@
1
+ // v4 markers (design.md 3, schema.md 3-4): each paragraph has a hidden marker
2
+ // on its own line before it, `<!-- INV-41 rule serves:R2 builds-on:INV-12 -->`,
3
+ // with a blank line before and after. parseMarkdown reads the paragraphs, their
4
+ // kinds, links, hashes and display numbers, and lints the markers of one doc.
5
+ import { createHash } from 'node:crypto';
6
+
7
+ // The kind groups of design.md 3. `approach` is design kept with its change.
8
+ export const KINDS = {
9
+ promise: ['purpose', 'scope', 'rule', 'limit', 'definition'],
10
+ design: ['component', 'interface', 'data', 'flow', 'choice', 'approach'],
11
+ informative: ['rationale', 'example', 'open'],
12
+ note: ['note'],
13
+ 'change-only': ['plan', 'step', 'migration'],
14
+ };
15
+
16
+ // The groups whose paragraphs need a link (design.md 3; approach included).
17
+ const NEEDS_LINK = ['promise', 'design'];
18
+
19
+ // The link words of a marker and the key of `links` each one fills.
20
+ export const LINK_WORDS = {
21
+ serves: 'serves', 'builds-on': 'buildsOn', changes: 'changes', removes: 'removes',
22
+ explains: 'explains', illustrates: 'illustrates', 'resolved-by': 'resolvedBy',
23
+ 'governed-by': 'governedBy', decides: 'decides', for: 'for', supersedes: 'supersedes', source: 'source',
24
+ };
25
+
26
+ export const groupOf = (kind, kinds = KINDS) => Object.keys(kinds).find((g) => kinds[g].includes(kind)) ?? null;
27
+
28
+ // An ID is PREFIX-n; an ADR's paragraphs below its heading take ADR-n-k (D4).
29
+ export const MARKER = /^<!--[ \t]+(ADR-[0-9]+-[0-9]+|[A-Z][A-Z0-9]*-[0-9]+)((?:[ \t]+\S+)*?)[ \t]+-->[ \t]*$/;
30
+ export const FENCE = /^ {0,3}(`{3,}|~{3,})/;
31
+ export const HEADING = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?[ \t]*$/;
32
+
33
+ // Line endings only: the rest of the text counts (design.md 5, Equality).
34
+ export const lf = (text) => text.replace(/\r\n?/g, '\n');
35
+ export const sha256 = (text) => createHash('sha256').update(lf(text)).digest('hex');
36
+
37
+ // A heading's text: no `#` marks, trimmed, a closing run of `#` removed.
38
+ const headingText = (raw) => (raw ?? '').replace(/(^|[ \t]+)#+$/, '').trim();
39
+
40
+ // A GFM table's delimiter row, such as `--- | ---` or `|:--|--:|`.
41
+ const DELIMITER = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/;
42
+
43
+ // The cells of a table row: one leading and one trailing pipe do not count,
44
+ // and an escaped pipe does not split a cell.
45
+ const cells = (row) => row.trim().replace(/^\|/, '').replace(/(?<!\\)\|$/, '').split(/(?<!\\)\|/).length;
46
+
47
+ // A table starts where a row is followed by a delimiter row with as many
48
+ // cells (GFM's tables extension).
49
+ const tableStart = (l, next) => l.includes('|') && next.includes('|') && DELIMITER.test(next) && cells(l) === cells(next);
50
+
51
+ // A line that belongs to the paragraph before it, also after a blank line:
52
+ // a list item, the start of a table, a block quote, an indented line or a
53
+ // fence.
54
+ export const continues = (l, next = '') => /^\s*([-*+]|\d+[.)])(\s|$)/.test(l) || tableStart(l, next)
55
+ || /^\s*>/.test(l) || /^\s+\S/.test(l) || FENCE.test(l);
56
+
57
+ // The blocks of a doc, each { first, marked, after }: `first` is the index of
58
+ // its first line, `marked` whether a marker comes right before it, `after`
59
+ // the ID of the last marker before it. Also the indexes of the marker lines.
60
+ // A heading line is a block of its own, and so is whatever follows it. A
61
+ // list, a table, a quote, an indented line or a fence belongs to the block
62
+ // before it, also after a blank line. Fenced code is not read for markers.
63
+ // parseMarkdown lints by these blocks and `al spec --add-ids` marks them.
64
+ export function blocksOf(lines) {
65
+ const blocks = [];
66
+ const markers = new Set();
67
+ let fence = null;
68
+ let blank = true;
69
+ let open = false;
70
+ let afterHeading = false;
71
+ let marked = false;
72
+ let after = null;
73
+ lines.forEach((l, i) => {
74
+ if (fence) {
75
+ const f = FENCE.exec(l);
76
+ if (f && f[1][0] === fence[0] && f[1].length >= fence.length && l.trim() === f[1]) fence = null;
77
+ return;
78
+ }
79
+ const m = MARKER.exec(l);
80
+ if (m) {
81
+ markers.add(i);
82
+ marked = true;
83
+ after = m[1];
84
+ open = false;
85
+ afterHeading = false;
86
+ blank = true;
87
+ return;
88
+ }
89
+ if (l.trim() === '') { blank = true; return; }
90
+ const heading = HEADING.test(l);
91
+ if (!open || heading || afterHeading || (blank && !continues(l, lines[i + 1]))) {
92
+ blocks.push({ first: i, marked, after });
93
+ marked = false;
94
+ open = true;
95
+ }
96
+ const f = FENCE.exec(l);
97
+ if (f) fence = f[1];
98
+ afterHeading = heading;
99
+ blank = false;
100
+ });
101
+ return { blocks, markers };
102
+ }
103
+
104
+ // `text` (LF line endings) with a marker before each block that has none: the
105
+ // ID from `nextId()`, kind note on a heading, with the blank lines a marker
106
+ // needs. Returns { text, marks }, marks being the IDs given, in file order.
107
+ export function markBlocks(text, nextId) {
108
+ const lines = text.split('\n');
109
+ const { blocks: all, markers } = blocksOf(lines);
110
+ const todo = all.filter((b) => !b.marked);
111
+ const out = [];
112
+ const marks = [];
113
+ let k = 0;
114
+ lines.forEach((l, i) => {
115
+ if (k < todo.length && todo[k].first === i) {
116
+ const id = nextId();
117
+ marks.push(id);
118
+ if (out.length && out.at(-1).trim() !== '') out.push('');
119
+ out.push(HEADING.test(l) ? `<!-- ${id} note -->` : `<!-- ${id} -->`, '');
120
+ k++;
121
+ } else if (markers.has(i) && out.length && out.at(-1).trim() !== '') {
122
+ out.push('');
123
+ }
124
+ out.push(l);
125
+ if (markers.has(i) && lines[i + 1] !== undefined && lines[i + 1].trim() !== '') out.push('');
126
+ });
127
+ return { text: out.join('\n'), marks };
128
+ }
129
+
130
+ // The IDs of `paragraphs` (one doc's, in file order) whose text is a block of
131
+ // `text` (that doc at another commit, marked or not) in the same order: the
132
+ // longest common subsequence of the two hash lists, as a diff keeps lines.
133
+ // So one block there stands for one paragraph here, and of two equal
134
+ // paragraphs only the one in the block's place matches (D16, D21, #196).
135
+ // A block whose ID is still a paragraph of the scope here (`living`, every
136
+ // doc of it, so a moved paragraph counts) is that paragraph's: it stands for
137
+ // no other one, and only the other blocks are matched.
138
+ export function sameBlocks(text, paragraphs, { file = '', living = new Set(paragraphs.map((p) => p.id)) } = {}) {
139
+ // The blocks with no marker get IDs of a prefix that the text does not hold.
140
+ let prefix = 'UNMARKED';
141
+ while (text.includes(`${prefix}-`)) prefix += 'X';
142
+ let n = 0;
143
+ const { text: marked, marks } = markBlocks(lf(text), () => `${prefix}-${++n}`);
144
+ const temporary = new Set(marks);
145
+ const there = parseMarkdown(marked, file).paragraphs;
146
+ const kept = new Set(there.filter((p) => !temporary.has(p.id) && living.has(p.id)).map((p) => p.id));
147
+ const a = there.filter((p) => !kept.has(p.id)).map((p) => p.sha256);
148
+ paragraphs = paragraphs.filter((p) => !kept.has(p.id));
149
+ const b = paragraphs.map((p) => p.sha256);
150
+ // len[i][j]: the length of the common subsequence of a[i..] and b[j..].
151
+ const len = Array.from({ length: a.length + 1 }, () => new Uint32Array(b.length + 1));
152
+ for (let i = a.length - 1; i >= 0; i--) {
153
+ for (let j = b.length - 1; j >= 0; j--) len[i][j] = a[i] === b[j] ? len[i + 1][j + 1] + 1 : Math.max(len[i + 1][j], len[i][j + 1]);
154
+ }
155
+ const same = new Set();
156
+ for (let i = 0, j = 0; i < a.length && j < b.length;) {
157
+ if (a[i] === b[j] && len[i][j] === len[i + 1][j + 1] + 1) { same.add(paragraphs[j].id); i++; j++; }
158
+ else if (len[i + 1][j] > len[i][j + 1]) i++;
159
+ else j++;
160
+ }
161
+ return same;
162
+ }
163
+
164
+ const emptyLinks = () => Object.fromEntries(Object.values(LINK_WORDS).map((k) => [k, []]));
165
+
166
+ export function parseMarkdown(text, file, options = {}) {
167
+ const kinds = options.kinds ?? KINDS;
168
+ const known = new Set(Object.values(kinds).flat());
169
+ const lines = lf(text).split('\n');
170
+ const paragraphs = [];
171
+ const lints = [];
172
+ const lint = (code, severity, id, line, message) => lints.push({ code, severity, id, file, line, message });
173
+
174
+ // Split into the text before the first marker and one block per marker,
175
+ // skipping markers inside fenced code.
176
+ const blocks = [];
177
+ let fence = null;
178
+ let cur = { marker: null, start: 0, lines: [] };
179
+ for (let i = 0; i < lines.length; i++) {
180
+ const l = lines[i];
181
+ const m = fence ? null : MARKER.exec(l);
182
+ if (m) {
183
+ blocks.push(cur);
184
+ cur = { marker: m, line: i + 1, start: i + 1, lines: [] };
185
+ continue;
186
+ }
187
+ const f = FENCE.exec(l);
188
+ if (f) {
189
+ if (!fence) fence = f[1];
190
+ else if (f[1][0] === fence[0] && f[1].length >= fence.length && l.trim() === f[1]) fence = null;
191
+ }
192
+ cur.lines.push({ text: l, n: i + 1 });
193
+ }
194
+ blocks.push(cur);
195
+
196
+ const [, ...marked] = blocks;
197
+ for (const b of blocksOf(lines).blocks) {
198
+ if (!b.marked) lint('no-id', 'not ok', null, b.first + 1, `a paragraph with no ID marker${b.after ? `, after ${b.after}` : ' before it'}`);
199
+ }
200
+
201
+ const seen = new Set();
202
+ for (const b of marked) {
203
+ const id = b.marker[1];
204
+ const n = b.line;
205
+ if (n > 1 && lines[n - 2].trim() !== '') lint('no-blank-before', 'not ok', id, n, `the marker of ${id} needs a blank line before it`);
206
+ // The empty string after a final newline is the end of the file, not a blank line.
207
+ if (n >= lines.length - 1 || lines[n].trim() !== '') lint('no-blank-after', 'not ok', id, n, `the marker of ${id} needs a blank line after it`);
208
+ if (seen.has(id)) lint('duplicate-id', 'not ok', id, n, `${id} is used by an earlier marker in this doc`);
209
+ seen.add(id);
210
+
211
+ let kind = null;
212
+ const links = emptyLinks();
213
+ for (const token of b.marker[2].trim().split(/[ \t]+/).filter(Boolean)) {
214
+ const c = token.indexOf(':');
215
+ if (c < 0) {
216
+ if (kind !== null) lint('unknown-kind', 'not ok', id, n, `${id} has a second kind "${token}"; a paragraph has one kind`);
217
+ else if (!known.has(token)) lint('unknown-kind', 'not ok', id, n, `${id}: "${token}" is not a kind`);
218
+ else kind = token;
219
+ continue;
220
+ }
221
+ const key = Object.hasOwn(LINK_WORDS, token.slice(0, c)) ? LINK_WORDS[token.slice(0, c)] : null;
222
+ if (!key) lint('unknown-link', 'not ok', id, n, `${id}: "${token.slice(0, c)}" is not a link word`);
223
+ else links[key].push(...token.slice(c + 1).split(',').filter(Boolean));
224
+ }
225
+ if (kind === null) lint('no-kind', 'hint', id, n, `${id} has no kind`);
226
+ if (kind && NEEDS_LINK.includes(groupOf(kind, kinds)) && Object.values(links).every((v) => !v.length)) {
227
+ lint('no-link', 'hint', id, n, `${id} is a ${kind} paragraph with no link`);
228
+ }
229
+
230
+ const body = b.lines.slice();
231
+ while (body.length && body[0].text.trim() === '') body.shift();
232
+ while (body.length && body.at(-1).text.trim() === '') body.pop();
233
+ const ptext = body.map((l) => l.text).join('\n');
234
+ const h = body.length ? HEADING.exec(body[0].text) : null;
235
+ paragraphs.push({
236
+ id, kind, links, text: ptext, sha256: sha256(ptext), line: n,
237
+ level: h ? h[1].length : 0, heading: h ? headingText(h[2]) : null, file,
238
+ });
239
+ }
240
+
241
+ number(paragraphs);
242
+ const headings = new Set();
243
+ for (const p of paragraphs) {
244
+ if (p.heading !== null) {
245
+ const key = JSON.stringify(p.headingPath);
246
+ if (headings.has(key)) lint('duplicate-heading', 'hint', p.id, p.line, `the heading "${p.heading}" is already used under the same parent`);
247
+ headings.add(key);
248
+ }
249
+ delete p.level;
250
+ delete p.heading;
251
+ }
252
+ return { paragraphs, lints };
253
+ }
254
+
255
+ // Display numbers and heading paths (design.md 3): a single leading H1 is the
256
+ // title and is not numbered; below it, H2 is 1, 2, ... Without it, H1 is 1.
257
+ function number(paragraphs) {
258
+ const heads = paragraphs.filter((p) => p.heading !== null);
259
+ const titled = heads.length > 0 && heads[0].level === 1 && !heads.slice(1).some((p) => p.level === 1);
260
+ const top = titled ? 2 : 1;
261
+ const title = titled ? heads[0].heading : null;
262
+ const counters = [];
263
+ const path = [];
264
+ let section = '0';
265
+ let where = title ?? 'the top';
266
+ let idx = 0;
267
+ for (const p of paragraphs) {
268
+ if (p.heading !== null) {
269
+ path.length = p.level - 1;
270
+ path[p.level - 1] = p.heading;
271
+ p.headingPath = path.filter((t) => t !== undefined);
272
+ if (titled && p === heads[0]) {
273
+ p.displayNumber = `${p.id} (0, ${p.heading})`;
274
+ } else {
275
+ const depth = p.level - top;
276
+ counters.length = depth + 1;
277
+ for (let k = 0; k <= depth; k++) counters[k] = counters[k] ?? 0;
278
+ counters[depth] += 1;
279
+ section = counters.join('.');
280
+ p.displayNumber = `${p.id} (${section}, ${p.heading})`;
281
+ }
282
+ where = p.heading;
283
+ idx = 0;
284
+ } else {
285
+ idx += 1;
286
+ p.headingPath = path.filter((t) => t !== undefined);
287
+ p.displayNumber = `${p.id} (${section}:${idx}, in ${where})`;
288
+ }
289
+ }
290
+ }
@@ -0,0 +1,66 @@
1
+ // The request record, .assuredloop/records/requests/<name>.yaml: read as a
2
+ // YAML document, so what an agent wrote by hand (comments, order, tasks,
3
+ // outputs) stays as it is, and the tool only appends or fills.
4
+ import { parseDocument, isMap, isSeq } from 'yaml';
5
+ import { Fail, read, recordPath } from './base.js';
6
+
7
+ export const YAML_OPTIONS = { lineWidth: 0 };
8
+
9
+ // The record of request `name`: { doc, path, data } or null when absent.
10
+ // A record that is not valid YAML is a refusal: the tool does not guess.
11
+ export function openRecord(top, name) {
12
+ const path = recordPath(name);
13
+ const text = read(top, path);
14
+ if (text === null) return null;
15
+ const doc = parseDocument(text);
16
+ if (doc.errors.length || !isMap(doc.contents)) {
17
+ throw Object.assign(new Fail(`${path} is not a valid record: ${doc.errors[0]?.message.split('\n')[0] ?? 'not a map'}`, `fix ${path} by hand`), { invalid: true });
18
+ }
19
+ return { doc, path, text, get data() { return doc.toJS() ?? {}; } };
20
+ }
21
+
22
+ export const recordText = (rec) => rec.doc.toString(YAML_OPTIONS);
23
+
24
+ // The list `key` of the record, made when missing.
25
+ export function seq(rec, key) {
26
+ let s = rec.doc.get(key);
27
+ if (!isSeq(s)) {
28
+ s = rec.doc.createNode([]);
29
+ rec.doc.set(key, s);
30
+ }
31
+ return s;
32
+ }
33
+
34
+ // Appends `value` to the list `key`; `flow` writes it on one line.
35
+ export function append(rec, key, value, flow = false) {
36
+ const node = rec.doc.createNode(value);
37
+ if (flow) node.flow = true;
38
+ const s = seq(rec, key);
39
+ s.flow = false;
40
+ s.items.push(node);
41
+ return node;
42
+ }
43
+
44
+ // The highest number `n` of the `<prefix><n>` IDs in list `key`, or 0.
45
+ export const highest = (rec, key, prefix) => Math.max(0, ...(rec.data[key] ?? [])
46
+ .map((e) => String(e?.id ?? '').match(new RegExp(`^${prefix}(\\d+)$`))?.[1]).filter(Boolean).map(Number));
47
+
48
+ // Appends a version for each requirement whose text hash is not its latest
49
+ // version's. Returns the versions appended.
50
+ export function addVersions(rec, reqs) {
51
+ const added = [];
52
+ const all = rec.data.requirements ?? [];
53
+ for (const r of reqs) {
54
+ const mine = all.filter((v) => v?.id === r.id);
55
+ const last = mine.reduce((a, v) => (Number(v.version) > Number(a?.version ?? 0) ? v : a), null);
56
+ if (last && last.sha256 === r.sha256) continue;
57
+ const v = { id: r.id, version: Number(last?.version ?? 0) + 1, sha256: r.sha256, title: r.title };
58
+ append(rec, 'requirements', v);
59
+ added.push(v);
60
+ }
61
+ return added;
62
+ }
63
+
64
+ // The latest version of requirement `id` in the record's data, or null.
65
+ export const latest = (data, id) => (data.requirements ?? []).filter((v) => v?.id === id)
66
+ .reduce((a, v) => (Number(v.version) > Number(a?.version ?? 0) ? v : a), null);