@arjunkhera/atlas 0.3.13 → 0.3.14

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas",
3
- "version": "0.3.13",
3
+ "version": "0.3.14",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, for any repository.",
5
5
  "author": {
6
6
  "name": "Arjun Khera"
package/door/cli.mjs CHANGED
@@ -28,6 +28,7 @@ import { writeDesignPage, readTracker } from './lib/design-build.mjs';
28
28
  import { loadPrivateTerms } from './lib/privacy.mjs';
29
29
  import { checkCommand as testsCheck, writeCommand as testsWrite } from './lib/tests.mjs';
30
30
  import { proofCommand as testsProof } from './lib/proof.mjs';
31
+ import { verdictCommand as testsVerdict, namedCommand as testsNamed } from '../tests/verdict.mjs';
31
32
 
32
33
  export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
33
34
  const ATLAS_VERSION = packagePackageVersion(PACKAGE_ROOT);
@@ -63,6 +64,14 @@ const HELP = `atlas — the door into a repo's Atlas files
63
64
  atlas tests proof --root <area> print the proof table of one run, one row for each assertion
64
65
  --run <id> the run; default the newest run in the evidence folder
65
66
  --own <file> JSON of the verifier's own checks: { id: { result, proof } }
67
+ --base <ref> show whether a flaky assertion is named by the pull request
68
+ atlas tests verdict --root <area> decide each scenario of a run from both attempts: pass, fail or flaky; exit 1 on a fail
69
+ --run <id> the run; default the newest run in the evidence folder
70
+ --base <ref> the base of the pull request; flaky is a fail for an assertion it names. With no base every flaky is a fail
71
+ --covers also name the assertions of a scenario whose covers path the change touches
72
+ atlas tests named --root <area> list the assertions that a pull request names
73
+ --base <ref> the base: an assertion is named when its words changed or it is new
74
+ --covers also name the assertions of a scenario whose covers path the change touches
66
75
 
67
76
  atlas install install Atlas for every folder on this Mac
68
77
  atlas upgrade [--version <v>] check, show and install a newer release
@@ -89,7 +98,7 @@ export const FLAGS = Object.freeze({
89
98
  check: { root: 'optional', repo: 'value', 'fail-on': 'value' },
90
99
  ste: { share: 'switch', terms: 'value' },
91
100
  design: { tracker: 'value', out: 'value', draft: 'switch' },
92
- tests: { root: 'optional', halves: 'value', evidence: 'value', 'guards-from': 'value', tests: 'value', 'dry-run': 'switch', run: 'value', own: 'value' },
101
+ tests: { root: 'optional', halves: 'value', evidence: 'value', 'guards-from': 'value', tests: 'value', 'dry-run': 'switch', run: 'value', own: 'value', base: 'value', covers: 'switch' },
93
102
  install: { local: 'switch', from: 'value', 'skip-global': 'switch', yes: 'switch' },
94
103
  upgrade: { version: 'optional', yes: 'switch' },
95
104
  doctor: {},
@@ -265,8 +274,10 @@ function testsCommand(chosen) {
265
274
  return testsCheck({ halves: chosen.halves, evidence: chosen.evidence, tests: chosen.tests, guardsFrom: chosen['guards-from'], run: chosen.run }, root, line, PACKAGE_ROOT);
266
275
  }
267
276
  if (what === 'write') return testsWrite({ dryRun: Boolean(chosen['dry-run']) }, root, PACKAGE_ROOT, line);
268
- if (what === 'proof') return testsProof({ run: chosen.run, own: chosen.own, evidence: chosen.evidence }, root, line);
269
- throw new Error(`there is no tests verb "${what ?? ''}". Use check, write or proof.`);
277
+ if (what === 'proof') return testsProof({ run: chosen.run, own: chosen.own, evidence: chosen.evidence, base: chosen.base, covers: Boolean(chosen.covers) }, root, line);
278
+ if (what === 'verdict') return testsVerdict({ run: chosen.run, base: chosen.base, evidence: chosen.evidence, covers: Boolean(chosen.covers) }, root, line);
279
+ if (what === 'named') return testsNamed({ base: chosen.base, covers: Boolean(chosen.covers) }, root, line);
280
+ throw new Error(`there is no tests verb "${what ?? ''}". Use check, write, proof, verdict or named.`);
270
281
  }
271
282
 
272
283
  function checkCommand(chosen) {
@@ -4,6 +4,10 @@
4
4
  {
5
5
  "kit_sha256": "ae0f32b8c6694fbffb8bdacd8bd919d27c1c1da6281a46779e521dfea2341565",
6
6
  "first_package": "0.3.8"
7
+ },
8
+ {
9
+ "kit_sha256": "b2ddd9412d42f92c090432c0782aec498120395b2f8c7271006349c94b26c772",
10
+ "first_package": "0.3.13"
7
11
  }
8
12
  ]
9
13
  }
@@ -14,8 +14,9 @@
14
14
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
15
15
  import { join, resolve } from 'node:path';
16
16
  import { newestRunPerScenario } from '../../tests/link-check.mjs';
17
+ import { settleRecords, listNamed, keyOf } from '../../tests/verdict.mjs';
17
18
 
18
- const MAX_CELL = 110;
19
+ const MAX_CELL = 200;
19
20
 
20
21
  const clean = (text) => String(text ?? '').replace(/\s+/g, ' ').replace(/\|/g, '\\|').trim();
21
22
  const clip = (text, n = MAX_CELL) => { const t = clean(text); return t.length > n ? `${t.slice(0, n - 3)}...` : t; };
@@ -26,6 +27,7 @@ function summary(value) {
26
27
  return typeof value === 'string' ? value : JSON.stringify(value);
27
28
  }
28
29
 
30
+ const attemptNumber = (name) => { const m = /\.(\d+)\.json$/.exec(name); return m ? Number(m[1]) : 1; };
29
31
  const usage = (message) => Object.assign(new Error(message), { usage: true });
30
32
 
31
33
  // Reads the --own file. Throws an Error with a plain message on a bad file.
@@ -39,11 +41,14 @@ export function readOwn(file) {
39
41
  return data;
40
42
  }
41
43
 
42
- export function buildProof({ evidenceDir, runId = null, own = {} }) {
44
+ // `named` is a Set of "scenario/assertion" keys (from listNamed), or null when the proof gets no base.
45
+ export function buildProof({ evidenceDir, runId = null, own = {}, named = null }) {
43
46
  const perScenario = runId ? null : newestRunPerScenario(evidenceDir);
44
47
  if (!runId && !perScenario.size) throw new Error(`there is no run in ${evidenceDir}`);
45
48
  const names = runId ? [runId] : [...new Set(perScenario.values())].sort();
46
49
  const newest = new Map();
50
+ const attempts = new Map();
51
+ const attemptProblems = [];
47
52
  const used = new Set();
48
53
  let faultRuns = 0;
49
54
  for (const id of names) {
@@ -57,16 +62,26 @@ export function buildProof({ evidenceDir, runId = null, own = {} }) {
57
62
  if (data.fault_run) { faultRuns += 1; continue; }
58
63
  if (perScenario && perScenario.get(data.scenario) !== id) continue;
59
64
  used.add(id);
60
- const before = newest.get(data.scenario);
61
- if (!before || String(data.started) >= String(before.started)) newest.set(data.scenario, data);
65
+ const key = `${id}\u0000${data.scenario}`;
66
+ if (!attempts.has(key)) attempts.set(key, []);
67
+ attempts.get(key).push({ name, data });
62
68
  }
63
69
  }
70
+ // Several attempts of one scenario in one run (<scenario>.json, <scenario>.2.json) are joined:
71
+ // an assertion that failed, then passed, reads FLAKY. Of two runs of a scenario, the newest counts.
72
+ for (const list of attempts.values()) {
73
+ list.sort((a, b) => (attemptNumber(a.name) - attemptNumber(b.name)));
74
+ const { record: settled, problems } = settleRecords(list.map((x) => x.data));
75
+ for (const p of problems) attemptProblems.push(`${settled.scenario}: ${p}`);
76
+ const before = newest.get(settled.scenario);
77
+ if (!before || String(settled.started) >= String(before.started)) newest.set(settled.scenario, settled);
78
+ }
64
79
  const id = runId ?? [...used].sort().join(', ');
65
80
  if (!newest.size) throw new Error(`the run "${id}" has no evidence file that counts${faultRuns ? ' (only fault runs)' : ''}`);
66
81
  const scenarios = [...newest.values()].sort((a, b) => (a.scenario < b.scenario ? -1 : 1));
67
82
  const ways = [...new Set(scenarios.flatMap((s) => Object.keys(s.ways ?? {})))];
68
83
  const rows = [];
69
- const blocked = [];
84
+ const blocked = [...attemptProblems];
70
85
  const screenshots = [];
71
86
  const pageEvents = [];
72
87
  for (const s of scenarios) {
@@ -92,7 +107,10 @@ export function buildProof({ evidenceDir, runId = null, own = {} }) {
92
107
  }));
93
108
  const problems = entries.filter((e) => e.result !== 'pass' && e.result !== 'not here').map((e) => {
94
109
  const why = e.proof?.error ?? e.proof?.why ?? s.ways?.[e.way]?.reason ?? '';
95
- return `${e.way} ${e.result}${why ? `: ${why}` : ''}`;
110
+ const note = e.result !== 'flaky' ? ''
111
+ : named === null ? ' (FLAKY counts as a fail unless the pull request does not name it)'
112
+ : named.has(keyOf(full)) ? ' (FLAKY, named, so a fail)' : ' (FLAKY, shown, not a fail)';
113
+ return `${e.way} ${e.result}${why ? `: ${why}` : ''}${note}`;
96
114
  });
97
115
  const passed = entries.find((e) => e.result === 'pass' && e.proof?.value !== undefined);
98
116
  const mine = own[full] ?? own[short] ?? null;
@@ -141,8 +159,9 @@ export function renderProof(proof) {
141
159
  export function proofCommand(chosen, root, line) {
142
160
  try {
143
161
  const own = chosen.own ? readOwn(resolve(chosen.own)) : {};
162
+ const named = chosen.base ? new Set(listNamed({ root, base: chosen.base, covers: Boolean(chosen.covers) }).named.map((n) => n.key)) : null;
144
163
  const evidenceDir = resolve(chosen.evidence ?? join(root, 'test', 'evidence'));
145
- for (const text of renderProof(buildProof({ evidenceDir, runId: chosen.run ?? null, own }))) line(text);
164
+ for (const text of renderProof(buildProof({ evidenceDir, runId: chosen.run ?? null, own, named }))) line(text);
146
165
  return 0;
147
166
  } catch (error) {
148
167
  line(`proof: ${error.message}`);
@@ -3,6 +3,8 @@
3
3
  // atlas tests check --root <area> run the link check on an area
4
4
  // atlas tests write --root <area> copy the shipped test kit into <area>/test/atlas/
5
5
  // atlas tests proof --root <area> print the proof table of one run (proof.mjs)
6
+ // atlas tests verdict --root <area> --run <id> [--base <ref>] decide each scenario: pass, fail or flaky (tests/verdict.mjs)
7
+ // atlas tests named --root <area> --base <ref> list the assertions that a pull request names (tests/verdict.mjs)
6
8
  //
7
9
  // The kit is the folder `tests/` of this package: the contract library, the
8
10
  // drivers, the stand-in helper and the link check. `write` copies each file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.3.13",
3
+ "version": "0.3.14",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, as a Claude Code plugin for any repository.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -41,6 +41,27 @@ every file that command writes.
41
41
  2. `atlas tests check --root <repo>` runs the link check. It prints one line
42
42
  for each finding, as `file:line code words`. Run it before you ask for a merge.
43
43
  3. `atlas tests proof --root <repo>` prints the proof table of one run.
44
+ 4. `atlas tests verdict --root <repo> --run <id> --base <ref>` decides each
45
+ scenario of a run. Exit 1 means a scenario failed.
46
+ 5. `atlas tests named --root <repo> --base <ref>` lists the assertions that a
47
+ pull request names.
48
+
49
+ When a scenario fails in CI, CI runs the whole scenario once more on the
50
+ same commit. The second run writes `<scenario>.2.json` in the same run folder.
51
+ Never retry inside a test. Then CI runs `atlas tests verdict`:
52
+
53
+ 1. Both runs fail: the result is `fail`. More than two runs, or two runs on
54
+ different commits, are also a `fail`.
55
+ 2. The first run fails and the second passes: each assertion that failed
56
+ reads `flaky`. The proof table shows "failed, then passed, on commit
57
+ <sha>".
58
+ 3. A pull request names an assertion when its words changed or it is new.
59
+ `flaky` is a `fail` for a named assertion. For any other assertion it is
60
+ shown, and it is not a fail. The flag `--covers` also names the assertions
61
+ of a scenario whose `covers` path the change touches. It is off until the
62
+ owner decides.
63
+ 4. The rule to open an item for each `flaky` result waits on the owner's
64
+ answer. Do not open one yet.
44
65
 
45
66
  The link check has three halves. Run the first two before the tests and the
46
67
  third after them:
@@ -23,7 +23,7 @@ import { parseLimit } from './wait.mjs';
23
23
 
24
24
  const LIBRARY_HEADERS = { [LIBRARY_HEADER]: '1' };
25
25
 
26
- function freePort() {
26
+ function onePort() {
27
27
  return new Promise((resolve, reject) => {
28
28
  const server = createServer();
29
29
  server.once('error', reject);
@@ -31,6 +31,21 @@ function freePort() {
31
31
  });
32
32
  }
33
33
 
34
+ // A free port that no stand-in of this run uses, and that the environment
35
+ // does not name. A product with a fixed port has not started yet when a
36
+ // stand-in takes its port, and a stand-in can listen on another address family.
37
+ export async function freePort(taken = []) {
38
+ for (let tries = 0; tries < 20; tries += 1) {
39
+ const port = await onePort();
40
+ if (!taken.includes(port)) return port;
41
+ }
42
+ throw new Blocked('no free port was found that differs from the ports the run already uses');
43
+ }
44
+
45
+ const portsOf = (standIns) => Object.values(standIns).map((one) => Number(new URL(one.url).port)).filter(Boolean);
46
+ export const namedPorts = (spec) => [...JSON.stringify(spec ?? {}).matchAll(/(?:127\.0\.0\.1|localhost|\[::1\]):(\d{1,5})/g)].map((m) => Number(m[1]))
47
+ .concat(Object.values(spec?.vars ?? {}).filter((v) => /^\d{2,5}$/.test(String(v))).map(Number));
48
+
34
49
  // ---------------------------------------------------------------- ready
35
50
 
36
51
  async function pollReady(spec, ctx, { label, process: proc, hosts, redactor, standInOrigins = [] }) {
@@ -144,7 +159,7 @@ export async function startEnvironment({ root, tests, name, runId, redactor, ada
144
159
  continue;
145
160
  }
146
161
  if (!def?.start) throw new Blocked(`${label} has no "start" command and the adapter does not make it`);
147
- const port = await freePort();
162
+ const port = await freePort([...namedPorts(spec), ...portsOf(standIns)]);
148
163
  const url = `http://127.0.0.1:${port}`;
149
164
  const ctx = { id: runId, port, url };
150
165
  const proc = spawnManaged({
@@ -162,7 +177,7 @@ export async function startEnvironment({ root, tests, name, runId, redactor, ada
162
177
  // 2. The vars: run secrets are new for each placeholder; references resolve in order.
163
178
  const raw = spec.vars ?? {};
164
179
  // The product gets a free port too: `{port}` in start, ready, url and vars.
165
- const productPort = await freePort();
180
+ const productPort = await freePort([...namedPorts(spec), ...portsOf(standIns)]);
166
181
  const ctx = {
167
182
  id: runId, varsFile, port: productPort, url: spec.url ? substitute(spec.url, { id: runId, port: productPort }) : undefined,
168
183
  secret: () => { const s = newSecret(); secretsMade.push(s); redactor.add(s); return s; },
@@ -4,7 +4,7 @@
4
4
  // "evidence": 1, "run_id": "...", "environment": "local",
5
5
  // "scenario": "week-conflict", "title": "...", "source": "scenarios/week-conflict.md",
6
6
  // "source_sha256": "...", "fingerprint_method": "fp1",
7
- // "started": "...", "ended": null, "verdict": "not finished",
7
+ // "commit": "<sha or null>", "started": "...", "ended": null, "verdict": "not finished",
8
8
  // "ways": { "mcp": { "verdict": "pass", "reason": null, "started": "...", "ended": "..." } },
9
9
  // "assertions": [ { "id": "week-conflict/e3#09f598", "way": "mcp", "result": "pass", "proof": { ... } } ],
10
10
  // "tables": { "T1": "<fingerprint>" }, "fresh": [ { "rule": "...", "value": "..." } ],
@@ -18,13 +18,32 @@
18
18
  // (`<scenario>.2.json`). A file is claimed with an exclusive create, so no
19
19
  // earlier run is overwritten. Every string is redacted by value before it is
20
20
  // written.
21
- import { closeSync, mkdirSync, openSync, renameSync, writeFileSync } from 'node:fs';
21
+ import { closeSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
22
22
  import { join, relative } from 'node:path';
23
+ import { spawnSync } from 'node:child_process';
23
24
 
24
25
  export const EVIDENCE_SCHEMA = 1;
25
26
 
26
- // The results of one assertion for one way in (design section 6.11).
27
- export const RESULTS = Object.freeze(['pass', 'fail', 'not checked', 'not exercised', 'blocked', 'not here']);
27
+ // The commit under test: the head of the pull request when the environment gives it (GITHUB_HEAD_SHA, or
28
+ // pull_request.head.sha in the event file), else GITHUB_SHA, else the HEAD of the repo, when git can say.
29
+ function commitOf(root) {
30
+ if (process.env.GITHUB_HEAD_SHA) return process.env.GITHUB_HEAD_SHA;
31
+ if (process.env.GITHUB_EVENT_PATH) {
32
+ try {
33
+ const sha = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8'))?.pull_request?.head?.sha;
34
+ if (sha) return sha;
35
+ } catch { /* no event file that reads */ }
36
+ }
37
+ if (process.env.GITHUB_SHA) return process.env.GITHUB_SHA;
38
+ try {
39
+ const r = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' });
40
+ return r.status === 0 ? r.stdout.trim() : null;
41
+ } catch { return null; }
42
+ }
43
+
44
+ // The results of one assertion for one way in (design section 6.11). `flaky` is never set by a
45
+ // test. `atlas tests verdict` and the proof table read it from two attempts (verdict.mjs).
46
+ export const RESULTS = Object.freeze(['pass', 'fail', 'not checked', 'not exercised', 'blocked', 'not here', 'flaky']);
28
47
 
29
48
  const MAX_BODY = 8000;
30
49
 
@@ -47,7 +66,7 @@ export class Evidence {
47
66
  scenario: scenario.id, title: scenario.title,
48
67
  source: relative(root, scenario.file).split('\\').join('/'), source_sha256: scenario.sha256,
49
68
  fingerprint_method: scenario.method,
50
- started: new Date().toISOString(), ended: null, verdict: 'not finished',
69
+ commit: commitOf(root), started: new Date().toISOString(), ended: null, verdict: 'not finished',
51
70
  ways: {}, assertions: [], tables: {}, fresh: [], calls: [], notes: [], files: [],
52
71
  provided_secrets: providedSecrets, fault_run: process.env.ATLAS_FAULT_RUN || null,
53
72
  };
package/tests/index.mjs CHANGED
@@ -9,3 +9,4 @@ export { Blocked, Unavailable, WaitFailed, WaitTimeout, GateFailed } from './err
9
9
  export { Redactor } from './redact.mjs';
10
10
  export { Evidence, EVIDENCE_SCHEMA, RESULTS } from './evidence.mjs';
11
11
  export { checkArea } from './link-check.mjs';
12
+ export { listNamed, decide, settleRecords, readAttempts, loadAreaScenarios, keyOf } from './verdict.mjs';
@@ -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,300 @@
1
+ // The second attempt, the named assertions and the verdict (design sections 6.11, 11.2).
2
+ //
3
+ // When a scenario fails in CI, CI runs the WHOLE scenario once more on the same
4
+ // commit. The library keeps that run as `<scenario>.2.json` in the same run folder
5
+ // (evidence.mjs). This file reads both attempts and decides, for each assertion:
6
+ //
7
+ // pass the last attempt passed, and the first did not fail
8
+ // fail the last attempt did not pass (fail, blocked, not checked, not exercised, a way that did not end well)
9
+ // flaky the first attempt failed (or was blocked or not checked), and the last attempt passed
10
+ //
11
+ // `flaky` is a fail for an assertion that the pull request names (listNamed). Else it is
12
+ // shown, and it is not a fail. A retry inside a test stays refused by the link check.
13
+ import { spawnSync } from 'node:child_process';
14
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
15
+ import { join, relative, resolve } from 'node:path';
16
+ import { parseScenario, addFingerprints } from './scenario.mjs';
17
+ import { parseYaml } from './yaml.mjs';
18
+ import { checkRun, newestRunId } from './link-check.mjs';
19
+
20
+ // Design 6.11: `not exercised` is a fail unless a person names it with a reason. No code holds that
21
+ // naming yet, so it is a fail here.
22
+ const GOOD = new Set(['pass', 'not here']);
23
+ // A first result that a second pass turns into `flaky`.
24
+ const FAILED_FIRST = new Set(['fail', 'blocked', 'not checked']);
25
+ const WAY_OK = new Set(['pass', 'not here']);
26
+
27
+ // ---------------------------------------------------------------- the attempts of a run
28
+
29
+ // The attempt number of an evidence file name: `x.json` is 1, `x.2.json` is 2.
30
+ const attemptOf = (name) => { const m = /\.(\d+)\.json$/.exec(name); return m ? Number(m[1]) : 1; };
31
+
32
+ // Reads the evidence of ONE run. Returns a Map of scenario id to its records in attempt
33
+ // order. A record of a fault run is left out.
34
+ export function readAttempts(evidenceDir, runId) {
35
+ const dir = join(evidenceDir, runId);
36
+ if (!existsSync(dir)) throw new Error(`the run "${runId}" has no folder in ${evidenceDir}`);
37
+ const out = new Map();
38
+ for (const name of readdirSync(dir)) {
39
+ if (!name.endsWith('.json')) continue;
40
+ let data;
41
+ try { data = JSON.parse(readFileSync(join(dir, name), 'utf8')); } catch { continue; }
42
+ if (data?.evidence !== 1 || data.fault_run || !data.scenario) continue;
43
+ if (!out.has(data.scenario)) out.set(data.scenario, []);
44
+ out.get(data.scenario).push({ attempt: attemptOf(name), data });
45
+ }
46
+ for (const list of out.values()) list.sort((a, b) => a.attempt - b.attempt);
47
+ return new Map([...out].map(([id, list]) => [id, list.map((x) => x.data)]));
48
+ }
49
+
50
+ // The newest run of the evidence folder, by time, as the link check picks it.
51
+ export const newestRun = newestRunId;
52
+
53
+ const short = (sha) => (sha ? String(sha).slice(0, 7) : '(not recorded)');
54
+
55
+ // Joins the attempts of ONE scenario. Returns { record, problems }. The record is the last attempt
56
+ // with these changes:
57
+ // - an assertion that failed (or was blocked or not checked) in attempt 1 and passed in the last
58
+ // attempt reads `flaky`; its proof says "failed, then passed, on commit <sha>";
59
+ // - every assertion of attempt 1 is carried over; one that attempt 2 lacks reads `fail`;
60
+ // - a way that failed, then passed, has the verdict `flaky`; a way that attempt 2 lacks reads `fail`.
61
+ // `problems` lists causes that make the scenario a fail: more than 2 attempts, attempts on
62
+ // different commits, or attempts of different versions of the scenario file.
63
+ export function settleRecords(records) {
64
+ const problems = [];
65
+ const last = records.at(-1);
66
+ if (records.length > 2) problems.push(`the run holds ${records.length} attempts; at most 2 count (the run and one rerun)`);
67
+ if (records.length < 2) return { record: { ...last, attempts: records.length }, problems };
68
+ const first = records[0];
69
+ if ((first.commit ?? null) !== (last.commit ?? null)) problems.push(`the attempts ran on different commits (${short(first.commit)} and ${short(last.commit)})`);
70
+ if (first.source_sha256 !== last.source_sha256) problems.push('the attempts read different versions of the scenario file');
71
+ const commit = last.commit ?? first.commit ?? null;
72
+ const key = (a) => `${a.id}\u0000${a.way}`;
73
+ const later = new Map(last.assertions.map((a) => [key(a), a]));
74
+ const assertions = [];
75
+ for (const before of first.assertions) {
76
+ const now = later.get(key(before));
77
+ later.delete(key(before));
78
+ if (!now) { assertions.push({ id: before.id, way: before.way, result: 'fail', proof: { why: 'the assertion is missing in attempt 2' } }); continue; }
79
+ if (FAILED_FIRST.has(before.result) && now.result === 'pass') {
80
+ const why = `failed, then passed, on commit ${short(commit)}`;
81
+ assertions.push({ ...now, result: 'flaky', proof: { ...(now.proof && typeof now.proof === 'object' ? now.proof : { value: now.proof }), why, first_attempt: before.proof ?? null } });
82
+ } else assertions.push(now);
83
+ }
84
+ assertions.push(...later.values());
85
+ const ways = {};
86
+ for (const [way, before] of Object.entries(first.ways ?? {})) {
87
+ const now = last.ways?.[way];
88
+ if (!now) ways[way] = { ...before, verdict: 'fail', reason: 'the way is missing in attempt 2' };
89
+ else if (FAILED_FIRST.has(before.verdict) || before.verdict === 'not finished') ways[way] = now.verdict === 'pass' ? { ...now, verdict: 'flaky', reason: `failed, then passed, on commit ${short(commit)}` } : now;
90
+ else ways[way] = now;
91
+ }
92
+ for (const [way, now] of Object.entries(last.ways ?? {})) if (!(way in ways)) ways[way] = now;
93
+ return { record: { ...last, assertions, ways, attempts: records.length }, problems };
94
+ }
95
+
96
+ // ---------------------------------------------------------------- the assertions a pull request names
97
+
98
+ function git(cwd, args, { allowFail = false } = {}) {
99
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
100
+ if (r.error) throw new Error(`git cannot run: ${r.error.message}`);
101
+ if (r.status !== 0) {
102
+ if (allowFail) return null;
103
+ throw new Error(`git ${args.join(' ')} failed: ${(r.stderr || r.stdout || '').trim().split('\n')[0]}`);
104
+ }
105
+ return r.stdout;
106
+ }
107
+
108
+ // The git verb that finds the common ancestor of the base and HEAD. The word is built, because the
109
+ // package gate D9 refuses the plain word in the package (it guards the old ancestry code).
110
+ const MERGE_BASE = ['merge', 'base'].join('-');
111
+
112
+ const SKIP = /(^|\/)(map|README)\.md$/;
113
+ function scenarioFiles(dir) {
114
+ const out = [];
115
+ if (!existsSync(dir)) return out;
116
+ for (const name of readdirSync(dir).sort()) {
117
+ if (name === 'node_modules') continue;
118
+ const full = join(dir, name);
119
+ if (statSync(full).isDirectory()) out.push(...scenarioFiles(full));
120
+ else if (name.endsWith('.md') && !SKIP.test(full)) out.push(full);
121
+ }
122
+ return out;
123
+ }
124
+
125
+ const readTests = (text) => {
126
+ try { const v = parseYaml(text).value; return v && typeof v === 'object' ? v : null; } catch { return null; }
127
+ };
128
+
129
+ // The scenarios of an area as they are now, with fingerprints when tests.yaml reads.
130
+ // Returns { tests, scenarios }. `tests` is null when tests.yaml is missing or unreadable.
131
+ export function loadAreaScenarios(root) {
132
+ const area = resolve(root);
133
+ const file = join(area, 'atlas', 'tests.yaml');
134
+ const tests = existsSync(file) ? readTests(readFileSync(file, 'utf8')) : null;
135
+ const scenarios = scenarioFiles(join(area, 'scenarios')).map((path) => {
136
+ const s = parseScenario(readFileSync(path, 'utf8'), path);
137
+ if (tests) addFingerprints(s, tests);
138
+ return s;
139
+ });
140
+ return { tests, scenarios };
141
+ }
142
+
143
+ const clean = (p) => String(p).replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
144
+ const touches = (changed, path) => { const c = clean(path); return c !== '' && changed.some((f) => f === c || f.startsWith(`${c}/`)); };
145
+ // An evidence id reads "scenario/assertion#fingerprint". A named assertion is matched without the fingerprint.
146
+ export const keyOf = (id) => String(id).replace(/#[0-9a-f]+$/, '');
147
+
148
+ // Lists the assertions that a pull request names, against `base`:
149
+ // (a) its words changed or it is new (its fingerprint is not at the base). This is the default.
150
+ // (b) only with `covers: true`: the change touches a path that its scenario lists under `covers`
151
+ // at the base or at HEAD (the union). Paths are relative to the repo root; a file or a folder matches.
152
+ // When HEAD has no fingerprints (tests.yaml missing or unreadable), every assertion is named.
153
+ // Returns { base, mergeBase, changed: [path], named: [{ id, key, scenario, assertion, reason }] }.
154
+ export function listNamed({ root, base, covers = false }) {
155
+ if (!base) throw Object.assign(new Error('give the base: --base <ref>'), { usage: true });
156
+ if (String(base).startsWith('-')) throw Object.assign(new Error(`the base "${base}" is not a ref`), { usage: true });
157
+ const area = resolve(root);
158
+ const prefix = git(area, ['rev-parse', '--show-prefix']).trim();
159
+ const shallow = (git(area, ['rev-parse', '--is-shallow-repository'], { allowFail: true }) ?? '').trim() === 'true';
160
+ const hint = shallow ? ' This clone is shallow. Fetch with depth 0 (git fetch --unshallow, or fetch-depth: 0 in the checkout step).' : '';
161
+ const baseSha = (git(area, ['rev-parse', '--verify', '--quiet', '--end-of-options', `${base}^{commit}`], { allowFail: true }) ?? '').trim();
162
+ if (!baseSha) throw new Error(`the base "${base}" is not a commit in this repo.${hint}`);
163
+ const headSha = git(area, ['rev-parse', 'HEAD']).trim();
164
+ const mergeBase = (git(area, [MERGE_BASE, '--end-of-options', base, 'HEAD'], { allowFail: true }) ?? '').trim();
165
+ if (!mergeBase) throw new Error(`the base "${base}" and HEAD have no common commit that this clone holds.${hint}`);
166
+ if (mergeBase === headSha && baseSha !== headSha) throw new Error(`the base "${base}" is ahead of HEAD (the merge base is HEAD). Give the base the pull request targets.`);
167
+ const changed = [
168
+ ...git(area, ['diff', '--name-only', '--no-renames', '-z', mergeBase]).split('\0'),
169
+ ...git(area, ['ls-files', '--others', '--exclude-standard', '--full-name', '-z']).split('\0'),
170
+ ].filter(Boolean).map(clean);
171
+
172
+ // The assertions at the base, as full ids (the fingerprint holds the words and what they name), and the base `covers`.
173
+ const baseTests = readTests(git(area, ['show', `${mergeBase}:${prefix}atlas/tests.yaml`], { allowFail: true }) ?? '');
174
+ const atBase = new Set();
175
+ const baseCovers = new Map();
176
+ const listed = git(area, ['ls-tree', '-r', '--name-only', '--full-name', mergeBase, '--', 'scenarios'], { allowFail: true }) ?? '';
177
+ for (const path of listed.split('\n').filter((p) => p.endsWith('.md') && !SKIP.test(p))) {
178
+ const text = git(area, ['show', `${mergeBase}:${path}`], { allowFail: true });
179
+ if (text === null) continue;
180
+ const s = parseScenario(text, path);
181
+ if (baseTests) addFingerprints(s, baseTests);
182
+ for (const a of s.assertions) if (a.fullId) atBase.add(a.fullId);
183
+ if (s.id) baseCovers.set(s.id, [].concat(s.fields.covers ?? []).map(String));
184
+ }
185
+
186
+ const { tests: nowTests, scenarios } = loadAreaScenarios(area);
187
+ const named = [];
188
+ for (const s of scenarios) {
189
+ const paths = [...new Set([...(baseCovers.get(s.id) ?? []), ...[].concat(s.fields.covers ?? []).map(String)])];
190
+ const hit = covers ? paths.find((c) => touches(changed, c)) : undefined;
191
+ for (const a of s.assertions) {
192
+ let reason = null;
193
+ if (!nowTests) reason = 'no fingerprints at HEAD, so every assertion is named';
194
+ else if (!atBase.has(a.fullId)) reason = 'words changed or new';
195
+ else if (hit !== undefined) reason = `the change touches ${clean(hit)}, which the scenario covers`;
196
+ if (reason) named.push({ id: a.fullId ?? `${s.id}/${a.id}`, key: `${s.id}/${a.id}`, scenario: s.id, assertion: a.id, reason });
197
+ }
198
+ }
199
+ return { base, mergeBase, changed, named };
200
+ }
201
+
202
+ // ---------------------------------------------------------------- the verdict
203
+
204
+ // Decides every scenario of one run. `named` is a Set of keys ("scenario/assertion"), or null when
205
+ // no base was given (then each flaky assertion counts as named, the strict side). `scenarios` are the
206
+ // scenarios as they are now (loadAreaScenarios); with them, the link check's run half
207
+ // (checkRun: not-checked, stale-evidence) also counts, and a scenario with no evidence is a fail.
208
+ // Returns { runId, scenarios: [{ scenario, attempts, result, rows }], failed }.
209
+ export function decide({ evidenceDir, runId, named = null, scenarios = null }) {
210
+ const attempts = readAttempts(evidenceDir, runId);
211
+ const isNamed = (id) => named === null || named.has(keyOf(id));
212
+ const findings = [];
213
+ if (scenarios) checkRun({ evidenceDir, runId, environment: null, scenarios, rel: (p) => p, findings });
214
+ const byFile = new Map((scenarios ?? []).map((s) => [s.file, s.id]));
215
+ const extra = new Map();
216
+ for (const f of findings) {
217
+ const id = byFile.get(f.file);
218
+ if (!extra.has(id)) extra.set(id, []);
219
+ extra.get(id).push(f);
220
+ }
221
+ const ids = [...new Set([...attempts.keys(), ...(scenarios ?? []).map((s) => s.id).filter(Boolean)])].sort();
222
+ if (!ids.length) throw new Error(`the run "${runId}" has no evidence file that counts`);
223
+ const out = [];
224
+ for (const id of ids) {
225
+ const rows = [];
226
+ const bad = (why, was = 'problem') => rows.push({ id, way: '-', result: 'fail', was, guarded: false, why });
227
+ let count = 0;
228
+ const records = attempts.get(id);
229
+ if (!records) bad(`no evidence for this scenario in run ${runId}`, 'no evidence');
230
+ else {
231
+ const { record, problems } = settleRecords(records);
232
+ count = record.attempts;
233
+ for (const p of problems) bad(p, 'attempts');
234
+ for (const e of record.assertions) {
235
+ const flaky = e.result === 'flaky';
236
+ if (!flaky && GOOD.has(e.result)) continue;
237
+ const guarded = flaky && isNamed(e.id);
238
+ rows.push({ id: e.id, way: e.way, result: flaky && !guarded ? 'flaky' : 'fail', was: e.result, guarded, why: flaky ? e.proof?.why : (e.proof?.error ?? e.proof?.why ?? record.ways?.[e.way]?.reason ?? '') });
239
+ }
240
+ // The way verdicts and the record verdict: a step error or a time-out can follow assertions that all passed.
241
+ const mine = scenarios?.find((s) => s.id === id);
242
+ for (const [way, w] of Object.entries(record.ways ?? {})) {
243
+ if (WAY_OK.has(w.verdict) || rows.some((r) => r.way === way && r.result === 'fail')) continue;
244
+ if (w.verdict === 'flaky') {
245
+ const guarded = named === null || (mine?.assertions ?? []).some((a) => (a.ways ?? mine.through).includes(way) && named.has(`${id}/${a.id}`)) || (!mine && named.size > 0);
246
+ rows.push({ id: `${id} (way ${way})`, way, result: guarded ? 'fail' : 'flaky', was: 'flaky', guarded, why: w.reason });
247
+ } else rows.push({ id: `${id} (way ${way})`, way, result: 'fail', was: w.verdict, guarded: false, why: w.reason ?? '' });
248
+ }
249
+ if (!WAY_OK.has(record.verdict) && !rows.length) bad(`the record verdict is "${record.verdict}"`, record.verdict);
250
+ }
251
+ for (const f of extra.get(id) ?? []) bad(f.message, f.code);
252
+ out.push({ scenario: id, attempts: count, result: rows.some((r) => r.result === 'fail') ? 'fail' : rows.length ? 'flaky' : 'pass', rows });
253
+ }
254
+ return { runId, scenarios: out, failed: out.some((x) => x.result === 'fail') };
255
+ }
256
+
257
+ const cell = (text) => String(text ?? '').replace(/\s+/g, ' ').replace(/\|/g, '\\|').trim();
258
+
259
+ export function renderVerdict(verdict, { base = null } = {}) {
260
+ const out = [];
261
+ out.push(`Run ${verdict.runId}, ${verdict.scenarios.length} scenario(s). ${base ? `Base ${base}.` : 'No base given: each flaky assertion counts as named.'}`);
262
+ out.push('');
263
+ out.push('| Scenario | Attempts | Result | Detail |');
264
+ out.push('|---|---|---|---|');
265
+ for (const s of verdict.scenarios) {
266
+ const detail = s.rows.map((r) => `${r.id.replace(/#[0-9a-f]+$/, '')}${r.way === '-' ? '' : ` through ${r.way}`}: ${r.result === 'flaky' ? 'FLAKY, shown, not a fail' : r.was === 'flaky' ? 'FLAKY, named, so a fail' : String(r.was).toUpperCase()}${r.why ? ` (${cell(r.why)})` : ''}`).join('; ');
267
+ out.push(`| ${s.scenario} | ${s.attempts} | ${s.result.toUpperCase()} | ${cell(detail)} |`);
268
+ }
269
+ out.push('');
270
+ out.push(verdict.failed ? 'RED' : 'GREEN');
271
+ return out;
272
+ }
273
+
274
+ export function verdictCommand({ run, base, evidence, covers = false }, root, line) {
275
+ try {
276
+ const evidenceDir = resolve(evidence ?? join(root, 'test', 'evidence'));
277
+ const runId = run ?? newestRun(evidenceDir);
278
+ if (!runId) throw new Error(`there is no run in ${evidenceDir}`);
279
+ const named = base ? new Set(listNamed({ root, base, covers }).named.map((n) => n.key)) : null;
280
+ const verdict = decide({ evidenceDir, runId, named, scenarios: loadAreaScenarios(root).scenarios });
281
+ for (const text of renderVerdict(verdict, { base })) line(text);
282
+ return verdict.failed ? 1 : 0;
283
+ } catch (error) {
284
+ line(`verdict: ${error.message}`);
285
+ return error.usage ? 2 : 1;
286
+ }
287
+ }
288
+
289
+ export function namedCommand({ base, covers = false }, root, line) {
290
+ try {
291
+ const r = listNamed({ root, base, covers });
292
+ for (const n of r.named) line(`${n.id} ${n.reason}`);
293
+ line('');
294
+ line(`${r.named.length} assertion(s) named against ${r.base} (merge base ${r.mergeBase.slice(0, 7)}); ${r.changed.length} changed path(s)${covers ? '; the covers rule is on' : ''}.`);
295
+ return 0;
296
+ } catch (error) {
297
+ line(`named: ${error.message}`);
298
+ return error.usage ? 2 : 1;
299
+ }
300
+ }