staysfixed 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +534 -402
  2. package/package.json +8 -3
  3. package/src/cli/index.js +14 -0
  4. package/src/v2/adapters/android-driver.js +1705 -0
  5. package/src/v2/adapters/android.js +1117 -0
  6. package/src/v2/adapters/contract.js +565 -0
  7. package/src/v2/adapters/electron.js +1594 -0
  8. package/src/v2/adapters/http.js +733 -0
  9. package/src/v2/adapters/ios-driver.js +1551 -0
  10. package/src/v2/adapters/ios.js +989 -0
  11. package/src/v2/adapters/isolate.js +739 -0
  12. package/src/v2/adapters/process.js +920 -0
  13. package/src/v2/adapters/source.js +1241 -0
  14. package/src/v2/adapters/web-driver.js +1532 -0
  15. package/src/v2/adapters/web.js +1009 -0
  16. package/src/v2/adapters/windows.js +1329 -0
  17. package/src/v2/browsers.js +1203 -0
  18. package/src/v2/cause.js +364 -0
  19. package/src/v2/check.js +1331 -0
  20. package/src/v2/ci.js +1209 -0
  21. package/src/v2/cli.js +657 -0
  22. package/src/v2/cluster.js +372 -0
  23. package/src/v2/coverage.js +1116 -0
  24. package/src/v2/detect.js +1199 -0
  25. package/src/v2/doctor.js +1690 -0
  26. package/src/v2/escalate.js +679 -0
  27. package/src/v2/init.js +1394 -0
  28. package/src/v2/intent.js +659 -0
  29. package/src/v2/journeys/from-routes.js +498 -0
  30. package/src/v2/journeys/from-suite.js +988 -0
  31. package/src/v2/journeys/index.js +651 -0
  32. package/src/v2/journeys/record.js +516 -0
  33. package/src/v2/mcp/server.js +374 -0
  34. package/src/v2/mcp/tools.js +1571 -0
  35. package/src/v2/normalise.js +783 -0
  36. package/src/v2/observation.js +877 -0
  37. package/src/v2/rank.js +672 -0
  38. package/src/v2/reference.js +1051 -0
  39. package/src/v2/remote.js +911 -0
  40. package/src/v2/run.js +964 -0
  41. package/src/v2/sealed.js +564 -0
  42. package/src/v2/selfcheck.js +564 -0
  43. package/src/v2/ship.js +684 -0
  44. package/src/v2/store.js +703 -0
  45. package/src/v2/types.js +503 -0
  46. package/src/v2/waiver.js +511 -0
  47. package/src/watch/panel.js +73 -44
