@assuredloop/cli 0.1.0 → 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/README.md +264 -212
- package/bin/al.js +82 -64
- package/package.json +4 -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/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/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/v4/search.js
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
// al search (design.md 11): the current system, a change's context and the
|
|
2
|
+
// history, at the strongest level installed. Level 0 scans the export; level
|
|
3
|
+
// 1 is a SQLite FTS5 index of it (BM25) with the ID in its own exact column;
|
|
4
|
+
// level 2 fuses level 1 with the ranking of the optional package
|
|
5
|
+
// @assuredloop/search (reciprocal rank fusion). `al` holds no model and no
|
|
6
|
+
// vectors: the package does, and returns a ranked list.
|
|
7
|
+
import { mkdirSync, rmSync } from 'node:fs';
|
|
8
|
+
import { createRequire } from 'node:module';
|
|
9
|
+
import { isAbsolute, join } from 'node:path';
|
|
10
|
+
import { pathToFileURL } from 'node:url';
|
|
11
|
+
import { parse } from 'yaml';
|
|
12
|
+
import { Fail, isName } from './base.js';
|
|
13
|
+
import { git } from './git.js';
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
import { exportRows, exported, firstParents, newHistory, resolveCommit, sectionAt, selectedRepos } from './export.js';
|
|
16
|
+
|
|
17
|
+
// The chunk rule and the tokenizer: a change to either rebuilds the index.
|
|
18
|
+
const MANIFEST = { schema: 'assuredloop-search/1', chunk: 'one paragraph version; header: file title, heading path, kind, ID', tokenizer: 'porter unicode61' };
|
|
19
|
+
const RRF_K = 60;
|
|
20
|
+
const DEEP = 100;
|
|
21
|
+
const STOP = new Set(['a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'can', 'did', 'do', 'does', 'for', 'from', 'has', 'have', 'how', 'in', 'is', 'it', 'its', 'of', 'on', 'or', 'that', 'the', 'this', 'to', 'was', 'what', 'when', 'which', 'who', 'why', 'with']);
|
|
22
|
+
|
|
23
|
+
const terms = (words) => [...new Set((words.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? []).filter((w) => !STOP.has(w)))];
|
|
24
|
+
const keyOf = (r) => [r.repo, r.doc_or_request, r.id, r.version, r.sha256].join('\t');
|
|
25
|
+
// A chunk: the fixed header (file title, heading path, kind, ID), then the text.
|
|
26
|
+
const chunk = (r) => [r.title ?? '', (r.heading_path ?? []).join(' > '), r.kind ?? '', r.id, r.text].join('\n');
|
|
27
|
+
const short = (c) => (c ? c.slice(0, 12) : 'unknown');
|
|
28
|
+
|
|
29
|
+
// node:sqlite with FTS5, or null with the reason.
|
|
30
|
+
async function sqlite() {
|
|
31
|
+
try {
|
|
32
|
+
const { DatabaseSync } = await import('node:sqlite');
|
|
33
|
+
const db = new DatabaseSync(':memory:');
|
|
34
|
+
db.exec(`CREATE VIRTUAL TABLE t USING fts5(x, tokenize='${MANIFEST.tokenizer}')`);
|
|
35
|
+
db.close();
|
|
36
|
+
return { DatabaseSync };
|
|
37
|
+
} catch (e) {
|
|
38
|
+
return { reason: `node:sqlite with FTS5 is not available here (${e.code ?? e.message})` };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The optional package by normal Node resolution: from the project, then
|
|
43
|
+
// beside al. Null with the reason when neither has it.
|
|
44
|
+
async function level2(top) {
|
|
45
|
+
for (const from of [join(top, 'package.json'), import.meta.url]) {
|
|
46
|
+
let path;
|
|
47
|
+
try { path = createRequire(from).resolve('@assuredloop/search'); } catch { continue; }
|
|
48
|
+
try {
|
|
49
|
+
const mod = await import(pathToFileURL(path).href);
|
|
50
|
+
const l2 = mod.default;
|
|
51
|
+
if (!l2 || typeof l2.refresh !== 'function' || typeof l2.rank !== 'function') return { reason: `${path} is not a level 2 package (no refresh and rank)` };
|
|
52
|
+
return { l2 };
|
|
53
|
+
} catch (e) {
|
|
54
|
+
return { reason: `@assuredloop/search did not load: ${e.message.split('\n')[0]}` };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { reason: 'level 2 is not installed: no @assuredloop/search in the project or beside al' };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The folder of the index: <git dir>/assuredloop, never in the working tree.
|
|
61
|
+
function indexDir(top) {
|
|
62
|
+
const p = git(top, ['rev-parse', '--git-path', 'assuredloop']);
|
|
63
|
+
return isAbsolute(p) ? p : join(top, p);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The requests each evidence row is evidence for, set as `for` (D17): an
|
|
67
|
+
// output row's declaring records and the requests whose IDs it cites; a
|
|
68
|
+
// result's, through its declared inputs: an output of the same repo, a
|
|
69
|
+
// declared output of the central repo, or a file of a request's folder.
|
|
70
|
+
function setFor(rows, outputs, names, central) {
|
|
71
|
+
const reqOf = (cite) => /^([a-z0-9][a-z0-9-]*)\//.exec(cite.replace(/^central:/, ''))?.[1];
|
|
72
|
+
const outFor = new Map();
|
|
73
|
+
for (const r of rows) {
|
|
74
|
+
if (r.source_type !== 'output') continue;
|
|
75
|
+
r.for = [...new Set([...(r.declared ?? []).map((d) => d.request), ...(r.cites ?? []).map(reqOf).filter((n) => n && names.has(n))])].sort();
|
|
76
|
+
if (r.superseded_by === null) outFor.set(`${r.repo}\t${r.file}`, r.for);
|
|
77
|
+
}
|
|
78
|
+
for (const r of rows) {
|
|
79
|
+
if (r.source_type === 'output') continue;
|
|
80
|
+
if (r.source_type !== 'result') { r.for = []; continue; }
|
|
81
|
+
let res;
|
|
82
|
+
try { res = parse(r.text, { schema: 'failsafe' }); } catch { r.for = []; continue; }
|
|
83
|
+
const out = new Set();
|
|
84
|
+
for (const f of (Array.isArray(res?.inputs) ? res.inputs : []).map((x) => String(x?.file ?? '')).filter(Boolean)) {
|
|
85
|
+
for (const n of outFor.get(`${r.repo}\t${f}`) ?? []) out.add(n);
|
|
86
|
+
if (r.repo !== central) continue;
|
|
87
|
+
for (const n of outputs.get(f) ?? []) out.add(n);
|
|
88
|
+
const m = /^requests\/(?:archive\/)?([^/]+)\//.exec(f);
|
|
89
|
+
if (m && names.has(m[1])) out.add(m[1]);
|
|
90
|
+
}
|
|
91
|
+
r.for = [...out].sort();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// A version is on the first-parent history of its repo's selected commit;
|
|
96
|
+
// a repo with no chain (unknown now) is not checked.
|
|
97
|
+
const onChain = (q, r) => q.chains.get(r.repo)?.has(r.valid_from) ?? true;
|
|
98
|
+
|
|
99
|
+
// Which current roles a query takes, and whether it adds history.
|
|
100
|
+
function inScope(row, q) {
|
|
101
|
+
if (row.role === 'baseline') return true;
|
|
102
|
+
if (!q.change) return false;
|
|
103
|
+
if (row.role === 'evidence') return (row.for ?? []).includes(q.change);
|
|
104
|
+
return ['proposal', 'spike', 'source'].includes(row.role) && row.doc_or_request === q.change;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// --- The index (levels 1 and 2) ---------------------------------------------
|
|
108
|
+
|
|
109
|
+
function openIndex(DatabaseSync, dir, rebuild) {
|
|
110
|
+
mkdirSync(dir, { recursive: true });
|
|
111
|
+
const path = join(dir, 'search.sqlite');
|
|
112
|
+
let db = new DatabaseSync(path);
|
|
113
|
+
const old = (() => { try { return db.prepare("SELECT v FROM meta WHERE k = 'manifest'").get()?.v ?? null; } catch { return null; } })();
|
|
114
|
+
const fresh = rebuild || old !== JSON.stringify(MANIFEST);
|
|
115
|
+
if (fresh) {
|
|
116
|
+
// Everything in the folder goes, the level 2 package's vectors included.
|
|
117
|
+
db.close();
|
|
118
|
+
rmSync(dir, { recursive: true, force: true });
|
|
119
|
+
mkdirSync(dir, { recursive: true });
|
|
120
|
+
db = new DatabaseSync(path);
|
|
121
|
+
}
|
|
122
|
+
db.exec(`
|
|
123
|
+
CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT);
|
|
124
|
+
CREATE TABLE IF NOT EXISTS current (key TEXT PRIMARY KEY, id TEXT NOT NULL, row TEXT NOT NULL);
|
|
125
|
+
CREATE INDEX IF NOT EXISTS current_id ON current (id);
|
|
126
|
+
CREATE TABLE IF NOT EXISTS history (key TEXT PRIMARY KEY, id TEXT NOT NULL, row TEXT NOT NULL);
|
|
127
|
+
CREATE INDEX IF NOT EXISTS history_id ON history (id);
|
|
128
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS current_text USING fts5(key UNINDEXED, body, tokenize='${MANIFEST.tokenizer}');
|
|
129
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS history_text USING fts5(key UNINDEXED, body, tokenize='${MANIFEST.tokenizer}');
|
|
130
|
+
`);
|
|
131
|
+
db.prepare("INSERT OR REPLACE INTO meta (k, v) VALUES ('manifest', ?)").run(JSON.stringify(MANIFEST));
|
|
132
|
+
return { db, fresh: fresh && old !== null ? (rebuild ? 'rebuilt on request' : 'rebuilt: the chunk rule changed') : null };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const getMeta = (db, k) => db.prepare('SELECT v FROM meta WHERE k = ?').get(k)?.v ?? null;
|
|
136
|
+
const setMeta = (db, k, v) => db.prepare('INSERT OR REPLACE INTO meta (k, v) VALUES (?, ?)').run(k, v);
|
|
137
|
+
|
|
138
|
+
// The walk state as stored: versions and the items of each path.
|
|
139
|
+
const saveHistory = (h) => JSON.stringify({ last: h.last, spec: h.spec, files: h.files ?? [], atPath: [...h.atPath], versions: [...h.versions].map(([k, vs]) => [k, [...vs]]) });
|
|
140
|
+
function loadHistory(text) {
|
|
141
|
+
if (!text) return newHistory();
|
|
142
|
+
const s = JSON.parse(text);
|
|
143
|
+
return { last: s.last, spec: s.spec, files: s.files ?? [], atPath: new Map(s.atPath), versions: new Map(s.versions.map(([k, vs]) => [k, new Map(vs)])) };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Brings the index to `commit`: the current index to that commit's rows
|
|
147
|
+
// (rows missing from its listing are removed), the history index added to and
|
|
148
|
+
// never removed from. Returns what it did.
|
|
149
|
+
function refresh(db, top, commit, repos) {
|
|
150
|
+
// The index is up to date only when no repo moved (D17).
|
|
151
|
+
const stamp = repos.map((r) => `${r.name}@${r.commit ?? 'unknown'}`).join(' ');
|
|
152
|
+
const before = getMeta(db, 'commit');
|
|
153
|
+
if (before === stamp) return { line: `up to date at ${short(commit)}`, changed: false };
|
|
154
|
+
// The repos that moved since the last refresh: only their new commits are walked.
|
|
155
|
+
const prev = new Set((before ?? '').split(' '));
|
|
156
|
+
const moved = repos.filter((r) => !prev.has(`${r.name}@${r.commit ?? 'unknown'}`)).map((r) => (r.commit ? `${r.name}@${short(r.commit)}` : `${r.name} (unknown)`));
|
|
157
|
+
const h = loadHistory(getMeta(db, 'walk'));
|
|
158
|
+
const hs = new Map(JSON.parse(getMeta(db, 'walks') ?? '[]').map(([k, v]) => [k, loadHistory(JSON.stringify(v))]));
|
|
159
|
+
const { rows, outputs, records, config } = exportRows(top, commit, h, hs);
|
|
160
|
+
const names = new Set(records.keys());
|
|
161
|
+
setFor(rows, outputs, names, config.repo);
|
|
162
|
+
const at = new Map(repos.map((r) => [r.name, r.commit]));
|
|
163
|
+
const live = rows.filter((r) => r.commit === at.get(r.repo) && r.superseded_by === null);
|
|
164
|
+
const now = new Map(live.map((r) => [keyOf(r), r]));
|
|
165
|
+
const had = new Map(db.prepare('SELECT key, row FROM current').all().map((x) => [x.key, x.row]));
|
|
166
|
+
db.exec('BEGIN');
|
|
167
|
+
let added = 0;
|
|
168
|
+
let removed = 0;
|
|
169
|
+
let kept = 0;
|
|
170
|
+
const delRow = db.prepare('DELETE FROM current WHERE key = ?');
|
|
171
|
+
const delText = db.prepare('DELETE FROM current_text WHERE key = ?');
|
|
172
|
+
for (const k of had.keys()) if (!now.has(k)) { delRow.run(k); delText.run(k); removed += 1; }
|
|
173
|
+
const putRow = db.prepare('INSERT OR REPLACE INTO current (key, id, row) VALUES (?, ?, ?)');
|
|
174
|
+
const putText = db.prepare('INSERT INTO current_text (key, body) VALUES (?, ?)');
|
|
175
|
+
for (const [k, r] of now) {
|
|
176
|
+
putRow.run(k, r.id, JSON.stringify(r));
|
|
177
|
+
if (!had.has(k)) { putText.run(k, chunk(r)); added += 1; }
|
|
178
|
+
// The same version under a new header (a heading renamed): its chunk is written again.
|
|
179
|
+
else if (chunk(JSON.parse(had.get(k))) !== chunk(r)) { delText.run(k); putText.run(k, chunk(r)); }
|
|
180
|
+
}
|
|
181
|
+
const inHistory = db.prepare('SELECT row FROM history WHERE key = ?');
|
|
182
|
+
const delHistoryText = db.prepare('DELETE FROM history_text WHERE key = ?');
|
|
183
|
+
const putHistory = db.prepare('INSERT OR REPLACE INTO history (key, id, row) VALUES (?, ?, ?)');
|
|
184
|
+
const putHistoryText = db.prepare('INSERT INTO history_text (key, body) VALUES (?, ?)');
|
|
185
|
+
for (const r of rows) {
|
|
186
|
+
const k = keyOf(r);
|
|
187
|
+
const known = inHistory.get(k);
|
|
188
|
+
putHistory.run(k, r.id, JSON.stringify({ ...r, for: now.get(k)?.for ?? [] }));
|
|
189
|
+
if (!known) { putHistoryText.run(k, chunk(r)); kept += 1; } else if (chunk(JSON.parse(known.row)) !== chunk(r)) { delHistoryText.run(k); putHistoryText.run(k, chunk(r)); }
|
|
190
|
+
}
|
|
191
|
+
setMeta(db, 'walk', saveHistory(h));
|
|
192
|
+
setMeta(db, 'walks', JSON.stringify([...hs].map(([k, v]) => [k, JSON.parse(saveHistory(v))])));
|
|
193
|
+
setMeta(db, 'commit', stamp);
|
|
194
|
+
setMeta(db, 'requests', JSON.stringify([...names].sort()));
|
|
195
|
+
db.exec('COMMIT');
|
|
196
|
+
return { line: `brought to ${short(commit)}: current +${added} -${removed}; history +${kept}, nothing removed${repos.length > 1 ? `; walked ${moved.join(', ')}` : ''}`, changed: true };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// The FTS5 query of the words: each term quoted, any term may match.
|
|
200
|
+
const match = (ws) => ws.map((w) => `"${w}"`).join(' OR ');
|
|
201
|
+
|
|
202
|
+
// Word hits at level 1, best first: rows in the query's scope.
|
|
203
|
+
function wordHits(db, q, ws, limit) {
|
|
204
|
+
if (!ws.length) return [];
|
|
205
|
+
const table = q.history ? 'history' : 'current';
|
|
206
|
+
const found = db.prepare(`SELECT t.key AS key, bm25(${table}_text) AS score FROM ${table}_text t WHERE ${table}_text MATCH ? ORDER BY score, key`).all(match(ws));
|
|
207
|
+
const cur = db.prepare('SELECT row FROM current WHERE key = ?');
|
|
208
|
+
const hist = db.prepare('SELECT row FROM history WHERE key = ?');
|
|
209
|
+
const out = [];
|
|
210
|
+
for (const { key } of found) {
|
|
211
|
+
const r = shownRow(cur.get(key)?.row, q.history ? hist.get(key)?.row : null, q);
|
|
212
|
+
if (r) out.push(r);
|
|
213
|
+
if (out.length >= limit) break;
|
|
214
|
+
}
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// A row as a query shows it, or null when the query does not take it. A
|
|
219
|
+
// current row keeps its role; in a history query every other version on the
|
|
220
|
+
// first-parent history of the selected commit (`q.chain`) is shown as
|
|
221
|
+
// history, never as a current promise. The index keeps every version it
|
|
222
|
+
// indexed, from other commits too; those are not shown.
|
|
223
|
+
function shownRow(curText, histText, q) {
|
|
224
|
+
if (curText) {
|
|
225
|
+
const r = JSON.parse(curText);
|
|
226
|
+
if (r.role === 'history') return q.history ? r : null;
|
|
227
|
+
return inScope(r, q) ? r : null;
|
|
228
|
+
}
|
|
229
|
+
if (!histText || !q.history) return null;
|
|
230
|
+
const r = JSON.parse(histText);
|
|
231
|
+
return onChain(q, r) ? { ...r, role: 'history' } : null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function exactHits(db, q, ids) {
|
|
235
|
+
const out = [];
|
|
236
|
+
for (const id of ids) {
|
|
237
|
+
const rows = db.prepare('SELECT key, row FROM current WHERE id = ? ORDER BY key').all(id).map((x) => JSON.parse(x.row))
|
|
238
|
+
.filter((r) => r.role !== 'history' || q.history);
|
|
239
|
+
if (q.history) {
|
|
240
|
+
const live = new Set(rows.map(keyOf));
|
|
241
|
+
for (const x of db.prepare('SELECT key, row FROM history WHERE id = ? ORDER BY key').all(id)) {
|
|
242
|
+
const r = JSON.parse(x.row);
|
|
243
|
+
if (!live.has(x.key) && onChain(q, r) && !db.prepare('SELECT 1 FROM current WHERE key = ?').get(x.key)) rows.push({ ...r, role: 'history' });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
out.push(...rows);
|
|
247
|
+
}
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// The IDs among the words: tokens equal to an ID in the index.
|
|
252
|
+
function idsAmong(words, known) {
|
|
253
|
+
return [...new Set(words.split(/\s+/).map((t) => t.replace(/^[("'`]+|[)"'`?,.;:!]+$/g, '')).filter((t) => t && known(t)))];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// The parent section of a hit as it was at the hit's commit. A paragraph's
|
|
257
|
+
// section is read from its file at that commit, so a past version gets its
|
|
258
|
+
// heading and siblings as they were then; each is the indexed row of that
|
|
259
|
+
// version when there is one.
|
|
260
|
+
function sectionOf(top, rows, hit, chain) {
|
|
261
|
+
if (['spec', 'adr', 'change', 'spike'].includes(hit.source_type)) {
|
|
262
|
+
const items = sectionAt(top, hit.commit, hit.file, hit.id);
|
|
263
|
+
if (items) {
|
|
264
|
+
// The place, the heading path and the text are the file's at that
|
|
265
|
+
// commit; the indexed row gives only the version's identity. Beside a
|
|
266
|
+
// past hit every row is history, never a current promise.
|
|
267
|
+
return items.map((it) => {
|
|
268
|
+
const known = rows.find((r) => r.id === it.id && r.sha256 === it.sha256 && chain.has(r.valid_from));
|
|
269
|
+
const { title, source_type: _, ...row } = it.row;
|
|
270
|
+
const base = known ?? { repo: hit.repo, doc_or_request: it.doc, id: it.id, version: null, role: hit.role, source_type: hit.source_type, valid_from: null, superseded_by: null };
|
|
271
|
+
return exported({ ...base, ...row, kind: row.kind ?? base.source_type, commit: hit.commit, sha256: it.sha256, role: hit.role === 'history' ? 'history' : base.role });
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const path = JSON.stringify(hit.heading_path ?? []);
|
|
276
|
+
return rows.filter((r) => r.repo === hit.repo && r.file === hit.file && r.commit === hit.commit && JSON.stringify(r.heading_path ?? []) === path)
|
|
277
|
+
.sort((a, b) => (a.line ?? 0) - (b.line ?? 0)).map(exported);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// The exact hits first, then the word hits that are not among them, up to `limit` word hits.
|
|
281
|
+
function merge(exact, ranked, limit) {
|
|
282
|
+
const seen = new Set(exact.map(keyOf));
|
|
283
|
+
return [...exact.map((r) => ({ ...r, exact: true })), ...ranked.filter((r) => !seen.has(keyOf(r))).slice(0, limit).map((r) => ({ ...r, exact: false }))];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Reciprocal rank fusion of ranked key lists.
|
|
287
|
+
function fuse(lists) {
|
|
288
|
+
const score = new Map();
|
|
289
|
+
for (const list of lists) list.forEach((k, i) => score.set(k, (score.get(k) ?? 0) + 1 / (RRF_K + i + 1)));
|
|
290
|
+
return [...score].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)).map(([k]) => k);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Level 2's fusion. Only baseline and proposal rows have vectors (design.md
|
|
294
|
+
// 11), so the fusion reorders only the places of those rows; every other row
|
|
295
|
+
// keeps its level 1 place, and the vectors cannot push it down. A row that
|
|
296
|
+
// only the vectors found takes a place after the word hits' rows with vectors.
|
|
297
|
+
function fuseInPlace(ranked, vec, embedded, byKey) {
|
|
298
|
+
const fused = fuse([ranked.map(keyOf).filter((k) => embedded.has(k)), vec]);
|
|
299
|
+
const out = [];
|
|
300
|
+
let j = 0;
|
|
301
|
+
for (const r of ranked) {
|
|
302
|
+
if (!embedded.has(keyOf(r))) out.push(r);
|
|
303
|
+
else if (j < fused.length) out.push(byKey.get(fused[j++]));
|
|
304
|
+
}
|
|
305
|
+
while (j < fused.length) out.push(byKey.get(fused[j++]));
|
|
306
|
+
return out;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// --- Level 0: a scan of the export ------------------------------------------
|
|
310
|
+
|
|
311
|
+
function scan(rows, q, ws, limit) {
|
|
312
|
+
const pool = rows.map((r) => shownRow(r.superseded_by === null ? JSON.stringify(r) : null, r.superseded_by === null ? null : JSON.stringify(r), q)).filter(Boolean);
|
|
313
|
+
const scored = pool.map((r) => {
|
|
314
|
+
const body = new Set(chunk(r).toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? []);
|
|
315
|
+
return { r, n: ws.filter((w) => body.has(w)).length };
|
|
316
|
+
}).filter((x) => x.n > 0);
|
|
317
|
+
scored.sort((a, b) => b.n - a.n || (keyOf(a.r) < keyOf(b.r) ? -1 : keyOf(a.r) > keyOf(b.r) ? 1 : 0));
|
|
318
|
+
return scored.slice(0, limit).map((x) => x.r);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// --- The command -------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
const LEVEL_TEXT = {
|
|
324
|
+
0: '0 (no index: a scan of the export)',
|
|
325
|
+
1: '1 (full text, SQLite FTS5)',
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
// al search [<words>...] [--id <ID>] [--change <request>] [--history] [--level 0|1|2]
|
|
329
|
+
// [--at <commit>] [--limit <n>] [--section] [--rebuild] [--json]
|
|
330
|
+
export async function search({ top, args, opts }) {
|
|
331
|
+
const words = args.join(' ').trim();
|
|
332
|
+
if (!words && opts.id === undefined) throw new Fail('no words and no --id given', 'al search <words> | al search --id <ID>');
|
|
333
|
+
if (opts.level !== undefined && !['0', '1', '2'].includes(opts.level)) throw new Fail(`--level ${opts.level}: one of 0, 1, 2`);
|
|
334
|
+
const limit = opts.limit === undefined ? 10 : Number(opts.limit);
|
|
335
|
+
if (!Number.isInteger(limit) || limit < 1) throw new Fail(`--limit ${opts.limit}: a whole number from 1`);
|
|
336
|
+
if (opts.change !== undefined && !isName(opts.change)) throw new Fail(`--change ${JSON.stringify(opts.change)} is not a request name`);
|
|
337
|
+
const commit = resolveCommit(top, opts.at);
|
|
338
|
+
const want = opts.level === undefined ? 2 : Number(opts.level);
|
|
339
|
+
const chain = firstParents(top, commit);
|
|
340
|
+
// Each repo at its selected commit (D17): the central repo first, then the output repos.
|
|
341
|
+
const repos = selectedRepos(top, commit);
|
|
342
|
+
const chains = new Map(repos.filter((r) => r.commit).map((r) => [r.name, r.dir === top ? chain : firstParents(r.dir, r.commit)]));
|
|
343
|
+
const q = { words, id: opts.id ?? null, change: opts.change ?? null, history: Boolean(opts.history), chain, chains };
|
|
344
|
+
const central = repos[0].name;
|
|
345
|
+
const notKnown = repos.filter((r) => r.unknown).map((r) => `the output repo ${r.name}: ${r.unknown}; none of its rows is shown`);
|
|
346
|
+
const ws = terms(words);
|
|
347
|
+
|
|
348
|
+
// The level that answers: the strongest one installed, up to --level.
|
|
349
|
+
const falls = [];
|
|
350
|
+
let level = want;
|
|
351
|
+
let found2 = null;
|
|
352
|
+
let lite = null;
|
|
353
|
+
if (level === 2) {
|
|
354
|
+
found2 = await level2(top);
|
|
355
|
+
if (!found2.l2) { falls.push(found2.reason); level = 1; }
|
|
356
|
+
}
|
|
357
|
+
if (level >= 1) {
|
|
358
|
+
lite = await sqlite();
|
|
359
|
+
if (!lite.DatabaseSync) { falls.push(lite.reason); level = 0; }
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
let hits = [];
|
|
363
|
+
let indexLine = null;
|
|
364
|
+
let model = null;
|
|
365
|
+
let sectionRows = null;
|
|
366
|
+
if (level === 0) {
|
|
367
|
+
const { rows, outputs, records } = exportRows(top, commit);
|
|
368
|
+
if (q.change && !records.has(q.change)) throw new Fail(`no request named ${q.change}`, 'al search --change <request> <words>');
|
|
369
|
+
setFor(rows, outputs, new Set(records.keys()), central);
|
|
370
|
+
const known = new Set(rows.map((r) => r.id));
|
|
371
|
+
const ids = [...(q.id ? [q.id] : []), ...idsAmong(words, (t) => known.has(t))];
|
|
372
|
+
// An ID resolves in every non-history row, whatever the query; history rows with --history.
|
|
373
|
+
const exact = ids.flatMap((id) => rows.filter((r) => r.id === id).map((r) => (r.superseded_by === null && r.role !== 'history' ? r : q.history ? { ...r, role: 'history' } : null)).filter(Boolean));
|
|
374
|
+
hits = merge(exact, scan(rows, q, ws, limit + exact.length), limit);
|
|
375
|
+
sectionRows = rows;
|
|
376
|
+
} else {
|
|
377
|
+
const dir = indexDir(top);
|
|
378
|
+
const { db, fresh } = openIndex(lite.DatabaseSync, dir, Boolean(opts.rebuild));
|
|
379
|
+
try {
|
|
380
|
+
const done = refresh(db, top, commit, repos);
|
|
381
|
+
indexLine = fresh ? `${fresh}; ${done.line}` : done.line;
|
|
382
|
+
if (q.change && !JSON.parse(getMeta(db, 'requests') ?? '[]').includes(q.change)) throw new Fail(`no request named ${q.change}`, 'al search --change <request> <words>');
|
|
383
|
+
const known = (t) => Boolean(db.prepare('SELECT 1 FROM current WHERE id = ? UNION SELECT 1 FROM history WHERE id = ?').get(t, t));
|
|
384
|
+
const ids = [...(q.id ? [q.id] : []), ...idsAmong(words, known)];
|
|
385
|
+
// An ID resolves in every non-history row, whatever the query.
|
|
386
|
+
const exact = exactHits(db, { ...q }, ids);
|
|
387
|
+
let ranked = wordHits(db, q, ws, level === 2 ? DEEP : limit);
|
|
388
|
+
if (level === 2) model = found2.l2.model ?? 'an unnamed model';
|
|
389
|
+
if (level === 2 && ws.length) {
|
|
390
|
+
const l2 = found2.l2;
|
|
391
|
+
try {
|
|
392
|
+
const cur = db.prepare('SELECT key, row FROM current').all().map((x) => ({ key: x.key, r: JSON.parse(x.row) }));
|
|
393
|
+
const embed = cur.filter((x) => x.r.role === 'baseline' || x.r.role === 'proposal');
|
|
394
|
+
// The hash is of the whole embedded text, header and hints included, so a renamed heading embeds again.
|
|
395
|
+
const texts = embed.map((x) => [chunk(x.r), x.r.hints?.summary ?? '', ...(x.r.hints?.tags ?? [])].filter(Boolean).join('\n'));
|
|
396
|
+
await l2.refresh({ dir, rows: embed.map((x, i) => ({ key: x.key, sha256: createHash('sha256').update(texts[i]).digest('hex'), text: texts[i] })) });
|
|
397
|
+
const keys = embed.filter((x) => inScope(x.r, q)).map((x) => x.key);
|
|
398
|
+
const vec = keys.length ? await l2.rank({ dir, query: words, keys, k: DEEP }) : [];
|
|
399
|
+
const byKey = new Map([...ranked.map((r) => [keyOf(r), r]), ...embed.map((x) => [x.key, x.r])]);
|
|
400
|
+
ranked = fuseInPlace(ranked, vec.filter((k) => byKey.has(k)), new Set(keys), byKey);
|
|
401
|
+
} catch (e) {
|
|
402
|
+
if (e instanceof Fail) throw e;
|
|
403
|
+
falls.push(`level 2 failed: ${String(e.message ?? e).split('\n')[0]}`);
|
|
404
|
+
level = 1;
|
|
405
|
+
ranked = ranked.slice(0, limit);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
hits = merge(exact, ranked, limit);
|
|
409
|
+
if (opts.section) {
|
|
410
|
+
const files = new Set(hits.map((h) => h.file));
|
|
411
|
+
sectionRows = [...db.prepare('SELECT row FROM current').all(), ...db.prepare('SELECT row FROM history').all()]
|
|
412
|
+
.map((x) => JSON.parse(x.row)).filter((r) => files.has(r.file));
|
|
413
|
+
const seen = new Set();
|
|
414
|
+
sectionRows = sectionRows.filter((r) => { const k = keyOf(r); if (seen.has(k)) return false; seen.add(k); return true; });
|
|
415
|
+
}
|
|
416
|
+
} finally {
|
|
417
|
+
db.close();
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const levelText = level === 2 ? `2 (hybrid: full text and ${model})` : LEVEL_TEXT[level];
|
|
422
|
+
const fallback = falls.length && want > level ? falls.join('; ') : null;
|
|
423
|
+
const out = hits.map((h, i) => {
|
|
424
|
+
const { for: _, ...row } = h;
|
|
425
|
+
return { rank: i + 1, exact: h.exact, ...exported(row), ...(opts.section ? { section: sectionOf(top, sectionRows ?? [], h, chain) } : {}) };
|
|
426
|
+
});
|
|
427
|
+
const scope = `${q.change ? `change ${q.change}` : 'current system'}${q.history ? ' + history' : ''}`;
|
|
428
|
+
if (opts.json) {
|
|
429
|
+
return { raw: `${JSON.stringify({ level, fallback, commit, repos: repos.map(({ name, commit: c, unknown }) => ({ name, commit: c, unknown })), query: { words: q.words, id: q.id, change: q.change, history: q.history }, not_known: notKnown, ...(indexLine ? { index: indexLine } : {}), hits: out })}\n` };
|
|
430
|
+
}
|
|
431
|
+
const body = [`Level ${levelText}`];
|
|
432
|
+
if (fallback) body.push(`Fallback ${fallback}`);
|
|
433
|
+
body.push(`Query ${scope} at ${short(commit)}`);
|
|
434
|
+
if (repos.length > 1) body.push(`Repos ${repos.map((r) => (r.commit ? `${r.name}@${short(r.commit)}` : `${r.name} unknown`)).join(' ')}`);
|
|
435
|
+
if (indexLine) body.push(`Index ${indexLine}`);
|
|
436
|
+
if (!out.length) body.push('No hits');
|
|
437
|
+
for (const h of out) {
|
|
438
|
+
body.push(`${h.rank}. ${h.id} ${h.role}${h.exact ? ' exact' : ''} ${h.kind} ${h.repo === central ? '' : `${h.repo}:`}${h.file} v${h.version} @${short(h.commit)}${h.heading_path?.length ? ` (${h.heading_path.join(' > ')})` : ''}`);
|
|
439
|
+
for (const l of String(h.text).split('\n')) body.push(` ${l}`);
|
|
440
|
+
for (const s of h.section ?? []) if (s.id !== h.id) body.push(` | ${s.id}: ${String(s.text).split('\n')[0]}`);
|
|
441
|
+
}
|
|
442
|
+
return { body, notKnown, next: out.length ? `al search --id <ID> for one row; --history adds removed and replaced text` : 'try other words, --change <request>, or --history' };
|
|
443
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Snapshots in a request's origin/ (the v1 format, kept): a header, a line
|
|
2
|
+
// starting with `---`, then the text exactly as given. The SHA-256 is of the
|
|
3
|
+
// bytes after that line.
|
|
4
|
+
import { sha256 } from './base.js';
|
|
5
|
+
|
|
6
|
+
export const SIGNED = '--- signed text ---';
|
|
7
|
+
|
|
8
|
+
export function formatSnapshot({ source, words, fetched, text, signed = false }) {
|
|
9
|
+
const header = [`Source: ${source}`];
|
|
10
|
+
if (words !== undefined) header.push(`Owner's words: ${words}`);
|
|
11
|
+
header.push(`Fetched: ${fetched}`, `SHA-256: ${sha256(text)}${signed ? ' (of the signed text below)' : ''}`, signed ? SIGNED : '---', '');
|
|
12
|
+
return Buffer.concat([Buffer.from(header.join('\n')), Buffer.from(text)]);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// A file-name part from a source: `https://github.com/o/r/issues/31` gives
|
|
16
|
+
// `github-com-o-r-issues-31`.
|
|
17
|
+
export function slug(source) {
|
|
18
|
+
const s = source.toLowerCase().replace(/^[a-z]+:\/\//, '').replace(/[^a-z0-9]+/g, '-').slice(0, 50).replace(/^-+|-+$/g, '');
|
|
19
|
+
return s || 'source';
|
|
20
|
+
}
|
package/src/v4/spec.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// al spec (design.md 13): lists each doc's IDs, display numbers and kinds;
|
|
2
|
+
// `--add-ids <file>` marks every paragraph that has no marker.
|
|
3
|
+
import { existsSync, lstatSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join, relative, resolve } from 'node:path';
|
|
5
|
+
import { parseDocument } from 'yaml';
|
|
6
|
+
import { Fail, exists, guard, read, recordPath, write } from './base.js';
|
|
7
|
+
import { loadConfig, loadSchema, writeSetup } from './config.js';
|
|
8
|
+
import { fileAt, git } from './git.js';
|
|
9
|
+
import { lf, markBlocks, parseMarkdown, sameBlocks } from './markers.js';
|
|
10
|
+
import { append, openRecord, recordText } from './records.js';
|
|
11
|
+
import { docsInScope, idsEverUsed, isShallow, notRead, prefixOf, rootProblem, scopeOf, symlinkOn } from './scope.js';
|
|
12
|
+
|
|
13
|
+
// The refusal every v4 command shares (exit 2, nothing written).
|
|
14
|
+
export { Fail };
|
|
15
|
+
|
|
16
|
+
const PREFIX = /^[A-Z][A-Z0-9]*$/;
|
|
17
|
+
// The adoption record (design.md 5 and 14, D16, D21): an archived request
|
|
18
|
+
// named adoption, whose dispositions say where each adopted paragraph's text
|
|
19
|
+
// came from. It is not an owner approval.
|
|
20
|
+
const ADOPTION = recordPath('adoption');
|
|
21
|
+
const ADOPTION_MD = 'requests/archive/adoption/request.md';
|
|
22
|
+
const ADOPTION_TEXT = `# Adoption
|
|
23
|
+
|
|
24
|
+
The paragraphs that \`al spec --add-ids\` marked, as they were at the commit
|
|
25
|
+
that each entry of \`${ADOPTION}\` names. \`source: adoption\`
|
|
26
|
+
says where the text came from. It is not an owner approval, and it does not
|
|
27
|
+
excuse a later change.
|
|
28
|
+
`;
|
|
29
|
+
const USAGE = 'al spec --add-ids <file> [--prefix <PREFIX>] [--yes]';
|
|
30
|
+
|
|
31
|
+
export function spec({ top, cwd, args, opts }) {
|
|
32
|
+
const root = rootProblem(loadConfig(top));
|
|
33
|
+
if (root) throw new Fail(root, 'fix root: in .assuredloop/config.yaml');
|
|
34
|
+
if (opts['add-ids'] !== undefined) return addIds({ top, cwd, opts });
|
|
35
|
+
const config = loadConfig(top);
|
|
36
|
+
const { kinds } = loadSchema(top);
|
|
37
|
+
let docs = docsInScope(top, config);
|
|
38
|
+
if (args.length) {
|
|
39
|
+
const wanted = args.map((a) => relative(top, resolve(cwd, a)));
|
|
40
|
+
const missing = wanted.filter((p) => !docs.some((d) => d.path === p));
|
|
41
|
+
if (missing.length) throw new Fail(`not a doc in scope: ${missing.join(', ')}`, 'al spec, with no file, lists every doc in scope');
|
|
42
|
+
docs = docs.filter((d) => wanted.includes(d.path));
|
|
43
|
+
}
|
|
44
|
+
const body = notRead(top, config);
|
|
45
|
+
for (const d of docs) {
|
|
46
|
+
body.push(d.path);
|
|
47
|
+
for (const p of parseMarkdown(d.text, d.path, { kinds }).paragraphs) body.push(` ${p.displayNumber} ${p.kind ?? '-'}`);
|
|
48
|
+
}
|
|
49
|
+
if (!docs.length) body.push(`no doc in scope: no *.md under ${config.root}/, no doc in .assuredloop/config.yaml, no requests/<name>/spec.md`);
|
|
50
|
+
return { body, next: 'al check', notKnown: ['whether each kind is right (the review judges that)'] };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function addIds({ top, cwd, opts }) {
|
|
54
|
+
const path = relative(top, resolve(cwd, opts['add-ids']));
|
|
55
|
+
const config = loadConfig(top);
|
|
56
|
+
if (!path.endsWith('.md') || path.startsWith('..')) throw new Fail(`${opts['add-ids']}: give a .md file in this repo`, USAGE);
|
|
57
|
+
// --add-ids writes the doc and the two settings files: never through a symlink.
|
|
58
|
+
for (const p of [path, '.assuredloop/config.yaml', '.assuredloop/schema.yaml', ADOPTION, ADOPTION_MD]) {
|
|
59
|
+
const link = symlinkOn(top, p);
|
|
60
|
+
if (link) throw new Fail(`${link} is a symlink; --add-ids writes no file through a symlink`, USAGE);
|
|
61
|
+
}
|
|
62
|
+
if (!existsSync(join(top, path)) || !lstatSync(join(top, path)).isFile()) throw new Fail(`${path}: no such file`, USAGE);
|
|
63
|
+
if (path.startsWith(`${config.root.replace(/\/+$/, '')}/adr/`)) throw new Fail(`${path}: ADRs are not marked by --add-ids`, USAGE);
|
|
64
|
+
const listed = prefixOf(path, config);
|
|
65
|
+
if (opts.prefix !== undefined && !PREFIX.test(opts.prefix)) {
|
|
66
|
+
throw new Fail(`--prefix ${JSON.stringify(opts.prefix)}: give capital letters and digits, starting with a letter, e.g. INV`, USAGE);
|
|
67
|
+
}
|
|
68
|
+
if (listed && opts.prefix !== undefined && opts.prefix !== listed) {
|
|
69
|
+
throw new Fail(`${path} has the prefix ${listed}${listed === 'SP' ? ' (a request spec)' : ' in .assuredloop/config.yaml'}, not ${opts.prefix}`, USAGE);
|
|
70
|
+
}
|
|
71
|
+
const prefix = listed ?? opts.prefix;
|
|
72
|
+
if (!prefix) throw new Fail(`${path} is not in .assuredloop/config.yaml: give --prefix <PREFIX>`, USAGE);
|
|
73
|
+
const scope = scopeOf(path, config) ?? 'spec';
|
|
74
|
+
|
|
75
|
+
// The highest number ever used for the prefix: in the history of the scope,
|
|
76
|
+
// and in the docs of the scope now (this file included).
|
|
77
|
+
const used = new Set(idsEverUsed(top, config, '--all')?.get(scope) ?? []);
|
|
78
|
+
const text = lf(readFileSync(join(top, path), 'utf8'));
|
|
79
|
+
const docs = [...docsInScope(top, config).filter((d) => d.scope === scope), { path, text }];
|
|
80
|
+
for (const d of docs) for (const p of parseMarkdown(d.text, d.path).paragraphs) used.add(p.id);
|
|
81
|
+
let next = Math.max(0, ...[...used].filter((id) => id.slice(0, id.lastIndexOf('-')) === prefix).map((id) => Number(id.slice(id.lastIndexOf('-') + 1)))) + 1;
|
|
82
|
+
|
|
83
|
+
const { text: marked, marks } = markBlocks(text, () => `${prefix}-${next++}`);
|
|
84
|
+
const notKnown = ['IDs used on branches that were never fetched here'];
|
|
85
|
+
if (isShallow(top)) notKnown.push('IDs used before the shallow history begins');
|
|
86
|
+
const verb = opts.yes ? 'Marked' : 'Would mark';
|
|
87
|
+
const body = [`${verb} ${marks.length} paragraph(s) in ${path}`];
|
|
88
|
+
if (marks.length) body.push(` ${marks[0]}${marks.length > 1 ? ` to ${marks.at(-1)}` : ''}`);
|
|
89
|
+
// Only spec paragraphs are adopted; a change spec's paragraphs are its own.
|
|
90
|
+
// The IDs of the scope now, in every doc of it, this one as marked.
|
|
91
|
+
const living = new Set([...docs.filter((d) => d.path !== path).flatMap((d) => parseMarkdown(d.text, d.path).paragraphs), ...parseMarkdown(marked, path).paragraphs].map((p) => p.id));
|
|
92
|
+
const adoption = scope === 'spec' ? adoptionOf(top, path, marked, marks, living) : { body: [], write: () => [] };
|
|
93
|
+
body.push(...adoption.body);
|
|
94
|
+
if (opts.yes) {
|
|
95
|
+
writeFileSync(join(top, path), marked);
|
|
96
|
+
for (const w of writeSetup(top, path, scope === 'spec' ? prefix : null)) body.push(`wrote ${w}`);
|
|
97
|
+
for (const w of adoption.write()) body.push(`wrote ${w}`);
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
body,
|
|
101
|
+
next: opts.yes ? `set the kind of each new marker, review the diff of ${path} and commit it` : 'run the same command with --yes to write it',
|
|
102
|
+
notKnown,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// What --add-ids records of the paragraphs it marked: one adoption entry for
|
|
107
|
+
// each whose text is a block of the file at HEAD, with that commit; the others
|
|
108
|
+
// are named as not adopted. `write()` writes the record and, when absent, the
|
|
109
|
+
// adoption request.md, and returns the paths written.
|
|
110
|
+
function adoptionOf(top, path, marked, marks, living) {
|
|
111
|
+
if (exists(top, 'requests/adoption/request.md')) {
|
|
112
|
+
throw new Fail('requests/adoption is an open request, and the name adoption holds the adoption record', 'give that request another name, then run it again');
|
|
113
|
+
}
|
|
114
|
+
const none = { body: [], write: () => [] };
|
|
115
|
+
if (!marks.length) return none;
|
|
116
|
+
const head = git(top, ['rev-parse', '--verify', '-q', 'HEAD^{commit}'], { allowFail: true });
|
|
117
|
+
if (!head) return { ...none, body: ['no adoption record written: the repo has no commit, so no commit holds the adopted text'] };
|
|
118
|
+
const atHead = fileAt(top, head, path);
|
|
119
|
+
const all = parseMarkdown(marked, path).paragraphs;
|
|
120
|
+
const same = atHead == null ? new Set() : sameBlocks(atHead, all, { file: path, living });
|
|
121
|
+
const ids = new Set(marks);
|
|
122
|
+
const paras = all.filter((p) => ids.has(p.id));
|
|
123
|
+
const adopted = paras.filter((p) => same.has(p.id));
|
|
124
|
+
const body = paras.filter((p) => !same.has(p.id))
|
|
125
|
+
.map((p) => `not adopted: ${p.id}: its text is not a block of ${path} at HEAD ${head.slice(0, 7)} in this place`);
|
|
126
|
+
if (!adopted.length) return { body: [...body, 'no adoption record written: no marked paragraph is in the file at HEAD'], write: () => [] };
|
|
127
|
+
body.unshift(`adoption: ${adopted.length} paragraph(s) from commit ${head.slice(0, 7)}, in ${ADOPTION}`);
|
|
128
|
+
// Read before anything is written: a record that does not parse refuses here.
|
|
129
|
+
guard(top, [ADOPTION, ADOPTION_MD]);
|
|
130
|
+
const rec = openRecord(top, 'adoption') ?? newRecord();
|
|
131
|
+
return {
|
|
132
|
+
body,
|
|
133
|
+
write() {
|
|
134
|
+
for (const p of adopted) {
|
|
135
|
+
append(rec, 'dispositions', { source: 'adoption', disposition: 'incorporated', spec: p.id, commit: head, spec_sha256: p.sha256 }, true);
|
|
136
|
+
}
|
|
137
|
+
const written = [];
|
|
138
|
+
if (write(top, ADOPTION, recordText(rec))) written.push(ADOPTION);
|
|
139
|
+
if (read(top, ADOPTION_MD) === null && write(top, ADOPTION_MD, ADOPTION_TEXT)) written.push(ADOPTION_MD);
|
|
140
|
+
return written;
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function newRecord() {
|
|
146
|
+
const doc = parseDocument('schema: assuredloop/1\nrequest: adoption\nstatus: concluded\ndispositions: []\n');
|
|
147
|
+
return { doc, get data() { return doc.toJS() ?? {}; } };
|
|
148
|
+
}
|