@arjunkhera/atlas 0.3.48 → 0.3.49
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 +8 -1
- package/door/lib/fails-before.mjs +65 -9
- package/door/lib/passes-after.mjs +212 -0
- package/package.json +1 -1
- package/skills/lead/SKILL.md +1 -1
package/door/cli.mjs
CHANGED
|
@@ -35,6 +35,7 @@ import { sweepCommand as testsSweep, coversCommand as testsCovers } from '../tes
|
|
|
35
35
|
import { issuesCommand as testsIssues } from './lib/issues.mjs';
|
|
36
36
|
import { changedCommand as testsChanged } from './lib/changed-tests.mjs';
|
|
37
37
|
import { beforeCommand as testsBefore } from './lib/fails-before.mjs';
|
|
38
|
+
import { afterCommand as testsAfter } from './lib/passes-after.mjs';
|
|
38
39
|
import { upkeepCommand } from './lib/upkeep.mjs';
|
|
39
40
|
import { gateCommand } from './lib/gate.mjs';
|
|
40
41
|
import { judgeCommand } from './lib/judge.mjs';
|
|
@@ -95,6 +96,11 @@ const HELP = `atlas — the door into a repo's Atlas files
|
|
|
95
96
|
--old <file> --new <file> read the two run files and give the answer; runs nothing
|
|
96
97
|
--out <file> write the JSON here; default the terminal
|
|
97
98
|
--text print a short table instead of the JSON
|
|
99
|
+
atlas tests after --root <repo> --base <ref> the old version of each changed or removed test case must pass on the new code; exit 1 when one goes to the owner
|
|
100
|
+
--head <ref> the head of the pull request; default HEAD
|
|
101
|
+
--list <file> a changed-test list from atlas tests changed; default the command makes it
|
|
102
|
+
--out <file> write the JSON here; default the terminal
|
|
103
|
+
--text print a short table instead of the JSON
|
|
98
104
|
atlas tests named --root <area> list the assertions that a pull request names
|
|
99
105
|
--base <ref> the base: an assertion is named when its words changed or it is new
|
|
100
106
|
--covers also name the assertions of a scenario whose covers path the change touches
|
|
@@ -366,6 +372,7 @@ async function testsCommand(chosen) {
|
|
|
366
372
|
if (what === 'proof') return testsProof({ run: chosen.run, own: chosen.own, evidence: chosen.evidence, base: chosen.base, covers: Boolean(chosen.covers) }, root, line);
|
|
367
373
|
if (what === 'verdict') return testsVerdict({ run: chosen.run, base: chosen.base, evidence: chosen.evidence, covers: Boolean(chosen.covers) }, root, line);
|
|
368
374
|
if (what === 'changed') return testsChanged({ base: chosen.base, head: chosen.head, out: chosen.out, text: Boolean(chosen.text) }, root, line);
|
|
375
|
+
if (what === 'after') return testsAfter({ base: chosen.base, head: chosen.head, list: chosen.list, out: chosen.out, text: Boolean(chosen.text) }, root, line);
|
|
369
376
|
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);
|
|
370
377
|
if (what === 'named') return testsNamed({ base: chosen.base, covers: Boolean(chosen.covers) }, root, line);
|
|
371
378
|
if (what === 'sweep') return testsSweep({ evidence: chosen.evidence, since: chosen.since, base: chosen.base, out: chosen.out, text: Boolean(chosen.text) }, root, line);
|
|
@@ -373,7 +380,7 @@ async function testsCommand(chosen) {
|
|
|
373
380
|
if (what === 'kept') return (await runKept({ root, evidenceDir: chosen.evidence, only: chosen.scenario ?? null, write: (text) => process.stdout.write(text) })).exit;
|
|
374
381
|
if (what === 'rerun') return testsRerun({ run: chosen.run, evidence: chosen.evidence, tests: chosen.tests, language: chosen.language ?? 'javascript' }, root, line);
|
|
375
382
|
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);
|
|
376
|
-
throw new Error(`there is no tests verb "${what ?? ''}". Use check, write, proof, verdict, named, changed, before, sweep, covers, kept, issues or rerun.`);
|
|
383
|
+
throw new Error(`there is no tests verb "${what ?? ''}". Use check, write, proof, verdict, named, changed, before, after, sweep, covers, kept, issues or rerun.`);
|
|
377
384
|
}
|
|
378
385
|
|
|
379
386
|
function upkeepVerb(chosen) {
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
// Green does not mean proven: the gate counts only `proven` cases (proven_count).
|
|
20
20
|
// The command runs head code. It holds no secret: each run gets a fixed environment and nothing else.
|
|
21
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
|
|
22
|
+
// reads both run files (--old, --new) with the base copy of Atlas. Each tree comes from git archive,
|
|
23
|
+
// and then becomes a new git repo with one commit, so a test that needs git still runs.
|
|
23
24
|
import { spawnSync } from 'node:child_process';
|
|
24
25
|
import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
25
26
|
import { tmpdir } from 'node:os';
|
|
@@ -50,11 +51,31 @@ export function runnerOf(path) {
|
|
|
50
51
|
return null;
|
|
51
52
|
}
|
|
52
53
|
|
|
53
|
-
//
|
|
54
|
+
// A slash starts a regular expression when the code before it cannot end a value.
|
|
55
|
+
function regexAt(src, i) {
|
|
56
|
+
let j = i - 1;
|
|
57
|
+
while (j >= 0 && /\s/.test(src[j])) j -= 1;
|
|
58
|
+
if (j < 0 || '(,=:[!&|?{};+-*%<>~^'.includes(src[j])) return true;
|
|
59
|
+
const word = /([A-Za-z_$][\w$]*)$/.exec(src.slice(Math.max(0, j - 10), j + 1));
|
|
60
|
+
return Boolean(word && ['return', 'typeof', 'case', 'in', 'of', 'void', 'delete', 'throw', 'yield', 'await'].includes(word[1]));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Skips a string, a template string, a regular expression or a comment at `i`. Returns the index
|
|
64
|
+
// after it, or `i`.
|
|
54
65
|
function skip(src, i) {
|
|
55
66
|
const c = src[i];
|
|
56
67
|
if (c === '/' && src[i + 1] === '/') { const end = src.indexOf('\n', i); return end < 0 ? src.length : end; }
|
|
57
68
|
if (c === '/' && src[i + 1] === '*') { const end = src.indexOf('*/', i + 2); if (end < 0) throw new Error('open comment'); return end + 2; }
|
|
69
|
+
if (c === '/' && regexAt(src, i)) {
|
|
70
|
+
let inClass = false;
|
|
71
|
+
for (let j = i + 1; j < src.length && src[j] !== '\n'; j += 1) {
|
|
72
|
+
if (src[j] === '\\') { j += 1; continue; }
|
|
73
|
+
if (src[j] === '[') inClass = true;
|
|
74
|
+
else if (src[j] === ']') inClass = false;
|
|
75
|
+
else if (src[j] === '/' && !inClass) { j += 1; while (/[a-z]/.test(src[j] ?? '')) j += 1; return j; }
|
|
76
|
+
}
|
|
77
|
+
throw new Error('open regular expression');
|
|
78
|
+
}
|
|
58
79
|
if (c === '"' || c === "'" || c === '`') {
|
|
59
80
|
for (let j = i + 1; j < src.length; j += 1) {
|
|
60
81
|
if (src[j] === '\\') { j += 1; continue; }
|
|
@@ -67,8 +88,7 @@ function skip(src, i) {
|
|
|
67
88
|
return i;
|
|
68
89
|
}
|
|
69
90
|
|
|
70
|
-
// The index after the bracket that closes the one at `open`.
|
|
71
|
-
// so a bracket inside one can end a case early; then the text still differs where the case differs.
|
|
91
|
+
// The index after the bracket that closes the one at `open`.
|
|
72
92
|
function close(src, open) {
|
|
73
93
|
const pair = { '(': ')', '{': '}', '[': ']' };
|
|
74
94
|
const stack = [];
|
|
@@ -86,6 +106,19 @@ function close(src, open) {
|
|
|
86
106
|
throw new Error('open bracket');
|
|
87
107
|
}
|
|
88
108
|
|
|
109
|
+
// The fixed name of a string at `i`, with its escapes read. null for a template with a ${…} in it.
|
|
110
|
+
function nameAt(src, i) {
|
|
111
|
+
const raw = src.slice(i + 1, skip(src, i) - 1);
|
|
112
|
+
if (src[i] === '`' && /(^|[^\\])\$\{/.test(raw)) return null;
|
|
113
|
+
const named = { n: '\n', t: '\t', r: '\r', b: '\b', f: '\f', v: '\v', 0: '\0' };
|
|
114
|
+
return raw.replace(/\\(u\{[0-9a-fA-F]+\}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|\n|.)/g, (_, e) => {
|
|
115
|
+
if (e[0] === 'u' && e.length > 1) return String.fromCodePoint(parseInt(e.replace(/[u{}]/g, ''), 16));
|
|
116
|
+
if (e[0] === 'x' && e.length === 3) return String.fromCharCode(parseInt(e.slice(1), 16));
|
|
117
|
+
if (e === '\n') return '';
|
|
118
|
+
return named[e] ?? e;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
89
122
|
// The test cases of one file: { cases: Map of name to its text, rest, duplicates }. `rest` is the
|
|
90
123
|
// text outside every case, so an edit there (a beforeEach, a shared value) can mark every case.
|
|
91
124
|
// null when the file cannot be read to the end. JavaScript: each test(…) or it(…) call whose first
|
|
@@ -111,13 +144,24 @@ export function casesOf(text, path) {
|
|
|
111
144
|
i = j - 1;
|
|
112
145
|
}
|
|
113
146
|
} else {
|
|
114
|
-
const call = /(?<![\w.$])(test|it)(\.(?:only|skip|todo))?\(\s*(["'`])
|
|
147
|
+
const call = /(?<![\w.$])(test|it)(\.(?:only|skip|todo))?\(\s*(?=["'`])/g;
|
|
115
148
|
let m;
|
|
116
149
|
let from = 0;
|
|
150
|
+
// scan walks the code up to each match, so a test( inside a string or a comment is no case.
|
|
151
|
+
let scan = 0;
|
|
117
152
|
while ((m = call.exec(src))) {
|
|
118
153
|
if (m.index < from) continue;
|
|
154
|
+
if (scan < from) scan = from;
|
|
155
|
+
while (scan < m.index) {
|
|
156
|
+
const after = skip(src, scan);
|
|
157
|
+
if (after === scan) scan += 1;
|
|
158
|
+
else scan = after;
|
|
159
|
+
}
|
|
160
|
+
if (scan > m.index) { call.lastIndex = scan; continue; }
|
|
161
|
+
const name = nameAt(src, m.index + m[0].length);
|
|
162
|
+
if (name === null) continue;
|
|
119
163
|
const end = close(src, src.indexOf('(', m.index));
|
|
120
|
-
add(
|
|
164
|
+
add(name, src.slice(m.index, end));
|
|
121
165
|
rest.push(src.slice(from, m.index));
|
|
122
166
|
from = end;
|
|
123
167
|
call.lastIndex = end;
|
|
@@ -217,12 +261,13 @@ export function runEnv(work) {
|
|
|
217
261
|
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
262
|
}
|
|
219
263
|
|
|
220
|
-
const commandOf = (runner, file, report) => (runner === 'node'
|
|
264
|
+
export const commandOf = (runner, file, report) => (runner === 'node'
|
|
221
265
|
? ['node', '--test', '--test-reporter=junit', `--test-reporter-destination=${report}`, file]
|
|
222
266
|
: ['python3', '-B', '-m', 'pytest', '-p', 'no:cacheprovider', '-q', `--junitxml=${report}`, file]);
|
|
223
267
|
|
|
224
|
-
// A fresh tree of `sha` at `tree`, from git archive: no .git, so a test cannot read which
|
|
225
|
-
|
|
268
|
+
// A fresh tree of `sha` at `tree`, from git archive: no .git of the repo, so a test cannot read which
|
|
269
|
+
// commit it is. gitTree then gives it a .git of its own.
|
|
270
|
+
export function freshTree(top, tree, sha) {
|
|
226
271
|
rmSync(tree, { recursive: true, force: true });
|
|
227
272
|
mkdirSync(tree, { recursive: true });
|
|
228
273
|
const archive = spawnSync('git', ['archive', '--format=tar', sha], { cwd: top, maxBuffer: 1024 * 1024 * 1024 });
|
|
@@ -232,6 +277,16 @@ function freshTree(top, tree, sha) {
|
|
|
232
277
|
if (existsSync(join(top, 'node_modules')) && !existsSync(join(tree, 'node_modules'))) symlinkSync(join(top, 'node_modules'), join(tree, 'node_modules'), 'dir');
|
|
233
278
|
}
|
|
234
279
|
|
|
280
|
+
// Makes the tree a new git repo with one commit of its files, so a test that needs git runs. The
|
|
281
|
+
// commit has a fixed author, date and message, so it holds no history and names neither real commit.
|
|
282
|
+
export function gitTree(tree) {
|
|
283
|
+
const env = { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: tree, GIT_CONFIG_NOSYSTEM: '1', GIT_AUTHOR_NAME: 'atlas', GIT_AUTHOR_EMAIL: 'atlas@invalid', GIT_COMMITTER_NAME: 'atlas', GIT_COMMITTER_EMAIL: 'atlas@invalid', GIT_AUTHOR_DATE: '2000-01-01T00:00:00Z', GIT_COMMITTER_DATE: '2000-01-01T00:00:00Z' };
|
|
284
|
+
for (const args of [['init', '-q', '-b', 'main'], ['add', '-A'], ['commit', '-q', '--no-verify', '--no-gpg-sign', '-m', 'tree']]) {
|
|
285
|
+
const r = spawnSync('git', args, { cwd: tree, env, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
286
|
+
if (r.status !== 0) throw new Error(`git ${args[0]} in the run tree failed: ${String(r.stderr).trim().split('\n')[0]}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
235
290
|
// Runs one side: `old` (the merge base with the head's test files) or `new` (the head).
|
|
236
291
|
// Returns the run file: { side, merge_base, head, tree, report, env, files: { path: { command, cases, why } } }.
|
|
237
292
|
export function runSide({ top, changed, plans, side, work = join(tmpdir(), 'atlas-before') }) {
|
|
@@ -255,6 +310,7 @@ export function runSide({ top, changed, plans, side, work = join(tmpdir(), 'atla
|
|
|
255
310
|
writeFileSync(join(tree, r.path), bytes);
|
|
256
311
|
}
|
|
257
312
|
}
|
|
313
|
+
gitTree(tree);
|
|
258
314
|
for (const dir of ['home', 'tmp', 'report']) { rmSync(join(work, dir), { recursive: true, force: true }); mkdirSync(join(work, dir), { recursive: true }); }
|
|
259
315
|
const command = commandOf(p.runner, p.path, report);
|
|
260
316
|
if (python && p.runner === 'pytest' && spawnSync('python3', ['-B', '-c', 'import pytest'], { cwd: tree, env }).status !== 0) {
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// `atlas tests after`: the base version of each changed, renamed or removed test case runs on the
|
|
2
|
+
// head code (roadmap R73, tracker item 29e1f17d; design docs/design-docs/passes-after/).
|
|
3
|
+
//
|
|
4
|
+
// It reads the changed-test list (changed-tests.mjs) and takes each unit test file that the pull
|
|
5
|
+
// request changes, renames or removes. It makes a tree of the head, puts in the base copy of each
|
|
6
|
+
// such file and the base copy of the test helpers, runner configs and kit manifests, and runs the
|
|
7
|
+
// old file there. So the old tests judge the new code.
|
|
8
|
+
//
|
|
9
|
+
// the old case on the new code the case in the PR result
|
|
10
|
+
// pass changed or renamed cleared
|
|
11
|
+
// any other changed or renamed owner: changed test (red)
|
|
12
|
+
// pass removed removed, passes (for the review job)
|
|
13
|
+
// any other removed owner: removed test (red)
|
|
14
|
+
// did not run or skipped any not run (not cleared)
|
|
15
|
+
//
|
|
16
|
+
// The command takes no card and no model verdict, so neither can clear a case. It runs head code
|
|
17
|
+
// with the environment, the tree and the runners of fails-before, and it holds no secret.
|
|
18
|
+
import { spawnSync } from 'node:child_process';
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { tmpdir } from 'node:os';
|
|
21
|
+
import { dirname, join, resolve } from 'node:path';
|
|
22
|
+
import { listChanged } from './changed-tests.mjs';
|
|
23
|
+
import { casesOf, commandOf, freshTree, gitTree, readReport, runEnv, runnerOf } from './fails-before.mjs';
|
|
24
|
+
|
|
25
|
+
export const VERSION = 1;
|
|
26
|
+
|
|
27
|
+
const TEST_GROUPS = new Set(['unit-test', 'check-block', 'scenario-words', 'test-helper', 'runner-config', 'test-script', 'kit-manifest']);
|
|
28
|
+
const SUPPORT = new Set(['test-helper', 'runner-config', 'kit-manifest']);
|
|
29
|
+
const isProduct = (path) => !/^(docs|archive)\//.test(path) && !/^[^/]+\.md$/i.test(path);
|
|
30
|
+
export const OWNER_RESULTS = Object.freeze(['owner: changed test', 'owner: removed test']);
|
|
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 text of a case with its own name taken out, so a case renamed with the same body matches.
|
|
44
|
+
const unnamed = (text, name) => text.split(name).join('\u0000');
|
|
45
|
+
|
|
46
|
+
// The old cases of one file that the head changed, renamed or removed.
|
|
47
|
+
export function oldCases(before, after) {
|
|
48
|
+
if (before === null) return { all: true, picked: [] };
|
|
49
|
+
if (after === null || before.rest !== after.rest) {
|
|
50
|
+
return { all: true, picked: [...before.cases.keys()].map((name) => ({ name, change: after?.cases.has(name) ? 'changed' : 'removed', to: null })) };
|
|
51
|
+
}
|
|
52
|
+
const picked = [];
|
|
53
|
+
const taken = new Set();
|
|
54
|
+
for (const [name, text] of before.cases) {
|
|
55
|
+
if (after.cases.has(name)) {
|
|
56
|
+
if (after.cases.get(name) !== text) picked.push({ name, change: 'changed', to: null });
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const to = [...after.cases].find(([n, t]) => !before.cases.has(n) && !taken.has(n) && unnamed(t, n) === unnamed(text, name))?.[0];
|
|
60
|
+
if (to) { taken.add(to); picked.push({ name, change: 'renamed', to }); } else picked.push({ name, change: 'removed', to: null });
|
|
61
|
+
}
|
|
62
|
+
return { all: false, picked };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// What to run: for each changed, renamed or removed unit test file, the base path and its old cases.
|
|
66
|
+
export function plan({ root, base, head = 'HEAD', list = null }) {
|
|
67
|
+
if (!base && !list) throw usage('give the base: --base <ref>');
|
|
68
|
+
const top = git(resolve(root), ['rev-parse', '--show-toplevel']).trim();
|
|
69
|
+
const changed = list ? JSON.parse(readFileSync(resolve(list), 'utf8')) : listChanged({ root: top, base, head });
|
|
70
|
+
if (changed.changed_tests !== 1) throw new Error(`the changed-test list has version ${changed.changed_tests}; this command reads version 1`);
|
|
71
|
+
const show = (sha, path) => git(top, ['show', `${sha}:${path}`], { allowFail: true });
|
|
72
|
+
const files = [];
|
|
73
|
+
const plans = [];
|
|
74
|
+
for (const r of changed.rows.filter((x) => x.group === 'check-block' && x.status !== 'added')) {
|
|
75
|
+
const path = r.from ?? r.path;
|
|
76
|
+
if (!files.some((f) => f.path === path)) files.push({ path, runner: null, result: 'not run', why: 'a scenario test needs its environment; the planted-fault procedure covers it' });
|
|
77
|
+
}
|
|
78
|
+
for (const r of changed.rows.filter((x) => x.group === 'unit-test' && x.status !== 'added')) {
|
|
79
|
+
const path = r.from ?? r.path;
|
|
80
|
+
const runner = runnerOf(path);
|
|
81
|
+
const before = casesOf(show(changed.merge_base, path), path);
|
|
82
|
+
const after = r.status === 'removed' ? { cases: new Map(), rest: before?.rest ?? '', duplicates: new Set() } : casesOf(show(changed.head, r.path), r.path);
|
|
83
|
+
const { all, picked } = oldCases(before, after);
|
|
84
|
+
if (!picked.length && !all) continue;
|
|
85
|
+
if (!runner) { files.push({ path, runner: null, result: 'not run', why: 'no runner for this file yet' }); plans.push({ path, runner: null, picked, all }); continue; }
|
|
86
|
+
files.push({ path, runner, result: 'ran', why: null });
|
|
87
|
+
plans.push({ path, runner, picked, all, head: r.status === 'removed' ? null : r.path });
|
|
88
|
+
}
|
|
89
|
+
files.sort((x, y) => (x.path < y.path ? -1 : 1));
|
|
90
|
+
return {
|
|
91
|
+
top, changed, files, plans,
|
|
92
|
+
productChanged: changed.other_paths.some(isProduct),
|
|
93
|
+
testsChanged: changed.rows.some((x) => TEST_GROUPS.has(x.group)),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Puts the base copy of a path into the tree.
|
|
98
|
+
function putBase(top, tree, sha, path) {
|
|
99
|
+
const bytes = git(top, ['show', `${sha}:${path}`], { allowFail: true, buffer: true });
|
|
100
|
+
if (bytes === null) return;
|
|
101
|
+
mkdirSync(dirname(join(tree, path)), { recursive: true });
|
|
102
|
+
writeFileSync(join(tree, path), bytes);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Runs the old version of each planned file on a tree of the head. Returns { tree, report, env, files }.
|
|
106
|
+
export function runOld({ top, changed, plans, work = join(tmpdir(), 'atlas-after') }) {
|
|
107
|
+
const tree = join(work, 'repo');
|
|
108
|
+
const report = join(work, 'report', 'report.xml');
|
|
109
|
+
const env = runEnv(work);
|
|
110
|
+
const out = { tree, report, env, files: {} };
|
|
111
|
+
try {
|
|
112
|
+
for (const p of plans.filter((x) => x.runner)) {
|
|
113
|
+
// A fresh tree, home and report folder for each file, so no file of one run reaches the next.
|
|
114
|
+
freshTree(top, tree, changed.head);
|
|
115
|
+
for (const r of changed.rows.filter((x) => SUPPORT.has(x.group) && x.status !== 'added')) putBase(top, tree, changed.merge_base, r.from ?? r.path);
|
|
116
|
+
for (const r of changed.rows.filter((x) => x.group === 'unit-test' && x.status !== 'added')) {
|
|
117
|
+
// A renamed file runs at its old path only, so no case runs twice.
|
|
118
|
+
if (r.status === 'renamed') rmSync(join(tree, r.path), { force: true });
|
|
119
|
+
putBase(top, tree, changed.merge_base, r.from ?? r.path);
|
|
120
|
+
}
|
|
121
|
+
gitTree(tree);
|
|
122
|
+
for (const dir of ['home', 'tmp', 'report']) { rmSync(join(work, dir), { recursive: true, force: true }); mkdirSync(join(work, dir), { recursive: true }); }
|
|
123
|
+
const command = commandOf(p.runner, p.path, report);
|
|
124
|
+
if (p.runner === 'pytest' && spawnSync('python3', ['-B', '-c', 'import pytest'], { cwd: tree, env }).status !== 0) {
|
|
125
|
+
out.files[p.path] = { command, cases: null, why: 'pytest is not installed', none: true };
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
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 });
|
|
129
|
+
let cases = null;
|
|
130
|
+
let why = null;
|
|
131
|
+
if (r.error) why = `the runner did not finish: ${r.error.message}`;
|
|
132
|
+
else if (!existsSync(report)) why = `the runner wrote no report (exit ${r.status})`;
|
|
133
|
+
else {
|
|
134
|
+
try { cases = Object.fromEntries(readReport(readFileSync(report, 'utf8'))); } catch (error) { why = `the report does not read: ${error.message}`; }
|
|
135
|
+
}
|
|
136
|
+
out.files[p.path] = { command, cases, why };
|
|
137
|
+
}
|
|
138
|
+
} finally {
|
|
139
|
+
rmSync(tree, { recursive: true, force: true });
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function resultOf(change, after) {
|
|
145
|
+
if (after === 'skipped' || after === null) return 'not run';
|
|
146
|
+
if (change === 'removed') return after === 'pass' ? 'removed, passes' : 'owner: removed test';
|
|
147
|
+
return after === 'pass' ? 'cleared' : 'owner: changed test';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// The answer, from the plan and the run.
|
|
151
|
+
export function answerOf(prepared, run) {
|
|
152
|
+
const { changed, files, plans, productChanged, testsChanged } = prepared;
|
|
153
|
+
const answer = { passes_after: VERSION, base: changed.base, merge_base: changed.merge_base, head: changed.head, product_changed: productChanged, tests_changed: testsChanged, red: false, owner_count: 0, files: files.map((f) => ({ ...f })), cases: [], env: run?.env ?? null };
|
|
154
|
+
for (const p of plans) {
|
|
155
|
+
const ran = run?.files[p.path];
|
|
156
|
+
const file = answer.files.find((f) => f.path === p.path);
|
|
157
|
+
if (ran?.none) Object.assign(file, { result: 'not run', why: ran.why });
|
|
158
|
+
else if (ran?.why) file.why = ran.why;
|
|
159
|
+
const results = ran && !ran.none ? (ran.cases ?? {}) : null;
|
|
160
|
+
const picked = [...p.picked];
|
|
161
|
+
// A file that cannot be read to the end: every case of its report counts as changed.
|
|
162
|
+
if (p.all && results) for (const name of Object.keys(results)) if (!picked.some((c) => c.name === name)) picked.push({ name, change: 'changed', to: null });
|
|
163
|
+
if (!picked.length) {
|
|
164
|
+
answer.cases.push({ path: p.path, name: null, change: 'changed', to: null, after: results ? 'missing' : null, result: results ? 'owner: changed test' : 'not run' });
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
for (const c of picked) {
|
|
168
|
+
const after = results ? (results[c.name] ?? 'missing') : null;
|
|
169
|
+
answer.cases.push({ path: p.path, name: c.name, change: c.change, to: c.to, after, result: resultOf(c.change, after) });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
answer.cases.sort((x, y) => (x.path === y.path ? String(x.name).localeCompare(String(y.name)) : x.path < y.path ? -1 : 1));
|
|
173
|
+
answer.owner_count = answer.cases.filter((c) => OWNER_RESULTS.includes(c.result)).length;
|
|
174
|
+
answer.red = answer.owner_count > 0;
|
|
175
|
+
return answer;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function passesAfter({ root, base, head = 'HEAD', list = null, work = join(tmpdir(), 'atlas-after') }) {
|
|
179
|
+
const prepared = plan({ root, base, head, list });
|
|
180
|
+
return answerOf(prepared, prepared.plans.length ? runOld({ ...prepared, work }) : null);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------- the command
|
|
184
|
+
|
|
185
|
+
const oneLine = (text) => String(text ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ');
|
|
186
|
+
|
|
187
|
+
export function renderText(a) {
|
|
188
|
+
const ran = a.files.filter((f) => f.result === 'ran').length;
|
|
189
|
+
const out = [`passes-after against ${oneLine(a.base)} (merge base ${a.merge_base.slice(0, 7)}): ${a.cases.length} old case(s) in ${ran} file(s); ${a.owner_count} for the owner; tests changed: ${a.tests_changed ? 'yes' : 'no'}`];
|
|
190
|
+
for (const c of a.cases) out.push(` ${c.result.padEnd(20)} ${oneLine(c.path)} ${oneLine(c.name ?? '(the whole file)')}${c.to ? ` (renamed to: ${oneLine(c.to)})` : ''}`);
|
|
191
|
+
for (const f of a.files.filter((x) => x.result === 'not run')) out.push(` ${'not run'.padEnd(20)} ${oneLine(f.path)} ${oneLine(f.why)}`);
|
|
192
|
+
for (const f of a.files.filter((x) => x.result === 'ran' && x.why)) out.push(` ${'note'.padEnd(20)} ${oneLine(f.path)} ${oneLine(f.why)}`);
|
|
193
|
+
if (a.red) out.push(`RED: ${a.owner_count} case(s) for the owner. Only the owner clears a changed test that fails on the new code.`);
|
|
194
|
+
else if (!a.tests_changed) out.push('GREEN: the pull request changes no test.');
|
|
195
|
+
else if (a.cases.some((c) => c.result === 'not run')) out.push(`GREEN with gaps: ${a.cases.filter((c) => c.result === 'not run').length} old case(s) did not run, so they are not cleared.`);
|
|
196
|
+
else out.push('GREEN: each old case that the pull request changed still passes on the new code.');
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function afterCommand({ base, head, list, out, text = false }, root, line) {
|
|
201
|
+
try {
|
|
202
|
+
const answer = passesAfter({ root, base, head: head ?? 'HEAD', list });
|
|
203
|
+
const json = `${JSON.stringify(answer, null, 2)}\n`;
|
|
204
|
+
if (out) writeFileSync(resolve(out), json);
|
|
205
|
+
else if (!text) process.stdout.write(json);
|
|
206
|
+
if (text) for (const l of renderText(answer)) line(l);
|
|
207
|
+
return answer.red ? 1 : 0;
|
|
208
|
+
} catch (error) {
|
|
209
|
+
line(`after: ${error.message}`);
|
|
210
|
+
return error.usage ? 2 : 1;
|
|
211
|
+
}
|
|
212
|
+
}
|
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` and `
|
|
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`, `before` and `after` run the test checks of the gate: changed tests, new cases fail on old code, old cases pass on new 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 |
|