@arjunkhera/atlas 0.3.41 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/door/cli.mjs +10 -4
- package/door/kit-releases.json +4 -0
- package/door/lib/changed-tests.mjs +420 -0
- package/door/lib/tests.mjs +3 -0
- package/package.json +1 -1
- package/skills/lead/SKILL.md +1 -1
- package/skills/tests/SKILL.md +25 -0
- package/tests/bridge.mjs +91 -0
- package/tests/contract.py +893 -0
- package/tests/drivers/index.mjs +3 -0
- package/tests/link-check.mjs +221 -9
- package/tests/tests-yaml.mjs +13 -4
- package/tests/verdict.mjs +8 -4
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
|
|
@@ -103,7 +108,7 @@ const HELP = `atlas — the door into a repo's Atlas files
|
|
|
103
108
|
--evidence <dir> where the evidence files go; default <area>/test/evidence
|
|
104
109
|
--scenario <id> run one scenario only
|
|
105
110
|
|
|
106
|
-
atlas tests rerun --root <area> --run <id> list the scenario test files whose first attempt did not end well
|
|
111
|
+
atlas tests rerun --root <area> --run <id> [--language python] list the scenario test files whose first attempt did not end well
|
|
107
112
|
--tests <dir> the test folder of the area; default test. The test files are in <dir>/scenarios
|
|
108
113
|
--evidence <dir> where the evidence files are; default <area>/test/evidence
|
|
109
114
|
|
|
@@ -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' },
|
|
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
|
-
if (what === 'rerun') return testsRerun({ run: chosen.run, evidence: chosen.evidence, tests: chosen.tests }, root, line);
|
|
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) {
|
package/door/kit-releases.json
CHANGED
|
@@ -44,6 +44,10 @@
|
|
|
44
44
|
{
|
|
45
45
|
"kit_sha256": "ea6dc64f5ae4b5caca925a9613c829f5738d16b21d2664434dada208027b60b6",
|
|
46
46
|
"first_package": "0.3.38"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"kit_sha256": "8d6ae3c4dede74bafe292a992de3ef650e5c5566fb282081a9de23b8d30d9025",
|
|
50
|
+
"first_package": "0.3.39"
|
|
47
51
|
}
|
|
48
52
|
]
|
|
49
53
|
}
|
|
@@ -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/door/lib/tests.mjs
CHANGED
|
@@ -31,9 +31,12 @@ export class KitRefusal extends Error {
|
|
|
31
31
|
constructor(message) { super(message); this.name = 'KitRefusal'; }
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// Python bytecode (__pycache__) is never part of the kit: a dev tree that ran a
|
|
35
|
+
// Python test may hold it, and it must not reach a repo.
|
|
34
36
|
function filesUnder(dir, prefix = '') {
|
|
35
37
|
const out = [];
|
|
36
38
|
for (const name of readdirSync(dir).sort()) {
|
|
39
|
+
if (name === '__pycache__') continue;
|
|
37
40
|
const full = join(dir, name);
|
|
38
41
|
if (statSync(full).isDirectory()) out.push(...filesUnder(full, `${prefix}${name}/`));
|
|
39
42
|
else out.push(`${prefix}${name}`);
|
package/package.json
CHANGED
package/skills/lead/SKILL.md
CHANGED
|
@@ -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 |
|
package/skills/tests/SKILL.md
CHANGED
|
@@ -358,6 +358,31 @@ not counted, and the test passes.
|
|
|
358
358
|
After the product is ready, a lost connection, a server exit or a time-out is
|
|
359
359
|
`fail`. `blocked` is for a guard refusal, a start-up fault and the first-call check.
|
|
360
360
|
|
|
361
|
+
### A test in Python
|
|
362
|
+
|
|
363
|
+
Use the Python library when the runner of the area is Python. Its header,
|
|
364
|
+
`test/atlas/contract.py`, holds a worked example.
|
|
365
|
+
|
|
366
|
+
1. Name the file `test/scenarios/<name>_test.py`. List a Python runner in
|
|
367
|
+
`runner` of `tests.yaml`. `runner` may be a list, one runner for each
|
|
368
|
+
language.
|
|
369
|
+
2. Before the import, set `sys.dont_write_bytecode = True`. Put
|
|
370
|
+
`test/atlas` on `sys.path`, and import `scenario` from `contract`.
|
|
371
|
+
Run Python with `-B`: the check refuses bytecode in the test folder.
|
|
372
|
+
Never run it with `-O`, which removes every assert.
|
|
373
|
+
3. Decorate the body: `@scenario("../../scenarios/<name>.md", adapter=adapter)`.
|
|
374
|
+
4. Write each check as a `with` block that holds an assert statement:
|
|
375
|
+
`with run.check("<scenario>/e1#<fingerprint>"):`. Use `run.gate` for a gate.
|
|
376
|
+
To keep a short proof, set `proof.value` in `with ... as proof:`.
|
|
377
|
+
5. Use `run.not_exercised` and `run.wait_for`, in snake case.
|
|
378
|
+
6. Give the driver of each way in through the adapter (`define_adapter`).
|
|
379
|
+
Build its child environment with `child_env`.
|
|
380
|
+
|
|
381
|
+
The library reads the scenario through node, so the runner needs node and
|
|
382
|
+
python3. It does not yet start an environment, give run secrets, repeat a
|
|
383
|
+
run, ask a judge or ship drivers. Each of these ends `blocked`. Write such a
|
|
384
|
+
scenario with `contract.mjs`.
|
|
385
|
+
|
|
361
386
|
### A model in the test
|
|
362
387
|
|
|
363
388
|
A model makes a test vary and costs money. Three parts of the kit handle it.
|
package/tests/bridge.mjs
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The bridge for a contract library in another language (design section 8.1).
|
|
3
|
+
// A library in Python, say, must read a scenario exactly as the link check
|
|
4
|
+
// reads it: the same parser, the same fingerprints, the same tests.yaml check
|
|
5
|
+
// and the same guard comparison. So it does not parse the words itself. It
|
|
6
|
+
// runs this file and reads one JSON object:
|
|
7
|
+
//
|
|
8
|
+
// node bridge.mjs load <scenario.md>
|
|
9
|
+
// -> { root, file, environment, evidenceDir, guardRefusal, tests, envSpec, shippedDrivers, wayGuardParts,
|
|
10
|
+
// scenario: { id, title, file, sha256, method, fields, through, ways, repeat,
|
|
11
|
+
// assertions: [{ id, fullId, kind, gate, ways, fp, names, line }],
|
|
12
|
+
// tables: { T1: { name, kind, header, rows, fingerprint, cases } } } }
|
|
13
|
+
//
|
|
14
|
+
// `cases` is every case of a combination table (every combination, or a set
|
|
15
|
+
// that holds every pair), so the other language need not make them again.
|
|
16
|
+
// A fault in the scenario or in tests.yaml prints { error } and exits 1.
|
|
17
|
+
// The environment and the folders come from the same variables as
|
|
18
|
+
// contract.mjs: ATLAS_ENV, CI, ATLAS_EVIDENCE_DIR and ATLAS_GUARDS_FROM.
|
|
19
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
20
|
+
import { dirname, join, resolve } from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { readTestsYaml, checkTestsYaml, compareGuardParts, resolveEnvironments, GUARD_PARTS } from './tests-yaml.mjs';
|
|
23
|
+
import { SHIPPED_DRIVERS } from './drivers/index.mjs';
|
|
24
|
+
import { readScenario, addFingerprints, combinations, pairs } from './scenario.mjs';
|
|
25
|
+
|
|
26
|
+
function findRoot(file) {
|
|
27
|
+
let dir = dirname(file);
|
|
28
|
+
for (;;) {
|
|
29
|
+
if (existsSync(join(dir, 'atlas', 'tests.yaml'))) return dir;
|
|
30
|
+
const up = dirname(dir);
|
|
31
|
+
if (up === dir) throw new Error(`no atlas/tests.yaml above ${file}`);
|
|
32
|
+
dir = up;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function loadForLibrary(path, env = process.env) {
|
|
37
|
+
const file = resolve(String(path));
|
|
38
|
+
const root = findRoot(file);
|
|
39
|
+
const yaml = readTestsYaml(join(root, 'atlas', 'tests.yaml'));
|
|
40
|
+
const findings = checkTestsYaml(yaml);
|
|
41
|
+
if (findings.length) throw new Error(`atlas/tests.yaml: ${findings.map((f) => `line ${f.line}: ${f.message}`).join('; ')}`);
|
|
42
|
+
const sc = readScenario(file);
|
|
43
|
+
if (sc.problems.length) throw new Error(`${path}: ${sc.problems.map((p) => `line ${p.line}: ${p.message}`).join('; ')}`);
|
|
44
|
+
const tests = yaml.value;
|
|
45
|
+
addFingerprints(sc, tests);
|
|
46
|
+
// The same rule as contract.mjs: a branch whose guard parts differ from the main branch copy is refused.
|
|
47
|
+
let guardRefusal = null;
|
|
48
|
+
if (env.ATLAS_GUARDS_FROM) {
|
|
49
|
+
try {
|
|
50
|
+
const changes = compareGuardParts(readFileSync(env.ATLAS_GUARDS_FROM, 'utf8'), yaml.value);
|
|
51
|
+
if (changes.length) guardRefusal = `the guard parts of atlas/tests.yaml differ from the main branch copy (${changes.map((c) => c.part).join(', ')}); the run is refused. A change to a guard part comes to the owner`;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
guardRefusal = `ATLAS_GUARDS_FROM names a file that cannot be read as tests.yaml (${error.code ?? error.message}); the run is refused`;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const environment = env.ATLAS_ENV ?? (env.CI ? 'ci' : 'local');
|
|
57
|
+
const evidenceDir = env.ATLAS_EVIDENCE_DIR ?? join(root, 'test', 'evidence');
|
|
58
|
+
const tables = {};
|
|
59
|
+
for (const [name, t] of sc.tables) {
|
|
60
|
+
const cases = t.kind === 'combination' ? (t.cover === 'pair' ? pairs(t.dimensions) : combinations(t.dimensions)) : null;
|
|
61
|
+
tables[name] = { name, kind: t.kind, header: t.header ?? null, rows: t.rows ?? [], fingerprint: t.fingerprint, cases };
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
root, file, environment, evidenceDir, guardRefusal, tests,
|
|
65
|
+
// What a library in another language must refuse until it has its own form of the guard.
|
|
66
|
+
shippedDrivers: SHIPPED_DRIVERS,
|
|
67
|
+
wayGuardParts: GUARD_PARTS.filter((p) => p.startsWith('ways-in.*.')).map((p) => p.slice('ways-in.*.'.length)),
|
|
68
|
+
envSpec: resolveEnvironments(tests.environments ?? {})[environment] ?? null,
|
|
69
|
+
scenario: {
|
|
70
|
+
id: sc.id, title: sc.title, file: sc.file, sha256: sc.sha256, method: sc.method,
|
|
71
|
+
names: [...new Set([...sc.start, ...sc.steps, ...sc.assertions].flatMap((x) => x.names ?? []))],
|
|
72
|
+
fields: sc.fields, through: sc.through, ways: sc.ways ?? sc.through, repeat: sc.repeat ?? null,
|
|
73
|
+
assertions: sc.assertions.map((a) => ({ id: a.id, fullId: a.fullId, kind: a.kind, gate: Boolean(a.gate), ways: a.ways ?? sc.through, fp: a.fp, names: a.names ?? [], line: a.line })),
|
|
74
|
+
tables,
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function main(argv, write = (text) => process.stdout.write(text)) {
|
|
80
|
+
const [verb, path] = argv;
|
|
81
|
+
if (verb !== 'load' || !path) { process.stderr.write('bridge: use "bridge.mjs load <scenario.md>"\n'); return 2; }
|
|
82
|
+
try {
|
|
83
|
+
write(`${JSON.stringify(loadForLibrary(path))}\n`);
|
|
84
|
+
return 0;
|
|
85
|
+
} catch (error) {
|
|
86
|
+
write(`${JSON.stringify({ error: error.message })}\n`);
|
|
87
|
+
return 1;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) process.exitCode = main(process.argv.slice(2));
|