@arjunkhera/atlas 0.3.48 → 0.3.50
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 +36 -3
- package/door/lib/fails-before.mjs +65 -9
- package/door/lib/passes-after.mjs +212 -0
- package/door/lib/status.mjs +223 -4
- package/package.json +1 -1
- package/shape/releases.json +11 -6
- package/skills/lead/SKILL.md +2 -2
package/door/cli.mjs
CHANGED
|
@@ -17,7 +17,7 @@ import { readFileSync, existsSync, realpathSync } from 'node:fs';
|
|
|
17
17
|
import { spawnSync } from 'node:child_process';
|
|
18
18
|
import { open } from './lib/pull-request.mjs';
|
|
19
19
|
import { write as writeTooling, changes as toolingChanges, ToolingRefusal, CHECK_PATH, WORKFLOW_PATH } from './lib/tooling.mjs';
|
|
20
|
-
import {
|
|
20
|
+
import { statusMany, copies } from './lib/status.mjs';
|
|
21
21
|
import { drift, kindsFromRegistry, fileKind } from './lib/kind-drift.mjs';
|
|
22
22
|
import { scan, toText, verdict, CHECK_VERSION, SPECS } from '../shape/check.mjs';
|
|
23
23
|
import { packagePackageVersion } from './lib/releases.mjs';
|
|
@@ -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';
|
|
@@ -49,6 +50,8 @@ const HELP = `atlas — the door into a repo's Atlas files
|
|
|
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
|
|
51
52
|
atlas status --root <repo> is the repo's check current, behind, unknown or missing
|
|
53
|
+
--all every Atlas copy in the repo: the check, its workflow, each test kit, each pinned Atlas version
|
|
54
|
+
--json print the rows as JSON: type, path, state, have, want, why
|
|
52
55
|
atlas kind-drift --root <repo> --record-kind <kinds>
|
|
53
56
|
does atlas.yaml agree with the repo record
|
|
54
57
|
atlas check --root <repo> run the repo shape check here
|
|
@@ -95,6 +98,11 @@ const HELP = `atlas — the door into a repo's Atlas files
|
|
|
95
98
|
--old <file> --new <file> read the two run files and give the answer; runs nothing
|
|
96
99
|
--out <file> write the JSON here; default the terminal
|
|
97
100
|
--text print a short table instead of the JSON
|
|
101
|
+
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
|
|
102
|
+
--head <ref> the head of the pull request; default HEAD
|
|
103
|
+
--list <file> a changed-test list from atlas tests changed; default the command makes it
|
|
104
|
+
--out <file> write the JSON here; default the terminal
|
|
105
|
+
--text print a short table instead of the JSON
|
|
98
106
|
atlas tests named --root <area> list the assertions that a pull request names
|
|
99
107
|
--base <ref> the base: an assertion is named when its words changed or it is new
|
|
100
108
|
--covers also name the assertions of a scenario whose covers path the change touches
|
|
@@ -161,7 +169,7 @@ A person merges every file the tooling writer writes.
|
|
|
161
169
|
// skipped the privacy filter and printed GREEN (review of PR #107).
|
|
162
170
|
export const FLAGS = Object.freeze({
|
|
163
171
|
tooling: { root: 'optional', branch: 'optional', 'replace-unknown': 'switch', workflow: 'switch', 'dry-run': 'switch' },
|
|
164
|
-
status: { root: 'optional' },
|
|
172
|
+
status: { root: 'optional', all: 'switch', json: 'switch' },
|
|
165
173
|
'kind-drift': { root: 'optional', 'record-kind': 'value', registry: 'value', repo: 'value' },
|
|
166
174
|
check: { root: 'optional', repo: 'value', 'fail-on': 'value' },
|
|
167
175
|
ste: { share: 'switch', terms: 'value' },
|
|
@@ -244,6 +252,7 @@ function toolingCommand(chosen) {
|
|
|
244
252
|
|
|
245
253
|
function statusCommand(chosen) {
|
|
246
254
|
const roots = chosen._.slice(1).length ? chosen._.slice(1).map((one) => resolve(one)) : [rootOf(chosen)];
|
|
255
|
+
if (chosen.all || chosen.json) return copiesCommand(roots, { all: Boolean(chosen.all), json: Boolean(chosen.json) });
|
|
247
256
|
const rows = statusMany(roots, PACKAGE_ROOT);
|
|
248
257
|
for (const row of rows) {
|
|
249
258
|
line(`${row.state.toUpperCase().padEnd(8)} ${row.root}`);
|
|
@@ -256,6 +265,29 @@ function statusCommand(chosen) {
|
|
|
256
265
|
return rows.some((row) => row.state === 'behind') ? 1 : 0;
|
|
257
266
|
}
|
|
258
267
|
|
|
268
|
+
// One row for each Atlas copy (roadmap item R19). Exit 1 when a copy is
|
|
269
|
+
// behind or broken, so a watcher can act on the exit alone.
|
|
270
|
+
function copiesCommand(roots, { all, json }) {
|
|
271
|
+
const repos = roots.map((root) => copies({ root, packageRoot: PACKAGE_ROOT, all }));
|
|
272
|
+
const decided = `Decided by the release lists in Atlas ${ATLAS_VERSION}, never by git history or the network.`;
|
|
273
|
+
if (json) {
|
|
274
|
+
line(JSON.stringify({ atlas: ATLAS_VERSION, decided_by: decided, repos }, null, 2));
|
|
275
|
+
} else {
|
|
276
|
+
for (const repo of repos) {
|
|
277
|
+
line(repo.root);
|
|
278
|
+
for (const one of repo.rows) {
|
|
279
|
+
line(` ${one.state.toUpperCase().padEnd(8)} ${one.type.padEnd(8)} ${one.path}`);
|
|
280
|
+
line(` have ${one.have ?? '—'} · want ${one.want}`);
|
|
281
|
+
if (one.state !== 'current' || one.have !== one.want) line(` ${one.why}`);
|
|
282
|
+
}
|
|
283
|
+
line('');
|
|
284
|
+
}
|
|
285
|
+
line(decided);
|
|
286
|
+
}
|
|
287
|
+
const bad = all ? ['behind', 'broken'] : ['behind'];
|
|
288
|
+
return repos.some((repo) => repo.rows.some((one) => bad.includes(one.state))) ? 1 : 0;
|
|
289
|
+
}
|
|
290
|
+
|
|
259
291
|
function kindDriftCommand(chosen) {
|
|
260
292
|
const root = rootOf(chosen);
|
|
261
293
|
let recordKind = chosen['record-kind'] ?? null;
|
|
@@ -366,6 +398,7 @@ async function testsCommand(chosen) {
|
|
|
366
398
|
if (what === 'proof') return testsProof({ run: chosen.run, own: chosen.own, evidence: chosen.evidence, base: chosen.base, covers: Boolean(chosen.covers) }, root, line);
|
|
367
399
|
if (what === 'verdict') return testsVerdict({ run: chosen.run, base: chosen.base, evidence: chosen.evidence, covers: Boolean(chosen.covers) }, root, line);
|
|
368
400
|
if (what === 'changed') return testsChanged({ base: chosen.base, head: chosen.head, out: chosen.out, text: Boolean(chosen.text) }, root, line);
|
|
401
|
+
if (what === 'after') return testsAfter({ base: chosen.base, head: chosen.head, list: chosen.list, out: chosen.out, text: Boolean(chosen.text) }, root, line);
|
|
369
402
|
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
403
|
if (what === 'named') return testsNamed({ base: chosen.base, covers: Boolean(chosen.covers) }, root, line);
|
|
371
404
|
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 +406,7 @@ async function testsCommand(chosen) {
|
|
|
373
406
|
if (what === 'kept') return (await runKept({ root, evidenceDir: chosen.evidence, only: chosen.scenario ?? null, write: (text) => process.stdout.write(text) })).exit;
|
|
374
407
|
if (what === 'rerun') return testsRerun({ run: chosen.run, evidence: chosen.evidence, tests: chosen.tests, language: chosen.language ?? 'javascript' }, root, line);
|
|
375
408
|
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.`);
|
|
409
|
+
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
410
|
}
|
|
378
411
|
|
|
379
412
|
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/door/lib/status.mjs
CHANGED
|
@@ -13,10 +13,12 @@
|
|
|
13
13
|
//
|
|
14
14
|
// CI never makes this judgement. A check with no network cannot learn the
|
|
15
15
|
// package's list. This runs in a session, with the package on disk.
|
|
16
|
-
import { readFileSync, existsSync } from 'node:fs';
|
|
17
|
-
import { join } from 'node:path';
|
|
18
|
-
import { CHECK_PATH, checkStamps, sha256 } from '../../shape/check.mjs';
|
|
19
|
-
import { packagedCheck, placeOf } from './releases.mjs';
|
|
16
|
+
import { readFileSync, existsSync, readdirSync, lstatSync } from 'node:fs';
|
|
17
|
+
import { join, relative, sep } from 'node:path';
|
|
18
|
+
import { CHECK_PATH, WORKFLOW_PATH, WORKFLOW_TEMPLATE, checkStamps, sha256 } from '../../shape/check.mjs';
|
|
19
|
+
import { packagedCheck, placeOf, packagePackageVersion } from './releases.mjs';
|
|
20
|
+
import { currentKitHash, KIT_RELEASES_FILE, KIT_TARGET, MANIFEST } from './tests.mjs';
|
|
21
|
+
import { kitHashOf } from '../../tests/link-check.mjs';
|
|
20
22
|
|
|
21
23
|
export const STATES = Object.freeze(['current', 'behind', 'unknown', 'missing']);
|
|
22
24
|
|
|
@@ -45,3 +47,220 @@ export function status({ root, packageRoot }) {
|
|
|
45
47
|
export function statusMany(roots, packageRoot) {
|
|
46
48
|
return roots.map((root) => { try { return status({ root, packageRoot }); } catch (error) { return { root, state: 'unknown', why: error.message, packaged: null, copy: null }; } });
|
|
47
49
|
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Every Atlas copy in a repository, and the age of each one (roadmap item
|
|
53
|
+
// R19, slice 0 of the routines design). The check is one copy of four types:
|
|
54
|
+
//
|
|
55
|
+
// check .github/atlas/check.mjs, by the release list of the check
|
|
56
|
+
// workflow .github/workflows/atlas-check.yml, by the workflow fingerprint
|
|
57
|
+
// in each row of that list
|
|
58
|
+
// kit each <area>/test/atlas/, by its manifest and the kit list
|
|
59
|
+
// pin an Atlas version written in a workflow, a script or package.json
|
|
60
|
+
//
|
|
61
|
+
// Each row has have, want and state. A copy that cannot be read is broken,
|
|
62
|
+
// never missing. Like the check alone, every answer comes from the lists this
|
|
63
|
+
// package carries, never from git history or the network.
|
|
64
|
+
export const COPY_STATES = Object.freeze(['current', 'behind', 'unknown', 'missing', 'broken']);
|
|
65
|
+
export const COPY_TYPES = Object.freeze(['check', 'workflow', 'kit', 'pin']);
|
|
66
|
+
|
|
67
|
+
const posix = (path) => path.split(sep).join('/');
|
|
68
|
+
const SKIP = new Set(['node_modules', '.git']);
|
|
69
|
+
const MAX_DEPTH = 8;
|
|
70
|
+
const MAX_PIN_FILE = 512 * 1024;
|
|
71
|
+
|
|
72
|
+
const row = (type, path, state, have, want, why) => ({ type, path, state, have, want, why });
|
|
73
|
+
const broken = (type, path, want, error) => row(type, path, 'broken', null, want, `it cannot be read: ${error.message}`);
|
|
74
|
+
// A link is never read. A link at a copy's path, even one that points
|
|
75
|
+
// nowhere, is a copy that cannot be read: broken, never missing.
|
|
76
|
+
const linkAt = (full) => { try { return lstatSync(full).isSymbolicLink(); } catch { return false; } };
|
|
77
|
+
const linked = (type, path, want) => row(type, path, 'broken', null, want, `${path} is a link. Atlas never reads a copy through a link. Replace it with a plain file or folder.`);
|
|
78
|
+
|
|
79
|
+
function checkRow(root, packageRoot) {
|
|
80
|
+
const pkg = packagedCheck(packageRoot);
|
|
81
|
+
const want = `check version ${checkStamps(pkg.text).check_version}`;
|
|
82
|
+
if (linkAt(join(root, CHECK_PATH))) return linked('check', CHECK_PATH, want);
|
|
83
|
+
let answer;
|
|
84
|
+
try { answer = status({ root, packageRoot }); } catch (error) { return broken('check', CHECK_PATH, want, error); }
|
|
85
|
+
const have = answer.copy ? (answer.state === 'unknown' ? `sha256 ${answer.copy.sha256.slice(0, 12)}…` : `check version ${answer.copy.check_version}`) : null;
|
|
86
|
+
return row('check', CHECK_PATH, answer.state, have, want, answer.why);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The workflow is written with the check, from the WORKFLOW_TEMPLATE of that
|
|
90
|
+
// check. Each row of the release list keeps the fingerprint of its template.
|
|
91
|
+
function workflowRow(root, packageRoot) {
|
|
92
|
+
const pkg = packagedCheck(packageRoot);
|
|
93
|
+
const version = checkStamps(pkg.text).check_version;
|
|
94
|
+
const want = `workflow of check version ${version}`;
|
|
95
|
+
const full = join(root, WORKFLOW_PATH);
|
|
96
|
+
if (linkAt(full)) return linked('workflow', WORKFLOW_PATH, want);
|
|
97
|
+
if (!existsSync(full)) return row('workflow', WORKFLOW_PATH, 'missing', null, want, `${WORKFLOW_PATH} does not exist in this repo, so its check never runs in CI. "atlas tooling pr" adds it.`);
|
|
98
|
+
let text;
|
|
99
|
+
try { text = readFileSync(full, 'utf8'); } catch (error) { return broken('workflow', WORKFLOW_PATH, want, error); }
|
|
100
|
+
const fingerprint = sha256(text);
|
|
101
|
+
if (fingerprint === sha256(WORKFLOW_TEMPLATE)) return row('workflow', WORKFLOW_PATH, 'current', want, want, 'the workflow is the one this package writes.');
|
|
102
|
+
const rows = pkg.releases.filter((one) => one.workflow_sha256 === fingerprint);
|
|
103
|
+
if (rows.length) {
|
|
104
|
+
const newest = rows.at(-1);
|
|
105
|
+
return row('workflow', WORKFLOW_PATH, 'behind', `workflow of check version ${newest.check_version}`, want, `the workflow was first written by Atlas ${rows[0].first_package}. "atlas tooling pr --workflow" refreshes it.`);
|
|
106
|
+
}
|
|
107
|
+
return row('workflow', WORKFLOW_PATH, 'unknown', `sha256 ${fingerprint.slice(0, 12)}…`, want, `the workflow is in no release. Someone edited it, or it came from an unreleased package. "atlas tooling pr" shows the difference.`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// A walk never follows a link and never enters node_modules or .git. The
|
|
111
|
+
// visit sees a link as a link, and says which other folders it enters: it
|
|
112
|
+
// returns false to stop.
|
|
113
|
+
function walk(dir, visit, depth = 0) {
|
|
114
|
+
if (depth > MAX_DEPTH) return;
|
|
115
|
+
let names;
|
|
116
|
+
try { names = readdirSync(dir).sort(); } catch { return; }
|
|
117
|
+
for (const name of names) {
|
|
118
|
+
if (SKIP.has(name)) continue;
|
|
119
|
+
const full = join(dir, name);
|
|
120
|
+
let stat;
|
|
121
|
+
try { stat = lstatSync(full); } catch { continue; }
|
|
122
|
+
if (visit(full, name, stat) === false) continue;
|
|
123
|
+
if (stat.isDirectory()) walk(full, visit, depth + 1);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Each <area>/test/atlas/ folder, where "atlas tests write" puts the kit. A
|
|
128
|
+
// dot folder holds no area: the check's base tree is one.
|
|
129
|
+
function kitFolders(root) {
|
|
130
|
+
const found = [];
|
|
131
|
+
const [parent, leaf] = KIT_TARGET.split('/');
|
|
132
|
+
walk(root, (full, name, stat) => {
|
|
133
|
+
const isKit = name === leaf && full.split(sep).at(-2) === parent;
|
|
134
|
+
if (isKit && stat.isSymbolicLink()) { found.push(full); return false; }
|
|
135
|
+
if (!stat.isDirectory() || name.startsWith('.')) return false;
|
|
136
|
+
if (isKit) { found.push(full); return false; }
|
|
137
|
+
return true;
|
|
138
|
+
});
|
|
139
|
+
return found;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function kitFilesOnDisk(dir, prefix = '') {
|
|
143
|
+
const out = [];
|
|
144
|
+
for (const name of readdirSync(dir).sort()) {
|
|
145
|
+
if (name === '__pycache__') continue;
|
|
146
|
+
const full = join(dir, name);
|
|
147
|
+
const stat = lstatSync(full);
|
|
148
|
+
if (stat.isDirectory()) out.push(...kitFilesOnDisk(full, `${prefix}${name}/`));
|
|
149
|
+
else out.push({ path: `${prefix}${name}`, full, link: stat.isSymbolicLink() });
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function kitRow(root, dir, ctx) {
|
|
155
|
+
const path = `${posix(relative(root, dir))}/`;
|
|
156
|
+
const area = posix(relative(root, join(dir, '..', '..'))) || '.';
|
|
157
|
+
const want = `kit of Atlas ${ctx.version}`;
|
|
158
|
+
const refresh = `"atlas tests write --root ${area}" refreshes it.`;
|
|
159
|
+
if (linkAt(dir)) return linked('kit', path, want);
|
|
160
|
+
try {
|
|
161
|
+
const manifestPath = join(dir, MANIFEST);
|
|
162
|
+
if (!existsSync(manifestPath)) return row('kit', path, 'broken', null, want, `the kit has no ${MANIFEST}, so its age cannot be read. ${refresh}`);
|
|
163
|
+
let manifest;
|
|
164
|
+
try { manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); } catch { return row('kit', path, 'broken', null, want, `${MANIFEST} is not JSON, so the kit's age cannot be read. ${refresh}`); }
|
|
165
|
+
const listed = manifest?.files;
|
|
166
|
+
if (!listed || typeof listed !== 'object' || Array.isArray(listed)) return row('kit', path, 'broken', null, want, `${MANIFEST} lists no files, so the kit's age cannot be read. ${refresh}`);
|
|
167
|
+
const have = `kit of Atlas ${typeof manifest.atlas === 'string' ? manifest.atlas : '?'}`;
|
|
168
|
+
const onDisk = kitFilesOnDisk(dir).filter((one) => one.path !== MANIFEST);
|
|
169
|
+
const edited = [];
|
|
170
|
+
for (const one of onDisk) {
|
|
171
|
+
if (one.link || !Object.hasOwn(listed, one.path) || sha256(readFileSync(one.full)) !== listed[one.path]) edited.push(one.path);
|
|
172
|
+
}
|
|
173
|
+
const present = new Set(onDisk.map((one) => one.path));
|
|
174
|
+
for (const listedPath of Object.keys(listed)) if (!present.has(listedPath)) edited.push(listedPath);
|
|
175
|
+
if (edited.length) return row('kit', path, 'unknown', have, want, `${edited.length} file(s) differ from ${MANIFEST}: ${edited.slice(0, 3).join(', ')}. Someone edited the kit. ${refresh}`);
|
|
176
|
+
const hash = kitHashOf(listed);
|
|
177
|
+
if (hash === ctx.kitHash) return row('kit', path, 'current', have, want, `the kit is the one this package ships${have === want ? '' : '; only its version label is older'}.`);
|
|
178
|
+
const first = ctx.kitReleases.get(hash);
|
|
179
|
+
if (first) return row('kit', path, 'behind', have, want, `the kit was first shipped in Atlas ${first}. ${refresh}`);
|
|
180
|
+
return row('kit', path, 'unknown', have, want, `the kit is in no release. It came from an unreleased package, or someone edited its ${MANIFEST}. ${refresh}`);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
return broken('kit', path, want, error);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// A version number compared part by part: -1, 0 or 1.
|
|
187
|
+
function compare(a, b) {
|
|
188
|
+
const [x, y] = [a, b].map((one) => one.split('.').map(Number));
|
|
189
|
+
for (let index = 0; index < 3; index += 1) if (x[index] !== y[index]) return x[index] < y[index] ? -1 : 1;
|
|
190
|
+
return 0;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const escape = (text) => text.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
|
194
|
+
|
|
195
|
+
// The places a repo writes an Atlas version: the workflows and actions under
|
|
196
|
+
// .github/, the scripts under scripts/, and package.json. A tag such as
|
|
197
|
+
// "latest", or a version held in a variable, is no pin.
|
|
198
|
+
function pinFiles(root) {
|
|
199
|
+
const files = [];
|
|
200
|
+
for (const top of ['.github', 'scripts']) {
|
|
201
|
+
const dir = join(root, top);
|
|
202
|
+
if (!existsSync(dir)) continue;
|
|
203
|
+
walk(dir, (full, name, stat) => {
|
|
204
|
+
if (stat.isFile() && stat.size <= MAX_PIN_FILE && posix(relative(root, full)) !== CHECK_PATH) files.push(full);
|
|
205
|
+
return stat.isDirectory();
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
const manifest = join(root, 'package.json');
|
|
209
|
+
if (existsSync(manifest)) files.push(manifest);
|
|
210
|
+
return files;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function pinRows(root, ctx) {
|
|
214
|
+
const name = escape(ctx.name);
|
|
215
|
+
const patterns = [
|
|
216
|
+
new RegExp(`${name}@(\\d+\\.\\d+\\.\\d+)\\b`, 'g'),
|
|
217
|
+
new RegExp(`"${name}"\\s*:\\s*"[~^=]?(\\d+\\.\\d+\\.\\d+)"`, 'g'),
|
|
218
|
+
/\bATLAS_VERSION\s*[:=]\s*["']?(\d+\.\d+\.\d+)\b/g,
|
|
219
|
+
];
|
|
220
|
+
const rows = [];
|
|
221
|
+
for (const full of pinFiles(root)) {
|
|
222
|
+
const file = posix(relative(root, full));
|
|
223
|
+
let text;
|
|
224
|
+
try { text = readFileSync(full, 'utf8'); } catch (error) { rows.push(broken('pin', file, ctx.version, error)); continue; }
|
|
225
|
+
text.split('\n').forEach((line, index) => {
|
|
226
|
+
const seen = new Set();
|
|
227
|
+
for (const pattern of patterns) {
|
|
228
|
+
for (const match of line.matchAll(pattern)) {
|
|
229
|
+
if (seen.has(match[1])) continue;
|
|
230
|
+
seen.add(match[1]);
|
|
231
|
+
const at = `${file}:${index + 1}`;
|
|
232
|
+
const order = compare(match[1], ctx.version);
|
|
233
|
+
if (order === 0) rows.push(row('pin', at, 'current', match[1], ctx.version, 'the pin names the version of this package.'));
|
|
234
|
+
else if (order < 0) rows.push(row('pin', at, 'behind', match[1], ctx.version, `the pin names Atlas ${match[1]}, and this package is ${ctx.version}. Move the pin in this line.`));
|
|
235
|
+
else rows.push(row('pin', at, 'unknown', match[1], ctx.version, `the pin names Atlas ${match[1]}, which is newer than this package. Upgrade Atlas to judge it.`));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
return rows;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function kitReleases(packageRoot) {
|
|
244
|
+
const file = join(packageRoot, KIT_RELEASES_FILE);
|
|
245
|
+
if (!existsSync(file)) return new Map();
|
|
246
|
+
return new Map((JSON.parse(readFileSync(file, 'utf8')).releases ?? []).map((one) => [String(one.kit_sha256), String(one.first_package)]));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// One row for each Atlas copy in the repository. With all false, only the
|
|
250
|
+
// check, which is what "atlas status" answered before --all.
|
|
251
|
+
export function copies({ root, packageRoot, all = true }) {
|
|
252
|
+
const version = packagePackageVersion(packageRoot);
|
|
253
|
+
const rows = [checkRow(root, packageRoot)];
|
|
254
|
+
if (all) {
|
|
255
|
+
const ctx = {
|
|
256
|
+
version,
|
|
257
|
+
name: JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')).name,
|
|
258
|
+
kitHash: currentKitHash(packageRoot),
|
|
259
|
+
kitReleases: kitReleases(packageRoot),
|
|
260
|
+
};
|
|
261
|
+
rows.push(workflowRow(root, packageRoot));
|
|
262
|
+
for (const dir of kitFolders(root)) rows.push(kitRow(root, dir, ctx));
|
|
263
|
+
rows.push(...pinRows(root, ctx));
|
|
264
|
+
}
|
|
265
|
+
return { root, atlas: version, rows };
|
|
266
|
+
}
|
package/package.json
CHANGED
package/shape/releases.json
CHANGED
|
@@ -1,30 +1,35 @@
|
|
|
1
1
|
{
|
|
2
|
-
"note": "Every copy of shape/check.mjs a release of Atlas has shipped. The last row is the check this package ships. atlas status reads this list; git history is never read.",
|
|
2
|
+
"note": "Every copy of shape/check.mjs a release of Atlas has shipped. The last row is the check this package ships. workflow_sha256 is the fingerprint of the workflow that check writes (its WORKFLOW_TEMPLATE). atlas status reads this list; git history is never read.",
|
|
3
3
|
"releases": [
|
|
4
4
|
{
|
|
5
5
|
"check_version": 1,
|
|
6
6
|
"sha256": "3dfd659a7533c51dce11bd0e2d7a9312f041c5955d45ac3cc67d3e29d328b5d2",
|
|
7
|
-
"first_package": "0.1.1"
|
|
7
|
+
"first_package": "0.1.1",
|
|
8
|
+
"workflow_sha256": "a463b58c30d5d7b1de27b5c7c145dd86d6ea58356ab83d9e6ddc62705902299e"
|
|
8
9
|
},
|
|
9
10
|
{
|
|
10
11
|
"check_version": 2,
|
|
11
12
|
"sha256": "a4bc5ab8c75be8f216d460df9233670f5ba4ed9d853236f6b55a7f0d10dfe293",
|
|
12
|
-
"first_package": "0.2.0"
|
|
13
|
+
"first_package": "0.2.0",
|
|
14
|
+
"workflow_sha256": "b333b57c016791e2b77f983f6fe70c9aaf50f5f6fca28b7b9ca84c222dd7b7e1"
|
|
13
15
|
},
|
|
14
16
|
{
|
|
15
17
|
"check_version": 3,
|
|
16
18
|
"sha256": "0184db66fd5d5061c21ee7d2d88395b5bac64ab1308e02e06781e6015f3d7608",
|
|
17
|
-
"first_package": "0.3.0"
|
|
19
|
+
"first_package": "0.3.0",
|
|
20
|
+
"workflow_sha256": "b69efaac2a3e52368ae5e7d7a33e1c88c6c7a7efa6fc1846844f79f9432ecd7d"
|
|
18
21
|
},
|
|
19
22
|
{
|
|
20
23
|
"check_version": 4,
|
|
21
24
|
"sha256": "3357ea247694634c3535998368683eceffaf863221dee5fec9e43a03042afb95",
|
|
22
|
-
"first_package": "0.3.36"
|
|
25
|
+
"first_package": "0.3.36",
|
|
26
|
+
"workflow_sha256": "b69efaac2a3e52368ae5e7d7a33e1c88c6c7a7efa6fc1846844f79f9432ecd7d"
|
|
23
27
|
},
|
|
24
28
|
{
|
|
25
29
|
"check_version": 5,
|
|
26
30
|
"sha256": "8f36633fe693ebf7a7cc0f7237d49eb4720f67d371d4758d556bab51965ec334",
|
|
27
|
-
"first_package": "0.3.48"
|
|
31
|
+
"first_package": "0.3.48",
|
|
32
|
+
"workflow_sha256": "b69efaac2a3e52368ae5e7d7a33e1c88c6c7a7efa6fc1846844f79f9432ecd7d"
|
|
28
33
|
}
|
|
29
34
|
]
|
|
30
35
|
}
|
package/skills/lead/SKILL.md
CHANGED
|
@@ -96,12 +96,12 @@ set. A test keeps the two lists equal.
|
|
|
96
96
|
| `atlas upgrade` | When the owner asks to upgrade Atlas. Call the `atlas_upgrade` tool instead: the Bash sandbox blocks this command. Run the command by hand only where that tool is missing. In a cloud session, call the tool with `check: true` at the first reply, as section 7 says |
|
|
97
97
|
| `atlas doctor` | When Atlas seems missing or broken in a session |
|
|
98
98
|
| `atlas check` | Before you ask for a merge in a repo that has the shape check |
|
|
99
|
-
| `atlas status` | To learn whether a repo's check is current or behind |
|
|
99
|
+
| `atlas status` | To learn whether a repo's check is current or behind. `atlas status --all --json <repo>` lists each Atlas copy in a repo, with have, want and state |
|
|
100
100
|
| `atlas tooling` | When `atlas status` says behind; `pr` opens the pull request |
|
|
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 |
|