@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/tests/drivers/index.mjs
CHANGED
|
@@ -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);
|
package/tests/link-check.mjs
CHANGED
|
@@ -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
|
|
9
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
454
|
-
|
|
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
|
package/tests/tests-yaml.mjs
CHANGED
|
@@ -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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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(
|
|
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`);
|