@@ -0,0 +1,783 @@
1
+ /**
2
+ * Normalisation — turning a raw value into a comparable one.
3
+ *
4
+ * This is the craft of the whole tool. Every product disagrees with itself about things that
5
+ * do not matter: the clock, an id, a port, where the temp folder went today. Left alone, a
6
+ * version bump in a footer reports five hundred differences and the agent reading them learns
7
+ * nothing. Normalised too hard, a real break disappears and the tool goes quiet — which is
8
+ * worse, because a quiet difference machine is indistinguishable from a working one.
9
+ *
10
+ * So the rules are DATA. They live in git next to the project's config, they are reviewed like
11
+ * any other change, and every one of them carries `wouldHide` in plain English: the real
12
+ * change this rule would wrongly cover up. Read that field before switching a rule on.
13
+ *
14
+ * And every normalisation is auditable. `explain()` returns exactly what was replaced, where,
15
+ * by which rule, and what that rule admits it might be hiding. A difference the tool decided
16
+ * not to show has to be answerable for.
17
+ */
18
+
19
+ import fsp from 'node:fs/promises';
20
+ import { sha256 } from '../core/hash.js';
21
+ import { StaysFixedError } from '../core/errors.js';
22
+ import { canonicalJson, matchPath } from './observation.js';
23
+
24
+ /**
25
+ * @typedef {import('./types.js').NormaliseRule} NormaliseRule
26
+ * @typedef {import('./types.js').ObservedValue} ObservedValue
27
+ * @typedef {import('./types.js').Observation} Observation
28
+ * @typedef {import('./types.js').Capture} Capture
29
+ * @typedef {import('./types.js').Channel} Channel
30
+ * @typedef {import('./types.js').Replacement} Replacement
31
+ * @typedef {import('./types.js').Explanation} Explanation
32
+ */
33
+
34
+ /** Where in a value we are, when a rule needs to say. */
35
+ const ROOT = '$';
36
+
37
+ /** Sentinel for a value a `drop` rule removed. Never appears in a result. */
38
+ const DROPPED = Symbol('dropped');
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // The default rules
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /**
45
+ * On by default.
46
+ *
47
+ * The posture: aggressive about clocks and ids, which are pure churn and never carry meaning
48
+ * on their own; conservative about everything that could plausibly be the thing that broke.
49
+ * Where a rule has a real cost, it says so in `wouldHide` and the fix is always the same —
50
+ * scope it with `paths` rather than switching it off everywhere.
51
+ *
52
+ * @type {NormaliseRule[]}
53
+ */
54
+ export const DEFAULT_RULES = [
55
+ {
56
+ id: 'clock.iso',
57
+ kind: 'replace',
58
+ what: 'Dates and times written the standard way, like 2026-08-29T04:11:07.412Z.',
59
+ why: 'A clock reading is different every run by definition. Left in, every log line and every record carrying a created-at time reports as changed.',
60
+ wouldHide: 'A date the product shows on purpose — a booking date, a due date, a birthday. If dates are part of what your product says, scope this rule to the paths where they are only timestamps.',
61
+ pattern: '\\d{4}-\\d{2}-\\d{2}[T ]\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,9})?(?:Z|[+-]\\d{2}:?\\d{2})?',
62
+ with: '<time>',
63
+ },
64
+ {
65
+ id: 'clock.epoch-ms',
66
+ kind: 'replace',
67
+ what: 'Millisecond timestamps — thirteen-digit numbers from about 2022 to 2033.',
68
+ why: 'The same clock reading in its other common form. Appears in ids, filenames and JSON bodies.',
69
+ wouldHide: 'Any genuine number that happens to be thirteen digits starting 16 to 19 — a large account number, or an amount held in the smallest currency unit. Money paths should exempt this rule.',
70
+ pattern: '\\b1[6-9]\\d{11}\\b',
71
+ with: '<time>',
72
+ numbers: true,
73
+ },
74
+ {
75
+ id: 'clock.duration',
76
+ kind: 'replace',
77
+ what: 'Durations printed next to their unit: 412ms, 1.2s, 900us.',
78
+ why: 'How long something took is different every run, and almost every CLI prints it.',
79
+ wouldHide: 'A duration the product deliberately shows and that ought to be fixed — a 30s timeout in a settings screen, a 14 day trial. Scope it away from those screens.',
80
+ pattern: '\\b\\d+(?:\\.\\d+)?\\s?(?:ms|us|\\u00b5s|ns)\\b|\\b\\d+(?:\\.\\d+)?s\\b',
81
+ with: '<duration>',
82
+ },
83
+ {
84
+ id: 'mem.address',
85
+ kind: 'replace',
86
+ what: 'Memory addresses — 0x followed by eight or more hex digits.',
87
+ why: 'Handed out by the operating system, different every launch, and they leak into crash dumps and object descriptions.',
88
+ wouldHide: 'A long hex constant the product prints on purpose. Eight digits is the floor precisely so that shorter constants, including colours, are left alone.',
89
+ pattern: '0x[0-9a-fA-F]{8,}',
90
+ with: '<address>',
91
+ },
92
+ {
93
+ id: 'id.uuid',
94
+ kind: 'replace',
95
+ what: 'UUIDs, in the usual 8-4-4-4-12 shape.',
96
+ why: 'Freshly minted on every run. One of them in a response body makes the whole body differ.',
97
+ wouldHide: 'A UUID written into a fixture on purpose, where the point of the check is that this exact id came back. Those are rare, and worth naming explicitly with a scoped exemption.',
98
+ pattern: '\\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\\b',
99
+ with: '<uuid>',
100
+ },
101
+ {
102
+ id: 'id.hex',
103
+ kind: 'replace',
104
+ what: 'Runs of 16 to 31 hex characters — request ids, session ids, short handles.',
105
+ why: 'Minted per run, like a UUID but without the dashes to recognise it by.',
106
+ wouldHide: 'A hex value that is meant to be stable and is in that length range. The 32-and-longer case is deliberately NOT covered here, because those are usually content hashes and a content hash changing is exactly the signal we want — see the optional rule id.hex-long.',
107
+ pattern: '\\b[0-9a-fA-F]{16,31}\\b',
108
+ with: '<hex>',
109
+ },
110
+ {
111
+ id: 'id.pid',
112
+ kind: 'replace',
113
+ what: 'Process ids, where they are labelled as such.',
114
+ why: 'Different every launch, and printed by anything that spawns a child process.',
115
+ wouldHide: 'Almost nothing. The label has to be there, so a bare number is never touched.',
116
+ pattern: '\\b(pid|PID)\\s*[=:]?\\s*\\d+\\b',
117
+ with: '$1 <pid>',
118
+ },
119
+ {
120
+ id: 'net.port',
121
+ kind: 'replace',
122
+ what: 'Port numbers on a local address — localhost:53412, 127.0.0.1:8931.',
123
+ why: 'Test servers and debug ports are picked free at launch, so they differ every run.',
124
+ wouldHide: 'A port the product is supposed to bind to and prints on purpose. If serving on a fixed port is part of the promise, check it on its own path and exempt that path here.',
125
+ pattern: '(localhost|127\\.0\\.0\\.1|0\\.0\\.0\\.0|\\[::1\\]):\\d{2,5}',
126
+ with: '$1:<port>',
127
+ },
128
+ {
129
+ id: 'path.temp',
130
+ kind: 'replace',
131
+ what: 'Temporary folders on Mac, Linux and Windows.',
132
+ why: 'A fresh temp folder every run, with a random name in the middle of it.',
133
+ wouldHide: 'The file NAME inside the temp folder, which this replaces along with the folder. If a check is about which file got written, observe the basename on its own path.',
134
+ pattern: '(?:/private)?/var/folders/[^\\s"\'`]*|/tmp/[^\\s"\'`]*|[A-Za-z]:\\\\Users\\\\[^\\\\]+\\\\AppData\\\\Local\\\\Temp[^\\s"\'`]*',
135
+ with: '<temp>',
136
+ },
137
+ {
138
+ id: 'path.home',
139
+ kind: 'replace',
140
+ what: 'The home folder of whoever is running it.',
141
+ why: 'Absolute paths differ between machines and between users, and the same run on his laptop and on the office box would otherwise disagree about everything.',
142
+ wouldHide: 'The username itself, where the product shows it on purpose. It also makes two different users look like the same user, so a permissions bug that only appears for one of them would not show.',
143
+ pattern: '/Users/[^/\\s"\'`]+|/home/[^/\\s"\'`]+|[A-Za-z]:\\\\Users\\\\[^\\\\\\s"\'`]+',
144
+ with: '<home>',
145
+ },
146
+ {
147
+ id: 'token.bearer',
148
+ kind: 'replace',
149
+ what: 'Bearer tokens in an authorization header or a log line.',
150
+ why: 'Reissued on every sign-in, so they never match twice.',
151
+ wouldHide: 'Whether the token changed — which for a sign-in flow can be the very thing worth seeing. It is acceptable here only because signing in is a sealed class: a difference in it goes to a person on other evidence, not on the token string.',
152
+ pattern: '(Bearer|bearer)\\s+[A-Za-z0-9\\-._~+/]+=*',
153
+ with: '$1 <token>',
154
+ },
155
+ {
156
+ id: 'token.jwt',
157
+ kind: 'replace',
158
+ what: 'JSON web tokens — the three dot-separated blocks starting eyJ.',
159
+ why: 'Signed with a timestamp inside, so a fresh one every time even for the same user.',
160
+ wouldHide: 'A change in what the token CLAIMS, which is real and which this erases. If a product decides anything from a token body, decode it and observe the claims on their own paths.',
161
+ pattern: 'eyJ[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]*',
162
+ with: '<jwt>',
163
+ },
164
+ {
165
+ id: 'text.ansi',
166
+ kind: 'replace',
167
+ what: 'Terminal colour codes.',
168
+ why: 'They surround almost every line a modern CLI prints, and they change when a library bumps, not when behaviour does.',
169
+ wouldHide: 'A colour change. If colour IS the output — a diff tool, a linter, a test runner showing red and green — switch this off, because then colour is behaviour.',
170
+ pattern: '\\u001b\\[[0-9;]*[A-Za-z]',
171
+ with: '',
172
+ },
173
+ {
174
+ id: 'text.crlf',
175
+ kind: 'replace',
176
+ what: 'Windows line endings.',
177
+ why: 'The same product printing the same words disagrees with itself across operating systems otherwise.',
178
+ wouldHide: 'A genuine line-ending change, which matters for a tool that WRITES files for other software to read. Products that promise a file format should check the raw bytes on their own path.',
179
+ pattern: '\\r\\n',
180
+ with: '\n',
181
+ },
182
+ {
183
+ id: 'text.trailing-space',
184
+ kind: 'replace',
185
+ what: 'Spaces and tabs at the end of a line.',
186
+ why: 'Invisible, and they move when a formatter runs.',
187
+ wouldHide: 'Trailing whitespace changes, which nobody has ever needed to know about.',
188
+ pattern: '[ \\t]+$',
189
+ flags: 'gm',
190
+ with: '',
191
+ },
192
+ {
193
+ id: 'number.float',
194
+ kind: 'round',
195
+ what: 'Floating point numbers, cut to twelve significant digits.',
196
+ why: 'The same arithmetic in a different order gives 0.30000000000000004 instead of 0.3, and that is a fact about binary floats, not about the product.',
197
+ wouldHide: 'A genuine change smaller than one part in a trillion. Whole numbers are never touched, so ids and counts are safe.',
198
+ digits: 12,
199
+ },
200
+ ];
201
+
202
+ /**
203
+ * Shipped, documented, and NOT switched on. Each says why not.
204
+ *
205
+ * They are here rather than in a wiki because the rule a project needs is usually one of
206
+ * these with a `paths` scope added, and it should be copyable rather than inventable.
207
+ *
208
+ * @type {NormaliseRule[]}
209
+ */
210
+ export const OPTIONAL_RULES = [
211
+ {
212
+ id: 'id.hex-long',
213
+ kind: 'replace',
214
+ what: 'Hex runs of 32 characters or more — MD5, SHA-1, SHA-256, content hashes.',
215
+ why: 'Build systems print them constantly and they change whenever anything upstream does.',
216
+ wouldHide: 'A content hash changing, which is very often the exact thing you want to be told about — a bundle whose contents moved, a lockfile that resolved differently.',
217
+ pattern: '\\b[0-9a-fA-F]{32,}\\b',
218
+ with: '<hash>',
219
+ off: true,
220
+ whyOff: 'A changed hash is usually a finding, not noise. Switch it on scoped to the paths where you know the hash is a cache key and nothing more.',
221
+ },
222
+ {
223
+ id: 'version.semver',
224
+ kind: 'replace',
225
+ what: 'Version numbers like 1.4.2.',
226
+ why: 'This is the footer case: bump the version and every screen carrying it reports as changed. One rule turns five hundred differences into zero.',
227
+ wouldHide: 'A dependency silently downgrading, a version pin that stopped being honoured, the wrong build being tested. All real, all serious.',
228
+ pattern: '\\bv?\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?\\b',
229
+ with: '<version>',
230
+ off: true,
231
+ whyOff: 'Never switch this on globally. Scope it to the footer, the about screen, the header — the places where the version is decoration. Leave it off everywhere the version is a fact.',
232
+ paths: ['screen.**.footer.**'],
233
+ },
234
+ {
235
+ id: 'keys.uuid',
236
+ kind: 'replace',
237
+ what: 'UUIDs used as object KEYS, not just values.',
238
+ why: 'A response keyed by record id differs entirely between runs even when every record is identical.',
239
+ wouldHide: 'Two entries collapsing into one, because normalising two different keys to the same text merges them and only the first survives. That silently deletes a record from the comparison.',
240
+ pattern: '\\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\\b',
241
+ with: '<uuid>',
242
+ keys: true,
243
+ off: true,
244
+ whyOff: 'The collision risk is real and silent. Prefer sorting the collection and observing its members by position, which loses nothing.',
245
+ },
246
+ {
247
+ id: 'stack.frames',
248
+ kind: 'replace',
249
+ what: 'File and line numbers inside stack traces.',
250
+ why: 'Every unrelated edit above a throw moves the line number, and internal frames churn with the Node version.',
251
+ wouldHide: 'Where the error came from — which is most of what a stack trace is for, and often the fastest way to see that a failure moved to a different cause.',
252
+ pattern: '\\(([^()\\s]+):\\d+:\\d+\\)',
253
+ with: '($1:<line>:<col>)',
254
+ off: true,
255
+ whyOff: 'Prefer observing the error MESSAGE and the top frame on their own paths. Blurring the frames throws away the answer along with the noise.',
256
+ },
257
+ ];
258
+
259
+ /**
260
+ * A version stamp for the shipped set. Bump it when the defaults change, so a stored capture
261
+ * normalised under the old rules is not silently compared against one normalised under the new
262
+ * ones — see `rulesFingerprint`, which is the mechanism that actually catches it.
263
+ */
264
+ export const RULES_VERSION = 1;
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // Rules as data: validating, merging, loading, describing
268
+ // ---------------------------------------------------------------------------
269
+
270
+ /**
271
+ * @param {unknown} rule
272
+ * @returns {string|null} the reason it is unusable, or null
273
+ */
274
+ export function ruleProblem(rule) {
275
+ if (typeof rule !== 'object' || rule === null) return 'a rule must be an object';
276
+ const r = /** @type {Partial<NormaliseRule>} */ (rule);
277
+ if (!r.id) return 'a rule needs an id';
278
+ if (!r.kind) return `rule ${r.id} needs a kind (replace, round, sort or drop)`;
279
+ if (!['replace', 'round', 'sort', 'drop'].includes(r.kind)) return `rule ${r.id} has an unknown kind "${r.kind}"`;
280
+ if (!r.what || !r.why || !r.wouldHide) {
281
+ return `rule ${r.id} is missing what/why/wouldHide — a rule nobody can audit is how a difference machine goes quiet`;
282
+ }
283
+ if (r.kind === 'replace') {
284
+ if (typeof r.pattern !== 'string' || r.pattern.length === 0) return `rule ${r.id} needs a pattern`;
285
+ if (typeof r.with !== 'string') return `rule ${r.id} needs a replacement (use "" to remove)`;
286
+ try {
287
+ new RegExp(r.pattern, r.flags ?? 'g');
288
+ } catch (e) {
289
+ return `rule ${r.id} has a pattern JavaScript cannot read: ${e instanceof Error ? e.message : String(e)}`;
290
+ }
291
+ }
292
+ if (r.kind === 'round' && (typeof r.digits !== 'number' || r.digits < 1 || r.digits > 21)) {
293
+ return `rule ${r.id} needs digits between 1 and 21`;
294
+ }
295
+ if (r.kind === 'drop' && (!r.at || r.at.length === 0)) {
296
+ return `rule ${r.id} drops part of a value, so it must say exactly where with "at"`;
297
+ }
298
+ return null;
299
+ }
300
+
301
+ /**
302
+ * @param {NormaliseRule[]} rules
303
+ * @returns {NormaliseRule[]} the same rules, once every one of them is usable
304
+ */
305
+ export function assertRules(rules) {
306
+ for (const rule of rules) {
307
+ const problem = ruleProblem(rule);
308
+ if (problem) {
309
+ throw new StaysFixedError(`Bad normalisation rule: ${problem}.`, {
310
+ hint: 'Rules are data. Fix it where it is written down, not in code.',
311
+ });
312
+ }
313
+ }
314
+ return rules;
315
+ }
316
+
317
+ /**
318
+ * Merge a project's rules over the shipped ones.
319
+ *
320
+ * Same id wins, so a project turns a default off by writing `{id, off: true}` and narrows one
321
+ * by writing `{id, paths: [...]}` — no need to restate a rule to change one field of it.
322
+ *
323
+ * @param {NormaliseRule[]} base
324
+ * @param {NormaliseRule[]} extra
325
+ * @returns {NormaliseRule[]}
326
+ */
327
+ export function mergeRules(base, extra) {
328
+ /** @type {Map<string, NormaliseRule>} */
329
+ const byId = new Map();
330
+ for (const rule of base) byId.set(rule.id, rule);
331
+ for (const rule of extra) {
332
+ const existing = byId.get(rule.id);
333
+ byId.set(rule.id, existing ? { ...existing, ...rule } : rule);
334
+ }
335
+ return [...byId.values()];
336
+ }
337
+
338
+ /**
339
+ * The rules that are actually switched on.
340
+ * @param {NormaliseRule[]} rules
341
+ * @returns {NormaliseRule[]}
342
+ */
343
+ export function activeRules(rules) {
344
+ return rules.filter((r) => !r.off);
345
+ }
346
+
347
+ /**
348
+ * A short fingerprint of a rule set.
349
+ *
350
+ * Stored on every capture. Comparing a capture normalised under one set of rules against one
351
+ * normalised under another is meaningless — the differences you see are the rules changing,
352
+ * not the product — and this is what lets the run notice and say so.
353
+ *
354
+ * @param {NormaliseRule[]} rules
355
+ * @returns {string}
356
+ */
357
+ export function rulesFingerprint(rules) {
358
+ const active = activeRules(rules)
359
+ .map((r) => ({
360
+ id: r.id,
361
+ kind: r.kind,
362
+ pattern: r.pattern ?? '',
363
+ flags: r.flags ?? '',
364
+ with: r.with ?? '',
365
+ digits: r.digits ?? 0,
366
+ keys: r.keys ?? false,
367
+ numbers: r.numbers ?? false,
368
+ paths: [...(r.paths ?? [])].sort(),
369
+ channels: [...(r.channels ?? [])].sort(),
370
+ at: [...(r.at ?? [])].sort(),
371
+ }))
372
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
373
+ return `v${RULES_VERSION}-${sha256(JSON.stringify(active)).slice(0, 12)}`;
374
+ }
375
+
376
+ /**
377
+ * The rule set in plain English, for `doctor` and for any agent asking the tool what it does.
378
+ * Says both what is on and what is deliberately off, because the second list is the more
379
+ * useful one when a run reports nothing.
380
+ *
381
+ * @param {NormaliseRule[]} rules
382
+ * @returns {{on: {id: string, what: string, wouldHide: string, scopedTo?: string[]}[], off: {id: string, what: string, whyOff: string}[]}}
383
+ */
384
+ export function describeRules(rules) {
385
+ return {
386
+ on: rules
387
+ .filter((r) => !r.off)
388
+ .map((r) => (r.paths ? { id: r.id, what: r.what, wouldHide: r.wouldHide, scopedTo: r.paths } : { id: r.id, what: r.what, wouldHide: r.wouldHide })),
389
+ off: rules
390
+ .filter((r) => r.off)
391
+ .map((r) => ({ id: r.id, what: r.what, whyOff: r.whyOff ?? 'no reason recorded' })),
392
+ };
393
+ }
394
+
395
+ /**
396
+ * Rules for the absolute paths of THIS machine.
397
+ *
398
+ * The shipped rules cover the shapes that are the same everywhere. A checkout folder is not
399
+ * one of those, and it turns up inside stack traces, error messages and every file the product
400
+ * writes — so it gets its own literal rules, generated where the tool is running rather than
401
+ * guessed by a pattern that would over-match.
402
+ *
403
+ * @param {{root?: string, home?: string, tmp?: string}} where
404
+ * @returns {NormaliseRule[]}
405
+ */
406
+ export function machineRules(where) {
407
+ /** @type {NormaliseRule[]} */
408
+ const rules = [];
409
+ /**
410
+ * @param {string} s
411
+ * @returns {string}
412
+ */
413
+ const literal = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
414
+ if (where.root) {
415
+ rules.push({
416
+ id: 'path.project-root',
417
+ kind: 'replace',
418
+ what: 'The folder this project is checked out in.',
419
+ why: 'It differs between his laptop, the office box and anyone else who clones the repo, and it appears in every stack trace and every written file path.',
420
+ wouldHide: 'Nothing about the product. It only ever replaces a prefix that is a fact about the machine.',
421
+ pattern: literal(where.root),
422
+ with: '<project>',
423
+ });
424
+ }
425
+ if (where.home) {
426
+ rules.push({
427
+ id: 'path.this-home',
428
+ kind: 'replace',
429
+ what: 'The home folder of the account running the tool.',
430
+ why: 'Same reason as the project root, one level up.',
431
+ wouldHide: 'The username, where a product shows it deliberately.',
432
+ pattern: literal(where.home),
433
+ with: '<home>',
434
+ });
435
+ }
436
+ if (where.tmp) {
437
+ rules.push({
438
+ id: 'path.this-tmp',
439
+ kind: 'replace',
440
+ what: 'The temp folder this machine hands out.',
441
+ why: 'Mac gives a per-boot random one under /var/folders that the general pattern only partly covers.',
442
+ wouldHide: 'Nothing the product decides.',
443
+ pattern: literal(where.tmp),
444
+ with: '<temp>',
445
+ });
446
+ }
447
+ return rules;
448
+ }
449
+
450
+ /**
451
+ * Read a project's own rules from a JSON file. Missing file means no extra rules, which is the
452
+ * normal case and not an error.
453
+ *
454
+ * @param {string} file
455
+ * @returns {Promise<NormaliseRule[]>}
456
+ */
457
+ export async function loadRules(file) {
458
+ /** @type {string} */
459
+ let raw;
460
+ try {
461
+ raw = await fsp.readFile(file, 'utf8');
462
+ } catch {
463
+ return [];
464
+ }
465
+ /** @type {unknown} */
466
+ let parsed;
467
+ try {
468
+ parsed = JSON.parse(raw);
469
+ } catch (e) {
470
+ throw new StaysFixedError(`Could not read the normalisation rules in ${file}: ${e instanceof Error ? e.message : String(e)}`, {
471
+ hint: 'The file holds a JSON array of rules, or an object with a "rules" array.',
472
+ });
473
+ }
474
+ const list = Array.isArray(parsed)
475
+ ? parsed
476
+ : /** @type {{rules?: unknown}} */ (parsed)?.rules;
477
+ if (!Array.isArray(list)) {
478
+ throw new StaysFixedError(`${file} does not contain a list of normalisation rules.`);
479
+ }
480
+ return assertRules(/** @type {NormaliseRule[]} */ (list));
481
+ }
482
+
483
+ // ---------------------------------------------------------------------------
484
+ // Applying the rules
485
+ // ---------------------------------------------------------------------------
486
+
487
+ /** Compiled regexes, keyed by source and flags. Building one per value would dominate the run. */
488
+ /** @type {Map<string, RegExp>} */
489
+ const rxCache = new Map();
490
+
491
+ /**
492
+ * @param {string} pattern
493
+ * @param {string} flags
494
+ * @returns {RegExp}
495
+ */
496
+ function regexFor(pattern, flags) {
497
+ const key = `${flags}${pattern}`;
498
+ let rx = rxCache.get(key);
499
+ if (!rx) {
500
+ rx = new RegExp(pattern, flags);
501
+ rxCache.set(key, rx);
502
+ }
503
+ // Shared instances with /g carry lastIndex between calls, and `replace` resets it — but
504
+ // `test` does not, so nothing here may use `test` on a cached global regex.
505
+ return rx;
506
+ }
507
+
508
+ /**
509
+ * Does this rule apply to the observation we are normalising?
510
+ *
511
+ * A rule scoped by path or channel is SKIPPED when we do not know the path or channel, rather
512
+ * than applied. A rule that says "only in api responses" must not fire on something we cannot
513
+ * confirm is one.
514
+ *
515
+ * @param {NormaliseRule} rule
516
+ * @param {{path?: string, channel?: Channel}} where
517
+ * @returns {boolean}
518
+ */
519
+ function ruleApplies(rule, where) {
520
+ if (rule.off) return false;
521
+ if (rule.channels && rule.channels.length > 0) {
522
+ if (!where.channel || !rule.channels.includes(where.channel)) return false;
523
+ }
524
+ if (rule.paths && rule.paths.length > 0) {
525
+ if (!where.path) return false;
526
+ if (!rule.paths.some((glob) => matchPath(/** @type {string} */ (where.path), glob))) return false;
527
+ }
528
+ return true;
529
+ }
530
+
531
+ /**
532
+ * @param {NormaliseRule} rule
533
+ * @param {string} at
534
+ * @returns {boolean}
535
+ */
536
+ function ruleAppliesAt(rule, at) {
537
+ if (!rule.at || rule.at.length === 0) return true;
538
+ return rule.at.some((glob) => matchPath(at, glob));
539
+ }
540
+
541
+ /**
542
+ * Turn a raw value into a canonical one.
543
+ *
544
+ * @param {ObservedValue} value
545
+ * @param {NormaliseRule[]} rules
546
+ * @param {{path?: string, channel?: Channel}} [where] The observation this value belongs to,
547
+ * so scoped rules know whether to fire.
548
+ * @returns {ObservedValue}
549
+ */
550
+ export function normalise(value, rules, where = {}) {
551
+ const applicable = rules.filter((r) => ruleApplies(r, where));
552
+ const out = walk(value, ROOT, applicable, null, 0);
553
+ return out === DROPPED ? null : out;
554
+ }
555
+
556
+ /**
557
+ * The same work, with a receipt.
558
+ *
559
+ * A difference hidden by normalisation has to be auditable, so this returns every replacement
560
+ * that was made, where it was made, and what the rule that made it admits it might be hiding.
561
+ * Used by the report, and by anyone asking "why did this run say nothing changed".
562
+ *
563
+ * @param {ObservedValue} value
564
+ * @param {NormaliseRule[]} rules
565
+ * @param {{path?: string, channel?: Channel}} [where]
566
+ * @returns {Explanation}
567
+ */
568
+ export function explain(value, rules, where = {}) {
569
+ const applicable = rules.filter((r) => ruleApplies(r, where));
570
+ /** @type {Replacement[]} */
571
+ const replacements = [];
572
+ const out = walk(value, ROOT, applicable, replacements, 0);
573
+ return {
574
+ value: out === DROPPED ? null : out,
575
+ replacements,
576
+ summary: summarise(replacements),
577
+ };
578
+ }
579
+
580
+ /**
581
+ * @param {Replacement[]} replacements
582
+ * @returns {string}
583
+ */
584
+ function summarise(replacements) {
585
+ if (replacements.length === 0) return 'Nothing was rewritten before comparing.';
586
+ /** @type {Map<string, number>} */
587
+ const counts = new Map();
588
+ for (const r of replacements) counts.set(r.ruleId, (counts.get(r.ruleId) ?? 0) + 1);
589
+ const parts = [...counts.entries()]
590
+ .sort((a, b) => b[1] - a[1])
591
+ .map(([id, n]) => `${id} (${n})`);
592
+ return `Rewritten before comparing by ${parts.join(', ')}. Anything those rules cover is not being watched here.`;
593
+ }
594
+
595
+ /**
596
+ * Normalise one observation, carrying its path and channel so scoped rules fire correctly.
597
+ * @param {Observation} observation
598
+ * @param {NormaliseRule[]} rules
599
+ * @returns {Observation}
600
+ */
601
+ export function normaliseObservation(observation, rules) {
602
+ const value = normalise(observation.value, rules, { path: observation.path, channel: observation.channel });
603
+ return { ...observation, value };
604
+ }
605
+
606
+ /**
607
+ * Normalise a whole capture and stamp it with the rule set that was used.
608
+ *
609
+ * The stamp is not decoration: comparing captures normalised under different rules produces
610
+ * differences that are about the rules, and the run has to be able to notice that.
611
+ *
612
+ * @param {Capture} capture
613
+ * @param {NormaliseRule[]} rules
614
+ * @returns {Capture}
615
+ */
616
+ export function normaliseCapture(capture, rules) {
617
+ return {
618
+ ...capture,
619
+ observations: capture.observations.map((o) => normaliseObservation(o, rules)),
620
+ rules: rulesFingerprint(rules),
621
+ };
622
+ }
623
+
624
+ /**
625
+ * The recursive worker. Returns the rewritten node, or DROPPED.
626
+ *
627
+ * @param {ObservedValue} node
628
+ * @param {string} at Position inside the value: '$', '$.items.3.id'.
629
+ * @param {NormaliseRule[]} rules
630
+ * @param {Replacement[]|null} record Non-null when we are explaining rather than just doing.
631
+ * @param {number} depth
632
+ * @returns {ObservedValue|typeof DROPPED}
633
+ */
634
+ function walk(node, at, rules, record, depth) {
635
+ if (depth > 64) return node;
636
+
637
+ for (const rule of rules) {
638
+ if (rule.kind === 'drop' && ruleAppliesAt(rule, at)) {
639
+ if (record) {
640
+ record.push(receipt(rule, at, canonicalJson(node), '<dropped>'));
641
+ }
642
+ return DROPPED;
643
+ }
644
+ }
645
+
646
+ if (typeof node === 'string') return rewriteString(node, at, rules, record);
647
+ if (typeof node === 'number') return rewriteNumber(node, at, rules, record);
648
+ if (node === null || typeof node === 'boolean') return node;
649
+
650
+ if (Array.isArray(node)) {
651
+ /** @type {ObservedValue[]} */
652
+ const items = [];
653
+ for (let i = 0; i < node.length; i++) {
654
+ const child = walk(node[i], `${at}.${i}`, rules, record, depth + 1);
655
+ if (child !== DROPPED) items.push(child);
656
+ }
657
+ for (const rule of rules) {
658
+ if (rule.kind !== 'sort' || !ruleAppliesAt(rule, at)) continue;
659
+ const before = record ? canonicalJson(items) : '';
660
+ items.sort((a, b) => {
661
+ const ja = canonicalJson(a);
662
+ const jb = canonicalJson(b);
663
+ return ja < jb ? -1 : ja > jb ? 1 : 0;
664
+ });
665
+ if (record) {
666
+ const after = canonicalJson(items);
667
+ if (before !== after) record.push(receipt(rule, at, before, after));
668
+ }
669
+ }
670
+ return items;
671
+ }
672
+
673
+ /** @type {Record<string, ObservedValue>} */
674
+ const out = {};
675
+ for (const [key, child] of Object.entries(node)) {
676
+ const newKey = rewriteKey(key, at, rules, record);
677
+ const value = walk(/** @type {ObservedValue} */ (child), `${at}.${key}`, rules, record, depth + 1);
678
+ if (value === DROPPED) continue;
679
+ // First key wins on a collision. Losing an entry silently is exactly why `keys` is off by
680
+ // default; when a project switches it on anyway, the loss is at least deterministic.
681
+ if (!(newKey in out)) out[newKey] = value;
682
+ }
683
+ return out;
684
+ }
685
+
686
+ /**
687
+ * @param {string} key
688
+ * @param {string} at
689
+ * @param {NormaliseRule[]} rules
690
+ * @param {Replacement[]|null} record
691
+ * @returns {string}
692
+ */
693
+ function rewriteKey(key, at, rules, record) {
694
+ let text = key;
695
+ for (const rule of rules) {
696
+ if (rule.kind !== 'replace' || !rule.keys) continue;
697
+ const next = text.replace(regexFor(/** @type {string} */ (rule.pattern), rule.flags ?? 'g'), rule.with ?? '');
698
+ if (next !== text) {
699
+ if (record) record.push(receipt(rule, `${at}.${key} (key)`, text, next));
700
+ text = next;
701
+ }
702
+ }
703
+ return text;
704
+ }
705
+
706
+ /**
707
+ * @param {string} value
708
+ * @param {string} at
709
+ * @param {NormaliseRule[]} rules
710
+ * @param {Replacement[]|null} record
711
+ * @returns {string}
712
+ */
713
+ function rewriteString(value, at, rules, record) {
714
+ let text = value;
715
+ for (const rule of rules) {
716
+ if (rule.kind !== 'replace' || !ruleAppliesAt(rule, at)) continue;
717
+ const next = text.replace(regexFor(/** @type {string} */ (rule.pattern), rule.flags ?? 'g'), rule.with ?? '');
718
+ if (next !== text) {
719
+ if (record) record.push(receipt(rule, at, text, next));
720
+ text = next;
721
+ }
722
+ }
723
+ return text;
724
+ }
725
+
726
+ /**
727
+ * @param {number} value
728
+ * @param {string} at
729
+ * @param {NormaliseRule[]} rules
730
+ * @param {Replacement[]|null} record
731
+ * @returns {ObservedValue}
732
+ */
733
+ function rewriteNumber(value, at, rules, record) {
734
+ for (const rule of rules) {
735
+ if (rule.kind !== 'replace' || !rule.numbers || !ruleAppliesAt(rule, at)) continue;
736
+ const text = String(value);
737
+ const next = text.replace(regexFor(/** @type {string} */ (rule.pattern), rule.flags ?? 'g'), rule.with ?? '');
738
+ if (next !== text) {
739
+ if (record) record.push(receipt(rule, at, text, next));
740
+ return next;
741
+ }
742
+ }
743
+ for (const rule of rules) {
744
+ if (rule.kind !== 'round' || !ruleAppliesAt(rule, at)) continue;
745
+ // Whole numbers are left alone on purpose. toPrecision on a large integer would turn it
746
+ // into a rounded float, and ids and counts are integers.
747
+ if (Number.isInteger(value) || !Number.isFinite(value)) continue;
748
+ const rounded = Number(value.toPrecision(rule.digits ?? 12));
749
+ if (rounded !== value) {
750
+ if (record) record.push(receipt(rule, at, String(value), String(rounded)));
751
+ return rounded;
752
+ }
753
+ }
754
+ return value;
755
+ }
756
+
757
+ /**
758
+ * @param {NormaliseRule} rule
759
+ * @param {string} at
760
+ * @param {string} before
761
+ * @param {string} after
762
+ * @returns {Replacement}
763
+ */
764
+ function receipt(rule, at, before, after) {
765
+ return {
766
+ ruleId: rule.id,
767
+ what: rule.what,
768
+ why: rule.why,
769
+ wouldHide: rule.wouldHide,
770
+ at,
771
+ before: clip(before),
772
+ after: clip(after),
773
+ };
774
+ }
775
+
776
+ /**
777
+ * Receipts are for reading. A megabyte of stdout in one is nobody's idea of an explanation.
778
+ * @param {string} s
779
+ * @returns {string}
780
+ */
781
+ function clip(s) {
782
+ return s.length <= 300 ? s : `${s.slice(0, 300)}… (${s.length} characters)`;
783
+ }