@syncerauk/test-rules 1.0.6 → 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 CHANGED
@@ -37,6 +37,7 @@ relisted: [{ file, rule, at }] }`.
37
37
  | `T1:hub` | A file listed as over the line limit grew against the base |
38
38
  | `R7:lines` | A test file over 1,500 lines |
39
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) |
40
41
  | `T6:timer` | A real wait, with no fake timers in the file |
41
42
  | `T6:clock` | The wall clock, with no fake timers in the file |
42
43
  | `T6:outside` | The home folder, a path that climbs out of the repo, a real host |
@@ -52,6 +53,7 @@ Both in the repo's `scripts/test-rules/`:
52
53
  `ignore` (which files are test files), `fixtures` (folders a test may read), `wording` (the
53
54
  one list of what a person reads), `style_contract` (`{ "<test file>": ["<.css or .scss path or
54
55
  glob>"] }`: the one test that may read those stylesheets; style rules are checked there, once),
56
+ `pins` (the kept pins, below),
55
57
  `reads` (the reads register: `"comment"`, or
56
58
  `{ "json": "<path>" }` for the map under `"tests"` in that file, `"key": "<name>"` for another
57
59
  key, `"key": null` when the file itself is the map). The defaults are `CONFIG_DEFAULTS` in the
@@ -60,6 +62,28 @@ Both in the repo's `scripts/test-rules/`:
60
62
  `test-rules --init`. An optional `moved` map, `{ "<new test file>": "<old test file>" }`,
61
63
  says where a split or a rename took a listed file.
62
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
+
63
87
  ## The list only shrinks
64
88
 
65
89
  - A breach on the list passes. A breach off the list fails.
@@ -102,9 +126,15 @@ when the PR does not raise the version.
102
126
  | 1.0.1, 1.0.4, 1.0.5, 1.0.6 | none |
103
127
  | 1.0.2 | `T4:source-text`: a path that starts with a dot-folder; a JSON register that is the whole file |
104
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) |
105
130
 
106
131
  ## Versions
107
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
+
108
138
  - **1.0.6** — Re-listed once per version: a PR that raises the installed version may add the
109
139
  entries a newer detection finds in files it does not change; `--relist` writes them.
110
140
  `--list` carries `relisted`. Nothing new is found.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncerauk/test-rules",
3
- "version": "1.0.6",
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
@@ -17,7 +17,8 @@
17
17
  // `relisted` is the growth a version bump may bring (below).
18
18
  //
19
19
  // Per repo, two files:
20
- // 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)
21
22
  // scripts/test-rules/listed.json { v: 1, entries: { "<test file>": ["<rule id>", …] },
22
23
  // moved: { "<new test file>": "<old test file>" } }
23
24
  // `moved` is optional: it says where a split or a rename took a listed file (1.0.5).
@@ -60,6 +61,10 @@
60
61
  // A JSON register is what a test says it reads; it does not stop the code
61
62
  // being read for what it does read (1.0.3). A stylesheet named for a test in
62
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)
63
68
  // T6:timer setTimeout( / setInterval( / sleep( / timers/promises with no fake timers in the file
64
69
  // T6:clock Date.now( / new Date() / performance.now( with no fake timers in the file
65
70
  // T6:outside homedir(), process.env.HOME, a relative path that climbs out of the repo,
