@syncerauk/test-rules 1.0.4 → 1.0.6
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 +46 -2
- package/package.json +1 -1
- package/test-rules.mjs +258 -39
package/README.md
CHANGED
|
@@ -26,7 +26,8 @@ npm run gate:test-rules -- --base origin/main --list
|
|
|
26
26
|
| 2 | A bad command line |
|
|
27
27
|
| 3 | The check could not run (no config, unreadable JSON, not a git worktree) |
|
|
28
28
|
|
|
29
|
-
`--list` prints one JSON object: `{ v, breaches: [{ file, rule, line, say }], listed, stale
|
|
29
|
+
`--list` prints one JSON object: `{ v, breaches: [{ file, rule, line, say }], listed, stale,
|
|
30
|
+
relisted: [{ file, rule, at }] }`.
|
|
30
31
|
|
|
31
32
|
## The rules
|
|
32
33
|
|
|
@@ -56,7 +57,8 @@ Both in the repo's `scripts/test-rules/`:
|
|
|
56
57
|
key, `"key": null` when the file itself is the map). The defaults are `CONFIG_DEFAULTS` in the
|
|
57
58
|
script.
|
|
58
59
|
- `listed.json`: the breaches the repo had on the day the check arrived. Written once by
|
|
59
|
-
`test-rules --init`.
|
|
60
|
+
`test-rules --init`. An optional `moved` map, `{ "<new test file>": "<old test file>" }`,
|
|
61
|
+
says where a split or a rename took a listed file.
|
|
60
62
|
|
|
61
63
|
## The list only shrinks
|
|
62
64
|
|
|
@@ -64,11 +66,53 @@ Both in the repo's `scripts/test-rules/`:
|
|
|
64
66
|
- An entry whose breach is gone is stale and fails until its line is deleted.
|
|
65
67
|
- Against a base, an entry the base's list does not hold fails. The base is `--base <ref>`,
|
|
66
68
|
else `origin/main` when it resolves.
|
|
69
|
+
- A split or a rename carries its listed breaches: the new file's entry passes when `moved`
|
|
70
|
+
names the old file, the base listed the same rule on it, and the old file is no longer a test
|
|
71
|
+
file. A `moved` line is never stale and never a breach; delete it whenever you like.
|
|
67
72
|
|
|
68
73
|
A repo starts with `"fail": false`: breaches are printed and the exit is 0. One line turns it on.
|
|
69
74
|
|
|
75
|
+
## Bumping the version: re-listed once per version
|
|
76
|
+
|
|
77
|
+
A newer version can find breaches an older one missed, in files nobody touched (Bernardo Cunha,
|
|
78
|
+
6 Nov 2026: re-list once per version). The PR that bumps the pin may add those entries to
|
|
79
|
+
`listed.json`, and only those:
|
|
80
|
+
|
|
81
|
+
- the PR raises the installed version: the base's is lower, and the version running is the one
|
|
82
|
+
installed (the lockfile, else an exact pin in `package.json`, read at the base through git);
|
|
83
|
+
- the breach is found only by a detection newer than the base's version: the check runs a
|
|
84
|
+
second time as the base's version (the `DETECTORS` table in `test-rules.mjs` says which version
|
|
85
|
+
first found what) and only what that run does not find counts;
|
|
86
|
+
- the PR does not change the file.
|
|
87
|
+
|
|
88
|
+
Any other new entry is still `list:grew`. Allowed growth is printed (`re-listed at 1.0.6: N
|
|
89
|
+
entries`, one `relisted` line each). Do not edit the list by hand: after the bump and
|
|
90
|
+
`npm install`, run
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
npm run gate:test-rules -- --relist
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
It writes exactly the allowed entries into `listed.json` (keeping `moved`) and refuses (exit 2)
|
|
97
|
+
when the PR does not raise the version.
|
|
98
|
+
|
|
99
|
+
| Version | Detections it added |
|
|
100
|
+
|---|---|
|
|
101
|
+
| 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` |
|
|
102
|
+
| 1.0.1, 1.0.4, 1.0.5, 1.0.6 | none |
|
|
103
|
+
| 1.0.2 | `T4:source-text`: a path that starts with a dot-folder; a JSON register that is the whole file |
|
|
104
|
+
| 1.0.3 | `T4:source-text`: the built-path scan under a JSON register |
|
|
105
|
+
|
|
70
106
|
## Versions
|
|
71
107
|
|
|
108
|
+
- **1.0.6** — Re-listed once per version: a PR that raises the installed version may add the
|
|
109
|
+
entries a newer detection finds in files it does not change; `--relist` writes them.
|
|
110
|
+
`--list` carries `relisted`. Nothing new is found.
|
|
111
|
+
|
|
112
|
+
- **1.0.5** — A split or rename carries its listed breaches: `listed.json` may hold
|
|
113
|
+
`"moved": { "<new test file>": "<old test file>" }`, and a new file's entry is not
|
|
114
|
+
`list:grew` when the base listed the same rule on the old file and the old file is gone.
|
|
115
|
+
Nothing changes for a repo that does not set it.
|
|
72
116
|
- **1.0.4** — `style_contract`: a repo may name one test that reads its stylesheets (`.css`,
|
|
73
117
|
`.scss`), the way wording is read once from the wording list. Those files are not a
|
|
74
118
|
`T4:source-text` breach in that test, and only in that test. Nothing changes for a repo that
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncerauk/test-rules",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
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
|
@@ -4,19 +4,23 @@
|
|
|
4
4
|
// of R7; docs/test-review/2026-11-05/RULES.md, adopted by Bernardo Cunha on 5 Nov 2026).
|
|
5
5
|
//
|
|
6
6
|
// npm run gate:test-rules [-- --base <ref>] [--list]
|
|
7
|
-
// node scripts/test-rules.mjs [--dir <worktree>] [--base <ref> | --no-base] [--list] [--init]
|
|
7
|
+
// node scripts/test-rules.mjs [--dir <worktree>] [--base <ref> | --no-base] [--list] [--init | --relist]
|
|
8
8
|
//
|
|
9
9
|
// One implementation for the six repos (R8): one file, no dependency, no build; it imports
|
|
10
10
|
// nothing from this repo and reads only the worktree and git, so another repo can run this same
|
|
11
11
|
// file. Exit 0 clean, 1 a breach, 2 a bad command line, 3 the check itself could not run (no
|
|
12
12
|
// config, unreadable JSON, not a git worktree, a --base that does not resolve).
|
|
13
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
|
-
//
|
|
14
|
+
// { v: 1, breaches: [{ file, rule, line, say }], listed: <n>, stale: [{ file, rule }],
|
|
15
|
+
// relisted: [{ file, rule, at }] }
|
|
16
|
+
// `breaches` are the breaches that are not on the list; `listed` counts the ones that are;
|
|
17
|
+
// `relisted` is the growth a version bump may bring (below).
|
|
16
18
|
//
|
|
17
19
|
// Per repo, two files:
|
|
18
20
|
// 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>", …] }
|
|
21
|
+
// scripts/test-rules/listed.json { v: 1, entries: { "<test file>": ["<rule id>", …] },
|
|
22
|
+
// moved: { "<new test file>": "<old test file>" } }
|
|
23
|
+
// `moved` is optional: it says where a split or a rename took a listed file (1.0.5).
|
|
20
24
|
//
|
|
21
25
|
// The list only shrinks. A breach on the list passes; a breach off the list fails; an entry
|
|
22
26
|
// whose breach is gone is `stale` and fails until its line is deleted; with a base, an entry the
|
|
@@ -24,6 +28,21 @@
|
|
|
24
28
|
// lines than at the base fails (`T1:hub`). The base is `--base <ref>`, else origin/main when it
|
|
25
29
|
// resolves (the merge base with HEAD is what is read); `--no-base` skips those two checks. The
|
|
26
30
|
// base having no list at all is the one change that may add it (`--init` writes it once).
|
|
31
|
+
// A split or a rename carries its listed breaches: an entry the base does not hold is not
|
|
32
|
+
// `list:grew` when `moved` names its old file, the base listed that same rule on the old file,
|
|
33
|
+
// and the old file is no longer a test file. A `moved` line is never stale and never a breach;
|
|
34
|
+
// it may be deleted at any time.
|
|
35
|
+
// Re-listed once per version (Bernardo Cunha, 6 Nov 2026, 1.0.6): a newer version can find
|
|
36
|
+
// breaches an older one missed, in files nobody touched. An entry the base does not hold is not
|
|
37
|
+
// `list:grew` when all three hold: (a) this change raises the installed version (the base's is
|
|
38
|
+
// lower than the one running, and the one running is the one installed); (b) the breach is found
|
|
39
|
+
// only by a detection newer than the base's version (DETECTORS below: the check is run a second
|
|
40
|
+
// time as the base's version, and only what that run does not find counts); (c) the change does
|
|
41
|
+
// not edit the file. Allowed growth is printed ("re-listed at <version>: N entries"), never
|
|
42
|
+
// silent. `--relist` writes exactly those entries into the list, so nobody edits it by hand.
|
|
43
|
+
// The version installed is read from the lockfile, else an exact pin in package.json (in the
|
|
44
|
+
// folder that holds node_modules/@syncerauk/test-rules, then the repo root), else, in the
|
|
45
|
+
// orchestrator, which runs this file itself, from scripts/test-rules-package/package.json.
|
|
27
46
|
// `"fail": false` in the config is report only: everything is printed, the exit is 0.
|
|
28
47
|
//
|
|
29
48
|
// The rules (RULES below) and how each is seen:
|
|
@@ -49,12 +68,13 @@
|
|
|
49
68
|
// T6:env process.env.<NAME> read and never set in that file
|
|
50
69
|
// Comments are never read; code patterns are never matched inside a string.
|
|
51
70
|
// The config and the list are read by the command line only, and the check runs on every small
|
|
52
|
-
// gate, so no test declares them.
|
|
71
|
+
// gate, so no test declares them. Its own version is read from its manifest:
|
|
72
|
+
// reads: scripts/test-rules-package/package.json
|
|
53
73
|
// =============================================================================
|
|
54
74
|
|
|
55
75
|
import { execFileSync } from 'node:child_process';
|
|
56
76
|
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
57
|
-
import { dirname, join, posix, resolve } from 'node:path';
|
|
77
|
+
import { dirname, join, posix, relative, resolve, sep } from 'node:path';
|
|
58
78
|
import { fileURLToPath } from 'node:url';
|
|
59
79
|
|
|
60
80
|
export const VERSION = 1;
|
|
@@ -62,7 +82,10 @@ export const EXIT = Object.freeze({ CLEAN: 0, BREACH: 1, USAGE: 2, FAILED: 3 });
|
|
|
62
82
|
export const CONFIG_FILE = 'scripts/test-rules/config.json';
|
|
63
83
|
export const LISTED_FILE = 'scripts/test-rules/listed.json';
|
|
64
84
|
export const DEFAULT_BASE = 'origin/main';
|
|
65
|
-
const
|
|
85
|
+
export const PACKAGE = '@syncerauk/test-rules';
|
|
86
|
+
/** The package's manifest in the orchestrator, the repo that runs this file itself. */
|
|
87
|
+
export const SOURCE_MANIFEST = 'scripts/test-rules-package/package.json';
|
|
88
|
+
const USAGE = 'usage: test-rules [--dir <worktree>] [--base <ref> | --no-base] [--list] [--init | --relist]';
|
|
66
89
|
|
|
67
90
|
/** What a repo's scripts/test-rules/config.json may set; anything it leaves out is this. */
|
|
68
91
|
export const CONFIG_DEFAULTS = Object.freeze({
|
|
@@ -96,6 +119,40 @@ export const RULES = Object.freeze({
|
|
|
96
119
|
/** The rules a listed.json entry can never excuse. */
|
|
97
120
|
export const NEVER_LISTED = Object.freeze(['T1:hub', 'list:grew']);
|
|
98
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Each way the check finds a breach: the rule it reports and the version that first found it,
|
|
124
|
+
* from the git history of this file. `breachesOf({ as })` runs only the ones a version had, so a
|
|
125
|
+
* re-list can tell what a bump newly finds from what was there all along. A version that changes
|
|
126
|
+
* how a breach is found adds a row here, with its own `since`, and keeps the old behaviour
|
|
127
|
+
* behind it. 1.0.1 (the same file as 1.0.0), 1.0.4 (`style_contract`: it narrows T4 and finds
|
|
128
|
+
* nothing new), 1.0.5 (`moved`) and 1.0.6 (the re-list) add none.
|
|
129
|
+
*/
|
|
130
|
+
export const DETECTORS = Object.freeze({
|
|
131
|
+
'T1:name': { rule: 'T1:name', since: '1.0.0' }, // the file named by task
|
|
132
|
+
'T1:hub': { rule: 'T1:hub', since: '1.0.0' }, // a listed long file grew (judged against the base, never listed)
|
|
133
|
+
'R7:lines': { rule: 'R7:lines', since: '1.0.0' }, // over max_lines
|
|
134
|
+
'T4:declared': { rule: 'T4:source-text', since: '1.0.0' }, // a declared read (comment lines or the JSON register) of a source file, folder or glob
|
|
135
|
+
'T4:raw': { rule: 'T4:source-text', since: '1.0.0' }, // a `?raw` import or a raw import.meta.glob
|
|
136
|
+
'T4:built': { rule: 'T4:source-text', since: '1.0.0' }, // a source path built from the test's own place, undeclared, with no JSON register
|
|
137
|
+
'T4:dot-folder': { rule: 'T4:source-text', since: '1.0.2' }, // that path starting with a dot-folder (.github/…) is a repo path, not one beside the test
|
|
138
|
+
'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
|
|
139
|
+
'T4:built-under-json': { rule: 'T4:source-text', since: '1.0.3' }, // the built-path scan runs under a JSON register too
|
|
140
|
+
'T6:timer': { rule: 'T6:timer', since: '1.0.0' },
|
|
141
|
+
'T6:clock': { rule: 'T6:clock', since: '1.0.0' },
|
|
142
|
+
'T6:outside': { rule: 'T6:outside', since: '1.0.0' },
|
|
143
|
+
'T6:env': { rule: 'T6:env', since: '1.0.0' },
|
|
144
|
+
});
|
|
145
|
+
const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
146
|
+
/** -1, 0 or 1 for two x.y.z versions. */
|
|
147
|
+
export function compareVersions(a, b) {
|
|
148
|
+
const x = String(a).split('.').map(Number);
|
|
149
|
+
const y = String(b).split('.').map(Number);
|
|
150
|
+
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;
|
|
151
|
+
return 0;
|
|
152
|
+
}
|
|
153
|
+
/** Whether version `as` had detector `id` (no `as`: this version, every detector). */
|
|
154
|
+
export const detects = (id, as = null) => !as || compareVersions(DETECTORS[id].since, as) <= 0;
|
|
155
|
+
|
|
99
156
|
const fail = (code, message) => Object.assign(new Error(message), { exit: code });
|
|
100
157
|
|
|
101
158
|
// ---------------------------------------------------------------------------------------------
|
|
@@ -273,7 +330,8 @@ function envLine({ code, bare }, allowed) {
|
|
|
273
330
|
}
|
|
274
331
|
|
|
275
332
|
/** T4: the line of the first read of a source file as text, or 0. */
|
|
276
|
-
function sourceTextLine({ path, text, code, bare, strings }, { config, register, tracked }) {
|
|
333
|
+
function sourceTextLine({ path, text, code, bare, strings }, { config, register, tracked, as }) {
|
|
334
|
+
const on = (id) => detects(id, as);
|
|
277
335
|
const isSource = (p) => config.source_ext.includes(String(p).split('.').pop().toLowerCase());
|
|
278
336
|
// A style contract is the one test that may read the stylesheets it names: style rules are
|
|
279
337
|
// checked once, there, the way wording is checked once from the wording list.
|
|
@@ -282,23 +340,25 @@ function sourceTextLine({ path, text, code, bare, strings }, { config, register,
|
|
|
282
340
|
const offends = (p) => !allowed(p) && (isFolderOrGlob(p) || isSource(p));
|
|
283
341
|
|
|
284
342
|
const declared = declaredIn(text);
|
|
285
|
-
|
|
343
|
+
const json = register && typeof register === 'object';
|
|
344
|
+
if (on('T4:declared') && json) {
|
|
286
345
|
if ((register[path] ?? []).some(offends)) return 1;
|
|
287
|
-
} else if (config.reads === 'comment') {
|
|
346
|
+
} else if (on('T4:declared') && config.reads === 'comment') {
|
|
288
347
|
const hit = declared.reads.find((r) => offends(r.pattern));
|
|
289
348
|
if (hit) return hit.line;
|
|
290
349
|
}
|
|
291
|
-
for (const m of bare.matchAll(RAW_IMPORT)) {
|
|
350
|
+
for (const m of on('T4:raw') ? bare.matchAll(RAW_IMPORT) : []) {
|
|
292
351
|
if (code[m.index] === ' ') continue; // the words are inside a string, not an import
|
|
293
352
|
if (!allowed(posix.normalize(posix.join(posix.dirname(path), m[1])))) return lineOf(bare, m.index);
|
|
294
353
|
}
|
|
295
|
-
for (const m of code.matchAll(RAW_GLOB)) {
|
|
354
|
+
for (const m of on('T4:raw') ? code.matchAll(RAW_GLOB) : []) {
|
|
296
355
|
const call = bare.slice(m.index, m.index + 300).split(')')[0];
|
|
297
356
|
if (/\?raw|as\s*:\s*['"]raw['"]/.test(call)) return lineOf(code, m.index);
|
|
298
357
|
}
|
|
299
358
|
// A source file whose path the test builds from its own place, in a test that reads the file
|
|
300
359
|
// system, that the test does not declare: `// runs:` when it is only run. A JSON register does
|
|
301
|
-
// not silence this: a register can miss a read, and a source file it names returned above.
|
|
360
|
+
// not silence this (1.0.3): a register can miss a read, and a source file it names returned above.
|
|
361
|
+
if (!on('T4:built') || (json && !on('T4:built-under-json'))) return 0;
|
|
302
362
|
if (!FS_READ.test(code) || !tracked) return 0;
|
|
303
363
|
const said = [...declared.reads, ...declared.runs].map((r) => r.pattern);
|
|
304
364
|
for (const s of strings) {
|
|
@@ -306,9 +366,11 @@ function sourceTextLine({ path, text, code, bare, strings }, { config, register,
|
|
|
306
366
|
if (/[\s*?$]/.test(v) || !isSource(v)) continue;
|
|
307
367
|
const before = code.slice(code.lastIndexOf('\n', s.start) + 1, s.start - 1);
|
|
308
368
|
if (IMPORT_BEFORE.test(before) || !BUILT_HERE.test(before)) continue;
|
|
309
|
-
// `./x` and `../x` are relative to the test; `.github/x` is a repo path like any other.
|
|
369
|
+
// `./x` and `../x` are relative to the test; `.github/x` is a repo path like any other (1.0.2;
|
|
370
|
+
// before it, any leading dot was read as beside the test).
|
|
310
371
|
const here = posix.normalize(posix.join(posix.dirname(path), v));
|
|
311
|
-
const
|
|
372
|
+
const besideTest = on('T4:dot-folder') ? v.startsWith('./') || v.startsWith('../') : v.startsWith('.');
|
|
373
|
+
const target = besideTest || !tracked.has(v) ? here : v;
|
|
312
374
|
if (target === path || !tracked.has(target) || allowed(target)) continue;
|
|
313
375
|
if (said.some((p) => globRe(p).test(target))) continue;
|
|
314
376
|
return lineOf(code, s.start);
|
|
@@ -318,28 +380,30 @@ function sourceTextLine({ path, text, code, bare, strings }, { config, register,
|
|
|
318
380
|
|
|
319
381
|
/**
|
|
320
382
|
* Every breach in `files` (the repo's test files as { path, text }), one per file and rule, at
|
|
321
|
-
* its first line: [{ file, rule, line, say }], sorted. Pure: no disk, no git.
|
|
383
|
+
* its first line: [{ file, rule, line, say }], sorted. Pure: no disk, no git. `as`: a version
|
|
384
|
+
* (x.y.z) to run only the detectors it had (DETECTORS), for the re-list; null runs them all.
|
|
322
385
|
*/
|
|
323
|
-
export function breachesOf({ files, config, register = null, tracked = null }) {
|
|
386
|
+
export function breachesOf({ files, config, register = null, tracked = null, as = null }) {
|
|
324
387
|
const out = [];
|
|
325
388
|
const add = (file, rule, line) => out.push({ file, rule, line, say: RULES[rule] });
|
|
389
|
+
const on = (id) => detects(id, as);
|
|
326
390
|
const names = config.task_names.map((s) => new RegExp(s, 'i'));
|
|
327
391
|
const fakes = config.fake_timers.map((s) => new RegExp(s));
|
|
328
392
|
for (const { path, text } of files) {
|
|
329
393
|
const view = { path, text, ...scan(text) };
|
|
330
394
|
const base = path.split('/').pop();
|
|
331
|
-
if (names.some((re) => re.test(base))) add(path, 'T1:name', 1);
|
|
332
|
-
if (countLines(text) > config.max_lines) add(path, 'R7:lines', config.max_lines + 1);
|
|
333
|
-
const t4 = sourceTextLine(view, { config, register, tracked });
|
|
395
|
+
if (on('T1:name') && names.some((re) => re.test(base))) add(path, 'T1:name', 1);
|
|
396
|
+
if (on('R7:lines') && countLines(text) > config.max_lines) add(path, 'R7:lines', config.max_lines + 1);
|
|
397
|
+
const t4 = sourceTextLine(view, { config, register, tracked, as });
|
|
334
398
|
if (t4) add(path, 'T4:source-text', t4);
|
|
335
399
|
const faked = fakes.some((re) => re.test(view.bare));
|
|
336
|
-
const timer = faked ? 0 : timerLine(view);
|
|
400
|
+
const timer = faked || !on('T6:timer') ? 0 : timerLine(view);
|
|
337
401
|
if (timer) add(path, 'T6:timer', timer);
|
|
338
|
-
const clock = faked ? 0 : clockLine(view);
|
|
402
|
+
const clock = faked || !on('T6:clock') ? 0 : clockLine(view);
|
|
339
403
|
if (clock) add(path, 'T6:clock', clock);
|
|
340
|
-
const outside = outsideLine(view);
|
|
404
|
+
const outside = on('T6:outside') ? outsideLine(view) : 0;
|
|
341
405
|
if (outside) add(path, 'T6:outside', outside);
|
|
342
|
-
const env = envLine(view, config.env_allowed);
|
|
406
|
+
const env = on('T6:env') ? envLine(view, config.env_allowed) : 0;
|
|
343
407
|
if (env) add(path, 'T6:env', env);
|
|
344
408
|
}
|
|
345
409
|
return out.sort((a, b) => a.file.localeCompare(b.file) || a.rule.localeCompare(b.rule));
|
|
@@ -349,9 +413,16 @@ export function breachesOf({ files, config, register = null, tracked = null }) {
|
|
|
349
413
|
* The verdict over the breaches and the list. `listed`: the head's entries ({ file: [rule] }).
|
|
350
414
|
* `base`: null (no base read), or { listed: the base's entries or null when the base has no
|
|
351
415
|
* list, lines: { file: its line count at the base } }.
|
|
352
|
-
*
|
|
416
|
+
* `moved`: { new file: old file } from the head's list. `present`: a Set of the head's test
|
|
417
|
+
* files, or null (then nothing is carried). An entry the base does not hold is carried, not
|
|
418
|
+
* `list:grew`, when its old file is gone and the base listed the same rule on it.
|
|
419
|
+
* `relist`: null, or { from, at, allowed: Set of "file\nrule", changed: Set of files } from
|
|
420
|
+
* relistable (a version bump): an entry the base does not hold and `allowed` has is re-listed,
|
|
421
|
+
* not `list:grew`.
|
|
422
|
+
* → { breaches (off the list, plus T1:hub and list:grew), listed (count on the list), stale,
|
|
423
|
+
* relisted: [{ file, rule, at }] }.
|
|
353
424
|
*/
|
|
354
|
-
export function verdict({ all, listed = {}, base = null, lineCounts = {} }) {
|
|
425
|
+
export function verdict({ all, listed = {}, base = null, lineCounts = {}, moved = {}, present = null, relist = null }) {
|
|
355
426
|
const on = (file, rule) => (listed[file] ?? []).includes(rule) && !NEVER_LISTED.includes(rule);
|
|
356
427
|
const breaches = all.filter((b) => !on(b.file, b.rule));
|
|
357
428
|
const hits = all.filter((b) => on(b.file, b.rule));
|
|
@@ -360,10 +431,20 @@ export function verdict({ all, listed = {}, base = null, lineCounts = {} }) {
|
|
|
360
431
|
for (const file of Object.keys(listed).sort()) {
|
|
361
432
|
for (const rule of [...listed[file]].sort()) if (!found.has(`${file}\n${rule}`)) stale.push({ file, rule });
|
|
362
433
|
}
|
|
434
|
+
const carried = (file, rule) => {
|
|
435
|
+
const old = Object.hasOwn(moved, file) ? moved[file] : null;
|
|
436
|
+
return typeof old === 'string' && present !== null && !present.has(old) && (base.listed?.[old] ?? []).includes(rule);
|
|
437
|
+
};
|
|
438
|
+
const relisted = [];
|
|
439
|
+
const why = (file) => !relist ? ''
|
|
440
|
+
: relist.changed.has(file) ? ` (a re-list at ${relist.at} never covers a file this change edits)`
|
|
441
|
+
: ` (a re-list at ${relist.at} covers only what ${relist.at} finds and ${relist.from} did not)`;
|
|
363
442
|
if (base) {
|
|
364
443
|
for (const file of Object.keys(listed).sort()) {
|
|
365
444
|
for (const rule of [...listed[file]].sort()) {
|
|
366
|
-
if (base.listed
|
|
445
|
+
if (!base.listed || (base.listed[file] ?? []).includes(rule) || carried(file, rule)) continue;
|
|
446
|
+
if (relist?.allowed.has(`${file}\n${rule}`)) relisted.push({ file, rule, at: relist.at });
|
|
447
|
+
else breaches.push({ file, rule: 'list:grew', line: 1, say: `${rule}: ${RULES['list:grew']}${why(file)}` });
|
|
367
448
|
}
|
|
368
449
|
const was = base.lines?.[file];
|
|
369
450
|
if (listed[file].includes('R7:lines') && Number.isFinite(was) && lineCounts[file] > was) {
|
|
@@ -372,7 +453,33 @@ export function verdict({ all, listed = {}, base = null, lineCounts = {} }) {
|
|
|
372
453
|
}
|
|
373
454
|
}
|
|
374
455
|
breaches.sort((a, b) => a.file.localeCompare(b.file) || a.rule.localeCompare(b.rule));
|
|
375
|
-
return { breaches, listed: hits.length, stale };
|
|
456
|
+
return { breaches, listed: hits.length, stale, relisted };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Whether this change may re-list, from the versions: `from` the base's installed version,
|
|
461
|
+
* `installed` the worktree's, `running` this file's own. → { open, from, at, why }: open only
|
|
462
|
+
* when all three are known, the one running is the one installed, and it is newer than the base's.
|
|
463
|
+
*/
|
|
464
|
+
export function relistGate({ from, installed, running }) {
|
|
465
|
+
const shut = (why) => ({ open: false, from, at: running, why });
|
|
466
|
+
if (!running) return shut(`this file's own version cannot be read`);
|
|
467
|
+
if (!from) return shut(`the base's ${PACKAGE} version cannot be read`);
|
|
468
|
+
if (!installed) return shut(`the installed ${PACKAGE} version cannot be read`);
|
|
469
|
+
if (installed !== running) return shut(`${running} is running but ${installed} is installed`);
|
|
470
|
+
if (compareVersions(from, running) >= 0) return shut(`the version did not go up (${from} at the base, ${running} here)`);
|
|
471
|
+
return { open: true, from, at: running, why: '' };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* The entries a bump may re-list: "file\nrule" for each breach in `all` (this version's) that
|
|
476
|
+
* `asBase` (the same files, run as the base's version) does not hold, on a file `changed` (the
|
|
477
|
+
* files this change touches) does not hold. Pure.
|
|
478
|
+
*/
|
|
479
|
+
export function relistable({ all, asBase, changed }) {
|
|
480
|
+
const key = (b) => `${b.file}\n${b.rule}`;
|
|
481
|
+
const before = new Set(asBase.map(key));
|
|
482
|
+
return new Set(all.filter((b) => !NEVER_LISTED.includes(b.rule) && !changed.has(b.file) && !before.has(key(b))).map(key));
|
|
376
483
|
}
|
|
377
484
|
|
|
378
485
|
/** The entries a first list holds for these breaches: { file: [rule, …] }, sorted. */
|
|
@@ -382,10 +489,24 @@ export function entriesFor(all) {
|
|
|
382
489
|
for (const k of Object.keys(entries)) entries[k].sort();
|
|
383
490
|
return Object.fromEntries(Object.keys(entries).sort().map((k) => [k, entries[k]]));
|
|
384
491
|
}
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
492
|
+
/** The list as written: one line per file; `moved`, when it has lines, after the entries. */
|
|
493
|
+
export const listedText = (entries, moved = {}) => {
|
|
494
|
+
const block = (map) => {
|
|
495
|
+
const rows = Object.keys(map).map((k) => ` ${JSON.stringify(k)}: ${JSON.stringify(map[k])}`);
|
|
496
|
+
return `{${rows.length ? `\n${rows.join(',\n')}\n ` : ''}}`;
|
|
497
|
+
};
|
|
498
|
+
return `{\n "v": ${VERSION},\n "entries": ${block(entries)}${Object.keys(moved).length ? `,\n "moved": ${block(moved)}` : ''}\n}\n`;
|
|
388
499
|
};
|
|
500
|
+
/** `entries` with each "file\nrule" of `keys` added, sorted as the first list is. */
|
|
501
|
+
export function withEntries(entries, keys) {
|
|
502
|
+
const out = Object.fromEntries(Object.entries(entries).map(([f, r]) => [f, [...r]]));
|
|
503
|
+
for (const k of keys) {
|
|
504
|
+
const [file, rule] = k.split('\n');
|
|
505
|
+
if (!(out[file] ??= []).includes(rule)) out[file].push(rule);
|
|
506
|
+
}
|
|
507
|
+
for (const f of Object.keys(out)) out[f].sort();
|
|
508
|
+
return Object.fromEntries(Object.keys(out).sort().map((f) => [f, out[f]]));
|
|
509
|
+
}
|
|
389
510
|
export function countLines(text) {
|
|
390
511
|
return text.length ? text.split('\n').length - (text.endsWith('\n') ? 1 : 0) : 0;
|
|
391
512
|
}
|
|
@@ -418,7 +539,9 @@ export function withDefaults(raw) {
|
|
|
418
539
|
* named), or the whole file when the key is null or empty. No map there cannot run (exit 3): an
|
|
419
540
|
* empty register would read as "no test reads anything".
|
|
420
541
|
*/
|
|
421
|
-
export function registerOf(file, reads) {
|
|
542
|
+
export function registerOf(file, reads, as = null) {
|
|
543
|
+
// Before 1.0.2 a key of null or "" fell back to "tests", and no map there read as empty.
|
|
544
|
+
if (!detects('T4:whole-register', as)) return file?.[reads.key ?? 'tests'] ?? {};
|
|
422
545
|
const key = reads.key === undefined ? 'tests' : reads.key;
|
|
423
546
|
const whole = key === null || key === '';
|
|
424
547
|
const map = whole ? file : file?.[key];
|
|
@@ -431,11 +554,12 @@ export function registerOf(file, reads) {
|
|
|
431
554
|
// The command line: the only part that touches disk and git.
|
|
432
555
|
// ---------------------------------------------------------------------------------------------
|
|
433
556
|
export function parseArgs(argv) {
|
|
434
|
-
const o = { dir: null, base: null, noBase: false, list: false, init: false, help: false };
|
|
557
|
+
const o = { dir: null, base: null, noBase: false, list: false, init: false, relist: false, help: false };
|
|
435
558
|
for (let i = 0; i < argv.length; i++) {
|
|
436
559
|
const a = argv[i];
|
|
437
560
|
if (a === '--list') o.list = true;
|
|
438
561
|
else if (a === '--init') o.init = true;
|
|
562
|
+
else if (a === '--relist') o.relist = true;
|
|
439
563
|
else if (a === '--no-base') o.noBase = true;
|
|
440
564
|
else if (a === '--help' || a === '-h') o.help = true;
|
|
441
565
|
else if (a === '--dir' || a === '--base') {
|
|
@@ -445,9 +569,50 @@ export function parseArgs(argv) {
|
|
|
445
569
|
} else throw fail(EXIT.USAGE, `unknown argument ${a}`);
|
|
446
570
|
}
|
|
447
571
|
if (o.base && o.noBase) throw fail(EXIT.USAGE, '--base and --no-base together');
|
|
572
|
+
if (o.relist && o.init) throw fail(EXIT.USAGE, '--relist and --init together');
|
|
573
|
+
if (o.relist && o.noBase) throw fail(EXIT.USAGE, '--relist needs a base: it re-lists against the base\'s version');
|
|
448
574
|
return o;
|
|
449
575
|
}
|
|
450
576
|
|
|
577
|
+
// ---------------------------------------------------------------------------------------------
|
|
578
|
+
// Versions. `read(rel)` gives a file's text, or null: at the base through git, here from disk.
|
|
579
|
+
// ---------------------------------------------------------------------------------------------
|
|
580
|
+
const jsonOrNull = (text) => { if (text == null) return null; try { return JSON.parse(text); } catch { return null; } };
|
|
581
|
+
const exact = (v) => (typeof v === 'string' && SEMVER.test(v) ? v : null);
|
|
582
|
+
/**
|
|
583
|
+
* The installed version of the package: for each folder in `prefixes` ('' is the repo root), its
|
|
584
|
+
* package-lock.json, else an exact pin in its package.json; else the orchestrator's own manifest
|
|
585
|
+
* (SOURCE_MANIFEST). null when none says.
|
|
586
|
+
*/
|
|
587
|
+
export function installedVersion(read, prefixes = ['']) {
|
|
588
|
+
for (const p of prefixes) {
|
|
589
|
+
const lock = jsonOrNull(read(`${p}package-lock.json`));
|
|
590
|
+
const locked = exact(lock?.packages?.[`node_modules/${PACKAGE}`]?.version) ?? exact(lock?.dependencies?.[PACKAGE]?.version);
|
|
591
|
+
if (locked) return locked;
|
|
592
|
+
const manifest = jsonOrNull(read(`${p}package.json`));
|
|
593
|
+
const pinned = exact(manifest?.devDependencies?.[PACKAGE]) ?? exact(manifest?.dependencies?.[PACKAGE]);
|
|
594
|
+
if (pinned) return pinned;
|
|
595
|
+
}
|
|
596
|
+
const own = jsonOrNull(read(SOURCE_MANIFEST));
|
|
597
|
+
return own?.name === PACKAGE ? exact(own.version) : null;
|
|
598
|
+
}
|
|
599
|
+
/** The folders to read the installed version from: the one that holds node_modules/<PACKAGE> when this file runs from there, then the root. */
|
|
600
|
+
export function installPrefixes(dir, modulePath) {
|
|
601
|
+
const rel = relative(real(dir), real(modulePath)).split(sep).join('/');
|
|
602
|
+
const at = rel.startsWith('../') ? -1 : rel.indexOf(`node_modules/${PACKAGE}/`);
|
|
603
|
+
return at > 0 ? [rel.slice(0, at), ''] : [''];
|
|
604
|
+
}
|
|
605
|
+
/** This file's own version: the manifest beside it (as published), else the orchestrator's package folder. */
|
|
606
|
+
export function ownVersion(modulePath = fileURLToPath(import.meta.url)) {
|
|
607
|
+
for (const rel of ['package.json', 'test-rules-package/package.json']) {
|
|
608
|
+
let manifest = null;
|
|
609
|
+
try { manifest = jsonOrNull(readFileSync(join(dirname(modulePath), rel), 'utf8')); } catch { /* not there */ }
|
|
610
|
+
if (manifest?.name === PACKAGE && exact(manifest.version)) return manifest.version;
|
|
611
|
+
}
|
|
612
|
+
return null;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const real = (p) => { try { return realpathSync(p); } catch { return p; } };
|
|
451
616
|
const git = (dir, args) => execFileSync('git', args, { cwd: dir, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
452
617
|
const gitOr = (dir, args) => { try { return git(dir, args); } catch { return null; } };
|
|
453
618
|
function readJson(dir, rel) {
|
|
@@ -462,8 +627,21 @@ export function entriesOf(obj, where) {
|
|
|
462
627
|
if (!ok) throw fail(EXIT.FAILED, `${where}: must be { "v": ${VERSION}, "entries": { "<test file>": ["<rule id>", …] } }`);
|
|
463
628
|
return e;
|
|
464
629
|
}
|
|
630
|
+
/** The optional `moved` map of a list: { "<new test file>": "<old test file>" }, or {} when absent. */
|
|
631
|
+
export function movedOf(obj, where) {
|
|
632
|
+
if (obj?.moved === undefined) return {};
|
|
633
|
+
const m = obj.moved;
|
|
634
|
+
const ok = m && typeof m === 'object' && !Array.isArray(m)
|
|
635
|
+
&& Object.entries(m).every(([to, from]) => typeof from === 'string' && from.length > 0 && from !== to);
|
|
636
|
+
if (!ok) throw fail(EXIT.FAILED, `${where}: "moved" must be { "<new test file>": "<old test file>", … }, each old file named and not the new one`);
|
|
637
|
+
return m;
|
|
638
|
+
}
|
|
465
639
|
|
|
466
|
-
|
|
640
|
+
/**
|
|
641
|
+
* The command line. `running`: this file's version (default: read from its manifest); `modulePath`:
|
|
642
|
+
* where this file is, to find the folder it is installed in. Both are for tests.
|
|
643
|
+
*/
|
|
644
|
+
export function main(argv = [], { log = console.log, error = console.error, running = undefined, modulePath = fileURLToPath(import.meta.url) } = {}) {
|
|
467
645
|
try {
|
|
468
646
|
const opts = parseArgs(argv);
|
|
469
647
|
if (opts.help) { log(USAGE); return EXIT.CLEAN; }
|
|
@@ -475,7 +653,8 @@ export function main(argv = [], { log = console.log, error = console.error } = {
|
|
|
475
653
|
const tracked = new Set(present);
|
|
476
654
|
const testPaths = present.filter((p) => !p.split('/').includes('node_modules') && matches(config.tests, p) && !matches(config.ignore, p)).sort();
|
|
477
655
|
const files = testPaths.map((path) => ({ path, text: readFileSync(join(dir, path), 'utf8') }));
|
|
478
|
-
const
|
|
656
|
+
const registerFile = config.reads && typeof config.reads === 'object' ? readJson(dir, config.reads.json) : null;
|
|
657
|
+
const register = registerFile ? registerOf(registerFile, config.reads) : null;
|
|
479
658
|
const all = breachesOf({ files, config, register, tracked });
|
|
480
659
|
|
|
481
660
|
if (opts.init) {
|
|
@@ -486,9 +665,13 @@ export function main(argv = [], { log = console.log, error = console.error } = {
|
|
|
486
665
|
return EXIT.CLEAN;
|
|
487
666
|
}
|
|
488
667
|
|
|
489
|
-
const
|
|
668
|
+
const listedJson = existsSync(join(dir, LISTED_FILE)) ? readJson(dir, LISTED_FILE) : null;
|
|
669
|
+
const listed = listedJson ? entriesOf(listedJson, LISTED_FILE) : {};
|
|
670
|
+
const moved = listedJson ? movedOf(listedJson, LISTED_FILE) : {};
|
|
490
671
|
let base = null;
|
|
491
672
|
let baseNote = 'no base';
|
|
673
|
+
let relist = null;
|
|
674
|
+
let gate = null;
|
|
492
675
|
if (!opts.noBase) {
|
|
493
676
|
const ref = opts.base ?? DEFAULT_BASE;
|
|
494
677
|
const mb = gitOr(dir, ['merge-base', ref, 'HEAD'])?.trim();
|
|
@@ -509,16 +692,53 @@ export function main(argv = [], { log = console.log, error = console.error } = {
|
|
|
509
692
|
}
|
|
510
693
|
base = { listed: baseListed, lines };
|
|
511
694
|
baseNote = `base ${mb.slice(0, 12)}${baseListed ? '' : ' (no list there)'}`;
|
|
695
|
+
|
|
696
|
+
// Re-listed once per version: only when this change raises the installed version.
|
|
697
|
+
const prefixes = installPrefixes(dir, modulePath);
|
|
698
|
+
gate = relistGate({
|
|
699
|
+
from: installedVersion((rel) => gitOr(dir, ['show', `${mb}:${rel}`]), prefixes),
|
|
700
|
+
installed: installedVersion((rel) => { try { return readFileSync(join(dir, rel), 'utf8'); } catch { return null; } }, prefixes),
|
|
701
|
+
running: running === undefined ? ownVersion(modulePath) : running,
|
|
702
|
+
});
|
|
703
|
+
if (gate.open) {
|
|
704
|
+
const changed = new Set([
|
|
705
|
+
...git(dir, ['diff', '--name-only', '--no-renames', '-z', mb]).split('\0'),
|
|
706
|
+
...git(dir, ['ls-files', '-o', '--exclude-standard', '-z']).split('\0'),
|
|
707
|
+
].filter(Boolean));
|
|
708
|
+
const asBase = breachesOf({ files, config, register: registerFile ? registerOf(registerFile, config.reads, gate.from) : null, tracked, as: gate.from });
|
|
709
|
+
relist = { from: gate.from, at: gate.at, changed, allowed: relistable({ all, asBase, changed }) };
|
|
710
|
+
baseNote += `; ${PACKAGE} ${gate.from} → ${gate.at}`;
|
|
711
|
+
} else if (gate.from && gate.from !== gate.at) baseNote += `; no re-list (${gate.why})`;
|
|
512
712
|
} else baseNote = `no base (${ref} does not resolve)`;
|
|
513
713
|
}
|
|
714
|
+
|
|
715
|
+
if (opts.relist) {
|
|
716
|
+
if (!base) throw fail(EXIT.USAGE, `--relist needs a base: ${baseNote}`);
|
|
717
|
+
if (!base.listed) throw fail(EXIT.USAGE, `the base has no ${LISTED_FILE}: write the first list with --init`);
|
|
718
|
+
if (!relist) throw fail(EXIT.USAGE, `nothing may be re-listed: ${gate.why}`);
|
|
719
|
+
const on = (b) => (listed[b.file] ?? []).includes(b.rule);
|
|
720
|
+
const add = all.filter((b) => !on(b) && relist.allowed.has(`${b.file}\n${b.rule}`)).map((b) => `${b.file}\n${b.rule}`);
|
|
721
|
+
if (!add.length) {
|
|
722
|
+
log(`test-rules: nothing to re-list at ${relist.at} (the base ran ${relist.from})`);
|
|
723
|
+
return EXIT.CLEAN;
|
|
724
|
+
}
|
|
725
|
+
mkdirSync(dirname(join(dir, LISTED_FILE)), { recursive: true });
|
|
726
|
+
writeFileSync(join(dir, LISTED_FILE), listedText(withEntries(listed, add), moved));
|
|
727
|
+
for (const k of add) log(`relisted ${k.replace('\n', ' ')}`);
|
|
728
|
+
log(`test-rules: re-listed at ${relist.at}: ${add.length} entries, written to ${LISTED_FILE} (the base ran ${relist.from})`);
|
|
729
|
+
return EXIT.CLEAN;
|
|
730
|
+
}
|
|
731
|
+
|
|
514
732
|
const lineCounts = Object.fromEntries(files.map((f) => [f.path, countLines(f.text)]));
|
|
515
|
-
const v = verdict({ all, listed, base, lineCounts });
|
|
733
|
+
const v = verdict({ all, listed, base, lineCounts, moved, present: new Set(testPaths), relist });
|
|
516
734
|
|
|
517
735
|
if (opts.list) {
|
|
518
|
-
log(JSON.stringify({ v: VERSION, breaches: v.breaches, listed: v.listed, stale: v.stale }));
|
|
736
|
+
log(JSON.stringify({ v: VERSION, breaches: v.breaches, listed: v.listed, stale: v.stale, relisted: v.relisted }));
|
|
519
737
|
} else {
|
|
520
738
|
for (const b of v.breaches) log(`${b.rule} ${b.file}:${b.line} — ${b.say}`);
|
|
521
739
|
for (const s of v.stale) log(`stale ${s.file} ${s.rule} — the breach is gone: delete it from ${LISTED_FILE}`);
|
|
740
|
+
for (const r of v.relisted) log(`relisted ${r.file} ${r.rule} — a detection newer than the base's ${relist.from} finds it`);
|
|
741
|
+
if (v.relisted.length) log(`test-rules: re-listed at ${relist.at}: ${v.relisted.length} entries`);
|
|
522
742
|
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)'}`);
|
|
523
743
|
}
|
|
524
744
|
return config.fail && (v.breaches.length || v.stale.length) ? EXIT.BREACH : EXIT.CLEAN;
|
|
@@ -530,5 +750,4 @@ export function main(argv = [], { log = console.log, error = console.error } = {
|
|
|
530
750
|
}
|
|
531
751
|
}
|
|
532
752
|
|
|
533
|
-
const real = (p) => { try { return realpathSync(p); } catch { return p; } };
|
|
534
753
|
if (process.argv[1] && real(process.argv[1]) === real(fileURLToPath(import.meta.url))) process.exitCode = main(process.argv.slice(2));
|