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.
- package/README.md +534 -402
- package/package.json +8 -3
- package/src/cli/index.js +14 -0
- package/src/v2/adapters/android-driver.js +1705 -0
- package/src/v2/adapters/android.js +1117 -0
- package/src/v2/adapters/contract.js +565 -0
- package/src/v2/adapters/electron.js +1594 -0
- package/src/v2/adapters/http.js +733 -0
- package/src/v2/adapters/ios-driver.js +1551 -0
- package/src/v2/adapters/ios.js +989 -0
- package/src/v2/adapters/isolate.js +739 -0
- package/src/v2/adapters/process.js +920 -0
- package/src/v2/adapters/source.js +1241 -0
- package/src/v2/adapters/web-driver.js +1532 -0
- package/src/v2/adapters/web.js +1009 -0
- package/src/v2/adapters/windows.js +1329 -0
- package/src/v2/browsers.js +1203 -0
- package/src/v2/cause.js +364 -0
- package/src/v2/check.js +1331 -0
- package/src/v2/ci.js +1209 -0
- package/src/v2/cli.js +657 -0
- package/src/v2/cluster.js +372 -0
- package/src/v2/coverage.js +1116 -0
- package/src/v2/detect.js +1199 -0
- package/src/v2/doctor.js +1690 -0
- package/src/v2/escalate.js +679 -0
- package/src/v2/init.js +1394 -0
- package/src/v2/intent.js +659 -0
- package/src/v2/journeys/from-routes.js +498 -0
- package/src/v2/journeys/from-suite.js +988 -0
- package/src/v2/journeys/index.js +651 -0
- package/src/v2/journeys/record.js +516 -0
- package/src/v2/mcp/server.js +374 -0
- package/src/v2/mcp/tools.js +1571 -0
- package/src/v2/normalise.js +783 -0
- package/src/v2/observation.js +877 -0
- package/src/v2/rank.js +672 -0
- package/src/v2/reference.js +1051 -0
- package/src/v2/remote.js +911 -0
- package/src/v2/run.js +964 -0
- package/src/v2/sealed.js +564 -0
- package/src/v2/selfcheck.js +564 -0
- package/src/v2/ship.js +684 -0
- package/src/v2/store.js +703 -0
- package/src/v2/types.js +503 -0
- package/src/v2/waiver.js +511 -0
- 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
|
+
}
|