@@ -96,6 +101,7 @@ export const CONFIG_DEFAULTS = Object.freeze({
96
101
  fixtures: [], // folders a test may read: `a/fixtures/`
97
102
  wording: [], // the one list of what a person reads (a built page, a legal text): files, globs, folders
98
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
99
105
  reads: null, // the reads register: null, "comment" (`// reads:` lines) or { json: "<path>", key: "tests" } (key null: the file itself is the map)
100
106
  source_ext: ['mjs', 'cjs', 'js', 'jsx', 'ts', 'tsx', 'mts', 'cts', 'sql', 'css', 'scss', 'html', 'sh', 'yml', 'yaml', 'md', 'mdx'],
101
107
  max_lines: 1500,
@@ -110,6 +116,7 @@ export const RULES = Object.freeze({
110
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',
111
117
  'R7:lines': 'over the line limit: split the file by topic',
112
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',
113
120
  'T6:timer': 'no real wait in a gated test: use the fake clock or fake timers',
114
121
  'T6:clock': 'no wall clock in a gated test: pass a fixed time in, or use fake timers',
115
122
  'T6:outside': 'nothing outside the commit: no home folder, machine path, path out of the repo or real host',
@@ -117,7 +124,7 @@ export const RULES = Object.freeze({
117
124
  'list:grew': 'the list only shrinks: fix the breach, do not list it',
118
125
  });
119
126
  /** The rules a listed.json entry can never excuse. */
120
- export const NEVER_LISTED = Object.freeze(['T1:hub', 'list:grew']);
127
+ export const NEVER_LISTED = Object.freeze(['T1:hub', 'T4:pin', 'list:grew']);
121
128
 
122
129
  /**
123
130
  * Each way the check finds a breach: the rule it reports and the version that first found it,
@@ -125,7 +132,8 @@ export const NEVER_LISTED = Object.freeze(['T1:hub', 'list:grew']);
125
132
  * re-list can tell what a bump newly finds from what was there all along. A version that changes
126
133
  * how a breach is found adds a row here, with its own `since`, and keeps the old behaviour
127
134
  * 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.
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.
129
137
  */
130
138
  export const DETECTORS = Object.freeze({
131
139
  'T1:name': { rule: 'T1:name', since: '1.0.0' }, // the file named by task
@@ -137,6 +145,7 @@ export const DETECTORS = Object.freeze({
137
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
138
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
139
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)
140
149
  'T6:timer': { rule: 'T6:timer', since: '1.0.0' },
141
150
  'T6:clock': { rule: 'T6:clock', since: '1.0.0' },
142
151
  'T6:outside': { rule: 'T6:outside', since: '1.0.0' },
@@ -378,6 +387,36 @@ function sourceTextLine({ path, text, code, bare, strings }, { config, register,
378
387
  return 0;
379
388
  }
380
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
+
381
420
  /**
382
421
  * Every breach in `files` (the repo's test files as { path, text }), one per file and rule, at
383
422
  * its first line: [{ file, rule, line, say }], sorted. Pure: no disk, no git. `as`: a version
@@ -389,13 +428,16 @@ export function breachesOf({ files, config, register = null, tracked = null, as
389
428
  const on = (id) => detects(id, as);
390
429
  const names = config.task_names.map((s) => new RegExp(s, 'i'));
391
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(' · ')})` });
392
434
  for (const { path, text } of files) {
393
435
  const view = { path, text, ...scan(text) };
394
436
  const base = path.split('/').pop();
395
437
  if (on('T1:name') && names.some((re) => re.test(base))) add(path, 'T1:name', 1);
396
438
  if (on('R7:lines') && countLines(text) > config.max_lines) add(path, 'R7:lines', config.max_lines + 1);
397
439
  const t4 = sourceTextLine(view, { config, register, tracked, as });
398
- if (t4) add(path, 'T4:source-text', t4);
440
+ if (t4 && !pins.pinned.has(path)) add(path, 'T4:source-text', t4);
399
441
  const faked = fakes.some((re) => re.test(view.bare));
400
442
  const timer = faked || !on('T6:timer') ? 0 : timerLine(view);
401
443
  if (timer) add(path, 'T6:timer', timer);
@@ -527,6 +569,9 @@ export function withDefaults(raw) {
527
569
  if (!sc || typeof sc !== 'object' || Array.isArray(sc) || Object.values(sc).some((v) => !Array.isArray(v) || !v.length || !v.every(styleOnly))) {
528
570
  throw fail(EXIT.FAILED, `${CONFIG_FILE}: style_contract must be { "<test file>": ["<path or glob ending .css or .scss>", …] }`);
529
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
+ }
530
575
  const r = config.reads;
531
576
  const json = r && typeof r === 'object' && !Array.isArray(r) && typeof r.json === 'string'
532
577
  && Object.keys(r).every((k) => k === 'json' || k === 'key') && (r.key === undefined || r.key === null || typeof r.key === 'string');