@syncerauk/test-rules 0.0.0-stage → 1.0.0

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.
Files changed (3) hide show
  1. package/README.md +69 -2
  2. package/package.json +23 -4
  3. package/test-rules.mjs +505 -0
package/README.md CHANGED
@@ -1,3 +1,70 @@
1
- # Temporary Holding Version
1
+ # @syncerauk/test-rules
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
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));