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/waiver.js
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The four gates, machine-checked.
|
|
3
|
+
*
|
|
4
|
+
* An agent cannot write a reference. Only shipping does that, and only Asad ships. What an agent
|
|
5
|
+
* CAN write is a waiver: a record saying "this difference is one I meant to cause". A waiver is
|
|
6
|
+
* provisional — it silences one exact difference until the reference moves, and then it dies. It
|
|
7
|
+
* never makes anything the new normal.
|
|
8
|
+
*
|
|
9
|
+
* Four things are checked before one is written, in this order, and each refusal says which gate
|
|
10
|
+
* stopped it in a sentence a person could read over the agent's shoulder:
|
|
11
|
+
*
|
|
12
|
+
* 1. SEALED Money, signing in, losing data, a crash, or anything touching a named guard.
|
|
13
|
+
* Unwaivable. No reason is read, because a reason is exactly what an agent
|
|
14
|
+
* under pressure produces best.
|
|
15
|
+
* 2. INTENT There has to be an intent, sealed BEFORE the check ran, still under the
|
|
16
|
+
* reference in force. An explanation written after seeing the damage proves
|
|
17
|
+
* nothing; one written before is falsifiable.
|
|
18
|
+
* 3. COVERAGE The difference has to fall inside what that intent declared. A claim about
|
|
19
|
+
* something the agent never said it was touching is not a claim about a side
|
|
20
|
+
* effect, it is a rationalisation.
|
|
21
|
+
* 4. BUDGET Five between one ship and the next. Past five it is not a change with side
|
|
22
|
+
* effects, it is a rewrite, and a person looks at a rewrite.
|
|
23
|
+
*
|
|
24
|
+
* WHY A REFUSAL IS A RETURN VALUE AND NOT AN EXCEPTION. Being told no is a normal, expected
|
|
25
|
+
* answer here — it is the tool working. It comes back as data with the gate named, so the caller
|
|
26
|
+
* can hand the agent the sentence unchanged and the summary can count refusals as easily as it
|
|
27
|
+
* counts waivers.
|
|
28
|
+
*
|
|
29
|
+
* WHY THE BUDGET IS COUNTED AGAINST THE REFERENCE AND NOT THE INTENT. Otherwise an agent that
|
|
30
|
+
* has spent its five buys five more by sealing a fresh intent and calling the same work a
|
|
31
|
+
* different change. Between one ship and the next, all of it is one change.
|
|
32
|
+
*
|
|
33
|
+
* WHY A WAIVER IS PINNED TO THE VALUES AND NOT THE ADDRESS. "I meant the total to read 9.99
|
|
34
|
+
* instead of 10.00" must not go on quietly covering the same total the day it becomes 0. The
|
|
35
|
+
* fingerprint takes in every value that differs, so a DIFFERENT break at the same address is a
|
|
36
|
+
* different difference and is reported.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import fsp from 'node:fs/promises';
|
|
40
|
+
import path from 'node:path';
|
|
41
|
+
import crypto from 'node:crypto';
|
|
42
|
+
|
|
43
|
+
import { safeName } from '../core/paths.js';
|
|
44
|
+
import { StaysFixedError } from '../core/errors.js';
|
|
45
|
+
import { classify, sayRefusal } from './sealed.js';
|
|
46
|
+
import {
|
|
47
|
+
intentCovers,
|
|
48
|
+
readIntent,
|
|
49
|
+
readIntentById,
|
|
50
|
+
referenceStamp,
|
|
51
|
+
readJsonFile,
|
|
52
|
+
writeJsonAtomic,
|
|
53
|
+
shortDigest,
|
|
54
|
+
} from './intent.js';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @typedef {import('./types.js').Store} Store
|
|
58
|
+
* @typedef {import('./types.js').Finding} Finding
|
|
59
|
+
* @typedef {import('./types.js').FindingClass} FindingClass
|
|
60
|
+
* @typedef {import('./intent.js').Intent} Intent
|
|
61
|
+
* @typedef {import('./intent.js').IntentCoverage} IntentCoverage
|
|
62
|
+
* @typedef {import('./sealed.js').SealedVerdict} SealedVerdict
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Five waivers between one ship and the next.
|
|
67
|
+
*
|
|
68
|
+
* The number is deliberately small enough to be annoying. An agent that needs a sixth has not
|
|
69
|
+
* had an unlucky day; it has misunderstood its task, and the right thing to happen next is that
|
|
70
|
+
* a person reads what it did.
|
|
71
|
+
*/
|
|
72
|
+
export const WAIVER_BUDGET = 5;
|
|
73
|
+
|
|
74
|
+
/** How many differences of one finding go into its fingerprint. Clusters can hold hundreds. */
|
|
75
|
+
const FINGERPRINT_DIFFERENCES = 40;
|
|
76
|
+
|
|
77
|
+
/** How many dead waivers are kept per product, so a summary can still say what expired. */
|
|
78
|
+
const KEEP_EXPIRED = 50;
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// The shapes
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A recorded "I meant to do that".
|
|
86
|
+
*
|
|
87
|
+
* Every field here answers a question somebody will ask later: who, when, why, against which
|
|
88
|
+
* intent, and under which reference. A waiver nobody can interrogate is a rubber stamp with
|
|
89
|
+
* extra steps.
|
|
90
|
+
*
|
|
91
|
+
* @typedef {object} Waiver
|
|
92
|
+
* @property {string} id
|
|
93
|
+
* @property {string} product
|
|
94
|
+
* @property {string} fingerprint Pins the exact difference, values included.
|
|
95
|
+
* @property {string} finding The finding's own id, when it had one.
|
|
96
|
+
* @property {string} summary The finding's title, kept so this reads without a check.
|
|
97
|
+
* @property {string[]} paths The addresses involved, trimmed.
|
|
98
|
+
* @property {FindingClass} class What the engine called it. Always ordinary — see gate 1.
|
|
99
|
+
* @property {string} why The agent's reason, in its own words.
|
|
100
|
+
* @property {string} intentId
|
|
101
|
+
* @property {string} intentSummary Copied, so a pruned intent does not orphan the waiver.
|
|
102
|
+
* @property {string} ordering What was known about when the intent was sealed.
|
|
103
|
+
* @property {IntentCoverage} coverage How well it matched what was declared, and how sure.
|
|
104
|
+
* @property {string} at ISO. Written here, never supplied.
|
|
105
|
+
* @property {string} [by]
|
|
106
|
+
* @property {string} reference The stamp it was written under. It dies when this moves.
|
|
107
|
+
* @property {string} [retiredAt] Set when something retired it early — a build shipped, or a
|
|
108
|
+
* person struck it out. A retired waiver is dead whatever its
|
|
109
|
+
* reference says. Kept rather than deleted, because "three
|
|
110
|
+
* waivers expired when you shipped" is a sentence somebody may
|
|
111
|
+
* reasonably want to check.
|
|
112
|
+
* @property {string} [retiredBy]
|
|
113
|
+
* @property {string} [retiredWhy]
|
|
114
|
+
*/
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @typedef {object} WaiverGranted
|
|
118
|
+
* @property {true} ok
|
|
119
|
+
* @property {Waiver} waiver
|
|
120
|
+
* @property {boolean} already True when this exact difference was already waived, which
|
|
121
|
+
* costs no further slot.
|
|
122
|
+
* @property {number} spent
|
|
123
|
+
* @property {number} left
|
|
124
|
+
* @property {number} budget
|
|
125
|
+
* @property {string} say The whole answer, in plain English, ready to hand back.
|
|
126
|
+
*/
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @typedef {object} WaiverRefused
|
|
130
|
+
* @property {false} ok
|
|
131
|
+
* @property {'sealed'|'intent'|'coverage'|'budget'|'incomplete'} gate
|
|
132
|
+
* Which gate stopped it. `incomplete` is not one of the four — it means the call itself was
|
|
133
|
+
* missing something the gates need, and it says what.
|
|
134
|
+
* @property {string} say
|
|
135
|
+
* @property {SealedVerdict} [sealed]
|
|
136
|
+
* @property {IntentCoverage} [coverage]
|
|
137
|
+
* @property {number} [spent]
|
|
138
|
+
* @property {number} [budget]
|
|
139
|
+
*/
|
|
140
|
+
|
|
141
|
+
/** @typedef {WaiverGranted|WaiverRefused} WaiverDecision */
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* What the caller has to say about the check this difference came out of.
|
|
145
|
+
*
|
|
146
|
+
* `at` is what makes gate 2 real. Without it there is no way to tell whether the intent was
|
|
147
|
+
* sealed before or after the agent saw what broke, and that ordering is the whole point of
|
|
148
|
+
* sealing one — so a call that leaves it out is refused rather than waved through on trust.
|
|
149
|
+
*
|
|
150
|
+
* @typedef {object} CheckStamp
|
|
151
|
+
* @property {string} at ISO. When the check that produced this finding ran.
|
|
152
|
+
* @property {string} [runId]
|
|
153
|
+
*/
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// Writing one
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Try to record a difference as intended.
|
|
161
|
+
*
|
|
162
|
+
* @param {Store} store
|
|
163
|
+
* @param {{product: string, finding: Finding, why: string, intentId?: string, check?: CheckStamp, guards?: string[], by?: string}} what
|
|
164
|
+
* @returns {Promise<WaiverDecision>}
|
|
165
|
+
*/
|
|
166
|
+
export async function waive(store, what) {
|
|
167
|
+
const product = typeof what?.product === 'string' ? what.product.trim() : '';
|
|
168
|
+
const finding = what?.finding;
|
|
169
|
+
const why = typeof what?.why === 'string' ? what.why.trim() : '';
|
|
170
|
+
if (!product) throw new StaysFixedError('A waiver has to say which product it is about.');
|
|
171
|
+
if (!finding || typeof finding !== 'object') throw new StaysFixedError('A waiver has to be about a finding the tool reported.');
|
|
172
|
+
|
|
173
|
+
if (why === '') {
|
|
174
|
+
return refuse(
|
|
175
|
+
'incomplete',
|
|
176
|
+
'Say why this difference is what you meant, in one plain sentence. A waiver with no reason is worth nothing to whoever reads it later, and somebody will read it.'
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---- GATE 1. The sealed classes. Nothing gets through this, ever, and it is checked before
|
|
181
|
+
// anything else so that no amount of good paperwork can get a look-in first.
|
|
182
|
+
const sealed = classify(finding, { guards: what.guards ?? [] });
|
|
183
|
+
if (sealed) {
|
|
184
|
+
return { ok: false, gate: 'sealed', say: sayRefusal(sealed, finding), sealed };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const fingerprint = fingerprintFinding(finding);
|
|
188
|
+
const stamp = await referenceStamp(store, product);
|
|
189
|
+
const waivers = await allWaivers(store, product);
|
|
190
|
+
|
|
191
|
+
// Already recorded. The gates were all passed the day it was written, the reference has not
|
|
192
|
+
// moved since, and re-affirming it must not cost another slot — an agent that runs a check
|
|
193
|
+
// twice would otherwise spend its budget on the same difference.
|
|
194
|
+
const standing = waivers.find((w) => w.fingerprint === fingerprint && isLive(w, stamp));
|
|
195
|
+
if (standing) {
|
|
196
|
+
const spent = waivers.filter((w) => isLive(w, stamp)).length;
|
|
197
|
+
return {
|
|
198
|
+
ok: true,
|
|
199
|
+
waiver: standing,
|
|
200
|
+
already: true,
|
|
201
|
+
spent,
|
|
202
|
+
left: Math.max(0, WAIVER_BUDGET - spent),
|
|
203
|
+
budget: WAIVER_BUDGET,
|
|
204
|
+
say: `Already recorded as intended, and it did not cost you another waiver.\n ${trim(finding.title ?? '', 200)}\n Your reason on the day: ${standing.why}`,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ---- GATE 2. There has to be an intent, it has to predate the check, and it has to belong to
|
|
209
|
+
// the world as it is now.
|
|
210
|
+
const intent = what.intentId ? await readIntentById(store, product, what.intentId) : await readIntent(store, product);
|
|
211
|
+
if (!intent) {
|
|
212
|
+
return refuse(
|
|
213
|
+
'intent',
|
|
214
|
+
what.intentId
|
|
215
|
+
? `Refused. There is no sealed intent called "${what.intentId}" for ${product}. You can only waive against an intent this tool actually holds.`
|
|
216
|
+
: 'Refused. You did not seal an intent before this run, so there is nothing to check your claim against. Seal one that names what you are changing, run the check again, and waive from that. Sealing one now, after seeing what broke, would prove nothing.'
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
if (intent.product !== product) {
|
|
220
|
+
return refuse('intent', `Refused. That intent was sealed for ${intent.product}, and this difference is in ${product}. An intent covers one product.`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const check = what.check;
|
|
224
|
+
if (!check || typeof check.at !== 'string' || Number.isNaN(Date.parse(check.at))) {
|
|
225
|
+
return refuse(
|
|
226
|
+
'incomplete',
|
|
227
|
+
'Refused. Nothing here says when the check ran, so there is no way to tell whether you sealed your intent before or after you saw what broke, and that ordering is the whole point of sealing one. Pass the time of the check that produced this finding.'
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
if (Date.parse(intent.sealedAt) > Date.parse(check.at)) {
|
|
231
|
+
return refuse(
|
|
232
|
+
'intent',
|
|
233
|
+
[
|
|
234
|
+
`Refused. That intent (${intent.id}) was sealed AFTER the check ran.`,
|
|
235
|
+
'An intent only means something when it is written before you see what broke. Run the check again so the claim is tested against an intent that already existed, and waive from that run.',
|
|
236
|
+
].join('\n')
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
if (intent.reference !== stamp) {
|
|
240
|
+
return refuse(
|
|
241
|
+
'intent',
|
|
242
|
+
[
|
|
243
|
+
'Refused. That intent was sealed against a different reference, and the reference has moved since: a build shipped.',
|
|
244
|
+
'What counts as working is now something else, so an intent written about the old one cannot cover anything. Seal a fresh intent, run the check again, and waive from there.',
|
|
245
|
+
].join('\n')
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ---- GATE 3. It has to fall inside what was declared. This is the substance of the whole
|
|
250
|
+
// system: the gate exists to stop a difference FAR from the declared work being waved through,
|
|
251
|
+
// and a weak match is exactly the shape a rationalisation takes.
|
|
252
|
+
const coverage = intentCovers(intent, finding);
|
|
253
|
+
if (!coverage.covers) {
|
|
254
|
+
return {
|
|
255
|
+
ok: false,
|
|
256
|
+
gate: 'coverage',
|
|
257
|
+
coverage,
|
|
258
|
+
say: [
|
|
259
|
+
'Refused. This is outside what you sealed.',
|
|
260
|
+
` ${trim(finding.title ?? '', 200)}`,
|
|
261
|
+
'',
|
|
262
|
+
`You said you were changing: ${intent.summary}`,
|
|
263
|
+
`You said it would affect: ${intent.files.join(', ')}.`,
|
|
264
|
+
`Why this does not match: ${coverage.why}`,
|
|
265
|
+
'',
|
|
266
|
+
'A difference outside what you declared is the definition of a side effect, which is the exact thing you are not allowed to wave through. If you genuinely meant to change this too, that is a different change: seal a new intent that names it, run the check again, and waive from there.',
|
|
267
|
+
].join('\n'),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ---- GATE 4. Five between one ship and the next.
|
|
272
|
+
const spent = waivers.filter((w) => isLive(w, stamp)).length;
|
|
273
|
+
if (spent >= WAIVER_BUDGET) {
|
|
274
|
+
return {
|
|
275
|
+
ok: false,
|
|
276
|
+
gate: 'budget',
|
|
277
|
+
spent,
|
|
278
|
+
budget: WAIVER_BUDGET,
|
|
279
|
+
say: [
|
|
280
|
+
`Refused. That would be waiver number ${spent + 1} since the last build shipped, and the limit is ${WAIVER_BUDGET}.`,
|
|
281
|
+
'',
|
|
282
|
+
'Past five, this is not a change with side effects, it is a rewrite, and a person looks at a rewrite. Sealing another intent will not give you more. Stop waiving, fix what you can, and report the rest plainly.',
|
|
283
|
+
].join('\n'),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** @type {Waiver} */
|
|
288
|
+
const waiver = {
|
|
289
|
+
id: `waiver-${crypto.randomBytes(5).toString('hex')}`,
|
|
290
|
+
product,
|
|
291
|
+
fingerprint,
|
|
292
|
+
finding: typeof finding.id === 'string' ? finding.id : '',
|
|
293
|
+
summary: trim(finding.title ?? '(a difference with no title)', 200),
|
|
294
|
+
paths: (finding.paths ?? (finding.differences ?? []).map((d) => d.path)).slice(0, 8),
|
|
295
|
+
class: finding.class ?? 'ordinary',
|
|
296
|
+
why,
|
|
297
|
+
intentId: intent.id,
|
|
298
|
+
intentSummary: intent.summary,
|
|
299
|
+
ordering: intent.ordering,
|
|
300
|
+
coverage,
|
|
301
|
+
at: new Date().toISOString(),
|
|
302
|
+
reference: stamp,
|
|
303
|
+
};
|
|
304
|
+
if (what.by) waiver.by = what.by;
|
|
305
|
+
|
|
306
|
+
waivers.push(waiver);
|
|
307
|
+
await writeJsonAtomic(waiversFile(store, product), prune(waivers, stamp));
|
|
308
|
+
|
|
309
|
+
const left = WAIVER_BUDGET - (spent + 1);
|
|
310
|
+
return {
|
|
311
|
+
ok: true,
|
|
312
|
+
waiver,
|
|
313
|
+
already: false,
|
|
314
|
+
spent: spent + 1,
|
|
315
|
+
left,
|
|
316
|
+
budget: WAIVER_BUDGET,
|
|
317
|
+
say: [
|
|
318
|
+
`Recorded as intended: ${waiver.summary}`,
|
|
319
|
+
`Your reason, kept: ${why}`,
|
|
320
|
+
`Matched against what you sealed: ${coverage.why} (${coverage.confidence} match)`,
|
|
321
|
+
'',
|
|
322
|
+
`${left} of your ${WAIVER_BUDGET} waivers left before the next ship. This one is pinned to the exact values that differ and to the reference in force now: if either moves, it stops covering anything.`,
|
|
323
|
+
'This is not approval. Nothing becomes the new normal until a build ships. Say in what you report back that you waived this, and why.',
|
|
324
|
+
].join('\n'),
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
// Reading them back
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* The waivers that still apply: written under the reference that is in force now.
|
|
334
|
+
*
|
|
335
|
+
* @param {Store} store
|
|
336
|
+
* @param {string} product
|
|
337
|
+
* @returns {Promise<Waiver[]>}
|
|
338
|
+
*/
|
|
339
|
+
export async function activeWaivers(store, product) {
|
|
340
|
+
const stamp = await referenceStamp(store, product);
|
|
341
|
+
return (await allWaivers(store, product)).filter((w) => isLive(w, stamp));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Every waiver kept for a product, live and dead, oldest first.
|
|
346
|
+
*
|
|
347
|
+
* @param {Store} store
|
|
348
|
+
* @param {string} product
|
|
349
|
+
* @returns {Promise<Waiver[]>}
|
|
350
|
+
*/
|
|
351
|
+
export async function allWaivers(store, product) {
|
|
352
|
+
const raw = await readJsonFile(waiversFile(store, product), []);
|
|
353
|
+
if (!Array.isArray(raw)) return [];
|
|
354
|
+
return raw.filter((w) => w && typeof w === 'object' && typeof w.fingerprint === 'string' && typeof w.id === 'string');
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Which waiver, if any, covers this exact difference.
|
|
359
|
+
*
|
|
360
|
+
* Pure and synchronous on purpose: a check has hundreds of findings and one list of waivers, so
|
|
361
|
+
* the list is read from disk once by `activeWaivers` and matched against every finding here
|
|
362
|
+
* without touching the disk again.
|
|
363
|
+
*
|
|
364
|
+
* @param {Waiver[]} waivers From `activeWaivers`. Passing every waiver ever written would let
|
|
365
|
+
* an expired one go on covering something, so it takes the live list.
|
|
366
|
+
* @param {Finding} finding
|
|
367
|
+
* @returns {Waiver|null}
|
|
368
|
+
*/
|
|
369
|
+
export function waiverFor(waivers, finding) {
|
|
370
|
+
const fingerprint = fingerprintFinding(finding);
|
|
371
|
+
return waivers.find((w) => w.fingerprint === fingerprint) ?? null;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* What the closing summary needs: how many were waived, how many are left, what expired, and one
|
|
376
|
+
* sentence saying so.
|
|
377
|
+
*
|
|
378
|
+
* Waivers must be visible, not quiet. This is the function that makes them so, and a summary
|
|
379
|
+
* that does not use it is hiding something an agent decided on its own.
|
|
380
|
+
*
|
|
381
|
+
* @param {Store} store
|
|
382
|
+
* @param {string} product
|
|
383
|
+
* @returns {Promise<{budget: number, spent: number, left: number, active: Waiver[], expired: number, reference: string, line: string}>}
|
|
384
|
+
*/
|
|
385
|
+
export async function countWaivers(store, product) {
|
|
386
|
+
const stamp = await referenceStamp(store, product);
|
|
387
|
+
const all = await allWaivers(store, product);
|
|
388
|
+
const active = all.filter((w) => isLive(w, stamp));
|
|
389
|
+
const expired = all.length - active.length;
|
|
390
|
+
const left = Math.max(0, WAIVER_BUDGET - active.length);
|
|
391
|
+
|
|
392
|
+
const line =
|
|
393
|
+
active.length === 0
|
|
394
|
+
? `Nothing was waived${expired > 0 ? `, and ${expired} older waiver${expired === 1 ? '' : 's'} died when the reference last moved` : ''}.`
|
|
395
|
+
: `${active.length} difference${active.length === 1 ? ' was' : 's were'} recorded as intended, not approved: ${active
|
|
396
|
+
.map((w) => trim(w.summary, 90))
|
|
397
|
+
.join('; ')}. ${left} of the ${WAIVER_BUDGET} allowed before a person has to look ${left === 1 ? 'is' : 'are'} left.`;
|
|
398
|
+
|
|
399
|
+
return { budget: WAIVER_BUDGET, spent: active.length, left, active, expired, reference: stamp, line };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Forget a product's waivers. Housekeeping, and the way a test starts clean.
|
|
404
|
+
*
|
|
405
|
+
* @param {Store} store
|
|
406
|
+
* @param {string} product
|
|
407
|
+
* @returns {Promise<void>}
|
|
408
|
+
*/
|
|
409
|
+
export async function forgetWaivers(store, product) {
|
|
410
|
+
await fsp.rm(waiversFile(store, product), { force: true });
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* What a waiver is pinned to.
|
|
415
|
+
*
|
|
416
|
+
* Every value that differs goes in, so the waiver covers this break and not the address. A
|
|
417
|
+
* cluster that grew a difference produces a different fingerprint and the waiver stops applying,
|
|
418
|
+
* which errs towards a person looking at something they have already seen rather than towards a
|
|
419
|
+
* new break hiding behind an old excuse. That is the right way round.
|
|
420
|
+
*
|
|
421
|
+
* @param {Finding} finding
|
|
422
|
+
* @returns {string}
|
|
423
|
+
*/
|
|
424
|
+
export function fingerprintFinding(finding) {
|
|
425
|
+
const differences = (finding.differences ?? [])
|
|
426
|
+
.slice(0, FINGERPRINT_DIFFERENCES)
|
|
427
|
+
.map((d) => [d.path, d.kind, face(d.reference), face(d.candidate)]);
|
|
428
|
+
// A finding with no differences attached, which some callers pass, still has to be pinnable,
|
|
429
|
+
// so the sample and the paths stand in for them.
|
|
430
|
+
const fallback =
|
|
431
|
+
differences.length > 0
|
|
432
|
+
? []
|
|
433
|
+
: [finding.sample?.path ?? '', finding.sample?.kind ?? '', face(finding.sample?.reference), face(finding.sample?.candidate)];
|
|
434
|
+
return shortDigest([finding.title ?? '', [...(finding.paths ?? [])].sort(), differences, fallback]);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
// Small things
|
|
439
|
+
// ---------------------------------------------------------------------------
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* @param {Store} store
|
|
443
|
+
* @param {string} product
|
|
444
|
+
* @returns {string}
|
|
445
|
+
*/
|
|
446
|
+
export function waiversFile(store, product) {
|
|
447
|
+
return path.join(store.dir, 'waivers', `${safeName(product)}.json`);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Keep every live waiver and the most recent dead ones. The dead ones are worth something, since
|
|
452
|
+
* "this was waived once and then the reference moved" is a real sentence in a summary, but they
|
|
453
|
+
* are not worth keeping forever.
|
|
454
|
+
*
|
|
455
|
+
* @param {Waiver[]} waivers
|
|
456
|
+
* @param {string} stamp
|
|
457
|
+
* @returns {Waiver[]}
|
|
458
|
+
*/
|
|
459
|
+
function prune(waivers, stamp) {
|
|
460
|
+
const live = waivers.filter((w) => isLive(w, stamp));
|
|
461
|
+
const dead = waivers.filter((w) => !isLive(w, stamp)).slice(-KEEP_EXPIRED);
|
|
462
|
+
return [...dead, ...live];
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Does this waiver still cover anything?
|
|
467
|
+
*
|
|
468
|
+
* Two conditions, and both are needed. A waiver retired by hand or by a ship is dead whatever it
|
|
469
|
+
* says. A waiver written against a reference that has since moved is dead even if nothing got
|
|
470
|
+
* round to retiring it. Belt and braces, because the cost of getting this wrong is a regression
|
|
471
|
+
* nobody is ever shown.
|
|
472
|
+
*
|
|
473
|
+
* @param {Waiver} waiver
|
|
474
|
+
* @param {string} stamp
|
|
475
|
+
* @returns {boolean}
|
|
476
|
+
*/
|
|
477
|
+
function isLive(waiver, stamp) {
|
|
478
|
+
return !waiver.retiredAt && waiver.reference === stamp;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* @param {'sealed'|'intent'|'coverage'|'budget'|'incomplete'} gate
|
|
483
|
+
* @param {string} say
|
|
484
|
+
* @returns {WaiverRefused}
|
|
485
|
+
*/
|
|
486
|
+
function refuse(gate, say) {
|
|
487
|
+
return { ok: false, gate, say };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* @param {unknown} value
|
|
492
|
+
* @returns {string}
|
|
493
|
+
*/
|
|
494
|
+
function face(value) {
|
|
495
|
+
if (value === undefined) return '(absent)';
|
|
496
|
+
try {
|
|
497
|
+
return JSON.stringify(value) ?? 'null';
|
|
498
|
+
} catch {
|
|
499
|
+
return String(value);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* @param {string} text
|
|
505
|
+
* @param {number} max
|
|
506
|
+
* @returns {string}
|
|
507
|
+
*/
|
|
508
|
+
function trim(text, max) {
|
|
509
|
+
const one = String(text).replace(/\s+/g, ' ').trim();
|
|
510
|
+
return one.length > max ? `${one.slice(0, max - 1)}…` : one;
|
|
511
|
+
}
|