@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.
- package/.claude-plugin/plugin.json +1 -1
- package/door/cli.mjs +10 -3
- package/door/lib/fails-before.mjs +65 -9
- package/door/lib/owner-only.mjs +778 -0
- package/door/lib/passes-after.mjs +212 -0
- package/door/lib/pull-request.mjs +3 -2
- package/door/lib/tooling.mjs +34 -10
- package/door/lib/upkeep.mjs +47 -6
- package/door/templates/pull_request_template.md +25 -0
- package/owner-only.yaml +238 -0
- package/package.json +3 -2
- package/shape/check.mjs +44 -3
- package/shape/releases.json +5 -0
- 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';
|
|
@@ -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
|
|
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
|
-
? '
|
|
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
|
-
//
|
|
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) {
|