@syncerauk/test-rules 1.0.5 → 1.0.7
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 +67 -1
- package/package.json +1 -1
- package/test-rules.mjs +280 -40
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
|
|
|
@@ -36,6 +37,7 @@ npm run gate:test-rules -- --base origin/main --list
|
|
|
36
37
|
| `T1:hub` | A file listed as over the line limit grew against the base |
|
|
37
38
|
| `R7:lines` | A test file over 1,500 lines |
|
|
38
39
|
| `T4:source-text` | A test reads a source file as text |
|
|
40
|
+
| `T4:pin` | A `pins` entry that does not hold (judged on the config, never listed) |
|
|
39
41
|
| `T6:timer` | A real wait, with no fake timers in the file |
|
|
40
42
|
| `T6:clock` | The wall clock, with no fake timers in the file |
|
|
41
43
|
| `T6:outside` | The home folder, a path that climbs out of the repo, a real host |
|
|
@@ -51,6 +53,7 @@ Both in the repo's `scripts/test-rules/`:
|
|
|
51
53
|
`ignore` (which files are test files), `fixtures` (folders a test may read), `wording` (the
|
|
52
54
|
one list of what a person reads), `style_contract` (`{ "<test file>": ["<.css or .scss path or
|
|
53
55
|
glob>"] }`: the one test that may read those stylesheets; style rules are checked there, once),
|
|
56
|
+
`pins` (the kept pins, below),
|
|
54
57
|
`reads` (the reads register: `"comment"`, or
|
|
55
58
|
`{ "json": "<path>" }` for the map under `"tests"` in that file, `"key": "<name>"` for another
|
|
56
59
|
key, `"key": null` when the file itself is the map). The defaults are `CONFIG_DEFAULTS` in the
|
|
@@ -59,6 +62,28 @@ Both in the repo's `scripts/test-rules/`:
|
|
|
59
62
|
`test-rules --init`. An optional `moved` map, `{ "<new test file>": "<old test file>" }`,
|
|
60
63
|
says where a split or a rename took a listed file.
|
|
61
64
|
|
|
65
|
+
## Kept pins
|
|
66
|
+
|
|
67
|
+
R4 allows a pin (a test that asserts on source text, a prompt or a document) only as a guard
|
|
68
|
+
with a recorded mutation that turns it red, one per rule (Bernardo Cunha, 6 Nov 2026). The
|
|
69
|
+
config registers each kept pin in `pins`:
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
"pins": [
|
|
73
|
+
{ "file": "<test file>", "rule": "<the rule it guards>",
|
|
74
|
+
"mutation": "<the change that turns it red>", "red_before": "<where that red is recorded>" }
|
|
75
|
+
]
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
- A test file on `pins` is not a `T4:source-text` breach. It excuses T4 only, and only in that
|
|
79
|
+
file.
|
|
80
|
+
- An entry fails the check (`T4:pin`, on the config) when it names no file or one that is not a
|
|
81
|
+
test file here, names no rule, names a rule another entry already pins (one pin per rule),
|
|
82
|
+
records no mutation or no `red_before`, or carries a key not above. An entry that fails
|
|
83
|
+
excuses nothing.
|
|
84
|
+
- `T4:pin` is never listed. Registering a listed file makes its `T4:source-text` entry stale:
|
|
85
|
+
delete that line in the same change.
|
|
86
|
+
|
|
62
87
|
## The list only shrinks
|
|
63
88
|
|
|
64
89
|
- A breach on the list passes. A breach off the list fails.
|
|
@@ -71,8 +96,49 @@ Both in the repo's `scripts/test-rules/`:
|
|
|
71
96
|
|
|
72
97
|
A repo starts with `"fail": false`: breaches are printed and the exit is 0. One line turns it on.
|
|
73
98
|
|
|
99
|
+
## Bumping the version: re-listed once per version
|
|
100
|
+
|
|
101
|
+
A newer version can find breaches an older one missed, in files nobody touched (Bernardo Cunha,
|
|
102
|
+
6 Nov 2026: re-list once per version). The PR that bumps the pin may add those entries to
|
|
103
|
+
`listed.json`, and only those:
|
|
104
|
+
|
|
105
|
+
- the PR raises the installed version: the base's is lower, and the version running is the one
|
|
106
|
+
installed (the lockfile, else an exact pin in `package.json`, read at the base through git);
|
|
107
|
+
- the breach is found only by a detection newer than the base's version: the check runs a
|
|
108
|
+
second time as the base's version (the `DETECTORS` table in `test-rules.mjs` says which version
|
|
109
|
+
first found what) and only what that run does not find counts;
|
|
110
|
+
- the PR does not change the file.
|
|
111
|
+
|
|
112
|
+
Any other new entry is still `list:grew`. Allowed growth is printed (`re-listed at 1.0.6: N
|
|
113
|
+
entries`, one `relisted` line each). Do not edit the list by hand: after the bump and
|
|
114
|
+
`npm install`, run
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
npm run gate:test-rules -- --relist
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
It writes exactly the allowed entries into `listed.json` (keeping `moved`) and refuses (exit 2)
|
|
121
|
+
when the PR does not raise the version.
|
|
122
|
+
|
|
123
|
+
| Version | Detections it added |
|
|
124
|
+
|---|---|
|
|
125
|
+
| 1.0.0 | every rule: `T1:name`, `T1:hub`, `R7:lines`, `T4:source-text` (a declared read, a raw import, a source path built from the test's place), `T6:timer`, `T6:clock`, `T6:outside`, `T6:env` |
|
|
126
|
+
| 1.0.1, 1.0.4, 1.0.5, 1.0.6 | none |
|
|
127
|
+
| 1.0.2 | `T4:source-text`: a path that starts with a dot-folder; a JSON register that is the whole file |
|
|
128
|
+
| 1.0.3 | `T4:source-text`: the built-path scan under a JSON register |
|
|
129
|
+
| 1.0.7 | `T4:pin`: a `pins` entry that does not hold (never listed, so never re-listed) |
|
|
130
|
+
|
|
74
131
|
## Versions
|
|
75
132
|
|
|
133
|
+
- **1.0.7** — Kept pins: the config's `pins` list registers each pin R4 allows, with the rule it
|
|
134
|
+
guards and its recorded mutation; a file on it is not a `T4:source-text` breach, and an entry
|
|
135
|
+
that does not hold is `T4:pin`. Nothing changes for a repo that does not set it; an older
|
|
136
|
+
version refuses a config with `pins` (unknown key), so bump before you register one.
|
|
137
|
+
|
|
138
|
+
- **1.0.6** — Re-listed once per version: a PR that raises the installed version may add the
|
|
139
|
+
entries a newer detection finds in files it does not change; `--relist` writes them.
|
|
140
|
+
`--list` carries `relisted`. Nothing new is found.
|
|
141
|
+
|
|
76
142
|
- **1.0.5** — A split or rename carries its listed breaches: `listed.json` may hold
|
|
77
143
|
`"moved": { "<new test file>": "<old test file>" }`, and a new file's entry is not
|
|
78
144
|
`list:grew` when the base listed the same rule on the old file and the old file is gone.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncerauk/test-rules",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
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,18 +4,21 @@
|
|
|
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
|
-
// scripts/test-rules/config.json what differs between repos (CONFIG_DEFAULTS below)
|
|
20
|
+
// scripts/test-rules/config.json what differs between repos (CONFIG_DEFAULTS below); its
|
|
21
|
+
// `pins` list registers the kept pins (1.0.7, below)
|
|
19
22
|
// scripts/test-rules/listed.json { v: 1, entries: { "<test file>": ["<rule id>", …] },
|
|
20
23
|
// moved: { "<new test file>": "<old test file>" } }
|
|
21
24
|
// `moved` is optional: it says where a split or a rename took a listed file (1.0.5).
|
|
@@ -30,6 +33,17 @@
|
|
|
30
33
|
// `list:grew` when `moved` names its old file, the base listed that same rule on the old file,
|
|
31
34
|
// and the old file is no longer a test file. A `moved` line is never stale and never a breach;
|
|
32
35
|
// it may be deleted at any time.
|
|
36
|
+
// Re-listed once per version (Bernardo Cunha, 6 Nov 2026, 1.0.6): a newer version can find
|
|
37
|
+
// breaches an older one missed, in files nobody touched. An entry the base does not hold is not
|
|
38
|
+
// `list:grew` when all three hold: (a) this change raises the installed version (the base's is
|
|
39
|
+
// lower than the one running, and the one running is the one installed); (b) the breach is found
|
|
40
|
+
// only by a detection newer than the base's version (DETECTORS below: the check is run a second
|
|
41
|
+
// time as the base's version, and only what that run does not find counts); (c) the change does
|
|
42
|
+
// not edit the file. Allowed growth is printed ("re-listed at <version>: N entries"), never
|
|
43
|
+
// silent. `--relist` writes exactly those entries into the list, so nobody edits it by hand.
|
|
44
|
+
// The version installed is read from the lockfile, else an exact pin in package.json (in the
|
|
45
|
+
// folder that holds node_modules/@syncerauk/test-rules, then the repo root), else, in the
|
|
46
|
+
// orchestrator, which runs this file itself, from scripts/test-rules-package/package.json.
|
|
33
47
|
// `"fail": false` in the config is report only: everything is printed, the exit is 0.
|
|
34
48
|
//
|
|
35
49
|
// The rules (RULES below) and how each is seen:
|
|
@@ -47,6 +61,10 @@
|
|
|
47
61
|
// A JSON register is what a test says it reads; it does not stop the code
|
|
48
62
|
// being read for what it does read (1.0.3). A stylesheet named for a test in
|
|
49
63
|
// `style_contract` is not a breach in that test, and only in that test (1.0.4).
|
|
64
|
+
// A test on the config's `pins` list is a kept pin (R4) and not a breach (1.0.7).
|
|
65
|
+
// T4:pin a `pins` entry that does not hold: no test file, or one that is not a test
|
|
66
|
+
// file here; no rule it guards, or a rule another entry already pins (one pin
|
|
67
|
+
// per rule); no recorded mutation, or no record of its red-before (1.0.7)
|
|
50
68
|
// T6:timer setTimeout( / setInterval( / sleep( / timers/promises with no fake timers in the file
|
|
51
69
|
// T6:clock Date.now( / new Date() / performance.now( with no fake timers in the file
|
|
52
70
|
// T6:outside homedir(), process.env.HOME, a relative path that climbs out of the repo,
|
|
@@ -55,12 +73,13 @@
|
|
|
55
73
|
// T6:env process.env.<NAME> read and never set in that file
|
|
56
74
|
// Comments are never read; code patterns are never matched inside a string.
|
|
57
75
|
// The config and the list are read by the command line only, and the check runs on every small
|
|
58
|
-
// gate, so no test declares them.
|
|
76
|
+
// gate, so no test declares them. Its own version is read from its manifest:
|
|
77
|
+
// reads: scripts/test-rules-package/package.json
|
|
59
78
|
// =============================================================================
|
|
60
79
|
|
|
61
80
|
import { execFileSync } from 'node:child_process';
|
|
62
81
|
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
63
|
-
import { dirname, join, posix, resolve } from 'node:path';
|
|
82
|
+
import { dirname, join, posix, relative, resolve, sep } from 'node:path';
|
|
64
83
|
import { fileURLToPath } from 'node:url';
|
|
65
84
|
|
|
66
85
|
export const VERSION = 1;
|
|
@@ -68,7 +87,10 @@ export const EXIT = Object.freeze({ CLEAN: 0, BREACH: 1, USAGE: 2, FAILED: 3 });
|
|
|
68
87
|
export const CONFIG_FILE = 'scripts/test-rules/config.json';
|
|
69
88
|
export const LISTED_FILE = 'scripts/test-rules/listed.json';
|
|
70
89
|
export const DEFAULT_BASE = 'origin/main';
|
|
71
|
-
const
|
|
90
|
+
export const PACKAGE = '@syncerauk/test-rules';
|
|
91
|
+
/** The package's manifest in the orchestrator, the repo that runs this file itself. */
|
|
92
|
+
export const SOURCE_MANIFEST = 'scripts/test-rules-package/package.json';
|
|
93
|
+
const USAGE = 'usage: test-rules [--dir <worktree>] [--base <ref> | --no-base] [--list] [--init | --relist]';
|
|
72
94
|
|
|
73
95
|
/** What a repo's scripts/test-rules/config.json may set; anything it leaves out is this. */
|
|
74
96
|
export const CONFIG_DEFAULTS = Object.freeze({
|
|
@@ -79,6 +101,7 @@ export const CONFIG_DEFAULTS = Object.freeze({
|
|
|
79
101
|
fixtures: [], // folders a test may read: `a/fixtures/`
|
|
80
102
|
wording: [], // the one list of what a person reads (a built page, a legal text): files, globs, folders
|
|
81
103
|
style_contract: {}, // { "<test file>": ["<.css/.scss path or glob>", …] }: the one test that may read those stylesheets
|
|
104
|
+
pins: [], // [{ file, rule, mutation, red_before }]: the kept pins (R4), one per rule, each with the mutation that turns it red
|
|
82
105
|
reads: null, // the reads register: null, "comment" (`// reads:` lines) or { json: "<path>", key: "tests" } (key null: the file itself is the map)
|
|
83
106
|
source_ext: ['mjs', 'cjs', 'js', 'jsx', 'ts', 'tsx', 'mts', 'cts', 'sql', 'css', 'scss', 'html', 'sh', 'yml', 'yaml', 'md', 'mdx'],
|
|
84
107
|
max_lines: 1500,
|
|
@@ -93,6 +116,7 @@ export const RULES = Object.freeze({
|
|
|
93
116
|
'T1:hub': 'this file is listed as over the line limit and it grew: the new test goes in a new file, by topic',
|
|
94
117
|
'R7:lines': 'over the line limit: split the file by topic',
|
|
95
118
|
'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)',
|
|
119
|
+
'T4:pin': 'a kept pin (R4) is the guard of one rule: its `pins` entry names the test file, the rule, the mutation that turns it red and where that red-before is recorded',
|
|
96
120
|
'T6:timer': 'no real wait in a gated test: use the fake clock or fake timers',
|
|
97
121
|
'T6:clock': 'no wall clock in a gated test: pass a fixed time in, or use fake timers',
|
|
98
122
|
'T6:outside': 'nothing outside the commit: no home folder, machine path, path out of the repo or real host',
|
|
@@ -100,7 +124,43 @@ export const RULES = Object.freeze({
|
|
|
100
124
|
'list:grew': 'the list only shrinks: fix the breach, do not list it',
|
|
101
125
|
});
|
|
102
126
|
/** The rules a listed.json entry can never excuse. */
|
|
103
|
-
export const NEVER_LISTED = Object.freeze(['T1:hub', 'list:grew']);
|
|
127
|
+
export const NEVER_LISTED = Object.freeze(['T1:hub', 'T4:pin', 'list:grew']);
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Each way the check finds a breach: the rule it reports and the version that first found it,
|
|
131
|
+
* from the git history of this file. `breachesOf({ as })` runs only the ones a version had, so a
|
|
132
|
+
* re-list can tell what a bump newly finds from what was there all along. A version that changes
|
|
133
|
+
* how a breach is found adds a row here, with its own `since`, and keeps the old behaviour
|
|
134
|
+
* behind it. 1.0.1 (the same file as 1.0.0), 1.0.4 (`style_contract`: it narrows T4 and finds
|
|
135
|
+
* nothing new), 1.0.5 (`moved`) and 1.0.6 (the re-list) add none. 1.0.7 adds T4:pin, which is
|
|
136
|
+
* judged on the config and never listed, so a re-list never carries it.
|
|
137
|
+
*/
|
|
138
|
+
export const DETECTORS = Object.freeze({
|
|
139
|
+
'T1:name': { rule: 'T1:name', since: '1.0.0' }, // the file named by task
|
|
140
|
+
'T1:hub': { rule: 'T1:hub', since: '1.0.0' }, // a listed long file grew (judged against the base, never listed)
|
|
141
|
+
'R7:lines': { rule: 'R7:lines', since: '1.0.0' }, // over max_lines
|
|
142
|
+
'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
|
|
143
|
+
'T4:raw': { rule: 'T4:source-text', since: '1.0.0' }, // a `?raw` import or a raw import.meta.glob
|
|
144
|
+
'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
|
|
145
|
+
'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
|
|
146
|
+
'T4:whole-register': { rule: 'T4:source-text', since: '1.0.2' }, // a JSON register that is the whole file ("key": null) is read, not taken as empty
|
|
147
|
+
'T4:built-under-json': { rule: 'T4:source-text', since: '1.0.3' }, // the built-path scan runs under a JSON register too
|
|
148
|
+
'T4:pin': { rule: 'T4:pin', since: '1.0.7' }, // a `pins` entry that does not hold (judged on the config, never listed)
|
|
149
|
+
'T6:timer': { rule: 'T6:timer', since: '1.0.0' },
|
|
150
|
+
'T6:clock': { rule: 'T6:clock', since: '1.0.0' },
|
|
151
|
+
'T6:outside': { rule: 'T6:outside', since: '1.0.0' },
|
|
152
|
+
'T6:env': { rule: 'T6:env', since: '1.0.0' },
|
|
153
|
+
});
|
|
154
|
+
const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
155
|
+
/** -1, 0 or 1 for two x.y.z versions. */
|
|
156
|
+
export function compareVersions(a, b) {
|
|
157
|
+
const x = String(a).split('.').map(Number);
|
|
158
|
+
const y = String(b).split('.').map(Number);
|
|
159
|
+
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
/** Whether version `as` had detector `id` (no `as`: this version, every detector). */
|
|
163
|
+
export const detects = (id, as = null) => !as || compareVersions(DETECTORS[id].since, as) <= 0;
|
|
104
164
|
|
|
105
165
|
const fail = (code, message) => Object.assign(new Error(message), { exit: code });
|
|
106
166
|
|
|
@@ -279,7 +339,8 @@ function envLine({ code, bare }, allowed) {
|
|
|
279
339
|
}
|
|
280
340
|
|
|
281
341
|
/** T4: the line of the first read of a source file as text, or 0. */
|
|
282
|
-
function sourceTextLine({ path, text, code, bare, strings }, { config, register, tracked }) {
|
|
342
|
+
function sourceTextLine({ path, text, code, bare, strings }, { config, register, tracked, as }) {
|
|
343
|
+
const on = (id) => detects(id, as);
|
|
283
344
|
const isSource = (p) => config.source_ext.includes(String(p).split('.').pop().toLowerCase());
|
|
284
345
|
// A style contract is the one test that may read the stylesheets it names: style rules are
|
|
285
346
|
// checked once, there, the way wording is checked once from the wording list.
|
|
@@ -288,23 +349,25 @@ function sourceTextLine({ path, text, code, bare, strings }, { config, register,
|
|
|
288
349
|
const offends = (p) => !allowed(p) && (isFolderOrGlob(p) || isSource(p));
|
|
289
350
|
|
|
290
351
|
const declared = declaredIn(text);
|
|
291
|
-
|
|
352
|
+
const json = register && typeof register === 'object';
|
|
353
|
+
if (on('T4:declared') && json) {
|
|
292
354
|
if ((register[path] ?? []).some(offends)) return 1;
|
|
293
|
-
} else if (config.reads === 'comment') {
|
|
355
|
+
} else if (on('T4:declared') && config.reads === 'comment') {
|
|
294
356
|
const hit = declared.reads.find((r) => offends(r.pattern));
|
|
295
357
|
if (hit) return hit.line;
|
|
296
358
|
}
|
|
297
|
-
for (const m of bare.matchAll(RAW_IMPORT)) {
|
|
359
|
+
for (const m of on('T4:raw') ? bare.matchAll(RAW_IMPORT) : []) {
|
|
298
360
|
if (code[m.index] === ' ') continue; // the words are inside a string, not an import
|
|
299
361
|
if (!allowed(posix.normalize(posix.join(posix.dirname(path), m[1])))) return lineOf(bare, m.index);
|
|
300
362
|
}
|
|
301
|
-
for (const m of code.matchAll(RAW_GLOB)) {
|
|
363
|
+
for (const m of on('T4:raw') ? code.matchAll(RAW_GLOB) : []) {
|
|
302
364
|
const call = bare.slice(m.index, m.index + 300).split(')')[0];
|
|
303
365
|
if (/\?raw|as\s*:\s*['"]raw['"]/.test(call)) return lineOf(code, m.index);
|
|
304
366
|
}
|
|
305
367
|
// A source file whose path the test builds from its own place, in a test that reads the file
|
|
306
368
|
// system, that the test does not declare: `// runs:` when it is only run. A JSON register does
|
|
307
|
-
// not silence this: a register can miss a read, and a source file it names returned above.
|
|
369
|
+
// not silence this (1.0.3): a register can miss a read, and a source file it names returned above.
|
|
370
|
+
if (!on('T4:built') || (json && !on('T4:built-under-json'))) return 0;
|
|
308
371
|
if (!FS_READ.test(code) || !tracked) return 0;
|
|
309
372
|
const said = [...declared.reads, ...declared.runs].map((r) => r.pattern);
|
|
310
373
|
for (const s of strings) {
|
|
@@ -312,9 +375,11 @@ function sourceTextLine({ path, text, code, bare, strings }, { config, register,
|
|
|
312
375
|
if (/[\s*?$]/.test(v) || !isSource(v)) continue;
|
|
313
376
|
const before = code.slice(code.lastIndexOf('\n', s.start) + 1, s.start - 1);
|
|
314
377
|
if (IMPORT_BEFORE.test(before) || !BUILT_HERE.test(before)) continue;
|
|
315
|
-
// `./x` and `../x` are relative to the test; `.github/x` is a repo path like any other.
|
|
378
|
+
// `./x` and `../x` are relative to the test; `.github/x` is a repo path like any other (1.0.2;
|
|
379
|
+
// before it, any leading dot was read as beside the test).
|
|
316
380
|
const here = posix.normalize(posix.join(posix.dirname(path), v));
|
|
317
|
-
const
|
|
381
|
+
const besideTest = on('T4:dot-folder') ? v.startsWith('./') || v.startsWith('../') : v.startsWith('.');
|
|
382
|
+
const target = besideTest || !tracked.has(v) ? here : v;
|
|
318
383
|
if (target === path || !tracked.has(target) || allowed(target)) continue;
|
|
319
384
|
if (said.some((p) => globRe(p).test(target))) continue;
|
|
320
385
|
return lineOf(code, s.start);
|
|
@@ -322,30 +387,65 @@ function sourceTextLine({ path, text, code, bare, strings }, { config, register,
|
|
|
322
387
|
return 0;
|
|
323
388
|
}
|
|
324
389
|
|
|
390
|
+
/**
|
|
391
|
+
* The pins register (1.0.7; R4, Bernardo Cunha, 6 Nov 2026: a pin is allowed only as a guard with
|
|
392
|
+
* a recorded mutation that turns it red, one per rule). `pins`: the config's list; `testPaths`: a
|
|
393
|
+
* Set of the repo's test files. → { pinned: Set of the test files a sound entry names, problems:
|
|
394
|
+
* [why each entry does not hold] }. An entry that does not hold excuses nothing, so a second entry
|
|
395
|
+
* for a rule that has its pin leaves its test a T4 breach.
|
|
396
|
+
*/
|
|
397
|
+
export function pinsOf(pins, testPaths) {
|
|
398
|
+
const pinned = new Set();
|
|
399
|
+
const problems = [];
|
|
400
|
+
const byRule = new Map();
|
|
401
|
+
const said = (v) => typeof v === 'string' && v.trim().length > 0;
|
|
402
|
+
(pins ?? []).forEach((pin, i) => {
|
|
403
|
+
const at = `pins[${i}]${said(pin.file) ? ` (${pin.file})` : ''}`;
|
|
404
|
+
const why = [];
|
|
405
|
+
if (!said(pin.file)) why.push('names no test file');
|
|
406
|
+
else if (!testPaths.has(pin.file)) why.push(`${pin.file} is not a test file in this repo`);
|
|
407
|
+
if (!said(pin.rule)) why.push('names no rule it guards');
|
|
408
|
+
else if (byRule.has(pin.rule.trim())) why.push(`"${pin.rule.trim()}" is already pinned by ${byRule.get(pin.rule.trim())}: one pin per rule`);
|
|
409
|
+
if (!said(pin.mutation)) why.push('records no mutation that turns it red');
|
|
410
|
+
if (!said(pin.red_before)) why.push('says not where its red-before is recorded');
|
|
411
|
+
const unknown = Object.keys(pin).filter((k) => !['file', 'rule', 'mutation', 'red_before'].includes(k));
|
|
412
|
+
if (unknown.length) why.push(`unknown key ${unknown.join(', ')}`);
|
|
413
|
+
if (said(pin.rule) && !byRule.has(pin.rule.trim())) byRule.set(pin.rule.trim(), at);
|
|
414
|
+
if (why.length) problems.push(`${at} ${why.join('; ')}`);
|
|
415
|
+
else pinned.add(pin.file);
|
|
416
|
+
});
|
|
417
|
+
return { pinned, problems };
|
|
418
|
+
}
|
|
419
|
+
|
|
325
420
|
/**
|
|
326
421
|
* Every breach in `files` (the repo's test files as { path, text }), one per file and rule, at
|
|
327
|
-
* its first line: [{ file, rule, line, say }], sorted. Pure: no disk, no git.
|
|
422
|
+
* its first line: [{ file, rule, line, say }], sorted. Pure: no disk, no git. `as`: a version
|
|
423
|
+
* (x.y.z) to run only the detectors it had (DETECTORS), for the re-list; null runs them all.
|
|
328
424
|
*/
|
|
329
|
-
export function breachesOf({ files, config, register = null, tracked = null }) {
|
|
425
|
+
export function breachesOf({ files, config, register = null, tracked = null, as = null }) {
|
|
330
426
|
const out = [];
|
|
331
427
|
const add = (file, rule, line) => out.push({ file, rule, line, say: RULES[rule] });
|
|
428
|
+
const on = (id) => detects(id, as);
|
|
332
429
|
const names = config.task_names.map((s) => new RegExp(s, 'i'));
|
|
333
430
|
const fakes = config.fake_timers.map((s) => new RegExp(s));
|
|
431
|
+
// A kept pin is not a T4 breach; an entry that does not hold is one T4:pin on the config.
|
|
432
|
+
const pins = pinsOf(config.pins, new Set(files.map((f) => f.path)));
|
|
433
|
+
if (on('T4:pin') && pins.problems.length) out.push({ file: CONFIG_FILE, rule: 'T4:pin', line: 1, say: `${RULES['T4:pin']} (${pins.problems.join(' · ')})` });
|
|
334
434
|
for (const { path, text } of files) {
|
|
335
435
|
const view = { path, text, ...scan(text) };
|
|
336
436
|
const base = path.split('/').pop();
|
|
337
|
-
if (names.some((re) => re.test(base))) add(path, 'T1:name', 1);
|
|
338
|
-
if (countLines(text) > config.max_lines) add(path, 'R7:lines', config.max_lines + 1);
|
|
339
|
-
const t4 = sourceTextLine(view, { config, register, tracked });
|
|
340
|
-
if (t4) add(path, 'T4:source-text', t4);
|
|
437
|
+
if (on('T1:name') && names.some((re) => re.test(base))) add(path, 'T1:name', 1);
|
|
438
|
+
if (on('R7:lines') && countLines(text) > config.max_lines) add(path, 'R7:lines', config.max_lines + 1);
|
|
439
|
+
const t4 = sourceTextLine(view, { config, register, tracked, as });
|
|
440
|
+
if (t4 && !pins.pinned.has(path)) add(path, 'T4:source-text', t4);
|
|
341
441
|
const faked = fakes.some((re) => re.test(view.bare));
|
|
342
|
-
const timer = faked ? 0 : timerLine(view);
|
|
442
|
+
const timer = faked || !on('T6:timer') ? 0 : timerLine(view);
|
|
343
443
|
if (timer) add(path, 'T6:timer', timer);
|
|
344
|
-
const clock = faked ? 0 : clockLine(view);
|
|
444
|
+
const clock = faked || !on('T6:clock') ? 0 : clockLine(view);
|
|
345
445
|
if (clock) add(path, 'T6:clock', clock);
|
|
346
|
-
const outside = outsideLine(view);
|
|
446
|
+
const outside = on('T6:outside') ? outsideLine(view) : 0;
|
|
347
447
|
if (outside) add(path, 'T6:outside', outside);
|
|
348
|
-
const env = envLine(view, config.env_allowed);
|
|
448
|
+
const env = on('T6:env') ? envLine(view, config.env_allowed) : 0;
|
|
349
449
|
if (env) add(path, 'T6:env', env);
|
|
350
450
|
}
|
|
351
451
|
return out.sort((a, b) => a.file.localeCompare(b.file) || a.rule.localeCompare(b.rule));
|
|
@@ -358,9 +458,13 @@ export function breachesOf({ files, config, register = null, tracked = null }) {
|
|
|
358
458
|
* `moved`: { new file: old file } from the head's list. `present`: a Set of the head's test
|
|
359
459
|
* files, or null (then nothing is carried). An entry the base does not hold is carried, not
|
|
360
460
|
* `list:grew`, when its old file is gone and the base listed the same rule on it.
|
|
361
|
-
*
|
|
461
|
+
* `relist`: null, or { from, at, allowed: Set of "file\nrule", changed: Set of files } from
|
|
462
|
+
* relistable (a version bump): an entry the base does not hold and `allowed` has is re-listed,
|
|
463
|
+
* not `list:grew`.
|
|
464
|
+
* → { breaches (off the list, plus T1:hub and list:grew), listed (count on the list), stale,
|
|
465
|
+
* relisted: [{ file, rule, at }] }.
|
|
362
466
|
*/
|
|
363
|
-
export function verdict({ all, listed = {}, base = null, lineCounts = {}, moved = {}, present = null }) {
|
|
467
|
+
export function verdict({ all, listed = {}, base = null, lineCounts = {}, moved = {}, present = null, relist = null }) {
|
|
364
468
|
const on = (file, rule) => (listed[file] ?? []).includes(rule) && !NEVER_LISTED.includes(rule);
|
|
365
469
|
const breaches = all.filter((b) => !on(b.file, b.rule));
|
|
366
470
|
const hits = all.filter((b) => on(b.file, b.rule));
|
|
@@ -373,10 +477,16 @@ export function verdict({ all, listed = {}, base = null, lineCounts = {}, moved
|
|
|
373
477
|
const old = Object.hasOwn(moved, file) ? moved[file] : null;
|
|
374
478
|
return typeof old === 'string' && present !== null && !present.has(old) && (base.listed?.[old] ?? []).includes(rule);
|
|
375
479
|
};
|
|
480
|
+
const relisted = [];
|
|
481
|
+
const why = (file) => !relist ? ''
|
|
482
|
+
: relist.changed.has(file) ? ` (a re-list at ${relist.at} never covers a file this change edits)`
|
|
483
|
+
: ` (a re-list at ${relist.at} covers only what ${relist.at} finds and ${relist.from} did not)`;
|
|
376
484
|
if (base) {
|
|
377
485
|
for (const file of Object.keys(listed).sort()) {
|
|
378
486
|
for (const rule of [...listed[file]].sort()) {
|
|
379
|
-
if (base.listed
|
|
487
|
+
if (!base.listed || (base.listed[file] ?? []).includes(rule) || carried(file, rule)) continue;
|
|
488
|
+
if (relist?.allowed.has(`${file}\n${rule}`)) relisted.push({ file, rule, at: relist.at });
|
|
489
|
+
else breaches.push({ file, rule: 'list:grew', line: 1, say: `${rule}: ${RULES['list:grew']}${why(file)}` });
|
|
380
490
|
}
|
|
381
491
|
const was = base.lines?.[file];
|
|
382
492
|
if (listed[file].includes('R7:lines') && Number.isFinite(was) && lineCounts[file] > was) {
|
|
@@ -385,7 +495,33 @@ export function verdict({ all, listed = {}, base = null, lineCounts = {}, moved
|
|
|
385
495
|
}
|
|
386
496
|
}
|
|
387
497
|
breaches.sort((a, b) => a.file.localeCompare(b.file) || a.rule.localeCompare(b.rule));
|
|
388
|
-
return { breaches, listed: hits.length, stale };
|
|
498
|
+
return { breaches, listed: hits.length, stale, relisted };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Whether this change may re-list, from the versions: `from` the base's installed version,
|
|
503
|
+
* `installed` the worktree's, `running` this file's own. → { open, from, at, why }: open only
|
|
504
|
+
* when all three are known, the one running is the one installed, and it is newer than the base's.
|
|
505
|
+
*/
|
|
506
|
+
export function relistGate({ from, installed, running }) {
|
|
507
|
+
const shut = (why) => ({ open: false, from, at: running, why });
|
|
508
|
+
if (!running) return shut(`this file's own version cannot be read`);
|
|
509
|
+
if (!from) return shut(`the base's ${PACKAGE} version cannot be read`);
|
|
510
|
+
if (!installed) return shut(`the installed ${PACKAGE} version cannot be read`);
|
|
511
|
+
if (installed !== running) return shut(`${running} is running but ${installed} is installed`);
|
|
512
|
+
if (compareVersions(from, running) >= 0) return shut(`the version did not go up (${from} at the base, ${running} here)`);
|
|
513
|
+
return { open: true, from, at: running, why: '' };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* The entries a bump may re-list: "file\nrule" for each breach in `all` (this version's) that
|
|
518
|
+
* `asBase` (the same files, run as the base's version) does not hold, on a file `changed` (the
|
|
519
|
+
* files this change touches) does not hold. Pure.
|
|
520
|
+
*/
|
|
521
|
+
export function relistable({ all, asBase, changed }) {
|
|
522
|
+
const key = (b) => `${b.file}\n${b.rule}`;
|
|
523
|
+
const before = new Set(asBase.map(key));
|
|
524
|
+
return new Set(all.filter((b) => !NEVER_LISTED.includes(b.rule) && !changed.has(b.file) && !before.has(key(b))).map(key));
|
|
389
525
|
}
|
|
390
526
|
|
|
391
527
|
/** The entries a first list holds for these breaches: { file: [rule, …] }, sorted. */
|
|
@@ -395,10 +531,24 @@ export function entriesFor(all) {
|
|
|
395
531
|
for (const k of Object.keys(entries)) entries[k].sort();
|
|
396
532
|
return Object.fromEntries(Object.keys(entries).sort().map((k) => [k, entries[k]]));
|
|
397
533
|
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
534
|
+
/** The list as written: one line per file; `moved`, when it has lines, after the entries. */
|
|
535
|
+
export const listedText = (entries, moved = {}) => {
|
|
536
|
+
const block = (map) => {
|
|
537
|
+
const rows = Object.keys(map).map((k) => ` ${JSON.stringify(k)}: ${JSON.stringify(map[k])}`);
|
|
538
|
+
return `{${rows.length ? `\n${rows.join(',\n')}\n ` : ''}}`;
|
|
539
|
+
};
|
|
540
|
+
return `{\n "v": ${VERSION},\n "entries": ${block(entries)}${Object.keys(moved).length ? `,\n "moved": ${block(moved)}` : ''}\n}\n`;
|
|
401
541
|
};
|
|
542
|
+
/** `entries` with each "file\nrule" of `keys` added, sorted as the first list is. */
|
|
543
|
+
export function withEntries(entries, keys) {
|
|
544
|
+
const out = Object.fromEntries(Object.entries(entries).map(([f, r]) => [f, [...r]]));
|
|
545
|
+
for (const k of keys) {
|
|
546
|
+
const [file, rule] = k.split('\n');
|
|
547
|
+
if (!(out[file] ??= []).includes(rule)) out[file].push(rule);
|
|
548
|
+
}
|
|
549
|
+
for (const f of Object.keys(out)) out[f].sort();
|
|
550
|
+
return Object.fromEntries(Object.keys(out).sort().map((f) => [f, out[f]]));
|
|
551
|
+
}
|
|
402
552
|
export function countLines(text) {
|
|
403
553
|
return text.length ? text.split('\n').length - (text.endsWith('\n') ? 1 : 0) : 0;
|
|
404
554
|
}
|
|
@@ -419,6 +569,9 @@ export function withDefaults(raw) {
|
|
|
419
569
|
if (!sc || typeof sc !== 'object' || Array.isArray(sc) || Object.values(sc).some((v) => !Array.isArray(v) || !v.length || !v.every(styleOnly))) {
|
|
420
570
|
throw fail(EXIT.FAILED, `${CONFIG_FILE}: style_contract must be { "<test file>": ["<path or glob ending .css or .scss>", …] }`);
|
|
421
571
|
}
|
|
572
|
+
if (!Array.isArray(config.pins) || config.pins.some((p) => !p || typeof p !== 'object' || Array.isArray(p))) {
|
|
573
|
+
throw fail(EXIT.FAILED, `${CONFIG_FILE}: pins must be [{ "file": "<test file>", "rule": "<the rule it guards>", "mutation": "<what turns it red>", "red_before": "<where that red is recorded>" }, …]`);
|
|
574
|
+
}
|
|
422
575
|
const r = config.reads;
|
|
423
576
|
const json = r && typeof r === 'object' && !Array.isArray(r) && typeof r.json === 'string'
|
|
424
577
|
&& Object.keys(r).every((k) => k === 'json' || k === 'key') && (r.key === undefined || r.key === null || typeof r.key === 'string');
|
|
@@ -431,7 +584,9 @@ export function withDefaults(raw) {
|
|
|
431
584
|
* named), or the whole file when the key is null or empty. No map there cannot run (exit 3): an
|
|
432
585
|
* empty register would read as "no test reads anything".
|
|
433
586
|
*/
|
|
434
|
-
export function registerOf(file, reads) {
|
|
587
|
+
export function registerOf(file, reads, as = null) {
|
|
588
|
+
// Before 1.0.2 a key of null or "" fell back to "tests", and no map there read as empty.
|
|
589
|
+
if (!detects('T4:whole-register', as)) return file?.[reads.key ?? 'tests'] ?? {};
|
|
435
590
|
const key = reads.key === undefined ? 'tests' : reads.key;
|
|
436
591
|
const whole = key === null || key === '';
|
|
437
592
|
const map = whole ? file : file?.[key];
|
|
@@ -444,11 +599,12 @@ export function registerOf(file, reads) {
|
|
|
444
599
|
// The command line: the only part that touches disk and git.
|
|
445
600
|
// ---------------------------------------------------------------------------------------------
|
|
446
601
|
export function parseArgs(argv) {
|
|
447
|
-
const o = { dir: null, base: null, noBase: false, list: false, init: false, help: false };
|
|
602
|
+
const o = { dir: null, base: null, noBase: false, list: false, init: false, relist: false, help: false };
|
|
448
603
|
for (let i = 0; i < argv.length; i++) {
|
|
449
604
|
const a = argv[i];
|
|
450
605
|
if (a === '--list') o.list = true;
|
|
451
606
|
else if (a === '--init') o.init = true;
|
|
607
|
+
else if (a === '--relist') o.relist = true;
|
|
452
608
|
else if (a === '--no-base') o.noBase = true;
|
|
453
609
|
else if (a === '--help' || a === '-h') o.help = true;
|
|
454
610
|
else if (a === '--dir' || a === '--base') {
|
|
@@ -458,9 +614,50 @@ export function parseArgs(argv) {
|
|
|
458
614
|
} else throw fail(EXIT.USAGE, `unknown argument ${a}`);
|
|
459
615
|
}
|
|
460
616
|
if (o.base && o.noBase) throw fail(EXIT.USAGE, '--base and --no-base together');
|
|
617
|
+
if (o.relist && o.init) throw fail(EXIT.USAGE, '--relist and --init together');
|
|
618
|
+
if (o.relist && o.noBase) throw fail(EXIT.USAGE, '--relist needs a base: it re-lists against the base\'s version');
|
|
461
619
|
return o;
|
|
462
620
|
}
|
|
463
621
|
|
|
622
|
+
// ---------------------------------------------------------------------------------------------
|
|
623
|
+
// Versions. `read(rel)` gives a file's text, or null: at the base through git, here from disk.
|
|
624
|
+
// ---------------------------------------------------------------------------------------------
|
|
625
|
+
const jsonOrNull = (text) => { if (text == null) return null; try { return JSON.parse(text); } catch { return null; } };
|
|
626
|
+
const exact = (v) => (typeof v === 'string' && SEMVER.test(v) ? v : null);
|
|
627
|
+
/**
|
|
628
|
+
* The installed version of the package: for each folder in `prefixes` ('' is the repo root), its
|
|
629
|
+
* package-lock.json, else an exact pin in its package.json; else the orchestrator's own manifest
|
|
630
|
+
* (SOURCE_MANIFEST). null when none says.
|
|
631
|
+
*/
|
|
632
|
+
export function installedVersion(read, prefixes = ['']) {
|
|
633
|
+
for (const p of prefixes) {
|
|
634
|
+
const lock = jsonOrNull(read(`${p}package-lock.json`));
|
|
635
|
+
const locked = exact(lock?.packages?.[`node_modules/${PACKAGE}`]?.version) ?? exact(lock?.dependencies?.[PACKAGE]?.version);
|
|
636
|
+
if (locked) return locked;
|
|
637
|
+
const manifest = jsonOrNull(read(`${p}package.json`));
|
|
638
|
+
const pinned = exact(manifest?.devDependencies?.[PACKAGE]) ?? exact(manifest?.dependencies?.[PACKAGE]);
|
|
639
|
+
if (pinned) return pinned;
|
|
640
|
+
}
|
|
641
|
+
const own = jsonOrNull(read(SOURCE_MANIFEST));
|
|
642
|
+
return own?.name === PACKAGE ? exact(own.version) : null;
|
|
643
|
+
}
|
|
644
|
+
/** The folders to read the installed version from: the one that holds node_modules/<PACKAGE> when this file runs from there, then the root. */
|
|
645
|
+
export function installPrefixes(dir, modulePath) {
|
|
646
|
+
const rel = relative(real(dir), real(modulePath)).split(sep).join('/');
|
|
647
|
+
const at = rel.startsWith('../') ? -1 : rel.indexOf(`node_modules/${PACKAGE}/`);
|
|
648
|
+
return at > 0 ? [rel.slice(0, at), ''] : [''];
|
|
649
|
+
}
|
|
650
|
+
/** This file's own version: the manifest beside it (as published), else the orchestrator's package folder. */
|
|
651
|
+
export function ownVersion(modulePath = fileURLToPath(import.meta.url)) {
|
|
652
|
+
for (const rel of ['package.json', 'test-rules-package/package.json']) {
|
|
653
|
+
let manifest = null;
|
|
654
|
+
try { manifest = jsonOrNull(readFileSync(join(dirname(modulePath), rel), 'utf8')); } catch { /* not there */ }
|
|
655
|
+
if (manifest?.name === PACKAGE && exact(manifest.version)) return manifest.version;
|
|
656
|
+
}
|
|
657
|
+
return null;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const real = (p) => { try { return realpathSync(p); } catch { return p; } };
|
|
464
661
|
const git = (dir, args) => execFileSync('git', args, { cwd: dir, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
465
662
|
const gitOr = (dir, args) => { try { return git(dir, args); } catch { return null; } };
|
|
466
663
|
function readJson(dir, rel) {
|
|
@@ -485,7 +682,11 @@ export function movedOf(obj, where) {
|
|
|
485
682
|
return m;
|
|
486
683
|
}
|
|
487
684
|
|
|
488
|
-
|
|
685
|
+
/**
|
|
686
|
+
* The command line. `running`: this file's version (default: read from its manifest); `modulePath`:
|
|
687
|
+
* where this file is, to find the folder it is installed in. Both are for tests.
|
|
688
|
+
*/
|
|
689
|
+
export function main(argv = [], { log = console.log, error = console.error, running = undefined, modulePath = fileURLToPath(import.meta.url) } = {}) {
|
|
489
690
|
try {
|
|
490
691
|
const opts = parseArgs(argv);
|
|
491
692
|
if (opts.help) { log(USAGE); return EXIT.CLEAN; }
|
|
@@ -497,7 +698,8 @@ export function main(argv = [], { log = console.log, error = console.error } = {
|
|
|
497
698
|
const tracked = new Set(present);
|
|
498
699
|
const testPaths = present.filter((p) => !p.split('/').includes('node_modules') && matches(config.tests, p) && !matches(config.ignore, p)).sort();
|
|
499
700
|
const files = testPaths.map((path) => ({ path, text: readFileSync(join(dir, path), 'utf8') }));
|
|
500
|
-
const
|
|
701
|
+
const registerFile = config.reads && typeof config.reads === 'object' ? readJson(dir, config.reads.json) : null;
|
|
702
|
+
const register = registerFile ? registerOf(registerFile, config.reads) : null;
|
|
501
703
|
const all = breachesOf({ files, config, register, tracked });
|
|
502
704
|
|
|
503
705
|
if (opts.init) {
|
|
@@ -513,6 +715,8 @@ export function main(argv = [], { log = console.log, error = console.error } = {
|
|
|
513
715
|
const moved = listedJson ? movedOf(listedJson, LISTED_FILE) : {};
|
|
514
716
|
let base = null;
|
|
515
717
|
let baseNote = 'no base';
|
|
718
|
+
let relist = null;
|
|
719
|
+
let gate = null;
|
|
516
720
|
if (!opts.noBase) {
|
|
517
721
|
const ref = opts.base ?? DEFAULT_BASE;
|
|
518
722
|
const mb = gitOr(dir, ['merge-base', ref, 'HEAD'])?.trim();
|
|
@@ -533,16 +737,53 @@ export function main(argv = [], { log = console.log, error = console.error } = {
|
|
|
533
737
|
}
|
|
534
738
|
base = { listed: baseListed, lines };
|
|
535
739
|
baseNote = `base ${mb.slice(0, 12)}${baseListed ? '' : ' (no list there)'}`;
|
|
740
|
+
|
|
741
|
+
// Re-listed once per version: only when this change raises the installed version.
|
|
742
|
+
const prefixes = installPrefixes(dir, modulePath);
|
|
743
|
+
gate = relistGate({
|
|
744
|
+
from: installedVersion((rel) => gitOr(dir, ['show', `${mb}:${rel}`]), prefixes),
|
|
745
|
+
installed: installedVersion((rel) => { try { return readFileSync(join(dir, rel), 'utf8'); } catch { return null; } }, prefixes),
|
|
746
|
+
running: running === undefined ? ownVersion(modulePath) : running,
|
|
747
|
+
});
|
|
748
|
+
if (gate.open) {
|
|
749
|
+
const changed = new Set([
|
|
750
|
+
...git(dir, ['diff', '--name-only', '--no-renames', '-z', mb]).split('\0'),
|
|
751
|
+
...git(dir, ['ls-files', '-o', '--exclude-standard', '-z']).split('\0'),
|
|
752
|
+
].filter(Boolean));
|
|
753
|
+
const asBase = breachesOf({ files, config, register: registerFile ? registerOf(registerFile, config.reads, gate.from) : null, tracked, as: gate.from });
|
|
754
|
+
relist = { from: gate.from, at: gate.at, changed, allowed: relistable({ all, asBase, changed }) };
|
|
755
|
+
baseNote += `; ${PACKAGE} ${gate.from} → ${gate.at}`;
|
|
756
|
+
} else if (gate.from && gate.from !== gate.at) baseNote += `; no re-list (${gate.why})`;
|
|
536
757
|
} else baseNote = `no base (${ref} does not resolve)`;
|
|
537
758
|
}
|
|
759
|
+
|
|
760
|
+
if (opts.relist) {
|
|
761
|
+
if (!base) throw fail(EXIT.USAGE, `--relist needs a base: ${baseNote}`);
|
|
762
|
+
if (!base.listed) throw fail(EXIT.USAGE, `the base has no ${LISTED_FILE}: write the first list with --init`);
|
|
763
|
+
if (!relist) throw fail(EXIT.USAGE, `nothing may be re-listed: ${gate.why}`);
|
|
764
|
+
const on = (b) => (listed[b.file] ?? []).includes(b.rule);
|
|
765
|
+
const add = all.filter((b) => !on(b) && relist.allowed.has(`${b.file}\n${b.rule}`)).map((b) => `${b.file}\n${b.rule}`);
|
|
766
|
+
if (!add.length) {
|
|
767
|
+
log(`test-rules: nothing to re-list at ${relist.at} (the base ran ${relist.from})`);
|
|
768
|
+
return EXIT.CLEAN;
|
|
769
|
+
}
|
|
770
|
+
mkdirSync(dirname(join(dir, LISTED_FILE)), { recursive: true });
|
|
771
|
+
writeFileSync(join(dir, LISTED_FILE), listedText(withEntries(listed, add), moved));
|
|
772
|
+
for (const k of add) log(`relisted ${k.replace('\n', ' ')}`);
|
|
773
|
+
log(`test-rules: re-listed at ${relist.at}: ${add.length} entries, written to ${LISTED_FILE} (the base ran ${relist.from})`);
|
|
774
|
+
return EXIT.CLEAN;
|
|
775
|
+
}
|
|
776
|
+
|
|
538
777
|
const lineCounts = Object.fromEntries(files.map((f) => [f.path, countLines(f.text)]));
|
|
539
|
-
const v = verdict({ all, listed, base, lineCounts, moved, present: new Set(testPaths) });
|
|
778
|
+
const v = verdict({ all, listed, base, lineCounts, moved, present: new Set(testPaths), relist });
|
|
540
779
|
|
|
541
780
|
if (opts.list) {
|
|
542
|
-
log(JSON.stringify({ v: VERSION, breaches: v.breaches, listed: v.listed, stale: v.stale }));
|
|
781
|
+
log(JSON.stringify({ v: VERSION, breaches: v.breaches, listed: v.listed, stale: v.stale, relisted: v.relisted }));
|
|
543
782
|
} else {
|
|
544
783
|
for (const b of v.breaches) log(`${b.rule} ${b.file}:${b.line} — ${b.say}`);
|
|
545
784
|
for (const s of v.stale) log(`stale ${s.file} ${s.rule} — the breach is gone: delete it from ${LISTED_FILE}`);
|
|
785
|
+
for (const r of v.relisted) log(`relisted ${r.file} ${r.rule} — a detection newer than the base's ${relist.from} finds it`);
|
|
786
|
+
if (v.relisted.length) log(`test-rules: re-listed at ${relist.at}: ${v.relisted.length} entries`);
|
|
546
787
|
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)'}`);
|
|
547
788
|
}
|
|
548
789
|
return config.fail && (v.breaches.length || v.stale.length) ? EXIT.BREACH : EXIT.CLEAN;
|
|
@@ -554,5 +795,4 @@ export function main(argv = [], { log = console.log, error = console.error } = {
|
|
|
554
795
|
}
|
|
555
796
|
}
|
|
556
797
|
|
|
557
|
-
const real = (p) => { try { return realpathSync(p); } catch { return p; } };
|
|
558
798
|
if (process.argv[1] && real(process.argv[1]) === real(fileURLToPath(import.meta.url))) process.exitCode = main(process.argv.slice(2));
|