@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.
@@ -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> [--words <quote>] [--presented <x>] [--transcribed-by <who>] [--yes]
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);
@@ -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((r) => r.data?.outputs ?? [])
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.data?.outputs ?? []) {
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
- // A change spec's IDs are its request's own, so they are compared as <request>/<ID>.
314
- const qualified = (docs) => docs.flatMap((d) => d.paragraphs.map((p) => (d.request ? { ...p, id: `${d.request}/${p.id}` } : p)));
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, ...(change.length ? { change } : {}), ...(hint ? { hint } : {}) };
441
+ return { id: p.id, ...(p.kind ? { kind: p.kind } : {}), text_sha256: p.sha256, display: p.displayNumber, ...(hint ? { hint } : {}) };
335
442
  });
336
- const removed = (diffs ?? []).filter((x) => x.sha256 === null && x.file === d.file).map((x) => (d.request ? x.id.slice(d.request.length + 1) : x.id));
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
 
@@ -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());