@arjunkhera/atlas 0.3.41 → 0.3.42

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.
@@ -37,6 +37,9 @@ export function commandFor(command, root, productRoot) {
37
37
  return command.split(root).join(productRoot);
38
38
  }
39
39
 
40
+ // The drivers that the kit ships: the cases of makeDriver, and the kept runner (kept.mjs).
41
+ export const SHIPPED_DRIVERS = Object.freeze(['http', 'browser', 'mcp-stdio', 'function', 'agent', 'command', 'kept']);
42
+
40
43
  export async function makeDriver(ctx) {
41
44
  const { way, spec, env, root, hosts, redactor, record, adapter = {}, runId } = ctx;
42
45
  const productRoot = productRootOf(root);
@@ -5,8 +5,13 @@
5
5
  //
6
6
  // static reads text: every scenario, atlas/tests.yaml, the tests and the
7
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.
8
+ // lint one lint for each language of the tests. JavaScript (.js, .mjs,
9
+ // .cjs): it fails on a skip, an "only", a todo, a retry, a soft
10
+ // check, or a test with no assert statement. Python (.py): it fails
11
+ // on a skip, an expected fail, a focus mark, a retry, a soft check
12
+ // (a swallowed assert), an exit, an assert that cannot fail, a check
13
+ // that is not a with block, and a check or a test with no assert
14
+ // statement.
10
15
  // run reads the evidence files of ONE run (--run <id>). With no --run,
11
16
  // it reads the newest run of each scenario, one run per scenario. It fails on an assertion
12
17
  // that the run did not check, on evidence of a changed scenario file
@@ -48,7 +53,7 @@ const FULL_ID = /^([a-z0-9][a-z0-9-]*)\/(e\d+)#([0-9a-f]{6})$/;
48
53
  // Folders skipped by name, at any depth. The kit folder and the evidence folder
49
54
  // are skipped by exact path only (<area>/test/atlas, <area>/test/evidence): a
50
55
  // folder that merely carries the name "atlas" or "evidence" is read like any other.
51
- const SKIP_DIRS = new Set(['node_modules', '.git']);
56
+ const SKIP_DIRS = new Set(['node_modules', '.git', '__pycache__']);
52
57
 
53
58
  // ---------------------------------------------------------------- files
54
59
 
@@ -187,8 +192,10 @@ function checkBudget(scenario, tests, findings, rel) {
187
192
  }
188
193
  }
189
194
 
190
- // The ids that tests name. Only a literal first argument can be read.
191
- const CALL = /\brun\.(check|gate|judge|notExercised|waitFor)\s*\(/g;
195
+ // The ids that tests name. Only a literal first argument can be read. A Python
196
+ // test names the same parts in snake case (not_exercised, wait_for).
197
+ const CALL = /\brun\.(check|gate|judge|notExercised|waitFor|not_exercised|wait_for)\s*\(/g;
198
+ const KIND = { not_exercised: 'notExercised', wait_for: 'waitFor' };
192
199
 
193
200
  export function readRefs(file, code, rel, findings) {
194
201
  const refs = [];
@@ -202,15 +209,17 @@ export function readRefs(file, code, rel, findings) {
202
209
  }
203
210
  const id = FULL_ID.exec(literal[2]);
204
211
  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] });
212
+ refs.push({ file, line, kind: KIND[m[1]] ?? m[1], scenario: id[1], n: id[2], fp: id[3], full: literal[2] });
206
213
  }
207
214
  return refs;
208
215
  }
209
216
 
210
217
  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) });
218
+ return walk(join(root, testsDir), { skipPaths: [join(root, testsDir, 'atlas'), join(root, testsDir, 'evidence')], accept: (f) => /\.(m?js|cjs|py)$/.test(f) });
212
219
  }
213
220
 
221
+ const isPython = (file) => file.endsWith('.py');
222
+
214
223
  // ---------------------------------------------------------------- the kit
215
224
 
