@arjunkhera/atlas 0.3.42 → 0.3.43

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.43",
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,7 @@ 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';
36
37
  import { upkeepCommand } from './lib/upkeep.mjs';
37
38
  import { judgeCommand } from './lib/judge.mjs';
38
39
 
@@ -81,6 +82,10 @@ const HELP = `atlas — the door into a repo's Atlas files
81
82
  --run <id> the run; default the newest run in the evidence folder
82
83
  --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
84
  --covers also name the assertions of a scenario whose covers path the change touches
85
+ atlas tests changed --root <repo> --base <ref> list each test that a pull request adds, changes, renames or removes, as JSON
86
+ --head <ref> the head of the pull request; default HEAD. Only committed files count
87
+ --out <file> write the JSON here; default the terminal
88
+ --text print a short table instead of the JSON
84
89
  atlas tests named --root <area> list the assertions that a pull request names
85
90
  --base <ref> the base: an assertion is named when its words changed or it is new
86
91
  --covers also name the assertions of a scenario whose covers path the change touches
@@ -146,7 +151,7 @@ export const FLAGS = Object.freeze({
146
151
  ste: { share: 'switch', terms: 'value' },
147
152
  design: { tracker: 'value', out: 'value', draft: 'switch', root: 'optional' },
148
153
  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' },
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' },
150
155
  upkeep: { root: 'optional', event: 'value', json: 'switch' },
151
156
  judge: { tasks: 'value', verdict: 'value', seal: 'value', runs: 'value', out: 'value', repo: 'value', budget: 'value', model: 'value' },
152
157
  install: { local: 'switch', from: 'value', 'skip-global': 'switch', yes: 'switch' },
@@ -343,13 +348,14 @@ async function testsCommand(chosen) {
343
348
  if (what === 'write') return testsWrite({ dryRun: Boolean(chosen['dry-run']) }, root, PACKAGE_ROOT, line);
344
349
  if (what === 'proof') return testsProof({ run: chosen.run, own: chosen.own, evidence: chosen.evidence, base: chosen.base, covers: Boolean(chosen.covers) }, root, line);
345
350
  if (what === 'verdict') return testsVerdict({ run: chosen.run, base: chosen.base, evidence: chosen.evidence, covers: Boolean(chosen.covers) }, root, line);
351
+ if (what === 'changed') return testsChanged({ base: chosen.base, head: chosen.head, out: chosen.out, text: Boolean(chosen.text) }, root, line);
346
352
  if (what === 'named') return testsNamed({ base: chosen.base, covers: Boolean(chosen.covers) }, root, line);
347
353
  if (what === 'sweep') return testsSweep({ evidence: chosen.evidence, since: chosen.since, base: chosen.base, out: chosen.out, text: Boolean(chosen.text) }, root, line);
348
354
  if (what === 'covers') return testsCovers({ itemId: chosen._[2], evidence: chosen.evidence, json: Boolean(chosen.json) }, root, line);
349
355
  if (what === 'kept') return (await runKept({ root, evidenceDir: chosen.evidence, only: chosen.scenario ?? null, write: (text) => process.stdout.write(text) })).exit;
350
356
  if (what === 'rerun') return testsRerun({ run: chosen.run, evidence: chosen.evidence, tests: chosen.tests, language: chosen.language ?? 'javascript' }, root, line);
351
357
  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.`);
358
+ throw new Error(`there is no tests verb "${what ?? ''}". Use check, write, proof, verdict, named, changed, sweep, covers, kept, issues or rerun.`);
353
359
  }
354
360
 
