@syncerauk/test-rules 1.0.5 → 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.
Files changed (3) hide show
  1. package/README.md +37 -1
  2. package/package.json +1 -1
  3. package/test-rules.mjs +232 -37
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
 
@@ -71,8 +72,43 @@ Both in the repo's `scripts/test-rules/`:
71
72
 
72
73
  A repo starts with `"fail": false`: breaches are printed and the exit is 0. One line turns it on.
73
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
+
74
106
  ## Versions
75
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
+
76
112
  - **1.0.5** — A split or rename carries its listed breaches: `listed.json` may hold
77
113
  `"moved": { "<new test file>": "<old test file>" }`, and a new file's entry is not
78
114
  `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.5",
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,15 +4,17 @@
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
- // `breaches` are the breaches that are not on the list; `listed` counts the ones that are.
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)
@@ -30,6 +32,17 @@
30
32
  // `list:grew` when `moved` names its old file, the base listed that same rule on the old file,
31
33
  // and the old file is no longer a test file. A `moved` line is never stale and never a breach;
32
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.
33
46
  // `"fail": false` in the config is report only: everything is printed, the exit is 0.
34
47
  //
35
48
  // The rules (RULES below) and how each is seen:
@@ -55,12 +68,13 @@
55
68
  // T6:env process.env.<NAME> read and never set in that file
56
69
  // Comments are never read; code patterns are never matched inside a string.
57
70
  // 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.
71
+ // gate, so no test declares them. Its own version is read from its manifest:
72
+ // reads: scripts/test-rules-package/package.json
59
73
  // =============================================================================
60
74
 
61
75
  import { execFileSync } from 'node:child_process';
62
76
  import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
63
- import { dirname, join, posix, resolve } from 'node:path';
77
+ import { dirname, join, posix, relative, resolve, sep } from 'node:path';
64
78
  import { fileURLToPath } from 'node:url';
65
79
 
66
80
  export const VERSION = 1;
@@ -68,7 +82,10 @@ export const EXIT = Object.freeze({ CLEAN: 0, BREACH: 1, USAGE: 2, FAILED: 3 });
68
82
  export const CONFIG_FILE = 'scripts/test-rules/config.json';
69
83
  export const LISTED_FILE = 'scripts/test-rules/listed.json';
70
84
  export const DEFAULT_BASE = 'origin/main';
71
- const USAGE = 'usage: test-rules [--dir <worktree>] [--base <ref> | --no-base] [--list] [--init]';
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]';
72
89
 
73
90
  /** What a repo's scripts/test-rules/config.json may set; anything it leaves out is this. */
74
91
  export const CONFIG_DEFAULTS = Object.freeze({
@@ -102,6 +119,40 @@ export const RULES = Object.freeze({
102
119
  /** The rules a listed.json entry can never excuse. */
103
120
  export const NEVER_LISTED = Object.freeze(['T1:hub', 'list:grew']);
104
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
+
105
156
  const fail = (code, message) => Object.assign(new Error(message), { exit: code });
106
157
 
107
158
  // ---------------------------------------------------------------------------------------------
@@ -279,7 +330,8 @@ function envLine({ code, bare }, allowed) {
279
330
  }
280
331
 
281
332
  /** 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 }) {
333
+ function sourceTextLine({ path, text, code, bare, strings }, { config, register, tracked, as }) {
334
+ const on = (id) => detects(id, as);
283
335
  const isSource = (p) => config.source_ext.includes(String(p).split('.').pop().toLowerCase());
284
336
  // A style contract is the one test that may read the stylesheets it names: style rules are
285
337
  // checked once, there, the way wording is checked once from the wording list.
@@ -288,23 +340,25 @@ function sourceTextLine({ path, text, code, bare, strings }, { config, register,
288
340
  const offends = (p) => !allowed(p) && (isFolderOrGlob(p) || isSource(p));
289
341
 
290
342
  const declared = declaredIn(text);
291
- if (register && typeof register === 'object') {
343
+ const json = register && typeof register === 'object';
344
+ if (on('T4:declared') && json) {
292
345
  if ((register[path] ?? []).some(offends)) return 1;
293
- } else if (config.reads === 'comment') {
346
+ } else if (on('T4:declared') && config.reads === 'comment') {
294
347
  const hit = declared.reads.find((r) => offends(r.pattern));
295
348
  if (hit) return hit.line;
296
349
  }
297
- for (const m of bare.matchAll(RAW_IMPORT)) {
350
+ for (const m of on('T4:raw') ? bare.matchAll(RAW_IMPORT) : []) {
298
351
  if (code[m.index] === ' ') continue; // the words are inside a string, not an import
299
352
  if (!allowed(posix.normalize(posix.join(posix.dirname(path), m[1])))) return lineOf(bare, m.index);
300
353
  }
301
- for (const m of code.matchAll(RAW_GLOB)) {
354
+ for (const m of on('T4:raw') ? code.matchAll(RAW_GLOB) : []) {
302
355
  const call = bare.slice(m.index, m.index + 300).split(')')[0];
303
356
  if (/\?raw|as\s*:\s*['"]raw['"]/.test(call)) return lineOf(code, m.index);
304
357
  }
305
358
  // A source file whose path the test builds from its own place, in a test that reads the file
306
359
  // 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.
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;
308
362
  if (!FS_READ.test(code) || !tracked) return 0;
309
363
  const said = [...declared.reads, ...declared.runs].map((r) => r.pattern);
310
364
  for (const s of strings) {
@@ -312,9 +366,11 @@ function sourceTextLine({ path, text, code, bare, strings }, { config, register,
312
366
  if (/[\s*?$]/.test(v) || !isSource(v)) continue;
313
367
  const before = code.slice(code.lastIndexOf('\n', s.start) + 1, s.start - 1);
314
368
  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.
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).
316
371
  const here = posix.normalize(posix.join(posix.dirname(path), v));
317
- const target = v.startsWith('./') || v.startsWith('../') || !tracked.has(v) ? here : v;
372
+ const besideTest = on('T4:dot-folder') ? v.startsWith('./') || v.startsWith('../') : v.startsWith('.');
373
+ const target = besideTest || !tracked.has(v) ? here : v;
318
374
  if (target === path || !tracked.has(target) || allowed(target)) continue;
319
375
  if (said.some((p) => globRe(p).test(target))) continue;
320
376
  return lineOf(code, s.start);
@@ -324,28 +380,30 @@ function sourceTextLine({ path, text, code, bare, strings }, { config, register,
324
380
 
325
381
  /**
326
382
  * 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.
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.
328
385
  */
329
- export function breachesOf({ files, config, register = null, tracked = null }) {
386
+ export function breachesOf({ files, config, register = null, tracked = null, as = null }) {
330
387
  const out = [];
331
388
  const add = (file, rule, line) => out.push({ file, rule, line, say: RULES[rule] });
389
+ const on = (id) => detects(id, as);
332
390
  const names = config.task_names.map((s) => new RegExp(s, 'i'));
333
391
  const fakes = config.fake_timers.map((s) => new RegExp(s));
334
392
  for (const { path, text } of files) {
335
393
  const view = { path, text, ...scan(text) };
336
394
  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 });
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 });
340
398
  if (t4) add(path, 'T4:source-text', t4);
341
399
  const faked = fakes.some((re) => re.test(view.bare));
342
- const timer = faked ? 0 : timerLine(view);
400
+ const timer = faked || !on('T6:timer') ? 0 : timerLine(view);
343
401
  if (timer) add(path, 'T6:timer', timer);
344
- const clock = faked ? 0 : clockLine(view);
402
+ const clock = faked || !on('T6:clock') ? 0 : clockLine(view);
345
403
  if (clock) add(path, 'T6:clock', clock);
346
- const outside = outsideLine(view);
404
+ const outside = on('T6:outside') ? outsideLine(view) : 0;
347
405
  if (outside) add(path, 'T6:outside', outside);
348
- const env = envLine(view, config.env_allowed);
406
+ const env = on('T6:env') ? envLine(view, config.env_allowed) : 0;
349
407
  if (env) add(path, 'T6:env', env);
350
408
  }
351
409
  return out.sort((a, b) => a.file.localeCompare(b.file) || a.rule.localeCompare(b.rule));
@@ -358,9 +416,13 @@ export function breachesOf({ files, config, register = null, tracked = null }) {
358
416
  * `moved`: { new file: old file } from the head's list. `present`: a Set of the head's test
359
417
  * files, or null (then nothing is carried). An entry the base does not hold is carried, not
360
418
  * `list:grew`, when its old file is gone and the base listed the same rule on it.
361
- * → { breaches (off the list, plus T1:hub and list:grew), listed (count on the list), stale }.
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 }] }.
362
424
  */
363
- export function verdict({ all, listed = {}, base = null, lineCounts = {}, moved = {}, present = null }) {
425
+ export function verdict({ all, listed = {}, base = null, lineCounts = {}, moved = {}, present = null, relist = null }) {
364
426
  const on = (file, rule) => (listed[file] ?? []).includes(rule) && !NEVER_LISTED.includes(rule);
365
427
  const breaches = all.filter((b) => !on(b.file, b.rule));
366
428
  const hits = all.filter((b) => on(b.file, b.rule));
@@ -373,10 +435,16 @@ export function verdict({ all, listed = {}, base = null, lineCounts = {}, moved
373
435
  const old = Object.hasOwn(moved, file) ? moved[file] : null;
374
436
  return typeof old === 'string' && present !== null && !present.has(old) && (base.listed?.[old] ?? []).includes(rule);
375
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)`;
376
442
  if (base) {
377
443
  for (const file of Object.keys(listed).sort()) {
378
444
  for (const rule of [...listed[file]].sort()) {
379
- if (base.listed && !(base.listed[file] ?? []).includes(rule) && !carried(file, rule)) breaches.push({ file, rule: 'list:grew', line: 1, say: `${rule}: ${RULES['list:grew']}` });
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)}` });
380
448
  }
381
449
  const was = base.lines?.[file];
382
450
  if (listed[file].includes('R7:lines') && Number.isFinite(was) && lineCounts[file] > was) {
@@ -385,7 +453,33 @@ export function verdict({ all, listed = {}, base = null, lineCounts = {}, moved
385
453
  }
386
454
  }
387
455
  breaches.sort((a, b) => a.file.localeCompare(b.file) || a.rule.localeCompare(b.rule));
388
- 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));
389
483
  }
390
484
 
391
485
  /** The entries a first list holds for these breaches: { file: [rule, …] }, sorted. */
@@ -395,10 +489,24 @@ export function entriesFor(all) {
395
489
  for (const k of Object.keys(entries)) entries[k].sort();
396
490
  return Object.fromEntries(Object.keys(entries).sort().map((k) => [k, entries[k]]));
397
491
  }
398
- export const listedText = (entries) => {
399
- const rows = Object.keys(entries).map((k) => ` ${JSON.stringify(k)}: ${JSON.stringify(entries[k])}`);
400
- return `{\n "v": ${VERSION},\n "entries": {${rows.length ? `\n${rows.join(',\n')}\n ` : ''}}\n}\n`;
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`;
401
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
+ }
402
510
  export function countLines(text) {
403
511
  return text.length ? text.split('\n').length - (text.endsWith('\n') ? 1 : 0) : 0;
404
512
  }
