@syncerauk/test-rules 1.0.7 → 1.0.9
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/README.md +20 -2
- package/package.json +1 -1
- package/test-rules.mjs +360 -26
package/README.md
CHANGED
|
@@ -56,7 +56,8 @@ Both in the repo's `scripts/test-rules/`:
|
|
|
56
56
|
`pins` (the kept pins, below),
|
|
57
57
|
`reads` (the reads register: `"comment"`, or
|
|
58
58
|
`{ "json": "<path>" }` for the map under `"tests"` in that file, `"key": "<name>"` for another
|
|
59
|
-
key, `"key": null` when the file itself is the map
|
|
59
|
+
key, `"key": null` when the file itself is the map; `"runs": "<name>"` (1.0.9) names a second
|
|
60
|
+
map in that file of what each test only runs). The defaults are `CONFIG_DEFAULTS` in the
|
|
60
61
|
script.
|
|
61
62
|
- `listed.json`: the breaches the repo had on the day the check arrived. Written once by
|
|
62
63
|
`test-rules --init`. An optional `moved` map, `{ "<new test file>": "<old test file>" }`,
|
|
@@ -123,13 +124,30 @@ when the PR does not raise the version.
|
|
|
123
124
|
| Version | Detections it added |
|
|
124
125
|
|---|---|
|
|
125
126
|
| 1.0.0 | every rule: `T1:name`, `T1:hub`, `R7:lines`, `T4:source-text` (a declared read, a raw import, a source path built from the test's place), `T6:timer`, `T6:clock`, `T6:outside`, `T6:env` |
|
|
126
|
-
| 1.0.1, 1.0.4, 1.0.5, 1.0.6 | none |
|
|
127
|
+
| 1.0.1, 1.0.4, 1.0.5, 1.0.6, 1.0.9 | none |
|
|
127
128
|
| 1.0.2 | `T4:source-text`: a path that starts with a dot-folder; a JSON register that is the whole file |
|
|
128
129
|
| 1.0.3 | `T4:source-text`: the built-path scan under a JSON register |
|
|
129
130
|
| 1.0.7 | `T4:pin`: a `pins` entry that does not hold (never listed, so never re-listed) |
|
|
131
|
+
| 1.0.8 | `T4:source-text`: a source path handed to a helper (the test's own function, or one it imports from the repo) at a parameter the helper reads the file system at |
|
|
130
132
|
|
|
131
133
|
## Versions
|
|
132
134
|
|
|
135
|
+
- **1.0.9** — Run, not read, under a JSON register: a register row the test also names as run
|
|
136
|
+
is not a `T4:source-text` breach. A test says what it runs on a `// runs:` line, as the
|
|
137
|
+
comment-form repos already do, or in the register file's runs map, named by
|
|
138
|
+
`"reads": { …, "runs": "<name>" }`. That covers a script the test runs, the migrations it
|
|
139
|
+
replays, or a checker's input paths (TESTING.md, 6 Nov 2026). A runs-map row also declares a
|
|
140
|
+
built path, as a `// runs:` line does. `**` covers nothing; a missing or malformed runs map
|
|
141
|
+
that the config names cannot run (exit 3). It only drops breaches: listed entries it clears come off
|
|
142
|
+
the list in the PR that bumps the pin.
|
|
143
|
+
- **1.0.8** — Reads made through a helper: a source path a test hands to a helper that reads
|
|
144
|
+
the file system at that argument is a `T4:source-text` read, as a `readFileSync` in the test
|
|
145
|
+
is. A helper is a function of the test's own or one the test imports by a relative path
|
|
146
|
+
(followed through re-exports and one more import). Only the arguments at the parameters that
|
|
147
|
+
reach the read are judged, so a path passed as data is not a read; a destructured parameter
|
|
148
|
+
and an aliased import (`@/…`) are not followed. A repo's first run of 1.0.8 can show breaches
|
|
149
|
+
in files nobody changed: the PR that bumps the pin re-lists them with `--relist`.
|
|
150
|
+
|
|
133
151
|
- **1.0.7** — Kept pins: the config's `pins` list registers each pin R4 allows, with the rule it
|
|
134
152
|
guards and its recorded mutation; a file on it is not a `T4:source-text` breach, and an entry
|
|
135
153
|
that does not hold is `T4:pin`. Nothing changes for a repo that does not set it; an older
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncerauk/test-rules",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.9",
|
|
4
4
|
"description": "The static check of Syncera's test-writing rules (T1, T4, T6 and the line limit of R7). One file, no dependency. Published to npm by the orchestrator's publish-test-rules workflow; each repo pins an exact version.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
package/test-rules.mjs
CHANGED
|
@@ -62,6 +62,11 @@
|
|
|
62
62
|
// being read for what it does read (1.0.3). A stylesheet named for a test in
|
|
63
63
|
// `style_contract` is not a breach in that test, and only in that test (1.0.4).
|
|
64
64
|
// A test on the config's `pins` list is a kept pin (R4) and not a breach (1.0.7).
|
|
65
|
+
// A source path the test hands to a helper of its own or of the repo's, at a
|
|
66
|
+
// parameter the helper reads the file system at, is a read too (1.0.8; below).
|
|
67
|
+
// A JSON register row the test also names as run (a `// runs:` line, or the
|
|
68
|
+
// register's `runs` map) is run, not read, and is not a breach; `**` covers
|
|
69
|
+
// nothing (1.0.9).
|
|
65
70
|
// T4:pin a `pins` entry that does not hold: no test file, or one that is not a test
|
|
66
71
|
// file here; no rule it guards, or a rule another entry already pins (one pin
|
|
67
72
|
// per rule); no recorded mutation, or no record of its red-before (1.0.7)
|
|
@@ -102,7 +107,7 @@ export const CONFIG_DEFAULTS = Object.freeze({
|
|
|
102
107
|
wording: [], // the one list of what a person reads (a built page, a legal text): files, globs, folders
|
|
103
108
|
style_contract: {}, // { "<test file>": ["<.css/.scss path or glob>", …] }: the one test that may read those stylesheets
|
|
104
109
|
pins: [], // [{ file, rule, mutation, red_before }]: the kept pins (R4), one per rule, each with the mutation that turns it red
|
|
105
|
-
reads: null, // the reads register: null, "comment" (`// reads:` lines) or { json: "<path>", key: "tests" } (key null: the file itself is the map)
|
|
110
|
+
reads: null, // the reads register: null, "comment" (`// reads:` lines) or { json: "<path>", key: "tests", runs: "runs" } (key null: the file itself is the map; runs, 1.0.9: the map of what each test only runs)
|
|
106
111
|
source_ext: ['mjs', 'cjs', 'js', 'jsx', 'ts', 'tsx', 'mts', 'cts', 'sql', 'css', 'scss', 'html', 'sh', 'yml', 'yaml', 'md', 'mdx'],
|
|
107
112
|
max_lines: 1500,
|
|
108
113
|
task_names: ['(?:^|[^a-z0-9])w-?\\d+', '(?:^|[^a-z0-9])(?:fs|ops|trk|ci|app|leg|mig|bank|push|fun)-\\d+', '(?:^|[^a-z0-9])s\\d+(?:[^a-z0-9]|$)'],
|
|
@@ -133,7 +138,7 @@ export const NEVER_LISTED = Object.freeze(['T1:hub', 'T4:pin', 'list:grew']);
|
|
|
133
138
|
* how a breach is found adds a row here, with its own `since`, and keeps the old behaviour
|
|
134
139
|
* behind it. 1.0.1 (the same file as 1.0.0), 1.0.4 (`style_contract`: it narrows T4 and finds
|
|
135
140
|
* nothing new), 1.0.5 (`moved`) and 1.0.6 (the re-list) add none. 1.0.7 adds T4:pin, which is
|
|
136
|
-
* judged on the config and never listed, so a re-list never carries it.
|
|
141
|
+
* judged on the config and never listed, so a re-list never carries it. 1.0.8 adds T4:helper.
|
|
137
142
|
*/
|
|
138
143
|
export const DETECTORS = Object.freeze({
|
|
139
144
|
'T1:name': { rule: 'T1:name', since: '1.0.0' }, // the file named by task
|
|
@@ -146,6 +151,7 @@ export const DETECTORS = Object.freeze({
|
|
|
146
151
|
'T4:whole-register': { rule: 'T4:source-text', since: '1.0.2' }, // a JSON register that is the whole file ("key": null) is read, not taken as empty
|
|
147
152
|
'T4:built-under-json': { rule: 'T4:source-text', since: '1.0.3' }, // the built-path scan runs under a JSON register too
|
|
148
153
|
'T4:pin': { rule: 'T4:pin', since: '1.0.7' }, // a `pins` entry that does not hold (judged on the config, never listed)
|
|
154
|
+
'T4:helper': { rule: 'T4:source-text', since: '1.0.8' }, // a source path handed to a helper of the repo's own that reads at its argument
|
|
149
155
|
'T6:timer': { rule: 'T6:timer', since: '1.0.0' },
|
|
150
156
|
'T6:clock': { rule: 'T6:clock', since: '1.0.0' },
|
|
151
157
|
'T6:outside': { rule: 'T6:outside', since: '1.0.0' },
|
|
@@ -338,8 +344,272 @@ function envLine({ code, bare }, allowed) {
|
|
|
338
344
|
return 0;
|
|
339
345
|
}
|
|
340
346
|
|
|
347
|
+
// ---------------------------------------------------------------------------------------------
|
|
348
|
+
// Reads made through a helper (1.0.8; Bernardo Cunha, 6 Nov 2026, card s7). A test that hands a
|
|
349
|
+
// source path to a helper of the repo's own reads that source as surely as a readFileSync of its
|
|
350
|
+
// own. A helper is a function of the test's own, or one exported by a tracked file the test
|
|
351
|
+
// imports by a relative path (`import`, `require`, followed through `export … from`), with a
|
|
352
|
+
// parameter that reaches a read of the file system: `readFileSync(join(ROOT, rel))`, directly,
|
|
353
|
+
// through a local variable, or through another such function, in that file or one it imports.
|
|
354
|
+
// Only the arguments at those parameters are judged, so a path handed as data (an option, a name
|
|
355
|
+
// in a list) is not a read. A parameter that is destructured is not followed. Aliased imports
|
|
356
|
+
// (`@/…`) and packages are not followed. A path handed over is the repo path it names, else the
|
|
357
|
+
// first tracked file it names from the helper's folder or one above it, then the test's.
|
|
358
|
+
// `readText(path)` gives a tracked file's text, or null.
|
|
359
|
+
// ---------------------------------------------------------------------------------------------
|
|
360
|
+
const FS_READ_CALL = /(?<![\w$])(?:[\w$]+\.)?(?:readFileSync|readFile|createReadStream|readdirSync|readdir|opendirSync)\s*\(/g;
|
|
361
|
+
const IMPORT_FROM = /\bimport\s+(?!type\b)([\w$*{}\s,]+?)\s*\bfrom\s*(['"])([^'"\n]+)\2/g;
|
|
362
|
+
const REQUIRE_OF = /\b(?:const|let|var)\s+([\w$]+|\{[^}]*\})\s*=\s*require\s*\(\s*(['"])([^'"\n]+)\2\s*\)/g;
|
|
363
|
+
const EXPORT_FROM = /\bexport\s+(\*|\{[^}]*\})\s*from\s*(['"])([^'"\n]+)\2/g;
|
|
364
|
+
const MODULE_EXT = ['', '.mjs', '.js', '.ts', '.tsx', '.mts', '.cts', '.cjs', '.jsx', '/index.mjs', '/index.js', '/index.ts', '/index.tsx'];
|
|
365
|
+
const HELPER_DEPTH = 2; // the test's helper, and the helpers that helper imports
|
|
366
|
+
|
|
367
|
+
/** The tracked file a relative import names (`./x.js` may be x.ts), or null: a package, an alias, a file not here. */
|
|
368
|
+
export function resolveImport(from, spec, tracked) {
|
|
369
|
+
if (!/^\.\.?\//.test(spec)) return null;
|
|
370
|
+
const p = posix.normalize(posix.join(posix.dirname(from), spec));
|
|
371
|
+
for (const stem of [p, p.replace(/\.[cm]?jsx?$/, '')]) for (const ext of MODULE_EXT) if (tracked.has(stem + ext)) return stem + ext;
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** The index just past the bracket that closes the one at `open` in blanked code, or the end. */
|
|
376
|
+
function closing(code, open) {
|
|
377
|
+
let depth = 0;
|
|
378
|
+
for (let k = open; k < code.length; k++) {
|
|
379
|
+
const c = code[k];
|
|
380
|
+
if (c === '(' || c === '[' || c === '{') depth++;
|
|
381
|
+
else if ((c === ')' || c === ']' || c === '}') && --depth === 0) return k + 1;
|
|
382
|
+
}
|
|
383
|
+
return code.length;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** The spans [from, to) of each argument of the call whose `(` is at `open`, in blanked code. */
|
|
387
|
+
function argSpans(code, open) {
|
|
388
|
+
const end = closing(code, open) - 1;
|
|
389
|
+
const spans = [];
|
|
390
|
+
let depth = 0;
|
|
391
|
+
let from = open + 1;
|
|
392
|
+
for (let k = open + 1; k < end; k++) {
|
|
393
|
+
const c = code[k];
|
|
394
|
+
if ('([{'.includes(c)) depth++;
|
|
395
|
+
else if (')]}'.includes(c)) depth--;
|
|
396
|
+
else if (c === ',' && depth === 0) { spans.push([from, k]); from = k + 1; }
|
|
397
|
+
}
|
|
398
|
+
if (code.slice(from, end).trim()) spans.push([from, end]);
|
|
399
|
+
return spans;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** { local name: exported name } of an import clause; a namespace (`* as ns`, a whole `require`) is '*'. */
|
|
403
|
+
function bindingsOf(clause, required = false) {
|
|
404
|
+
const out = {};
|
|
405
|
+
const named = /\{([^}]*)\}/.exec(clause);
|
|
406
|
+
for (const part of named ? named[1].split(',') : []) {
|
|
407
|
+
const m = /^\s*(?:type\s+)?([\w$]+)(?:\s*(?:\bas\b|:)\s*([\w$]+))?\s*$/.exec(part);
|
|
408
|
+
if (m) out[m[2] ?? m[1]] = m[1];
|
|
409
|
+
}
|
|
410
|
+
const rest = clause.replace(/\{[^}]*\}/, '');
|
|
411
|
+
const ns = /\*\s*as\s+([\w$]+)/.exec(rest);
|
|
412
|
+
if (ns) out[ns[1]] = '*';
|
|
413
|
+
else { const def = /^\s*([\w$]+)/.exec(rest); if (def) out[def[1]] = required ? '*' : 'default'; }
|
|
414
|
+
return out;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** The relative imports of a file's code: [{ target, bindings }], `target` the tracked file. */
|
|
418
|
+
function importsOf(path, { code, bare }, tracked) {
|
|
419
|
+
const out = [];
|
|
420
|
+
for (const [re, required] of [[IMPORT_FROM, false], [REQUIRE_OF, true]]) {
|
|
421
|
+
for (const m of bare.matchAll(re)) {
|
|
422
|
+
if (code[m.index] === ' ') continue; // inside a string
|
|
423
|
+
const target = resolveImport(path, m[3], tracked);
|
|
424
|
+
if (target) out.push({ target, bindings: bindingsOf(m[1], required) });
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return out;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** The name at each place of a parameter list, null where it is destructured or a rest: `(rel: string, { a } = {}, ...r)` → rel, null, null. */
|
|
431
|
+
function paramNames(text) {
|
|
432
|
+
const parts = [];
|
|
433
|
+
let depth = 0;
|
|
434
|
+
let cur = '';
|
|
435
|
+
for (const c of text) {
|
|
436
|
+
if ('([{<'.includes(c)) depth++;
|
|
437
|
+
else if (')]}'.includes(c) || (c === '>' && !cur.endsWith('='))) depth--;
|
|
438
|
+
if (c === ',' && depth === 0) { parts.push(cur); cur = ''; } else cur += c;
|
|
439
|
+
}
|
|
440
|
+
if (cur.trim() || parts.length) parts.push(cur);
|
|
441
|
+
return parts.map((part) => /^\s*([A-Za-z_$][\w$]*)\s*\??\s*(?:[:=]|$)/.exec(part)?.[1] ?? null);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** The functions a file defines, at any depth: [{ name, params, from, to }], `from`/`to` the body. */
|
|
445
|
+
function functionsOf(code) {
|
|
446
|
+
const out = [];
|
|
447
|
+
const DEF = /\bfunction\s*\*?\s*([\w$]+)\s*(?:<[^>()]*>)?\s*\(|\b(?:const|let|var)\s+([\w$]+)\s*(?::[^=;]+)?=\s*(?:async\s+)?(?:function\b\s*\*?\s*[\w$]*\s*)?(?:<[^>()]*>)?\s*(\(|[\w$]+\s*=>)/g;
|
|
448
|
+
for (const m of code.matchAll(DEF)) {
|
|
449
|
+
const named = Boolean(m[1]) || /\bfunction\b/.test(m[0]);
|
|
450
|
+
let params;
|
|
451
|
+
let after;
|
|
452
|
+
if (m[3] && m[3] !== '(') {
|
|
453
|
+
params = [m[3].replace(/\s*=>$/, '')];
|
|
454
|
+
after = m.index + m[0].length;
|
|
455
|
+
} else {
|
|
456
|
+
const open = m.index + m[0].length - 1;
|
|
457
|
+
after = closing(code, open);
|
|
458
|
+
params = paramNames(code.slice(open + 1, after - 1));
|
|
459
|
+
const arrow = /^\s*(?::[^={]+)?=>\s*/.exec(code.slice(after));
|
|
460
|
+
if (!named && !arrow) continue; // `const x = (a, b)`: a value, not a function
|
|
461
|
+
if (arrow) after += arrow[0].length;
|
|
462
|
+
}
|
|
463
|
+
let from = after;
|
|
464
|
+
while (from < code.length && /\s/.test(code[from])) from++;
|
|
465
|
+
if (!named && code[from] !== '{') {
|
|
466
|
+
// An arrow with an expression body: to the end of the statement.
|
|
467
|
+
let depth = 0;
|
|
468
|
+
let k = from;
|
|
469
|
+
for (; k < code.length; k++) {
|
|
470
|
+
const c = code[k];
|
|
471
|
+
if ('([{'.includes(c)) depth++;
|
|
472
|
+
else if (')]}'.includes(c)) { if (depth === 0) break; depth--; } else if ((c === ';' || c === '\n' || c === ',') && depth === 0) break;
|
|
473
|
+
}
|
|
474
|
+
out.push({ name: m[1] ?? m[2], params, from, to: k });
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
const open = code.indexOf('{', after);
|
|
478
|
+
if (open >= 0) out.push({ name: m[1] ?? m[2], params, from: open, to: closing(code, open) });
|
|
479
|
+
}
|
|
480
|
+
return out;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* The places of `fn`'s parameters that reach a read of the file system: a Set of indexes.
|
|
485
|
+
* `callees`: { local name: Set of the places at which it reads }; `nsCallees`: { namespace: that map }.
|
|
486
|
+
*/
|
|
487
|
+
function placesRead(fn, { code, bare }, callees, nsCallees) {
|
|
488
|
+
const body = code.slice(fn.from, fn.to);
|
|
489
|
+
const raw = bare.slice(fn.from, fn.to);
|
|
490
|
+
const taint = new Map(fn.params.flatMap((p, i) => (p ? [[p, new Set([i])]] : [])));
|
|
491
|
+
const out = new Set();
|
|
492
|
+
if (!taint.size) return out;
|
|
493
|
+
// The places a span of the body carries: by a name in the code, or inside a template's `${…}`.
|
|
494
|
+
const named = (p) => `(?<![\\w$.])${p.replace(/\$/g, '\\$')}(?![\\w$])`;
|
|
495
|
+
const carried = (from, to) => {
|
|
496
|
+
const got = new Set();
|
|
497
|
+
for (const [p, places] of taint) {
|
|
498
|
+
if (new RegExp(named(p)).test(body.slice(from, to)) || new RegExp(`\\$\\{[^}]*${named(p)}`).test(raw.slice(from, to))) for (const i of places) got.add(i);
|
|
499
|
+
}
|
|
500
|
+
return got;
|
|
501
|
+
};
|
|
502
|
+
for (let round = 0; round < 3; round++) {
|
|
503
|
+
for (const a of body.matchAll(/\b(?:const|let|var)\s+([\w$]+)\s*(?::[^=;]+)?=\s*([^;\n]*)/g)) {
|
|
504
|
+
const from = a.index + a[0].length - a[2].length;
|
|
505
|
+
const got = carried(from, from + a[2].length);
|
|
506
|
+
if (got.size) taint.set(a[1], new Set([...(taint.get(a[1]) ?? []), ...got]));
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
const reach = (open, places) => {
|
|
510
|
+
const spans = argSpans(body, open);
|
|
511
|
+
for (const [k, span] of spans.entries()) if (places === null || places.has(k)) for (const i of carried(...span)) out.add(i);
|
|
512
|
+
};
|
|
513
|
+
for (const c of body.matchAll(FS_READ_CALL)) reach(c.index + c[0].length - 1, null);
|
|
514
|
+
for (const c of body.matchAll(/(?<![\w$.])([\w$]+)\s*\(/g)) if (callees.has(c[1])) reach(c.index + c[0].length - 1, callees.get(c[1]));
|
|
515
|
+
for (const c of body.matchAll(/(?<![\w$.])([\w$]+)\s*\.\s*([\w$]+)\s*\(/g)) {
|
|
516
|
+
const places = nsCallees.get(c[1])?.get(c[2]);
|
|
517
|
+
if (places) reach(c.index + c[0].length - 1, places);
|
|
518
|
+
}
|
|
519
|
+
return out;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* The functions a file can call that read at a parameter, its own and those it imports (at
|
|
524
|
+
* `depth`): { reading: { local name: Set of places }, nsCallees: { namespace: that map }, origin:
|
|
525
|
+
* { local name or namespace: the file that defines it } }.
|
|
526
|
+
*/
|
|
527
|
+
function localReaders(path, view, ctx, depth) {
|
|
528
|
+
const reading = new Map();
|
|
529
|
+
const nsCallees = new Map();
|
|
530
|
+
const origin = new Map();
|
|
531
|
+
for (const imp of importsOf(path, view, ctx.tracked)) {
|
|
532
|
+
const theirs = readersOf(imp.target, ctx, depth);
|
|
533
|
+
for (const [local, name] of Object.entries(imp.bindings)) {
|
|
534
|
+
if (name === '*' ? !theirs.size : !theirs.has(name)) continue;
|
|
535
|
+
if (name === '*') nsCallees.set(local, theirs); else reading.set(local, theirs.get(name));
|
|
536
|
+
origin.set(local, imp.target);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
if (!reading.size && !nsCallees.size && !FS_READ.test(view.code)) return { reading, nsCallees, origin }; // nothing here can read
|
|
540
|
+
const fns = functionsOf(view.code);
|
|
541
|
+
for (let grew = true; grew;) {
|
|
542
|
+
grew = false;
|
|
543
|
+
for (const fn of fns) {
|
|
544
|
+
const places = placesRead(fn, view, reading, nsCallees);
|
|
545
|
+
const had = reading.get(fn.name);
|
|
546
|
+
if (places.size && (!had || [...places].some((i) => !had.has(i)))) {
|
|
547
|
+
reading.set(fn.name, new Set([...(had ?? []), ...places]));
|
|
548
|
+
origin.set(fn.name, path);
|
|
549
|
+
grew = true;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return { reading, nsCallees, origin };
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** The exported functions of `path` that read at a parameter: { exported name ('default' for a default export): Set of places }. */
|
|
557
|
+
function readersOf(path, ctx, depth = 0) {
|
|
558
|
+
const key = `${depth}\n${path}`;
|
|
559
|
+
if (ctx.memo.has(key)) return ctx.memo.get(key);
|
|
560
|
+
const out = new Map();
|
|
561
|
+
ctx.memo.set(key, out); // a cycle reads as nothing
|
|
562
|
+
const text = depth >= HELPER_DEPTH ? null : ctx.readText(path);
|
|
563
|
+
if (text == null) return out;
|
|
564
|
+
const view = scan(text);
|
|
565
|
+
const { code, bare } = view;
|
|
566
|
+
const { reading } = localReaders(path, view, ctx, depth + 1);
|
|
567
|
+
const add = (as, name) => { if (reading.has(name)) out.set(as, reading.get(name)); };
|
|
568
|
+
for (const m of code.matchAll(/\bexport\s+(?:async\s+)?(?:function\s*\*?|const|let|var)\s*([\w$]+)/g)) add(m[1], m[1]);
|
|
569
|
+
for (const m of code.matchAll(/\bexport\s+default\s+(?:async\s+)?(?:function\s*\*?\s*)?([\w$]+)/g)) add('default', m[1]);
|
|
570
|
+
for (const m of bare.matchAll(/\bexport\s*\{([^}]*)\}(?!\s*from)/g)) for (const [as, name] of Object.entries(bindingsOf(`{${m[1]}}`))) add(as, name);
|
|
571
|
+
for (const m of bare.matchAll(/\b(?:module\.)?exports\s*(?:\.\s*([\w$]+)\s*=\s*([\w$]+)|=\s*\{([^}]*)\})/g)) {
|
|
572
|
+
if (m[1]) add(m[1], m[2]);
|
|
573
|
+
for (const part of (m[3] ?? '').split(',')) {
|
|
574
|
+
const p = /^\s*([\w$]+)(?:\s*:\s*([\w$]+))?\s*$/.exec(part);
|
|
575
|
+
if (p) add(p[1], p[2] ?? p[1]);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
for (const m of bare.matchAll(EXPORT_FROM)) {
|
|
579
|
+
if (code[m.index] === ' ') continue;
|
|
580
|
+
const target = resolveImport(path, m[3], ctx.tracked);
|
|
581
|
+
const theirs = target ? readersOf(target, ctx, depth + 1) : new Map();
|
|
582
|
+
if (m[1] === '*') { for (const [n, places] of theirs) if (n !== 'default') out.set(n, places); continue; }
|
|
583
|
+
for (const [as, name] of Object.entries(bindingsOf(m[1]))) if (theirs.has(name)) out.set(as, theirs.get(name));
|
|
584
|
+
}
|
|
585
|
+
return out;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* The arguments a test hands to a helper at a place it reads: [{ helper, from, to }], spans of
|
|
590
|
+
* the test's code. A helper is imported, or a function of the test's own.
|
|
591
|
+
*/
|
|
592
|
+
function helperArgsOf(path, view, ctx) {
|
|
593
|
+
const out = [];
|
|
594
|
+
const { code } = view;
|
|
595
|
+
const { reading, nsCallees, origin } = localReaders(path, view, ctx, 0);
|
|
596
|
+
const take = (places, helper, open) => {
|
|
597
|
+
for (const [k, [from, to]] of argSpans(code, open).entries()) if (places.has(k)) out.push({ helper, from, to });
|
|
598
|
+
};
|
|
599
|
+
for (const c of code.matchAll(/(?<![\w$.])([\w$]+)\s*\(/g)) {
|
|
600
|
+
if (!reading.has(c[1]) || /\bfunction\s*\*?\s*$/.test(code.slice(Math.max(0, c.index - 12), c.index))) continue; // its definition
|
|
601
|
+
take(reading.get(c[1]), origin.get(c[1]), c.index + c[0].length - 1);
|
|
602
|
+
}
|
|
603
|
+
for (const c of code.matchAll(/(?<![\w$.])([\w$]+)\s*\.\s*([\w$]+)\s*\(/g)) {
|
|
604
|
+
const places = nsCallees.get(c[1])?.get(c[2]);
|
|
605
|
+
if (places) take(places, origin.get(c[1]), c.index + c[0].length - 1);
|
|
606
|
+
}
|
|
607
|
+
return out;
|
|
608
|
+
}
|
|
609
|
+
|
|
341
610
|
/** T4: the line of the first read of a source file as text, or 0. */
|
|
342
|
-
function sourceTextLine(
|
|
611
|
+
function sourceTextLine(view, { config, register, runs = null, tracked, as, helpers }) {
|
|
612
|
+
const { path, text, code, bare, strings } = view;
|
|
343
613
|
const on = (id) => detects(id, as);
|
|
344
614
|
const isSource = (p) => config.source_ext.includes(String(p).split('.').pop().toLowerCase());
|
|
345
615
|
// A style contract is the one test that may read the stylesheets it names: style rules are
|
|
@@ -350,8 +620,15 @@ function sourceTextLine({ path, text, code, bare, strings }, { config, register,
|
|
|
350
620
|
|
|
351
621
|
const declared = declaredIn(text);
|
|
352
622
|
const json = register && typeof register === 'object';
|
|
623
|
+
const wide = (p) => /^\*\*\/?$/.test(p); // `**`: the whole repo is never "run"
|
|
624
|
+
const mapped = (runs?.[path] ?? []).filter((p) => !wide(p));
|
|
625
|
+
const ran = [...declared.runs.map((r) => r.pattern).filter((p) => !wide(p)), ...mapped];
|
|
353
626
|
if (on('T4:declared') && json) {
|
|
354
|
-
|
|
627
|
+
// A register row the test also names as run, on a `// runs:` line or in the register's runs
|
|
628
|
+
// map, is run, not read as text (1.0.9): a script it runs, migrations it replays, or a
|
|
629
|
+
// checker's inputs (TESTING.md, 6 Nov), never the whole repo.
|
|
630
|
+
const run = (p) => ran.some((q) => q === p || globRe(q).test(p));
|
|
631
|
+
if ((register[path] ?? []).some((p) => offends(p) && !run(p))) return 1;
|
|
355
632
|
} else if (on('T4:declared') && config.reads === 'comment') {
|
|
356
633
|
const hit = declared.reads.find((r) => offends(r.pattern));
|
|
357
634
|
if (hit) return hit.line;
|
|
@@ -367,24 +644,59 @@ function sourceTextLine({ path, text, code, bare, strings }, { config, register,
|
|
|
367
644
|
// A source file whose path the test builds from its own place, in a test that reads the file
|
|
368
645
|
// system, that the test does not declare: `// runs:` when it is only run. A JSON register does
|
|
369
646
|
// not silence this (1.0.3): a register can miss a read, and a source file it names returned above.
|
|
370
|
-
if (!
|
|
371
|
-
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
const before = code.slice(code.lastIndexOf('\n', s.start) + 1, s.start - 1);
|
|
377
|
-
if (IMPORT_BEFORE.test(before) || !BUILT_HERE.test(before)) continue;
|
|
378
|
-
// `./x` and `../x` are relative to the test; `.github/x` is a repo path like any other (1.0.2;
|
|
379
|
-
// before it, any leading dot was read as beside the test).
|
|
380
|
-
const here = posix.normalize(posix.join(posix.dirname(path), v));
|
|
647
|
+
if (!tracked) return 0;
|
|
648
|
+
const said = [...[...declared.reads, ...declared.runs].map((r) => r.pattern), ...mapped];
|
|
649
|
+
const breach = (target) => target !== path && tracked.has(target) && !allowed(target) && !said.some((p) => globRe(p).test(target));
|
|
650
|
+
// `./x` and `../x` are relative to the test; `.github/x` is a repo path like any other (1.0.2;
|
|
651
|
+
// before it, any leading dot was read as beside the test).
|
|
652
|
+
const builtTarget = (v) => {
|
|
381
653
|
const besideTest = on('T4:dot-folder') ? v.startsWith('./') || v.startsWith('../') : v.startsWith('.');
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
654
|
+
return besideTest || !tracked.has(v) ? posix.normalize(posix.join(posix.dirname(path), v)) : v;
|
|
655
|
+
};
|
|
656
|
+
const builtBefore = (start) => {
|
|
657
|
+
const before = code.slice(code.lastIndexOf('\n', start) + 1, start - 1);
|
|
658
|
+
return !IMPORT_BEFORE.test(before) && BUILT_HERE.test(before);
|
|
659
|
+
};
|
|
660
|
+
const lines = [];
|
|
661
|
+
if (on('T4:built') && !(json && !on('T4:built-under-json')) && FS_READ.test(code)) {
|
|
662
|
+
for (const s of strings) {
|
|
663
|
+
const v = s.value;
|
|
664
|
+
if (/[\s*?$]/.test(v) || !isSource(v) || !builtBefore(s.start)) continue;
|
|
665
|
+
if (breach(builtTarget(v))) { lines.push(lineOf(code, s.start)); break; }
|
|
666
|
+
}
|
|
386
667
|
}
|
|
387
|
-
|
|
668
|
+
// A source path handed to a helper at a place it reads (1.0.8): a string in that argument, or a
|
|
669
|
+
// constant of the test holding one. Built from the test's place, it is read as above; else the
|
|
670
|
+
// helper may read it from the repo root, beside itself or beside the test.
|
|
671
|
+
const handed = on('T4:helper') && helpers ? helperArgsOf(path, view, helpers) : [];
|
|
672
|
+
if (handed.length) {
|
|
673
|
+
const consts = new Map();
|
|
674
|
+
for (const s of strings) {
|
|
675
|
+
const name = /\b(?:const|let|var)\s+([\w$]+)\s*=\s*$/.exec(code.slice(code.lastIndexOf('\n', s.start) + 1, s.start - 1));
|
|
676
|
+
if (name) consts.set(name[1], s);
|
|
677
|
+
}
|
|
678
|
+
const targetOf = (s, helper) => {
|
|
679
|
+
const v = s.value;
|
|
680
|
+
if (/[\s*?$]/.test(v) || !isSource(v)) return null;
|
|
681
|
+
if (builtBefore(s.start)) return builtTarget(v);
|
|
682
|
+
const from = (file) => posix.normalize(posix.join(posix.dirname(file), v));
|
|
683
|
+
// A repo path, else beside the helper or a folder above it, then the same from the test.
|
|
684
|
+
const above = (file) => { const dirs = []; for (let d = posix.dirname(file); d !== '.'; d = posix.dirname(d)) dirs.push(posix.join(d, v)); return dirs; };
|
|
685
|
+
const tries = /^\.\.?\//.test(v) ? [from(path), from(helper)] : [v, ...above(helper), ...above(path)];
|
|
686
|
+
return tries.find((t) => tracked.has(t)) ?? null;
|
|
687
|
+
};
|
|
688
|
+
for (const arg of handed) {
|
|
689
|
+
const found = [
|
|
690
|
+
...strings.filter((s) => s.start > arg.from && s.start < arg.to).map((s) => ({ s, at: s.start })),
|
|
691
|
+
...[...code.slice(arg.from, arg.to).matchAll(/(?<![\w$.])([A-Za-z_$][\w$]*)/g)].filter((m) => consts.has(m[1])).map((m) => ({ s: consts.get(m[1]), at: arg.from + m.index })),
|
|
692
|
+
];
|
|
693
|
+
for (const { s, at } of found) {
|
|
694
|
+
const target = targetOf(s, arg.helper);
|
|
695
|
+
if (target && breach(target)) lines.push(lineOf(code, at));
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
return lines.length ? Math.min(...lines) : 0;
|
|
388
700
|
}
|
|
389
701
|
|
|
390
702
|
/**
|
|
@@ -422,7 +734,7 @@ export function pinsOf(pins, testPaths) {
|
|
|
422
734
|
* its first line: [{ file, rule, line, say }], sorted. Pure: no disk, no git. `as`: a version
|
|
423
735
|
* (x.y.z) to run only the detectors it had (DETECTORS), for the re-list; null runs them all.
|
|
424
736
|
*/
|
|
425
|
-
export function breachesOf({ files, config, register = null, tracked = null, as = null }) {
|
|
737
|
+
export function breachesOf({ files, config, register = null, runs = null, tracked = null, as = null, readText = null }) {
|
|
426
738
|
const out = [];
|
|
427
739
|
const add = (file, rule, line) => out.push({ file, rule, line, say: RULES[rule] });
|
|
428
740
|
const on = (id) => detects(id, as);
|
|
@@ -430,13 +742,15 @@ export function breachesOf({ files, config, register = null, tracked = null, as
|
|
|
430
742
|
const fakes = config.fake_timers.map((s) => new RegExp(s));
|
|
431
743
|
// A kept pin is not a T4 breach; an entry that does not hold is one T4:pin on the config.
|
|
432
744
|
const pins = pinsOf(config.pins, new Set(files.map((f) => f.path)));
|
|
745
|
+
// The repo's own helpers, read once each (1.0.8): only with a reader and the tracked files.
|
|
746
|
+
const helpers = readText && tracked ? { readText, tracked, memo: new Map() } : null;
|
|
433
747
|
if (on('T4:pin') && pins.problems.length) out.push({ file: CONFIG_FILE, rule: 'T4:pin', line: 1, say: `${RULES['T4:pin']} (${pins.problems.join(' · ')})` });
|
|
434
748
|
for (const { path, text } of files) {
|
|
435
749
|
const view = { path, text, ...scan(text) };
|
|
436
750
|
const base = path.split('/').pop();
|
|
437
751
|
if (on('T1:name') && names.some((re) => re.test(base))) add(path, 'T1:name', 1);
|
|
438
752
|
if (on('R7:lines') && countLines(text) > config.max_lines) add(path, 'R7:lines', config.max_lines + 1);
|
|
439
|
-
const t4 = sourceTextLine(view, { config, register, tracked, as });
|
|
753
|
+
const t4 = sourceTextLine(view, { config, register, runs, tracked, as, helpers });
|
|
440
754
|
if (t4 && !pins.pinned.has(path)) add(path, 'T4:source-text', t4);
|
|
441
755
|
const faked = fakes.some((re) => re.test(view.bare));
|
|
442
756
|
const timer = faked || !on('T6:timer') ? 0 : timerLine(view);
|
|
@@ -574,8 +888,9 @@ export function withDefaults(raw) {
|
|
|
574
888
|
}
|
|
575
889
|
const r = config.reads;
|
|
576
890
|
const json = r && typeof r === 'object' && !Array.isArray(r) && typeof r.json === 'string'
|
|
577
|
-
&& Object.keys(r).every((k) => k === 'json' || k === 'key') && (r.key === undefined || r.key === null || typeof r.key === 'string')
|
|
578
|
-
|
|
891
|
+
&& Object.keys(r).every((k) => k === 'json' || k === 'key' || k === 'runs') && (r.key === undefined || r.key === null || typeof r.key === 'string')
|
|
892
|
+
&& (r.runs === undefined || (typeof r.runs === 'string' && r.runs !== ''));
|
|
893
|
+
if (!(r === null || r === 'comment' || json)) throw fail(EXIT.FAILED, `${CONFIG_FILE}: reads must be null, "comment" or { "json": "<path>", "key": "<name>" | null, "runs": "<name>" (optional) }`);
|
|
579
894
|
return config;
|
|
580
895
|
}
|
|
581
896
|
|
|
@@ -595,6 +910,18 @@ export function registerOf(file, reads, as = null) {
|
|
|
595
910
|
return map;
|
|
596
911
|
}
|
|
597
912
|
|
|
913
|
+
/**
|
|
914
|
+
* The runs map beside the reads register (1.0.9), when `reads.runs` names one: what each test only
|
|
915
|
+
* runs (scripts, migrations it replays, a checker's inputs). No map there cannot run (exit 3).
|
|
916
|
+
*/
|
|
917
|
+
export function runsOf(file, reads) {
|
|
918
|
+
if (!reads || typeof reads !== 'object' || reads.runs === undefined) return null;
|
|
919
|
+
const map = file?.[reads.runs];
|
|
920
|
+
const isMap = map && typeof map === 'object' && !Array.isArray(map) && Object.values(map).every((v) => Array.isArray(v));
|
|
921
|
+
if (!isMap) throw fail(EXIT.FAILED, `${reads.json}: no "${reads.runs}" map of { "<test file>": ["<pattern>", …] } (named by "runs")`);
|
|
922
|
+
return map;
|
|
923
|
+
}
|
|
924
|
+
|
|
598
925
|
// ---------------------------------------------------------------------------------------------
|
|
599
926
|
// The command line: the only part that touches disk and git.
|
|
600
927
|
// ---------------------------------------------------------------------------------------------
|
|
@@ -700,7 +1027,14 @@ export function main(argv = [], { log = console.log, error = console.error, runn
|
|
|
700
1027
|
const files = testPaths.map((path) => ({ path, text: readFileSync(join(dir, path), 'utf8') }));
|
|
701
1028
|
const registerFile = config.reads && typeof config.reads === 'object' ? readJson(dir, config.reads.json) : null;
|
|
702
1029
|
const register = registerFile ? registerOf(registerFile, config.reads) : null;
|
|
703
|
-
const
|
|
1030
|
+
const runs = registerFile ? runsOf(registerFile, config.reads) : null;
|
|
1031
|
+
// A helper a test imports is read from the worktree, once (1.0.8).
|
|
1032
|
+
const texts = new Map(files.map((f) => [f.path, f.text]));
|
|
1033
|
+
const readText = (rel) => {
|
|
1034
|
+
if (!texts.has(rel)) { try { texts.set(rel, tracked.has(rel) ? readFileSync(join(dir, rel), 'utf8') : null); } catch { texts.set(rel, null); } }
|
|
1035
|
+
return texts.get(rel);
|
|
1036
|
+
};
|
|
1037
|
+
const all = breachesOf({ files, config, register, runs, tracked, readText });
|
|
704
1038
|
|
|
705
1039
|
if (opts.init) {
|
|
706
1040
|
if (existsSync(join(dir, LISTED_FILE))) throw fail(EXIT.USAGE, `${LISTED_FILE} exists: the list is written once and only shrinks`);
|
|
@@ -750,7 +1084,7 @@ export function main(argv = [], { log = console.log, error = console.error, runn
|
|
|
750
1084
|
...git(dir, ['diff', '--name-only', '--no-renames', '-z', mb]).split('\0'),
|
|
751
1085
|
...git(dir, ['ls-files', '-o', '--exclude-standard', '-z']).split('\0'),
|
|
752
1086
|
].filter(Boolean));
|
|
753
|
-
const asBase = breachesOf({ files, config, register: registerFile ? registerOf(registerFile, config.reads, gate.from) : null, tracked, as: gate.from });
|
|
1087
|
+
const asBase = breachesOf({ files, config, register: registerFile ? registerOf(registerFile, config.reads, gate.from) : null, tracked, readText, as: gate.from });
|
|
754
1088
|
relist = { from: gate.from, at: gate.at, changed, allowed: relistable({ all, asBase, changed }) };
|
|
755
1089
|
baseNote += `; ${PACKAGE} ${gate.from} → ${gate.at}`;
|
|
756
1090
|
} else if (gate.from && gate.from !== gate.at) baseNote += `; no re-list (${gate.why})`;
|