@arjunkhera/atlas 0.3.46 → 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.46",
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';
@@ -44,7 +45,7 @@ const ATLAS_VERSION = packagePackageVersion(PACKAGE_ROOT);
44
45
 
45
46
  const HELP = `atlas — the door into a repo's Atlas files
46
47
 
47
- atlas tooling write --root <repo> write both Atlas files into a repo
48
+ atlas tooling write --root <repo> write the Atlas files into a repo
48
49
  atlas tooling pr --root <repo> the same, as one pull request
49
50
  --replace-unknown also replace a copy that is in no release
50
51
  --workflow rewrite only the workflow, for a repo whose check is current
@@ -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
@@ -232,7 +238,7 @@ function toolingCommand(chosen) {
232
238
  for (const row of result.rows) line(` ${row.state.padEnd(10)} ${row.path} sha256 ${row.sha256.slice(0, 12)}…`);
233
239
  line('');
234
240
  line(result.changed
235
- ? 'Both files come from the package. No hand step, and no edit by hand afterwards.'
241
+ ? 'The files come from the package. No hand step, and no edit by hand afterwards.'
236
242
  : 'Nothing changed. Both files already match the package.');
237
243
  return 0;
238
244
  } catch (error) {
@@ -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) {