@arjunkhera/atlas 0.3.6 → 0.3.8

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.
@@ -0,0 +1,576 @@
1
+ #!/usr/bin/env node
2
+ // The link check (design section 8.2). Exact code, with no model. It runs in
3
+ // CI on every pull request, from the main branch, so a pull request cannot
4
+ // change its own judge. It has three halves.
5
+ //
6
+ // static reads text: every scenario, atlas/tests.yaml, the tests and the
7
+ // support code. It fails on the nine cases below.
8
+ // lint the JavaScript half. It fails on a skip, an "only", a todo, a
9
+ // retry, a soft check, or a test with no assert statement.
10
+ // run reads the evidence files of ONE run (--run <id>). With no --run,
11
+ // it reads the newest run of each scenario, one run per scenario. It fails on an assertion
12
+ // that the run did not check, on evidence of a changed scenario file
13
+ // (source_sha256), on a run that used an old case table, and, in the
14
+ // CI environment, on a scenario that lists ci in runs-in and has no
15
+ // way in with pass, fail or not exercised.
16
+ //
17
+ // Besides the nine cases, the static half checks the kit folder
18
+ // <area>/test/atlas/ against its manifest.json (a changed or extra file is a
19
+ // finding), and against the kit hashes that a release of Atlas shipped.
20
+ //
21
+ // The nine static cases, with their codes:
22
+ // 1. no-test an assertion has no test that names its full id
23
+ // 2. unknown-assertion a test names an assertion that does not exist
24
+ // 3. old-fingerprint a test names an old fingerprint
25
+ // 4. unknown-name a name in backticks is not known
26
+ // 5. wait-no-limit a wait has no time limit
27
+ // 6. when-no-step a "when" assertion names no step
28
+ // 7. tests-yaml-* tests.yaml has an unknown field, or holds a secret value
29
+ // 8. persona-shared-secret two personas share a secret and do not say same-identity
30
+ // 9. table-over-budget a case table has more cases than the budget
31
+ //
32
+ // Usage:
33
+ // node link-check.mjs --root <area> [--halves static,lint,run] [--evidence <dir>]
34
+ // [--tests <dir>] [--guards-from <tests.yaml of the main branch>]
35
+ // [--run <run id>] [--kit-releases <kit-releases.json>]
36
+ // ATLAS_GUARDS_FROM sets --guards-from when the flag is not given.
37
+ // One line for each finding: <file>:<line> <code> <message>. Exit 0 when
38
+ // there is none, 1 when there is one, 2 for a bad call.
39
+ import { createHash } from 'node:crypto';
40
+ import { existsSync, lstatSync, readdirSync, readFileSync, statSync } from 'node:fs';
41
+ import { dirname, join, relative, resolve } from 'node:path';
42
+ import { fileURLToPath } from 'node:url';
43
+ import { parseYaml, YamlError } from './yaml.mjs';
44
+ import { checkTestsYaml, compareGuardParts, resolveEnvironments } from './tests-yaml.mjs';
45
+ import { parseScenario, addFingerprints } from './scenario.mjs';
46
+
47
+ const FULL_ID = /^([a-z0-9][a-z0-9-]*)\/(e\d+)#([0-9a-f]{6})$/;
48
+ // Folders skipped by name, at any depth. The kit folder and the evidence folder
49
+ // are skipped by exact path only (<area>/test/atlas, <area>/test/evidence): a
50
+ // folder that merely carries the name "atlas" or "evidence" is read like any other.
51
+ const SKIP_DIRS = new Set(['node_modules', '.git']);
52
+
53
+ // ---------------------------------------------------------------- files
54
+
55
+ function walk(dir, { skip = SKIP_DIRS, skipPaths = [], accept = () => true } = {}) {
56
+ if (!existsSync(dir)) return [];
57
+ const out = [];
58
+ for (const name of readdirSync(dir).sort()) {
59
+ const full = join(dir, name);
60
+ const stat = statSync(full);
61
+ if (stat.isDirectory()) { if (!skip.has(name) && !skipPaths.includes(full)) out.push(...walk(full, { skip, skipPaths, accept })); }
62
+ else if (accept(full)) out.push(full);
63
+ }
64
+ return out;
65
+ }
66
+
67
+ const lineAt = (text, index) => text.slice(0, index).split('\n').length;
68
+
69
+ // Replaces comments, and the insides of strings, with spaces. Line breaks and
70
+ // positions stay, so a match maps back to its line.
71
+ export function stripJs(code) {
72
+ let out = '';
73
+ let i = 0;
74
+ const blank = (s) => s.replace(/[^\n]/g, ' ');
75
+ while (i < code.length) {
76
+ const c = code[i];
77
+ const n = code[i + 1];
78
+ if (c === '/' && n === '/') { const end = code.indexOf('\n', i); const stop = end < 0 ? code.length : end; out += blank(code.slice(i, stop)); i = stop; continue; }
79
+ if (c === '/' && n === '*') { const end = code.indexOf('*/', i + 2); const stop = end < 0 ? code.length : end + 2; out += blank(code.slice(i, stop)); i = stop; continue; }
80
+ if (c === '"' || c === "'" || c === '`') {
81
+ let j = i + 1;
82
+ while (j < code.length && code[j] !== c) { if (code[j] === '\\') j += 1; j += 1; }
83
+ out += c + blank(code.slice(i + 1, j)) + (code[j] ?? '');
84
+ i = j + 1;
85
+ continue;
86
+ }
87
+ out += c;
88
+ i += 1;
89
+ }
90
+ return out;
91
+ }
92
+
93
+ // The text from an opening bracket to its match, in already stripped code.
94
+ function balanced(code, from) {
95
+ const open = code[from];
96
+ const close = { '(': ')', '{': '}', '[': ']' }[open];
97
+ let depth = 0;
98
+ for (let i = from; i < code.length; i += 1) {
99
+ if (code[i] === open) depth += 1;
100
+ else if (code[i] === close) { depth -= 1; if (depth === 0) return code.slice(from, i + 1); }
101
+ }
102
+ return code.slice(from);
103
+ }
104
+
105
+ // ---------------------------------------------------------------- the static half
106
+
107
+ function loadScenarios(root, findings, rel, tests) {
108
+ const dir = join(root, 'scenarios');
109
+ const files = walk(dir, { skip: new Set(['node_modules']), accept: (f) => f.endsWith('.md') && !/(^|\/)(map|README)\.md$/.test(f) });
110
+ const list = [];
111
+ const ids = new Map();
112
+ for (const file of files) {
113
+ const scenario = parseScenario(readFileSync(file, 'utf8'), file);
114
+ for (const p of scenario.problems) findings.push({ file: rel(file), line: p.line, code: 'scenario-format', message: p.message });
115
+ if (scenario.id) {
116
+ if (ids.has(scenario.id)) findings.push({ file: rel(file), line: scenario.fieldLine('id'), code: 'scenario-format', message: `the id "${scenario.id}" is also used in ${rel(ids.get(scenario.id))}` });
117
+ ids.set(scenario.id, file);
118
+ }
119
+ if (tests) addFingerprints(scenario, tests);
120
+ list.push(scenario);
121
+ }
122
+ return list;
123
+ }
124
+
125
+ function checkNames(scenario, tests, findings, rel) {
126
+ const file = rel(scenario.file);
127
+ const t = tests;
128
+ const base = new Set([
129
+ ...Object.keys(t.personas ?? {}), ...Object.keys(t['ways-in'] ?? {}), ...Object.keys(t.data ?? {}), ...Object.keys(t.setups ?? {}),
130
+ ...Object.keys(t.fixtures ?? {}), ...Object.keys(t.judges ?? {}), ...Object.keys(t.secrets ?? {}), ...Object.keys(t['stand-ins'] ?? {}),
131
+ ...scenario.tables.keys(),
132
+ ]);
133
+ const startNames = new Set(scenario.start.filter((s) => s.name).map((s) => s.name));
134
+ const unknown = (line, name, where) => findings.push({
135
+ file, line, code: 'unknown-name',
136
+ message: `the name \`${name}\` in ${where} is not a persona, way in, data rule, setup, fixture, judge, table, start item or kept result`,
137
+ });
138
+
139
+ // The front matter lists.
140
+ const lists = [['through', t['ways-in']], ['personas', t.personas], ['setups', t.setups], ['fixtures', t.fixtures], ['runs-in', resolveEnvironments(t.environments ?? {})]];
141
+ for (const [key, known] of lists) {
142
+ const value = scenario.fields[key];
143
+ if (value === undefined) continue;
144
+ for (const name of [].concat(value).map(String)) {
145
+ if (!Object.hasOwn(known ?? {}, name)) findings.push({ file, line: scenario.fieldLine(key), code: 'unknown-name', message: `"${name}" in ${key}: is not in tests.yaml` });
146
+ }
147
+ }
148
+
149
+ for (const item of scenario.start) {
150
+ for (const name of item.names) if (!base.has(name) && !startNames.has(name)) unknown(item.line, name, 'Start with');
151
+ }
152
+ const kept = new Set();
153
+ for (const step of scenario.steps) {
154
+ for (const k of step.keeps) kept.add(k);
155
+ for (const name of step.names) if (!base.has(name) && !startNames.has(name) && !kept.has(name)) unknown(step.line, name, `step ${step.n}`);
156
+ }
157
+ const allKept = new Set(scenario.steps.flatMap((s) => s.keeps));
158
+ for (const a of scenario.assertions) {
159
+ for (const name of a.names) if (!base.has(name) && !startNames.has(name) && !allKept.has(name)) unknown(a.line, name, `assertion ${a.id}`);
160
+ }
161
+ }
162
+
163
+ function checkWaitsAndWhens(scenario, findings, rel) {
164
+ const file = rel(scenario.file);
165
+ for (const step of scenario.steps) {
166
+ if (step.wait && !step.wait.limit) {
167
+ findings.push({ file, line: step.line, code: 'wait-no-limit', message: `step ${step.n} is a wait with no time limit; end it with "Give up after <time>"` });
168
+ }
169
+ }
170
+ if (!scenario.steps.length) return;
171
+ const keptStep = new Map();
172
+ for (const step of scenario.steps) for (const k of step.keeps) keptStep.set(k, step.n);
173
+ for (const a of scenario.assertions) {
174
+ if (!/^\s*when\b/i.test(a.words)) continue;
175
+ const refs = [...a.words.matchAll(/\bsteps?\s+(\d+)/gi)].map((m) => Number(m[1]));
176
+ const named = a.names.some((n) => keptStep.has(n));
177
+ const ok = refs.length ? refs.every((n) => scenario.steps.some((s) => s.n === n)) : named;
178
+ if (!ok) findings.push({ file, line: a.line, code: 'when-no-step', message: `${a.id} starts with "when" and names no step that makes it true; write "step N" or name a result that a step keeps` });
179
+ }
180
+ }
181
+
182
+ function checkBudget(scenario, tests, findings, rel) {
183
+ const limit = tests.budget?.cases;
184
+ if (!Number.isFinite(limit)) return;
185
+ for (const table of scenario.tables.values()) {
186
+ if (table.count > limit) findings.push({ file: rel(scenario.file), line: table.line, code: 'table-over-budget', message: `the table ${table.name} makes ${table.count} cases and the budget allows ${limit}` });
187
+ }
188
+ }
189
+
190
+ // The ids that tests name. Only a literal first argument can be read.
191
+ const CALL = /\brun\.(check|gate|notExercised|waitFor)\s*\(/g;
192
+
193
+ export function readRefs(file, code, rel, findings) {
194
+ const refs = [];
195
+ for (const m of code.matchAll(CALL)) {
196
+ const rest = code.slice(m.index + m[0].length);
197
+ const literal = /^\s*(['"`])([^'"`\n]*)\1/.exec(rest);
198
+ const line = lineAt(code, m.index);
199
+ if (!literal || literal[1] === '`' && literal[2].includes('${')) {
200
+ findings?.push({ file: rel(file), line, code: 'id-not-literal', message: `run.${m[1]} must take the full assertion id as a plain string, so the check can read it` });
201
+ continue;
202
+ }
203
+ const id = FULL_ID.exec(literal[2]);
204
+ if (!id) { findings?.push({ file: rel(file), line, code: 'bad-id', message: `"${literal[2]}" is not a full assertion id like <scenario>/e1#abc123` }); continue; }
205
+ refs.push({ file, line, kind: m[1], scenario: id[1], n: id[2], fp: id[3], full: literal[2] });
206
+ }
207
+ return refs;
208
+ }
209
+
210
+ function scenarioTestFiles(root, testsDir) {
211
+ return walk(join(root, testsDir), { skipPaths: [join(root, testsDir, 'atlas'), join(root, testsDir, 'evidence')], accept: (f) => /\.(m?js|cjs)$/.test(f) });
212
+ }
213
+
214
+ // ---------------------------------------------------------------- the kit
215
+
216
+ // The hash of a kit: the sha256 of its files, as sorted "path hash" lines.
217
+ // `files` maps each path to the sha256 of that file.
218
+ export function kitHashOf(files) {
219
+ const lines = Object.keys(files).sort().map((path) => `${path} ${files[path]}\n`).join('');
220
+ return createHash('sha256').update(lines).digest('hex');
221
+ }
222
+
223
+ const sha256Of = (bytes) => createHash('sha256').update(bytes).digest('hex');
224
+
225
+ // Reads the list of released kit hashes (packages/atlas/shape/kit-releases.json).
226
+ export function loadKitReleases(file) {
227
+ const data = JSON.parse(readFileSync(file, 'utf8'));
228
+ return (data.releases ?? []).map((r) => String(r.kit_sha256));
229
+ }
230
+
231
+ function kitFilesOnDisk(dir, prefix = '') {
232
+ const out = [];
233
+ for (const name of readdirSync(dir).sort()) {
234
+ const full = join(dir, name);
235
+ const stat = lstatSync(full);
236
+ if (stat.isSymbolicLink()) out.push({ path: `${prefix}${name}`, link: true });
237
+ else if (stat.isDirectory()) out.push(...kitFilesOnDisk(full, `${prefix}${name}/`));
238
+ else out.push({ path: `${prefix}${name}`, full });
239
+ }
240
+ return out;
241
+ }
242
+
243
+ // Every file of <area>/test/atlas/ must match manifest.json, and the manifest
244
+ // must match a kit that a release of Atlas shipped. `kitReleases` is a list of
245
+ // kit hashes; null skips the second test.
246
+ export function checkKit({ root, testsDir, rel, findings, kitReleases = null }) {
247
+ const dir = join(root, testsDir, 'atlas');
248
+ if (!existsSync(dir)) return;
249
+ const manifestPath = join(dir, 'manifest.json');
250
+ const say = (file, code, message) => findings.push({ file: rel(file), line: 1, code, message });
251
+ if (!existsSync(manifestPath)) { say(dir, 'kit-no-manifest', 'the kit folder has no manifest.json; run "atlas tests write"'); return; }
252
+ let manifest;
253
+ try { manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); } catch { say(manifestPath, 'kit-no-manifest', 'manifest.json is not JSON'); return; }
254
+ const listed = manifest?.files && typeof manifest.files === 'object' ? manifest.files : {};
255
+ const onDisk = kitFilesOnDisk(dir).filter((f) => f.path !== 'manifest.json');
256
+ for (const one of onDisk) {
257
+ const full = join(dir, one.path);
258
+ if (one.link) { say(full, 'kit-extra', `${one.path} in the kit folder is a link; the kit holds plain files`); continue; }
259
+ if (!Object.hasOwn(listed, one.path)) { say(full, 'kit-extra', `${one.path} is in the kit folder and not in manifest.json; the kit folder holds only what "atlas tests write" put there`); continue; }
260
+ if (sha256Of(readFileSync(one.full)) !== listed[one.path]) say(full, 'kit-changed', `${one.path} differs from manifest.json; it was edited. Restore it with "atlas tests write"`);
261
+ }
262
+ const have = new Set(onDisk.map((f) => f.path));
263
+ for (const path of Object.keys(listed)) if (!have.has(path)) say(join(dir, path), 'kit-missing', `${path} is in manifest.json and not in the kit folder`);
264
+ if (kitReleases && !kitReleases.includes(kitHashOf(listed))) {
265
+ say(manifestPath, 'kit-unknown-release', 'the hashes of manifest.json do not match any kit that a release of Atlas shipped; it was edited or came from another source. Restore it with "atlas tests write"');
266
+ }
267
+ }
268
+
269
+ function checkLinks(scenarios, root, testsDir, findings, rel) {
270
+ const byId = new Map(scenarios.filter((s) => s.id).map((s) => [s.id, s]));
271
+ const refs = [];
272
+ for (const file of scenarioTestFiles(root, testsDir)) {
273
+ refs.push(...readRefs(file, readFileSync(file, 'utf8'), rel, findings));
274
+ }
275
+ const by = new Map();
276
+ for (const r of refs) {
277
+ const s = byId.get(r.scenario);
278
+ const a = s?.assertions.find((x) => x.id === r.n);
279
+ if (!s || !a) { findings.push({ file: rel(r.file), line: r.line, code: 'unknown-assertion', message: `${r.full} names ${s ? `the assertion ${r.n}, which ${r.scenario} does not have` : `the scenario ${r.scenario}, which does not exist`}` }); continue; }
280
+ const key = `${r.scenario}/${r.n}`;
281
+ if (!by.has(key)) by.set(key, []);
282
+ by.get(key).push({ ...r, a });
283
+ if (a.fp && r.fp !== a.fp) findings.push({ file: rel(r.file), line: r.line, code: 'old-fingerprint', message: `${r.full} has an old fingerprint; ${key} now prints #${a.fp}` });
284
+ else if (r.kind === 'check' && a.gate) findings.push({ file: rel(r.file), line: r.line, code: 'gate-mismatch', message: `${key} is marked gate in the scenario; the test must use run.gate` });
285
+ else if (r.kind === 'gate' && !a.gate) findings.push({ file: rel(r.file), line: r.line, code: 'gate-mismatch', message: `${key} is not marked gate in the scenario; the test must use run.check` });
286
+ }
287
+ for (const s of scenarios) {
288
+ for (const a of s.assertions) {
289
+ const named = (by.get(`${s.id}/${a.id}`) ?? []).filter((r) => r.kind !== 'waitFor');
290
+ if (!named.length) findings.push({ file: rel(s.file), line: a.line, code: 'no-test', message: `no test names ${s.id}/${a.id}#${a.fp ?? '??????'}` });
291
+ }
292
+ }
293
+ }
294
+
295
+ export function checkStatic({ root, testsDir, guardsFrom, rel, findings }) {
296
+ const yamlPath = join(root, 'atlas', 'tests.yaml');
297
+ let tests = null;
298
+ if (!existsSync(yamlPath)) {
299
+ findings.push({ file: rel(yamlPath), line: 1, code: 'tests-yaml-missing', message: 'there is no atlas/tests.yaml' });
300
+ } else {
301
+ try {
302
+ const parsed = parseYaml(readFileSync(yamlPath, 'utf8'));
303
+ for (const f of checkTestsYaml(parsed)) findings.push({ file: rel(yamlPath), line: f.line, code: f.code, message: f.message });
304
+ tests = parsed.value && typeof parsed.value === 'object' ? parsed.value : null;
305
+ if (guardsFrom) {
306
+ const baseText = readFileSync(guardsFrom, 'utf8');
307
+ for (const c of compareGuardParts(baseText, parsed.value)) {
308
+ const line = parsed.lineOf(c.part.split('.')) ?? 1;
309
+ findings.push({ file: rel(yamlPath), line, code: 'guard-changed', message: `the guard part "${c.part}" differs from the main branch; a change to a guard part comes to the owner` });
310
+ }
311
+ }
312
+ } catch (error) {
313
+ if (!(error instanceof YamlError)) throw error;
314
+ findings.push({ file: rel(yamlPath), line: error.line ?? 1, code: 'tests-yaml', message: `the file cannot be read: ${error.message.replace(/ \(line \d+\)$/, '')}` });
315
+ }
316
+ }
317
+ const scenarios = loadScenarios(root, findings, rel, tests);
318
+ if (tests) {
319
+ for (const s of scenarios) {
320
+ checkNames(s, tests, findings, rel);
321
+ checkBudget(s, tests, findings, rel);
322
+ }
323
+ if (tests['outside-text'] === true) {
324
+ for (const [name, fx] of Object.entries(tests.fixtures ?? {})) {
325
+ if (!fx || fx['made-up'] !== true) findings.push({ file: rel(yamlPath), line: parseYaml(readFileSync(yamlPath, 'utf8')).lineOf(['fixtures', name]) ?? 1, code: 'fixture-not-made-up', message: `outside-text is true, so the fixture "${name}" must say made-up: true` });
326
+ }
327
+ }
328
+ }
329
+ for (const s of scenarios) checkWaitsAndWhens(s, findings, rel);
330
+ checkLinks(scenarios, root, testsDir, findings, rel);
331
+ return { tests, scenarios };
332
+ }
333
+
334
+ // ---------------------------------------------------------------- the lint half
335
+
336
+ const BANNED = [
337
+ [/\b(?:test|it|describe|suite)\s*\.\s*(?:skip|only|todo)\b/, 'a skipped, focused or todo test'],
338
+ [/\b(?:test|it|describe|suite)\s*\(\s*[^,()]*,\s*\{[^}]*\b(?:skip|only|todo)\s*:/, 'a skip, only or todo option on a test'],
339
+ [/\b(?:t|ctx|context)\s*\.\s*(?:skip|todo)\s*\(/, 'a test that skips itself'],
340
+ [/\b(?:xit|xtest|xdescribe|fit|fdescribe)\s*\(/, 'a skipped or focused test'],
341
+ [/\btest\s*\.\s*(?:fail|failing)\b/, 'a test marked as expected to fail'],
342
+ [/\bretries\s*:|\bretry\s*:|\.retry\s*\(|\bretryTimes\b|\bflaky\s*:\s*true/, 'a retry'],
343
+ [/\bexpect\s*\.\s*soft\b|\bassert\s*\.\s*soft\b|\bsoftAssert\w*\b/, 'a soft check'],
344
+ [/\bprocess\s*\.\s*exit\s*\(/, 'an exit from inside a test'],
345
+ ];
346
+ const KEYWORDS = new Set(['if', 'for', 'while', 'switch', 'catch', 'function', 'return', 'await', 'async', 'typeof', 'new', 'super']);
347
+ const ASSERTS = /\bassert\b|\bexpect\s*\(|\bshould\b/;
348
+
349
+ // Names of functions whose body holds an assertion.
350
+ function assertingNames(code) {
351
+ const names = new Set();
352
+ const patterns = [
353
+ /\bfunction\s*\*?\s*([A-Za-z_$][\w$]*)\s*\(/g,
354
+ /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:function\b[^(]*)?\(/g,
355
+ /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?[A-Za-z_$][\w$]*\s*=>/g,
356
+ /^\s*(?:async\s+)?([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*\{/gm,
357
+ /\b([A-Za-z_$][\w$]*)\s*:\s*(?:async\s*)?\([^)]*\)\s*=>/g,
358
+ ];
359
+ for (const re of patterns) {
360
+ for (const m of code.matchAll(re)) {
361
+ if (KEYWORDS.has(m[1])) continue;
362
+ const after = m.index + m[0].length;
363
+ const brace = code.indexOf('{', m.index);
364
+ const arrow = code.indexOf('=>', m.index);
365
+ let body;
366
+ if (m[0].endsWith('{')) body = balanced(code, after - 1);
367
+ else if (arrow >= 0 && arrow < after + 400) {
368
+ const start = arrow + 2;
369
+ body = /^\s*\{/.test(code.slice(start)) ? balanced(code, code.indexOf('{', start)) : code.slice(start, code.indexOf('\n', start) < 0 ? undefined : code.indexOf('\n', start));
370
+ } else if (brace >= 0) body = balanced(code, brace);
371
+ else continue;
372
+ if (ASSERTS.test(body)) names.add(m[1]);
373
+ }
374
+ }
375
+ return names;
376
+ }
377
+
378
+ function lintFiles(root, testsDir) {
379
+ const base = join(root, testsDir);
380
+ const all = scenarioTestFiles(root, testsDir);
381
+ const text = new Map(all.map((f) => [f, readFileSync(f, 'utf8')]));
382
+ const seeds = all.filter((f) => /\bscenario\s*\(/.test(text.get(f)) || /(^|\/)(support|scenarios)\//.test(f.slice(base.length)));
383
+ const picked = new Set(seeds);
384
+ const queue = [...seeds];
385
+ while (queue.length) {
386
+ const file = queue.pop();
387
+ for (const m of text.get(file).matchAll(/\bfrom\s+['"](\.{1,2}\/[^'"]+)['"]|\bimport\s*\(\s*['"](\.{1,2}\/[^'"]+)['"]/g)) {
388
+ const target = resolve(dirname(file), m[1] ?? m[2]);
389
+ if (text.has(target) && !picked.has(target)) { picked.add(target); queue.push(target); }
390
+ }
391
+ }
392
+ return { files: [...picked].sort(), text, all };
393
+ }
394
+
395
+ export function checkLint({ root, testsDir, rel, findings }) {
396
+ const { files, text, all } = lintFiles(root, testsDir);
397
+ const asserting = new Set();
398
+ for (const f of all) for (const n of assertingNames(stripJs(text.get(f)))) asserting.add(n);
399
+ const holds = (body) => ASSERTS.test(body) || [...asserting].some((n) => new RegExp(`(?<![\\w$.])${n.replace(/\$/g, '\\$')}\\s*\\(|\\.${n.replace(/\$/g, '\\$')}\\s*\\(`).test(body));
400
+ for (const file of files) {
401
+ const raw = text.get(file);
402
+ const code = stripJs(raw);
403
+ for (const [pattern, what] of BANNED) {
404
+ const re = new RegExp(pattern.source, 'g');
405
+ for (const m of code.matchAll(re)) findings.push({ file: rel(file), line: lineAt(code, m.index), code: 'lint', message: `${what}` });
406
+ }
407
+ // A check or a gate must hold an assert statement.
408
+ for (const m of code.matchAll(/\brun\s*\.\s*(check|gate)\s*\(/g)) {
409
+ const open = m.index + m[0].length - 1;
410
+ const body = balanced(code, open);
411
+ const afterId = body.indexOf(',');
412
+ if (afterId < 0 || !holds(body.slice(afterId + 1))) findings.push({ file: rel(file), line: lineAt(code, m.index), code: 'lint', message: `the run.${m[1]} holds no assert statement` });
413
+ }
414
+ // An ordinary test (test or it, not a scenario) must hold one too.
415
+ for (const m of code.matchAll(/(?<![\w$.])(?:test|it)\s*\(/g)) {
416
+ const body = balanced(code, m.index + m[0].length - 1);
417
+ if (!holds(body)) findings.push({ file: rel(file), line: lineAt(code, m.index), code: 'lint', message: 'a test with no assert statement' });
418
+ }
419
+ }
420
+ }
421
+
422
+ // ---------------------------------------------------------------- the run half
423
+
424
+ // The run ids in an evidence folder, newest first. A run is a sub-folder that
425
+ // holds evidence files; its time is the newest `started` of its files.
426
+ export function runIds(evidenceDir) {
427
+ if (!existsSync(evidenceDir)) return [];
428
+ const runs = [];
429
+ for (const name of readdirSync(evidenceDir)) {
430
+ const dir = join(evidenceDir, name);
431
+ if (!statSync(dir).isDirectory()) continue;
432
+ let newest = null;
433
+ for (const file of readdirSync(dir)) {
434
+ if (!file.endsWith('.json')) continue;
435
+ try {
436
+ const data = JSON.parse(readFileSync(join(dir, file), 'utf8'));
437
+ if (data?.evidence === 1 && (newest === null || String(data.started) > newest)) newest = String(data.started);
438
+ } catch { /* unreadable files are reported later */ }
439
+ }
440
+ if (newest !== null) runs.push({ name, newest });
441
+ }
442
+ return runs.sort((x, y) => (x.newest < y.newest ? 1 : x.newest > y.newest ? -1 : x.name < y.name ? 1 : -1)).map((r) => r.name);
443
+ }
444
+
445
+ export const newestRun = (evidenceDir) => runIds(evidenceDir)[0] ?? null;
446
+
447
+ // With no run id, each scenario uses its own newest run: the run folder that
448
+ // holds its newest evidence file. Evidence of a fault run does not count.
449
+ // Returns a Map of scenario id to run id.
450
+ export function newestRunPerScenario(evidenceDir) {
451
+ const best = new Map();
452
+ if (!existsSync(evidenceDir)) return new Map();
453
+ for (const name of readdirSync(evidenceDir)) {
454
+ const dir = join(evidenceDir, name);
455
+ if (!statSync(dir).isDirectory()) continue;
456
+ for (const file of readdirSync(dir)) {
457
+ if (!file.endsWith('.json')) continue;
458
+ let data;
459
+ try { data = JSON.parse(readFileSync(join(dir, file), 'utf8')); } catch { continue; }
460
+ if (data?.evidence !== 1 || data.fault_run || !data.scenario) continue;
461
+ const started = String(data.started);
462
+ const before = best.get(data.scenario);
463
+ if (!before || started > before.started || (started === before.started && name > before.name)) best.set(data.scenario, { started, name });
464
+ }
465
+ }
466
+ return new Map([...best].map(([scenario, b]) => [scenario, b.name]));
467
+ }
468
+
469
+ // The run half reads the files of one run only, and only evidence of the
470
+ // scenario file as it is now (source_sha256).
471
+ export function checkRun({ evidenceDir, runId = null, environment = null, scenarios, rel, findings }) {
472
+ const records = [];
473
+ const runOf = new Map();
474
+ const perScenario = runId === null ? newestRunPerScenario(evidenceDir) : null;
475
+ const names = runId !== null ? [runId] : [...new Set(perScenario.values())];
476
+ for (const id of names) {
477
+ const runDir = join(evidenceDir, id);
478
+ if (!existsSync(runDir)) continue;
479
+ for (const name of readdirSync(runDir).sort()) {
480
+ if (!name.endsWith('.json')) continue;
481
+ const file = join(runDir, name);
482
+ let data;
483
+ try { data = JSON.parse(readFileSync(file, 'utf8')); } catch { findings.push({ file: rel(file), line: 1, code: 'evidence-unreadable', message: 'the evidence file is not JSON' }); continue; }
484
+ if (data?.evidence !== 1) continue;
485
+ if (data.fault_run) continue;
486
+ if (perScenario && perScenario.get(data.scenario) !== id) continue;
487
+ records.push({ file, data });
488
+ runOf.set(data.scenario, id);
489
+ }
490
+ }
491
+ const reached = new Set(['pass', 'fail', 'not exercised', 'not here']);
492
+ const ran = new Set(['pass', 'fail', 'not exercised']);
493
+ for (const s of scenarios) {
494
+ if (!s.id) continue;
495
+ const all = records.filter((r) => r.data.scenario === s.id);
496
+ const id = runId ?? runOf.get(s.id) ?? null;
497
+ const mine = all.filter((r) => r.data.source_sha256 === s.sha256);
498
+ if (all.length && !mine.length) {
499
+ findings.push({ file: rel(s.file), line: 1, code: 'stale-evidence', message: `the evidence of run ${id} for ${s.id} was made from another version of this scenario file; run it again` });
500
+ continue;
501
+ }
502
+ for (const a of s.assertions) {
503
+ for (const way of s.through) {
504
+ const seen = mine.some((r) => r.data.assertions?.some((e) => e.id === a.fullId && e.way === way && reached.has(e.result)));
505
+ if (!seen) findings.push({ file: rel(s.file), line: a.line, code: 'not-checked', message: `no run checked ${s.id}/${a.id}#${a.fp} through ${way}` });
506
+ }
507
+ }
508
+ if (mine.length && environment === 'ci' && [].concat(s.fields['runs-in'] ?? []).map(String).includes('ci')) {
509
+ const did = mine.some((r) => r.data.assertions?.some((e) => ran.has(e.result)));
510
+ if (!did) findings.push({ file: rel(s.file), line: s.fieldLine('runs-in'), code: 'ci-not-run', message: `${s.id} lists ci in runs-in, and no way in has pass, fail or not exercised in run ${id}` });
511
+ }
512
+ const newest = [...mine].sort((x, y) => String(y.data.started).localeCompare(String(x.data.started)))[0];
513
+ for (const [name, fp] of Object.entries(newest?.data.tables ?? {})) {
514
+ const now = s.tables.get(name);
515
+ if (now && now.fingerprint !== fp) findings.push({ file: rel(s.file), line: now.line, code: 'stale-table', message: `the newest run of ${s.id} read the table ${name} as #${fp}; it now prints #${now.fingerprint}; run it again` });
516
+ }
517
+ }
518
+ }
519
+
520
+ // ---------------------------------------------------------------- the whole check
521
+
522
+ export const HALVES = Object.freeze(['static', 'lint', 'run']);
523
+
524
+ // Returns { findings: [{ file, line, code, message }], scenarios }.
525
+ export function checkArea({ root, halves = HALVES, evidence, tests = 'test', guardsFrom = process.env.ATLAS_GUARDS_FROM || null, run = null, environment = process.env.ATLAS_ENV || (process.env.CI ? 'ci' : 'local'), kitReleases = null, cwd = process.cwd() } = {}) {
526
+ const abs = resolve(root ?? '.');
527
+ const rel = (p) => { const r = relative(cwd, p); return r.startsWith('..') ? p : (r || '.'); };
528
+ const findings = [];
529
+ const bad = halves.filter((h) => !HALVES.includes(h));
530
+ if (bad.length) throw new Error(`there is no half "${bad[0]}"; use ${HALVES.join(', ')}`);
531
+ if (!existsSync(abs)) throw new Error(`${abs} does not exist`);
532
+ // The scenarios are read for every half; their findings only show with `static`.
533
+ const sink = halves.includes('static') ? findings : [];
534
+ const staticResult = checkStatic({ root: abs, testsDir: tests, guardsFrom, rel, findings: sink });
535
+ const { scenarios } = staticResult;
536
+ if (halves.includes('static')) checkKit({ root: abs, testsDir: tests, rel, findings, kitReleases });
537
+ if (halves.includes('lint')) checkLint({ root: abs, testsDir: tests, rel, findings });
538
+ if (halves.includes('run')) checkRun({ evidenceDir: resolve(evidence ?? join(abs, tests, 'evidence')), runId: run, environment, scenarios, rel, findings });
539
+ findings.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line));
540
+ return { findings, scenarios };
541
+ }
542
+
543
+ export const formatFinding = (f) => `${f.file}:${f.line} ${f.code} ${f.message}`;
544
+
545
+ // ---------------------------------------------------------------- the command line
546
+
547
+ export function parseArgs(argv) {
548
+ const out = { halves: [...HALVES] };
549
+ const takes = new Set(['root', 'halves', 'evidence', 'tests', 'guards-from', 'run', 'kit-releases']);
550
+ for (let i = 0; i < argv.length; i += 1) {
551
+ const word = argv[i];
552
+ if (!word.startsWith('--')) throw new Error(`unexpected argument "${word}"`);
553
+ const key = word.slice(2);
554
+ if (!takes.has(key)) throw new Error(`there is no flag "--${key}"`);
555
+ const value = argv[i + 1];
556
+ if (value === undefined || value.startsWith('--')) throw new Error(`the flag "--${key}" needs a value`);
557
+ i += 1;
558
+ if (key === 'halves') out.halves = value.split(',').map((s) => s.trim()).filter(Boolean);
559
+ else out[key === 'guards-from' ? 'guardsFrom' : key === 'kit-releases' ? 'kitReleases' : key] = value;
560
+ }
561
+ return out;
562
+ }
563
+
564
+ export function main(argv, write = (text) => process.stdout.write(text)) {
565
+ let args;
566
+ try { args = parseArgs(argv); } catch (error) { process.stderr.write(`link-check: ${error.message}\n`); return 2; }
567
+ if (!args.root) { process.stderr.write('link-check: --root <area> is needed\n'); return 2; }
568
+ let result;
569
+ try { if (args.kitReleases) args.kitReleases = loadKitReleases(args.kitReleases); result = checkArea(args); } catch (error) { process.stderr.write(`link-check: ${error.message}\n`); return 2; }
570
+ for (const f of result.findings) write(`${formatFinding(f)}\n`);
571
+ write(`\n${result.scenarios.length} scenario(s) read; halves: ${args.halves.join(', ')}; ${result.findings.length} finding(s).\n`);
572
+ write(result.findings.length ? 'RED\n' : 'GREEN\n');
573
+ return result.findings.length ? 1 : 0;
574
+ }
575
+
576
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) process.exitCode = main(process.argv.slice(2));
@@ -0,0 +1,43 @@
1
+ // Child processes that the library starts: the product, a stand-in, an MCP
2
+ // server. Each one runs in its own process group, so a stop reaches the whole
3
+ // tree. Whatever is still running when this process ends is killed.
4
+ import { spawn, spawnSync } from 'node:child_process';
5
+ import { setTimeout as sleep } from 'node:timers/promises';
6
+
7
+ const live = new Set();
8
+ let hooked = false;
9
+
10
+ export function killGroup(child, signal) {
11
+ try { process.kill(-child.pid, signal); } catch { try { child.kill(signal); } catch { /* it is gone */ } }
12
+ }
13
+
14
+ // Stops what is still running when this process ends, even on a crash.
15
+ function hook() {
16
+ if (hooked) return;
17
+ hooked = true;
18
+ process.once('exit', () => { for (const child of live) killGroup(child, 'SIGKILL'); });
19
+ }
20
+
21
+ export function spawnManaged({ command, cwd, env, redactor, stdin = false }) {
22
+ hook();
23
+ const child = spawn('sh', ['-c', command], { cwd, env, detached: true, stdio: [stdin ? 'pipe' : 'ignore', 'pipe', 'pipe'] });
24
+ live.add(child);
25
+ let tail = '';
26
+ const keep = (chunk) => { tail = (tail + chunk.toString('utf8')).slice(-4000); };
27
+ child.stdout.on('data', keep);
28
+ child.stderr.on('data', keep);
29
+ const exited = new Promise((resolve) => child.once('exit', (code, signal) => { live.delete(child); resolve({ code, signal }); }));
30
+ child.once('error', () => live.delete(child));
31
+ return {
32
+ child, exited,
33
+ tail: () => (redactor ? redactor.text(tail) : tail).trim(),
34
+ async stop(stopCommand) {
35
+ if (child.exitCode !== null || child.signalCode !== null) return;
36
+ if (stopCommand && stopCommand !== 'signal') spawnSync('sh', ['-c', stopCommand], { cwd, env, timeout: 10000 });
37
+ killGroup(child, 'SIGTERM');
38
+ const done = await Promise.race([exited.then(() => true), sleep(5000, undefined, { ref: false }).then(() => false)]);
39
+ if (!done) { killGroup(child, 'SIGKILL'); await Promise.race([exited, sleep(2000, undefined, { ref: false })]); }
40
+ },
41
+ };
42
+ }
43
+