355
361
  function upkeepVerb(chosen) {
@@ -0,0 +1,420 @@
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
+ const HELPER_ANY = new Set(['__tests__', '__snapshots__', 'testdata']);
50
+ // A helper folder only at the top of the repo or of an area. Deeper, such a name can be product code
51
+ // (the Atlas test kit ships in packages/atlas/tests/).
52
+ const HELPER_TOP = new Set(['test', 'tests', 'spec', 'fixtures', 'golden']);
53
+ // Test settings that live inside a package.json, beside the test scripts.
54
+ const PACKAGE_TEST_KEYS = Object.freeze(['jest', 'vitest', 'mocha', 'ava', 'c8', 'nyc']);
55
+ const SCENARIO_TEST = /(^|\/)tests?\/scenarios\/[^/]+(\.test\.mjs|_test\.py)$/;
56
+ const TEST_SCRIPT = /^(pre|post)?test(:.*)?$/;
57
+ const SKIP_SCENARIO = /(^|\/)(map|README)\.md$/;
58
+ // A check id reads "<scenario>/e<n>", with or without its "#<fingerprint>".
59
+ const CHECK_ID = /^[\w.-]+\/e\d+(#[0-9a-f]+)?$/;
60
+
61
+ // An area is a folder that holds atlas/tests.yaml. A folder inside a test folder is a fixture, not an area.
62
+ const FIXTURE_PART = new Set([...HELPER_ANY, ...HELPER_TOP]);
63
+ const isKitManifest = (path) => /(^|\/)atlas\/tests\.yaml$/.test(path) || /(^|\/)tests?\/atlas\/manifest\.json$/.test(path);
64
+ // A scenario file of an area: <area>/scenarios/**.md, where <area>/atlas/tests.yaml exists on one side.
65
+ function scenarioArea(path, areas) {
66
+ if (!path.endsWith('.md') || SKIP_SCENARIO.test(path)) return null;
67
+ for (const area of areas) {
68
+ const prefix = area ? `${area}/scenarios/` : 'scenarios/';
69
+ if (path.startsWith(prefix)) return area;
70
+ }
71
+ return null;
72
+ }
73
+
74
+ // The area of a path: the longest area that holds it, or '' (the repo root).
75
+ export function areaOf(path, areas = ['']) {
76
+ for (const area of [...areas].sort((a, b) => b.length - a.length)) if (!area || path.startsWith(`${area}/`)) return area;
77
+ return '';
78
+ }
79
+
80
+ function isHelper(path, areas) {
81
+ const name = posix.basename(path);
82
+ if (name === 'conftest.py' || name.endsWith('.snap')) return true;
83
+ const area = areaOf(path, areas);
84
+ const dirs = (area ? path.slice(area.length + 1) : path).split('/').slice(0, -1);
85
+ return dirs.some((d) => HELPER_ANY.has(d)) || HELPER_TOP.has(dirs[0]) || HELPER_TOP.has(path.split('/')[0]);
86
+ }
87
+
88
+ // The first rule that matches wins, in the order of the design (routes part).
89
+ export function groupOf(path, areas = ['']) {
90
+ const name = posix.basename(path);
91
+ if (isKitManifest(path)) return 'kit-manifest';
92
+ if (scenarioArea(path, areas) !== null) return 'scenario-words';
93
+ if (SCENARIO_TEST.test(path)) return 'check-block';
94
+ if (RUNNER_CONFIG.test(name) || name === 'pyproject.toml' || name === 'setup.cfg' || name === 'noxfile.py') return 'runner-config';
95
+ if (name === 'package.json') return 'test-script';
96
+ // A snapshot is named after its test file (`a.test.ts.snap`), but it is data that a test reads.
97
+ if (name.endsWith('.snap')) return 'test-helper';
98
+ if (UNIT_TEST.test(name)) return 'unit-test';
99
+ if (isHelper(path, areas)) return 'test-helper';
100
+ return null;
101
+ }
102
+
103
+ // ---------------------------------------------------------------- readers of one side
104
+
105
+ // The pytest section of a pyproject.toml or a setup.cfg, or '' when it has none.
106
+ export function pytestSection(text, name) {
107
+ if (text === null) return '';
108
+ const head = name === 'setup.cfg' ? /^\[tool:pytest\]\s*$/ : /^\[tool\.pytest(\.[^\]]+)?\]\s*$/;
109
+ const out = [];
110
+ let inside = false;
111
+ for (const line of text.replace(/\r\n?/g, '\n').split('\n')) {
112
+ if (/^\s*\[/.test(line)) inside = head.test(line.trim());
113
+ if (inside) out.push(line);
114
+ }
115
+ return out.join('\n');
116
+ }
117
+
118
+ // The test scripts of a package.json: { name: command }. null when the file does not read as JSON.
119
+ export function testScripts(text) {
120
+ if (text === null) return {};
121
+ let value;
122
+ try { value = JSON.parse(text); } catch { return null; }
123
+ if (!value || typeof value !== 'object') return {};
124
+ const scripts = value.scripts && typeof value.scripts === 'object' ? value.scripts : {};
125
+ const out = Object.fromEntries(Object.entries(scripts).filter(([name]) => TEST_SCRIPT.test(name)));
126
+ // A test setting key reads "key:<name>", so it never meets a script name.
127
+ for (const key of PACKAGE_TEST_KEYS) if (key in value) out[`key:${key}`] = JSON.stringify(value[key]);
128
+ return out;
129
+ }
130
+
131
+ // Skips a string, a template string, a comment or a regular expression that starts at `i`.
132
+ // Returns the index after it, or `i` when nothing starts there. Throws when it does not end.
133
+ function skipToken(src, i, prev) {
134
+ const c = src[i];
135
+ const n = src[i + 1];
136
+ if (c === '/' && n === '/') { const end = src.indexOf('\n', i); return end < 0 ? src.length : end; }
137
+ if (c === '/' && n === '*') { const end = src.indexOf('*/', i + 2); if (end < 0) throw new Error('open comment'); return end + 2; }
138
+ if (c === '"' || c === "'") {
139
+ for (let j = i + 1; j < src.length; j += 1) {
140
+ if (src[j] === '\\') { j += 1; continue; }
141
+ if (src[j] === c) return j + 1;
142
+ if (src[j] === '\n') break;
143
+ }
144
+ throw new Error('open string');
145
+ }
146
+ if (c === '`') {
147
+ for (let j = i + 1; j < src.length; j += 1) {
148
+ if (src[j] === '\\') { j += 1; continue; }
149
+ if (src[j] === '`') return j + 1;
150
+ if (src[j] === '$' && src[j + 1] === '{') { j = balanced(src, j + 1, '{', '}') - 1; }
151
+ }
152
+ throw new Error('open template');
153
+ }
154
+ // A slash starts a regular expression after an operator or an opening bracket, never after a value.
155
+ if (c === '/' && (prev === null || /[(,=:[!&|?{};+\-*%<>~^]/.test(prev))) {
156
+ let inClass = false;
157
+ for (let j = i + 1; j < src.length; j += 1) {
158
+ if (src[j] === '\\') { j += 1; continue; }
159
+ if (src[j] === '\n') break;
160
+ if (src[j] === '[') inClass = true;
161
+ else if (src[j] === ']') inClass = false;
162
+ else if (src[j] === '/' && !inClass) { let k = j + 1; while (/[a-z]/i.test(src[k] ?? '')) k += 1; return k; }
163
+ }
164
+ throw new Error('open regular expression');
165
+ }
166
+ return i;
167
+ }
168
+
169
+ // The index after the bracket that closes the one at `open`. Throws when it does not close.
170
+ function balanced(src, open, opener, closer) {
171
+ let depth = 0;
172
+ let prev = null;
173
+ for (let i = open; i < src.length;) {
174
+ const after = skipToken(src, i, prev);
175
+ if (after !== i) { i = after; prev = 'a'; continue; }
176
+ const c = src[i];
177
+ if (c === opener) depth += 1;
178
+ else if (c === closer) { depth -= 1; if (depth === 0) return i + 1; }
179
+ if (!/\s/.test(c)) prev = c;
180
+ i += 1;
181
+ }
182
+ throw new Error('open bracket');
183
+ }
184
+
185
+ // The check blocks of a scenario test file: Map of check key ("scenario/e1") to its text.
186
+ // JavaScript: each `<name>.check("<id>", …)` call. Python: each `with <name>.check("<id>"):` block.
187
+ // Returns null when the file cannot be read to the end; then the caller lists the whole file.
188
+ export function checkBlocks(text, path) {
189
+ if (text === null) return new Map();
190
+ const blocks = new Map();
191
+ const src = text.replace(/\r\n?/g, '\n');
192
+ try {
193
+ if (path.endsWith('.py')) {
194
+ const lines = src.split('\n');
195
+ for (let i = 0; i < lines.length; i += 1) {
196
+ const m = /^(\s*)with\s+\w+\.check\(\s*(["'])([^"']+)\2\s*\)\s*(as\s+\w+\s*)?:\s*(#.*)?$/.exec(lines[i]);
197
+ if (!m || !CHECK_ID.test(m[3])) continue;
198
+ const indent = m[1].length;
199
+ let j = i + 1;
200
+ while (j < lines.length && (lines[j].trim() === '' || lines[j].search(/\S/) > indent)) j += 1;
201
+ add(blocks, keyOf(m[3]), lines.slice(i, j).join('\n').trimEnd());
202
+ }
203
+ return blocks;
204
+ }
205
+ const call = /\b\w+\.check\(\s*(["'`])([^"'`\n]+)\1/g;
206
+ let m;
207
+ let from = 0;
208
+ while ((m = call.exec(src))) {
209
+ if (m.index < from || !CHECK_ID.test(m[2])) continue;
210
+ const open = src.indexOf('(', m.index);
211
+ const end = balanced(src, open, '(', ')');
212
+ add(blocks, keyOf(m[2]), src.slice(m.index, end));
213
+ from = end;
214
+ call.lastIndex = end;
215
+ }
216
+ return blocks;
217
+ } catch {
218
+ return null;
219
+ }
220
+ }
221
+
222
+ // Two blocks with one id are kept as one text, so a second copy shows as a change.
223
+ function add(map, key, text) { map.set(key, map.has(key) ? `${map.get(key)}\n${text}` : text); }
224
+
225
+ // The assertions and the case tables of one scenario file on one side: Map of id to fingerprint.
226
+ // null when the file does not parse.
227
+ function scenarioPrints(text, path, testsText) {
228
+ if (text === null) return { id: null, prints: new Map() };
229
+ let tests = null;
230
+ try { const v = testsText === null ? null : parseYaml(testsText).value; tests = v && typeof v === 'object' ? v : null; } catch { tests = null; }
231
+ let s;
232
+ try { s = parseScenario(text, path); } catch { return null; }
233
+ if (!s || !s.id) return null;
234
+ addFingerprints(s, tests);
235
+ const prints = new Map();
236
+ for (const a of s.assertions) prints.set(a.id, a.fp ?? fingerprintOf(a.words));
237
+ for (const [name, table] of s.tables ?? []) prints.set(name, table.fingerprint ?? fingerprintOf(JSON.stringify(table)));
238
+ return { id: s.id, prints };
239
+ }
240
+
241
+ // ---------------------------------------------------------------- the list
242
+
243
+ const row = (area, group, status, path, from = null, id = null, contentChanged = null) => ({ area, group, status, path, from, id, content_changed: contentChanged });
244
+
245
+ // Rows for the ids of two sides: added, changed or removed. `inner` names each id.
246
+ function compareMaps(baseMap, headMap, make) {
247
+ const out = [];
248
+ for (const [id, text] of headMap) {
249
+ if (!baseMap.has(id)) out.push(make('added', id));
250
+ else if (baseMap.get(id) !== text) out.push(make('changed', id));
251
+ }
252
+ for (const id of baseMap.keys()) if (!headMap.has(id)) out.push(make('removed', id));
253
+ return out;
254
+ }
255
+
256
+ // The changes of the diff, from `git diff --name-status -z`. A copy reads as added.
257
+ function diffEntries(cwd, from, to) {
258
+ const parts = git(cwd, ['diff', '--name-status', '-M', '-z', '--no-ext-diff', '--no-textconv', from, to]).split('\0');
259
+ const out = [];
260
+ for (let i = 0; i < parts.length && parts[i];) {
261
+ const code = parts[i][0];
262
+ if (code === 'R' || code === 'C') {
263
+ out.push({ status: code === 'R' ? 'renamed' : 'added', from: code === 'R' ? parts[i + 1] : null, path: parts[i + 2] });
264
+ i += 3;
265
+ } else {
266
+ out.push({ status: code === 'A' ? 'added' : code === 'D' ? 'removed' : 'changed', from: null, path: parts[i + 1] });
267
+ i += 2;
268
+ }
269
+ }
270
+ return out;
271
+ }
272
+
273
+ // The areas on one side: each folder that holds atlas/tests.yaml. '' is the repo root.
274
+ function areasAt(cwd, sha) {
275
+ const listed = git(cwd, ['ls-tree', '-r', '--name-only', '-z', sha], { allowFail: true }) ?? '';
276
+ return listed.split('\0')
277
+ .filter((p) => p === 'atlas/tests.yaml' || p.endsWith('/atlas/tests.yaml'))
278
+ .map((p) => p.slice(0, -'atlas/tests.yaml'.length).replace(/\/$/, ''))
279
+ .filter((area) => !area.split('/').some((part) => FIXTURE_PART.has(part)));
280
+ }
281
+
282
+ function scenarioFilesAt(cwd, sha, area) {
283
+ const dir = area ? `${area}/scenarios` : 'scenarios';
284
+ const listed = git(cwd, ['ls-tree', '-r', '--name-only', '-z', sha, '--', dir], { allowFail: true }) ?? '';
285
+ return listed.split('\0').filter((p) => p.endsWith('.md') && !SKIP_SCENARIO.test(p));
286
+ }
287
+
288
+ const testsYamlOf = (area) => (area ? `${area}/atlas/tests.yaml` : 'atlas/tests.yaml');
289
+
290
+ // Lists the test things that the head changes against `base`. Returns the JSON answer.
291
+ export function listChanged({ root, base, head = 'HEAD' }) {
292
+ if (!base) throw usage('give the base: --base <ref>');
293
+ for (const ref of [base, head]) if (String(ref).startsWith('-')) throw usage(`"${ref}" is not a ref`);
294
+ const cwd = resolve(root);
295
+ const top = git(cwd, ['rev-parse', '--show-toplevel']).trim();
296
+ const shallow = (git(top, ['rev-parse', '--is-shallow-repository'], { allowFail: true }) ?? '').trim() === 'true';
297
+ const hint = shallow ? ' This clone is shallow. Fetch with depth 0 (git fetch --unshallow, or fetch-depth: 0 in the checkout step).' : '';
298
+ const commit = (ref) => (git(top, ['rev-parse', '--verify', '--quiet', '--end-of-options', `${ref}^{commit}`], { allowFail: true }) ?? '').trim();
299
+ const baseSha = commit(base);
300
+ if (!baseSha) throw new Error(`the base "${base}" is not a commit in this repo.${hint}`);
301
+ const headSha = commit(head);
302
+ if (!headSha) throw new Error(`the head "${head}" is not a commit in this repo.`);
303
+ const mergeBase = (git(top, [MERGE_BASE, '--end-of-options', baseSha, headSha], { allowFail: true }) ?? '').trim();
304
+ if (!mergeBase) throw new Error(`the base "${base}" and the head have no common commit that this clone holds.${hint}`);
305
+ 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.`);
306
+
307
+ const entries = diffEntries(top, mergeBase, headSha);
308
+ const areas = [...new Set([...areasAt(top, mergeBase), ...areasAt(top, headSha)])].sort((a, b) => b.length - a.length);
309
+ const rows = [];
310
+ const other = [];
311
+
312
+ // A changed tests.yaml can change the fingerprints of every scenario of its area, so those are read too.
313
+ const scenarioPairs = new Map();
314
+ const addPair = (path, from) => { if (from || !scenarioPairs.has(path)) scenarioPairs.set(path, from ?? path); };
315
+ for (const e of entries) {
316
+ if (!e.path.endsWith('atlas/tests.yaml')) continue;
317
+ const area = e.path.slice(0, -'atlas/tests.yaml'.length).replace(/\/$/, '');
318
+ if (!areas.includes(area)) continue;
319
+ for (const p of new Set([...scenarioFilesAt(top, mergeBase, area), ...scenarioFilesAt(top, headSha, area)])) addPair(p, null);
320
+ }
321
+ const blob = (sha, path) => (git(top, ['rev-parse', '--verify', '--quiet', `${sha}:${path}`], { allowFail: true }) ?? '').trim();
322
+
323
+ for (const e of entries) {
324
+ const path = e.path;
325
+ const group = groupOf(path, areas) ?? (e.from ? groupOf(e.from, areas) : null);
326
+ if (!group) { other.push(path); continue; }
327
+ const area = areaOf(path, areas);
328
+ const before = e.from ?? path;
329
+ // A rename says whether the bytes moved as they were. Other statuses leave it null.
330
+ const moved = e.status === 'renamed' ? blob(mergeBase, before) !== blob(headSha, path) : null;
331
+ const fileRow = (status = e.status) => row(area, group, status, path, e.status === 'renamed' ? e.from : null, null, moved);
332
+ const idRow = (status, id) => row(area, group, status, path, null, id);
333
+ if (group === 'scenario-words') { addPair(path, e.from); continue; }
334
+ if (group === 'check-block') {
335
+ if (e.status === 'added' || e.status === 'removed') { rows.push(fileRow()); continue; }
336
+ if (e.status === 'renamed') rows.push(fileRow());
337
+ const b = checkBlocks(show(top, mergeBase, before), before);
338
+ const h = checkBlocks(show(top, headSha, path), path);
339
+ if (b === null || h === null) { if (e.status !== 'renamed') rows.push(fileRow('changed')); continue; }
340
+ const blockRows = compareMaps(b, h, idRow);
341
+ rows.push(...blockRows);
342
+ // Code outside the blocks (a setup, a helper inside the file) changed: the file itself is a row.
343
+ if (!blockRows.length && e.status === 'changed') rows.push(fileRow('changed'));
344
+ continue;
345
+ }
346
+ if (group === 'test-script') {
347
+ const b = testScripts(show(top, mergeBase, before));
348
+ const h = testScripts(show(top, headSha, path));
349
+ if (b === null || h === null) { rows.push(fileRow()); continue; }
350
+ const scriptRows = compareMaps(new Map(Object.entries(b)), new Map(Object.entries(h)), idRow);
351
+ if (!scriptRows.length) other.push(path);
352
+ rows.push(...scriptRows);
353
+ continue;
354
+ }
355
+ if (group === 'runner-config') {
356
+ const name = posix.basename(path);
357
+ if (name === 'pyproject.toml' || name === 'setup.cfg') {
358
+ const b = pytestSection(show(top, mergeBase, before), name);
359
+ const h = pytestSection(show(top, headSha, path), name);
360
+ if (b === h) { other.push(path); continue; }
361
+ rows.push(fileRow(!b ? 'added' : !h ? 'removed' : 'changed'));
362
+ continue;
363
+ }
364
+ }
365
+ rows.push(fileRow());
366
+ }
367
+
368
+ for (const [path, from] of scenarioPairs) {
369
+ const area = scenarioArea(path, areas) ?? scenarioArea(from, areas) ?? '';
370
+ const headText = show(top, headSha, path);
371
+ const b = scenarioPrints(show(top, mergeBase, from), from, show(top, mergeBase, testsYamlOf(area)));
372
+ const h = scenarioPrints(headText, path, show(top, headSha, testsYamlOf(area)));
373
+ if (b === null || h === null) {
374
+ rows.push(row(area, 'scenario-words', headText === null ? 'removed' : 'changed', path));
375
+ continue;
376
+ }
377
+ const scenarioId = h.id ?? b.id;
378
+ rows.push(...compareMaps(b.prints, h.prints, (status, id) => row(area, 'scenario-words', status, path, null, `${scenarioId}/${id}`)));
379
+ }
380
+
381
+ const order = (r) => [r.area, GROUPS.indexOf(r.group), r.path, r.id ?? '', STATUSES.indexOf(r.status)];
382
+ rows.sort((x, y) => {
383
+ const a = order(x);
384
+ const b = order(y);
385
+ for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
386
+ return 0;
387
+ });
388
+ const counts = Object.fromEntries(STATUSES.map((s) => [s, rows.filter((r) => r.status === s).length]));
389
+ return { changed_tests: VERSION, base, merge_base: mergeBase, head: headSha, counts, other_paths: [...new Set(other)].sort(), rows };
390
+ }
391
+
392
+ // ---------------------------------------------------------------- the command
393
+
394
+ const oneLine = (text) => String(text).replace(/[\u0000-\u001f\u007f]/g, ' ');
395
+
396
+ export function renderText(answer) {
397
+ const c = answer.counts;
398
+ 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`];
399
+ for (const r of answer.rows) {
400
+ const tail = [r.id ? oneLine(r.id) : null, r.from ? `(from ${oneLine(r.from)})` : null].filter(Boolean).join(' ');
401
+ out.push(` ${r.status.padEnd(8)} ${r.group.padEnd(14)} ${oneLine(r.path)}${tail ? ` ${tail}` : ''}`);
402
+ }
403
+ out.push(`${answer.other_paths.length} other changed path(s) match no test rule.`);
404
+ return out;
405
+ }
406
+
407
+ export function changedCommand({ base, head, out, text = false }, root, line) {
408
+ try {
409
+ const answer = listChanged({ root, base, head: head ?? 'HEAD' });
410
+ const json = `${JSON.stringify(answer, null, 2)}\n`;
411
+ if (out) writeFileSync(resolve(out), json);
412
+ if (text) for (const l of renderText(answer)) line(l);
413
+ else if (!out) process.stdout.write(json);
414
+ else line(`Wrote ${out}: ${answer.rows.length} test change(s).`);
415
+ return 0;
416
+ } catch (error) {
417
+ line(`changed: ${error.message}`);
418
+ return error.usage ? 2 : 1;
419
+ }
420
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.3.42",
3
+ "version": "0.3.43",
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` lists each test that a pull request changes, as JSON, for the gate's test checks. `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 |