@arjunkhera/atlas 0.3.42 → 0.3.44

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.42",
3
+ "version": "0.3.44",
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
@@ -33,6 +33,8 @@ import { verdictCommand as testsVerdict, namedCommand as testsNamed, rerunComman
33
33
  import { runKept } from '../tests/kept.mjs';
34
34
  import { sweepCommand as testsSweep, coversCommand as testsCovers } from '../tests/sweep.mjs';
35
35
  import { issuesCommand as testsIssues } from './lib/issues.mjs';
36
+ import { changedCommand as testsChanged } from './lib/changed-tests.mjs';
37
+ import { beforeCommand as testsBefore } from './lib/fails-before.mjs';
36
38
  import { upkeepCommand } from './lib/upkeep.mjs';
37
39
  import { judgeCommand } from './lib/judge.mjs';
38
40
 
@@ -81,6 +83,17 @@ const HELP = `atlas — the door into a repo's Atlas files
81
83
  --run <id> the run; default the newest run in the evidence folder
82
84
  --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
83
85
  --covers also name the assertions of a scenario whose covers path the change touches
86
+ atlas tests changed --root <repo> --base <ref> list each test that a pull request adds, changes, renames or removes, as JSON
87
+ --head <ref> the head of the pull request; default HEAD. Only committed files count
88
+ --out <file> write the JSON here; default the terminal
89
+ --text print a short table instead of the JSON
90
+ atlas tests before --root <repo> --base <ref> each new or changed test case must fail on the old code and pass on the new code; exit 1 when one does not
91
+ --head <ref> the head of the pull request; default HEAD
92
+ --list <file> a changed-test list from atlas tests changed; default the command makes it
93
+ --side <old|new> run one side only, and write its run file (CI runs each side in its own job)
94
+ --old <file> --new <file> read the two run files and give the answer; runs nothing
95
+ --out <file> write the JSON here; default the terminal
96
+ --text print a short table instead of the JSON
84
97
  atlas tests named --root <area> list the assertions that a pull request names
85
98
  --base <ref> the base: an assertion is named when its words changed or it is new
86
99
  --covers also name the assertions of a scenario whose covers path the change touches
@@ -146,7 +159,7 @@ export const FLAGS = Object.freeze({
146
159
  ste: { share: 'switch', terms: 'value' },
147
160
  design: { tracker: 'value', out: 'value', draft: 'switch', root: 'optional' },
148
161
  view: { product: 'value', name: 'value', out: 'value' },
149
- tests: { root: 'optional', halves: 'value', evidence: 'value', 'guards-from': 'value', tests: 'value', 'dry-run': 'switch', run: 'value', own: 'value', base: 'value', covers: 'switch', findings: 'value', repo: 'value', max: 'value', bot: 'value', since: 'value', out: 'value', text: 'switch', json: 'switch', scenario: 'value', language: 'value' },
162
+ tests: { root: 'optional', halves: 'value', evidence: 'value', 'guards-from': 'value', tests: 'value', 'dry-run': 'switch', run: 'value', own: 'value', base: 'value', head: 'value', list: 'value', side: 'value', old: 'value', new: 'value', covers: 'switch', findings: 'value', repo: 'value', max: 'value', bot: 'value', since: 'value', out: 'value', text: 'switch', json: 'switch', scenario: 'value', language: 'value' },
150
163
  upkeep: { root: 'optional', event: 'value', json: 'switch' },
151
164
  judge: { tasks: 'value', verdict: 'value', seal: 'value', runs: 'value', out: 'value', repo: 'value', budget: 'value', model: 'value' },
152
165
  install: { local: 'switch', from: 'value', 'skip-global': 'switch', yes: 'switch' },
@@ -343,13 +356,15 @@ async function testsCommand(chosen) {
343
356
  if (what === 'write') return testsWrite({ dryRun: Boolean(chosen['dry-run']) }, root, PACKAGE_ROOT, line);
344
357
  if (what === 'proof') return testsProof({ run: chosen.run, own: chosen.own, evidence: chosen.evidence, base: chosen.base, covers: Boolean(chosen.covers) }, root, line);
345
358
  if (what === 'verdict') return testsVerdict({ run: chosen.run, base: chosen.base, evidence: chosen.evidence, covers: Boolean(chosen.covers) }, root, line);
359
+ if (what === 'changed') return testsChanged({ base: chosen.base, head: chosen.head, out: chosen.out, text: Boolean(chosen.text) }, root, line);
360
+ if (what === 'before') return testsBefore({ base: chosen.base, head: chosen.head, list: chosen.list, out: chosen.out, side: chosen.side, old: chosen.old, new: chosen.new, text: Boolean(chosen.text) }, root, line);
346
361
  if (what === 'named') return testsNamed({ base: chosen.base, covers: Boolean(chosen.covers) }, root, line);
347
362
  if (what === 'sweep') return testsSweep({ evidence: chosen.evidence, since: chosen.since, base: chosen.base, out: chosen.out, text: Boolean(chosen.text) }, root, line);
348
363
  if (what === 'covers') return testsCovers({ itemId: chosen._[2], evidence: chosen.evidence, json: Boolean(chosen.json) }, root, line);
349
364
  if (what === 'kept') return (await runKept({ root, evidenceDir: chosen.evidence, only: chosen.scenario ?? null, write: (text) => process.stdout.write(text) })).exit;
350
365
  if (what === 'rerun') return testsRerun({ run: chosen.run, evidence: chosen.evidence, tests: chosen.tests, language: chosen.language ?? 'javascript' }, root, line);
351
366
  if (what === 'issues') return testsIssues({ findings: chosen.findings, repo: chosen.repo, max: chosen.max, bot: chosen.bot, dryRun: Boolean(chosen['dry-run']), args: [...chosen._, ...Object.values(chosen).filter((v) => typeof v === 'string')] }, line);
352
- throw new Error(`there is no tests verb "${what ?? ''}". Use check, write, proof, verdict, named, sweep, covers, kept, issues or rerun.`);
367
+ throw new Error(`there is no tests verb "${what ?? ''}". Use check, write, proof, verdict, named, changed, before, sweep, covers, kept, issues or rerun.`);
353
368
  }
354
369
 
355
370
  function upkeepVerb(chosen) {
@@ -0,0 +1,422 @@
1
+ // `atlas tests changed`: the list of test things that a pull request adds, changes, renames or
2
+ // removes (roadmap R3, tracker item 62a3b4d0; design docs/design-docs/changed-test-list/).
3
+ //
4
+ // It compares the head with its merge base. It reads both sides as git objects only: it never
5
+ // imports, runs or loads a file of the head, so a pull request cannot change how its own list is
6
+ // made. The group rules live here, in the package. In CI the later checks run the base copy.
7
+ //
8
+ // The answer is JSON with fixed keys (version `changed_tests: 1`). Every row has the same seven keys:
9
+ // { area, group, status, path, from, id, content_changed }
10
+ // `area`, `group`, `path` and `id` together name a row. The fails-before and passes-after checks write
11
+ // the case names of their JUnit results in their own answers, against that name; this list stays fixed.
12
+ //
13
+ // The command lists; it never judges. It exits 0 when it can read the diff, and 2 on a usage error.
14
+ import { spawnSync } from 'node:child_process';
15
+ import { writeFileSync } from 'node:fs';
16
+ import { resolve, posix } from 'node:path';
17
+ import { parseScenario, addFingerprints, fingerprintOf } from '../../tests/scenario.mjs';
18
+ import { parseYaml } from '../../tests/yaml.mjs';
19
+
20
+ export const VERSION = 1;
21
+ export const GROUPS = Object.freeze(['scenario-words', 'check-block', 'unit-test', 'runner-config', 'test-script', 'test-helper', 'kit-manifest']);
22
+ export const STATUSES = Object.freeze(['added', 'changed', 'renamed', 'removed']);
23
+ export const ROW_KEYS = Object.freeze(['area', 'group', 'status', 'path', 'from', 'id', 'content_changed']);
24
+ export const ANSWER_KEYS = Object.freeze(['changed_tests', 'base', 'merge_base', 'head', 'counts', 'other_paths', 'rows']);
25
+
26
+ // The git verb that finds the common ancestor. The word is built, as in tests/verdict.mjs: a package
27
+ // test refuses the plain word in the door (it guards the old ancestry code).
28
+ const MERGE_BASE = ['merge', 'base'].join('-');
29
+
30
+ function git(cwd, args, { allowFail = false } = {}) {
31
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
32
+ if (r.error) throw new Error(`git cannot run: ${r.error.message}`);
33
+ if (r.status !== 0) {
34
+ if (allowFail) return null;
35
+ throw new Error(`git ${args.join(' ')} failed: ${(r.stderr || r.stdout || '').trim().split('\n')[0]}`);
36
+ }
37
+ return r.stdout;
38
+ }
39
+
40
+ const usage = (message) => Object.assign(new Error(message), { usage: true });
41
+ const show = (cwd, sha, path) => git(cwd, ['show', `${sha}:${path}`], { allowFail: true });
42
+ const keyOf = (id) => String(id).replace(/#[0-9a-f]+$/, '');
43
+
44
+ // ---------------------------------------------------------------- the rules for each group
45
+
46
+ const RUNNER_CONFIG = /^(vitest\.config\.[^/]+|vitest\.[^/]+\.config\.[^/]+|jest\.config\.[^/]+|playwright\.config\.[^/]+|\.mocharc(\.[^/]+)?|pytest\.ini|tox\.ini)$/;
47
+ const UNIT_TEST = /^([^/]+\.(test|spec)\.[^/]+|test_[^/]+\.py|[^/]+_test\.py|[^/]+_test\.go)$/;
48
+ // A helper folder at any depth. These names mean tests in every tool that uses them.
49
+ // `test` and `fixtures` are among them (shape/test/, pkg/fixtures/): fails-before needs a deep helper
50
+ // or a deep data file to be the same in both runs (design docs/design-docs/fails-before/).
51
+ const HELPER_ANY = new Set(['test', '__tests__', '__snapshots__', 'testdata', 'fixtures']);
52
+ // A helper folder only at the top of the repo or of an area. Deeper, such a name can be product code
53
+ // (the Atlas test kit ships in packages/atlas/tests/).
54
+ const HELPER_TOP = new Set(['tests', 'spec', 'golden']);
55
+ // Test settings that live inside a package.json, beside the test scripts.
56
+ const PACKAGE_TEST_KEYS = Object.freeze(['jest', 'vitest', 'mocha', 'ava', 'c8', 'nyc']);
57
+ const SCENARIO_TEST = /(^|\/)tests?\/scenarios\/[^/]+(\.test\.mjs|_test\.py)$/;
58
+ const TEST_SCRIPT = /^(pre|post)?test(:.*)?$/;
59
+ const SKIP_SCENARIO = /(^|\/)(map|README)\.md$/;
60
+ // A check id reads "<scenario>/e<n>", with or without its "#<fingerprint>".
61
+ const CHECK_ID = /^[\w.-]+\/e\d+(#[0-9a-f]+)?$/;
62
+
63
+ // An area is a folder that holds atlas/tests.yaml. A folder inside a test folder is a fixture, not an area.
64
+ const FIXTURE_PART = new Set([...HELPER_ANY, ...HELPER_TOP]);
65
+ const isKitManifest = (path) => /(^|\/)atlas\/tests\.yaml$/.test(path) || /(^|\/)tests?\/atlas\/manifest\.json$/.test(path);
66
+ // A scenario file of an area: <area>/scenarios/**.md, where <area>/atlas/tests.yaml exists on one side.
67
+ function scenarioArea(path, areas) {
68
+ if (!path.endsWith('.md') || SKIP_SCENARIO.test(path)) return null;
69
+ for (const area of areas) {
70
+ const prefix = area ? `${area}/scenarios/` : 'scenarios/';
71
+ if (path.startsWith(prefix)) return area;
72
+ }
73
+ return null;
74
+ }
75
+
76
+ // The area of a path: the longest area that holds it, or '' (the repo root).
77
+ export function areaOf(path, areas = ['']) {
78
+ for (const area of [...areas].sort((a, b) => b.length - a.length)) if (!area || path.startsWith(`${area}/`)) return area;
79
+ return '';
80
+ }
81
+
82
+ function isHelper(path, areas) {
83
+ const name = posix.basename(path);
84
+ if (name === 'conftest.py' || name.endsWith('.snap')) return true;
85
+ const area = areaOf(path, areas);
86
+ const dirs = (area ? path.slice(area.length + 1) : path).split('/').slice(0, -1);
87
+ return dirs.some((d) => HELPER_ANY.has(d)) || HELPER_TOP.has(dirs[0]) || HELPER_TOP.has(path.split('/')[0]);
88
+ }
89
+
90
+ // The first rule that matches wins, in the order of the design (routes part).
91
+ export function groupOf(path, areas = ['']) {
92
+ const name = posix.basename(path);
93
+ if (isKitManifest(path)) return 'kit-manifest';
94
+ if (scenarioArea(path, areas) !== null) return 'scenario-words';
95
+ if (SCENARIO_TEST.test(path)) return 'check-block';
96
+ if (RUNNER_CONFIG.test(name) || name === 'pyproject.toml' || name === 'setup.cfg' || name === 'noxfile.py') return 'runner-config';
97
+ if (name === 'package.json') return 'test-script';
98
+ // A snapshot is named after its test file (`a.test.ts.snap`), but it is data that a test reads.
99
+ if (name.endsWith('.snap')) return 'test-helper';
100
+ if (UNIT_TEST.test(name)) return 'unit-test';
101
+ if (isHelper(path, areas)) return 'test-helper';
102
+ return null;
103
+ }
104
+
105
+ // ---------------------------------------------------------------- readers of one side
106
+
107
+ // The pytest section of a pyproject.toml or a setup.cfg, or '' when it has none.
108
+ export function pytestSection(text, name) {
109
+ if (text === null) return '';
110
+ const head = name === 'setup.cfg' ? /^\[tool:pytest\]\s*$/ : /^\[tool\.pytest(\.[^\]]+)?\]\s*$/;
111
+ const out = [];
112
+ let inside = false;
113
+ for (const line of text.replace(/\r\n?/g, '\n').split('\n')) {
114
+ if (/^\s*\[/.test(line)) inside = head.test(line.trim());
115
+ if (inside) out.push(line);
116
+ }
117
+ return out.join('\n');
118
+ }
119
+
120
+ // The test scripts of a package.json: { name: command }. null when the file does not read as JSON.
121
+ export function testScripts(text) {
122
+ if (text === null) return {};
123
+ let value;
124
+ try { value = JSON.parse(text); } catch { return null; }
125
+ if (!value || typeof value !== 'object') return {};
126
+ const scripts = value.scripts && typeof value.scripts === 'object' ? value.scripts : {};
127
+ const out = Object.fromEntries(Object.entries(scripts).filter(([name]) => TEST_SCRIPT.test(name)));
128
+ // A test setting key reads "key:<name>", so it never meets a script name.
129
+ for (const key of PACKAGE_TEST_KEYS) if (key in value) out[`key:${key}`] = JSON.stringify(value[key]);
130
+ return out;
131
+ }
132
+
133
+ // Skips a string, a template string, a comment or a regular expression that starts at `i`.
134
+ // Returns the index after it, or `i` when nothing starts there. Throws when it does not end.
135
+ function skipToken(src, i, prev) {
136
+ const c = src[i];
137
+ const n = src[i + 1];
138
+ if (c === '/' && n === '/') { const end = src.indexOf('\n', i); return end < 0 ? src.length : end; }
139
+ if (c === '/' && n === '*') { const end = src.indexOf('*/', i + 2); if (end < 0) throw new Error('open comment'); return end + 2; }
140
+ if (c === '"' || c === "'") {
141
+ for (let j = i + 1; j < src.length; j += 1) {
142
+ if (src[j] === '\\') { j += 1; continue; }
143
+ if (src[j] === c) return j + 1;
144
+ if (src[j] === '\n') break;
145
+ }
146
+ throw new Error('open string');
147
+ }
148
+ if (c === '`') {
149
+ for (let j = i + 1; j < src.length; j += 1) {
150
+ if (src[j] === '\\') { j += 1; continue; }
151
+ if (src[j] === '`') return j + 1;
152
+ if (src[j] === '$' && src[j + 1] === '{') { j = balanced(src, j + 1, '{', '}') - 1; }
153
+ }
154
+ throw new Error('open template');
155
+ }
156
+ // A slash starts a regular expression after an operator or an opening bracket, never after a value.
157
+ if (c === '/' && (prev === null || /[(,=:[!&|?{};+\-*%<>~^]/.test(prev))) {
158
+ let inClass = false;
159
+ for (let j = i + 1; j < src.length; j += 1) {
160
+ if (src[j] === '\\') { j += 1; continue; }
161
+ if (src[j] === '\n') break;
162
+ if (src[j] === '[') inClass = true;
163
+ else if (src[j] === ']') inClass = false;
164
+ else if (src[j] === '/' && !inClass) { let k = j + 1; while (/[a-z]/i.test(src[k] ?? '')) k += 1; return k; }
165
+ }
166
+ throw new Error('open regular expression');
167
+ }
168
+ return i;
169
+ }
170
+
171
+ // The index after the bracket that closes the one at `open`. Throws when it does not close.
172
+ function balanced(src, open, opener, closer) {
173
+ let depth = 0;
174
+ let prev = null;
175
+ for (let i = open; i < src.length;) {
176
+ const after = skipToken(src, i, prev);
177
+ if (after !== i) { i = after; prev = 'a'; continue; }
178
+ const c = src[i];
179
+ if (c === opener) depth += 1;
180
+ else if (c === closer) { depth -= 1; if (depth === 0) return i + 1; }
181
+ if (!/\s/.test(c)) prev = c;
182
+ i += 1;
183
+ }
184
+ throw new Error('open bracket');
185
+ }
186
+
187
+ // The check blocks of a scenario test file: Map of check key ("scenario/e1") to its text.
188
+ // JavaScript: each `<name>.check("<id>", …)` call. Python: each `with <name>.check("<id>"):` block.
189
+ // Returns null when the file cannot be read to the end; then the caller lists the whole file.
190
+ export function checkBlocks(text, path) {
191
+ if (text === null) return new Map();
192
+ const blocks = new Map();
193
+ const src = text.replace(/\r\n?/g, '\n');
194
+ try {
195
+ if (path.endsWith('.py')) {
196
+ const lines = src.split('\n');
197
+ for (let i = 0; i < lines.length; i += 1) {
198
+ const m = /^(\s*)with\s+\w+\.check\(\s*(["'])([^"']+)\2\s*\)\s*(as\s+\w+\s*)?:\s*(#.*)?$/.exec(lines[i]);
199
+ if (!m || !CHECK_ID.test(m[3])) continue;
200
+ const indent = m[1].length;
201
+ let j = i + 1;
202
+ while (j < lines.length && (lines[j].trim() === '' || lines[j].search(/\S/) > indent)) j += 1;
203
+ add(blocks, keyOf(m[3]), lines.slice(i, j).join('\n').trimEnd());
204
+ }
205
+ return blocks;
206
+ }
207
+ const call = /\b\w+\.check\(\s*(["'`])([^"'`\n]+)\1/g;
208
+ let m;
209
+ let from = 0;
210
+ while ((m = call.exec(src))) {
211
+ if (m.index < from || !CHECK_ID.test(m[2])) continue;
212
+ const open = src.indexOf('(', m.index);
213
+ const end = balanced(src, open, '(', ')');
214
+ add(blocks, keyOf(m[2]), src.slice(m.index, end));
215
+ from = end;
216
+ call.lastIndex = end;
217
+ }
218
+ return blocks;
219
+ } catch {
220
+ return null;
221
+ }
222
+ }
223
+
224
+ // Two blocks with one id are kept as one text, so a second copy shows as a change.
225
+ function add(map, key, text) { map.set(key, map.has(key) ? `${map.get(key)}\n${text}` : text); }
226
+
227
+ // The assertions and the case tables of one scenario file on one side: Map of id to fingerprint.
228
+ // null when the file does not parse.
229
+ function scenarioPrints(text, path, testsText) {
230
+ if (text === null) return { id: null, prints: new Map() };
231
+ let tests = null;
232
+ try { const v = testsText === null ? null : parseYaml(testsText).value; tests = v && typeof v === 'object' ? v : null; } catch { tests = null; }
233
+ let s;
234
+ try { s = parseScenario(text, path); } catch { return null; }
235
+ if (!s || !s.id) return null;
236
+ addFingerprints(s, tests);
237
+ const prints = new Map();
238
+ for (const a of s.assertions) prints.set(a.id, a.fp ?? fingerprintOf(a.words));
239
+ for (const [name, table] of s.tables ?? []) prints.set(name, table.fingerprint ?? fingerprintOf(JSON.stringify(table)));
240
+ return { id: s.id, prints };
241
+ }
242
+
243
+ // ---------------------------------------------------------------- the list
244
+
245
+ const row = (area, group, status, path, from = null, id = null, contentChanged = null) => ({ area, group, status, path, from, id, content_changed: contentChanged });
246
+
247
+ // Rows for the ids of two sides: added, changed or removed. `inner` names each id.
248
+ function compareMaps(baseMap, headMap, make) {
249
+ const out = [];
250
+ for (const [id, text] of headMap) {
251
+ if (!baseMap.has(id)) out.push(make('added', id));
252
+ else if (baseMap.get(id) !== text) out.push(make('changed', id));
253
+ }
254
+ for (const id of baseMap.keys()) if (!headMap.has(id)) out.push(make('removed', id));
255
+ return out;
256
+ }
257
+
258
+ // The changes of the diff, from `git diff --name-status -z`. A copy reads as added.
259
+ function diffEntries(cwd, from, to) {
260
+ const parts = git(cwd, ['diff', '--name-status', '-M', '-z', '--no-ext-diff', '--no-textconv', from, to]).split('\0');
261
+ const out = [];
262
+ for (let i = 0; i < parts.length && parts[i];) {
263
+ const code = parts[i][0];
264
+ if (code === 'R' || code === 'C') {
265
+ out.push({ status: code === 'R' ? 'renamed' : 'added', from: code === 'R' ? parts[i + 1] : null, path: parts[i + 2] });
266
+ i += 3;
267
+ } else {
268
+ out.push({ status: code === 'A' ? 'added' : code === 'D' ? 'removed' : 'changed', from: null, path: parts[i + 1] });
269
+ i += 2;
270
+ }
271
+ }
272
+ return out;
273
+ }
274
+
275
+ // The areas on one side: each folder that holds atlas/tests.yaml. '' is the repo root.
276
+ function areasAt(cwd, sha) {
277
+ const listed = git(cwd, ['ls-tree', '-r', '--name-only', '-z', sha], { allowFail: true }) ?? '';
278
+ return listed.split('\0')
279
+ .filter((p) => p === 'atlas/tests.yaml' || p.endsWith('/atlas/tests.yaml'))
280
+ .map((p) => p.slice(0, -'atlas/tests.yaml'.length).replace(/\/$/, ''))
281
+ .filter((area) => !area.split('/').some((part) => FIXTURE_PART.has(part)));
282
+ }
283
+
284
+ function scenarioFilesAt(cwd, sha, area) {
285
+ const dir = area ? `${area}/scenarios` : 'scenarios';
286
+ const listed = git(cwd, ['ls-tree', '-r', '--name-only', '-z', sha, '--', dir], { allowFail: true }) ?? '';
287
+ return listed.split('\0').filter((p) => p.endsWith('.md') && !SKIP_SCENARIO.test(p));
288
+ }
289
+
290
+ const testsYamlOf = (area) => (area ? `${area}/atlas/tests.yaml` : 'atlas/tests.yaml');
291
+
292
+ // Lists the test things that the head changes against `base`. Returns the JSON answer.
293
+ export function listChanged({ root, base, head = 'HEAD' }) {
294
+ if (!base) throw usage('give the base: --base <ref>');
295
+ for (const ref of [base, head]) if (String(ref).startsWith('-')) throw usage(`"${ref}" is not a ref`);
296
+ const cwd = resolve(root);
297
+ const top = git(cwd, ['rev-parse', '--show-toplevel']).trim();
298
+ const shallow = (git(top, ['rev-parse', '--is-shallow-repository'], { allowFail: true }) ?? '').trim() === 'true';
299
+ const hint = shallow ? ' This clone is shallow. Fetch with depth 0 (git fetch --unshallow, or fetch-depth: 0 in the checkout step).' : '';
300
+ const commit = (ref) => (git(top, ['rev-parse', '--verify', '--quiet', '--end-of-options', `${ref}^{commit}`], { allowFail: true }) ?? '').trim();
301
+ const baseSha = commit(base);
302
+ if (!baseSha) throw new Error(`the base "${base}" is not a commit in this repo.${hint}`);
303
+ const headSha = commit(head);
304
+ if (!headSha) throw new Error(`the head "${head}" is not a commit in this repo.`);
305
+ const mergeBase = (git(top, [MERGE_BASE, '--end-of-options', baseSha, headSha], { allowFail: true }) ?? '').trim();
306
+ if (!mergeBase) throw new Error(`the base "${base}" and the head have no common commit that this clone holds.${hint}`);
307
+ if (mergeBase === headSha && baseSha !== headSha) throw new Error(`the base "${base}" is ahead of the head (the merge base is the head). Give the base the pull request targets.`);
308
+
309
+ const entries = diffEntries(top, mergeBase, headSha);
310
+ const areas = [...new Set([...areasAt(top, mergeBase), ...areasAt(top, headSha)])].sort((a, b) => b.length - a.length);
311
+ const rows = [];
312
+ const other = [];
313
+
314
+ // A changed tests.yaml can change the fingerprints of every scenario of its area, so those are read too.
315
+ const scenarioPairs = new Map();
316
+ const addPair = (path, from) => { if (from || !scenarioPairs.has(path)) scenarioPairs.set(path, from ?? path); };
317
+ for (const e of entries) {
318
+ if (!e.path.endsWith('atlas/tests.yaml')) continue;
319
+ const area = e.path.slice(0, -'atlas/tests.yaml'.length).replace(/\/$/, '');
320
+ if (!areas.includes(area)) continue;
321
+ for (const p of new Set([...scenarioFilesAt(top, mergeBase, area), ...scenarioFilesAt(top, headSha, area)])) addPair(p, null);
322
+ }
323
+ const blob = (sha, path) => (git(top, ['rev-parse', '--verify', '--quiet', `${sha}:${path}`], { allowFail: true }) ?? '').trim();
324
+
325
+ for (const e of entries) {
326
+ const path = e.path;
327
+ const group = groupOf(path, areas) ?? (e.from ? groupOf(e.from, areas) : null);
328
+ if (!group) { other.push(path); continue; }
329
+ const area = areaOf(path, areas);
330
+ const before = e.from ?? path;
331
+ // A rename says whether the bytes moved as they were. Other statuses leave it null.
332
+ const moved = e.status === 'renamed' ? blob(mergeBase, before) !== blob(headSha, path) : null;
333
+ const fileRow = (status = e.status) => row(area, group, status, path, e.status === 'renamed' ? e.from : null, null, moved);
334
+ const idRow = (status, id) => row(area, group, status, path, null, id);
335
+ if (group === 'scenario-words') { addPair(path, e.from); continue; }
336
+ if (group === 'check-block') {
337
+ if (e.status === 'added' || e.status === 'removed') { rows.push(fileRow()); continue; }
338
+ if (e.status === 'renamed') rows.push(fileRow());
339
+ const b = checkBlocks(show(top, mergeBase, before), before);
340
+ const h = checkBlocks(show(top, headSha, path), path);
341
+ if (b === null || h === null) { if (e.status !== 'renamed') rows.push(fileRow('changed')); continue; }
342
+ const blockRows = compareMaps(b, h, idRow);
343
+ rows.push(...blockRows);
344
+ // Code outside the blocks (a setup, a helper inside the file) changed: the file itself is a row.
345
+ if (!blockRows.length && e.status === 'changed') rows.push(fileRow('changed'));
346
+ continue;
347
+ }
348
+ if (group === 'test-script') {
349
+ const b = testScripts(show(top, mergeBase, before));
350
+ const h = testScripts(show(top, headSha, path));
351
+ if (b === null || h === null) { rows.push(fileRow()); continue; }
352
+ const scriptRows = compareMaps(new Map(Object.entries(b)), new Map(Object.entries(h)), idRow);
353
+ if (!scriptRows.length) other.push(path);
354
+ rows.push(...scriptRows);
355
+ continue;
356
+ }
357
+ if (group === 'runner-config') {
358
+ const name = posix.basename(path);
359
+ if (name === 'pyproject.toml' || name === 'setup.cfg') {
360
+ const b = pytestSection(show(top, mergeBase, before), name);
361
+ const h = pytestSection(show(top, headSha, path), name);
362
+ if (b === h) { other.push(path); continue; }
363
+ rows.push(fileRow(!b ? 'added' : !h ? 'removed' : 'changed'));
364
+ continue;
365
+ }
366
+ }
367
+ rows.push(fileRow());
368
+ }
369
+
370
+ for (const [path, from] of scenarioPairs) {
371
+ const area = scenarioArea(path, areas) ?? scenarioArea(from, areas) ?? '';
372
+ const headText = show(top, headSha, path);
373
+ const b = scenarioPrints(show(top, mergeBase, from), from, show(top, mergeBase, testsYamlOf(area)));
374
+ const h = scenarioPrints(headText, path, show(top, headSha, testsYamlOf(area)));
375
+ if (b === null || h === null) {
376
+ rows.push(row(area, 'scenario-words', headText === null ? 'removed' : 'changed', path));
377
+ continue;
378
+ }
379
+ const scenarioId = h.id ?? b.id;
380
+ rows.push(...compareMaps(b.prints, h.prints, (status, id) => row(area, 'scenario-words', status, path, null, `${scenarioId}/${id}`)));
381
+ }
382
+
383
+ const order = (r) => [r.area, GROUPS.indexOf(r.group), r.path, r.id ?? '', STATUSES.indexOf(r.status)];
384
+ rows.sort((x, y) => {
385
+ const a = order(x);
386
+ const b = order(y);
387
+ for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
388
+ return 0;
389
+ });
390
+ const counts = Object.fromEntries(STATUSES.map((s) => [s, rows.filter((r) => r.status === s).length]));
391
+ return { changed_tests: VERSION, base, merge_base: mergeBase, head: headSha, counts, other_paths: [...new Set(other)].sort(), rows };
392
+ }
393
+
394
+ // ---------------------------------------------------------------- the command
395
+
396
+ const oneLine = (text) => String(text).replace(/[\u0000-\u001f\u007f]/g, ' ');
397
+
398
+ export function renderText(answer) {
399
+ const c = answer.counts;
400
+ const out = [`${answer.rows.length} test change(s) against ${oneLine(answer.base)} (merge base ${answer.merge_base.slice(0, 7)}): ${c.added} added, ${c.changed} changed, ${c.renamed} renamed, ${c.removed} removed`];
401
+ for (const r of answer.rows) {
402
+ const tail = [r.id ? oneLine(r.id) : null, r.from ? `(from ${oneLine(r.from)})` : null].filter(Boolean).join(' ');
403
+ out.push(` ${r.status.padEnd(8)} ${r.group.padEnd(14)} ${oneLine(r.path)}${tail ? ` ${tail}` : ''}`);
404
+ }
405
+ out.push(`${answer.other_paths.length} other changed path(s) match no test rule.`);
406
+ return out;
407
+ }
408
+
409
+ export function changedCommand({ base, head, out, text = false }, root, line) {
410
+ try {
411
+ const answer = listChanged({ root, base, head: head ?? 'HEAD' });
412
+ const json = `${JSON.stringify(answer, null, 2)}\n`;
413
+ if (out) writeFileSync(resolve(out), json);
414
+ if (text) for (const l of renderText(answer)) line(l);
415
+ else if (!out) process.stdout.write(json);
416
+ else line(`Wrote ${out}: ${answer.rows.length} test change(s).`);
417
+ return 0;
418
+ } catch (error) {
419
+ line(`changed: ${error.message}`);
420
+ return error.usage ? 2 : 1;
421
+ }
422
+ }
@@ -0,0 +1,382 @@
1
+ // `atlas tests before`: each new or changed test case of a pull request must fail on the old code
2
+ // and pass on the new code (roadmap R4, tracker item 1bbafe3d; design docs/design-docs/fails-before/).
3
+ //
4
+ // It reads the changed-test list (changed-tests.mjs) and takes each unit test file that the pull
5
+ // request adds, changes or renames. Run 1 is on the old code: a tree of the merge base, with the
6
+ // head's test files and test helpers copied in. Run 2 is a tree of the head. Both runs use one
7
+ // path and one environment, so only the code under test differs. Each report is read with the JUnit
8
+ // reader of the kit.
9
+ //
10
+ // before after result
11
+ // assertion pass proven
12
+ // pass pass passes before (red: the test cannot catch this change)
13
+ // any other fails after (red)
14
+ // missing pass load error (not proven)
15
+ // error pass error before (not proven)
16
+ // skipped on either side skipped (red)
17
+ // two cases with one name duplicate (red)
18
+ //
19
+ // Green does not mean proven: the gate counts only `proven` cases (proven_count).
20
+ // The command runs head code. It holds no secret: each run gets a fixed environment and nothing else.
21
+ // In CI each side runs in its own job on a fresh runner (--side old, --side new), and a third job
22
+ // reads both run files (--old, --new) with the base copy of Atlas. Each tree comes from git archive.
23
+ import { spawnSync } from 'node:child_process';
24
+ import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
25
+ import { tmpdir } from 'node:os';
26
+ import { dirname, join, posix, resolve } from 'node:path';
27
+ import { readJunit } from '../../tests/junit.mjs';
28
+ import { listChanged } from './changed-tests.mjs';
29
+
30
+ export const VERSION = 1;
31
+
32
+ function git(cwd, args, { allowFail = false, buffer = false } = {}) {
33
+ const r = spawnSync('git', args, { cwd, encoding: buffer ? 'buffer' : 'utf8', maxBuffer: 256 * 1024 * 1024 });
34
+ if (r.error) throw new Error(`git cannot run: ${r.error.message}`);
35
+ if (r.status !== 0) {
36
+ if (allowFail) return null;
37
+ throw new Error(`git ${args.join(' ')} failed: ${String(r.stderr || r.stdout || '').trim().split('\n')[0]}`);
38
+ }
39
+ return r.stdout;
40
+ }
41
+ const usage = (message) => Object.assign(new Error(message), { usage: true });
42
+
43
+ // ---------------------------------------------------------------- the cases of one test file
44
+
45
+ // The runner of a test file, by its name. null: no runner yet.
46
+ export function runnerOf(path) {
47
+ const name = posix.basename(path);
48
+ if (/\.test\.(mjs|js|cjs)$/.test(name)) return 'node';
49
+ if (/^test_[^/]+\.py$|_test\.py$/.test(name)) return 'pytest';
50
+ return null;
51
+ }
52
+
53
+ // Skips a string, a template string or a comment at `i`. Returns the index after it, or `i`.
54
+ function skip(src, i) {
55
+ const c = src[i];
56
+ if (c === '/' && src[i + 1] === '/') { const end = src.indexOf('\n', i); return end < 0 ? src.length : end; }
57
+ if (c === '/' && src[i + 1] === '*') { const end = src.indexOf('*/', i + 2); if (end < 0) throw new Error('open comment'); return end + 2; }
58
+ if (c === '"' || c === "'" || c === '`') {
59
+ for (let j = i + 1; j < src.length; j += 1) {
60
+ if (src[j] === '\\') { j += 1; continue; }
61
+ if (src[j] === c) return j + 1;
62
+ if (c === '`' && src[j] === '$' && src[j + 1] === '{') j = close(src, j + 1) - 1;
63
+ else if (c !== '`' && src[j] === '\n') break;
64
+ }
65
+ throw new Error('open string');
66
+ }
67
+ return i;
68
+ }
69
+
70
+ // The index after the bracket that closes the one at `open`. A regular expression is read as code,
71
+ // so a bracket inside one can end a case early; then the text still differs where the case differs.
72
+ function close(src, open) {
73
+ const pair = { '(': ')', '{': '}', '[': ']' };
74
+ const stack = [];
75
+ for (let i = open; i < src.length;) {
76
+ const after = skip(src, i);
77
+ if (after !== i) { i = after; continue; }
78
+ const c = src[i];
79
+ if (pair[c]) stack.push(pair[c]);
80
+ else if (c === ')' || c === '}' || c === ']') {
81
+ if (stack.pop() !== c) throw new Error('bracket mismatch');
82
+ if (!stack.length) return i + 1;
83
+ }
84
+ i += 1;
85
+ }
86
+ throw new Error('open bracket');
87
+ }
88
+
89
+ // The test cases of one file: { cases: Map of name to its text, rest, duplicates }. `rest` is the
90
+ // text outside every case, so an edit there (a beforeEach, a shared value) can mark every case.
91
+ // null when the file cannot be read to the end. JavaScript: each test(…) or it(…) call whose first
92
+ // argument is a fixed string. Python: each def test_… function, at any indent, to the end of its block.
93
+ export function casesOf(text, path) {
94
+ const out = { cases: new Map(), rest: '', duplicates: new Set() };
95
+ if (text === null) return out;
96
+ const src = String(text).replace(/\r\n?/g, '\n');
97
+ const add = (name, body) => {
98
+ if (out.cases.has(name)) out.duplicates.add(name);
99
+ out.cases.set(name, out.cases.has(name) ? `${out.cases.get(name)}\n${body}` : body);
100
+ };
101
+ const rest = [];
102
+ try {
103
+ if (path.endsWith('.py')) {
104
+ const lines = src.split('\n');
105
+ for (let i = 0; i < lines.length; i += 1) {
106
+ const m = /^(\s*)(?:async\s+)?def\s+(test\w*)\s*\(/.exec(lines[i]);
107
+ if (!m) { rest.push(lines[i]); continue; }
108
+ let j = i + 1;
109
+ while (j < lines.length && (lines[j].trim() === '' || lines[j].search(/\S/) > m[1].length)) j += 1;
110
+ add(m[2], lines.slice(i, j).join('\n').trimEnd());
111
+ i = j - 1;
112
+ }
113
+ } else {
114
+ const call = /(?<![\w.$])(test|it)(\.(?:only|skip|todo))?\(\s*(["'`])([^"'`\n]*)\3/g;
115
+ let m;
116
+ let from = 0;
117
+ while ((m = call.exec(src))) {
118
+ if (m.index < from) continue;
119
+ const end = close(src, src.indexOf('(', m.index));
120
+ add(m[4], src.slice(m.index, end));
121
+ rest.push(src.slice(from, m.index));
122
+ from = end;
123
+ call.lastIndex = end;
124
+ }
125
+ rest.push(src.slice(from));
126
+ }
127
+ } catch {
128
+ return null;
129
+ }
130
+ // A piece between two cases that holds only white space and separators is no code.
131
+ out.rest = rest.map((piece) => piece.replace(/^[\s;,]+|[\s;,]+$/g, '')).filter(Boolean).join('\n').replace(/\s+/g, ' ').trim();
132
+ return out;
133
+ }
134
+
135
+ // ---------------------------------------------------------------- one report
136
+
137
+ // The text of each <testcase> element, in order, so a failure can be sorted.
138
+ function caseTexts(xml) {
139
+ return [...String(xml).matchAll(/<testcase\b[^>]*?(?:\/>|>([\s\S]*?)<\/testcase>)/g)].map((m) => m[1] ?? '');
140
+ }
141
+
142
+ // The results of one report: Map of case name to pass, assertion, error or skipped.
143
+ // For pytest, a name with parameters ("test_x[1]") counts under its function name.
144
+ export function readReport(xml) {
145
+ const cases = readJunit(xml);
146
+ const texts = caseTexts(xml);
147
+ const rank = { pass: 0, skipped: 1, error: 2, assertion: 3 };
148
+ const out = new Map();
149
+ cases.forEach((c, i) => {
150
+ let result = c.result;
151
+ if (result === 'fail') result = /AssertionError|ERR_ASSERTION/.test(`${c.message ?? ''}\n${texts[i] ?? ''}`) ? 'assertion' : 'error';
152
+ const name = c.name.replace(/\[.*\]$/, '');
153
+ // When one name holds several results, the worst one counts: a fail beats a pass.
154
+ if (!out.has(name) || rank[result] > rank[out.get(name)]) out.set(name, result);
155
+ });
156
+ return out;
157
+ }
158
+
159
+ export function resultOf(before, after) {
160
+ if (before === 'skipped' || after === 'skipped') return 'skipped';
161
+ if (after !== 'pass') return 'fails after';
162
+ if (before === 'assertion') return 'proven';
163
+ if (before === 'pass') return 'passes before';
164
+ if (before === 'missing') return 'load error';
165
+ return 'error before';
166
+ }
167
+
168
+ export const RED_RESULTS = Object.freeze(['passes before', 'fails after', 'skipped', 'duplicate']);
169
+
170
+ // ---------------------------------------------------------------- the plan
171
+
172
+ // A JavaScript case that only moved its line breaks or spaces is not changed. In Python the indent
173
+ // is code, so its text counts as it is.
174
+ const squash = (text, path) => (path.endsWith('.py') ? text : text.replace(/\s+/g, ' ').trim());
175
+ const isProduct = (path) => !/^(docs|archive)\//.test(path) && !/^[^/]+\.md$/i.test(path);
176
+ const OVERLAY = new Set(['unit-test', 'test-helper', 'runner-config', 'kit-manifest', 'check-block']);
177
+
178
+ // What to run: the changed-test list, the files and, for each file that runs, its picked cases.
179
+ export function plan({ root, base, head = 'HEAD', list = null }) {
180
+ if (!base && !list) throw usage('give the base: --base <ref>');
181
+ const top = git(resolve(root), ['rev-parse', '--show-toplevel']).trim();
182
+ const changed = list ? JSON.parse(readFileSync(resolve(list), 'utf8')) : listChanged({ root: top, base, head });
183
+ if (changed.changed_tests !== 1) throw new Error(`the changed-test list has version ${changed.changed_tests}; this command reads version 1`);
184
+ const show = (sha, path) => git(top, ['show', `${sha}:${path}`], { allowFail: true });
185
+ const files = [];
186
+ const plans = [];
187
+ for (const r of changed.rows.filter((x) => x.group === 'check-block' && x.status !== 'removed')) {
188
+ if (!files.some((f) => f.path === r.path)) files.push({ path: r.path, runner: null, result: 'not run', why: 'a scenario test needs its environment; the planted-fault procedure covers it' });
189
+ }
190
+ for (const r of changed.rows.filter((x) => x.group === 'unit-test' && x.status !== 'removed')) {
191
+ const runner = runnerOf(r.path);
192
+ if (!runner) { files.push({ path: r.path, runner: null, result: 'not run', why: 'no runner for this file yet' }); continue; }
193
+ const before = r.status === 'added' ? casesOf(null, r.path) : casesOf(show(changed.merge_base, r.from ?? r.path), r.from ?? r.path);
194
+ const after = casesOf(show(changed.head, r.path), r.path);
195
+ // all: every case of the file counts as changed, also those that the reader did not find.
196
+ let all = false;
197
+ const picked = [];
198
+ if (after === null || before === null) all = true;
199
+ else {
200
+ if (r.status !== 'added' && before.rest !== after.rest) all = true;
201
+ for (const [name, text] of after.cases) {
202
+ if (all || !before.cases.has(name) || squash(before.cases.get(name), r.path) !== squash(text, r.path)) picked.push({ name, change: before.cases.has(name) ? 'changed' : 'added', duplicate: after.duplicates.has(name) });
203
+ }
204
+ }
205
+ if (!picked.length && !all && r.status !== 'added') continue;
206
+ files.push({ path: r.path, runner, result: 'ran', why: null });
207
+ plans.push({ path: r.path, runner, picked, all: all || r.status === 'added', known: after ? [...after.cases.keys()] : [] });
208
+ }
209
+ files.sort((x, y) => (x.path < y.path ? -1 : 1));
210
+ return { top, changed, files, plans, productChanged: changed.other_paths.some(isProduct) };
211
+ }
212
+
213
+ // ---------------------------------------------------------------- one run
214
+
215
+ // The environment of every run. Nothing of the parent passes through but PATH.
216
+ export function runEnv(work) {
217
+ return { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: join(work, 'home'), TMPDIR: join(work, 'tmp'), TZ: 'UTC', LANG: 'C.UTF-8', CI: 'true' };
218
+ }
219
+
220
+ const commandOf = (runner, file, report) => (runner === 'node'
221
+ ? ['node', '--test', '--test-reporter=junit', `--test-reporter-destination=${report}`, file]
222
+ : ['python3', '-B', '-m', 'pytest', '-p', 'no:cacheprovider', '-q', `--junitxml=${report}`, file]);
223
+
224
+ // A fresh tree of `sha` at `tree`, from git archive: no .git, so a test cannot read which commit it is.
225
+ function freshTree(top, tree, sha) {
226
+ rmSync(tree, { recursive: true, force: true });
227
+ mkdirSync(tree, { recursive: true });
228
+ const archive = spawnSync('git', ['archive', '--format=tar', sha], { cwd: top, maxBuffer: 1024 * 1024 * 1024 });
229
+ if (archive.status !== 0) throw new Error(`git archive failed: ${String(archive.stderr).trim()}`);
230
+ const untar = spawnSync('tar', ['-x', '-C', tree], { input: archive.stdout });
231
+ if (untar.status !== 0) throw new Error(`tar failed: ${String(untar.stderr).trim()}`);
232
+ if (existsSync(join(top, 'node_modules')) && !existsSync(join(tree, 'node_modules'))) symlinkSync(join(top, 'node_modules'), join(tree, 'node_modules'), 'dir');
233
+ }
234
+
235
+ // Runs one side: `old` (the merge base with the head's test files) or `new` (the head).
236
+ // Returns the run file: { side, merge_base, head, tree, report, env, files: { path: { command, cases, why } } }.
237
+ export function runSide({ top, changed, plans, side, work = join(tmpdir(), 'atlas-before') }) {
238
+ if (side !== 'old' && side !== 'new') throw usage('--side is old or new');
239
+ const tree = join(work, 'repo');
240
+ const report = join(work, 'report', 'report.xml');
241
+ const env = runEnv(work);
242
+ const out = { fails_before_run: VERSION, side, merge_base: changed.merge_base, head: changed.head, tree, report, env, files: {} };
243
+ const python = plans.some((p) => p.runner === 'pytest');
244
+ try {
245
+ for (const p of plans) {
246
+ // A fresh tree, home and report folder for each file, so no file of one run reaches the next.
247
+ freshTree(top, tree, side === 'old' ? changed.merge_base : changed.head);
248
+ if (side === 'old') {
249
+ for (const r of changed.rows.filter((x) => OVERLAY.has(x.group))) {
250
+ if (r.from) rmSync(join(tree, r.from), { force: true });
251
+ if (r.status === 'removed') { rmSync(join(tree, r.path), { force: true }); continue; }
252
+ const bytes = git(top, ['show', `${changed.head}:${r.path}`], { allowFail: true, buffer: true });
253
+ if (bytes === null) continue;
254
+ mkdirSync(dirname(join(tree, r.path)), { recursive: true });
255
+ writeFileSync(join(tree, r.path), bytes);
256
+ }
257
+ }
258
+ for (const dir of ['home', 'tmp', 'report']) { rmSync(join(work, dir), { recursive: true, force: true }); mkdirSync(join(work, dir), { recursive: true }); }
259
+ const command = commandOf(p.runner, p.path, report);
260
+ if (python && p.runner === 'pytest' && spawnSync('python3', ['-B', '-c', 'import pytest'], { cwd: tree, env }).status !== 0) {
261
+ out.files[p.path] = { command, cases: null, why: 'pytest is not installed', none: true };
262
+ continue;
263
+ }
264
+ const r = spawnSync(command[0] === 'node' ? process.execPath : command[0], command.slice(1), { cwd: tree, env, encoding: 'utf8', timeout: 10 * 60 * 1000, killSignal: 'SIGKILL', maxBuffer: 64 * 1024 * 1024 });
265
+ let cases = null;
266
+ let why = null;
267
+ if (r.error) why = `the runner did not finish: ${r.error.message}`;
268
+ else if (!existsSync(report)) why = `the runner wrote no report (exit ${r.status})`;
269
+ else {
270
+ try { cases = Object.fromEntries(readReport(readFileSync(report, 'utf8'))); } catch (error) { why = `the report does not read: ${error.message}`; }
271
+ }
272
+ out.files[p.path] = { command, cases, why };
273
+ }
274
+ } finally {
275
+ rmSync(tree, { recursive: true, force: true });
276
+ }
277
+ return out;
278
+ }
279
+
280
+ // ---------------------------------------------------------------- the answer
281
+
282
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
283
+
284
+ // Reads the two run files and gives the answer. It runs nothing.
285
+ export function combine(prepared, oldRun, newRun) {
286
+ const { changed, files, plans, productChanged } = prepared;
287
+ const answer = { fails_before: VERSION, base: changed.base, merge_base: changed.merge_base, head: changed.head, product_changed: productChanged, red: false, proven_count: 0, files: files.map((f) => ({ ...f })), cases: [], env: null, runs: null };
288
+ if (!productChanged || !plans.length) {
289
+ for (const p of plans) for (const c of p.picked) answer.cases.push({ path: p.path, name: c.name, change: c.change, before: null, after: null, result: c.duplicate ? 'duplicate' : 'not proven' });
290
+ return finish(answer);
291
+ }
292
+ if (oldRun?.side !== 'old' || newRun?.side !== 'new') throw new Error('give one run file of the side old and one of the side new');
293
+ for (const run of [oldRun, newRun]) {
294
+ if (run.fails_before_run !== VERSION) throw new Error(`a run file has version ${run.fails_before_run}; this command reads version ${VERSION}`);
295
+ if (run.merge_base !== changed.merge_base || run.head !== changed.head) throw new Error(`the run file of side ${run.side} is of other commits`);
296
+ }
297
+ if (!same(oldRun.env, newRun.env) || oldRun.tree !== newRun.tree || oldRun.report !== newRun.report) throw new Error('the two runs did not use one tree path, one report path and one environment');
298
+ answer.env = newRun.env;
299
+ answer.runs = { tree: newRun.tree, report: newRun.report };
300
+ for (const p of plans) {
301
+ const b = oldRun.files[p.path];
302
+ const a = newRun.files[p.path];
303
+ const file = answer.files.find((f) => f.path === p.path);
304
+ if (!a || !b) throw new Error(`a run file has no result for ${p.path}`);
305
+ if (!same(a.command, b.command)) throw new Error(`the two runs started ${p.path} with other command lines`);
306
+ if (a.none || b.none) { Object.assign(file, { result: 'not run', why: a.why ?? b.why }); continue; }
307
+ if (a.why || b.why) file.why = [b.why && `old code: ${b.why}`, a.why && `new code: ${a.why}`].filter(Boolean).join('; ');
308
+ const headCases = a.cases ?? {};
309
+ // A case of the head report that the reader did not find counts as changed.
310
+ const picked = [...p.picked];
311
+ const unknown = Object.keys(headCases).filter((name) => !p.known.includes(name));
312
+ for (const name of p.all ? Object.keys(headCases) : unknown) if (!picked.some((c) => c.name === name)) picked.push({ name, change: 'changed', duplicate: false });
313
+ if (!picked.length) { answer.cases.push({ path: p.path, name: null, change: 'changed', before: null, after: 'missing', result: 'fails after' }); continue; }
314
+ for (const c of picked) {
315
+ const before = b.cases?.[c.name] ?? 'missing';
316
+ const after = headCases[c.name] ?? 'missing';
317
+ answer.cases.push({ path: p.path, name: c.name, change: c.change, before, after, result: c.duplicate ? 'duplicate' : resultOf(before, after) });
318
+ }
319
+ }
320
+ return finish(answer);
321
+ }
322
+
323
+ // Both runs, one after the other, then the answer. For a local run.
324
+ export function failsBefore({ root, base, head = 'HEAD', list = null, work = join(tmpdir(), 'atlas-before') }) {
325
+ const prepared = plan({ root, base, head, list });
326
+ if (!prepared.productChanged || !prepared.plans.length) return combine(prepared, null, null);
327
+ const oldRun = runSide({ ...prepared, side: 'old', work });
328
+ const newRun = runSide({ ...prepared, side: 'new', work });
329
+ return combine(prepared, oldRun, newRun);
330
+ }
331
+
332
+ function finish(answer) {
333
+ answer.cases.sort((x, y) => (x.path === y.path ? String(x.name).localeCompare(String(y.name)) : x.path < y.path ? -1 : 1));
334
+ answer.red = answer.cases.some((c) => RED_RESULTS.includes(c.result));
335
+ answer.proven_count = answer.cases.filter((c) => c.result === 'proven').length;
336
+ return answer;
337
+ }
338
+
339
+ // ---------------------------------------------------------------- the command
340
+
341
+ const oneLine = (text) => String(text ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ');
342
+
343
+ export function renderText(a) {
344
+ const out = [`fails-before against ${oneLine(a.base)} (merge base ${a.merge_base.slice(0, 7)}): ${a.proven_count} of ${a.cases.length} case(s) proven, in ${a.files.filter((f) => f.result === 'ran').length} file(s); product code changed: ${a.product_changed ? 'yes' : 'no'}`];
345
+ for (const c of a.cases) out.push(` ${c.result.padEnd(14)} ${oneLine(c.path)} ${oneLine(c.name ?? '(the whole file)')}`);
346
+ for (const f of a.files.filter((x) => x.result === 'not run')) out.push(` ${'not run'.padEnd(14)} ${oneLine(f.path)} ${oneLine(f.why)}`);
347
+ for (const f of a.files.filter((x) => x.result === 'ran' && x.why)) out.push(` note ${oneLine(f.path)} ${oneLine(f.why)}`);
348
+ const red = a.cases.filter((c) => RED_RESULTS.includes(c.result));
349
+ if (red.length) out.push(`RED: ${red.length} case(s): ${[...new Set(red.map((c) => c.result))].join(', ')}. A case that passes on the old code cannot catch this change.`);
350
+ else if (!a.product_changed) out.push('GREEN: no product code changed, so the new cases are not proven.');
351
+ else if (a.product_changed && !a.cases.length) out.push('GREEN: product code changed, and no new or changed case runs. Nothing is proven.');
352
+ else out.push(`GREEN: no red case. Green does not mean proven: ${a.proven_count} proven, ${a.cases.length - a.proven_count} not proven.`);
353
+ return out;
354
+ }
355
+
356
+ export function beforeCommand({ base, head, list, out, side, old: oldFile, new: newFile, text = false }, root, line) {
357
+ try {
358
+ const write = (value) => {
359
+ const json = `${JSON.stringify(value, null, 2)}\n`;
360
+ if (out) writeFileSync(resolve(out), json);
361
+ else if (!text) process.stdout.write(json);
362
+ };
363
+ if (side) {
364
+ const prepared = plan({ root, base, head: head ?? 'HEAD', list });
365
+ const run = runSide({ ...prepared, side });
366
+ write(run);
367
+ if (text) line(`fails-before: ran the ${side} side of ${Object.keys(run.files).length} file(s).`);
368
+ return 0;
369
+ }
370
+ let answer;
371
+ if (oldFile || newFile) {
372
+ const read = (file) => (file ? JSON.parse(readFileSync(resolve(file), 'utf8')) : null);
373
+ answer = combine(plan({ root, base, head: head ?? 'HEAD', list }), read(oldFile), read(newFile));
374
+ } else answer = failsBefore({ root, base, head: head ?? 'HEAD', list });
375
+ write(answer);
376
+ if (text) for (const l of renderText(answer)) line(l);
377
+ return answer.red ? 1 : 0;
378
+ } catch (error) {
379
+ line(`before: ${error.message}`);
380
+ return error.usage ? 2 : 1;
381
+ }
382
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.3.42",
3
+ "version": "0.3.44",
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",
@@ -101,7 +101,7 @@ set. A test keeps the two lists equal.
101
101
  | `atlas kind-drift` | When the repo record and `atlas.yaml` may disagree on the kind; this command retires in stage 2, the onboarding stage |
102
102
  | `atlas code-read` | To resolve the citations of a code digest |
103
103
  | `atlas ste` | Before each publish; `--share` before a page goes to anyone else |
104
- | `atlas tests` | `write` puts the test kit in an area. `check` runs before a merge. `kept` runs the kept tests of a path 1 area and writes their evidence. `proof`, `verdict`, `named`, `covers` and `rerun` read the results of a run. `sweep` and `issues` run the daily sweep |
104
+ | `atlas tests` | `write` puts the test kit in an area. `check` runs before a merge. `kept` runs the kept tests of a path 1 area and writes their evidence. `proof`, `verdict`, `named`, `covers` and `rerun` read the results of a run. `changed` and `before` run test checks of the gate: the changed tests, and the proof that a new case fails on the old code. `sweep` and `issues` run the daily sweep |
105
105
  | `atlas design` | `folder` to learn where designs live; `check` before you show a design; `build` to make its page |
106
106
  | `atlas view` | `build --product <id>` makes the project view page of one product, when the owner asks for it or after an Atlas release changes it. Publish it with only the grant the command prints |
107
107
  | `atlas upkeep` | `check` before you ask for a merge: the docs index, dead doc links, and the tracker item a pull request names. The daily upkeep run calls it on the main branch |