@arjunkhera/atlas 0.3.13 → 0.3.15

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.
@@ -466,6 +466,25 @@ export function newestRunPerScenario(evidenceDir) {
466
466
  return new Map([...best].map(([scenario, b]) => [scenario, b.name]));
467
467
  }
468
468
 
469
+ // The newest run of the whole folder, by the start time of its evidence (a name breaks a tie).
470
+ export function newestRunId(evidenceDir) {
471
+ let best = null;
472
+ if (!existsSync(evidenceDir)) return null;
473
+ for (const name of readdirSync(evidenceDir)) {
474
+ const dir = join(evidenceDir, name);
475
+ if (!statSync(dir).isDirectory()) continue;
476
+ for (const file of readdirSync(dir)) {
477
+ if (!file.endsWith('.json')) continue;
478
+ let data;
479
+ try { data = JSON.parse(readFileSync(join(dir, file), 'utf8')); } catch { continue; }
480
+ if (data?.evidence !== 1 || data.fault_run || !data.scenario) continue;
481
+ const started = String(data.started);
482
+ if (!best || started > best.started || (started === best.started && name > best.name)) best = { started, name };
483
+ }
484
+ }
485
+ return best ? best.name : null;
486
+ }
487
+
469
488
  // The run half reads the files of one run only, and only evidence of the
470
489
  // scenario file as it is now (source_sha256).
