@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.
- package/.claude-plugin/plugin.json +1 -1
- package/door/cli.mjs +14 -3
- package/door/kit-releases.json +4 -0
- package/door/lib/proof.mjs +26 -7
- package/door/lib/tests.mjs +2 -0
- package/package.json +1 -1
- package/skills/tests/SKILL.md +21 -0
- package/tests/environment.mjs +18 -3
- package/tests/evidence.mjs +24 -5
- package/tests/index.mjs +1 -0
- package/tests/link-check.mjs +21 -2
- package/tests/verdict.mjs +300 -0
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
|
-
|
|
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) {
|
package/door/kit-releases.json
CHANGED
package/door/lib/proof.mjs
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|
61
|
-
if (!
|
|
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
|
-
|
|
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}`);
|
package/door/lib/tests.mjs
CHANGED
|
@@ -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
package/skills/tests/SKILL.md
CHANGED
|
@@ -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:
|
package/tests/environment.mjs
CHANGED
|
@@ -23,7 +23,7 @@ import { parseLimit } from './wait.mjs';
|
|
|
23
23
|
|
|
24
24
|
const LIBRARY_HEADERS = { [LIBRARY_HEADER]: '1' };
|
|
25
25
|
|
|
26
|
-
function
|
|
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; },
|
package/tests/evidence.mjs
CHANGED
|
@@ -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
|
|
27
|
-
|
|
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';
|
package/tests/link-check.mjs
CHANGED
|
@@ -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
|
+
}
|