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
package/src/v2/sealed.js
ADDED
|
@@ -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
|
+
}
|