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,564 @@
1
+ /**
2
+ * The five classes no agent may ever wave through.
3
+ *
4
+ * Everything else in the waiver system is a judgement with a confidence attached. This file is
5
+ * not. It answers one question — "may an agent decide, on its own, that this difference is
6
+ * fine?" — and when the answer is no, no amount of good reasoning changes it. That asymmetry is
7
+ * deliberate. An agent under pressure to finish writes a plausible reason for anything, so the
8
+ * defence cannot be the quality of the reason; it has to be a class of difference where the
9
+ * reason is never listened to at all.
10
+ *
11
+ * The five, and why each one is on the list:
12
+ *
13
+ * money Being wrong costs somebody money, and the mistake is not reversible by editing
14
+ * code afterwards. A charge that went out is out.
15
+ * sign-in Being wrong locks a real person out of their own account, or lets the wrong
16
+ * person in. Both are worse than the feature that was being built.
17
+ * data-loss Being wrong destroys something that cannot be rebuilt from the repository.
18
+ * crash The product stopped. There is no version of "I meant that" worth reading.
19
+ * guard A guard is the encoded memory of a bug somebody already reported once. A
20
+ * difference there means a bug that was already paid for is back, and an agent
21
+ * deciding that is acceptable is the single fastest way to destroy trust in a
22
+ * product. This one leads the list for that reason.
23
+ *
24
+ * WHERE THE LINES ARE DRAWN, and what I was worried about in each direction. Sealing too much
25
+ * sends a person a stream of things they do not need to see, they stop reading it, and the one
26
+ * that mattered goes past unread — a seal that is never read is worth less than no seal. Sealing
27
+ * too little lets an agent wave through the one difference that costs somebody their afternoon.
28
+ * So each rule below says which way I erred and why.
29
+ *
30
+ * This file reads a finding and nothing else. No disk, no network, no clock — so it gives the
31
+ * same answer twice, it can be run on a finding that came out of a file, and a test can hand it
32
+ * anything at all. `waiver.js` is the only caller that matters, and it calls this FIRST, before
33
+ * it looks at intents or budgets, because a sealed finding is refused whatever the rest says.
34
+ *
35
+ * It deliberately does NOT trust the engine to have got this right on its own. `rank.js` already
36
+ * labels findings, and this agrees with it by design — same class names, same order. But it also
37
+ * does the work again from the finding's own text, and seals when EITHER says so. A gate that
38
+ * only reads a flag another part of the tool set is a gate that a bug in that part can open.
39
+ */
40
+
41
+ /**
42
+ * @typedef {import('./types.js').Finding} Finding
43
+ * @typedef {import('./types.js').FindingClass} FindingClass
44
+ * @typedef {import('./types.js').Difference} Difference
45
+ * @typedef {import('./types.js').Channel} Channel
46
+ */
47
+
48
+ /**
49
+ * The classes, worst first, and what each is called in a sentence a person reads.
50
+ *
51
+ * The order matches `SEALED_ORDER` in rank.js on purpose: two parts of one tool disagreeing
52
+ * about which of two bad things is worse is the kind of small inconsistency that makes people
53
+ * stop believing either of them.
54
+ *
55
+ * @type {{name: Exclude<FindingClass, 'ordinary'>, says: string, because: string}[]}
56
+ */
57
+ export const SEALED_CLASSES = [
58
+ {
59
+ name: 'guard',
60
+ says: 'a bug somebody already reported once',
61
+ because:
62
+ 'A guard exists because this exact thing broke before and somebody had to say so. A difference here means it is back.',
63
+ },
64
+ {
65
+ name: 'crash',
66
+ says: 'a crash',
67
+ because: 'The product stopped, or started stopping. Nothing about that can be intended.',
68
+ },
69
+ {
70
+ name: 'data-loss',
71
+ says: 'losing data',
72
+ because: 'Code can be edited back. Data that was deleted cannot.',
73
+ },
74
+ {
75
+ name: 'money',
76
+ says: 'money',
77
+ because: 'A charge, a price or a refund that goes out wrong costs a real person real money.',
78
+ },
79
+ {
80
+ name: 'sign-in',
81
+ says: 'signing in',
82
+ because: 'Getting this wrong locks the right people out or lets the wrong people in.',
83
+ },
84
+ ];
85
+
86
+ /** Just the names, worst first. @type {Exclude<FindingClass, 'ordinary'>[]} */
87
+ export const SEALED_ORDER = SEALED_CLASSES.map((c) => c.name);
88
+
89
+ /** @type {Record<string, {says: string, because: string}>} */
90
+ const CLASS_INDEX = Object.fromEntries(SEALED_CLASSES.map((c) => [c.name, { says: c.says, because: c.because }]));
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // The vocabularies
94
+ // ---------------------------------------------------------------------------
95
+
96
+ /**
97
+ * Money words that seal on any channel at all.
98
+ *
99
+ * ERRED TOWARDS SEALING. These are words that almost never appear in a product except where
100
+ * money is involved, so a false seal is rare and the cost of missing one is somebody being
101
+ * charged the wrong amount. `price` is in here even though a price is often just a label,
102
+ * because a price label that changed by itself is exactly the thing worth interrupting a person
103
+ * for.
104
+ *
105
+ * Note `\bpay\b` cannot match inside `payload` — a word boundary needs a non-word character —
106
+ * so the obvious false positive is already handled by the boundary rather than by an exception.
107
+ */
108
+ const MONEY_ALWAYS =
109
+ /\b(charge|charged|charges|charging|payment|payments|pay|paying|paid|invoice|invoices|refund|refunds|refunded|price|prices|pricing|billing|billed|subscription|subscriptions|checkout|stripe|paypal|braintree|adyen|payout|payouts|wallet|coupon|coupons|discount|discounts|voucher|iban|swift code|credit card|card number|cardnumber|cvv|amount due|total due|purchase|purchases|receipt|receipts)\b/i;
110
+
111
+ /**
112
+ * Money words that only seal when something actually went out or came back.
113
+ *
114
+ * ERRED TOWARDS NOT SEALING. `total`, `amount`, `balance` and `cost` are ordinary English and
115
+ * live all over a product that has nothing to do with money — a load balance, a total count of
116
+ * files, the cost of a query. On the `effects` and `results` channels they sit inside a call
117
+ * that was made or a body that came back, which is where a number of that name is usually a
118
+ * sum of money. On a screen label they are usually not, and sealing there would flood a person
119
+ * with counters that moved.
120
+ */
121
+ const MONEY_WHEN_SENT =
122
+ /\b(amount|amounts|balance|balances|total|totals|subtotal|cost|costs|fee|fees|cents|currency|usd|eur|gbp|aed|pkr|inr|tax|vat)\b/i;
123
+
124
+ /**
125
+ * Sign-in words that seal on any channel.
126
+ *
127
+ * ERRED TOWARDS SEALING, with two deliberate exclusions.
128
+ *
129
+ * `session` on its own is NOT here, and that is the most important omission in this file. A
130
+ * terminal session, a shell session, a browser session and a recorded session are all ordinary
131
+ * words in the products this runs against — Terminal Deck calls every shell it opens a session —
132
+ * and sealing on it would put a sealed finding in front of a person on nearly every run. The
133
+ * phrase `session token` IS here, because that is unambiguous.
134
+ *
135
+ * The status numbers 401 and 403 are NOT here either. A word boundary sits either side of the
136
+ * digits in a version string like `1.401.0`, so they seal things that have nothing to do with
137
+ * signing in. `unauthorized` and `forbidden` carry the same meaning without the false matches.
138
+ */
139
+ const SIGN_IN_ALWAYS =
140
+ /\b(sign ?in|signin|sign ?out|signout|sign ?up|signup|log ?in|login|log ?out|logout|password|passwords|passcode|passphrase|credential|credentials|oauth|jwt|api key|apikey|access token|refresh token|session token|bearer token|two ?factor|2fa|otp|mfa|sso|saml|authenticate|authenticated|authentication|authorise|authorize|authorisation|authorization|unauthorised|unauthorized|forbidden|impersonate|impersonation)\b/i;
141
+
142
+ /**
143
+ * Sign-in words that only seal when something went out, came back, or is a door the code
144
+ * exposes.
145
+ *
146
+ * ERRED TOWARDS NOT SEALING. `auth`, `token`, `role`, `permission` and `account` are everywhere
147
+ * in a codebase's own vocabulary. Inside a request that was made, a body that came back, or the
148
+ * list of routes the source exposes, a change to one of them is a change to who can do what.
149
+ */
150
+ const SIGN_IN_WHEN_SENT =
151
+ /\b(auth|tokens?|permissions?|roles?|cookies?|identity|identities|account|accounts|scope|scopes|acl)\b/i;
152
+
153
+ /**
154
+ * Data-loss phrases that seal on any channel.
155
+ *
156
+ * ERRED HARD TOWARDS SEALING. Every one of these is a phrase that only appears where something
157
+ * is being destroyed. There is no ordinary reading of `drop table` or `rm -rf`.
158
+ */
159
+ const DATA_LOSS_ALWAYS =
160
+ /(\bdrop\s+(?:table|database|schema)\b|\btruncate\b|\bdelete\s+from\b|\brm\s+-rf\b|\bunlink\b|\brmdir\b|\bwiped?\b|\bpurged?\b|\berased?\b|\bdata ?loss\b|\bdestroys?\b|\bdestroyed\b|\bshred\b|--accept-data-loss|\bmigrate\s+reset\b|\bforce-reset\b|\bdrop\s+column\b|\bformat\s+(?:disk|drive)\b)/i;
161
+
162
+ /**
163
+ * Data-loss words that only seal when something went out or a door changed.
164
+ *
165
+ * ERRED TOWARDS NOT SEALING, and this is the line I am least comfortable with in either
166
+ * direction. A button labelled Delete that changed colour is not a data-loss incident, and
167
+ * treating it as one is how a safety net gets ignored. But a Delete button that stopped asking
168
+ * "are you sure" IS one, and that difference lives on the meaning channel where this rule does
169
+ * not look. I chose the quieter rule and left the loud case to the guard class: a confirmation
170
+ * that matters enough to worry about is a bug somebody has already reported once, and a guard
171
+ * names it. If that turns out to be wrong in practice, the fix is a guard, not a wider regex.
172
+ */
173
+ const DATA_LOSS_WHEN_SENT =
174
+ /\b(delete|deletes|deleted|deleting|remove all|removes all|clear all|clears all|drop|dropped|migration|migrations|migrate|migrated|overwrite|overwrites|overwritten|reset)\b/i;
175
+
176
+ /**
177
+ * Crash words that seal on any channel, because none of them has a calm reading.
178
+ */
179
+ const CRASH_ALWAYS =
180
+ /\b(segfault|segmentation fault|core dumped|kernel panic|stack overflow|out of memory|fatal error|abort trap|sigsegv|sigabrt|sigbus|heap corruption)\b/i;
181
+
182
+ /**
183
+ * Crash words that seal when they come from the complaints channel — console, stderr, exit codes.
184
+ *
185
+ * ERRED TOWARDS NOT SEALING on the word `error` alone, which is why it is missing here. Every
186
+ * product with an error message in it says "error" somewhere, on every screen, forever. An error
187
+ * MESSAGE changing is an ordinary difference and rank.js already puts complaints near the top of
188
+ * the list, so it will be looked at; it just does not need a person woken up. `uncaught`,
189
+ * `unhandled`, `traceback` and `panic` are different — they are what a program says while it is
190
+ * dying.
191
+ */
192
+ const CRASH_IN_COMPLAINTS =
193
+ /\b(crash|crashed|crashes|crashing|uncaught|unhandled|fatal|panic|panicked|traceback|stack trace|nonzero exit|non-zero exit|exited unexpectedly|terminated unexpectedly)\b/i;
194
+
195
+ /** Paths that hold the exit code of something we ran. */
196
+ const EXIT_CODE_PATH = /(^|[.\-_/])(exit ?code|exitstatus|exit ?status|status ?code|returncode|return ?code)([.\-_/]|$)/i;
197
+
198
+ /** Channels where a value represents something that actually left the machine or came back. */
199
+ /** @type {Set<Channel>} */
200
+ const SENT_CHANNELS = new Set(['effects', 'results', 'contract']);
201
+
202
+ /** A guard name shorter than this is too small to match on safely. */
203
+ const SHORTEST_GUARD_NAME = 6;
204
+
205
+ /** How much of one value is read. A whole HTTP body would drown the match in noise. */
206
+ const VALUE_CHARS = 400;
207
+
208
+ /** How many differences of one finding are read. Clusters can hold hundreds. */
209
+ const MAX_DIFFERENCES_READ = 80;
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // The answer
213
+ // ---------------------------------------------------------------------------
214
+
215
+ /**
216
+ * What classify hands back when a finding is sealed.
217
+ *
218
+ * `strength` never changes the outcome — sealed is sealed — it only tells a person reading the
219
+ * summary how sure the machine was. A guard matched by name is not the same kind of certainty
220
+ * as the word `refund` turning up in a title, and pretending they are is how a person learns to
221
+ * skim.
222
+ *
223
+ * @typedef {object} SealedVerdict
224
+ * @property {Exclude<FindingClass, 'ordinary'>} class
225
+ * @property {string} says What this class is called in a sentence: 'money'.
226
+ * @property {string} why One plain sentence: why nobody may wave it through.
227
+ * @property {string[]} matched The exact words or guard names that decided it.
228
+ * @property {string[]} where The addresses those words came from, so it can be checked.
229
+ * @property {'engine'|'words'|'both'} from
230
+ * Who said so: the engine's own label, this file reading the finding's text, or both agreeing.
231
+ * @property {'certain'|'likely'} strength
232
+ */
233
+
234
+ /**
235
+ * @typedef {object} ClassifyOptions
236
+ * @property {string[]} [guards] Names of the guards loaded for this project. A difference that
237
+ * names one is sealed by name, which is the strongest signal here.
238
+ * @property {boolean} [trustEngine] Default true. Set false to ignore `finding.class` entirely
239
+ * and judge only from the text — used by the self-check, so a
240
+ * wrong label upstream cannot make this file look right.
241
+ */
242
+
243
+ /**
244
+ * Is this difference in a class no agent may wave through?
245
+ *
246
+ * @param {Finding} finding
247
+ * @param {ClassifyOptions} [opts]
248
+ * @returns {SealedVerdict|null} null means ordinary: an agent may waive it if the other three
249
+ * gates let it through.
250
+ */
251
+ export function classify(finding, opts = {}) {
252
+ const guards = (opts.guards ?? []).filter((g) => typeof g === 'string' && g.trim().length >= SHORTEST_GUARD_NAME);
253
+ const trustEngine = opts.trustEngine !== false;
254
+
255
+ const read = readFinding(finding);
256
+ const fromWords = judgeWords(read, guards);
257
+
258
+ /** @type {Exclude<FindingClass, 'ordinary'>|null} */
259
+ let fromEngine = null;
260
+ if (trustEngine) {
261
+ const label = finding.class;
262
+ if (typeof label === 'string' && label !== 'ordinary' && label in CLASS_INDEX) {
263
+ fromEngine = /** @type {Exclude<FindingClass, 'ordinary'>} */ (label);
264
+ }
265
+ }
266
+
267
+ if (!fromWords && !fromEngine) return null;
268
+
269
+ // When the two disagree, take the worse of the two. Disagreement means one of them saw
270
+ // something the other did not, and this is not the place to split the difference.
271
+ const chosen = worseOf(fromWords ? fromWords.class : null, fromEngine);
272
+ if (!chosen) return null;
273
+
274
+ const agreed = Boolean(fromWords && fromEngine && fromWords.class === fromEngine);
275
+ const matched = fromWords && fromWords.class === chosen ? fromWords.matched : [];
276
+ const where = fromWords && fromWords.class === chosen ? fromWords.where : [];
277
+ const meta = CLASS_INDEX[chosen];
278
+
279
+ return {
280
+ class: chosen,
281
+ says: meta.says,
282
+ why: `Nobody may wave this through on their own: it touches ${meta.says}. ${meta.because}`,
283
+ matched,
284
+ where,
285
+ from: agreed ? 'both' : fromWords && fromWords.class === chosen ? 'words' : 'engine',
286
+ // A guard matched by its own full name, or a phrase with no calm reading, is certain.
287
+ // A single ordinary word turning up in a title is a good reason to look, not a proof.
288
+ strength: chosen === 'guard' || (fromWords?.certain ?? false) || agreed ? 'certain' : 'likely',
289
+ };
290
+ }
291
+
292
+ /**
293
+ * The same answer as a plain class name, for callers that already work in `FindingClass`.
294
+ *
295
+ * @param {Finding} finding
296
+ * @param {ClassifyOptions} [opts]
297
+ * @returns {FindingClass}
298
+ */
299
+ export function sealedClassOf(finding, opts = {}) {
300
+ return classify(finding, opts)?.class ?? 'ordinary';
301
+ }
302
+
303
+ /**
304
+ * @param {Finding} finding
305
+ * @param {ClassifyOptions} [opts]
306
+ * @returns {boolean}
307
+ */
308
+ export function isSealed(finding, opts = {}) {
309
+ return classify(finding, opts) !== null;
310
+ }
311
+
312
+ /**
313
+ * The refusal, written out for whoever reads it — an agent that has just been told no, or a
314
+ * person reading the closing summary.
315
+ *
316
+ * @param {SealedVerdict} verdict
317
+ * @param {Finding} [finding]
318
+ * @returns {string}
319
+ */
320
+ export function sayRefusal(verdict, finding) {
321
+ const lines = [`Refused. ${verdict.why}`];
322
+ if (finding?.title) lines.push(` ${trim(finding.title, 200)}`);
323
+ if (verdict.matched.length > 0) {
324
+ lines.push(` What decided it: ${verdict.matched.slice(0, 6).join(', ')}${verdict.where[0] ? ` (at ${verdict.where[0]})` : ''}.`);
325
+ }
326
+ lines.push(
327
+ '',
328
+ 'No agent can wave this through, whatever the reason, and asking again in different words will get the same answer. Fix it, or put it in front of a person and say plainly what changed.'
329
+ );
330
+ return lines.join('\n');
331
+ }
332
+
333
+ // ---------------------------------------------------------------------------
334
+ // Reading a finding
335
+ // ---------------------------------------------------------------------------
336
+
337
+ /**
338
+ * Everything about a finding that a word can be looked for in, kept next to the address it came
339
+ * from so a match can be shown rather than asserted.
340
+ *
341
+ * @typedef {object} ReadFinding
342
+ * @property {{text: string, where: string}[]} pieces
343
+ * @property {string} all Every piece joined, for one fast test.
344
+ * @property {Set<Channel>} channels
345
+ * @property {Difference[]} differences
346
+ */
347
+
348
+ /**
349
+ * Pull the text out of a finding.
350
+ *
351
+ * `finding.why` is deliberately NOT read. That field holds prose the ranker wrote about the
352
+ * finding — including, for a sealed one, the sentence "it touches money". Feeding the tool's own
353
+ * explanation back into the tool's own classifier makes a loop where a label justifies itself,
354
+ * and the day somebody rewords that sentence the seals change for no reason at all.
355
+ *
356
+ * @param {Finding} finding
357
+ * @returns {ReadFinding}
358
+ */
359
+ function readFinding(finding) {
360
+ /** @type {{text: string, where: string}[]} */
361
+ const pieces = [];
362
+ /** @param {unknown} text @param {string} where */
363
+ const add = (text, where) => {
364
+ if (typeof text === 'string' && text.trim() !== '') pieces.push({ text, where });
365
+ };
366
+
367
+ add(finding.title, 'the title');
368
+ add(finding.summary, 'the summary');
369
+ add(finding.signature, 'what the differences were grouped on');
370
+ for (const file of finding.nearFiles ?? []) add(file, 'a source file this points at');
371
+ for (const p of finding.paths ?? []) add(p, p);
372
+
373
+ const differences = (finding.differences ?? []).slice(0, MAX_DIFFERENCES_READ);
374
+ for (const d of differences) {
375
+ add(d.path, d.path);
376
+ add(d.describe, d.path);
377
+ add(faceOf(d.reference), d.path);
378
+ add(faceOf(d.candidate), d.path);
379
+ add(d.journey, `the ${d.journey} journey`);
380
+ }
381
+ if (finding.sample) {
382
+ add(finding.sample.path, finding.sample.path);
383
+ add(faceOf(finding.sample.reference), finding.sample.path);
384
+ add(faceOf(finding.sample.candidate), finding.sample.path);
385
+ }
386
+
387
+ return {
388
+ pieces,
389
+ all: pieces.map((p) => p.text).join('\n'),
390
+ channels: new Set((finding.differences ?? []).map((d) => d.channel)),
391
+ differences,
392
+ };
393
+ }
394
+
395
+ /**
396
+ * A value as text, short enough to search without drowning the match in noise.
397
+ * @param {unknown} value
398
+ * @returns {string}
399
+ */
400
+ function faceOf(value) {
401
+ if (value === undefined) return '';
402
+ if (value === null) return 'null';
403
+ if (typeof value === 'string') return trim(value, VALUE_CHARS);
404
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
405
+ try {
406
+ return trim(JSON.stringify(value) ?? '', VALUE_CHARS);
407
+ } catch {
408
+ return '';
409
+ }
410
+ }
411
+
412
+ /**
413
+ * @param {string} text
414
+ * @param {number} max
415
+ * @returns {string}
416
+ */
417
+ function trim(text, max) {
418
+ const one = String(text).replace(/\s+/g, ' ').trim();
419
+ return one.length > max ? `${one.slice(0, max - 1)}…` : one;
420
+ }
421
+
422
+ // ---------------------------------------------------------------------------
423
+ // The rules, in order
424
+ // ---------------------------------------------------------------------------
425
+
426
+ /**
427
+ * @typedef {object} WordVerdict
428
+ * @property {Exclude<FindingClass, 'ordinary'>} class
429
+ * @property {string[]} matched
430
+ * @property {string[]} where
431
+ * @property {boolean} certain
432
+ */
433
+
434
+ /**
435
+ * Judge the finding from its own text, in the order the classes are ranked, so a difference
436
+ * that is both a crash and a money difference is reported as the worse of the two.
437
+ *
438
+ * @param {ReadFinding} read
439
+ * @param {string[]} guards
440
+ * @returns {WordVerdict|null}
441
+ */
442
+ function judgeWords(read, guards) {
443
+ const sent = [...read.channels].some((c) => SENT_CHANNELS.has(c));
444
+
445
+ // GUARD. A guard name is a plain-English sentence somebody wrote about a bug they had —
446
+ // 'the sidebar still collapses'. Whole-name matching is safe precisely because they are long
447
+ // and specific. Matching on the individual WORDS of a guard name would seal half the product,
448
+ // because those words are 'the', 'sidebar' and 'still'.
449
+ for (const name of guards) {
450
+ const hit = read.pieces.find((p) => p.text.toLowerCase().includes(name.toLowerCase().trim()));
451
+ if (hit) return { class: 'guard', matched: [name], where: [hit.where], certain: true };
452
+ }
453
+ const guardPath = read.differences.find((d) => firstSegment(d.path) === 'guard');
454
+ if (guardPath) return { class: 'guard', matched: ['a guard address'], where: [guardPath.path], certain: true };
455
+
456
+ // CRASH. Two ways in: words that only get said while a program is dying, and an exit code
457
+ // that used to be zero and is not any more. The second one needs no vocabulary at all, which
458
+ // makes it the most reliable rule in the file.
459
+ const exit = exitCodeCrash(read.differences);
460
+ if (exit) return exit;
461
+ const crashAlways = firstMatch(read, CRASH_ALWAYS);
462
+ if (crashAlways) return { class: 'crash', ...crashAlways, certain: true };
463
+ if (read.channels.has('complaints')) {
464
+ const complaint = firstMatch(read, CRASH_IN_COMPLAINTS);
465
+ if (complaint) return { class: 'crash', ...complaint, certain: true };
466
+ }
467
+
468
+ // DATA LOSS.
469
+ const lossAlways = firstMatch(read, DATA_LOSS_ALWAYS);
470
+ if (lossAlways) return { class: 'data-loss', ...lossAlways, certain: true };
471
+ if (sent) {
472
+ const lossSent = firstMatch(read, DATA_LOSS_WHEN_SENT);
473
+ if (lossSent) return { class: 'data-loss', ...lossSent, certain: false };
474
+ }
475
+
476
+ // MONEY.
477
+ const moneyAlways = firstMatch(read, MONEY_ALWAYS);
478
+ if (moneyAlways) return { class: 'money', ...moneyAlways, certain: false };
479
+ if (sent) {
480
+ const moneySent = firstMatch(read, MONEY_WHEN_SENT);
481
+ if (moneySent) return { class: 'money', ...moneySent, certain: false };
482
+ }
483
+
484
+ // SIGN IN.
485
+ const signAlways = firstMatch(read, SIGN_IN_ALWAYS);
486
+ if (signAlways) return { class: 'sign-in', ...signAlways, certain: false };
487
+ if (sent) {
488
+ const signSent = firstMatch(read, SIGN_IN_WHEN_SENT);
489
+ if (signSent) return { class: 'sign-in', ...signSent, certain: false };
490
+ }
491
+
492
+ return null;
493
+ }
494
+
495
+ /**
496
+ * A program that used to finish and now does not.
497
+ *
498
+ * Read as: the address is an exit code or a status code, the old value was zero or absent, and
499
+ * the new value is not zero. No words involved, so it works on a product in any language, and it
500
+ * cannot be talked out of.
501
+ *
502
+ * @param {Difference[]} differences
503
+ * @returns {WordVerdict|null}
504
+ */
505
+ function exitCodeCrash(differences) {
506
+ for (const d of differences) {
507
+ if (!EXIT_CODE_PATH.test(d.path)) continue;
508
+ const now = asNumber(d.candidate);
509
+ if (now === null || now === 0) continue;
510
+ const before = asNumber(d.reference);
511
+ if (before !== null && before !== 0) continue; // It was already failing. Not new, not a crash.
512
+ return {
513
+ class: 'crash',
514
+ matched: [`exit code ${before === null ? 'became' : `went from ${before} to`} ${now}`],
515
+ where: [d.path],
516
+ certain: true,
517
+ };
518
+ }
519
+ return null;
520
+ }
521
+
522
+ /**
523
+ * @param {unknown} value
524
+ * @returns {number|null}
525
+ */
526
+ function asNumber(value) {
527
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
528
+ if (typeof value === 'string' && /^-?\d+$/.test(value.trim())) return Number(value.trim());
529
+ return null;
530
+ }
531
+
532
+ /**
533
+ * The first piece of the finding that a pattern matches, with the address it came from.
534
+ *
535
+ * @param {ReadFinding} read
536
+ * @param {RegExp} pattern
537
+ * @returns {{matched: string[], where: string[]}|null}
538
+ */
539
+ function firstMatch(read, pattern) {
540
+ for (const piece of read.pieces) {
541
+ const hit = pattern.exec(piece.text);
542
+ if (hit) return { matched: [hit[0]], where: [piece.where] };
543
+ }
544
+ return null;
545
+ }
546
+
547
+ /**
548
+ * @param {string} path
549
+ * @returns {string}
550
+ */
551
+ function firstSegment(path) {
552
+ return String(path).split('.')[0] ?? '';
553
+ }
554
+
555
+ /**
556
+ * @param {Exclude<FindingClass, 'ordinary'>|null} a
557
+ * @param {Exclude<FindingClass, 'ordinary'>|null} b
558
+ * @returns {Exclude<FindingClass, 'ordinary'>|null}
559
+ */
560
+ function worseOf(a, b) {
561
+ if (!a) return b;
562
+ if (!b) return a;
563
+ return SEALED_ORDER.indexOf(a) <= SEALED_ORDER.indexOf(b) ? a : b;
564
+ }