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,679 @@
1
+ /**
2
+ * What reaches a person, and nothing else.
3
+ *
4
+ * The word "approve" was hiding four separate decisions, and version 2 splits them:
5
+ *
6
+ * 1. WHAT COUNTS AS WORKING — Asad, and only Asad, and never by opening this tool. It is cut
7
+ * by an act he already performs: saying ship. That is `cutReference` in reference.js,
8
+ * called from `onShip` in ship.js, and no agent can reach either.
9
+ * 2. IS THIS DIFFERENCE REAL OR IS IT NOISE — the machine, arithmetically, from running the
10
+ * new build twice. Nobody's opinion. That is `subtractWobble` in run.js.
11
+ * 3. DID MY OWN EDIT CAUSE THIS — the agent, and it PROVES the claim by reverting the suspect
12
+ * hunk and re-running rather than asserting it. That is cause.js.
13
+ * 4. IS AN UNINTENDED DIFFERENCE ACCEPTABLE ANYWAY — a person. THIS FILE.
14
+ *
15
+ * So this module does two jobs and refuses the rest.
16
+ *
17
+ * It hands `check.js` the arithmetic of the decision record: which findings are already
18
+ * accounted for by a live waiver, which are sealed and can never be accounted for by any
19
+ * agent at all, and the counts that make the resulting silence legible. A waiver applied
20
+ * without being counted out loud is how a rubber stamp starts, so the count travels on the
21
+ * verdict itself and is never optional.
22
+ *
23
+ * And it writes the escalation block: the handful of items a month that genuinely need a
24
+ * person, three sentences each, in a shape a closing session summary can paste in whole. NOT
25
+ * a report. NOT a dashboard. NOT a link to somewhere he has to go and look. He reads one
26
+ * closing summary at the end of a working stretch, so anything not inside it did not happen.
27
+ *
28
+ * WHAT THIS FILE DELIBERATELY DOES NOT OWN. Classifying a difference as sealed belongs to
29
+ * sealed.js; sealing an intent and judging what it covers belongs to intent.js; moving the
30
+ * reference and retiring waivers belongs to reference.js and ship.js. Every one of those is
31
+ * a safety property, and a safety property implemented twice is a safety property that will
32
+ * disagree with itself in six months. This file calls them.
33
+ */
34
+
35
+ import fsp from 'node:fs/promises';
36
+ import path from 'node:path';
37
+ import crypto from 'node:crypto';
38
+
39
+ import { findConfigFile } from '../core/paths.js';
40
+ import { classify } from './sealed.js';
41
+ import { readIntent, referenceStamp, readJsonFile, writeJsonAtomic } from './intent.js';
42
+ import { WAIVER_BUDGET, activeWaivers, allWaivers, fingerprintFinding, waiverFor } from './waiver.js';
43
+ import { recordCheck } from './reference.js';
44
+
45
+ /** @typedef {import('./types.js').Store} Store */
46
+ /** @typedef {import('./types.js').Finding} Finding */
47
+ /** @typedef {import('./types.js').Verdict} Verdict */
48
+ /** @typedef {import('./intent.js').Intent} Intent */
49
+ /** @typedef {import('./sealed.js').SealedVerdict} SealedVerdict */
50
+ /** @typedef {import('./waiver.js').Waiver} Waiver */
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Policy
54
+ // ---------------------------------------------------------------------------
55
+
56
+ /**
57
+ * Five waivers between one ship and the next.
58
+ *
59
+ * Re-exported from waiver.js rather than restated, because the budget is a safety property
60
+ * and a safety property with two numbers in two files is a safety property that will
61
+ * disagree with itself. waiver.js enforces it; this only says it out loud.
62
+ */
63
+ export { WAIVER_BUDGET };
64
+
65
+ /**
66
+ * How many items the escalation block prints before it stops and says how many are left.
67
+ *
68
+ * If this ceiling is ever reached in ordinary use, the gates are wrong and the fix is the
69
+ * gates, not a longer list. Six things needing a person in one run is not a summary line,
70
+ * it is a meeting.
71
+ */
72
+ const MOST_ITEMS = 6;
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // The decision record, gathered
76
+ // ---------------------------------------------------------------------------
77
+
78
+ /**
79
+ * Everything an agent has said about this change, and what is left of its budget.
80
+ *
81
+ * @typedef {object} Decisions
82
+ * @property {string} product
83
+ * @property {string} stamp Which reference is in force for this product, as one short string.
84
+ * @property {Intent|null} intent The most recently sealed intent, if there is one.
85
+ * @property {Waiver[]} live Waivers that still cover something.
86
+ * @property {Waiver[]} expired Waivers that have stopped covering anything.
87
+ * @property {number} budget
88
+ * @property {number} spent
89
+ * @property {number} left
90
+ */
91
+
92
+ /**
93
+ * @param {Store} store
94
+ * @param {string} product
95
+ * @returns {Promise<Decisions>}
96
+ */
97
+ export async function readDecisions(store, product) {
98
+ const live = await activeWaivers(store, product);
99
+ const all = await allWaivers(store, product);
100
+ // By id, never by object identity: the two calls above each read the file again, so every
101
+ // waiver comes back as a fresh object and an identity test would call every live waiver
102
+ // expired. The visible symptom would be a run reporting "3 waivers have expired" on the
103
+ // very run that wrote them.
104
+ const alive = new Set(live.map((w) => w.id));
105
+ return {
106
+ product,
107
+ stamp: await referenceStamp(store, product),
108
+ intent: await readIntent(store, product),
109
+ live,
110
+ expired: all.filter((w) => !alive.has(w.id)),
111
+ budget: WAIVER_BUDGET,
112
+ spent: live.length,
113
+ left: Math.max(0, WAIVER_BUDGET - live.length),
114
+ };
115
+ }
116
+
117
+ /**
118
+ * What to fall back to when the bookkeeping cannot be read at all.
119
+ *
120
+ * No intent and no waivers means nothing is accounted for, so every difference is reported.
121
+ * That is the only safe direction for this to fail in: a broken record makes the tool
122
+ * noisier, never quieter.
123
+ *
124
+ * @param {string} product
125
+ * @returns {Decisions}
126
+ */
127
+ export function noDecisions(product) {
128
+ return { product, stamp: 'unreadable', intent: null, live: [], expired: [], budget: WAIVER_BUDGET, spent: 0, left: WAIVER_BUDGET };
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Which product is this?
133
+ // ---------------------------------------------------------------------------
134
+
135
+ /**
136
+ * The product name a folder answers to.
137
+ *
138
+ * The reference pointer, the intents and the waivers are all keyed by this string, so the
139
+ * command line, the MCP surface and the ship hook agreeing on it is not a nicety. Disagree
140
+ * and an agent's waivers are counted against a product that never ships, while the product
141
+ * that does ship never retires any.
142
+ *
143
+ * The rule matches `productName` in ship.js and `openProject` in check.js exactly: the
144
+ * settings file's own `product`, else the package name, else the folder.
145
+ *
146
+ * @param {string} root
147
+ * @returns {Promise<string>}
148
+ */
149
+ export async function productFor(root) {
150
+ const configFile = findConfigFile(root);
151
+ if (configFile && configFile.endsWith('.json')) {
152
+ try {
153
+ const parsed = JSON.parse(await fsp.readFile(configFile, 'utf8'));
154
+ if (typeof parsed?.product === 'string' && parsed.product) return parsed.product;
155
+ } catch {
156
+ // A settings file nobody can parse is somebody else's problem to report. Falling
157
+ // through to the package name keeps the bookkeeping keyed on something either way.
158
+ }
159
+ }
160
+ try {
161
+ const pkg = JSON.parse(await fsp.readFile(path.join(root, 'package.json'), 'utf8'));
162
+ if (typeof pkg?.name === 'string' && pkg.name) return pkg.name;
163
+ } catch {
164
+ // No package.json, or an unreadable one. The folder name it is.
165
+ }
166
+ return path.basename(path.resolve(root));
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // Naming a finding, and pinning a waiver to one exact difference
171
+ // ---------------------------------------------------------------------------
172
+
173
+ /**
174
+ * Something to call a finding by.
175
+ *
176
+ * Stable while the finding itself persists, so an agent can run a check twice and still
177
+ * explain, prove or waive the same one. Built from the title and the addresses rather than
178
+ * from a counter, because a counter renumbers everything the moment one finding is fixed.
179
+ *
180
+ * @param {Finding} f
181
+ * @returns {string}
182
+ */
183
+ export function findingId(f) {
184
+ return 'f-' + shortDigest([f.title, ...(f.paths ?? [])]).slice(0, 6);
185
+ }
186
+
187
+ /**
188
+ * What a waiver pins to — the exact difference, not the finding — comes from waiver.js, which
189
+ * is what writes the waivers. Computing it a second way here would produce records that
190
+ * never match the waivers written against them, and the symptom would be a waiver that
191
+ * silently covers nothing.
192
+ */
193
+ export { fingerprintFinding as fingerprintOf };
194
+
195
+ /**
196
+ * Sixteen hex characters of SHA-256 over whatever it is handed. The same algorithm
197
+ * `shortDigest` in intent.js uses, so two parts of the tool naming the same thing name it
198
+ * the same way.
199
+ *
200
+ * @param {unknown[]} parts
201
+ * @returns {string}
202
+ */
203
+ function shortDigest(parts) {
204
+ return crypto.createHash('sha256').update(JSON.stringify(parts)).digest('hex').slice(0, 16);
205
+ }
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // The arithmetic check.js runs after ranking
209
+ // ---------------------------------------------------------------------------
210
+
211
+ /**
212
+ * A finding with the three things the decision layer adds: something to call it by, the
213
+ * fingerprint a waiver pins to, and whether any agent is allowed to account for it at all.
214
+ *
215
+ * @typedef {Finding & {id: string, fingerprint: string, unwaivable?: boolean, unwaivableWhy?: string, sealedBy?: SealedVerdict, waivedBy?: string, waivedBecause?: string}} DecidedFinding
216
+ */
217
+
218
+ /**
219
+ * The counts that make the silence legible, carried on the verdict itself.
220
+ *
221
+ * An agent has to be able to see that fifty things were waived, not merely that nothing was
222
+ * reported. "Nothing changed", "nothing ran" and "everything was waived" read identically
223
+ * without this, and two of those three are a safety net quietly announcing success.
224
+ *
225
+ * @typedef {object} Accounting
226
+ * @property {number} reported Findings the agent still has to deal with.
227
+ * @property {number} waived Findings dropped because they were already recorded as intended.
228
+ * @property {number} unwaivable Of the reported ones, how many no agent may account for.
229
+ * @property {number} expiredWaivers Waivers that have stopped covering anything.
230
+ * @property {number} budget
231
+ * @property {number} spent
232
+ * @property {number} left
233
+ * @property {string|null} intent The id of the intent in force, or null when none was sealed.
234
+ * @property {string} note One plain sentence saying all of the above.
235
+ */
236
+
237
+ /**
238
+ * @typedef {object} Decided
239
+ * @property {DecidedFinding[]} all Every finding, named and marked. Nothing dropped.
240
+ * @property {DecidedFinding[]} reported What the agent still has to deal with.
241
+ * @property {DecidedFinding[]} waived What was dropped, kept so the count can be justified.
242
+ * @property {Accounting} accounting
243
+ */
244
+
245
+ /**
246
+ * Apply the decision record to a freshly ranked set of findings.
247
+ *
248
+ * Three things happen here and nothing else.
249
+ *
250
+ * Every finding is named and fingerprinted. Sealed-class findings are marked unwaivable —
251
+ * asked of sealed.js, never decided here — so that no later code has to re-derive the rule
252
+ * and no later code can get it wrong. And findings already covered by a LIVE waiver are
253
+ * dropped from what anybody reads, but counted, and kept in `waived` so the count can be
254
+ * justified line by line if anybody asks.
255
+ *
256
+ * A waiver never applies to a sealed finding, whatever the file on disk says. That is
257
+ * checked here as well as where the waiver is written, because a gate that exists in only
258
+ * one place is a gate a hand-edited JSON file walks straight through.
259
+ *
260
+ * @param {Finding[]} findings
261
+ * @param {Decisions} decisions
262
+ * @param {{guards?: string[]}} [opts] Guard names, so a difference touching one is sealed by name.
263
+ * @returns {Decided}
264
+ */
265
+ export function decide(findings, decisions, opts = {}) {
266
+ /** @type {DecidedFinding[]} */
267
+ const all = [];
268
+ /** @type {DecidedFinding[]} */
269
+ const reported = [];
270
+ /** @type {DecidedFinding[]} */
271
+ const waived = [];
272
+
273
+ for (const f of findings) {
274
+ /** @type {DecidedFinding} */
275
+ const named = { ...f, id: findingId(f), fingerprint: fingerprintFinding(f) };
276
+ const sealed = classify(f, { guards: opts.guards ?? [] });
277
+ if (sealed) {
278
+ named.unwaivable = true;
279
+ named.unwaivableWhy = sealed.why;
280
+ named.sealedBy = sealed;
281
+ }
282
+
283
+ const cover = sealed ? undefined : waiverFor(decisions.live, f);
284
+ if (cover) {
285
+ named.waivedBy = cover.id;
286
+ named.waivedBecause = cover.why;
287
+ waived.push(named);
288
+ } else {
289
+ reported.push(named);
290
+ }
291
+ all.push(named);
292
+ }
293
+
294
+ const unwaivable = reported.filter((f) => f.unwaivable === true).length;
295
+
296
+ /** @type {Accounting} */
297
+ const accounting = {
298
+ reported: reported.length,
299
+ waived: waived.length,
300
+ unwaivable,
301
+ expiredWaivers: decisions.expired.length,
302
+ budget: decisions.budget,
303
+ spent: decisions.spent,
304
+ left: decisions.left,
305
+ intent: decisions.intent?.id ?? null,
306
+ note: accountingNote(reported.length, waived.length, unwaivable, decisions),
307
+ };
308
+
309
+ return { all, reported, waived, accounting };
310
+ }
311
+
312
+ /**
313
+ * The sentence that keeps a quiet run honest.
314
+ *
315
+ * @param {number} reported
316
+ * @param {number} waived
317
+ * @param {number} unwaivable
318
+ * @param {Decisions} decisions
319
+ * @returns {string}
320
+ */
321
+ function accountingNote(reported, waived, unwaivable, decisions) {
322
+ /** @type {string[]} */
323
+ const parts = [];
324
+ if (waived > 0) {
325
+ parts.push(
326
+ `${waived} ${waived === 1 ? 'difference was' : 'differences were'} recorded as intended earlier and ${waived === 1 ? 'is' : 'are'} not shown again`,
327
+ );
328
+ }
329
+ if (unwaivable > 0) {
330
+ parts.push(`${unwaivable} of what is left ${unwaivable === 1 ? 'is' : 'are'} in a class nobody may wave through`);
331
+ }
332
+ if (decisions.spent > 0) {
333
+ parts.push(`${decisions.left} of the ${decisions.budget} waivers allowed before the next ship remain`);
334
+ }
335
+ if (parts.length === 0) {
336
+ return reported === 0
337
+ ? 'Nothing was waived and nothing was hidden: this run reports every difference it found.'
338
+ : 'Nothing has been recorded as intended, so everything reported is exactly what the run found.';
339
+ }
340
+ return capital(parts.join(', ')) + '. Nothing is the new normal until a build ships.';
341
+ }
342
+
343
+ // ---------------------------------------------------------------------------
344
+ // What is written down after every check
345
+ // ---------------------------------------------------------------------------
346
+
347
+ /**
348
+ * The record of one check.
349
+ *
350
+ * Every finding is kept here, waived ones included. The verdict drops them; this does not.
351
+ * Dropping a finding from what an agent READS is the point of a waiver; dropping it from the
352
+ * record would make the waiver unauditable, which is the opposite of the point.
353
+ *
354
+ * @typedef {object} CheckRecord
355
+ * @property {string} at
356
+ * @property {string} product
357
+ * @property {string} reference The reference stamp in force when the check ran.
358
+ * @property {string} verdict 'blocked' | 'nothing unaccounted for' | 'differences found'
359
+ * @property {DecidedFinding[]} findings
360
+ * @property {string[]} newlyUnstable
361
+ * @property {Accounting} accounting
362
+ * @property {any} result The whole verdict, for anything that wants the detail.
363
+ */
364
+
365
+ /**
366
+ * @param {Store} store
367
+ * @returns {Promise<CheckRecord|null>}
368
+ */
369
+ export async function readCheckRecord(store) {
370
+ const raw = await readJsonFile(path.join(store.dir, 'last-check.json'), null);
371
+ return raw && typeof raw === 'object' && Array.isArray(raw.findings) ? raw : null;
372
+ }
373
+
374
+ /**
375
+ * Write down what this check concluded, and what a person now has to decide.
376
+ *
377
+ * Three files, each with one reader. `last-check.json` is the working record and holds
378
+ * everything, so explain, prove and waive can be handed an id. `escalations.json` holds only
379
+ * what a person must rule on, per product, so a closing summary can be assembled without
380
+ * loading a whole run. And `recordCheck` adds a line to the check log that reference.js
381
+ * reads at ship time — without it, somebody who runs `staysfixed check` on the command line
382
+ * and then ships is told their build was never checked.
383
+ *
384
+ * @param {Store} store
385
+ * @param {{product: string, verdict: Verdict & {blocked?: boolean}, decided: Decided}} what
386
+ * @returns {Promise<void>}
387
+ */
388
+ export async function rememberCheck(store, what) {
389
+ const { product, verdict, decided } = what;
390
+ const unstable = (verdict.newlyUnstable ?? []).map((e) => e.path);
391
+ const state =
392
+ verdict.blocked === true
393
+ ? 'blocked'
394
+ : decided.reported.length === 0 && unstable.length === 0
395
+ ? 'nothing unaccounted for'
396
+ : 'differences found';
397
+
398
+ /** @type {CheckRecord} */
399
+ const record = {
400
+ at: new Date().toISOString(),
401
+ product,
402
+ reference: await referenceStamp(store, product),
403
+ verdict: state,
404
+ findings: decided.all,
405
+ newlyUnstable: unstable,
406
+ accounting: decided.accounting,
407
+ result: verdict,
408
+ };
409
+ await writeJsonAtomic(path.join(store.dir, 'last-check.json'), record);
410
+
411
+ const book = await readJsonFile(path.join(store.dir, 'escalations.json'), {});
412
+ const all = book && typeof book === 'object' && !Array.isArray(book) ? book : {};
413
+ all[product] = buildEscalations(product, record, verdict);
414
+ await writeJsonAtomic(path.join(store.dir, 'escalations.json'), all);
415
+
416
+ if (verdict.candidate?.id) {
417
+ await recordCheck(store, {
418
+ buildId: verdict.candidate.id,
419
+ product,
420
+ ok: verdict.ok === true,
421
+ blocked: verdict.blocked === true,
422
+ findings: decided.all.length,
423
+ unaccounted: decided.reported.length,
424
+ waived: decided.accounting.waived,
425
+ sealed: decided.accounting.unwaivable,
426
+ by: 'staysfixed check',
427
+ });
428
+ }
429
+ }
430
+
431
+ // ---------------------------------------------------------------------------
432
+ // Escalation — the only thing that reaches him
433
+ // ---------------------------------------------------------------------------
434
+
435
+ /**
436
+ * One thing a person has to rule on. Three sentences, and there is no fourth.
437
+ *
438
+ * @typedef {object} Escalation
439
+ * @property {string} id
440
+ * @property {'sealed'|'budget'|'unpredictable'|'blocked'|'no-reference'} kind
441
+ * @property {string} what What changed.
442
+ * @property {string} why Why no agent could wave it through.
443
+ * @property {string} todo What to do about it.
444
+ * @property {string} [class]
445
+ * @property {string[]} [paths]
446
+ */
447
+
448
+ /**
449
+ * What a person must decide about one product, and nothing else.
450
+ *
451
+ * @typedef {object} Escalations
452
+ * @property {string} product
453
+ * @property {string|null} at When the check that produced this ran.
454
+ * @property {Escalation[]} items
455
+ * @property {number} waived
456
+ * @property {number} expiredWaivers
457
+ * @property {string} note One sentence for the summary, true even when items is empty.
458
+ */
459
+
460
+ /**
461
+ * What a person must decide about this product.
462
+ *
463
+ * Deliberately narrow. An ordinary difference an agent caused and has not fixed yet is the
464
+ * agent's problem, not his, and putting it here would turn a handful of items a month into a
465
+ * feed nobody reads. Five things reach a person, and four of them are rare by construction:
466
+ *
467
+ * - a difference in a sealed class, which no agent may account for at any time;
468
+ * - the waiver budget running out while differences remain, because that is a rewrite;
469
+ * - addresses that used to give the same answer every time and now do not, which cannot be
470
+ * waived because they are not a difference, they are a loss of determinism;
471
+ * - a check that could not run at all, because "no answer" must never look like a pass;
472
+ * - no reference yet, because until he ships once the tool is not protecting him and he
473
+ * ought to know that rather than assume it is.
474
+ *
475
+ * A clean run produces none of these. That is the design target, and if a normal week ever
476
+ * produces more than a couple, the gates are wrong and the gates are what should change.
477
+ *
478
+ * @param {Store} store
479
+ * @param {string} product
480
+ * @returns {Promise<Escalations>}
481
+ */
482
+ export async function escalationsFor(store, product) {
483
+ const book = await readJsonFile(path.join(store.dir, 'escalations.json'), {});
484
+ const found = book && typeof book === 'object' ? book[product] : null;
485
+ if (found && Array.isArray(found.items)) return found;
486
+ return {
487
+ product,
488
+ at: null,
489
+ items: [],
490
+ waived: 0,
491
+ expiredWaivers: 0,
492
+ note: `Stays Fixed has not checked ${product} yet, so nothing here says anything about it either way.`,
493
+ };
494
+ }
495
+
496
+ /**
497
+ * @param {string} product
498
+ * @param {CheckRecord} record
499
+ * @param {Verdict & {blocked?: boolean}} verdict
500
+ * @returns {Escalations}
501
+ */
502
+ function buildEscalations(product, record, verdict) {
503
+ /** @type {Escalation[]} */
504
+ const items = [];
505
+
506
+ if (verdict.blocked === true) {
507
+ items.push({
508
+ id: 'blocked',
509
+ kind: 'blocked',
510
+ what: `Stays Fixed could not check ${product} at all on this run, so nothing about it has been proved either way.`,
511
+ why: 'A check that did not run is not a pass and not a failure, and nobody may file it under either.',
512
+ todo: `Something is in the way and it needs clearing — the run said: ${oneLine(verdict.summary, 200)}`,
513
+ });
514
+ } else if (!verdict.reference || verdict.reference.id === '') {
515
+ items.push({
516
+ id: 'no-reference',
517
+ kind: 'no-reference',
518
+ what: `There is no build of ${product} on record as working yet, so this run had nothing to compare against.`,
519
+ why: 'Only you can say what "working" means, and you say it by shipping — no agent may cut that reference.',
520
+ todo: 'Ship once with the hook in place. From the next change onwards it is automatic and you will not see this again.',
521
+ });
522
+ }
523
+
524
+ for (const f of record.findings) {
525
+ if (f.unwaivable !== true) continue;
526
+ items.push({
527
+ id: f.id,
528
+ kind: 'sealed',
529
+ what: oneLine(f.title, 220),
530
+ // One sentence, not sealed.js's full two. He knows why money matters; what he needs
531
+ // from this line is which of the five classes it landed in, so the item is three
532
+ // sentences and stays three sentences.
533
+ why: `No agent may wave this through: it touches ${f.sealedBy?.says ?? 'something a person has to rule on'}.`,
534
+ todo: sealedTodo(f),
535
+ class: typeof f.class === 'string' ? f.class : undefined,
536
+ paths: (f.paths ?? []).slice(0, 6),
537
+ });
538
+ }
539
+
540
+ if (record.accounting.left === 0 && record.accounting.reported > record.accounting.unwaivable) {
541
+ items.push({
542
+ id: 'budget',
543
+ kind: 'budget',
544
+ what: `The agent has used all ${record.accounting.budget} of the differences it is allowed to record as intended on ${product}, and there are still differences left over.`,
545
+ why: 'Past five, this stopped being a change with side effects and became a rewrite, and a person looks at a rewrite.',
546
+ todo: 'Read what it changed before it goes any further, or ship what you are happy with so the count starts again.',
547
+ });
548
+ }
549
+
550
+ if (record.newlyUnstable.length > 0) {
551
+ const n = record.newlyUnstable.length;
552
+ items.push({
553
+ id: 'unpredictable',
554
+ kind: 'unpredictable',
555
+ what: `${n} ${n === 1 ? 'thing in' : 'things in'} ${product} used to give the same answer every single run and now ${n === 1 ? 'does' : 'do'} not: ${record.newlyUnstable.slice(0, 3).join(', ')}${n > 3 ? ', and more' : ''}.`,
556
+ why: 'Nothing here has a wrong value, so no agent can point at it and no waiver can cover it — which is exactly why this kind of bug survives for months.',
557
+ todo: 'Have it looked into before shipping. Something in the change made the product unpredictable.',
558
+ paths: record.newlyUnstable.slice(0, 6),
559
+ });
560
+ }
561
+
562
+ return {
563
+ product,
564
+ at: record.at,
565
+ items,
566
+ waived: record.accounting.waived,
567
+ expiredWaivers: record.accounting.expiredWaivers,
568
+ note: summaryNote(product, items, record),
569
+ };
570
+ }
571
+
572
+ /**
573
+ * @param {DecidedFinding} f
574
+ * @returns {string}
575
+ */
576
+ function sealedTodo(f) {
577
+ const where = (f.paths ?? [])[0];
578
+ const cls = typeof f.class === 'string' ? f.class : '';
579
+ if (cls === 'guard') return 'This is a bug you already reported once, coming back. Say whether it goes back on the list, or the guard was wrong.';
580
+ if (cls === 'money') return 'Say whether that is the amount you wanted. If it is, shipping makes it the new normal; if it is not, nothing ships.';
581
+ if (cls === 'sign-in') return 'Say whether signing in is meant to behave like that now. Nothing ships until you do.';
582
+ if (cls === 'data-loss') return 'Say whether that deletion is meant to happen. This one is worth thirty seconds before anything ships.';
583
+ if (cls === 'crash') return 'This has to be fixed before anything ships. Nobody needs to decide anything, but you should know it happened.';
584
+ return `Say whether that is what you wanted${where ? `, at ${where}` : ''}. If it is, ship — shipping is what makes it the new normal.`;
585
+ }
586
+
587
+ /**
588
+ * @param {string} product
589
+ * @param {Escalation[]} items
590
+ * @param {CheckRecord} record
591
+ * @returns {string}
592
+ */
593
+ function summaryNote(product, items, record) {
594
+ if (items.length === 0) {
595
+ const waived = record.accounting.waived;
596
+ return waived > 0
597
+ ? `Stays Fixed: nothing on ${product} needs your word. ${waived} ${waived === 1 ? 'difference was' : 'differences were'} recorded as intended by the agent and ${waived === 1 ? 'is' : 'are'} waiting on your next ship.`
598
+ : `Stays Fixed: nothing on ${product} needs your word.`;
599
+ }
600
+ return `Stays Fixed: ${items.length} ${items.length === 1 ? 'thing needs' : 'things need'} your word on ${product}.`;
601
+ }
602
+
603
+ /**
604
+ * The escalation block, as plain text a closing summary can paste in whole.
605
+ *
606
+ * No headings to navigate, no table, no link, no reference numbers only the tool
607
+ * understands. He reads one summary at the end of a working stretch; this has to fit inside
608
+ * it and read as part of it.
609
+ *
610
+ * @param {Escalations} escalations
611
+ * @returns {string}
612
+ */
613
+ export function escalationBlock(escalations) {
614
+ /** @type {string[]} */
615
+ const out = [escalations.note];
616
+
617
+ const shown = escalations.items.slice(0, MOST_ITEMS);
618
+ shown.forEach((item, i) => {
619
+ out.push('');
620
+ out.push(`${i + 1}. ${item.what}`);
621
+ out.push(` ${item.why}`);
622
+ out.push(` ${item.todo}`);
623
+ });
624
+
625
+ if (escalations.items.length > shown.length) {
626
+ out.push('');
627
+ out.push(
628
+ `And ${escalations.items.length - shown.length} more of the same kind. That many at once means something bigger went wrong than any one of them.`,
629
+ );
630
+ }
631
+
632
+ if (escalations.items.length > 0 && escalations.waived > 0) {
633
+ out.push('');
634
+ out.push(
635
+ `Also: the agent recorded ${escalations.waived} other ${escalations.waived === 1 ? 'difference' : 'differences'} as intended. ${escalations.waived === 1 ? 'It is' : 'They are'} provisional until you ship.`,
636
+ );
637
+ }
638
+
639
+ return out.join('\n');
640
+ }
641
+
642
+ /**
643
+ * Write the escalation block where a closing summary can pick it up.
644
+ *
645
+ * @param {Store} store
646
+ * @param {string} product
647
+ * @param {string} file Where to write it. Absolute, or relative to the project folder.
648
+ * @returns {Promise<{file: string, text: string, count: number}>}
649
+ */
650
+ export async function writeEscalations(store, product, file) {
651
+ const escalations = await escalationsFor(store, product);
652
+ const text = escalationBlock(escalations);
653
+ const where = path.isAbsolute(file) ? file : path.join(store.root, file);
654
+ await fsp.mkdir(path.dirname(where), { recursive: true });
655
+ await fsp.writeFile(where, text.endsWith('\n') ? text : text + '\n');
656
+ return { file: where, text, count: escalations.items.length };
657
+ }
658
+
659
+ // ---------------------------------------------------------------------------
660
+ // Small things
661
+ // ---------------------------------------------------------------------------
662
+
663
+ /**
664
+ * @param {unknown} s
665
+ * @param {number} max
666
+ * @returns {string}
667
+ */
668
+ function oneLine(s, max) {
669
+ const one = String(s ?? '').replace(/\s+/g, ' ').trim();
670
+ return one.length > max ? one.slice(0, max - 1) + '...' : one;
671
+ }
672
+
673
+ /**
674
+ * @param {string} s
675
+ * @returns {string}
676
+ */
677
+ function capital(s) {
678
+ return s.length === 0 ? s : s[0].toUpperCase() + s.slice(1);
679
+ }