@arjunkhera/atlas 0.3.43 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/door/cli.mjs +11 -2
- package/door/lib/changed-tests.mjs +4 -2
- package/door/lib/fails-before.mjs +382 -0
- package/package.json +1 -1
- package/skills/lead/SKILL.md +1 -1
package/door/cli.mjs
CHANGED
|
@@ -34,6 +34,7 @@ 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
36
|
import { changedCommand as testsChanged } from './lib/changed-tests.mjs';
|
|
37
|
+
import { beforeCommand as testsBefore } from './lib/fails-before.mjs';
|
|
37
38
|
import { upkeepCommand } from './lib/upkeep.mjs';
|
|
38
39
|
import { judgeCommand } from './lib/judge.mjs';
|
|
39
40
|
|
|
@@ -86,6 +87,13 @@ const HELP = `atlas — the door into a repo's Atlas files
|
|
|
86
87
|
--head <ref> the head of the pull request; default HEAD. Only committed files count
|
|
87
88
|
--out <file> write the JSON here; default the terminal
|
|
88
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
|
|
89
97
|
atlas tests named --root <area> list the assertions that a pull request names
|
|
90
98
|
--base <ref> the base: an assertion is named when its words changed or it is new
|
|
91
99
|
--covers also name the assertions of a scenario whose covers path the change touches
|
|
@@ -151,7 +159,7 @@ export const FLAGS = Object.freeze({
|
|
|
151
159
|
ste: { share: 'switch', terms: 'value' },
|
|
152
160
|
design: { tracker: 'value', out: 'value', draft: 'switch', root: 'optional' },
|
|
153
161
|
view: { product: 'value', name: 'value', out: 'value' },
|
|
154
|
-
tests: { root: 'optional', halves: 'value', evidence: 'value', 'guards-from': 'value', tests: 'value', 'dry-run': 'switch', run: 'value', own: 'value', base: 'value', head: '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' },
|
|
155
163
|
upkeep: { root: 'optional', event: 'value', json: 'switch' },
|
|
156
164
|
judge: { tasks: 'value', verdict: 'value', seal: 'value', runs: 'value', out: 'value', repo: 'value', budget: 'value', model: 'value' },
|
|
157
165
|
install: { local: 'switch', from: 'value', 'skip-global': 'switch', yes: 'switch' },
|
|
@@ -349,13 +357,14 @@ async function testsCommand(chosen) {
|
|
|
349
357
|
if (what === 'proof') return testsProof({ run: chosen.run, own: chosen.own, evidence: chosen.evidence, base: chosen.base, covers: Boolean(chosen.covers) }, root, line);
|
|
350
358
|
if (what === 'verdict') return testsVerdict({ run: chosen.run, base: chosen.base, evidence: chosen.evidence, covers: Boolean(chosen.covers) }, root, line);
|
|
351
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);
|
|
352
361
|
if (what === 'named') return testsNamed({ base: chosen.base, covers: Boolean(chosen.covers) }, root, line);
|
|
353
362
|
if (what === 'sweep') return testsSweep({ evidence: chosen.evidence, since: chosen.since, base: chosen.base, out: chosen.out, text: Boolean(chosen.text) }, root, line);
|
|
354
363
|
if (what === 'covers') return testsCovers({ itemId: chosen._[2], evidence: chosen.evidence, json: Boolean(chosen.json) }, root, line);
|
|
355
364
|
if (what === 'kept') return (await runKept({ root, evidenceDir: chosen.evidence, only: chosen.scenario ?? null, write: (text) => process.stdout.write(text) })).exit;
|
|
356
365
|
if (what === 'rerun') return testsRerun({ run: chosen.run, evidence: chosen.evidence, tests: chosen.tests, language: chosen.language ?? 'javascript' }, root, line);
|
|
357
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);
|
|
358
|
-
throw new Error(`there is no tests verb "${what ?? ''}". Use check, write, proof, verdict, named, changed, 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.`);
|
|
359
368
|
}
|
|
360
369
|
|
|
361
370
|
function upkeepVerb(chosen) {
|
|
@@ -46,10 +46,12 @@ const keyOf = (id) => String(id).replace(/#[0-9a-f]+$/, '');
|
|
|
46
46
|
const RUNNER_CONFIG = /^(vitest\.config\.[^/]+|vitest\.[^/]+\.config\.[^/]+|jest\.config\.[^/]+|playwright\.config\.[^/]+|\.mocharc(\.[^/]+)?|pytest\.ini|tox\.ini)$/;
|
|
47
47
|
const UNIT_TEST = /^([^/]+\.(test|spec)\.[^/]+|test_[^/]+\.py|[^/]+_test\.py|[^/]+_test\.go)$/;
|
|
48
48
|
// A helper folder at any depth. These names mean tests in every tool that uses them.
|
|
49
|
-
|
|
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']);
|
|
50
52
|
// A helper folder only at the top of the repo or of an area. Deeper, such a name can be product code
|
|
51
53
|
// (the Atlas test kit ships in packages/atlas/tests/).
|
|
52
|
-
const HELPER_TOP = new Set(['
|
|
54
|
+
const HELPER_TOP = new Set(['tests', 'spec', 'golden']);
|
|
53
55
|
// Test settings that live inside a package.json, beside the test scripts.
|
|
54
56
|
const PACKAGE_TEST_KEYS = Object.freeze(['jest', 'vitest', 'mocha', 'ava', 'c8', 'nyc']);
|
|
55
57
|
const SCENARIO_TEST = /(^|\/)tests?\/scenarios\/[^/]+(\.test\.mjs|_test\.py)$/;
|
|
@@ -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
package/skills/lead/SKILL.md
CHANGED
|
@@ -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. `changed`
|
|
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 |
|