471
490
  export function checkRun({ evidenceDir, runId = null, environment = null, scenarios, rel, findings }) {
@@ -488,8 +507,8 @@ export function checkRun({ evidenceDir, runId = null, environment = null, scenar
488
507
  runOf.set(data.scenario, id);
489
508
  }
490
509
  }
491
- const reached = new Set(['pass', 'fail', 'not exercised', 'not here']);
492
- const ran = new Set(['pass', 'fail', 'not exercised']);
510
+ const reached = new Set(['pass', 'fail', 'not exercised', 'not here', 'flaky']);
511
+ const ran = new Set(['pass', 'fail', 'not exercised', 'flaky']);
493
512
  for (const s of scenarios) {
494
513
  if (!s.id) continue;
495
514
  const all = records.filter((r) => r.data.scenario === s.id);
@@ -0,0 +1,408 @@
1
+ // The daily sweep and the coverage view (design sections 11.3 and 11.4).
2
+ //
3
+ // atlas tests sweep --root <area> --evidence <dir> [--since <ISO date>] [--base <ref>] [--out <file>] [--text]
4
+ // atlas tests covers <item-id> --root <area> [--evidence <dir>] [--json]
5
+ //
6
+ // Pure code: no network, no key, no model. The sweep reads the evidence folders of the runs in
7
+ // <dir> (one folder for each run; the folders may come from several CI runs) and the git history of
8
+ // the repo that holds the area. It returns findings for the five checks of section 11.3:
9
+ //
10
+ // 1 flaky each scenario/assertion with a `flaky` result, and on how many commits
11
+ // 2 stand-ins each contract scenario whose newest result is `blocked` (no test instance)
12
+ // 3 no scenario a pull request (a first-parent change of the base) changed a path that a scenario `covers`,
13
+ // and no scenario that covers that path changed in the same pull request
14
+ // 4 quarantine NOT BUILT. The kit has no quarantine field and no `quarantined` result yet
15
+ // (RESULTS in evidence.mjs), so this check reports none and says so. No format is invented.
16
+ // 5 map facts NOT BUILT. link-check.mjs reads no map format, so the check is skipped with a note
17
+ //
18
+ // The sweep never files an item. Each finding carries an `issue` block (title, body, labels, a
19
+ // dedupe marker). A scheduled workflow opens the issues with `atlas tests issues`. That verb is in
20
+ // the package (door/lib/issues.mjs), not in this kit, because it reaches the network. The lead
21
+ // then adopts each issue into the tracker.
22
+ //
23
+ // The evidence is data from a run of pull request code, so the sweep does not trust it. It keeps
24
+ // an assertion only when its id has the shape `scenario/eN`, its scenario exists in the area on
25
+ // disk, and its way in is one that the area names. It keeps a run folder only when the name is a
26
+ // CI run id or the kit's own run id. It counts what it drops in `notes`. Free text from the
27
+ // evidence goes into an issue only in a fenced block, cut to 200 characters.
28
+ import { spawnSync } from 'node:child_process';
29
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
30
+ import { basename, dirname, join, relative, resolve } from 'node:path';
31
+ import { readAttempts, settleRecords, loadAreaScenarios, clean, touches, keyOf } from './verdict.mjs';
32
+
33
+ export const LABEL = 'atlas-sweep';
34
+ const DAY = 24 * 60 * 60 * 1000;
35
+ // A pull request is found by the next sweep too, so two days make a sweep that missed a day lose nothing.
36
+ // The marker of a finding keeps a second sweep from opening a second issue.
37
+ const WINDOW = 2 * DAY;
38
+ const SCENARIO_FILE = /\.md$/;
39
+ const NOT_SCENARIO = /(^|\/)(map|README)\.md$/;
40
+
41
+ const usage = (message) => Object.assign(new Error(message), { usage: true });
42
+ const list = (value) => (Array.isArray(value) ? value.map(String) : value === undefined || value === null ? [] : [String(value)]);
43
+
44
+ function git(cwd, args, { allowFail = false } = {}) {
45
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
46
+ if (r.error) throw new Error(`git cannot run: ${r.error.message}`);
47
+ if (r.status !== 0) {
48
+ if (allowFail) return null;
49
+ throw new Error(`git ${args.join(' ')} failed: ${(r.stderr || r.stdout || '').trim().split('\n')[0]}`);
50
+ }
51
+ return r.stdout;
52
+ }
53
+
54
+ // ---------------------------------------------------------------- the evidence of many runs
55
+
56
+ // A run folder is named for a CI run (ci<run id>-<attempt>, with an optional rerun count) or by the
57
+ // kit's own run id (run<6 hex>, fresh.mjs). Any other name is dropped by the sweep.
58
+ const RUN_NAME = /^(ci\d+-\d+(-\d+)?|run[0-9a-f]{6})$/;
59
+ const ASSERTION_ID = /^[a-z0-9-]+\/e[0-9]+$/;
60
+ const COMMIT = /^[0-9a-f]{40}$/;
61
+ const hasJson = (dir) => readdirSync(dir).some((n) => n.endsWith('.json'));
62
+ const dirsOf = (dir) => readdirSync(dir).sort().filter((n) => statSync(join(dir, n)).isDirectory());
63
+
64
+ // Reads every run folder of the evidence folder. Returns [{ runId, scenarios: Map(id -> settled record) }].
65
+ // A folder with no JSON of its own may hold run folders one level down (a sweep downloads each CI run
66
+ // into its own folder). A run with no evidence file that counts is left out. A scenario with more than
67
+ // two attempts keeps the settled record of its first and last attempt (settleRecords), as `verdict` does.
68
+ //
69
+ // With `filter` ({ scenarios: Set of ids, ways: Set of names }) the evidence is not trusted: see the
70
+ // header. The array then has a `dropped` property that counts what was left out.
71
+ export function readRuns(evidenceDir, filter = null) {
72
+ const dir = resolve(evidenceDir);
73
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) throw usage(`the evidence folder ${dir} does not exist`);
74
+ const dropped = { folders: 0, scenarios: 0, assertions: 0, ways: 0 };
75
+ const candidates = [];
76
+ for (const name of dirsOf(dir)) {
77
+ if (hasJson(join(dir, name))) candidates.push({ parent: dir, name });
78
+ else for (const inner of dirsOf(join(dir, name))) candidates.push({ parent: join(dir, name), name: inner });
79
+ }
80
+ const runs = [];
81
+ for (const { parent, name } of candidates) {
82
+ if (filter && !RUN_NAME.test(name)) { dropped.folders += 1; continue; }
83
+ let attempts;
84
+ try { attempts = readAttempts(parent, name); } catch { continue; }
85
+ if (!attempts.size) continue;
86
+ const scenarios = new Map();
87
+ for (const [id, records] of attempts) {
88
+ const { record } = settleRecords(records);
89
+ if (!filter) { scenarios.set(id, record); continue; }
90
+ if (!filter.scenarios.has(id)) { dropped.scenarios += 1; continue; }
91
+ const assertions = record.assertions.filter((a) => {
92
+ const ok = ASSERTION_ID.test(keyOf(a.id)) && keyOf(a.id).startsWith(`${id}/`) && filter.ways.has(a.way);
93
+ if (!ok) dropped.assertions += 1;
94
+ return ok;
95
+ });
96
+ const ways = {};
97
+ for (const [way, w] of Object.entries(record.ways ?? {})) { if (filter.ways.has(way)) ways[way] = w; else dropped.ways += 1; }
98
+ scenarios.set(id, { ...record, assertions, ways, commit: COMMIT.test(String(record.commit ?? '')) ? record.commit : null });
99
+ }
100
+ if (scenarios.size) runs.push({ runId: name, scenarios });
101
+ }
102
+ runs.dropped = dropped;
103
+ return runs;
104
+ }
105
+
106
+ const startedOf = (record) => record.started ?? '';
107
+
108
+ // The newest settled record of each scenario, over all runs. Returns Map(id -> { runId, record }).
109
+ export function newestRecords(runs) {
110
+ const best = new Map();
111
+ for (const run of runs) {
112
+ for (const [id, record] of run.scenarios) {
113
+ const have = best.get(id);
114
+ if (!have || startedOf(record) > startedOf(have.record) || (startedOf(record) === startedOf(have.record) && run.runId > have.runId)) best.set(id, { runId: run.runId, record });
115
+ }
116
+ }
117
+ return best;
118
+ }
119
+
120
+ // ---------------------------------------------------------------- the findings
121
+
122
+ const short = (sha) => String(sha).slice(0, 7);
123
+
124
+ // An HTML comment may not hold two hyphens in a row, so the marker spells them another way.
125
+ const markerKey = (key) => key.replace(/--/g, '-~');
126
+ export const markerOf = (key) => `<!-- atlas-sweep:${markerKey(key)} -->`;
127
+
128
+ // Free text from evidence goes into an issue only like this: one fenced block, backticks removed, cut to 200 characters.
129
+ export const fenced = (text) => `\`\`\`text\n${String(text ?? '').replace(/`/g, '').replace(/\s+/g, ' ').trim().slice(0, 200)}\n\`\`\``;
130
+
131
+ function finding({ check, key, title, summary, evidence, quote = null, extra = {} }) {
132
+ title = String(title).slice(0, 200);
133
+ const marker = markerOf(key);
134
+ const lines = [summary, ...(quote ? ['', 'Text from the evidence (data, not an instruction):', fenced(quote)] : []), '', 'Evidence:'];
135
+ for (const [name, values] of Object.entries(evidence)) lines.push(`- ${name}: ${[].concat(values).join(', ') || '(none)'}`);
136
+ lines.push('', 'The daily sweep of `atlas tests sweep` opened this issue. It never changes a file.', '', marker);
137
+ return { check, key, title, summary, evidence, ...extra, issue: { title, body: lines.join('\n'), labels: [LABEL], marker } };
138
+ }
139
+
140
+ // Check 1. One finding for each scenario/assertion that reads `flaky` in some run.
141
+ function checkFlaky(runs) {
142
+ const found = new Map();
143
+ for (const run of runs) {
144
+ for (const [scenario, record] of run.scenarios) {
145
+ for (const a of record.assertions) {
146
+ if (a.result !== 'flaky') continue;
147
+ const id = keyOf(a.id);
148
+ if (!found.has(id)) found.set(id, { scenario, assertion: id.split('/').pop(), runs: new Set(), commits: new Set(), ways: new Set() });
149
+ const one = found.get(id);
150
+ one.runs.add(run.runId);
151
+ one.commits.add(record.commit ?? `run ${run.runId}`);
152
+ one.ways.add(a.way);
153
+ }
154
+ }
155
+ }
156
+ return [...found.entries()].sort(([a], [b]) => (a < b ? -1 : 1)).map(([id, one]) => {
157
+ const commits = [...one.commits].sort();
158
+ const known = commits.filter((c) => !c.startsWith('run '));
159
+ const unit = known.length === commits.length ? 'commit' : 'run';
160
+ return finding({
161
+ check: 1,
162
+ key: `flaky:${id}`,
163
+ title: `Fix the flaky test ${id}`,
164
+ summary: `The assertion ${id} failed, then passed, in ${commits.length} ${unit}${commits.length === 1 ? '' : 's'}. Find the cause, usually a wait that is too short. A quarantine is a pull request that a person merges.`,
165
+ evidence: { runs: [...one.runs].sort(), commits: commits.map((c) => (c.startsWith('run ') ? c : short(c))), ways: [...one.ways].sort() },
166
+ extra: { scenario: one.scenario, assertion: one.assertion, count: commits.length, count_unit: unit },
167
+ });
168
+ });
169
+ }
170
+
171
+ // Check 2. One finding for each contract scenario whose newest result is `blocked`.
172
+ function checkStandIns(newest, scenarios, tests) {
173
+ const out = [];
174
+ const standIns = new Set(Object.keys(tests?.['stand-ins'] ?? {}));
175
+ for (const s of scenarios) {
176
+ if (!s.id || String(s.fields.kind ?? '') !== 'contract') continue;
177
+ const one = newest.get(s.id);
178
+ if (!one || one.record.verdict !== 'blocked') continue;
179
+ const names = list(s.fields.through).filter((w) => standIns.has(w));
180
+ const label = names.length ? `${names.join(', ')} stand-in` : `stand-in of ${s.id}`;
181
+ const why = Object.values(one.record.ways ?? {}).map((w) => w.reason).find(Boolean);
182
+ out.push(finding({
183
+ check: 2,
184
+ key: `stand-in:${s.id}`,
185
+ title: `No test instance proves the ${label}`,
186
+ summary: `The contract scenario ${s.id} is blocked in its newest run, ${one.runId}. No test instance answers for the ${label}, so nothing proves that it answers like the real service.`,
187
+ quote: why ?? null,
188
+ evidence: { scenario: s.id, run: one.runId, commit: one.record.commit ? short(one.record.commit) : '(not recorded)' },
189
+ extra: { scenario: s.id },
190
+ }));
191
+ }
192
+ return out;
193
+ }
194
+
195
+ // Check 3. One finding for each pull request, found in the first-parent history of the base. A merge
196
+ // commit is one pull request: its files are the diff of its first parent and itself. A commit with one
197
+ // parent (a squash merge or a direct push) counts as itself. The pull request is answered for a changed
198
+ // path only when a scenario that covers that path changed in the same pull request.
199
+ function checkNoScenario({ area, scenarios, since, base }) {
200
+ const prefix = git(area, ['rev-parse', '--show-prefix']).trim();
201
+ const baseSha = (git(area, ['rev-parse', '--verify', '--quiet', '--end-of-options', `${base}^{commit}`], { allowFail: true }) ?? '').trim();
202
+ if (!baseSha) throw usage(`the base "${base}" is not a commit in this repo`);
203
+ const raw = git(area, ['log', '--first-parent', `--since=${since.toISOString()}`, '--format=%H%x1f%P%x1f%cI%x1f%s', '--end-of-options', baseSha]);
204
+ const filesOf = (sha, parents) => (parents.length
205
+ ? git(area, ['diff', '--name-only', '--no-renames', '-z', parents[0], sha])
206
+ : git(area, ['diff-tree', '--root', '-r', '--name-only', '--no-renames', '--no-commit-id', '-z', sha])
207
+ ).split('\0').filter(Boolean).map(clean);
208
+ // The repo path of each scenario file, and the covers of each scenario.
209
+ const mine = scenarios.filter((s) => s.id).map((s) => ({ id: s.id, file: clean(`${prefix}${relative(area, s.file)}`), covers: list(s.fields.covers) }));
210
+ const out = [];
211
+ for (const line of raw.split('\n').filter(Boolean)) {
212
+ const [sha, parentText, date, subject] = line.split('\x1f');
213
+ const files = filesOf(sha, parentText.split(' ').filter(Boolean));
214
+ const unanswered = [];
215
+ const ids = new Set();
216
+ for (const f of files) {
217
+ const covering = mine.filter((s) => s.covers.some((c) => touches([f], c)));
218
+ if (!covering.length || covering.some((s) => files.includes(s.file))) continue;
219
+ unanswered.push(f);
220
+ for (const s of covering) ids.add(s.id);
221
+ }
222
+ if (!unanswered.length) continue;
223
+ const paths = unanswered.sort().slice(0, 20);
224
+ const names = [...ids].sort();
225
+ out.push(finding({
226
+ check: 3,
227
+ key: `no-scenario:${sha}`,
228
+ title: `Change ${short(sha)} touched ${paths.slice(0, 3).join(', ')}${paths.length > 3 ? ' and more' : ''} with no scenario change`,
229
+ summary: `The change ${short(sha)} to the base branch touched ${paths.length} path(s) that a scenario covers. No scenario that covers them changed in the same pull request. The scenarios are ${names.join(', ')}. Decide whether a scenario must change.`,
230
+ quote: subject,
231
+ evidence: { commit: sha, date, paths, scenarios: names, parents: parentText.split(' ').filter(Boolean).length },
232
+ extra: { commit: sha },
233
+ }));
234
+ }
235
+ return out;
236
+ }
237
+
238
+ // ---------------------------------------------------------------- the sweep
239
+
240
+ export function sweep({ root, evidence, since = null, base = 'HEAD', now = new Date() }) {
241
+ if (!root) throw usage('give the area: --root <area>');
242
+ if (!evidence) throw usage('give the evidence folder: --evidence <dir>');
243
+ const area = resolve(root);
244
+ if (!existsSync(area)) throw usage(`the area ${area} does not exist`);
245
+ if (git(area, ['rev-parse', '--is-inside-work-tree'], { allowFail: true }) === null) throw usage(`${area} is not inside a git repo, so the sweep cannot read its history`);
246
+ if (String(base).startsWith('-')) throw usage(`the base "${base}" is not a ref`);
247
+ let sinceDate;
248
+ if (since === null || since === undefined) sinceDate = new Date(now.getTime() - WINDOW);
249
+ else {
250
+ sinceDate = new Date(since);
251
+ if (Number.isNaN(sinceDate.getTime())) throw usage(`--since "${since}" is not a date; give an ISO date such as 2035-01-01 or 2035-01-01T08:00:00Z`);
252
+ }
253
+ // The scenarios of the tree on disk (the sweep runs on the base branch) say what evidence can mean.
254
+ const { tests, scenarios } = loadAreaScenarios(area);
255
+ const ways = new Set([...Object.keys(tests?.['ways-in'] ?? {}), ...scenarios.flatMap((s) => s.through)]);
256
+ const runs = readRuns(evidence, { scenarios: new Set(scenarios.map((s) => s.id).filter(Boolean)), ways });
257
+ const newest = newestRecords(runs);
258
+
259
+ const findings = [
260
+ ...checkFlaky(runs),
261
+ ...checkStandIns(newest, scenarios, tests),
262
+ ...checkNoScenario({ area, scenarios, since: sinceDate, base }),
263
+ ].sort((a, b) => a.check - b.check || (a.key < b.key ? -1 : 1));
264
+ const notes = [
265
+ 'check 4 (quarantine dates): not built. The kit has no quarantine field and no `quarantined` result, so it reports none. No format is invented.',
266
+ 'check 5 (map facts that no test uses): skipped. link-check.mjs reads no map format, so there is nothing exact to compare.',
267
+ ];
268
+ const d = runs.dropped;
269
+ if (d.folders || d.scenarios || d.assertions || d.ways) {
270
+ notes.unshift(`evidence left out as untrusted: ${d.folders} run folder(s) with a name that is no run id, ${d.scenarios} scenario record(s) that are not in this area, ${d.assertions} assertion(s) with a bad id or way in, ${d.ways} way(s) in that the area does not name.`);
271
+ }
272
+ return {
273
+ sweep: 1,
274
+ generated: now.toISOString(),
275
+ area: tests?.area ?? basename(area),
276
+ root: area,
277
+ since: sinceDate.toISOString(),
278
+ base,
279
+ runs: runs.map((r) => r.runId),
280
+ checks: { 1: 'flaky', 2: 'stand-ins', 3: 'code with no scenario', 4: 'quarantine dates (not built)', 5: 'map facts (skipped)' },
281
+ notes,
282
+ findings,
283
+ };
284
+ }
285
+
286
+ const cell = (text) => String(text ?? '').replace(/\s+/g, ' ').replace(/\|/g, '\\|').trim();
287
+
288
+ export function renderSweep(result) {
289
+ const out = [];
290
+ out.push(`Sweep of ${result.area}: ${result.runs.length} run(s) read, commits since ${result.since}, base ${result.base}.`);
291
+ out.push('');
292
+ if (!result.findings.length) out.push('No finding.');
293
+ else {
294
+ out.push('| Check | Key | Proposed item |');
295
+ out.push('|---|---|---|');
296
+ for (const f of result.findings) out.push(`| ${f.check} | ${cell(f.key)} | ${cell(f.title)} |`);
297
+ }
298
+ out.push('');
299
+ for (const n of result.notes) out.push(`Note: ${n}`);
300
+ out.push(`${result.findings.length} finding(s).`);
301
+ return out;
302
+ }
303
+
304
+ export function sweepCommand({ evidence, since, base, out, text = false }, root, line) {
305
+ try {
306
+ const result = sweep({ root, evidence, since: since ?? null, base: base ?? 'HEAD' });
307
+ const json = `${JSON.stringify(result, null, 2)}\n`;
308
+ if (out) {
309
+ mkdirSync(dirname(resolve(out)), { recursive: true });
310
+ writeFileSync(resolve(out), json);
311
+ if (text) for (const t of renderSweep(result)) line(t);
312
+ else line(`sweep: ${result.findings.length} finding(s) written to ${out}`);
313
+ } else if (text) for (const t of renderSweep(result)) line(t);
314
+ else line(json.trimEnd());
315
+ return 0;
316
+ } catch (error) {
317
+ line(`sweep: ${error.message}`);
318
+ return 2;
319
+ }
320
+ }
321
+
322
+ // ---------------------------------------------------------------- the coverage of an item
323
+
324
+ // The one result of an assertion from its results for each way in. A fail wins, then the other
325
+ // bad results, then pass.
326
+ const WORST = ['fail', 'flaky', 'blocked', 'not checked', 'not exercised', 'unsure', 'quarantined'];
327
+ function combine(results) {
328
+ for (const w of WORST) if (results.includes(w)) return w;
329
+ if (results.every((r) => r === 'not here')) return 'not here';
330
+ return 'pass';
331
+ }
332
+
333
+ // Lists every scenario whose front matter `items:` names the item: its assertions by full id
334
+ // (area/scenario/eN), its covers paths, and the newest result of each assertion from the evidence.
335
+ // An assertion with no evidence reads "no run". Evidence of an older version of the words has
336
+ // `stale: true`. `evidence` may be null, then every assertion reads "no run".
337
+ export function coverage({ itemId, root, evidence = null }) {
338
+ if (!itemId) throw usage('give the item id: atlas tests covers <item-id> --root <area>');
339
+ const area = resolve(root);
340
+ if (!existsSync(area)) throw usage(`the area ${area} does not exist`);
341
+ const runs = evidence && existsSync(resolve(evidence)) ? readRuns(evidence) : [];
342
+ const newest = newestRecords(runs);
343
+ const { tests, scenarios } = loadAreaScenarios(area);
344
+ const areaName = tests?.area ?? basename(area);
345
+ const mine = scenarios.filter((s) => s.id && list(s.fields.items).includes(itemId));
346
+ return {
347
+ item: itemId,
348
+ area: areaName,
349
+ evidence: evidence ? resolve(evidence) : null,
350
+ runs: runs.map((r) => r.runId),
351
+ scenarios: mine.map((s) => {
352
+ const one = newest.get(s.id);
353
+ return {
354
+ scenario: s.id,
355
+ title: s.title,
356
+ kind: s.fields.kind ?? null,
357
+ covers: list(s.fields.covers),
358
+ run: one?.runId ?? null,
359
+ started: one?.record.started ?? null,
360
+ commit: one?.record.commit ?? null,
361
+ assertions: s.assertions.map((a) => {
362
+ const full = `${areaName}/${s.id}/${a.id}`;
363
+ const rows = (one?.record.assertions ?? []).filter((e) => keyOf(e.id) === `${s.id}/${a.id}`);
364
+ if (!rows.length) return { id: full, result: 'no run', ways: {}, stale: false };
365
+ const ways = Object.fromEntries(rows.map((e) => [e.way, e.result]));
366
+ const fp = /#([0-9a-f]+)$/.exec(a.fullId ?? '')?.[1] ?? null;
367
+ const stale = fp !== null && rows.some((e) => !String(e.id).endsWith(`#${fp}`));
368
+ return { id: full, result: combine(Object.values(ways)), ways, stale };
369
+ }),
370
+ };
371
+ }),
372
+ };
373
+ }
374
+
375
+ export function renderCoverage(result) {
376
+ const out = [];
377
+ out.push(`Item ${result.item}, area ${result.area}.`);
378
+ if (!result.scenarios.length) {
379
+ out.push('', `No scenario names this item in its front matter (items:). The item has no test coverage in ${result.area}.`);
380
+ return out;
381
+ }
382
+ out.push(`${result.scenarios.length} scenario(s) name it. ${result.runs.length ? `Evidence: ${result.runs.length} run(s) read.` : 'No evidence was read, so every result reads "no run".'}`);
383
+ for (const s of result.scenarios) {
384
+ out.push('', `${s.scenario}${s.kind ? ` (${s.kind})` : ''}: ${s.title}`);
385
+ out.push(` covers: ${s.covers.join(', ') || '(none)'}`);
386
+ out.push(` newest run: ${s.run ? `${s.run}${s.commit ? ` on ${short(s.commit)}` : ''}` : 'no run'}`);
387
+ for (const a of s.assertions) {
388
+ const ways = Object.entries(a.ways).map(([w, r]) => `${w} ${r}`).join(', ');
389
+ out.push(` ${a.id} ${a.result.toUpperCase()}${ways && new Set(Object.values(a.ways)).size > 1 ? ` (${ways})` : ''}${a.stale ? ' [the words changed since that run]' : ''}`);
390
+ }
391
+ }
392
+ const all = result.scenarios.flatMap((s) => s.assertions);
393
+ const pass = all.filter((a) => a.result === 'pass' && !a.stale).length;
394
+ out.push('', `${all.length} assertion(s): ${pass} pass, ${all.filter((a) => a.result === 'no run').length} with no run, ${all.length - pass - all.filter((a) => a.result === 'no run').length} other.`);
395
+ return out;
396
+ }
397
+
398
+ export function coversCommand({ itemId, evidence, json = false }, root, line) {
399
+ try {
400
+ const result = coverage({ itemId, root, evidence: evidence ?? join(resolve(root), 'test', 'evidence') });
401
+ if (json) line(JSON.stringify(result, null, 2));
402
+ else for (const t of renderCoverage(result)) line(t);
403
+ return 0;
404
+ } catch (error) {
405
+ line(`covers: ${error.message}`);
406
+ return error.usage ? 2 : 1;
407
+ }
408
+ }