@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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas",
3
- "version": "0.3.48",
3
+ "version": "0.3.49",
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
@@ -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
- // Skips a string, a template string or a comment at `i`. Returns the index after it, or `i`.
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`. 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.
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*(["'`])([^"'`\n]*)\3/g;
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(m[4], src.slice(m.index, end));
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 commit it is.
225
- function freshTree(top, tree, sha) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.3.48",
3
+ "version": "0.3.49",
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. `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 |
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 |