216
225
  // The hash of a kit: the sha256 of its files, as sorted "path hash" lines.
@@ -256,6 +265,7 @@ export function checkKit({ root, testsDir, rel, findings, kitReleases = null })
256
265
  for (const one of onDisk) {
257
266
  const full = join(dir, one.path);
258
267
  if (one.link) { say(full, 'kit-extra', `${one.path} in the kit folder is a link; the kit holds plain files`); continue; }
268
+ if (one.path.split('/').includes('__pycache__')) { say(full, 'kit-extra', `${one.path} is Python bytecode in the kit folder; delete it. A Python test sets sys.dont_write_bytecode = True before it imports contract`); continue; }
259
269
  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
270
  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
271
  }
@@ -356,6 +366,27 @@ function checkKeptFiles(scenarios, tests, root, findings, rel, yamlPath) {
356
366
  }
357
367
  }
358
368
 
369
+ // Python loads bytecode and binary modules before (or instead of) the source, so a
370
+ // committed .pyc could run code that the lint and the reviewer never read. The test
371
+ // folder holds none, at any depth: delete it, and run Python with -B.
372
+ const BINARY = /\.(?:pyc|pyo|so|pyd)$/;
373
+ function checkBinaries(root, testsDir, findings, rel) {
374
+ const found = [];
375
+ const look = (dir) => {
376
+ if (!existsSync(dir)) return;
377
+ for (const name of readdirSync(dir).sort()) {
378
+ const full = join(dir, name);
379
+ const stat = lstatSync(full);
380
+ // A link could point Python at bytecode outside the folder, so a link of any kind is a finding.
381
+ if (name === '__pycache__' || stat.isSymbolicLink()) found.push(full);
382
+ else if (stat.isDirectory()) { if (!SKIP_DIRS.has(name)) look(full); }
383
+ else if (BINARY.test(name)) found.push(full);
384
+ }
385
+ };
386
+ look(join(root, testsDir));
387
+ for (const full of found) findings.push({ file: rel(full), line: 1, code: 'python-bytecode', message: 'Python bytecode, a binary module or a link in the test folder can run code that no one reads; delete it, and run Python with -B' });
388
+ }
389
+
359
390
  export function checkStatic({ root, testsDir, guardsFrom, rel, findings }) {
360
391
  const yamlPath = join(root, 'atlas', 'tests.yaml');
361
392
  let tests = null;
@@ -394,6 +425,7 @@ export function checkStatic({ root, testsDir, guardsFrom, rel, findings }) {
394
425
  for (const s of scenarios) checkKept(s, tests, root, findings, rel);
395
426
  checkKeptFiles(scenarios, tests, root, findings, rel, yamlPath);
396
427
  checkLinks(scenarios, root, testsDir, findings, rel);
428
+ checkBinaries(root, testsDir, findings, rel);
397
429
  return { tests, scenarios };
398
430
  }
399
431
 
@@ -441,6 +473,9 @@ function assertingNames(code) {
441
473
  return names;
442
474
  }
443
475
 
476
+ // The test files and the support files that the lint reads: each file that
477
+ // holds a scenario, each file under support/ or scenarios/, and each file of the
478
+ // test folder that one of them imports.
444
479
  function lintFiles(root, testsDir) {
445
480
  const base = join(root, testsDir);
446
481
  const all = scenarioTestFiles(root, testsDir);
@@ -450,16 +485,55 @@ function lintFiles(root, testsDir) {
450
485
  const queue = [...seeds];
451
486
  while (queue.length) {
452
487
  const file = queue.pop();
453
- for (const m of text.get(file).matchAll(/\bfrom\s+['"](\.{1,2}\/[^'"]+)['"]|\bimport\s*\(\s*['"](\.{1,2}\/[^'"]+)['"]/g)) {
454
- const target = resolve(dirname(file), m[1] ?? m[2]);
488
+ const targets = isPython(file) ? pythonImports(file, text.get(file), base) : [...text.get(file).matchAll(/\bfrom\s+['"](\.{1,2}\/[^'"]+)['"]|\bimport\s*\(\s*['"](\.{1,2}\/[^'"]+)['"]/g)].map((m) => resolve(dirname(file), m[1] ?? m[2]));
489
+ for (const target of targets) {
455
490
  if (text.has(target) && !picked.has(target)) { picked.add(target); queue.push(target); }
456
491
  }
457
492
  }
458
493
  return { files: [...picked].sort(), text, all };
459
494
  }
460
495
 
496
+ // The files that a Python file may import: "from a.b import c" gives a/b.py, a/b/__init__.py
497
+ // and a/b/c.py, from the folder of the file and from the test folder ("from .x" from the folder only).
498
+ function pythonImports(file, code, base) {
499
+ const out = [];
500
+ const add = (dir, dotted) => {
501
+ const path = join(dir, ...dotted.split('.').filter(Boolean));
502
+ out.push(`${path}.py`, join(path, '__init__.py'));
503
+ };
504
+ const stripped = stripPy(code);
505
+ for (const m of stripped.matchAll(/^[ \t]*from[ \t]+(\.*)([\w.]*)[ \t]+import[ \t]+\(?([\w, \t\n]+)\)?/gm)) {
506
+ const names = m[3].split(',').map((n) => n.trim().split(/\s+/)[0]).filter(Boolean);
507
+ let dirs;
508
+ if (m[1]) {
509
+ let dir = dirname(file);
510
+ for (let i = 1; i < m[1].length; i += 1) dir = dirname(dir);
511
+ dirs = [dir];
512
+ } else dirs = [dirname(file), base];
513
+ for (const dir of dirs) {
514
+ if (m[2]) add(dir, m[2]);
515
+ for (const n of names) add(dir, m[2] ? `${m[2]}.${n}` : n);
516
+ }
517
+ }
518
+ for (const m of stripped.matchAll(/^[ \t]*import[ \t]+([\w., \t]+)$/gm)) {
519
+ for (const one of m[1].split(',').map((n) => n.trim().split(/\s+/)[0]).filter(Boolean)) {
520
+ for (const dir of [dirname(file), base]) add(dir, one);
521
+ }
522
+ }
523
+ return out;
524
+ }
525
+
461
526
  export function checkLint({ root, testsDir, rel, findings }) {
462
527
  const { files, text, all } = lintFiles(root, testsDir);
528
+ const js = files.filter((f) => !isPython(f));
529
+ const py = files.filter(isPython);
530
+ if (js.length) lintJs({ files: js, all: all.filter((f) => !isPython(f)), text, rel, findings });
531
+ if (py.length) lintPy({ files: py, all: all.filter(isPython), text, rel, findings });
532
+ }
533
+
534
+ // ---------------------------------------------------------------- the JavaScript lint
535
+
536
+ function lintJs({ files, all, text, rel, findings }) {
463
537
  const asserting = new Set();
464
538
  for (const f of all) for (const n of assertingNames(stripJs(text.get(f)))) asserting.add(n);
465
539
  const holds = (body) => ASSERTS.test(body) || [...asserting].some((n) => new RegExp(`(?<![\\w$.])${n.replace(/\$/g, '\\$')}\\s*\\(|\\.${n.replace(/\$/g, '\\$')}\\s*\\(`).test(body));
@@ -485,6 +559,144 @@ export function checkLint({ root, testsDir, rel, findings }) {
485
559
  }
486
560
  }
487
561
 
562
+ // ---------------------------------------------------------------- the Python lint
563
+
564
+ // Replaces comments, and the insides of strings, with spaces, as stripJs does.
565
+ // It knows the four quote forms and the string prefixes (r, b, f, u). The fields
566
+ // of an f-string ({...}) are code, and may hold strings of the same quote
567
+ // (Python 3.12), so they are read as code and kept.
568
+ export function stripPy(code) {
569
+ const blank = (t) => t.replace(/[^\n]/g, ' ');
570
+ // Reads code from i until `stop` (a closing brace of a field) at depth 0, or the end.
571
+ const readCode = (i, stop) => {
572
+ let out = '';
573
+ let depth = 0;
574
+ while (i < code.length) {
575
+ const c = code[i];
576
+ if (stop && depth === 0 && (c === '}' || c === '!' && code[i + 1] !== '=' || c === ':' )) return { out, i };
577
+ if (c === '#') { const end = code.indexOf('\n', i); const at = end < 0 ? code.length : end; out += blank(code.slice(i, at)); i = at; continue; }
578
+ if (c === '"' || c === "'") {
579
+ const prefix = /[A-Za-z]*$/.exec(code.slice(Math.max(0, i - 3), i))[0].toLowerCase();
580
+ const r = readString(i, prefix.includes('f'));
581
+ out += r.out; i = r.i; continue;
582
+ }
583
+ if (c === '(' || c === '[' || c === '{') depth += 1;
584
+ else if (c === ')' || c === ']' || c === '}') depth -= 1;
585
+ out += c; i += 1;
586
+ }
587
+ return { out, i };
588
+ };
589
+ const readString = (i, isF) => {
590
+ const c = code[i];
591
+ const quote = code.startsWith(c.repeat(3), i) ? c.repeat(3) : c;
592
+ let out = quote;
593
+ let j = i + quote.length;
594
+ while (j < code.length && !code.startsWith(quote, j) && (quote.length === 3 || code[j] !== '\n')) {
595
+ if (code[j] === '\\') { out += blank(code.slice(j, j + 2)); j += 2; continue; }
596
+ if (isF && code[j] === '{') {
597
+ if (code[j + 1] === '{') { out += ' '; j += 2; continue; }
598
+ out += '{';
599
+ const field = readCode(j + 1, true);
600
+ out += field.out; j = field.i;
601
+ // A format spec or a conversion, up to the closing brace: blank text, but nested fields are code.
602
+ while (j < code.length && code[j] !== '}' && code[j] !== '\n') {
603
+ if (code[j] === '{') { const inner = readCode(j + 1, true); out += `{${inner.out}`; j = inner.i; if (code[j] === '}') { out += '}'; j += 1; } continue; }
604
+ out += ' '; j += 1;
605
+ }
606
+ if (code[j] === '}') { out += '}'; j += 1; }
607
+ continue;
608
+ }
609
+ out += code[j] === '\n' ? '\n' : ' ';
610
+ j += 1;
611
+ }
612
+ if (code.startsWith(quote, j)) { out += quote; j += quote.length; }
613
+ return { out, i: j };
614
+ };
615
+ return readCode(0, false).out;
616
+ }
617
+
618
+ const PY_BANNED = [
619
+ [/@\s*(?:unittest\s*\.\s*)?(?:skip|skipIf|skipUnless|expectedFailure)\b/, 'a skipped or expected-fail test'],
620
+ [/\bmark\s*\.\s*(?:skip|skipif|xfail)\b/, 'a skipped or expected-fail test'],
621
+ [/\bmark\s*\.\s*(?:only|focus)\b/, 'a focused test'],
622
+ [/\.\s*skipTest\s*\(|\bpytest\s*\.\s*(?:skip|xfail|importorskip)\s*\(|\bSkipTest\b/, 'a test that skips itself'],
623
+ [/\bmark\s*\.\s*flaky\b|@\s*flaky\b|\breruns\s*=|@\s*(?:\w+\s*\.\s*)?retry\b|\b(?:import|from)\s+(?:tenacity|backoff|flaky)\b/, 'a retry'],
624
+ [/\bpytest_check\b|\bsoft_assertions\b|\bsoftest\b/, 'a soft check'],
625
+ [/\bexcept\s*(?:\(\s*[\w.,\s]*)?\b(?:AssertionError|BaseException|Exception)\b|\bexcept\s*:/, 'a soft check: an except that swallows a failed assert'],
626
+ [/\bsuppress\b|\bExitStack\b|\bdef\s+__exit__\b/, 'a soft check: a context manager that can swallow a failed assert'],
627
+ [/=\s*AssertionError\b|\bAssertionError\s+as\b|\bas\s+AssertionError\b/, 'a soft check: a new name for AssertionError'],
628
+ [/\bsys\s*\.\s*exit\s*\(|\bos\s*\.\s*_exit\s*\(|(?<![\w.])(?:exit|quit)\s*\(/, 'an exit from inside a test'],
629
+ [/(?<![\w.])assert\s*\(?\s*(?:not\s+(?:False|None|0|""|'')|True|[1-9][\d_.]*|"\s+"|'\s+')\s*\)?\s*(?:,|;|$)/m, 'an assert that cannot fail'],
630
+ [/\bassert(?:True|Truthy)\s*\(\s*(?:True|[1-9][\d_.]*)\s*[,)]|\bassert(?:False|IsNone)\s*\(\s*(?:False|None|0)\s*[,)]/, 'an assert that cannot fail'],
631
+ [/(?<![\w.])assert\s*\([^()\n]*,[^()\n]*\)\s*$/m, 'an assert that cannot fail: it asserts a tuple'],
632
+ ];
633
+ const PY_ASSERTS = /(?<![\w.])assert\b|\bself\s*\.\s*(?:assert\w*|fail)\s*\(|\.\s*assert_\w+\s*\(|\bpytest\s*\.\s*(?:raises|fail)\s*\(/;
634
+
635
+ // The block of a statement that starts at `index` (a def, a with): the rest of its
636
+ // header line after the colon, or else the lines below it that are indented more.
637
+ function pyBlock(code, index) {
638
+ let depth = 0;
639
+ let colon = -1;
640
+ for (let i = index; i < code.length; i += 1) {
641
+ const c = code[i];
642
+ if (c === '(' || c === '[' || c === '{') depth += 1;
643
+ else if (c === ')' || c === ']' || c === '}') depth -= 1;
644
+ else if (c === ':' && depth === 0) { colon = i; break; }
645
+ }
646
+ if (colon < 0) return '';
647
+ const lineStart = code.lastIndexOf('\n', index) + 1;
648
+ const indent = /^[ \t]*/.exec(code.slice(lineStart))[0].length;
649
+ const end = code.indexOf('\n', colon);
650
+ const rest = code.slice(colon + 1, end < 0 ? undefined : end);
651
+ if (rest.trim()) return rest;
652
+ const lines = [];
653
+ for (const line of code.slice(end + 1).split('\n')) {
654
+ if (!line.trim()) { lines.push(line); continue; }
655
+ if (/^[ \t]*/.exec(line)[0].length <= indent) break;
656
+ lines.push(line);
657
+ }
658
+ return lines.join('\n');
659
+ }
660
+
661
+ function pyAssertingNames(code) {
662
+ const names = new Set();
663
+ for (const m of code.matchAll(/^[ \t]*(?:async[ \t]+)?def[ \t]+([A-Za-z_]\w*)\s*\(/gm)) {
664
+ if (PY_ASSERTS.test(pyBlock(code, m.index))) names.add(m[1]);
665
+ }
666
+ return names;
667
+ }
668
+
669
+ function lintPy({ files, all, text, rel, findings }) {
670
+ const asserting = new Set();
671
+ for (const f of all) for (const n of pyAssertingNames(stripPy(text.get(f)))) asserting.add(n);
672
+ const holds = (body) => PY_ASSERTS.test(body) || [...asserting].some((n) => new RegExp(`(?<![\\w.])${n}\\s*\\(|\\.${n}\\s*\\(`).test(body));
673
+ for (const file of files) {
674
+ const code = stripPy(text.get(file));
675
+ const say = (index, message) => findings.push({ file: rel(file), line: lineAt(code, index), code: 'lint', message });
676
+ for (const [pattern, what] of PY_BANNED) {
677
+ const re = new RegExp(pattern.source, pattern.flags.includes('m') ? 'gm' : 'g');
678
+ for (const m of code.matchAll(re)) say(m.index, what);
679
+ }
680
+ // A check or a gate is a with block, and its block holds an assert statement.
681
+ for (const m of code.matchAll(/\brun\s*\.\s*(check|gate)\s*\(/g)) {
682
+ const lineStart = code.lastIndexOf('\n', m.index) + 1;
683
+ const head = code.slice(lineStart, m.index);
684
+ if (!/^[ \t]*(?:async[ \t]+)?with\b/.test(head)) { say(m.index, `the run.${m[1]} is not a with block, so it checks nothing`); continue; }
685
+ const block = pyBlock(code, m.index);
686
+ if (!holds(block)) say(m.index, `the run.${m[1]} holds no assert statement`);
687
+ // The block holds plain asserts: no try, and no context manager of its own, on its line or inside it.
688
+ const call = balanced(code, code.indexOf('(', m.index));
689
+ const after = code.slice(code.indexOf('(', m.index) + call.length, code.indexOf(':', code.indexOf('(', m.index) + call.length));
690
+ if (/,/.test(after.replace(/\bas\s+\w+/, ''))) say(m.index, `the run.${m[1]} shares its with line with another context manager, which can swallow a failed assert`);
691
+ if (/(?:^|\n)[ \t]*(?:try\s*:|(?:async[ \t]+)?with\b)/.test(block)) say(m.index, `the run.${m[1]} holds a try or a with block, which can swallow a failed assert`);
692
+ }
693
+ // An ordinary test (a def whose name starts with test) must hold one too.
694
+ for (const m of code.matchAll(/^[ \t]*(?:async[ \t]+)?def[ \t]+(test\w*)\s*\(/gm)) {
695
+ if (!holds(pyBlock(code, m.index))) say(m.index + m[0].indexOf('def'), 'a test with no assert statement');
696
+ }
697
+ }
698
+ }
699
+
488
700
  // ---------------------------------------------------------------- the run half
489
701
 
490
702
  // The run ids in an evidence folder, newest first. A run is a sub-folder that
@@ -150,10 +150,19 @@ export function checkTestsYaml(parsed) {
150
150
  known(value, SCHEMA.top, []);
151
151
  for (const key of REQUIRED) if (!(key in value)) say('tests-yaml-missing-field', [key], `the field "${key}" is needed`);
152
152
  if ('spec' in value && value.spec !== SPEC) say('tests-yaml-spec', ['spec'], `spec must be ${SPEC}; this check reads spec ${SPEC} only`);
153
- if (isMap(value.runner)) {
154
- known(value.runner, SCHEMA.runner, ['runner']);
155
- for (const key of SCHEMA.runner) if (!(key in value.runner)) say('tests-yaml-missing-field', ['runner', key], `the field "runner.${key}" is needed`);
156
- } else if ('runner' in value) say('tests-yaml', ['runner'], '"runner" must be a map');
153
+ // One runner, or a list of runners: one for each language of the tests (a JavaScript runner and a Python runner, say).
154
+ const runners = isMap(value.runner) ? [[value.runner, ['runner']]] : Array.isArray(value.runner) ? value.runner.map((r, i) => [r, ['runner', i]]) : null;
155
+ if (runners) {
156
+ if (!runners.length) say('tests-yaml', ['runner'], '"runner" lists no runner');
157
+ const languages = new Set();
158
+ for (const [one, path] of runners) {
159
+ if (!isMap(one)) { say('tests-yaml', path, `"${path.join('.')}" must be a map`); continue; }
160
+ known(one, SCHEMA.runner, path);
161
+ for (const key of SCHEMA.runner) if (!(key in one)) say('tests-yaml-missing-field', [...path, key], `the field "${[...path, key].join('.')}" is needed`);
162
+ if (languages.has(one.language)) say('tests-yaml', [...path, 'language'], `two runners name the language "${one.language}"`);
163
+ languages.add(one.language);
164
+ }
165
+ } else if ('runner' in value) say('tests-yaml', ['runner'], '"runner" must be a map, or a list of maps');
157
166
 
158
167
  each(value.environments, SCHEMA.environment, ['environments'], (env, path) => {
159
168
  if (isMap(env.ready)) known(env.ready, SCHEMA.ready, [...path, 'ready']);
package/tests/verdict.mjs CHANGED
@@ -311,7 +311,11 @@ const ENDED_WELL = new Set(['pass', 'not here']);
311
311
  // not pass or not here. A file whose scenario wrote no evidence at all runs again. So does a file that
312
312
  // names no scenario of the area. Returns the test files, as paths from the area, in order.
313
313
  // When nothing qualifies, it returns every test file: a run failed, and the evidence does not say where.
314
- export function rerunFiles({ root, run, evidence = null, tests = 'test' }) {
314
+ // `language` picks the test files of one runner: javascript (*.test.mjs) or python (*_test.py).
315
+ export const TEST_FILE = Object.freeze({ javascript: (n) => n.endsWith('.test.mjs'), python: (n) => n.endsWith('_test.py') });
316
+ export function rerunFiles({ root, run, evidence = null, tests = 'test', language = 'javascript' }) {
317
+ const isTest = Object.hasOwn(TEST_FILE, language) ? TEST_FILE[language] : null;
318
+ if (!isTest) throw new Error(`there is no language "${language}"; use ${Object.keys(TEST_FILE).join(' or ')}`);
315
319
  const area = resolve(root);
316
320
  const dir = join(area, tests, 'scenarios');
317
321
  if (!existsSync(dir)) throw new Error(`there is no folder ${dir}`);
@@ -322,7 +326,7 @@ export function rerunFiles({ root, run, evidence = null, tests = 'test' }) {
322
326
  // A scenario with a model in it (repeat, or a judged assertion) never runs a second time: its subject
323
327
  // varies by design, so a second try would weaken its pass rule and spend the money twice.
324
328
  const varying = new Set(loaded.filter((s) => s.repeat || s.assertions.some((a) => a.kind === 'judged')).map((s) => s.id));
325
- const files = readdirSync(dir).filter((n) => n.endsWith('.test.mjs')).filter((n) => {
329
+ const files = readdirSync(dir).filter(isTest).filter((n) => {
326
330
  const ids = [...readFileSync(join(dir, n), 'utf8').matchAll(/scenarios\/([A-Za-z0-9._-]+\.md)/g)].map((m) => byFile.get(m[1])).filter(Boolean);
327
331
  return !(ids.length && ids.every((id) => varying.has(id)));
328
332
  }).sort();
@@ -339,10 +343,10 @@ export function rerunFiles({ root, run, evidence = null, tests = 'test' }) {
339
343
  return pick.length ? pick : files.map((n) => join(tests, 'scenarios', n));
340
344
  }
341
345
 
342
- export function rerunCommand({ run, evidence, tests = 'test' }, root, line) {
346
+ export function rerunCommand({ run, evidence, tests = 'test', language = 'javascript' }, root, line) {
343
347
  try {
344
348
  if (!run) throw Object.assign(new Error('give the run: --run <id>'), { usage: true });
345
- for (const f of rerunFiles({ root, run, evidence, tests: tests ?? 'test' })) line(f);
349
+ for (const f of rerunFiles({ root, run, evidence, tests: tests ?? 'test', language: language ?? 'javascript' })) line(f);
346
350
  return 0;
347
351
  } catch (error) {
348
352
  process.stderr.write(`rerun: ${error.message}\n`);