@assuredloop/cli 0.2.4 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +274 -21
- package/bin/al.js +8 -2
- package/package.json +1 -1
- package/skills/assuredloop/SKILL.md +40 -17
- package/src/v4/blocks.js +333 -0
- package/src/v4/check.js +37 -16
- package/src/v4/checks.js +71 -20
- package/src/v4/commands.js +3 -1
- package/src/v4/conclude.js +8 -4
- package/src/v4/indexer.js +121 -15
- package/src/v4/issues.js +297 -0
- package/src/v4/markers.js +63 -0
- package/src/v4/phrases.js +78 -0
- package/src/v4/scope.js +13 -1
- package/src/v4/state.js +36 -9
package/src/v4/commands.js
CHANGED
|
@@ -107,11 +107,13 @@ function recordOrigin({ top, cwd, args, opts }) {
|
|
|
107
107
|
return { body: [`Wrote ${dir}/origin/${file} and source ${file} in ${rec.path}`], next: `name it in a requirement's marker: <!-- R<n> from:${file} -->` };
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
// al record <name> signoff --source <where>
|
|
110
|
+
// al record <name> signoff --source <where> --words <quote> [--presented <x>] [--transcribed-by <who>] [--yes]
|
|
111
111
|
function recordSignoff({ top, args, opts }) {
|
|
112
112
|
const [name] = args;
|
|
113
113
|
oneLine(opts, ['source', 'words', 'presented', 'transcribed-by']);
|
|
114
114
|
if (!opts.source) throw new Fail('--source <where the owner signed> is missing', `al record ${name} signoff --source <where> --words <quote>`);
|
|
115
|
+
// A sign-off with no words is not valid in al check (DES-89, D10).
|
|
116
|
+
if (typeof opts.words !== 'string' || !opts.words) throw new Fail('--words <the owner\'s words> is missing', `pass the owner's words: al record ${name} signoff --source <where> --words <quote>`);
|
|
115
117
|
const { dir, rec } = openRequest(top, name);
|
|
116
118
|
const md = read(top, `${dir}/request.md`);
|
|
117
119
|
const text = signedText(md);
|
package/src/v4/conclude.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// change of the branch, against the merge-base with main, counts as al check
|
|
7
7
|
// counts it (branchOf, #217). With
|
|
8
8
|
// --yes it writes the Outcome, sets the status and archives the request.
|
|
9
|
-
import { mkdirSync, renameSync } from 'node:fs';
|
|
9
|
+
import { mkdirSync, renameSync, statSync } from 'node:fs';
|
|
10
10
|
import { dirname, join } from 'node:path';
|
|
11
11
|
import { Fail, day, exists, guard, isName, now, write } from './base.js';
|
|
12
12
|
import { branchOf } from './checks.js';
|
|
@@ -112,13 +112,17 @@ export function conclude({ top, args, opts }) {
|
|
|
112
112
|
const block = outcome(state, r, judged, top);
|
|
113
113
|
const target = `requests/archive/${name}`;
|
|
114
114
|
if (exists(top, target)) throw new Fail(`${target} already exists; a request name is never reused`, `al context ${name}`);
|
|
115
|
+
// The preview refuses for each reason that --yes refuses (#241).
|
|
116
|
+
const rec = openRecord(top, name);
|
|
117
|
+
if (!rec) throw new Fail(`${name} has no record .assuredloop/records/requests/${name}.yaml`, 'a v4 request is made with al new');
|
|
118
|
+
guard(top, [`${r.dir}/request.md`, `${target}/request.md`, rec.path]);
|
|
119
|
+
if (statSync(join(top, dirname(target)), { throwIfNoEntry: false })?.isDirectory() === false) {
|
|
120
|
+
throw new Fail(`${dirname(target)} is not a folder; nothing was written`, `make ${dirname(target)} a folder, then al conclude ${name} again`);
|
|
121
|
+
}
|
|
115
122
|
const what = `Status: concluded, the Outcome in ${target}/request.md, status: concluded in the record, and ${r.dir}/ moved to ${target}/`;
|
|
116
123
|
if (!opts.yes) {
|
|
117
124
|
return { body: [`Would conclude ${name}: ${what}`, ...block.slice(2).map((l) => ` ${l}`)], read, next: 'run the same command with --yes to do it', notKnown };
|
|
118
125
|
}
|
|
119
|
-
const rec = openRecord(top, name);
|
|
120
|
-
if (!rec) throw new Fail(`${name} has no record .assuredloop/records/requests/${name}.yaml`, 'a v4 request is made with al new');
|
|
121
|
-
guard(top, [`${r.dir}/request.md`, `${target}/request.md`, rec.path]);
|
|
122
126
|
const md = withOutcome(withStatus(r.md), block);
|
|
123
127
|
rec.doc.set('status', 'concluded');
|
|
124
128
|
mkdirSync(dirname(join(top, target)), { recursive: true });
|
package/src/v4/indexer.js
CHANGED
|
@@ -2,11 +2,10 @@
|
|
|
2
2
|
// with the agents' hints kept), appends requirement versions, freezes the
|
|
3
3
|
// bindings of new links and fills the hashes of new dispositions. Ordinary
|
|
4
4
|
// indexing never advances a binding; only --align does (design.md 4, 10).
|
|
5
|
-
import { readdirSync } from 'node:fs';
|
|
5
|
+
import { accessSync, constants, lstatSync, readdirSync } from 'node:fs';
|
|
6
6
|
import { join, posix } from 'node:path';
|
|
7
7
|
import { isMap, parse, stringify } from 'yaml';
|
|
8
8
|
import { parseMarkdown } from './markers.js';
|
|
9
|
-
import { diffParagraphs } from './ids.js';
|
|
10
9
|
import { git, mergeBase, fileAt, filesAt } from './git.js';
|
|
11
10
|
import { loadConfig } from './config.js';
|
|
12
11
|
import { SPEC, docsInScope, symlinkOn } from './scope.js';
|
|
@@ -110,6 +109,9 @@ export const isCentral = (config) => Array.isArray(config.outputs) && config.out
|
|
|
110
109
|
// An output in another repo (T12): its repo is named, and is not this one.
|
|
111
110
|
const foreign = (o, config) => Boolean(o.repo) && o.repo !== config.repo && o.repo !== 'central';
|
|
112
111
|
|
|
112
|
+
// A record's declared outputs; a value that is not a list declares none, so al check still runs (#2).
|
|
113
|
+
const outputsOf = (r) => (Array.isArray(r.data?.outputs) ? r.data.outputs : []);
|
|
114
|
+
|
|
113
115
|
// Every declared link of a state: { holder, holderSha, link, target, homes }, with
|
|
114
116
|
// `homes` the requests whose record holds it. An output in another repo also
|
|
115
117
|
// has `commit` (<repo>@<sha>), or `unknown` with the reason; `repos` are the
|
|
@@ -120,7 +122,7 @@ function linksOf(top, state, config, live, repos = new Map()) {
|
|
|
120
122
|
const batches = new Map();
|
|
121
123
|
const outputFiles = (repo) => {
|
|
122
124
|
if (!batches.has(repo.name)) {
|
|
123
|
-
const files = [...state.requests.values()].filter((r) => r.open).flatMap(
|
|
125
|
+
const files = [...state.requests.values()].filter((r) => r.open).flatMap(outputsOf)
|
|
124
126
|
.filter((o) => o?.file && o.repo === repo.name).map((o) => String(o.file));
|
|
125
127
|
batches.set(repo.name, filesAt(repo.dir, repo.sha, [...new Set(files)], null));
|
|
126
128
|
}
|
|
@@ -140,7 +142,7 @@ function linksOf(top, state, config, live, repos = new Map()) {
|
|
|
140
142
|
for (const f of q.from) out.push({ holder: `${r.name}/${q.id}`, holderSha: q.sha256, link: 'from', target: `${r.name}/${f}`, t: { req: r.name, id: f }, homes: [r.name] });
|
|
141
143
|
}
|
|
142
144
|
const own = new Set(r.paras.keys());
|
|
143
|
-
for (const o of r
|
|
145
|
+
for (const o of outputsOf(r)) {
|
|
144
146
|
if (!o?.file) continue;
|
|
145
147
|
const other = foreign(o, config);
|
|
146
148
|
const repo = other ? repos.get(o.repo) : null;
|
|
@@ -186,6 +188,76 @@ export function targetOf(t, state) {
|
|
|
186
188
|
const tripleKey = (l) => `${l.holder}\t${l.link}\t${l.target}`;
|
|
187
189
|
const short = (h) => (h ? h.slice(0, 12) : 'unknown');
|
|
188
190
|
|
|
191
|
+
// Whether a binding's holder declares its link now: the same holder, link
|
|
192
|
+
// word and target (#2). It reads al check's own state (state.js), so each
|
|
193
|
+
// file is read once, by al check's rules.
|
|
194
|
+
export function declaredNow(top, state) {
|
|
195
|
+
const docs = [
|
|
196
|
+
{ request: null, ids: new Set(), paragraphs: [...state.spec.values()].filter((p) => p.id) },
|
|
197
|
+
...[...state.requests.values()].filter((r) => r.open).map((r) => ({ request: r.name, ids: new Set(r.paras.keys()), paragraphs: [...r.paras.values()].filter((p) => p.id) })),
|
|
198
|
+
...[...state.adrs.values()].map((a) => ({ request: null, ids: new Set(), paragraphs: a.paragraphs.filter((p) => p.id) })),
|
|
199
|
+
];
|
|
200
|
+
const keys = new Set(linksOf(top, { docs, requests: state.requests }, state.config, false).map(tripleKey));
|
|
201
|
+
return (b) => keys.has(tripleKey(b));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Whether al index --align can move a binding to `target` now, by its own
|
|
205
|
+
// target rules, in al check's state (state.js): holder-changed names --align
|
|
206
|
+
// only where it works (#11).
|
|
207
|
+
export function canAlignTo(state) {
|
|
208
|
+
const adrs = [...state.adrs.values()].flatMap((a) => a.paragraphs.filter((p) => p.id).map((p) => [p.id, p]));
|
|
209
|
+
const view = { spec: new Map([...state.spec, ...adrs]), requests: state.requests };
|
|
210
|
+
const central = isCentral(state.config);
|
|
211
|
+
return (target) => Boolean(targetOf(qualify(String(target), null, new Set(), central), view));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// A state whose archived requests declare their links too: their change
|
|
215
|
+
// specs' markers, their requirements' from: and their records' outputs: (#11).
|
|
216
|
+
function everyDeclaration(state) {
|
|
217
|
+
const old = [...state.requests.values()].filter((r) => !r.open);
|
|
218
|
+
return {
|
|
219
|
+
docs: [...state.docs, ...old.map((r) => ({ request: r.name, ids: new Set(r.paras.keys()), paragraphs: [...r.paras.values()] }))],
|
|
220
|
+
requests: new Map([...state.requests].map(([n, r]) => [n, { ...r, open: true }])),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// The files and folders that can declare links but are not read, each with
|
|
225
|
+
// why: reached through a symlink, a file that cannot be read, or a folder that
|
|
226
|
+
// cannot be listed. They are the spec root's, the requests' (open and
|
|
227
|
+
// archived) request.md, spec.md and record, whose outputs: declare links, and
|
|
228
|
+
// the docs that config lists (#11).
|
|
229
|
+
function notRead(top, root, config) {
|
|
230
|
+
const out = [];
|
|
231
|
+
const blocked = (p) => {
|
|
232
|
+
let stat;
|
|
233
|
+
try { stat = lstatSync(join(top, p)); } catch { return false; }
|
|
234
|
+
if (stat.isSymbolicLink()) { out.push(`${p}: through a symlink`); return true; }
|
|
235
|
+
try { accessSync(join(top, p), stat.isDirectory() ? constants.R_OK | constants.X_OK : constants.R_OK); } catch { out.push(`${p}: cannot be read`); return true; }
|
|
236
|
+
return false;
|
|
237
|
+
};
|
|
238
|
+
const tree = (dir) => {
|
|
239
|
+
if (blocked(dir)) return;
|
|
240
|
+
for (const e of list(top, dir)) {
|
|
241
|
+
if (e.isDirectory()) tree(`${dir}/${e.name}`);
|
|
242
|
+
else blocked(`${dir}/${e.name}`);
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
tree(root);
|
|
246
|
+
const records = !blocked('.assuredloop/records/requests');
|
|
247
|
+
for (const dir of ['requests', 'requests/archive']) {
|
|
248
|
+
if (blocked(dir)) continue;
|
|
249
|
+
for (const e of list(top, dir)) {
|
|
250
|
+
const d = `${dir}/${e.name}`;
|
|
251
|
+
if (d === 'requests/archive' || (!e.isDirectory() && !e.isSymbolicLink()) || blocked(d)) continue;
|
|
252
|
+
blocked(`${d}/request.md`);
|
|
253
|
+
blocked(`${d}/spec.md`);
|
|
254
|
+
if (records) blocked(recordPath(e.name));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
for (const d of config.docs) blocked(posix.normalize(String(d.file)));
|
|
258
|
+
return out;
|
|
259
|
+
}
|
|
260
|
+
|
|
189
261
|
export function index({ top, opts }) {
|
|
190
262
|
let loaded;
|
|
191
263
|
try { loaded = loadConfig(top); } catch (e) { throw new Fail(e.message, 'fix .assuredloop/config.yaml'); }
|
|
@@ -240,13 +312,25 @@ export function index({ top, opts }) {
|
|
|
240
312
|
if (!froms.has(align)) throw new Fail(`--align ${align}: no paragraph, requirement or output with that ID (a change spec's ID is <request>/<ID>)`, 'al index --align <ID>');
|
|
241
313
|
}
|
|
242
314
|
|
|
315
|
+
// The records that already hold each binding: a holder whose marker names
|
|
316
|
+
// no request any more, such as a superseded ADR that dropped `source:`,
|
|
317
|
+
// still reaches the bindings it holds (#11).
|
|
318
|
+
const held = new Map();
|
|
319
|
+
for (const [n, rec] of recs) {
|
|
320
|
+
for (const it of rec.doc.get('bindings')?.items ?? []) {
|
|
321
|
+
if (!it?.get) continue;
|
|
322
|
+
const k = tripleKey({ holder: it.get('holder'), link: it.get('link'), target: it.get('target') });
|
|
323
|
+
held.set(k, [...(held.get(k) ?? []), n]);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
243
327
|
let bound = 0;
|
|
244
328
|
const seen = new Set();
|
|
245
329
|
const once = (line) => { if (!seen.has(line)) { seen.add(line); body.push(line); } };
|
|
246
330
|
for (const l of links) {
|
|
247
331
|
const name = `${l.holder} ${l.link} ${l.target}`;
|
|
248
332
|
if (l.t.cross) { once(`not bound: ${name}: cross-repo`); continue; }
|
|
249
|
-
const homes = l.homes.filter((h) => recs.has(h));
|
|
333
|
+
const homes = [...new Set([...l.homes.filter((h) => recs.has(h)), ...(held.get(tripleKey(l)) ?? [])])];
|
|
250
334
|
if (!homes.length) { once(`not bound: ${name}: no request record holds it`); continue; }
|
|
251
335
|
const aligning = align !== undefined && l.holder === align;
|
|
252
336
|
for (const h of homes) {
|
|
@@ -277,6 +361,33 @@ export function index({ top, opts }) {
|
|
|
277
361
|
}
|
|
278
362
|
}
|
|
279
363
|
|
|
364
|
+
// Removed links (removed-links/R1, #11): a binding whose link no current
|
|
365
|
+
// marker, requirement or outputs: entry declares is removed from the records
|
|
366
|
+
// of the open and the archived requests, and named. An archived change spec
|
|
367
|
+
// and request.md still declare their links. While a file that can declare
|
|
368
|
+
// links is not read, nothing is removed: a missing file is not a removed link.
|
|
369
|
+
const archived = new Map();
|
|
370
|
+
for (const n of names.archived) {
|
|
371
|
+
try { const rec = openRecord(top, n); if (rec) archived.set(n, rec); } catch (e) { if (!e.invalid) throw e; notes.push(`not read: ${e.message}`); }
|
|
372
|
+
}
|
|
373
|
+
const unread = [...new Set([
|
|
374
|
+
...notes.filter((l) => /^not (indexed|read): /.test(l)).map((l) => l.replace(/^not (indexed|read): /, '')),
|
|
375
|
+
...notRead(top, root, config),
|
|
376
|
+
])];
|
|
377
|
+
const declared = new Set(linksOf(top, everyDeclaration(now), config, false).map(tripleKey));
|
|
378
|
+
const undeclared = (it) => it?.get && !declared.has(tripleKey({ holder: it.get('holder'), link: it.get('link'), target: it.get('target') }));
|
|
379
|
+
const dead = [...recs, ...archived].flatMap(([n, rec]) => (rec.doc.get('bindings')?.items ?? []).filter(undeclared).map((it) => [n, rec, it]));
|
|
380
|
+
if (unread.length) {
|
|
381
|
+
body.push(`not removed: no binding is removed in this run, as al index could not read ${unread.join('; ')}`);
|
|
382
|
+
} else {
|
|
383
|
+
for (const [n, rec, it] of dead) {
|
|
384
|
+
const items = rec.doc.get('bindings').items;
|
|
385
|
+
items.splice(items.indexOf(it), 1);
|
|
386
|
+
body.push(`removed: ${it.get('holder')} ${it.get('link')} ${it.get('target')} from the record of ${n}: no longer declared`);
|
|
387
|
+
rec.changed = true;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
280
391
|
// Dispositions: the hashes of the source version, and of the spec text it
|
|
281
392
|
// produced, frozen when first indexed.
|
|
282
393
|
for (const [n, rec] of recs) {
|
|
@@ -310,11 +421,8 @@ export function index({ top, opts }) {
|
|
|
310
421
|
}
|
|
311
422
|
|
|
312
423
|
// The per-doc records: regenerable fields, written fresh; hints kept by ID.
|
|
313
|
-
//
|
|
314
|
-
|
|
315
|
-
const diffs = main ? diffParagraphs(qualified(loadState(top, main, config, names).docs), qualified(now.docs)) : null;
|
|
316
|
-
const own = (d, id) => (d.request ? `${d.request}/${id}` : id);
|
|
317
|
-
const changeOf = new Map((diffs ?? []).filter((x) => x.sha256 !== null).map((x) => [`${x.file}\t${x.id}`, x.changes]));
|
|
424
|
+
// Nothing in them depends on the branch or its base, so main's index
|
|
425
|
+
// rebuilds a merged record as it is (#245).
|
|
318
426
|
const outputs = [];
|
|
319
427
|
for (const d of now.docs) {
|
|
320
428
|
const path = docRecordPath(d.file);
|
|
@@ -328,16 +436,14 @@ export function index({ top, opts }) {
|
|
|
328
436
|
}
|
|
329
437
|
const status = d.adr ? d.text.split('\n').find((l) => l.trim())?.match(/^Status:\s*([a-z]+)/i)?.[1].toLowerCase() : undefined;
|
|
330
438
|
const paragraphs = d.paragraphs.map((p) => {
|
|
331
|
-
const change = changeOf.get(`${d.file}\t${own(d, p.id)}`) ?? [];
|
|
332
439
|
const hint = hints.get(p.id);
|
|
333
440
|
if (hint && stale(hint, p)) body.push(`refresh hint: ${d.file} ${p.id}`);
|
|
334
|
-
return { id: p.id, ...(p.kind ? { kind: p.kind } : {}), text_sha256: p.sha256, display: p.displayNumber, ...(
|
|
441
|
+
return { id: p.id, ...(p.kind ? { kind: p.kind } : {}), text_sha256: p.sha256, display: p.displayNumber, ...(hint ? { hint } : {}) };
|
|
335
442
|
});
|
|
336
|
-
const
|
|
337
|
-
const rec = { schema: SCHEMA, file: d.file, ...(status ? { status } : {}), paragraphs, ...(removed.length ? { removed } : {}) };
|
|
443
|
+
const rec = { schema: SCHEMA, file: d.file, ...(status ? { status } : {}), paragraphs };
|
|
338
444
|
outputs.push([path, stringify(rec, YAML_OPTIONS)]);
|
|
339
445
|
}
|
|
340
|
-
for (const [, rec] of recs) if (rec.changed) outputs.push([rec.path, recordText(rec)]);
|
|
446
|
+
for (const [, rec] of [...recs, ...archived]) if (rec.changed) outputs.push([rec.path, recordText(rec)]);
|
|
341
447
|
guard(top, outputs.map(([path]) => path));
|
|
342
448
|
const written = outputs.filter(([path, text]) => write(top, path, text)).map(([path]) => path);
|
|
343
449
|
|
package/src/v4/issues.js
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// Hints from the linked work items (issue-hints R1, design.md 8). `al` never
|
|
2
|
+
// contacts a tracker (D9): the caller saves the items in a folder, and
|
|
3
|
+
// `al check --issues <folder>` reads them. An item is the JSON that
|
|
4
|
+
// `gh issue view --json number,url,body,comments,parent` prints, or the same
|
|
5
|
+
// shape with `ref` and `repository` for another tracker (D10). Only the
|
|
6
|
+
// items that the `tasks:` of the open requests name are checked (D11). Each
|
|
7
|
+
// finding is a hint (R1, hints-and-blocks R2):
|
|
8
|
+
// - issue-missing: a task names an item that is not in the folder;
|
|
9
|
+
// - issue-superseded: the item's text names a superseded ADR without its
|
|
10
|
+
// successor, by R5's own code (supersededIn, D7);
|
|
11
|
+
// - issue-bare-ref: a bare `#n` in an item of another repository;
|
|
12
|
+
// - issue-not-sub: not a child of the request's own item, which the file
|
|
13
|
+
// assuredloop-requests.json of the folder names (own-item R1);
|
|
14
|
+
// - phrase: a phrase of the config matches (R2, phrases.js);
|
|
15
|
+
// - issue-unread: a file of the folder that is not valid JSON, or an entry
|
|
16
|
+
// that is not an item, or an assuredloop-requests.json that is not valid.
|
|
17
|
+
// An item's text is its body and its comments that are not minimized (D4).
|
|
18
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
19
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
20
|
+
import { recordPath } from './base.js';
|
|
21
|
+
import { citedMessage, supersededIn } from './blocks.js';
|
|
22
|
+
import { git } from './git.js';
|
|
23
|
+
import { phraseHits, phraseMessage } from './phrases.js';
|
|
24
|
+
import { outputRepos } from './repos.js';
|
|
25
|
+
import { Fail } from './spec.js';
|
|
26
|
+
|
|
27
|
+
const ISSUE_URL = /\bhttps?:\/\/[^\s/]+\/([\w.-]+)\/([\w.-]+)\/issues\/(\d+)\b/g;
|
|
28
|
+
const QUALIFIED = /(?<![\w./-])([\w.-]+)\/([\w.-]+)#(\d+)\b/g;
|
|
29
|
+
const NAMED = /(?<![\w./#-])([a-z0-9][a-z0-9._-]*)#(\d+)\b/g;
|
|
30
|
+
const BARE = /(?<![\w/#&-])#(\d+)\b/g;
|
|
31
|
+
const slug = (s) => String(s).toLowerCase();
|
|
32
|
+
const isMap = (x) => !!x && typeof x === 'object' && !Array.isArray(x);
|
|
33
|
+
const once = (re) => new RegExp(re.source);
|
|
34
|
+
// The file of the folder that names each request's own item (own-item SP-8).
|
|
35
|
+
const OWN_ITEMS = 'assuredloop-requests.json';
|
|
36
|
+
const FORMAT = 'assuredloop-items/1';
|
|
37
|
+
// `text` holds `word` as a whole word: no letter, digit, `_` or `-` beside it.
|
|
38
|
+
const holds = (text, word) => new RegExp(`(?<![\\w-])${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w-])`).test(text);
|
|
39
|
+
|
|
40
|
+
// The `owner/repo` of a URL or an `owner/repo#n` (null when it names no item).
|
|
41
|
+
const keyOf = (s) => {
|
|
42
|
+
const t = String(s ?? '');
|
|
43
|
+
const m = once(ISSUE_URL).exec(t) ?? once(QUALIFIED).exec(t);
|
|
44
|
+
return m ? `${m[1]}/${m[2]}#${m[3]}` : null;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// The `owner/repo` that `git remote get-url origin` names in `dir`, any host
|
|
48
|
+
// (D12): { repo } or { unknown }.
|
|
49
|
+
function remoteRepo(dir) {
|
|
50
|
+
const url = git(dir, ['remote', 'get-url', 'origin'], { allowFail: true });
|
|
51
|
+
if (!url) return { unknown: 'no remote origin' };
|
|
52
|
+
const m = /^(?:[\w.+-]+:\/\/(?:[^@/]+@)?[^/]+\/|[^@\s/]+@[^:\s/]+:)(.+?)(?:\.git)?\/?$/.exec(url);
|
|
53
|
+
// The URL is not shown: it may hold a user name and a password (SP-22).
|
|
54
|
+
return m && /^[^/]+\/[^/]+$/.test(m[1]) ? { repo: m[1] } : { unknown: 'the remote origin names no owner/repo' };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// An entry of an item file: the item, or null when it is not one (SP-24).
|
|
58
|
+
function itemOf(x, file) {
|
|
59
|
+
if (!isMap(x) || typeof x.body !== 'string') return null;
|
|
60
|
+
const at = once(ISSUE_URL).exec(String(x.url ?? ''));
|
|
61
|
+
const repository = typeof x.repository === 'string' && x.repository ? x.repository
|
|
62
|
+
: isMap(x.repository) && typeof x.repository.nameWithOwner === 'string' ? x.repository.nameWithOwner
|
|
63
|
+
: at ? `${at[1]}/${at[2]}` : null;
|
|
64
|
+
const number = Number.isInteger(x.number) ? x.number : at ? Number(at[3]) : null;
|
|
65
|
+
const ref = typeof x.ref === 'string' && x.ref ? x.ref : null;
|
|
66
|
+
const key = repository && number !== null ? `${repository}#${number}` : ref;
|
|
67
|
+
if (!key) return null;
|
|
68
|
+
return { key, ref, url: typeof x.url === 'string' ? x.url : null, repository, number, body: x.body, comments: x.comments, parent: x.parent, file };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The own items that assuredloop-requests.json names (own-item SP-8, SP-9):
|
|
72
|
+
// { requests } (a Map of name to value), { absent: true }, or { why } when
|
|
73
|
+
// the file is not valid and so is ignored as a whole.
|
|
74
|
+
function ownItems(path) {
|
|
75
|
+
if (!existsSync(path)) return { absent: true };
|
|
76
|
+
let data;
|
|
77
|
+
try {
|
|
78
|
+
data = JSON.parse(readFileSync(path, 'utf8'));
|
|
79
|
+
} catch (e) {
|
|
80
|
+
return { why: e instanceof SyntaxError ? 'it is not valid JSON' : `it cannot be read (${e.code ?? e.message})` };
|
|
81
|
+
}
|
|
82
|
+
if (!isMap(data)) return { why: 'it is not a JSON object' };
|
|
83
|
+
const other = Object.keys(data).filter((k) => k !== 'format' && k !== 'requests');
|
|
84
|
+
if (other.length) return { why: `it has a field other than format and requests: ${other.join(', ')}` };
|
|
85
|
+
if (data.format !== FORMAT) return { why: `its format is not ${FORMAT}` };
|
|
86
|
+
if (!isMap(data.requests)) return { why: 'its requests is not an object' };
|
|
87
|
+
const bad = Object.entries(data.requests).filter(([, v]) => typeof v !== 'string' || !v).map(([k]) => k);
|
|
88
|
+
if (bad.length) return { why: `the value of ${bad.join(', ')} is not a text that is not empty` };
|
|
89
|
+
return { requests: new Map(Object.entries(data.requests)) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The items of the folder that the caller saved (SP-12): { items, unread,
|
|
93
|
+
// owns }. A folder that is not there is a refusal.
|
|
94
|
+
export function loadItems(cwd, folder) {
|
|
95
|
+
const dir = resolve(cwd, folder);
|
|
96
|
+
let isDir = false;
|
|
97
|
+
try {
|
|
98
|
+
isDir = statSync(dir).isDirectory();
|
|
99
|
+
} catch {
|
|
100
|
+
isDir = false;
|
|
101
|
+
}
|
|
102
|
+
if (!isDir) throw new Fail(`--issues ${folder}: no such folder`, 'save the work items in a folder (see the README), then al check --issues <folder>');
|
|
103
|
+
const items = [];
|
|
104
|
+
const unread = [];
|
|
105
|
+
const shown = (name) => `${folder.replace(/\/+$/, '')}/${name}`;
|
|
106
|
+
const owns = ownItems(join(dir, OWN_ITEMS));
|
|
107
|
+
if (owns.why) unread.push({ file: shown(OWN_ITEMS), why: owns.why });
|
|
108
|
+
for (const name of readdirSync(dir).filter((n) => n.endsWith('.json') && n !== OWN_ITEMS).sort()) {
|
|
109
|
+
const file = shown(name);
|
|
110
|
+
let data;
|
|
111
|
+
try {
|
|
112
|
+
if (!statSync(join(dir, name)).isFile()) continue;
|
|
113
|
+
data = JSON.parse(readFileSync(join(dir, name), 'utf8'));
|
|
114
|
+
} catch (e) {
|
|
115
|
+
unread.push({ file, why: e instanceof SyntaxError ? 'it is not valid JSON' : `it cannot be read (${e.code ?? e.message})` });
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
(Array.isArray(data) ? data : [data]).forEach((x, i) => {
|
|
119
|
+
const it = itemOf(x, file);
|
|
120
|
+
if (it) items.push(it);
|
|
121
|
+
else unread.push({ file, why: `${Array.isArray(data) ? `its entry ${i + 1}` : 'it'} is not an item: an item needs a body, and a url, a number with a repository, or a ref` });
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return { folder, items, unread, owns };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The names of the repos that the config names, each with its folder or why it has none.
|
|
128
|
+
function namedRepos(top, config) {
|
|
129
|
+
const out = new Map();
|
|
130
|
+
for (const o of outputRepos(top, config)) if (o.name) out.set(o.name, o.dir ? { dir: o.dir } : { unknown: o.unknown });
|
|
131
|
+
const c = config.central;
|
|
132
|
+
if (isMap(c) && typeof c.path === 'string' && c.path) {
|
|
133
|
+
const dir = isAbsolute(c.path) ? c.path : resolve(top, c.path);
|
|
134
|
+
out.set('central', existsSync(dir) ? { dir } : { unknown: `no clone at ${c.path}` });
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The references that a task's `ref` writes in the forms of SP-8:
|
|
140
|
+
// [{ written, key } | { written, unknown }].
|
|
141
|
+
function written(ref, names, own, folders) {
|
|
142
|
+
let rest = String(ref);
|
|
143
|
+
const out = [];
|
|
144
|
+
const take = (re, f) => {
|
|
145
|
+
rest = rest.replace(re, (...m) => {
|
|
146
|
+
const r = f(m);
|
|
147
|
+
if (!r) return m[0];
|
|
148
|
+
out.push({ written: m[0], ...r });
|
|
149
|
+
return ' '.repeat(m[0].length);
|
|
150
|
+
});
|
|
151
|
+
};
|
|
152
|
+
take(ISSUE_URL, (m) => ({ key: `${m[1]}/${m[2]}#${m[3]}` }));
|
|
153
|
+
take(QUALIFIED, (m) => ({ key: `${m[1]}/${m[2]}#${m[3]}` }));
|
|
154
|
+
take(NAMED, (m) => {
|
|
155
|
+
if (!names.has(m[1])) return null;
|
|
156
|
+
const r = folders(m[1]);
|
|
157
|
+
return r.repo ? { key: `${r.repo}#${m[2]}` } : { unknown: `${m[1]}: ${r.unknown}` };
|
|
158
|
+
});
|
|
159
|
+
take(BARE, (m) => (own.repo ? { key: `${own.repo}#${m[1]}` } : { unknown: `the request's own repository is not known (${own.unknown})` }));
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function issueHints(top, state, matchers, add, { folder, items, unread, owns }) {
|
|
164
|
+
const notKnown = [];
|
|
165
|
+
const names = namedRepos(top, state.config);
|
|
166
|
+
const cited = supersededIn(state);
|
|
167
|
+
const own = remoteRepo(top);
|
|
168
|
+
const remotes = new Map();
|
|
169
|
+
const folders = (name) => {
|
|
170
|
+
if (!remotes.has(name)) {
|
|
171
|
+
const n = names.get(name);
|
|
172
|
+
remotes.set(name, n.dir ? remoteRepo(n.dir) : { unknown: n.unknown });
|
|
173
|
+
}
|
|
174
|
+
return remotes.get(name);
|
|
175
|
+
};
|
|
176
|
+
const byKey = new Map();
|
|
177
|
+
for (const it of items) if (!byKey.has(slug(it.key))) byKey.set(slug(it.key), it);
|
|
178
|
+
const withRef = items.filter((it) => it.ref);
|
|
179
|
+
|
|
180
|
+
for (const u of unread) add('hint', 'issue-unread', u.file, '-', `not read: ${u.why}`);
|
|
181
|
+
if (unread.length) notKnown.push(`the files in ${folder} that were not read: ${unread.map((u) => u.file).join(', ')}`);
|
|
182
|
+
|
|
183
|
+
// The items that each task of each open request names, in order (SP-8, SP-9).
|
|
184
|
+
const named = []; // [{ r, task, it }]
|
|
185
|
+
const tasked = []; // the open requests with a task that has a ref
|
|
186
|
+
for (const r of state.requests.values()) {
|
|
187
|
+
if (!r.open) continue;
|
|
188
|
+
for (const t of Array.isArray(r.data?.tasks) ? r.data.tasks : []) {
|
|
189
|
+
if (!isMap(t) || t.ref == null) continue;
|
|
190
|
+
if (!tasked.includes(r)) tasked.push(r);
|
|
191
|
+
const task = String(t.id ?? '-');
|
|
192
|
+
const id = `${r.name}/${task}`;
|
|
193
|
+
const found = [];
|
|
194
|
+
for (const w of written(t.ref, names, own, folders)) {
|
|
195
|
+
const it = w.key && byKey.get(slug(w.key));
|
|
196
|
+
if (it) found.push(it);
|
|
197
|
+
else add('hint', 'issue-missing', w.written, id, w.key ? `${w.written}: no item in ${folder} is ${w.key}` : `${w.written}: ${w.unknown}, so it names no item`);
|
|
198
|
+
}
|
|
199
|
+
for (const it of withRef) if (!found.includes(it) && holds(String(t.ref), it.ref)) found.push(it);
|
|
200
|
+
if (!found.length && !written(t.ref, names, own, folders).length) add('hint', 'issue-missing', recordPath(r.name), id, `its ref names no item in ${folder}: ${JSON.stringify(String(t.ref))}`);
|
|
201
|
+
for (const it of found) named.push({ r, task, it });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Each item once, with the ID of the first task that names it.
|
|
206
|
+
const first = new Map();
|
|
207
|
+
for (const n of named) if (!first.has(n.it)) first.set(n.it, n);
|
|
208
|
+
for (const [it, n] of first) {
|
|
209
|
+
const id = `${n.r.name}/${n.task}`;
|
|
210
|
+
const texts = [{ where: it.key, text: it.body }];
|
|
211
|
+
(Array.isArray(it.comments) ? it.comments : []).forEach((c, i) => {
|
|
212
|
+
if (isMap(c) && c.isMinimized !== true) texts.push({ where: `${it.key}/comment-${i + 1}`, text: c.body });
|
|
213
|
+
});
|
|
214
|
+
const other = own.repo && it.repository && slug(it.repository) !== slug(own.repo);
|
|
215
|
+
for (const { where, text } of texts) {
|
|
216
|
+
if (typeof text !== 'string' || !text) continue;
|
|
217
|
+
for (const c of cited(text)) add('hint', 'issue-superseded', `${where}:${c.line}`, id, citedMessage(c));
|
|
218
|
+
if (other) {
|
|
219
|
+
text.replace(/\r\n?/g, '\n').split('\n').forEach((l, i) => {
|
|
220
|
+
const refs = [...l.matchAll(BARE)].map((m) => `#${m[1]}`);
|
|
221
|
+
if (refs.length) add('hint', 'issue-bare-ref', `${where}:${i + 1}`, id, `${refs.join(', ')} in an item of ${it.repository} points to ${it.repository}, not to ${own.repo}: write ${refs.map((x) => `${own.repo}${x}`).join(', ')} if that is meant`);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
for (const h of phraseHits(text, matchers)) add('hint', 'phrase', `${where}:${h.line}`, id, phraseMessage(h));
|
|
225
|
+
}
|
|
226
|
+
if (!own.repo) continue;
|
|
227
|
+
if (!it.repository) notKnown.push(`the issue-bare-ref check of ${it.key}: its repository is not known`);
|
|
228
|
+
}
|
|
229
|
+
if (!own.repo) notKnown.push(`the request's own repository, for #n and the issue-bare-ref check: ${own.unknown}`);
|
|
230
|
+
|
|
231
|
+
// Children (SP-10): once for each request and item. The own item is the one
|
|
232
|
+
// that assuredloop-requests.json names (own-item SP-7); nothing else names it.
|
|
233
|
+
const ownOf = new Map();
|
|
234
|
+
const ownItem = (r) => {
|
|
235
|
+
if (ownOf.has(r.name)) return ownOf.get(r.name);
|
|
236
|
+
let ids;
|
|
237
|
+
const value = owns.requests?.get(r.name);
|
|
238
|
+
if (owns.absent) ids = { unknown: `${folder} has no ${OWN_ITEMS}` };
|
|
239
|
+
else if (!owns.requests) ids = { unknown: `${folder.replace(/\/+$/, '')}/${OWN_ITEMS} was not read` };
|
|
240
|
+
else if (value === undefined) ids = { unknown: `${OWN_ITEMS} names no own item for ${r.name}` };
|
|
241
|
+
else {
|
|
242
|
+
const w = written(value, names, own, folders);
|
|
243
|
+
const lost = w.find((x) => x.unknown);
|
|
244
|
+
if (lost) ids = { unknown: `its own item ${value}: ${lost.unknown}` };
|
|
245
|
+
else if (w.length) {
|
|
246
|
+
const keys = new Set(w.map((x) => slug(x.key)));
|
|
247
|
+
const its = items.filter((x) => keys.has(slug(x.key)));
|
|
248
|
+
ids = { keys: new Set([...keys, ...its.map((x) => x.ref).filter(Boolean).map(slug)]), urls: new Set([value, ...its.map((x) => x.url).filter(Boolean)]), ref: its.find((x) => x.ref)?.ref ?? null };
|
|
249
|
+
} else {
|
|
250
|
+
// Another tracker's key: the item that holds it as its ref, or else the value itself.
|
|
251
|
+
const it = items.find((x) => x.ref && holds(value, x.ref));
|
|
252
|
+
ids = it ? { keys: new Set([it.key, it.ref].map(slug)), urls: new Set([value, it.url].filter(Boolean)), ref: it.ref }
|
|
253
|
+
: { keys: new Set([slug(value)]), urls: new Set([value]), ref: value };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
ownOf.set(r.name, ids);
|
|
257
|
+
return ids;
|
|
258
|
+
};
|
|
259
|
+
const seen = new Set();
|
|
260
|
+
for (const n of named) {
|
|
261
|
+
const pair = `${n.r.name} ${slug(n.it.key)}`;
|
|
262
|
+
if (seen.has(pair)) continue;
|
|
263
|
+
seen.add(pair);
|
|
264
|
+
const mine = ownItem(n.r);
|
|
265
|
+
if (mine.unknown) continue;
|
|
266
|
+
const it = n.it;
|
|
267
|
+
if (mine.keys.has(slug(it.key)) || (it.ref && mine.keys.has(slug(it.ref)))) continue;
|
|
268
|
+
if (it.parent === undefined) {
|
|
269
|
+
notKnown.push(`whether ${it.key} is a child of the own item of ${n.r.name}: the item has no parent field`);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const p = it.parent;
|
|
273
|
+
// A parent's number is in the item's repository only when no url names its own.
|
|
274
|
+
const parent = isMap(p) ? [keyOf(p.url), typeof p.ref === 'string' ? p.ref : null,
|
|
275
|
+
!keyOf(p.url) && Number.isInteger(p.number) && it.repository ? `${it.repository}#${p.number}` : null].filter(Boolean) : [];
|
|
276
|
+
// A parent's url names the own item when it is the own item's url or the value, or holds its ref.
|
|
277
|
+
const url = isMap(p) && typeof p.url === 'string' && p.url ? p.url : null;
|
|
278
|
+
if (p !== null && !parent.length && !url) {
|
|
279
|
+
notKnown.push(`whether ${it.key} is a child of the own item of ${n.r.name}: its parent names no item`);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (url && (mine.urls.has(url) || (mine.ref && holds(url, mine.ref)))) continue;
|
|
283
|
+
if (parent.some((x) => mine.keys.has(slug(x)))) continue;
|
|
284
|
+
const named = parent[0] ?? url;
|
|
285
|
+
add('hint', 'issue-not-sub', it.key, `${n.r.name}/${n.task}`, `is not a child of the request's own item (${[...mine.keys][0]})${named ? `; its parent is ${named}` : ''}`);
|
|
286
|
+
}
|
|
287
|
+
// Once for each request with a task that has a ref, whether or not its items are in the folder.
|
|
288
|
+
for (const r of tasked) {
|
|
289
|
+
const why = ownItem(r).unknown;
|
|
290
|
+
if (why) notKnown.push(`whether the items of ${r.name} are children of its own item: ${why}`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const others = items.filter((it) => !first.has(it)).length;
|
|
294
|
+
if (others) notKnown.push(`${others} item${others === 1 ? '' : 's'} in ${folder} that no task names, not checked`);
|
|
295
|
+
add('info', 'issues', '-', '-', `checked ${first.size} items for the tasks of the open requests, from ${folder}`);
|
|
296
|
+
return notKnown;
|
|
297
|
+
}
|
package/src/v4/markers.js
CHANGED
|
@@ -163,6 +163,69 @@ export function sameBlocks(text, paragraphs, { file = '', living = new Set(parag
|
|
|
163
163
|
|
|
164
164
|
const emptyLinks = () => Object.fromEntries(Object.values(LINK_WORDS).map((k) => [k, []]));
|
|
165
165
|
|
|
166
|
+
// The tokens of a marker line that are not its ID, one kind or a link (#4): a
|
|
167
|
+
// word that is no kind or a second kind, a word that is no link word, a token
|
|
168
|
+
// that holds `<!--` or `-->`, and a link with an empty item. An HTML renderer
|
|
169
|
+
// can show text that such a token hides.
|
|
170
|
+
export function markerExtras(line, kinds = KINDS) {
|
|
171
|
+
const m = MARKER.exec(line);
|
|
172
|
+
if (!m) return [];
|
|
173
|
+
const known = new Set(Object.values(kinds).flat());
|
|
174
|
+
const extras = [];
|
|
175
|
+
let kind = false;
|
|
176
|
+
for (const token of m[2].trim().split(/[ \t]+/).filter(Boolean)) {
|
|
177
|
+
const c = token.indexOf(':');
|
|
178
|
+
if (/<!--|-->/.test(token)) extras.push(token);
|
|
179
|
+
else if (c < 0) {
|
|
180
|
+
if (kind || !known.has(token)) extras.push(token);
|
|
181
|
+
kind = true;
|
|
182
|
+
} else if (!Object.hasOwn(LINK_WORDS, token.slice(0, c)) || token.slice(c + 1).split(',').includes('')) extras.push(token);
|
|
183
|
+
}
|
|
184
|
+
return extras;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// The lines of `text`, outside fenced code, that start like a marker: `<!--`
|
|
188
|
+
// and an ID, spaces allowed. Each is { id, line, extras }: the extras of a
|
|
189
|
+
// well-formed marker, or null for a line that MARKER does not read, whose
|
|
190
|
+
// paragraph is then lost (#4).
|
|
191
|
+
export function markerLines(text, kinds = KINDS) {
|
|
192
|
+
const out = [];
|
|
193
|
+
let fence = null;
|
|
194
|
+
lf(text).split('\n').forEach((l, i) => {
|
|
195
|
+
const f = FENCE.exec(l);
|
|
196
|
+
if (fence) {
|
|
197
|
+
if (f && f[1][0] === fence[0] && f[1].length >= fence.length && l.trim() === f[1]) fence = null;
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (f) { fence = f[1]; return; }
|
|
201
|
+
const m = /^[ \t]*<!--[ \t]*(ADR-[0-9]+-[0-9]+|[A-Z][A-Z0-9]*-[0-9]+)/.exec(l);
|
|
202
|
+
if (m) out.push({ id: m[1], line: i + 1, extras: MARKER.test(l) ? markerExtras(l, kinds) : null });
|
|
203
|
+
});
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Whether marker line `now` is marker line `was` with links added and nothing
|
|
208
|
+
// else (schema.md 5, #4): with each link item that `was` does not hold taken
|
|
209
|
+
// out, and each token left with no item, `now` is `was` exactly. A marker
|
|
210
|
+
// with extras never qualifies, so an added item cannot hide text.
|
|
211
|
+
export function onlyLinksAdded(was, now, kinds = KINDS) {
|
|
212
|
+
const w = MARKER.exec(was);
|
|
213
|
+
const n = MARKER.exec(now);
|
|
214
|
+
if (!w || !n || markerExtras(now, kinds).length) return false;
|
|
215
|
+
const old = new Set((w[2].match(/\S+/g) ?? []).filter((t) => t.includes(':')).flatMap((t) => {
|
|
216
|
+
const c = t.indexOf(':');
|
|
217
|
+
return t.slice(c + 1).split(',').map((x) => `${t.slice(0, c)}:${x}`);
|
|
218
|
+
}));
|
|
219
|
+
const kept = (n[2].match(/[ \t]+\S+/g) ?? []).map((piece) => {
|
|
220
|
+
const c = piece.indexOf(':');
|
|
221
|
+
if (c < 0) return piece;
|
|
222
|
+
const items = piece.slice(c + 1).split(',').filter((x) => old.has(`${piece.slice(0, c).trim()}:${x}`));
|
|
223
|
+
return items.length ? `${piece.slice(0, c + 1)}${items.join(',')}` : '';
|
|
224
|
+
}).join('');
|
|
225
|
+
const start = now.indexOf(n[1]) + n[1].length;
|
|
226
|
+
return now.slice(0, start) + kept + now.slice(start + n[2].length) === was;
|
|
227
|
+
}
|
|
228
|
+
|
|
166
229
|
export function parseMarkdown(text, file, options = {}) {
|
|
167
230
|
const kinds = options.kinds ?? KINDS;
|
|
168
231
|
const known = new Set(Object.values(kinds).flat());
|