@syncerauk/test-rules 0.0.0-stage → 1.0.1
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 +69 -2
- package/package.json +23 -4
- package/test-rules.mjs +505 -0
package/README.md
CHANGED
|
@@ -1,3 +1,70 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @syncerauk/test-rules
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The static check of Syncera's test-writing rules: T1, T4, T6 and the line limit of R7 (adopted
|
|
4
|
+
by Bernardo Cunha on 5 Nov 2026). One file, no dependency. It reads only the repo it runs in
|
|
5
|
+
and git.
|
|
6
|
+
|
|
7
|
+
## In a repo
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install --save-dev --save-exact @syncerauk/test-rules
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{ "scripts": { "gate:test-rules": "test-rules" } }
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm run gate:test-rules
|
|
19
|
+
npm run gate:test-rules -- --base origin/main --list
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
| Exit | Meaning |
|
|
23
|
+
|---|---|
|
|
24
|
+
| 0 | Clean, or the repo is still report only |
|
|
25
|
+
| 1 | A breach off the list, a stale entry, or the list grew |
|
|
26
|
+
| 2 | A bad command line |
|
|
27
|
+
| 3 | The check could not run (no config, unreadable JSON, not a git worktree) |
|
|
28
|
+
|
|
29
|
+
`--list` prints one JSON object: `{ v, breaches: [{ file, rule, line, say }], listed, stale }`.
|
|
30
|
+
|
|
31
|
+
## The rules
|
|
32
|
+
|
|
33
|
+
| Id | Breach |
|
|
34
|
+
|---|---|
|
|
35
|
+
| `T1:name` | A test file named by task |
|
|
36
|
+
| `T1:hub` | A file listed as over the line limit grew against the base |
|
|
37
|
+
| `R7:lines` | A test file over 1,500 lines |
|
|
38
|
+
| `T4:source-text` | A test reads a source file as text |
|
|
39
|
+
| `T6:timer` | A real wait, with no fake timers in the file |
|
|
40
|
+
| `T6:clock` | The wall clock, with no fake timers in the file |
|
|
41
|
+
| `T6:outside` | The home folder, a path that climbs out of the repo, a real host |
|
|
42
|
+
| `T6:env` | `process.env.<NAME>` read and never set in the file |
|
|
43
|
+
|
|
44
|
+
How each is seen is in the header of `test-rules.mjs`.
|
|
45
|
+
|
|
46
|
+
## The repo's two files
|
|
47
|
+
|
|
48
|
+
Both in the repo's `scripts/test-rules/`:
|
|
49
|
+
|
|
50
|
+
- `config.json`: what differs between repos. `fail` (false is report only), `tests` and
|
|
51
|
+
`ignore` (which files are test files), `fixtures` (folders a test may read), `wording` (the
|
|
52
|
+
one list of what a person reads), `reads` (the reads register: `"comment"` or
|
|
53
|
+
`{ "json": "<path>" }`). The defaults are `CONFIG_DEFAULTS` in the script.
|
|
54
|
+
- `listed.json`: the breaches the repo had on the day the check arrived. Written once by
|
|
55
|
+
`test-rules --init`.
|
|
56
|
+
|
|
57
|
+
## The list only shrinks
|
|
58
|
+
|
|
59
|
+
- A breach on the list passes. A breach off the list fails.
|
|
60
|
+
- An entry whose breach is gone is stale and fails until its line is deleted.
|
|
61
|
+
- Against a base, an entry the base's list does not hold fails. The base is `--base <ref>`,
|
|
62
|
+
else `origin/main` when it resolves.
|
|
63
|
+
|
|
64
|
+
A repo starts with `"fail": false`: breaches are printed and the exit is 0. One line turns it on.
|
|
65
|
+
|
|
66
|
+
## Releases
|
|
67
|
+
|
|
68
|
+
The source is `scripts/test-rules.mjs` in Syncera-UK/syncera-orchestrator, where its tests run
|
|
69
|
+
in the gate. A release is a version bump in this folder's `package.json`, merged, then the tag
|
|
70
|
+
`test-rules-v<version>` on `main`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncerauk/test-rules",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.1",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"license": "UNLICENSED",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Syncera-UK/syncera-orchestrator.git",
|
|
10
|
+
"directory": "scripts/test-rules-package"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"test-rules": "test-rules.mjs"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"test-rules.mjs",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=22"
|
|
24
|
+
}
|
|
25
|
+
}
|
package/test-rules.mjs
ADDED
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// =============================================================================
|
|
3
|
+
// test-rules.mjs — the static check of the test-writing rules (T7: T1, T4, T6 and the line half
|
|
4
|
+
// of R7; docs/test-review/2026-11-05/RULES.md, adopted by Bernardo Cunha on 5 Nov 2026).
|
|
5
|
+
//
|
|
6
|
+
// npm run gate:test-rules [-- --base <ref>] [--list]
|
|
7
|
+
// node scripts/test-rules.mjs [--dir <worktree>] [--base <ref> | --no-base] [--list] [--init]
|
|
8
|
+
//
|
|
9
|
+
// One implementation for the six repos (R8): one file, no dependency, no build; it imports
|
|
10
|
+
// nothing from this repo and reads only the worktree and git, so another repo can run this same
|
|
11
|
+
// file. Exit 0 clean, 1 a breach, 2 a bad command line, 3 the check itself could not run (no
|
|
12
|
+
// config, unreadable JSON, not a git worktree, a --base that does not resolve).
|
|
13
|
+
// `--list` prints one JSON object on one line and nothing else:
|
|
14
|
+
// { v: 1, breaches: [{ file, rule, line, say }], listed: <n>, stale: [{ file, rule }] }
|
|
15
|
+
// `breaches` are the breaches that are not on the list; `listed` counts the ones that are.
|
|
16
|
+
//
|
|
17
|
+
// Per repo, two files:
|
|
18
|
+
// scripts/test-rules/config.json what differs between repos (CONFIG_DEFAULTS below)
|
|
19
|
+
// scripts/test-rules/listed.json { v: 1, entries: { "<test file>": ["<rule id>", …] } }
|
|
20
|
+
//
|
|
21
|
+
// The list only shrinks. A breach on the list passes; a breach off the list fails; an entry
|
|
22
|
+
// whose breach is gone is `stale` and fails until its line is deleted; with a base, an entry the
|
|
23
|
+
// base's list does not hold fails (`list:grew`), and a file listed under R7:lines that has more
|
|
24
|
+
// lines than at the base fails (`T1:hub`). The base is `--base <ref>`, else origin/main when it
|
|
25
|
+
// resolves (the merge base with HEAD is what is read); `--no-base` skips those two checks. The
|
|
26
|
+
// base having no list at all is the one change that may add it (`--init` writes it once).
|
|
27
|
+
// `"fail": false` in the config is report only: everything is printed, the exit is 0.
|
|
28
|
+
//
|
|
29
|
+
// The rules (RULES below) and how each is seen:
|
|
30
|
+
// T1:name the file is named by task (w-184, fs-7-15, ops-3, s12)
|
|
31
|
+
// T1:hub a file listed under R7:lines grew against the base
|
|
32
|
+
// R7:lines more than `max_lines` lines (1,500). The 60 s half is tier 3's, not static.
|
|
33
|
+
// T4:source-text the test reads a source file as text: a read it declares (the repo's reads
|
|
34
|
+
// register) that is a source file or a folder and is neither under a fixture
|
|
35
|
+
// folder nor on the wording list; a `?raw` import or a raw import.meta.glob;
|
|
36
|
+
// and, where the register is comment lines or absent, a source file whose
|
|
37
|
+
// path the test builds from its own place (new URL(…, import.meta.url), or
|
|
38
|
+
// join( / resolve( on import.meta.dirname, __dirname, ROOT or HERE) in a test
|
|
39
|
+
// that reads the file system and declares it neither `// reads:` nor
|
|
40
|
+
// `// runs:` (a file that is only run is declared `// runs:`)
|
|
41
|
+
// T6:timer setTimeout( / setInterval( / sleep( / timers/promises with no fake timers in the file
|
|
42
|
+
// T6:clock Date.now( / new Date() / performance.now( with no fake timers in the file
|
|
43
|
+
// T6:outside homedir(), process.env.HOME, a relative path that climbs out of the repo,
|
|
44
|
+
// fetch( of a real host. A made-up absolute path handed to a pure function
|
|
45
|
+
// is not a read and is not judged.
|
|
46
|
+
// T6:env process.env.<NAME> read and never set in that file
|
|
47
|
+
// Comments are never read; code patterns are never matched inside a string.
|
|
48
|
+
// The config and the list are read by the command line only, and the check runs on every small
|
|
49
|
+
// gate, so no test declares them.
|
|
50
|
+
// =============================================================================
|
|
51
|
+
|
|
52
|
+
import { execFileSync } from 'node:child_process';
|
|
53
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
54
|
+
import { dirname, join, posix, resolve } from 'node:path';
|
|
55
|
+
import { fileURLToPath } from 'node:url';
|
|
56
|
+
|
|
57
|
+
export const VERSION = 1;
|
|
58
|
+
export const EXIT = Object.freeze({ CLEAN: 0, BREACH: 1, USAGE: 2, FAILED: 3 });
|
|
59
|
+
export const CONFIG_FILE = 'scripts/test-rules/config.json';
|
|
60
|
+
export const LISTED_FILE = 'scripts/test-rules/listed.json';
|
|
61
|
+
export const DEFAULT_BASE = 'origin/main';
|
|
62
|
+
const USAGE = 'usage: test-rules [--dir <worktree>] [--base <ref> | --no-base] [--list] [--init]';
|
|
63
|
+
|
|
64
|
+
/** What a repo's scripts/test-rules/config.json may set; anything it leaves out is this. */
|
|
65
|
+
export const CONFIG_DEFAULTS = Object.freeze({
|
|
66
|
+
v: VERSION,
|
|
67
|
+
fail: false, // report only until the repo's one-line PR sets it to true
|
|
68
|
+
tests: ['**/*.test.*', '**/*.spec.*'], // which tracked files are test files
|
|
69
|
+
ignore: [], // test files the rules do not judge (a real-time tier 3 suite)
|
|
70
|
+
fixtures: [], // folders a test may read: `a/fixtures/`
|
|
71
|
+
wording: [], // the one list of what a person reads (a built page, a legal text): files, globs, folders
|
|
72
|
+
reads: null, // the reads register: null, "comment" (`// reads:` lines) or { json: "<path>", key: "tests" }
|
|
73
|
+
source_ext: ['mjs', 'cjs', 'js', 'jsx', 'ts', 'tsx', 'mts', 'cts', 'sql', 'css', 'scss', 'html', 'sh', 'yml', 'yaml', 'md', 'mdx'],
|
|
74
|
+
max_lines: 1500,
|
|
75
|
+
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]|$)'],
|
|
76
|
+
fake_timers: ['useFakeTimers', 'mock\\.timers', '[fF]akeClock', '\\bclock\\.mjs\\b'],
|
|
77
|
+
env_allowed: ['PATH'], // process.env names a test may read without setting them (PATH: to find a binary)
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
/** Rule id → the one sentence that tells the builder what to do instead. */
|
|
81
|
+
export const RULES = Object.freeze({
|
|
82
|
+
'T1:name': 'name the test file by its topic, never by the task that added it',
|
|
83
|
+
'T1:hub': 'this file is listed as over the line limit and it grew: the new test goes in a new file, by topic',
|
|
84
|
+
'R7:lines': 'over the line limit: split the file by topic',
|
|
85
|
+
'T4:source-text': 'a test does not open a source file and search it: run the code and assert on what it did (wording is checked once, from the wording list)',
|
|
86
|
+
'T6:timer': 'no real wait in a gated test: use the fake clock or fake timers',
|
|
87
|
+
'T6:clock': 'no wall clock in a gated test: pass a fixed time in, or use fake timers',
|
|
88
|
+
'T6:outside': 'nothing outside the commit: no home folder, machine path, path out of the repo or real host',
|
|
89
|
+
'T6:env': 'the machine\'s environment is not part of the commit: set the variable in the test, or pass the value in',
|
|
90
|
+
'list:grew': 'the list only shrinks: fix the breach, do not list it',
|
|
91
|
+
});
|
|
92
|
+
/** The rules a listed.json entry can never excuse. */
|
|
93
|
+
export const NEVER_LISTED = Object.freeze(['T1:hub', 'list:grew']);
|
|
94
|
+
|
|
95
|
+
const fail = (code, message) => Object.assign(new Error(message), { exit: code });
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------------------------
|
|
98
|
+
// Globs: `*` within a path segment, `**` across segments, a trailing `/` is the folder and all
|
|
99
|
+
// under it.
|
|
100
|
+
// ---------------------------------------------------------------------------------------------
|
|
101
|
+
const reCache = new Map();
|
|
102
|
+
export function globRe(glob) {
|
|
103
|
+
if (reCache.has(glob)) return reCache.get(glob);
|
|
104
|
+
let g = String(glob);
|
|
105
|
+
if (g.endsWith('/')) g += '**';
|
|
106
|
+
let re = '';
|
|
107
|
+
for (let i = 0; i < g.length; i++) {
|
|
108
|
+
const c = g[i];
|
|
109
|
+
if (c === '*' && g[i + 1] === '*') {
|
|
110
|
+
if (g[i + 2] === '/') { re += '(?:.*/)?'; i += 2; } else { re += '.*'; i += 1; }
|
|
111
|
+
} else if (c === '*') re += '[^/]*';
|
|
112
|
+
else if (c === '?') re += '[^/]';
|
|
113
|
+
else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
114
|
+
}
|
|
115
|
+
const out = new RegExp(`^${re}$`);
|
|
116
|
+
reCache.set(glob, out);
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
export const matches = (globs, path) => (globs ?? []).some((g) => globRe(g).test(path));
|
|
120
|
+
/** The part of a pattern before its first wildcard: `api/**` → `api/`. */
|
|
121
|
+
const literalHead = (pattern) => String(pattern).split(/[*?]/)[0];
|
|
122
|
+
/** Whether a declared read (a path, a folder or a glob) lies wholly under one of `entries`. */
|
|
123
|
+
export function under(entries, pattern) {
|
|
124
|
+
const head = literalHead(pattern);
|
|
125
|
+
return (entries ?? []).some((e) => {
|
|
126
|
+
if (globRe(e).test(pattern)) return true;
|
|
127
|
+
const folder = String(e).endsWith('/') ? e : String(e).endsWith('/**') ? String(e).slice(0, -2) : null;
|
|
128
|
+
return folder ? head.startsWith(folder) : false;
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------------------------
|
|
133
|
+
// scan: one pass over a source text. `code` has comments and the inside of every string and
|
|
134
|
+
// regex blanked; `bare` has only comments blanked; both keep every offset and newline. `strings`
|
|
135
|
+
// lists each string literal's inside with its start offset.
|
|
136
|
+
// ---------------------------------------------------------------------------------------------
|
|
137
|
+
const REGEX_AFTER = new Set(['', '(', ',', '=', ':', '[', '!', '&', '|', '?', '{', '}', ';', '+', '-', '*', '%', '<', '>', '~', '^']);
|
|
138
|
+
const REGEX_AFTER_WORD = new Set(['return', 'typeof', 'case', 'do', 'else', 'in', 'of', 'void', 'delete', 'throw', 'new', 'yield', 'await']);
|
|
139
|
+
export function scan(text) {
|
|
140
|
+
const src = String(text);
|
|
141
|
+
const n = src.length;
|
|
142
|
+
const code = src.split('');
|
|
143
|
+
const bare = src.split('');
|
|
144
|
+
const strings = [];
|
|
145
|
+
const blank = (arr, a, b) => { for (let k = a; k < b; k++) if (arr[k] !== '\n') arr[k] = ' '; };
|
|
146
|
+
const regexMayStart = (i) => {
|
|
147
|
+
let k = i - 1;
|
|
148
|
+
while (k >= 0 && /\s/.test(code[k])) k--;
|
|
149
|
+
if (k < 0) return true;
|
|
150
|
+
if (/[\w$]/.test(code[k])) {
|
|
151
|
+
let s = k;
|
|
152
|
+
while (s > 0 && /[\w$]/.test(code[s - 1])) s--;
|
|
153
|
+
return REGEX_AFTER_WORD.has(code.slice(s, k + 1).join(''));
|
|
154
|
+
}
|
|
155
|
+
return REGEX_AFTER.has(code[k]);
|
|
156
|
+
};
|
|
157
|
+
let i = 0;
|
|
158
|
+
while (i < n) {
|
|
159
|
+
const c = src[i];
|
|
160
|
+
const d = src[i + 1];
|
|
161
|
+
if (c === '/' && d === '/') {
|
|
162
|
+
let j = src.indexOf('\n', i);
|
|
163
|
+
if (j < 0) j = n;
|
|
164
|
+
blank(code, i, j); blank(bare, i, j);
|
|
165
|
+
i = j;
|
|
166
|
+
} else if (c === '/' && d === '*') {
|
|
167
|
+
let j = src.indexOf('*/', i + 2);
|
|
168
|
+
j = j < 0 ? n : j + 2;
|
|
169
|
+
blank(code, i, j); blank(bare, i, j);
|
|
170
|
+
i = j;
|
|
171
|
+
} else if (c === '"' || c === "'" || c === '`') {
|
|
172
|
+
let j = i + 1;
|
|
173
|
+
while (j < n && src[j] !== c) {
|
|
174
|
+
if (src[j] === '\\') j++;
|
|
175
|
+
else if (c !== '`' && src[j] === '\n') break;
|
|
176
|
+
j++;
|
|
177
|
+
}
|
|
178
|
+
const end = Math.min(j, n);
|
|
179
|
+
strings.push({ start: i + 1, value: src.slice(i + 1, end) });
|
|
180
|
+
blank(code, i + 1, end);
|
|
181
|
+
i = end + 1;
|
|
182
|
+
} else if (c === '/' && regexMayStart(i)) {
|
|
183
|
+
let j = i + 1;
|
|
184
|
+
let cls = false;
|
|
185
|
+
while (j < n && src[j] !== '\n') {
|
|
186
|
+
const e = src[j];
|
|
187
|
+
if (e === '\\') { j += 2; continue; }
|
|
188
|
+
if (e === '[') cls = true;
|
|
189
|
+
else if (e === ']') cls = false;
|
|
190
|
+
else if (e === '/' && !cls) break;
|
|
191
|
+
j++;
|
|
192
|
+
}
|
|
193
|
+
if (j < n && src[j] === '/') { blank(code, i + 1, j); i = j + 1; } else i++;
|
|
194
|
+
} else i++;
|
|
195
|
+
}
|
|
196
|
+
return { code: code.join(''), bare: bare.join(''), strings };
|
|
197
|
+
}
|
|
198
|
+
export const lineOf = (text, index) => {
|
|
199
|
+
let line = 1;
|
|
200
|
+
for (let k = 0; k < index && k < text.length; k++) if (text.charCodeAt(k) === 10) line++;
|
|
201
|
+
return line;
|
|
202
|
+
};
|
|
203
|
+
const firstIndex = (text, re) => { const m = re.exec(text); return m ? m.index : -1; };
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------------------------
|
|
206
|
+
// The reads register. Comment form: `// reads: a b` and `// runs: a b` on comment lines of their
|
|
207
|
+
// own (the orchestrator's declared reads). JSON form: { <key>: { "<test>": ["<pattern>", …] } }.
|
|
208
|
+
// ---------------------------------------------------------------------------------------------
|
|
209
|
+
const DECLARED = /^[ \t]*\/\/[ \t]*(reads|runs):[ \t]*(\S.*)$/gm;
|
|
210
|
+
export function declaredIn(text) {
|
|
211
|
+
const out = { reads: [], runs: [] };
|
|
212
|
+
for (const m of String(text).matchAll(DECLARED)) {
|
|
213
|
+
const line = lineOf(text, m.index);
|
|
214
|
+
for (const word of m[2].trim().split(/\s+/)) out[m[1]].push({ pattern: word, line });
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------------------------------------
|
|
220
|
+
// The per-file rules. Each returns the line of its first breach, or 0.
|
|
221
|
+
// ---------------------------------------------------------------------------------------------
|
|
222
|
+
const TIMER = /(?<![\w$.])(?:(?:globalThis|global|window|self)\.)?(?:setTimeout|setInterval|sleep)\s*\(/;
|
|
223
|
+
const TIMERS_PROMISES = /['"`](?:node:)?timers\/promises['"`]/;
|
|
224
|
+
const CLOCK = /\bDate\.now\s*\(|\bnew\s+Date\s*\(\s*\)|\bperformance\.now\s*\(/;
|
|
225
|
+
const HOME = /\bhomedir\s*\(|\bprocess\.env\.HOME\b/;
|
|
226
|
+
const FETCH = /(?<![\w$.])fetch\s*\(\s*(['"`])/g;
|
|
227
|
+
const LOCAL_HOST = /^https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\]|[^/:]*\.(?:test|localhost|invalid|example)|example\.(?:com|org|net))(?=[/:?#]|$)/i;
|
|
228
|
+
const ENV_READ = /\bprocess\.env\.([A-Za-z_][A-Za-z0-9_]*)/g;
|
|
229
|
+
const FS_READ = /\b(?:readFileSync|readFile|createReadStream|readdirSync|readdir|opendirSync)\s*\(/;
|
|
230
|
+
const RAW_IMPORT = /(?:\bfrom|\bimport)\s*\(?\s*['"]([^'"\n]+)\?raw['"]/g;
|
|
231
|
+
const RAW_GLOB = /\bimport\.meta\.glob\s*\(/g;
|
|
232
|
+
const IMPORT_BEFORE = /(?:\bfrom|\bimport|\brequire)\s*\(?\s*$/;
|
|
233
|
+
const BUILT_HERE = /\bnew\s+URL\s*\(\s*$|\b(?:join|resolve)\s*\([^;]*(?:\bimport\.meta\.(?:dirname|url|filename)|\b__dirname\b|\bROOT\b|\bHERE\b)[^;]*$/;
|
|
234
|
+
|
|
235
|
+
function timerLine({ code, bare }) {
|
|
236
|
+
const a = firstIndex(code, TIMER);
|
|
237
|
+
const b = firstIndex(bare, TIMERS_PROMISES);
|
|
238
|
+
const at = [a, b].filter((x) => x >= 0).sort((x, y) => x - y)[0];
|
|
239
|
+
return at === undefined ? 0 : lineOf(code, at);
|
|
240
|
+
}
|
|
241
|
+
const clockLine = ({ code }) => { const at = firstIndex(code, CLOCK); return at < 0 ? 0 : lineOf(code, at); };
|
|
242
|
+
|
|
243
|
+
function outsideLine({ path, code, bare, strings }) {
|
|
244
|
+
const hits = [];
|
|
245
|
+
const home = firstIndex(code, HOME);
|
|
246
|
+
if (home >= 0) hits.push(home);
|
|
247
|
+
const depth = path.split('/').length - 1;
|
|
248
|
+
for (const s of strings) {
|
|
249
|
+
const climb = /^((?:\.\.\/)+)[^\s]*$/.exec(s.value);
|
|
250
|
+
if (climb && climb[1].length / 3 > depth) hits.push(s.start);
|
|
251
|
+
}
|
|
252
|
+
for (const m of code.matchAll(FETCH)) {
|
|
253
|
+
const open = m.index + m[0].length;
|
|
254
|
+
const url = bare.slice(open, open + 200).split(m[1])[0];
|
|
255
|
+
if (/^https?:\/\//i.test(url) && !LOCAL_HOST.test(url)) hits.push(m.index);
|
|
256
|
+
}
|
|
257
|
+
return hits.length ? lineOf(code, Math.min(...hits)) : 0;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function envLine({ code, bare }, allowed) {
|
|
261
|
+
for (const m of code.matchAll(ENV_READ)) {
|
|
262
|
+
const name = m[1];
|
|
263
|
+
if (name === 'HOME' || allowed.includes(name)) continue; // HOME is T6:outside's
|
|
264
|
+
const set = new RegExp(`\\bprocess\\.env\\.${name}\\s*=(?!=)|\\bdelete\\s+process\\.env\\.${name}\\b|\\bstubEnv\\s*\\(\\s*['"\`]${name}['"\`]`);
|
|
265
|
+
if (!set.test(bare)) return lineOf(code, m.index);
|
|
266
|
+
}
|
|
267
|
+
return 0;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** T4: the line of the first read of a source file as text, or 0. */
|
|
271
|
+
function sourceTextLine({ path, text, code, bare, strings }, { config, register, tracked }) {
|
|
272
|
+
const isSource = (p) => config.source_ext.includes(String(p).split('.').pop().toLowerCase());
|
|
273
|
+
const allowed = (p) => under(config.fixtures, p) || under(config.wording, p);
|
|
274
|
+
const isFolderOrGlob = (p) => (/[*?]/.test(p) ? !/\.[A-Za-z0-9]+$/.test(p) || isSource(p) : String(p).endsWith('/'));
|
|
275
|
+
const offends = (p) => !allowed(p) && (isFolderOrGlob(p) || isSource(p));
|
|
276
|
+
|
|
277
|
+
const declared = declaredIn(text);
|
|
278
|
+
if (register && typeof register === 'object') {
|
|
279
|
+
if ((register[path] ?? []).some(offends)) return 1;
|
|
280
|
+
} else if (config.reads === 'comment') {
|
|
281
|
+
const hit = declared.reads.find((r) => offends(r.pattern));
|
|
282
|
+
if (hit) return hit.line;
|
|
283
|
+
}
|
|
284
|
+
for (const m of bare.matchAll(RAW_IMPORT)) {
|
|
285
|
+
if (code[m.index] === ' ') continue; // the words are inside a string, not an import
|
|
286
|
+
if (!allowed(posix.normalize(posix.join(posix.dirname(path), m[1])))) return lineOf(bare, m.index);
|
|
287
|
+
}
|
|
288
|
+
for (const m of code.matchAll(RAW_GLOB)) {
|
|
289
|
+
const call = bare.slice(m.index, m.index + 300).split(')')[0];
|
|
290
|
+
if (/\?raw|as\s*:\s*['"]raw['"]/.test(call)) return lineOf(code, m.index);
|
|
291
|
+
}
|
|
292
|
+
// A source file whose path the test builds from its own place, in a test that reads the file
|
|
293
|
+
// system, where no register says what the test reads: declared `// runs:` when it is only run.
|
|
294
|
+
if (register && typeof register === 'object') return 0;
|
|
295
|
+
if (!FS_READ.test(code) || !tracked) return 0;
|
|
296
|
+
const said = [...declared.reads, ...declared.runs].map((r) => r.pattern);
|
|
297
|
+
for (const s of strings) {
|
|
298
|
+
const v = s.value;
|
|
299
|
+
if (/[\s*?$]/.test(v) || !isSource(v)) continue;
|
|
300
|
+
const before = code.slice(code.lastIndexOf('\n', s.start) + 1, s.start - 1);
|
|
301
|
+
if (IMPORT_BEFORE.test(before) || !BUILT_HERE.test(before)) continue;
|
|
302
|
+
const here = posix.normalize(posix.join(posix.dirname(path), v));
|
|
303
|
+
const target = v.startsWith('.') || !tracked.has(v) ? here : v;
|
|
304
|
+
if (target === path || !tracked.has(target) || allowed(target)) continue;
|
|
305
|
+
if (said.some((p) => globRe(p).test(target))) continue;
|
|
306
|
+
return lineOf(code, s.start);
|
|
307
|
+
}
|
|
308
|
+
return 0;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Every breach in `files` (the repo's test files as { path, text }), one per file and rule, at
|
|
313
|
+
* its first line: [{ file, rule, line, say }], sorted. Pure: no disk, no git.
|
|
314
|
+
*/
|
|
315
|
+
export function breachesOf({ files, config, register = null, tracked = null }) {
|
|
316
|
+
const out = [];
|
|
317
|
+
const add = (file, rule, line) => out.push({ file, rule, line, say: RULES[rule] });
|
|
318
|
+
const names = config.task_names.map((s) => new RegExp(s, 'i'));
|
|
319
|
+
const fakes = config.fake_timers.map((s) => new RegExp(s));
|
|
320
|
+
for (const { path, text } of files) {
|
|
321
|
+
const view = { path, text, ...scan(text) };
|
|
322
|
+
const base = path.split('/').pop();
|
|
323
|
+
if (names.some((re) => re.test(base))) add(path, 'T1:name', 1);
|
|
324
|
+
if (countLines(text) > config.max_lines) add(path, 'R7:lines', config.max_lines + 1);
|
|
325
|
+
const t4 = sourceTextLine(view, { config, register, tracked });
|
|
326
|
+
if (t4) add(path, 'T4:source-text', t4);
|
|
327
|
+
const faked = fakes.some((re) => re.test(view.bare));
|
|
328
|
+
const timer = faked ? 0 : timerLine(view);
|
|
329
|
+
if (timer) add(path, 'T6:timer', timer);
|
|
330
|
+
const clock = faked ? 0 : clockLine(view);
|
|
331
|
+
if (clock) add(path, 'T6:clock', clock);
|
|
332
|
+
const outside = outsideLine(view);
|
|
333
|
+
if (outside) add(path, 'T6:outside', outside);
|
|
334
|
+
const env = envLine(view, config.env_allowed);
|
|
335
|
+
if (env) add(path, 'T6:env', env);
|
|
336
|
+
}
|
|
337
|
+
return out.sort((a, b) => a.file.localeCompare(b.file) || a.rule.localeCompare(b.rule));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* The verdict over the breaches and the list. `listed`: the head's entries ({ file: [rule] }).
|
|
342
|
+
* `base`: null (no base read), or { listed: the base's entries or null when the base has no
|
|
343
|
+
* list, lines: { file: its line count at the base } }.
|
|
344
|
+
* → { breaches (off the list, plus T1:hub and list:grew), listed (count on the list), stale }.
|
|
345
|
+
*/
|
|
346
|
+
export function verdict({ all, listed = {}, base = null, lineCounts = {} }) {
|
|
347
|
+
const on = (file, rule) => (listed[file] ?? []).includes(rule) && !NEVER_LISTED.includes(rule);
|
|
348
|
+
const breaches = all.filter((b) => !on(b.file, b.rule));
|
|
349
|
+
const hits = all.filter((b) => on(b.file, b.rule));
|
|
350
|
+
const found = new Set(all.map((b) => `${b.file}\n${b.rule}`));
|
|
351
|
+
const stale = [];
|
|
352
|
+
for (const file of Object.keys(listed).sort()) {
|
|
353
|
+
for (const rule of [...listed[file]].sort()) if (!found.has(`${file}\n${rule}`)) stale.push({ file, rule });
|
|
354
|
+
}
|
|
355
|
+
if (base) {
|
|
356
|
+
for (const file of Object.keys(listed).sort()) {
|
|
357
|
+
for (const rule of [...listed[file]].sort()) {
|
|
358
|
+
if (base.listed && !(base.listed[file] ?? []).includes(rule)) breaches.push({ file, rule: 'list:grew', line: 1, say: `${rule}: ${RULES['list:grew']}` });
|
|
359
|
+
}
|
|
360
|
+
const was = base.lines?.[file];
|
|
361
|
+
if (listed[file].includes('R7:lines') && Number.isFinite(was) && lineCounts[file] > was) {
|
|
362
|
+
breaches.push({ file, rule: 'T1:hub', line: was + 1, say: `${RULES['T1:hub']} (${was} lines at the base, ${lineCounts[file]} now)` });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
breaches.sort((a, b) => a.file.localeCompare(b.file) || a.rule.localeCompare(b.rule));
|
|
367
|
+
return { breaches, listed: hits.length, stale };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** The entries a first list holds for these breaches: { file: [rule, …] }, sorted. */
|
|
371
|
+
export function entriesFor(all) {
|
|
372
|
+
const entries = {};
|
|
373
|
+
for (const b of all) if (!NEVER_LISTED.includes(b.rule)) (entries[b.file] ??= []).push(b.rule);
|
|
374
|
+
for (const k of Object.keys(entries)) entries[k].sort();
|
|
375
|
+
return Object.fromEntries(Object.keys(entries).sort().map((k) => [k, entries[k]]));
|
|
376
|
+
}
|
|
377
|
+
export const listedText = (entries) => {
|
|
378
|
+
const rows = Object.keys(entries).map((k) => ` ${JSON.stringify(k)}: ${JSON.stringify(entries[k])}`);
|
|
379
|
+
return `{\n "v": ${VERSION},\n "entries": {${rows.length ? `\n${rows.join(',\n')}\n ` : ''}}\n}\n`;
|
|
380
|
+
};
|
|
381
|
+
export function countLines(text) {
|
|
382
|
+
return text.length ? text.split('\n').length - (text.endsWith('\n') ? 1 : 0) : 0;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** A config object with the defaults filled in; throws (exit 3) on a wrong shape. */
|
|
386
|
+
export function withDefaults(raw) {
|
|
387
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw fail(EXIT.FAILED, `${CONFIG_FILE}: not an object`);
|
|
388
|
+
const unknown = Object.keys(raw).filter((k) => !(k in CONFIG_DEFAULTS) && k !== '_');
|
|
389
|
+
if (unknown.length) throw fail(EXIT.FAILED, `${CONFIG_FILE}: unknown key ${unknown.join(', ')}`);
|
|
390
|
+
const config = { ...CONFIG_DEFAULTS, ...raw };
|
|
391
|
+
if (config.v !== VERSION) throw fail(EXIT.FAILED, `${CONFIG_FILE}: v must be ${VERSION}`);
|
|
392
|
+
if (typeof config.fail !== 'boolean') throw fail(EXIT.FAILED, `${CONFIG_FILE}: fail must be true or false`);
|
|
393
|
+
for (const k of ['tests', 'ignore', 'fixtures', 'wording', 'source_ext', 'task_names', 'fake_timers', 'env_allowed']) {
|
|
394
|
+
if (!Array.isArray(config[k]) || config[k].some((x) => typeof x !== 'string')) throw fail(EXIT.FAILED, `${CONFIG_FILE}: ${k} must be a list of strings`);
|
|
395
|
+
}
|
|
396
|
+
const r = config.reads;
|
|
397
|
+
if (!(r === null || r === 'comment' || (r && typeof r === 'object' && typeof r.json === 'string'))) throw fail(EXIT.FAILED, `${CONFIG_FILE}: reads must be null, "comment" or { "json": "<path>" }`);
|
|
398
|
+
return config;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ---------------------------------------------------------------------------------------------
|
|
402
|
+
// The command line: the only part that touches disk and git.
|
|
403
|
+
// ---------------------------------------------------------------------------------------------
|
|
404
|
+
export function parseArgs(argv) {
|
|
405
|
+
const o = { dir: null, base: null, noBase: false, list: false, init: false, help: false };
|
|
406
|
+
for (let i = 0; i < argv.length; i++) {
|
|
407
|
+
const a = argv[i];
|
|
408
|
+
if (a === '--list') o.list = true;
|
|
409
|
+
else if (a === '--init') o.init = true;
|
|
410
|
+
else if (a === '--no-base') o.noBase = true;
|
|
411
|
+
else if (a === '--help' || a === '-h') o.help = true;
|
|
412
|
+
else if (a === '--dir' || a === '--base') {
|
|
413
|
+
const v = argv[++i];
|
|
414
|
+
if (!v || v.startsWith('--')) throw fail(EXIT.USAGE, `${a} needs a value`);
|
|
415
|
+
o[a.slice(2)] = v;
|
|
416
|
+
} else throw fail(EXIT.USAGE, `unknown argument ${a}`);
|
|
417
|
+
}
|
|
418
|
+
if (o.base && o.noBase) throw fail(EXIT.USAGE, '--base and --no-base together');
|
|
419
|
+
return o;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const git = (dir, args) => execFileSync('git', args, { cwd: dir, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
423
|
+
const gitOr = (dir, args) => { try { return git(dir, args); } catch { return null; } };
|
|
424
|
+
function readJson(dir, rel) {
|
|
425
|
+
let text;
|
|
426
|
+
try { text = readFileSync(join(dir, rel), 'utf8'); } catch { throw fail(EXIT.FAILED, `${rel}: cannot be read`); }
|
|
427
|
+
try { return JSON.parse(text); } catch (e) { throw fail(EXIT.FAILED, `${rel}: not JSON (${e.message})`); }
|
|
428
|
+
}
|
|
429
|
+
export function entriesOf(obj, where) {
|
|
430
|
+
const e = obj?.entries;
|
|
431
|
+
const ok = obj?.v === VERSION && e && typeof e === 'object' && !Array.isArray(e)
|
|
432
|
+
&& Object.values(e).every((r) => Array.isArray(r) && r.every((x) => typeof x === 'string' && x in RULES && !NEVER_LISTED.includes(x)));
|
|
433
|
+
if (!ok) throw fail(EXIT.FAILED, `${where}: must be { "v": ${VERSION}, "entries": { "<test file>": ["<rule id>", …] } }`);
|
|
434
|
+
return e;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export function main(argv = [], { log = console.log, error = console.error } = {}) {
|
|
438
|
+
try {
|
|
439
|
+
const opts = parseArgs(argv);
|
|
440
|
+
if (opts.help) { log(USAGE); return EXIT.CLEAN; }
|
|
441
|
+
const dir = resolve(opts.dir ?? process.cwd());
|
|
442
|
+
if (gitOr(dir, ['rev-parse', '--show-toplevel']) === null) throw fail(EXIT.FAILED, `${dir}: not a git worktree`);
|
|
443
|
+
const config = withDefaults(readJson(dir, CONFIG_FILE));
|
|
444
|
+
|
|
445
|
+
const present = git(dir, ['ls-files', '-co', '--exclude-standard', '-z']).split('\0').filter((p) => p && existsSync(join(dir, p)));
|
|
446
|
+
const tracked = new Set(present);
|
|
447
|
+
const testPaths = present.filter((p) => !p.split('/').includes('node_modules') && matches(config.tests, p) && !matches(config.ignore, p)).sort();
|
|
448
|
+
const files = testPaths.map((path) => ({ path, text: readFileSync(join(dir, path), 'utf8') }));
|
|
449
|
+
const register = config.reads && typeof config.reads === 'object' ? (readJson(dir, config.reads.json)[config.reads.key ?? 'tests'] ?? {}) : null;
|
|
450
|
+
const all = breachesOf({ files, config, register, tracked });
|
|
451
|
+
|
|
452
|
+
if (opts.init) {
|
|
453
|
+
if (existsSync(join(dir, LISTED_FILE))) throw fail(EXIT.USAGE, `${LISTED_FILE} exists: the list is written once and only shrinks`);
|
|
454
|
+
mkdirSync(dirname(join(dir, LISTED_FILE)), { recursive: true });
|
|
455
|
+
writeFileSync(join(dir, LISTED_FILE), listedText(entriesFor(all)));
|
|
456
|
+
log(`test-rules: wrote ${LISTED_FILE} — ${all.length} breach(es) in ${new Set(all.map((b) => b.file)).size} of ${files.length} test files`);
|
|
457
|
+
return EXIT.CLEAN;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const listed = existsSync(join(dir, LISTED_FILE)) ? entriesOf(readJson(dir, LISTED_FILE), LISTED_FILE) : {};
|
|
461
|
+
let base = null;
|
|
462
|
+
let baseNote = 'no base';
|
|
463
|
+
if (!opts.noBase) {
|
|
464
|
+
const ref = opts.base ?? DEFAULT_BASE;
|
|
465
|
+
const mb = gitOr(dir, ['merge-base', ref, 'HEAD'])?.trim();
|
|
466
|
+
if (!mb && opts.base) throw fail(EXIT.FAILED, `--base ${ref}: no merge base with HEAD`);
|
|
467
|
+
if (mb) {
|
|
468
|
+
const text = gitOr(dir, ['show', `${mb}:${LISTED_FILE}`]);
|
|
469
|
+
let baseListed = null;
|
|
470
|
+
if (text !== null) {
|
|
471
|
+
let parsed;
|
|
472
|
+
try { parsed = JSON.parse(text); } catch (e) { throw fail(EXIT.FAILED, `${LISTED_FILE} at ${mb.slice(0, 12)}: not JSON (${e.message})`); }
|
|
473
|
+
baseListed = entriesOf(parsed, `${LISTED_FILE} at ${mb.slice(0, 12)}`);
|
|
474
|
+
}
|
|
475
|
+
const lines = {};
|
|
476
|
+
for (const file of Object.keys(listed)) {
|
|
477
|
+
if (!listed[file].includes('R7:lines')) continue;
|
|
478
|
+
const was = gitOr(dir, ['show', `${mb}:${file}`]);
|
|
479
|
+
if (was !== null) lines[file] = countLines(was);
|
|
480
|
+
}
|
|
481
|
+
base = { listed: baseListed, lines };
|
|
482
|
+
baseNote = `base ${mb.slice(0, 12)}${baseListed ? '' : ' (no list there)'}`;
|
|
483
|
+
} else baseNote = `no base (${ref} does not resolve)`;
|
|
484
|
+
}
|
|
485
|
+
const lineCounts = Object.fromEntries(files.map((f) => [f.path, countLines(f.text)]));
|
|
486
|
+
const v = verdict({ all, listed, base, lineCounts });
|
|
487
|
+
|
|
488
|
+
if (opts.list) {
|
|
489
|
+
log(JSON.stringify({ v: VERSION, breaches: v.breaches, listed: v.listed, stale: v.stale }));
|
|
490
|
+
} else {
|
|
491
|
+
for (const b of v.breaches) log(`${b.rule} ${b.file}:${b.line} — ${b.say}`);
|
|
492
|
+
for (const s of v.stale) log(`stale ${s.file} ${s.rule} — the breach is gone: delete it from ${LISTED_FILE}`);
|
|
493
|
+
log(`test-rules: ${files.length} test files, ${v.breaches.length} breach(es), ${v.listed} listed, ${v.stale.length} stale; ${baseNote}${config.fail ? '' : '; report only ("fail": false)'}`);
|
|
494
|
+
}
|
|
495
|
+
return config.fail && (v.breaches.length || v.stale.length) ? EXIT.BREACH : EXIT.CLEAN;
|
|
496
|
+
} catch (e) {
|
|
497
|
+
const code = e?.exit ?? EXIT.FAILED;
|
|
498
|
+
error(`test-rules: ${e?.message ?? e}`);
|
|
499
|
+
if (code === EXIT.USAGE) error(USAGE);
|
|
500
|
+
return code;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const real = (p) => { try { return realpathSync(p); } catch { return p; } };
|
|
505
|
+
if (process.argv[1] && real(process.argv[1]) === real(fileURLToPath(import.meta.url))) process.exitCode = main(process.argv.slice(2));
|