@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.
- package/README.md +263 -211
- package/bin/al.js +82 -64
- package/package.json +12 -1
- package/skills/assuredloop/SKILL.md +203 -44
- package/src/v4/base.js +80 -0
- package/src/v4/check.js +81 -0
- package/src/v4/checks.js +383 -0
- package/src/v4/commands.js +168 -0
- package/src/v4/conclude.js +119 -0
- package/src/v4/config.js +75 -0
- package/src/v4/dispositions.js +191 -0
- package/src/v4/export.js +469 -0
- package/src/v4/git.js +153 -0
- package/src/v4/ids.js +87 -0
- package/src/v4/indexer.js +361 -0
- package/src/v4/level2.js +154 -0
- package/src/v4/markers.js +290 -0
- package/src/v4/records.js +66 -0
- package/src/v4/repos.js +329 -0
- package/src/v4/request-md.js +108 -0
- package/src/v4/results.js +42 -0
- package/src/v4/scope.js +167 -0
- package/src/v4/search.js +431 -0
- package/src/v4/snapshot.js +20 -0
- package/src/v4/spec.js +148 -0
- package/src/v4/state.js +208 -0
- package/src/v4/views.js +480 -0
- package/src/v4/words.js +98 -0
- package/src/adrs.js +0 -110
- package/src/audit.js +0 -176
- package/src/check.js +0 -127
- package/src/commands.js +0 -456
- package/src/conclude.js +0 -293
- package/src/consolidate.js +0 -279
- package/src/git.js +0 -155
- package/src/hints.js +0 -433
- package/src/links.js +0 -181
- package/src/record-section.js +0 -126
- package/src/sections.js +0 -125
- package/src/signoff.js +0 -166
- package/src/snapshot.js +0 -63
- package/src/spec.js +0 -228
- package/src/states.js +0 -227
- package/src/tests.js +0 -267
- package/src/tree.js +0 -113
- package/src/views.js +0 -390
package/src/snapshot.js
DELETED
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
// Snapshots of originals ([REC-3]): a header, a line starting with `---`, then
|
|
2
|
-
// the text exactly as fetched. SHA-256 is of the bytes after that line.
|
|
3
|
-
import { createHash } from 'node:crypto';
|
|
4
|
-
|
|
5
|
-
export const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
6
|
-
|
|
7
|
-
// A snapshot's bytes; `signed` makes it a sign-off ([REC-5]): the owner's
|
|
8
|
-
// `words` after Source, and SIGNED as its separator.
|
|
9
|
-
export function formatSnapshot({ source, words, fetched, updated, text, signed = false }) {
|
|
10
|
-
const header = [`Source: ${source}`];
|
|
11
|
-
if (words !== undefined) header.push(`Owner's words: ${words}`);
|
|
12
|
-
header.push(`Fetched: ${fetched}`);
|
|
13
|
-
if (updated) header.push(`Target updated: ${updated}`);
|
|
14
|
-
header.push(`SHA-256: ${sha256(text)}${signed ? ' (of the signed text below)' : ''}`, signed ? SIGNED : '---', '');
|
|
15
|
-
return Buffer.concat([Buffer.from(header.join('\n')), Buffer.from(text)]);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
// What a file that is not a valid snapshot is said to lack.
|
|
19
|
-
export const notSnapshot = (what) => `${what} is not a valid snapshot: it needs Source, Fetched and SHA-256, then a --- line`;
|
|
20
|
-
|
|
21
|
-
// Every file in a request's origin/, whatever its name, each a snapshot
|
|
22
|
-
// ([REC-3]): { file, s }, s null when it is not a valid one. Every reader
|
|
23
|
-
// reads origin/ through this. A hidden file that is not a snapshot
|
|
24
|
-
// (.DS_Store, an editor's) is left out; a hidden snapshot counts.
|
|
25
|
-
export const snapshots = (tree, dir) => (tree.list(`${dir}/origin`) ?? [])
|
|
26
|
-
.map((file) => ({ file, s: parseSnapshot(tree.read(`${dir}/origin/${file}`) ?? Buffer.alloc(0)) }))
|
|
27
|
-
.filter(({ file, s }) => s || !file.startsWith('.'));
|
|
28
|
-
|
|
29
|
-
// The header fields, the separator line, the text, and whether the text still
|
|
30
|
-
// matches its hash. Null when the header lacks Source, Fetched or SHA-256, or
|
|
31
|
-
// the separator.
|
|
32
|
-
export function parseSnapshot(bytes) {
|
|
33
|
-
const fields = {};
|
|
34
|
-
let pos = 0;
|
|
35
|
-
while (pos < bytes.length) {
|
|
36
|
-
let end = bytes.indexOf(0x0a, pos);
|
|
37
|
-
if (end === -1) end = bytes.length;
|
|
38
|
-
const line = bytes.subarray(pos, end).toString('utf8').replace(/\r$/, '');
|
|
39
|
-
pos = end + 1;
|
|
40
|
-
if (line.startsWith('---')) {
|
|
41
|
-
const text = bytes.subarray(Math.min(pos, bytes.length));
|
|
42
|
-
const recorded = (fields['SHA-256'] || '').split(/\s/)[0].toLowerCase();
|
|
43
|
-
if (!recorded || !fields.Source || !fields.Fetched) return null;
|
|
44
|
-
return { fields, separator: line, text, recorded, intact: sha256(text) === recorded };
|
|
45
|
-
}
|
|
46
|
-
const m = line.match(/^([^:]+):\s?(.*)$/);
|
|
47
|
-
if (m) fields[m[1]] = m[2];
|
|
48
|
-
}
|
|
49
|
-
return null;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// A parsed snapshot is a sign-off ([REC-5]) only when its header ends with this
|
|
53
|
-
// line, as record signoff writes it; the marker quoted in a text is not one.
|
|
54
|
-
export const SIGNED = '--- signed text ---';
|
|
55
|
-
export const isSignoff = (s) => s?.separator === SIGNED;
|
|
56
|
-
|
|
57
|
-
// A file-name part from a source: `https://github.com/o/r/issues/31` gives
|
|
58
|
-
// `github-com-o-r-issues-31`.
|
|
59
|
-
export function slug(source) {
|
|
60
|
-
const s = source.toLowerCase().replace(/^[a-z]+:\/\//, '').replace(/[^a-z0-9]+/g, '-')
|
|
61
|
-
.slice(0, 50).replace(/^-+|-+$/g, '');
|
|
62
|
-
return s || 'source';
|
|
63
|
-
}
|
package/src/spec.js
DELETED
|
@@ -1,228 +0,0 @@
|
|
|
1
|
-
// al spec: the design as it stands ([VW-5]), and numbering headings ([SPC-2], [SPC-3]).
|
|
2
|
-
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
3
|
-
import { isAbsolute, join, posix, relative, resolve } from 'node:path';
|
|
4
|
-
import { Fail, HISTORY, gapFail, git, historyGap, isShallow } from './git.js';
|
|
5
|
-
import { openTree, noSymlinkOn } from './tree.js';
|
|
6
|
-
import { ID_TEXT, PREFIX_TEXT, parseSections, numberHeadings } from './sections.js';
|
|
7
|
-
import { line } from './commands.js';
|
|
8
|
-
import { hintLines } from './hints.js';
|
|
9
|
-
import { allBlocks, parseChange, stateText, statesOf } from './states.js';
|
|
10
|
-
import { sameSection } from './sections.js';
|
|
11
|
-
|
|
12
|
-
// The baseline root when .assuredloop names none ([SPC-1]).
|
|
13
|
-
export const ROOT = 'specs';
|
|
14
|
-
|
|
15
|
-
const PREFIX = new RegExp(`^${PREFIX_TEXT}$`);
|
|
16
|
-
const ID_OPEN = new RegExp(`\\[(${ID_TEXT})`, 'g');
|
|
17
|
-
|
|
18
|
-
// A path `.assuredloop` names must lie inside this repo: never absolute,
|
|
19
|
-
// through `..`, or through a symlink.
|
|
20
|
-
function inside(top, tree, at, key, raw) {
|
|
21
|
-
const path = raw.replace(/\/+$/, '');
|
|
22
|
-
const parts = path.split('/');
|
|
23
|
-
let ok = !isAbsolute(raw) && !parts.includes('..') && !parts.includes('.');
|
|
24
|
-
// No part of it may be a symlink, in the commit or the working tree.
|
|
25
|
-
if (ok) ok = at ? !tree.linkOn(path) : noSymlinkOn(top, path);
|
|
26
|
-
if (!ok) throw new Fail(`${key} must be inside this repo: ${raw} (from .assuredloop)`, `set \`${key}:\` in .assuredloop to a ${key === 'root' ? 'folder' : 'path'} in this repo`);
|
|
27
|
-
return path;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// `text` without the spaces and tabs at its ends, and nothing else. A loop:
|
|
31
|
-
// a regex that trims a long line's spaces takes quadratic time.
|
|
32
|
-
export function trimBlanks(text) {
|
|
33
|
-
let [a, b] = [0, text.length];
|
|
34
|
-
while (a < b && (text[a] === ' ' || text[a] === '\t')) a++;
|
|
35
|
-
while (b > a && (text[b - 1] === ' ' || text[b - 1] === '\t')) b--;
|
|
36
|
-
return text.slice(a, b);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// The values of the `<key>: <path>` lines of `.assuredloop`, as written.
|
|
40
|
-
const values = (tree, key) => [...(tree.read('.assuredloop')?.toString('utf8') ?? '').matchAll(new RegExp(`^${key}:(.*)$`, 'gm'))].map((m) => trimBlanks(m[1])).filter(Boolean);
|
|
41
|
-
|
|
42
|
-
// The checked paths of the `tests:`, `results:` or `adrs:` lines ([LNK-3], [LNK-4]).
|
|
43
|
-
export const configured = (top, tree, at, key) => values(tree, key).map((raw) => inside(top, tree, at, key, raw));
|
|
44
|
-
|
|
45
|
-
// The baseline root: `specs`, or `root: <path>` in `.assuredloop` ([SPC-1]).
|
|
46
|
-
// The baseline is on main, so the root must lie inside this repo. Every
|
|
47
|
-
// other path the file names is checked here too, so a bad one stops any command.
|
|
48
|
-
export function rootOf(top, tree, at) {
|
|
49
|
-
for (const key of ['tests', 'results', 'adrs']) configured(top, tree, at, key);
|
|
50
|
-
return inside(top, tree, at, 'root', values(tree, 'root')[0] ?? ROOT);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// A past tree's root, from its `root:` line only, or null when that line is
|
|
54
|
-
// not a folder inside this repo; the other lines are not checked, so one old
|
|
55
|
-
// bad line does not stop a read of history ([VW-7], [VW-8]).
|
|
56
|
-
export function rootLine(top, tree, at) {
|
|
57
|
-
try { return inside(top, tree, at, 'root', values(tree, 'root')[0] ?? ROOT); } catch { return null; }
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// A past tree's paths for `key`: only its lines that lie inside this repo, for
|
|
61
|
-
// the same reason ([VW-8]).
|
|
62
|
-
export const pastLines = (top, tree, at, key) => values(tree, key).flatMap((raw) => {
|
|
63
|
-
try { return [inside(top, tree, at, key, raw)]; } catch { return []; }
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
// Whether `path` names a baseline file: a .md file under the root, in plain form ([SPC-1]).
|
|
67
|
-
export const inBaseline = (path, root) => posix.normalize(path) === path && path.startsWith(`${root}/`) && path.endsWith('.md');
|
|
68
|
-
|
|
69
|
-
// The baseline's Markdown files, each with its sections.
|
|
70
|
-
export function baseline(tree, root) {
|
|
71
|
-
return tree.walk(root).filter((p) => p.endsWith('.md'))
|
|
72
|
-
.map((path) => ({ path, text: tree.read(path).toString('utf8') }))
|
|
73
|
-
.map((f) => ({ ...f, sections: parseSections(f.text) }));
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// Every ID found more than once in the root, with where: [[id, ['path:line', ...]]] ([SPC-3]).
|
|
77
|
-
export function duplicateIds(files) {
|
|
78
|
-
const where = new Map();
|
|
79
|
-
for (const f of files) {
|
|
80
|
-
for (const s of f.sections) if (s.id) where.set(s.id, [...(where.get(s.id) ?? []), `${f.path}:${s.line}`]);
|
|
81
|
-
}
|
|
82
|
-
return [...where].filter(([, at]) => at.length > 1);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// The `not ok` for an ID found more than once in the root ([SPC-3]); no request owns it.
|
|
86
|
-
export const duplicateHint = ([id, at]) => ({ kind: 'not ok', rank: 5, owners: [], counts: true, text: `duplicate ID [${id}] in ${at.join(' and ')}`, command: 'rename one copy by hand, then al spec --list' });
|
|
87
|
-
|
|
88
|
-
// Those hints as check words them, three at most; check --all shows the rest ([HNT-1]).
|
|
89
|
-
const duplicates = (files) => hintLines(duplicateIds(files).map(duplicateHint), 3, { more: 'al check --all' });
|
|
90
|
-
|
|
91
|
-
export function spec(ctx) {
|
|
92
|
-
const { top, opts } = ctx;
|
|
93
|
-
if (opts['add-ids'] !== undefined) return addIds(ctx);
|
|
94
|
-
const tree = openTree(top, opts.at);
|
|
95
|
-
const root = rootOf(top, tree, opts.at);
|
|
96
|
-
const files = baseline(tree, root);
|
|
97
|
-
const notKnown = [isShallow(top) && HISTORY, 'whether the code does what the spec says (tests and review judge that)'].filter(Boolean);
|
|
98
|
-
// [VW-5]: each open change under the section it holds; an add not in the
|
|
99
|
-
// baseline yet under the baseline section its chain of anchors starts
|
|
100
|
-
// from; the rest (a new file) after the text. With no baseline yet, the
|
|
101
|
-
// open changes still show.
|
|
102
|
-
const blocks = allBlocks(tree);
|
|
103
|
-
const held = statesOf(files, blocks);
|
|
104
|
-
const empty = `no baseline yet; requests add sections as they go (root: ${root}/)`;
|
|
105
|
-
if (!files.length && !held.length) return { tree, body: [empty], next: 'al new <name> --from <file|->', notKnown };
|
|
106
|
-
const ids = new Set(files.flatMap((f) => f.sections.map((s) => s.id)));
|
|
107
|
-
const placed = (e, n = 0) => {
|
|
108
|
-
const anchor = blocks.get(e.block).anchor;
|
|
109
|
-
const up = held.find((x) => x.request === e.request && x.id === anchor && !ids.has(anchor));
|
|
110
|
-
return ids.has(anchor) ? anchor : up && n < 50 ? placed(up, n + 1) : null;
|
|
111
|
-
};
|
|
112
|
-
const under = (id) => held.filter((e) => e.id === id || (!ids.has(e.id) && placed(e) === id));
|
|
113
|
-
// A remove of a file's first section, gone from it, shows first under that file.
|
|
114
|
-
const paths = new Set(files.map((f) => f.path));
|
|
115
|
-
const first = (path) => held.filter((e) => !ids.has(e.id) && !placed(e) && e.op === 'remove' && e.file === path);
|
|
116
|
-
const loose = held.filter((e) => !ids.has(e.id) && !placed(e) && !(e.op === 'remove' && paths.has(e.file)));
|
|
117
|
-
const overlay = (s, text, list = under(s.id)) => list.flatMap((e) => {
|
|
118
|
-
const now = blocks.get(e.block).now;
|
|
119
|
-
const same = now && e.id === s.id && sameSection(now, s.text);
|
|
120
|
-
return [`>> ${e.block} ${stateText(e)}${!text ? '' : !now ? ', removes it' : same ? ', now as above' : ', now:'}`,
|
|
121
|
-
...(text && now && !same ? now.replace(/\n+$/, '').split('\n').map((l) => ` ${l}`) : [])];
|
|
122
|
-
});
|
|
123
|
-
const body = files.length ? [] : [empty];
|
|
124
|
-
for (const f of files) {
|
|
125
|
-
body.push(line(body.length ? '' : 'Map', f.path), ...overlay({}, false, first(f.path)).map((l) => `${' '.repeat(14)}${l}`));
|
|
126
|
-
for (const s of f.sections) body.push(`${' '.repeat(12)}${s.id ? `[${s.id}] ` : ''}${s.title}`, ...overlay(s).map((l) => `${' '.repeat(14)}${l}`));
|
|
127
|
-
}
|
|
128
|
-
const into = [...new Set(loose.map((e) => e.file ?? 'no file named'))].map((p) => [p, loose.filter((e) => (e.file ?? 'no file named') === p)]);
|
|
129
|
-
for (const [p, list] of into) body.push(line('New', `${p} (not in the baseline yet)`), ...overlay({}, false, list).map((l) => `${' '.repeat(14)}${l}`));
|
|
130
|
-
if (files.length) body.push(line('Covers', files.map((f) => `${f.path} (${f.sections.length} sections)`).join(' · ')));
|
|
131
|
-
const hints = duplicates(files);
|
|
132
|
-
body.push(...hints);
|
|
133
|
-
if (!opts.list) {
|
|
134
|
-
for (const f of files) {
|
|
135
|
-
const preamble = f.text.slice(0, f.text.length - f.sections.reduce((n, s) => n + s.text.length, 0));
|
|
136
|
-
body.push('', `==> ${f.path}`, ...(preamble ? preamble.replace(/\n$/, '').split('\n') : []), ...overlay({}, true, first(f.path)));
|
|
137
|
-
for (const s of f.sections) body.push(...s.text.replace(/\n$/, '').split('\n'), ...overlay(s, true));
|
|
138
|
-
}
|
|
139
|
-
for (const [p, list] of into) body.push('', `==> ${p} (not in the baseline yet)`, ...overlay({}, true, list));
|
|
140
|
-
body.push('');
|
|
141
|
-
}
|
|
142
|
-
return {
|
|
143
|
-
tree,
|
|
144
|
-
body,
|
|
145
|
-
next: hints.length ? 'rename one copy of each duplicate ID by hand, to a new ID; an ID is never reused'
|
|
146
|
-
: opts.list ? 'al spec, for the text' : 'al spec --list, for the map only',
|
|
147
|
-
notKnown,
|
|
148
|
-
};
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// Every `[ID` ever used ([SPC-3]): in the root and every request's change.md
|
|
152
|
-
// now, and under the root and requests/ anywhere in the history of `rev`
|
|
153
|
-
// (every ref by default), each ID with the paths where it headed a section:
|
|
154
|
-
// a heading of a root .md file, out of code ([SPC-2]), or a block of a
|
|
155
|
-
// change.md as the states read it ([SPC-5]).
|
|
156
|
-
export function idsUsed(top, tree, root, rev = '--all') {
|
|
157
|
-
const used = new Map();
|
|
158
|
-
const note = (path, text) => {
|
|
159
|
-
for (const [, id] of text.matchAll(ID_OPEN)) if (!used.has(id)) used.set(id, new Set());
|
|
160
|
-
const headed = path.startsWith(`${root}/`) && path.endsWith('.md') ? parseSections(text).map((s) => s.id).filter(Boolean)
|
|
161
|
-
: /^requests\/(?:archive\/)?[^/]+\/change\.md$/.test(path) ? parseChange(text, '').map((b) => b.id) : [];
|
|
162
|
-
for (const id of headed) used.get(id).add(path);
|
|
163
|
-
};
|
|
164
|
-
for (const p of [...tree.walk(root).filter((x) => x.endsWith('.md')), ...tree.walk('requests').filter((x) => x.endsWith('/change.md'))]) note(p, tree.read(p).toString('utf8'));
|
|
165
|
-
// Each changed file whole, both sides, so headings are read with their
|
|
166
|
-
// fences; a merge against its first parent, so what only it brought counts.
|
|
167
|
-
const log = git(top, ['log', rev, '-p', '--diff-merges=first-parent', '--no-renames', '-U1000000', '--format=', '--', root, 'requests'], { allowFail: true }) ?? '';
|
|
168
|
-
let file = null;
|
|
169
|
-
const flush = () => { if (file?.path) { note(file.path, file.old.join('\n')); note(file.path, file.now.join('\n')); } };
|
|
170
|
-
for (const l of log.split('\n')) {
|
|
171
|
-
if (l.startsWith('diff --git ')) { flush(); file = { path: diffPath(l), old: [], now: [], hunk: false }; continue; }
|
|
172
|
-
if (!file) continue;
|
|
173
|
-
if (l.startsWith('@@')) file.hunk = true;
|
|
174
|
-
else if (file.hunk && (l[0] === ' ' || l[0] === '-')) file.old.push(l.slice(1));
|
|
175
|
-
if (file.hunk && (l[0] === ' ' || l[0] === '+')) file.now.push(l.slice(1));
|
|
176
|
-
}
|
|
177
|
-
flush();
|
|
178
|
-
return used;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// The path of a `diff --git` header under --no-renames, whose two sides name
|
|
182
|
-
// one path: `a/P b/P`, or each side C-quoted by git when P has bytes it quotes.
|
|
183
|
-
function diffPath(header) {
|
|
184
|
-
const rest = header.slice('diff --git '.length);
|
|
185
|
-
if (rest[0] !== '"') return rest.length % 2 ? rest.slice(2, 2 + (rest.length - 5) / 2) : null;
|
|
186
|
-
const bytes = [];
|
|
187
|
-
const ESC = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13, '"': 34, '\\': 92 };
|
|
188
|
-
for (let i = 1; i < rest.length && rest[i] !== '"'; i++) {
|
|
189
|
-
// A character git leaves as it is, whole: one outside the BMP is two code units.
|
|
190
|
-
if (rest[i] !== '\\') { const c = String.fromCodePoint(rest.codePointAt(i)); bytes.push(...Buffer.from(c)); i += c.length - 1; }
|
|
191
|
-
else if (/[0-7]/.test(rest[i + 1])) { bytes.push(parseInt(rest.slice(i + 1, i + 4), 8)); i += 3; }
|
|
192
|
-
else bytes.push(ESC[rest[++i]] ?? rest.charCodeAt(i));
|
|
193
|
-
}
|
|
194
|
-
const path = Buffer.from(bytes).toString('utf8');
|
|
195
|
-
return path.startsWith('a/') ? path.slice(2) : null;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// The next ID for `prefix`: one more than the highest n of any `[PREFIX-n` in `used` ([SPC-3]).
|
|
199
|
-
export const nextId = (used, prefix) => `${prefix}-${Math.max(0, ...[...used.keys()].filter((id) => id.split('-')[0] === prefix).map((id) => Number(id.split('-')[1].split('.')[0]))) + 1}`;
|
|
200
|
-
|
|
201
|
-
function addIds({ top, cwd, opts }) {
|
|
202
|
-
if (opts.at !== undefined) throw new Fail('--add-ids writes the working tree; it does not take --at', 'al spec --add-ids <file> --prefix <PREFIX>');
|
|
203
|
-
if (opts.prefix === undefined || !PREFIX.test(opts.prefix)) {
|
|
204
|
-
throw new Fail(`--prefix ${JSON.stringify(opts.prefix ?? '')}: give capital letters and digits, starting with a letter, e.g. INV`,
|
|
205
|
-
'al spec --add-ids <file> --prefix <PREFIX>');
|
|
206
|
-
}
|
|
207
|
-
const tree = openTree(top);
|
|
208
|
-
const root = rootOf(top, tree, opts.at);
|
|
209
|
-
const path = relative(top, resolve(cwd, opts['add-ids']));
|
|
210
|
-
if (!path.endsWith('.md') || !path.startsWith(`${root}/`) || !existsSync(join(top, path)) || !noSymlinkOn(top, path)) {
|
|
211
|
-
throw new Fail(`${opts['add-ids']}: give a .md file under the baseline root ${root}/, not through a symlink`, `al spec --list shows the files under ${root}/`);
|
|
212
|
-
}
|
|
213
|
-
const gap = historyGap(top);
|
|
214
|
-
if (gap) throw gapFail(gap, ", and IDs can't be allocated safely without the full history");
|
|
215
|
-
|
|
216
|
-
const before = readFileSync(join(top, path), 'utf8');
|
|
217
|
-
const first = Number(nextId(idsUsed(top, tree, root), opts.prefix).split('-')[1]);
|
|
218
|
-
const { text, numbered } = numberHeadings(before, opts.prefix, first);
|
|
219
|
-
const body = duplicates(baseline(tree, root));
|
|
220
|
-
const notKnown = ['IDs used on branches that were never fetched here'];
|
|
221
|
-
if (!numbered.length) {
|
|
222
|
-
body.unshift(`${path}: every heading already has an ID; nothing to number`);
|
|
223
|
-
return { body, next: `al spec --list`, notKnown };
|
|
224
|
-
}
|
|
225
|
-
if (opts.yes) writeFileSync(join(top, path), text);
|
|
226
|
-
body.unshift(`${opts.yes ? 'Numbered' : 'Would number'} ${numbered.length} heading(s) in ${path}:`, ...numbered.map((h) => ` ${h}`));
|
|
227
|
-
return { body, next: opts.yes ? `review the diff of ${path} and commit it` : 'run the same command with --yes to write it', notKnown };
|
|
228
|
-
}
|
package/src/states.js
DELETED
|
@@ -1,227 +0,0 @@
|
|
|
1
|
-
// The change spec ([SPC-5]) and the state of each held section: the link
|
|
2
|
-
// first ([STA-1]), then the content ([STA-2]), and whether a block retains
|
|
3
|
-
// nothing, shown positively ([STA-3]).
|
|
4
|
-
import { openTree, requestDirs } from './tree.js';
|
|
5
|
-
import { ID_TEXT, TITLES, closes, headingAt, parseSections, sameSection } from './sections.js';
|
|
6
|
-
import { rootOf, baseline } from './spec.js';
|
|
7
|
-
import { isDropped } from './signoff.js';
|
|
8
|
-
|
|
9
|
-
const HEAD = new RegExp(`^### \\[(${ID_TEXT})\\]@(\\d+)\\s*(.*)$`);
|
|
10
|
-
const FENCE = /^ {0,3}(`{3,}|~{3,})/;
|
|
11
|
-
|
|
12
|
-
// The Was: or Now: text after a label: a fenced block, or the indented lines
|
|
13
|
-
// (blank lines inside kept), with the indent removed.
|
|
14
|
-
export function takeText(lines, i) {
|
|
15
|
-
while (i < lines.length && !lines[i].trim()) i++;
|
|
16
|
-
const f = lines[i]?.match(FENCE);
|
|
17
|
-
if (f) {
|
|
18
|
-
const out = [];
|
|
19
|
-
for (i++; i < lines.length && !closes(lines[i], f[1]); i++) out.push(lines[i]);
|
|
20
|
-
return { text: out.join('\n') + '\n', next: i + 1 };
|
|
21
|
-
}
|
|
22
|
-
const out = [];
|
|
23
|
-
for (; i < lines.length && (!lines[i].trim() || /^( {4}|\t)/.test(lines[i])); i++) out.push(lines[i].replace(/^( {4}|\t)/, ''));
|
|
24
|
-
while (out.length && !out.at(-1).trim()) out.pop();
|
|
25
|
-
return { text: out.length ? out.join('\n') + '\n' : null, next: i };
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// The blocks of one change.md, each `<request>/<ID>@<n>`.
|
|
29
|
-
export function parseChange(text, request) {
|
|
30
|
-
const lines = text.replace(/\r\n/g, '\n').split('\n');
|
|
31
|
-
const start = headingAt(lines, TITLES.specChanges);
|
|
32
|
-
const blocks = [];
|
|
33
|
-
let b = null;
|
|
34
|
-
for (let i = start + 1; start >= 0 && i < lines.length;) {
|
|
35
|
-
const line = lines[i];
|
|
36
|
-
const h = line.match(HEAD);
|
|
37
|
-
if (h) {
|
|
38
|
-
const [, id, n, rest] = h;
|
|
39
|
-
const on = rest.match(/builds on (\S+)/)?.[1];
|
|
40
|
-
const marker = (name) => rest.match(new RegExp(`${name} (\\S+) \\((D\\d+)\\)`))?.slice(1, 3) ?? null;
|
|
41
|
-
b = {
|
|
42
|
-
request, id, n: Number(n), key: `${request}/${id}@${n}`,
|
|
43
|
-
// [SPC-5]'s ops, the whole token matched; any other is a fault (blockFault), never a remove.
|
|
44
|
-
op: OPS.get(rest.split(/\s+/)[0]) ?? null,
|
|
45
|
-
opWord: rest.split(/\s+/)[0] ?? '',
|
|
46
|
-
path: rest.match(/^add in (\S+)/)?.[1] ?? null,
|
|
47
|
-
anchor: rest.match(/^(?:add after|remove, was after) \[([^\]]+)\]/)?.[1] ?? null,
|
|
48
|
-
// A remove of a file's first section records the file instead ([SPC-5]).
|
|
49
|
-
firstIn: rest.match(/^remove, was first in (\S+)/)?.[1] ?? null,
|
|
50
|
-
base: on ? (on.startsWith('@') ? `${request}/${id}${on}` : on) : Number(n) > 1 ? `${request}/${id}@${Number(n) - 1}` : null,
|
|
51
|
-
dropped: marker('Dropped'), kept: marker('Kept'), revised: marker('Revised'),
|
|
52
|
-
forR: rest.match(/for (R\d+(?:,\s*R\d+)*)/)?.[1].split(/,\s*/) ?? [],
|
|
53
|
-
was: null, now: null,
|
|
54
|
-
};
|
|
55
|
-
blocks.push(b);
|
|
56
|
-
i++;
|
|
57
|
-
} else if (b && /^(Was|Now):\s*$/.test(line.trim())) {
|
|
58
|
-
const { text: t, next } = takeText(lines, i + 1);
|
|
59
|
-
b[line.trim().startsWith('Was') ? 'was' : 'now'] = t;
|
|
60
|
-
i = next;
|
|
61
|
-
} else if (/^##?\s/.test(line)) {
|
|
62
|
-
break;
|
|
63
|
-
} else {
|
|
64
|
-
i++;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
return blocks;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// The states in which a block's work is in the baseline: consolidate leaves it
|
|
71
|
-
// alone, and conclude holds it ([STA-2], [STA-7]).
|
|
72
|
-
export const CONSOLIDATED = ['consolidated', 'carried'];
|
|
73
|
-
|
|
74
|
-
// A state as the views say it: `waiting on <block>`, `carried by <block>`.
|
|
75
|
-
export const stateText = (e, by = e.by) => `${e.state}${by ? ` ${e.state === 'waiting' ? 'on' : 'by'} ${by}` : ''}`;
|
|
76
|
-
|
|
77
|
-
const same = (a, b) => a != null && b != null && sameSection(a, b);
|
|
78
|
-
// A section's body: the lines under its heading, trailing spaces and blank edge lines
|
|
79
|
-
// dropped ([SPC-4]); indentation is kept.
|
|
80
|
-
const body = (text) => text.replace(/\r\n/g, '\n').split('\n').slice(1).map((l) => l.replace(/[ \t]+$/, '')).join('\n').replace(/^\n+|\n+$/g, '');
|
|
81
|
-
|
|
82
|
-
// Every block of every request (archived ones too, for chains), with the
|
|
83
|
-
// request's status.
|
|
84
|
-
export function allBlocks(tree) {
|
|
85
|
-
const blocks = new Map();
|
|
86
|
-
for (const { dir, name, open } of requestDirs(tree)) {
|
|
87
|
-
const text = tree.read(`${dir}/change.md`)?.toString('utf8');
|
|
88
|
-
if (!text) continue;
|
|
89
|
-
const md = tree.read(`${dir}/request.md`)?.toString('utf8') ?? '';
|
|
90
|
-
const dropped = isDropped(md);
|
|
91
|
-
// A repeated request/ID@n is a fault (blockFault); the first is kept, and says so.
|
|
92
|
-
for (const b of withRepeats(parseChange(text, name))) blocks.set(b.key, { ...b, open, requestDropped: dropped });
|
|
93
|
-
}
|
|
94
|
-
return blocks;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// [SPC-5]: why a block can't be read as written, or null: its request/ID@n
|
|
98
|
-
// repeated, an op outside the grammar, a remove with no anchor, or Was:/Now:
|
|
99
|
-
// against its op. check says so; consolidate and conclude refuse.
|
|
100
|
-
export function blockFault(b) {
|
|
101
|
-
if (b.repeated) return `${b.key} appears ${b.repeated} times in change.md; each version of a section is one block ([SPC-5])`;
|
|
102
|
-
if (!b.op) return `${b.key}: "${b.opWord}" is not an op; use modify, add in <path>, add after [ID], remove, was after [ID], or remove, was first in <path> ([SPC-5])`;
|
|
103
|
-
if (b.op === 'add' && !b.path && !b.anchor) return `${b.key}: an add names neither "in <path>" nor "after [ID]" ([SPC-5])`;
|
|
104
|
-
if (b.op === 'remove' && !b.anchor && !b.firstIn) return `${b.key}: a remove names neither "was after [ID]" nor "was first in <path>" ([SPC-5])`;
|
|
105
|
-
if (b.op === 'add' && (b.was !== null || b.now === null)) return `${b.key}: an add has a Now: and no Was: ([SPC-5])`;
|
|
106
|
-
if (b.op === 'remove' && (b.now !== null || b.was === null)) return `${b.key}: a remove has a Was: and no Now: ([SPC-5])`;
|
|
107
|
-
if (b.op === 'modify' && (b.was === null || b.now === null)) return `${b.key}: a modify has both Was: and Now: ([SPC-5])`;
|
|
108
|
-
return null;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// [STA-4]: why a remove's `was after [X]` doesn't hold in `file` (the file
|
|
112
|
-
// holding its section, { path, sections }), or null. [X] must be the nearest
|
|
113
|
-
// section before it at its own level, or, for a first sub-section, its
|
|
114
|
-
// parent: where a revert puts it back. The revert reads which of the two from
|
|
115
|
-
// the Was's depth, so that depth must be on the same side of [X]'s as the
|
|
116
|
-
// section's own ([SPC-4] lets the two differ).
|
|
117
|
-
export function anchorFault(b, file) {
|
|
118
|
-
const i = file.sections.findIndex((s) => s.id === b.id);
|
|
119
|
-
if (i < 0) return null;
|
|
120
|
-
const name = (s) => (s.id ? `[${s.id}]` : `"${s.title}"`);
|
|
121
|
-
const level = file.sections[i].level;
|
|
122
|
-
const side = (s) => {
|
|
123
|
-
const was = b.was === null ? null : parseSections(b.was)[0];
|
|
124
|
-
if (!was || (was.level > s.level) === (level > s.level)) return null;
|
|
125
|
-
return `[${b.id}] is ${'#'.repeat(level)} in ${file.path} but its Was is headed ${'#'.repeat(was.level)}, so a revert would not put it back ${level > s.level ? 'first under' : 'after'} ${name(s)}; head the Was ${'#'.repeat(level)}`;
|
|
126
|
-
};
|
|
127
|
-
for (let j = i - 1; j >= 0; j--) {
|
|
128
|
-
const s = file.sections[j];
|
|
129
|
-
if (s.level < level) return s.id === b.anchor ? side(s) : `[${b.id}] is the first under ${name(s)}, not after [${b.anchor}], as its remove says`;
|
|
130
|
-
if (s.level === level) return s.id === b.anchor ? side(s) : `[${b.id}] stands after ${name(s)}, not [${b.anchor}], as its remove says`;
|
|
131
|
-
}
|
|
132
|
-
return `[${b.id}] is the first section of ${file.path}; write "remove, was first in ${file.path}"`;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// The op tokens of [SPC-5]: `modify`, `add in|after …`, `remove, was after …`.
|
|
136
|
-
// A Map, so no name an object inherits (constructor, toString…) reads as one.
|
|
137
|
-
const OPS = new Map([['modify', 'modify'], ['add', 'add'], ['remove,', 'remove'], ['remove', 'remove']]);
|
|
138
|
-
|
|
139
|
-
// Blocks with a repeated request/ID@n marked `repeated`, the first kept ([SPC-5]).
|
|
140
|
-
export function withRepeats(list) {
|
|
141
|
-
const seen = new Map();
|
|
142
|
-
for (const b of list) {
|
|
143
|
-
if (seen.has(b.key)) seen.get(b.key).repeated = (seen.get(b.key).repeated ?? 1) + 1;
|
|
144
|
-
else seen.set(b.key, b);
|
|
145
|
-
}
|
|
146
|
-
return [...seen.values()];
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// [STA-1]: the link of a block that builds on another, checked first.
|
|
150
|
-
function linkState(b, blocks) {
|
|
151
|
-
if (!b.base) return null;
|
|
152
|
-
const x = blocks.get(b.base);
|
|
153
|
-
if (!x) return 'broken link';
|
|
154
|
-
const seen = new Set([b.key]);
|
|
155
|
-
for (let cur = b; cur?.base; cur = blocks.get(cur.base)) {
|
|
156
|
-
if (seen.has(cur.base)) return 'broken link';
|
|
157
|
-
seen.add(cur.base);
|
|
158
|
-
}
|
|
159
|
-
if (!(b.was === null && x.now === null) && !same(b.was, x.now)) return 'broken link';
|
|
160
|
-
if ((x.dropped || x.requestDropped) && !x.kept) return 'base dropped';
|
|
161
|
-
if (x.revised && x.request !== b.request) return 'base revised';
|
|
162
|
-
return null;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// [STA-2]: the state of every open block, and [STA-3]: whether it retains nothing.
|
|
166
|
-
export function changeStates(top, { at } = {}) {
|
|
167
|
-
const tree = openTree(top, at);
|
|
168
|
-
return statesOf(baseline(tree, rootOf(top, tree, at)), allBlocks(tree));
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// The states of the blocks `include` picks (the open ones by default), read
|
|
172
|
-
// against `files`: the baseline's files, each with its sections.
|
|
173
|
-
export function statesOf(files, blocks, include = (b) => b.open) {
|
|
174
|
-
const sections = files.flatMap((f) => f.sections);
|
|
175
|
-
const byId = new Map();
|
|
176
|
-
const fileOf = new Map();
|
|
177
|
-
for (const f of files) {
|
|
178
|
-
for (const s of f.sections) if (s.id && !byId.has(s.id)) { byId.set(s.id, s.text); fileOf.set(s.id, f.path); }
|
|
179
|
-
}
|
|
180
|
-
const links = new Map([...blocks.values()].map((b) => [b.key, linkState(b, blocks)]));
|
|
181
|
-
const valid = (b) => b.base && links.get(b.key) === null;
|
|
182
|
-
// Blocks this one reaches upwards through valid links, nearest first.
|
|
183
|
-
const upward = (b) => {
|
|
184
|
-
const out = [];
|
|
185
|
-
for (let cur = b; valid(cur) && !out.includes(cur.base); cur = blocks.get(cur.base)) out.push(cur.base);
|
|
186
|
-
return out.map((k) => blocks.get(k));
|
|
187
|
-
};
|
|
188
|
-
|
|
189
|
-
// The file an add goes to: its own path, its anchor's file, or, for an
|
|
190
|
-
// anchor its request adds too, where that add goes; for a remove of a
|
|
191
|
-
// file's first section, that file.
|
|
192
|
-
const addedIn = (b, n = 0) => {
|
|
193
|
-
const anchor = [...blocks.values()].find((x) => x.request === b.request && x.id === b.anchor && x.op === 'add');
|
|
194
|
-
return b.path ?? b.firstIn ?? fileOf.get(b.anchor) ?? (anchor && n < 50 ? addedIn(anchor, n + 1) : null);
|
|
195
|
-
};
|
|
196
|
-
const result = [];
|
|
197
|
-
for (const b of blocks.values()) {
|
|
198
|
-
if (!include(b)) continue;
|
|
199
|
-
const base = byId.get(b.id);
|
|
200
|
-
const atWas = b.op === 'add' ? base === undefined : same(base, b.was);
|
|
201
|
-
const entry = (state, by = null, candidates = []) => ({
|
|
202
|
-
request: b.request, id: b.id, n: b.n, block: b.key, op: b.op, state, by, candidates, forR: b.forR,
|
|
203
|
-
mark: b.kept ? 'kept' : b.dropped ? 'dropped' : null, // the block's marker, for the views ([VW-2])
|
|
204
|
-
file: fileOf.get(b.id) ?? addedIn(b),
|
|
205
|
-
retainsNothing: same(b.was, b.now) || atWas || state === 'waiting',
|
|
206
|
-
});
|
|
207
|
-
const link = links.get(b.key);
|
|
208
|
-
if (link) { result.push(entry(link)); continue; }
|
|
209
|
-
if (same(b.was, b.now)) { result.push(entry('no change yet')); continue; }
|
|
210
|
-
if (b.op === 'remove' ? base === undefined : same(base, b.now)) { result.push(entry('consolidated')); continue; }
|
|
211
|
-
// [STA-2] row 3: a successor of the same section whose "now" is in the
|
|
212
|
-
// baseline; for a remove, as in row 2, the ID is absent.
|
|
213
|
-
const carriers = [...blocks.values()].filter((d) => d.key !== b.key && d.id === b.id && upward(d).includes(b)
|
|
214
|
-
&& (d.op === 'remove' ? base === undefined : same(base, d.now)));
|
|
215
|
-
if (carriers.length) { result.push({ ...entry('carried', carriers[0].key), carriers: carriers.map((d) => d.key) }); continue; }
|
|
216
|
-
const waitOn = upward(b).find((u) => (u.op === 'add' ? base === undefined : same(base, u.was)));
|
|
217
|
-
if (waitOn) { result.push(entry('waiting', waitOn.key)); continue; }
|
|
218
|
-
if (atWas) { result.push(entry('pending')); continue; }
|
|
219
|
-
if (base === undefined) {
|
|
220
|
-
const bodies = [b.now, b.was].filter(Boolean).map(body);
|
|
221
|
-
result.push(entry('not found', null, sections.filter((s) => s.id && bodies.includes(body(s.text))).map((s) => s.id)));
|
|
222
|
-
continue;
|
|
223
|
-
}
|
|
224
|
-
result.push(entry('differs'));
|
|
225
|
-
}
|
|
226
|
-
return result;
|
|
227
|
-
}
|