@@ -431,7 +539,9 @@ export function withDefaults(raw) {
431
539
  * named), or the whole file when the key is null or empty. No map there cannot run (exit 3): an
432
540
  * empty register would read as "no test reads anything".
433
541
  */
434
- 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'] ?? {};
435
545
  const key = reads.key === undefined ? 'tests' : reads.key;
436
546
  const whole = key === null || key === '';
437
547
  const map = whole ? file : file?.[key];
@@ -444,11 +554,12 @@ export function registerOf(file, reads) {
444
554
  // The command line: the only part that touches disk and git.
445
555
  // ---------------------------------------------------------------------------------------------
446
556
  export function parseArgs(argv) {
447
- 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 };
448
558
  for (let i = 0; i < argv.length; i++) {
449
559
  const a = argv[i];
450
560
  if (a === '--list') o.list = true;
451
561
  else if (a === '--init') o.init = true;
562
+ else if (a === '--relist') o.relist = true;
452
563
  else if (a === '--no-base') o.noBase = true;
453
564
  else if (a === '--help' || a === '-h') o.help = true;
454
565
  else if (a === '--dir' || a === '--base') {
@@ -458,9 +569,50 @@ export function parseArgs(argv) {
458
569
  } else throw fail(EXIT.USAGE, `unknown argument ${a}`);
459
570
  }
460
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');
461
574
  return o;
462
575
  }
463
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; } };
464
616
  const git = (dir, args) => execFileSync('git', args, { cwd: dir, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
465
617
  const gitOr = (dir, args) => { try { return git(dir, args); } catch { return null; } };
466
618
  function readJson(dir, rel) {
@@ -485,7 +637,11 @@ export function movedOf(obj, where) {
485
637
  return m;
486
638
  }
487
639
 
488
- export function main(argv = [], { log = console.log, error = console.error } = {}) {
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) } = {}) {
489
645
  try {
490
646
  const opts = parseArgs(argv);
491
647
  if (opts.help) { log(USAGE); return EXIT.CLEAN; }
@@ -497,7 +653,8 @@ export function main(argv = [], { log = console.log, error = console.error } = {
497
653
  const tracked = new Set(present);
498
654
  const testPaths = present.filter((p) => !p.split('/').includes('node_modules') && matches(config.tests, p) && !matches(config.ignore, p)).sort();
499
655
  const files = testPaths.map((path) => ({ path, text: readFileSync(join(dir, path), 'utf8') }));
500
- const register = config.reads && typeof config.reads === 'object' ? registerOf(readJson(dir, config.reads.json), config.reads) : null;
656
+ const registerFile = config.reads && typeof config.reads === 'object' ? readJson(dir, config.reads.json) : null;
657
+ const register = registerFile ? registerOf(registerFile, config.reads) : null;
501
658
  const all = breachesOf({ files, config, register, tracked });
502
659
 
503
660
  if (opts.init) {
@@ -513,6 +670,8 @@ export function main(argv = [], { log = console.log, error = console.error } = {
513
670
  const moved = listedJson ? movedOf(listedJson, LISTED_FILE) : {};
514
671
  let base = null;
515
672
  let baseNote = 'no base';
673
+ let relist = null;
674
+ let gate = null;
516
675
  if (!opts.noBase) {
517
676
  const ref = opts.base ?? DEFAULT_BASE;
518
677
  const mb = gitOr(dir, ['merge-base', ref, 'HEAD'])?.trim();
@@ -533,16 +692,53 @@ export function main(argv = [], { log = console.log, error = console.error } = {
533
692
  }
534
693
  base = { listed: baseListed, lines };
535
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})`;
536
712
  } else baseNote = `no base (${ref} does not resolve)`;
537
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
+
538
732
  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) });
733
+ const v = verdict({ all, listed, base, lineCounts, moved, present: new Set(testPaths), relist });
540
734
 
541
735
  if (opts.list) {
542
- 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 }));
543
737
  } else {
544
738
  for (const b of v.breaches) log(`${b.rule} ${b.file}:${b.line} — ${b.say}`);
545
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`);
546
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)'}`);
547
743
  }
548
744
  return config.fail && (v.breaches.length || v.stale.length) ? EXIT.BREACH : EXIT.CLEAN;
@@ -554,5 +750,4 @@ export function main(argv = [], { log = console.log, error = console.error } = {
554
750
  }
555
751
  }
556
752
 
557
- const real = (p) => { try { return realpathSync(p); } catch { return p; } };
558
753
  if (process.argv[1] && real(process.argv[1]) === real(fileURLToPath(import.meta.url))) process.exitCode = main(process.argv.slice(2));