@assuredloop/cli 0.0.1 → 0.2.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/LICENSE +21 -0
- package/README.md +311 -4
- package/bin/al.js +129 -0
- package/package.json +31 -4
- package/skills/assuredloop/SKILL.md +207 -0
- 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/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 +443 -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/v4/export.js
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
// al export (design.md 11): one JSONL row per version of each paragraph,
|
|
2
|
+
// requirement, decision, snapshot, result and declared output, read from the
|
|
3
|
+
// committed files only, with the row's role at the selected commit. Every
|
|
4
|
+
// version comes from the first-parent history up to that commit, so removed
|
|
5
|
+
// and replaced text stays findable as `history`.
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { basename, posix } from 'node:path';
|
|
8
|
+
import { parse } from 'yaml';
|
|
9
|
+
import { parseMarkdown } from './markers.js';
|
|
10
|
+
import { requirements } from './request-md.js';
|
|
11
|
+
import { git, readObjects } from './git.js';
|
|
12
|
+
import { findCites, namedIds, outputRepos, repoConfigAt } from './repos.js';
|
|
13
|
+
import { SPEC, scopeOf } from './scope.js';
|
|
14
|
+
import { Fail, guard, write } from './base.js';
|
|
15
|
+
|
|
16
|
+
const OUTPUT_LINKS = ['implements', 'verifies', 'documents'];
|
|
17
|
+
const sha256 = (text) => createHash('sha256').update(text).digest('hex');
|
|
18
|
+
const REQUEST_FILE = /^requests\/(archive\/)?([^/]+)\/(spec\.md|request\.md|origin\/([^/]+))$/;
|
|
19
|
+
|
|
20
|
+
// The commit `at` (default HEAD) as a full hash; a refusal when there is none.
|
|
21
|
+
export function resolveCommit(top, at) {
|
|
22
|
+
const c = git(top, ['rev-parse', '--verify', '--quiet', `${at ?? 'HEAD'}^{commit}`], { allowFail: true });
|
|
23
|
+
if (!c) throw new Fail(at ? `no commit ${at}` : 'no commit yet: the export reads committed files only', at ? 'name a commit with --at <commit>' : 'commit the docs and records, then run it again');
|
|
24
|
+
return c;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const yamlAt = (text) => {
|
|
28
|
+
try { const v = parse(text ?? 'null'); return v && typeof v === 'object' && !Array.isArray(v) ? v : null; } catch { return null; }
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// The text of each blob (or `<commit>:<path>`) that is there; with `raw`, its bytes.
|
|
32
|
+
function readBlobs(top, blobs, raw = false) {
|
|
33
|
+
return new Map([...readObjects(top, blobs)].map(([b, bytes]) => [b, raw ? bytes : bytes.toString('utf8')]));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The files of `commit`: Map path -> blob.
|
|
37
|
+
function treeOf(top, commit) {
|
|
38
|
+
const out = git(top, ['ls-tree', '-r', '-z', '--full-tree', commit]) ?? '';
|
|
39
|
+
const tree = new Map();
|
|
40
|
+
for (const e of out.split('\0').filter(Boolean)) {
|
|
41
|
+
const tab = e.indexOf('\t');
|
|
42
|
+
const [, type, blob] = e.slice(0, tab).split(' ');
|
|
43
|
+
if (type === 'blob') tree.set(e.slice(tab + 1), blob);
|
|
44
|
+
}
|
|
45
|
+
return tree;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The changes of each first-parent commit up to `commit`, oldest first:
|
|
49
|
+
// [{ commit, changes: [{ path, blob|null }] }]. A merge counts as one change
|
|
50
|
+
// against its first parent.
|
|
51
|
+
function walk(top, commit, paths) {
|
|
52
|
+
const out = git(top, ['log', '--first-parent', '--diff-merges=first-parent', '--reverse', '--raw', '-z', '--no-abbrev', '--no-renames', '--format=%x01%H', commit, '--', ...paths]) ?? '';
|
|
53
|
+
const commits = [];
|
|
54
|
+
const fields = out.split('\0');
|
|
55
|
+
for (let i = 0; i < fields.length; i++) {
|
|
56
|
+
const f = fields[i].replace(/^\n+/, '');
|
|
57
|
+
if (f.startsWith('\x01')) commits.push({ commit: f.slice(1), changes: [] });
|
|
58
|
+
else if (f.startsWith(':') && commits.length) {
|
|
59
|
+
const blob = f.split(' ')[3];
|
|
60
|
+
commits.at(-1).changes.push({ path: fields[++i], blob: /^0+$/.test(blob) ? null : blob });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return commits;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// A link target as written, made exact in its doc (as al index does).
|
|
67
|
+
function qualify(ref, request, own) {
|
|
68
|
+
if (!request || ref.includes(':') || ref.includes('/')) return ref;
|
|
69
|
+
if (/^[RQDS]\d+$/.test(ref) || own.has(ref)) return `${request}/${ref}`;
|
|
70
|
+
return ref;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// What a path holds: { type, doc, request?, archived? }, or null.
|
|
74
|
+
function kindOfPath(path, config, extra) {
|
|
75
|
+
const r = REQUEST_FILE.exec(path);
|
|
76
|
+
if (r) {
|
|
77
|
+
const [, archived, request, part, origin] = r;
|
|
78
|
+
const type = part === 'spec.md' ? 'change' : part === 'request.md' ? 'requirements' : 'origin';
|
|
79
|
+
return { type, doc: request, request, archived: Boolean(archived), file: origin };
|
|
80
|
+
}
|
|
81
|
+
const adr = `${config.root}/adr/`;
|
|
82
|
+
if (path.startsWith(adr) && /^\d{4}-[^/]*\.md$/.test(path.slice(adr.length))) return { type: 'adr', doc: path };
|
|
83
|
+
if (scopeOf(path, config) === SPEC) return { type: 'spec', doc: path };
|
|
84
|
+
const rec = /^\.assuredloop\/records\/requests\/([^/]+)\.yaml$/.exec(path);
|
|
85
|
+
if (rec) return { type: 'record', doc: rec[1], request: rec[1] };
|
|
86
|
+
if (path.startsWith(`${config.results}/`) && path.endsWith('.yaml')) return { type: 'result', doc: path };
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The items one version of a file holds: [{ doc, id, sha256, row }].
|
|
91
|
+
function itemsOf(path, text, what, bytes = null) {
|
|
92
|
+
if (what.type === 'spec' || what.type === 'adr' || what.type === 'change') {
|
|
93
|
+
const paragraphs = parseMarkdown(text, path).paragraphs.filter((p) => p.id);
|
|
94
|
+
const title = paragraphs.find((p) => /^#\s/.test(p.text))?.text.replace(/^#\s+/, '').split('\n')[0].trim() ?? null;
|
|
95
|
+
const own = new Set(paragraphs.map((p) => p.id));
|
|
96
|
+
const req = what.type === 'change' ? what.request : null;
|
|
97
|
+
return paragraphs.map((p) => ({
|
|
98
|
+
doc: what.doc,
|
|
99
|
+
id: req ? `${req}/${p.id}` : p.id,
|
|
100
|
+
sha256: p.sha256,
|
|
101
|
+
row: {
|
|
102
|
+
source_type: what.type, file: path, line: p.line ?? null, heading_path: p.headingPath ?? [], kind: p.kind ?? null, title,
|
|
103
|
+
serves: (p.links?.serves ?? []).map((x) => qualify(x, req, own)),
|
|
104
|
+
builds_on: (p.links?.buildsOn ?? []).map((x) => qualify(x, req, own)),
|
|
105
|
+
changes: (p.links?.changes ?? []).map((x) => qualify(x, req, own)),
|
|
106
|
+
text: p.text,
|
|
107
|
+
},
|
|
108
|
+
}));
|
|
109
|
+
}
|
|
110
|
+
if (what.type === 'requirements') {
|
|
111
|
+
return requirements(text).map((q) => ({
|
|
112
|
+
doc: what.doc, id: `${what.request}/${q.id}`, sha256: q.sha256,
|
|
113
|
+
row: { source_type: q.id.startsWith('Q') ? 'question' : 'requirement', file: path, line: null, heading_path: [], text: q.text },
|
|
114
|
+
}));
|
|
115
|
+
}
|
|
116
|
+
if (what.type === 'record') {
|
|
117
|
+
const data = yamlAt(text);
|
|
118
|
+
return (data?.decisions ?? []).filter((d) => d?.id).map((d) => {
|
|
119
|
+
const body = String(d.text ?? d.summary ?? '');
|
|
120
|
+
return { doc: what.doc, id: `${what.request}/${d.id}`, sha256: sha256(body), row: { source_type: 'decision', file: path, line: null, heading_path: [], text: body } };
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
// An output file (D17): its version (the hash of its bytes), and only the
|
|
124
|
+
// lines that name an ID.
|
|
125
|
+
if (what.type === 'output') {
|
|
126
|
+
const lines = text.split('\n').map((l, i) => ({ n: i + 1, text: l, ids: namedIds(l, what.cite) })).filter((l) => l.ids.length);
|
|
127
|
+
return [{ doc: path, id: path, sha256: sha256(bytes ?? text), row: { source_type: 'output', file: path, line: null, heading_path: [], lines } }];
|
|
128
|
+
}
|
|
129
|
+
// A snapshot or a result: the whole file is one item.
|
|
130
|
+
const id = what.type === 'origin' ? `${what.request}/${what.file}` : path;
|
|
131
|
+
const type = what.type === 'origin' ? 'owner-words' : what.type;
|
|
132
|
+
return [{ doc: what.doc, id, sha256: sha256(text), row: { source_type: type, file: path, line: null, heading_path: [], text } }];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The settings of the commit: the spec root, the docs, the repo name and the results folder.
|
|
136
|
+
function configAt(top, tree, blobs) {
|
|
137
|
+
const c = yamlAt(blobs.get(tree.get('.assuredloop/config.yaml'))) ?? {};
|
|
138
|
+
const clean = (p) => String(p).replace(/^\.\//, '').replace(/\/+$/, '');
|
|
139
|
+
return {
|
|
140
|
+
...c,
|
|
141
|
+
root: clean(c.root ?? 'specs'),
|
|
142
|
+
docs: Array.isArray(c.docs) ? c.docs : [],
|
|
143
|
+
results: clean(c.results ?? '.assuredloop/results'),
|
|
144
|
+
repo: typeof c.repo === 'string' && c.repo ? c.repo : basename(top),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const startsWith = (full, short) => typeof short === 'string' && short.length >= 7 && String(full).startsWith(short);
|
|
149
|
+
|
|
150
|
+
// The records of the requests at the commit, by name, and the declared
|
|
151
|
+
// output files of this repo with the requests that declare them.
|
|
152
|
+
function recordsAt(top, tree, config) {
|
|
153
|
+
const recordBlobs = readBlobs(top, [...tree].filter(([p]) => /^\.assuredloop\/records\/requests\/[^/]+\.yaml$/.test(p)).map(([, b]) => b));
|
|
154
|
+
const records = new Map();
|
|
155
|
+
for (const [p, b] of tree) {
|
|
156
|
+
const m = /^\.assuredloop\/records\/requests\/([^/]+)\.yaml$/.exec(p);
|
|
157
|
+
if (m) records.set(m[1], yamlAt(recordBlobs.get(b)) ?? {});
|
|
158
|
+
}
|
|
159
|
+
const outputs = new Map();
|
|
160
|
+
for (const [name, rec] of records) {
|
|
161
|
+
for (const o of Array.isArray(rec.outputs) ? rec.outputs : []) {
|
|
162
|
+
if (!o?.file || (o.repo && o.repo !== config.repo)) continue;
|
|
163
|
+
if (!outputs.has(o.file)) outputs.set(o.file, new Set());
|
|
164
|
+
outputs.get(o.file).add(name);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { records, outputs };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// What the history walk knows: every version of every item, and the items
|
|
171
|
+
// each path holds at `last`. `spec` names the settings it was walked with; a
|
|
172
|
+
// walk with other settings starts again from the first commit.
|
|
173
|
+
export const newHistory = () => ({ last: null, spec: null, files: [], versions: new Map(), atPath: new Map() });
|
|
174
|
+
|
|
175
|
+
// The commits of the first-parent history up to `commit`, as a set.
|
|
176
|
+
export const firstParents = (top, commit) => new Set((git(top, ['rev-list', '--first-parent', commit]) ?? '').split('\n').filter(Boolean));
|
|
177
|
+
|
|
178
|
+
// Brings `h` to `commit`: walks only the commits after `h.last` when that is
|
|
179
|
+
// on the first-parent history of `commit` (`chain`) and the settings are the
|
|
180
|
+
// same; else from the first commit, so a version's number never depends on a
|
|
181
|
+
// branch indexed before. Reads only the files each commit changed. Returns the keys
|
|
182
|
+
// (doc TAB id) whose versions changed.
|
|
183
|
+
function advance(top, h, commit, spec, paths, classify, chain, { added = [], removed = [] } = {}) {
|
|
184
|
+
const resume = h.last && h.spec === spec && chain.has(h.last);
|
|
185
|
+
if (!resume) Object.assign(h, newHistory());
|
|
186
|
+
else {
|
|
187
|
+
// Files that left the set lose their rows; a file that joined it gets its
|
|
188
|
+
// history up to the indexed commit, read for its path alone.
|
|
189
|
+
for (const path of removed) {
|
|
190
|
+
h.atPath.delete(path);
|
|
191
|
+
for (const [key, vs] of h.versions) if ([...vs.values()].some((v) => v.meta.doc === path && v.meta.type === 'output')) h.versions.delete(key);
|
|
192
|
+
}
|
|
193
|
+
if (added.length) walkInto(top, h, h.last, added.map((p) => `:(literal)${p}`), classify);
|
|
194
|
+
}
|
|
195
|
+
h.spec = spec;
|
|
196
|
+
if (h.last === commit) return;
|
|
197
|
+
walkInto(top, h, h.last ? `${h.last}..${commit}` : commit, paths, classify);
|
|
198
|
+
h.last = commit;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Walks the commits of `range` that touch `paths` into `h`, oldest first.
|
|
202
|
+
function walkInto(top, h, range, paths, classify) {
|
|
203
|
+
const commits = walk(top, range, paths);
|
|
204
|
+
const blobs = readBlobs(top, [...new Set(commits.flatMap((c) => c.changes.map((x) => x.blob)).filter(Boolean))], true);
|
|
205
|
+
for (const { commit: c, changes } of commits) {
|
|
206
|
+
const touched = new Set();
|
|
207
|
+
for (const { path, blob } of changes) {
|
|
208
|
+
for (const it of h.atPath.get(path) ?? []) touched.add(it.key);
|
|
209
|
+
h.atPath.delete(path);
|
|
210
|
+
const what = blob && classify(path);
|
|
211
|
+
const bytes = what ? blobs.get(blob) : undefined;
|
|
212
|
+
if (bytes === undefined) continue;
|
|
213
|
+
const items = itemsOf(path, bytes.toString('utf8'), what, bytes);
|
|
214
|
+
h.atPath.set(path, items.map((it) => ({ key: `${it.doc}\t${it.id}`, sha: it.sha256 })));
|
|
215
|
+
for (const it of items) {
|
|
216
|
+
const key = `${it.doc}\t${it.id}`;
|
|
217
|
+
touched.add(key);
|
|
218
|
+
if (!h.versions.has(key)) h.versions.set(key, new Map());
|
|
219
|
+
const vs = h.versions.get(key);
|
|
220
|
+
const meta = { doc: it.doc, id: it.id, archived: Boolean(what.archived), request: what.request ?? null, type: what.type };
|
|
221
|
+
const v = vs.get(it.sha256);
|
|
222
|
+
if (!v) vs.set(it.sha256, { n: vs.size + 1, from: c, until: null, row: it.row, meta });
|
|
223
|
+
else Object.assign(v, { row: it.row, meta });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// A touched version that no path holds after this commit ended here.
|
|
227
|
+
const present = new Set([...h.atPath.values()].flat().filter((x) => touched.has(x.key)).map((x) => `${x.key}\t${x.sha}`));
|
|
228
|
+
for (const key of touched) {
|
|
229
|
+
for (const [sha, v] of h.versions.get(key) ?? []) {
|
|
230
|
+
if (present.has(`${key}\t${sha}`)) v.until = null;
|
|
231
|
+
else if (v.until === null) v.until = c;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// The repos of the export at `commit`: the central repo first, then the
|
|
238
|
+
// output repos of its config as committed there, each { name, commit, unknown, dir }.
|
|
239
|
+
export function selectedRepos(top, commit) {
|
|
240
|
+
const tree = treeOf(top, commit);
|
|
241
|
+
const config = configAt(top, tree, readBlobs(top, [tree.get('.assuredloop/config.yaml')].filter(Boolean)));
|
|
242
|
+
return [{ name: config.repo, commit, unknown: null, dir: top },
|
|
243
|
+
...outputRepos(top, config).map((r) => ({ name: r.name, commit: r.sha, unknown: r.unknown, dir: r.dir }))];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// The declared outputs of the records, by `<repo> TAB <file>`: [{ request,
|
|
247
|
+
// link, target }], sorted. An entry with no repo, or the central's own name,
|
|
248
|
+
// is the central repo's.
|
|
249
|
+
function declaredOf(records, config) {
|
|
250
|
+
const out = new Map();
|
|
251
|
+
for (const [request, rec] of records) {
|
|
252
|
+
for (const o of Array.isArray(rec.outputs) ? rec.outputs : []) {
|
|
253
|
+
if (!o?.file) continue;
|
|
254
|
+
const repo = !o.repo || o.repo === config.repo || o.repo === 'central' ? config.repo : String(o.repo);
|
|
255
|
+
const key = `${repo}\t${o.file}`;
|
|
256
|
+
if (!out.has(key)) out.set(key, []);
|
|
257
|
+
for (const link of OUTPUT_LINKS) for (const target of [].concat(o[link] ?? [])) out.get(key).push({ request, link, target: String(target) });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
for (const list of out.values()) list.sort((a, b) => `${a.request}\t${a.link}\t${a.target}`.localeCompare(`${b.request}\t${b.link}\t${b.target}`));
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const PROVES = { exact: 'proves only that the ID is named', declared: 'a claim, not proven' };
|
|
265
|
+
|
|
266
|
+
// The rows of one repo's output files and results, walked on its own
|
|
267
|
+
// first-parent history up to `commit` with the history `hs.get(key)`. An
|
|
268
|
+
// output row holds only the lines that name an ID (D17).
|
|
269
|
+
function outputRows(dir, commit, hs, key, { repo, files, results, cite, declared, chain }) {
|
|
270
|
+
if (!hs.has(key)) hs.set(key, newHistory());
|
|
271
|
+
const h = hs.get(key);
|
|
272
|
+
const inResults = (p) => results !== null && p.endsWith('.yaml') && (results === '.' ? !p.includes('/') : p.startsWith(`${results}/`) && !p.slice(results.length + 1).includes('/'));
|
|
273
|
+
// A result file is a result, never a member of the output set (it keeps its history).
|
|
274
|
+
const list = [...files].filter((p) => !inResults(p)).sort();
|
|
275
|
+
const paths = [...list.map((f) => `:(literal)${f}`), ...(results === null ? [] : [results === '.' ? ':(glob)*.yaml' : `:(literal)${results}/`])];
|
|
276
|
+
if (!paths.length) { Object.assign(h, newHistory()); return []; }
|
|
277
|
+
// The set of files and the known IDs are not in the walk's settings: a file
|
|
278
|
+
// that joins the set is walked alone, and IDs are matched when rows are made.
|
|
279
|
+
const spec = JSON.stringify({ results, cite: cite.qualified ? 'central:' : 'known IDs' });
|
|
280
|
+
const walked = { qualified: cite.qualified, known: null };
|
|
281
|
+
const members = new Set(list);
|
|
282
|
+
const classify = (p) => (inResults(p) ? { type: 'result', doc: p } : members.has(p) ? { type: 'output', doc: p, cite: walked } : null);
|
|
283
|
+
const before = new Set(h.files ?? []);
|
|
284
|
+
advance(dir, h, commit, spec, paths, classify, chain, { added: list.filter((f) => !before.has(f)), removed: [...before].filter((f) => !members.has(f) && !inResults(f)) });
|
|
285
|
+
h.files = list;
|
|
286
|
+
const current = new Set([...h.atPath.values()].flat().map((x) => `${x.key}\t${x.sha}`));
|
|
287
|
+
const rows = [];
|
|
288
|
+
for (const [k, vs] of h.versions) {
|
|
289
|
+
for (const [sha, v] of vs) {
|
|
290
|
+
const live = current.has(`${k}\t${sha}`);
|
|
291
|
+
const out = v.row.source_type === 'output';
|
|
292
|
+
let extra = {};
|
|
293
|
+
let text = v.row.text;
|
|
294
|
+
if (out) {
|
|
295
|
+
// In the central repo only the IDs that the export has count (D17).
|
|
296
|
+
const keep = (id) => !cite.known || cite.known.has(id.replace(/^central:/, ''));
|
|
297
|
+
const lines = v.row.lines.map((l) => ({ ...l, ids: l.ids.filter(keep) })).filter((l) => l.ids.length);
|
|
298
|
+
const cites = [...new Set(lines.flatMap((l) => l.ids))].sort();
|
|
299
|
+
const dec = declared.get(`${repo}\t${v.row.file}`);
|
|
300
|
+
const how = [...(dec ? ['declared'] : []), ...(cites.length ? ['exact'] : [])];
|
|
301
|
+
extra = { cites, declared: dec ?? [], how, proves: how.map((x) => PROVES[x]) };
|
|
302
|
+
text = lines.map((l) => `${l.n}: ${l.text}`).join('\n');
|
|
303
|
+
}
|
|
304
|
+
rows.push({
|
|
305
|
+
repo, doc_or_request: v.meta.doc, id: v.meta.id, version: v.n, role: live ? 'evidence' : 'history', source_type: v.row.source_type,
|
|
306
|
+
file: v.row.file, line: null, heading_path: [], kind: v.row.source_type, serves: [], builds_on: [], changes: [],
|
|
307
|
+
valid_from: v.from, superseded_by: live ? null : v.until, commit: live ? commit : v.from, sha256: sha, text, ...extra, title: null,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return rows;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// The rows of the export at `commit`: the central repo's, sorted, then each
|
|
315
|
+
// known output repo's (design.md 12; D17). `h` is the central history to
|
|
316
|
+
// resume from and `hs` the output histories by key (the index keeps them);
|
|
317
|
+
// with none, the whole history is walked. `repos` gives each repo's commit,
|
|
318
|
+
// or why it is unknown.
|
|
319
|
+
export function exportRows(top, commit, h = newHistory(), hs = new Map()) {
|
|
320
|
+
const tree = treeOf(top, commit);
|
|
321
|
+
const config = configAt(top, tree, readBlobs(top, [tree.get('.assuredloop/config.yaml')].filter(Boolean)));
|
|
322
|
+
const { records, outputs } = recordsAt(top, tree, config);
|
|
323
|
+
const chain = firstParents(top, commit);
|
|
324
|
+
const spec = JSON.stringify({ root: config.root, docs: config.docs, results: config.results, repo: config.repo });
|
|
325
|
+
const paths = ['*.md', ...['.assuredloop/records/requests/', `${config.results}/`].map((p) => `:(literal)${p}`)];
|
|
326
|
+
advance(top, h, commit, spec, paths, (path) => kindOfPath(path, config, outputs), chain);
|
|
327
|
+
const current = new Set([...h.atPath.values()].flat().map((x) => `${x.key}\t${x.sha}`));
|
|
328
|
+
|
|
329
|
+
// Requirement versions: the record's number when it holds the hash; else after its highest.
|
|
330
|
+
const number = new Map();
|
|
331
|
+
for (const [key, vs] of h.versions) {
|
|
332
|
+
const m = /^([^/]+)\/([RQ]\d+)$/.exec(key.split('\t')[1]);
|
|
333
|
+
if (!m || ![...vs.values()].some((v) => v.meta.type === 'requirements')) continue;
|
|
334
|
+
const rec = records.get(m[1]) ?? {};
|
|
335
|
+
const known = [...(Array.isArray(rec.requirements) ? rec.requirements : []), ...(Array.isArray(rec.questions) ? rec.questions : [])].filter((x) => x?.id === m[2]);
|
|
336
|
+
let next = Math.max(0, ...known.map((x) => Number(x.version) || 0));
|
|
337
|
+
for (const [sha, v] of [...vs].sort((a, b) => a[1].n - b[1].n)) {
|
|
338
|
+
const hit = known.find((x) => x.sha256 === sha || startsWith(sha, x.sha256));
|
|
339
|
+
number.set(`${key}\t${sha}`, hit ? Number(hit.version) : ++next);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// The hints of the per-doc records at the commit.
|
|
344
|
+
const hintBlobs = readBlobs(top, [...tree].filter(([p]) => /^\.assuredloop\/records\/.*\.md\.yaml$/.test(p)).map(([, b]) => b));
|
|
345
|
+
const hintDocs = new Map();
|
|
346
|
+
const hintsOf = (file, id, sha) => {
|
|
347
|
+
if (!hintDocs.has(file)) hintDocs.set(file, yamlAt(hintBlobs.get(tree.get(`.assuredloop/records/${file}.yaml`))));
|
|
348
|
+
const local = id.includes('/') ? id.slice(id.indexOf('/') + 1) : id;
|
|
349
|
+
const paras = hintDocs.get(file)?.paragraphs;
|
|
350
|
+
const hint = (Array.isArray(paras) ? paras : []).find((p) => p?.id === local)?.hint;
|
|
351
|
+
if (!hint || hint.basis_sha256 !== sha) return null;
|
|
352
|
+
return { summary: hint.summary ?? null, tags: Array.isArray(hint.tags) ? hint.tags : [] };
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const adrs = [...tree].filter(([p]) => kindOfPath(p, config, outputs)?.type === 'adr');
|
|
356
|
+
const adrTexts = readBlobs(top, adrs.map(([, b]) => b));
|
|
357
|
+
const statusOf = new Map(adrs.map(([p, b]) => [p, ((adrTexts.get(b) ?? '').match(/^Status:\s*([a-z]+)/im)?.[1] ?? '').toLowerCase()]));
|
|
358
|
+
|
|
359
|
+
const rows = [];
|
|
360
|
+
for (const [key, vs] of h.versions) {
|
|
361
|
+
for (const [sha, v] of vs) {
|
|
362
|
+
const { meta, row } = v;
|
|
363
|
+
const live = current.has(`${key}\t${sha}`);
|
|
364
|
+
const rec = meta.request ? records.get(meta.request) : null;
|
|
365
|
+
const signoffs = Array.isArray(rec?.signoff) ? rec.signoff : [];
|
|
366
|
+
let sourceType = row.source_type;
|
|
367
|
+
let id = meta.id;
|
|
368
|
+
if (sourceType === 'change' && String(rec?.tier) === 'S') sourceType = 'spike';
|
|
369
|
+
const signed = sourceType === 'owner-words' ? signoffs.find((x) => x?.id && basename(String(x.file ?? "")) === basename(row.file)) : null;
|
|
370
|
+
if (signed) { sourceType = 'signoff'; id = `${meta.request}/${signed.id}`; }
|
|
371
|
+
const role0 = { spec: 'baseline', adr: { proposed: 'proposal', superseded: 'history' }[statusOf.get(row.file)] ?? 'baseline', change: 'proposal', spike: 'spike', result: 'evidence', output: 'evidence' }[sourceType] ?? 'source';
|
|
372
|
+
// history wins: a past version, a closed or archived request, a dropped change paragraph.
|
|
373
|
+
const closed = meta.archived || (rec && rec.status !== undefined && rec.status !== 'open');
|
|
374
|
+
const dispositions = Array.isArray(rec?.dispositions) ? rec.dispositions : [];
|
|
375
|
+
const local = meta.id.slice(meta.id.indexOf('/') + 1);
|
|
376
|
+
const dropped = (sourceType === 'change' || sourceType === 'spike') && dispositions.some((d) => ['abandoned', 'superseded'].includes(d?.disposition)
|
|
377
|
+
&& [meta.id, local].includes(String(d.source)) && (d.source_sha256 === sha || startsWith(sha, d.source_sha256)));
|
|
378
|
+
const role = !live || closed || dropped ? 'history' : role0;
|
|
379
|
+
const hints = live && ['spec', 'adr', 'change', 'spike'].includes(sourceType) ? hintsOf(row.file, meta.id, sha) : null;
|
|
380
|
+
rows.push({
|
|
381
|
+
repo: config.repo,
|
|
382
|
+
doc_or_request: meta.doc,
|
|
383
|
+
id,
|
|
384
|
+
version: number.get(`${key}\t${sha}`) ?? v.n,
|
|
385
|
+
role,
|
|
386
|
+
source_type: sourceType,
|
|
387
|
+
file: row.file,
|
|
388
|
+
line: row.line,
|
|
389
|
+
heading_path: row.heading_path,
|
|
390
|
+
kind: row.kind ?? sourceType,
|
|
391
|
+
serves: row.serves ?? [],
|
|
392
|
+
builds_on: row.builds_on ?? [],
|
|
393
|
+
changes: row.changes ?? [],
|
|
394
|
+
valid_from: v.from,
|
|
395
|
+
superseded_by: live ? null : v.until,
|
|
396
|
+
commit: live ? commit : v.from,
|
|
397
|
+
sha256: sha,
|
|
398
|
+
text: row.text,
|
|
399
|
+
...(hints ? { hints } : {}),
|
|
400
|
+
title: row.title ?? null,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
405
|
+
const order = (list) => list.sort((a, b) => cmp(a.doc_or_request, b.doc_or_request) || cmp(a.id, b.id) || a.version - b.version || cmp(a.sha256, b.sha256));
|
|
406
|
+
|
|
407
|
+
// The output files (D17): in the central repo, the declared ones and every
|
|
408
|
+
// other file that names a known ID, but not the spec's own files; in an
|
|
409
|
+
// output repo, the declared ones and every file that names central:<ID>.
|
|
410
|
+
const declared = declaredOf(records, config);
|
|
411
|
+
const known = new Set(rows.filter((r) => r.role !== 'history').map((r) => r.id));
|
|
412
|
+
const own = (name) => [...declared.keys()].filter((k) => k.startsWith(`${name}\t`)).map((k) => k.slice(name.length + 1));
|
|
413
|
+
const cite = { qualified: false, known };
|
|
414
|
+
const exclude = [config.root, 'requests', '.assuredloop', config.results, ...config.docs.map((d) => String(d.file ?? '').replace(/^\.\//, ''))].filter(Boolean);
|
|
415
|
+
// A declared file of the spec's own is no output either.
|
|
416
|
+
const outside = (path) => !exclude.some((e) => path === e || path.startsWith(`${e}/`));
|
|
417
|
+
const files = new Set([...findCites(top, commit, { ...cite, exclude }).map((c) => c.file), ...own(config.repo).filter(outside)]);
|
|
418
|
+
rows.push(...outputRows(top, commit, hs, `${config.repo}\toutputs`, { repo: config.repo, files, results: null, cite, declared, chain }));
|
|
419
|
+
order(rows);
|
|
420
|
+
|
|
421
|
+
const repos = [{ name: config.repo, commit, unknown: null }];
|
|
422
|
+
for (const r of outputRepos(top, config)) {
|
|
423
|
+
repos.push({ name: r.name, commit: r.sha, unknown: r.unknown });
|
|
424
|
+
if (!r.sha) continue;
|
|
425
|
+
const results = posix.normalize(String(repoConfigAt(r.dir, r.sha).results ?? '.assuredloop/results')).replace(/\/+$/, '');
|
|
426
|
+
const skip = ['.assuredloop', ...(results === '.' ? [] : [results])];
|
|
427
|
+
const cited = findCites(r.dir, r.sha, { exclude: skip }).map((c) => c.file);
|
|
428
|
+
const theirs = new Set([...cited, ...own(r.name).filter((p) => !skip.some((e) => p === e || p.startsWith(`${e}/`)))]);
|
|
429
|
+
rows.push(...order(outputRows(r.dir, r.sha, hs, r.name, { repo: r.name, files: theirs, results, cite: { qualified: true }, declared, chain: firstParents(r.dir, r.sha) })));
|
|
430
|
+
}
|
|
431
|
+
return { rows, config, records, outputs, chain, repos };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// The parent section of paragraph `id` in `file` as it was at `commit`: the
|
|
435
|
+
// paragraphs under the same heading path, in file order, each { id, sha256,
|
|
436
|
+
// row }. Null when the file or the ID is not there.
|
|
437
|
+
export function sectionAt(top, commit, file, id) {
|
|
438
|
+
const [text] = readBlobs(top, [`${commit}:${file}`]).values();
|
|
439
|
+
if (text === undefined) return null;
|
|
440
|
+
const r = REQUEST_FILE.exec(file);
|
|
441
|
+
const what = r && r[3] === 'spec.md' ? { type: 'change', doc: r[2], request: r[2] } : { type: 'spec', doc: file };
|
|
442
|
+
const items = itemsOf(file, text, what);
|
|
443
|
+
const hit = items.find((it) => it.id === id);
|
|
444
|
+
if (!hit) return null;
|
|
445
|
+
const path = JSON.stringify(hit.row.heading_path);
|
|
446
|
+
return items.filter((it) => JSON.stringify(it.row.heading_path) === path);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// A row as the export writes it: the design's fields, in a fixed order.
|
|
450
|
+
export const exported = ({ title, ...row }) => row;
|
|
451
|
+
|
|
452
|
+
// al export [--at <commit>] [--out <file>]
|
|
453
|
+
export function exportCommand({ top, opts }) {
|
|
454
|
+
const commit = resolveCommit(top, opts.at);
|
|
455
|
+
const { rows, repos } = exportRows(top, commit);
|
|
456
|
+
const text = rows.map((r) => JSON.stringify(exported(r))).join('\n') + (rows.length ? '\n' : '');
|
|
457
|
+
if (opts.out === undefined) return { raw: text };
|
|
458
|
+
guard(top, [opts.out]);
|
|
459
|
+
write(top, opts.out, text);
|
|
460
|
+
const roles = {};
|
|
461
|
+
for (const r of rows) roles[r.role] = (roles[r.role] ?? 0) + 1;
|
|
462
|
+
const count = (name) => rows.filter((r) => r.repo === name).length;
|
|
463
|
+
return {
|
|
464
|
+
body: [`Wrote ${rows.length} rows to ${opts.out}, at ${commit.slice(0, 12)}: ${Object.entries(roles).sort().map(([k, n]) => `${n} ${k}`).join(', ') || 'none'}`,
|
|
465
|
+
...(repos.length > 1 ? repos.map((r) => (r.commit ? ` ${r.name}@${r.commit.slice(0, 12)}: ${count(r.name)} rows` : ` ${r.name} unknown: no rows`)) : [])],
|
|
466
|
+
next: 'al search <words>',
|
|
467
|
+
notKnown: repos.filter((r) => r.unknown).map((r) => `the output repo ${r.name}: ${r.unknown}; none of its rows is exported`),
|
|
468
|
+
};
|
|
469
|
+
}
|
package/src/v4/git.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// The git reads of v4: the base of a branch, and files at a commit, read in
|
|
2
|
+
// one batch git process.
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
// Trimmed stdout of `git -C top <args>`; with allowFail, null when git fails.
|
|
6
|
+
export function git(top, args, { allowFail = false } = {}) {
|
|
7
|
+
try {
|
|
8
|
+
return execFileSync('git', ['-C', top, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 1 << 30 }).replace(/\s+$/, '');
|
|
9
|
+
} catch (e) {
|
|
10
|
+
if (allowFail) return null;
|
|
11
|
+
throw e;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// The merge-base of HEAD with main (or origin/main), or null.
|
|
16
|
+
export function mergeBase(top) {
|
|
17
|
+
for (const main of ['main', 'origin/main']) {
|
|
18
|
+
if (git(top, ['rev-parse', '--verify', '--quiet', `${main}^{commit}`], { allowFail: true }) === null) continue;
|
|
19
|
+
return git(top, ['merge-base', 'HEAD', main], { allowFail: true }) || null;
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Git objects by name (a blob hash, or `<commit>:<path>`), read with one
|
|
25
|
+
// `git cat-file --batch` process: Map name -> bytes for each name that is a
|
|
26
|
+
// blob. A missing, ambiguous or other object is not in the Map. Every reader
|
|
27
|
+
// of files at a commit uses it. It needs nothing newer than git 2.31 (no
|
|
28
|
+
// `-z`): a name that holds a newline, or ends in a carriage return, cannot go
|
|
29
|
+
// on a line of the batch, so it is read with its own process. A name that
|
|
30
|
+
// holds a NUL is never read: no git path holds one, so it is absent (#205).
|
|
31
|
+
export function readObjects(top, names) {
|
|
32
|
+
const out = new Map();
|
|
33
|
+
const list = [...new Set(names.map(String))].filter((n) => !n.includes('\0'));
|
|
34
|
+
const alone = (n) => n.includes('\n') || n.endsWith('\r');
|
|
35
|
+
const lines = list.filter((n) => !alone(n));
|
|
36
|
+
let buf = null;
|
|
37
|
+
if (lines.length) {
|
|
38
|
+
try {
|
|
39
|
+
buf = execFileSync('git', ['-C', top, 'cat-file', '--batch'], { input: lines.map((n) => `${n}\n`).join(''), stdio: ['pipe', 'pipe', 'ignore'], maxBuffer: 1 << 30 });
|
|
40
|
+
} catch {
|
|
41
|
+
buf = null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Each answer, in the order asked: "<oid> <type> <size>" LF, the bytes, LF;
|
|
45
|
+
// or "<name> missing" (or ambiguous) LF.
|
|
46
|
+
let at = 0;
|
|
47
|
+
for (const name of buf ? lines : []) {
|
|
48
|
+
const end = buf.indexOf(10, at);
|
|
49
|
+
if (end < 0) break;
|
|
50
|
+
const header = /^[0-9a-f]+ ([a-z]+) (\d+)$/.exec(buf.subarray(at, end).toString('utf8'));
|
|
51
|
+
at = end + 1;
|
|
52
|
+
if (!header) continue;
|
|
53
|
+
const size = Number(header[2]);
|
|
54
|
+
if (header[1] === 'blob') out.set(name, buf.subarray(at, at + size));
|
|
55
|
+
at += size + 1;
|
|
56
|
+
}
|
|
57
|
+
for (const name of list.filter(alone)) {
|
|
58
|
+
try {
|
|
59
|
+
out.set(name, execFileSync('git', ['-C', top, 'cat-file', 'blob', name], { stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1 << 30 }));
|
|
60
|
+
} catch {
|
|
61
|
+
// not a blob there
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Each of `paths` at `commit`, with one git process (and one for each path
|
|
68
|
+
// that holds a newline): Map path -> its text in
|
|
69
|
+
// `encoding` (its bytes when encoding is null), or null when it is not a file
|
|
70
|
+
// there (missing, a folder, or a commit that does not resolve).
|
|
71
|
+
export function filesAt(top, commit, paths, encoding = 'utf8') {
|
|
72
|
+
const read = readObjects(top, paths.map((p) => `${commit}:${p}`));
|
|
73
|
+
return new Map(paths.map((p) => {
|
|
74
|
+
const bytes = read.get(`${commit}:${p}`);
|
|
75
|
+
return [p, bytes === undefined ? null : encoding === null ? bytes : bytes.toString(encoding)];
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The text of `path` at `commit`, or null when it is not a file there.
|
|
80
|
+
export const fileAt = (top, commit, path) => filesAt(top, commit, [path]).get(path);
|
|
81
|
+
|
|
82
|
+
// The full hash of commit `rev` in the repo at `dir`, or null when git cannot
|
|
83
|
+
// resolve it there. So a short and a full hash of one commit give one hash;
|
|
84
|
+
// an ambiguous or absent one gives null.
|
|
85
|
+
export function commitOf(dir, rev) {
|
|
86
|
+
if (typeof rev !== 'string' || !rev) return null;
|
|
87
|
+
return git(dir, ['rev-parse', '--verify', '--quiet', '--end-of-options', `${rev}^{commit}`], { allowFail: true }) || null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Each of `revs` as a full commit hash in the repo at `dir`, or null when git
|
|
91
|
+
// cannot resolve it to a commit there, as commitOf gives it, read with one
|
|
92
|
+
// `git cat-file --batch-check` process. A rev that holds a newline or a NUL, or
|
|
93
|
+
// ends in a carriage return, cannot go on a line of the batch: it gets commitOf.
|
|
94
|
+
export function commitsOf(dir, revs) {
|
|
95
|
+
const out = new Map();
|
|
96
|
+
const asked = [...new Set(revs)];
|
|
97
|
+
const alone = (r) => r.includes('\n') || r.endsWith('\r') || r.includes('\0');
|
|
98
|
+
const lines = asked.filter((r) => typeof r === 'string' && r && !alone(r));
|
|
99
|
+
for (const r of asked) out.set(r, null);
|
|
100
|
+
if (lines.length) {
|
|
101
|
+
let text = '';
|
|
102
|
+
try {
|
|
103
|
+
text = execFileSync('git', ['-C', dir, 'cat-file', '--batch-check'], { input: lines.map((r) => `${r}^{commit}\n`).join(''), encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'], maxBuffer: 1 << 30 });
|
|
104
|
+
} catch {
|
|
105
|
+
text = '';
|
|
106
|
+
}
|
|
107
|
+
// One answer per line, in the order asked: "<sha> commit <size>", or "<name> missing" (or ambiguous).
|
|
108
|
+
const answers = text.split('\n');
|
|
109
|
+
lines.forEach((r, i) => {
|
|
110
|
+
const m = /^([0-9a-f]{40,64}) commit \d+$/.exec(answers[i] ?? '');
|
|
111
|
+
if (m) out.set(r, m[1]);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
for (const r of asked) if (typeof r === 'string' && r && alone(r)) out.set(r, commitOf(dir, r));
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The bytes of `path` at `commit`, or null when it is not there.
|
|
119
|
+
export const blobAt = (dir, commit, path) => filesAt(dir, commit, [path], null).get(path);
|
|
120
|
+
|
|
121
|
+
// mergeOf for many PRs: the first-parent history at `commit` read with one git
|
|
122
|
+
// process, and a function from `n` to the commit that merged PR `n`, or null,
|
|
123
|
+
// as mergeOf gives it.
|
|
124
|
+
export function mergesOf(dir, commit) {
|
|
125
|
+
const out = git(dir, ['log', '--first-parent', '--format=%H %s', commit], { allowFail: true }) ?? '';
|
|
126
|
+
const merges = new Map();
|
|
127
|
+
for (const line of out.split('\n')) {
|
|
128
|
+
const sp = line.indexOf(' ');
|
|
129
|
+
if (sp <= 0) continue;
|
|
130
|
+
const subject = line.slice(sp + 1);
|
|
131
|
+
const sha = line.slice(0, sp);
|
|
132
|
+
// The newest commit wins: the log is newest first.
|
|
133
|
+
for (const m of [/^Merge pull request #(\d+)(\s|$)/.exec(subject), /\(#(\d+)\)\s*$/.exec(subject)]) {
|
|
134
|
+
if (m && !merges.has(m[1])) merges.set(m[1], sha);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return (n) => merges.get(String(Number(n))) ?? null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// The commit that merged PR `n`, in the first-parent history at `commit`: the
|
|
141
|
+
// newest whose subject is "Merge pull request #n ..." or ends in "(#n)". Null
|
|
142
|
+
// means no merge was found there, not that the PR is not merged.
|
|
143
|
+
export function mergeOf(dir, commit, n) {
|
|
144
|
+
const out = git(dir, ['log', '--first-parent', '--format=%H %s', commit], { allowFail: true }) ?? '';
|
|
145
|
+
const merge = new RegExp(`^Merge pull request #${Number(n)}(\\s|$)`);
|
|
146
|
+
const squash = new RegExp(`\\(#${Number(n)}\\)\\s*$`);
|
|
147
|
+
for (const line of out.split('\n')) {
|
|
148
|
+
const sp = line.indexOf(' ');
|
|
149
|
+
const subject = line.slice(sp + 1);
|
|
150
|
+
if (sp > 0 && (merge.test(subject) || squash.test(subject))) return line.slice(0, sp);
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
}
|