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,1051 @@
1
+ /**
2
+ * What "working" means, and the only act that is allowed to change it.
3
+ *
4
+ * Everything else in version 2 measures differences. This file holds the one thing a
5
+ * measurement can never supply: the standard the measurement is taken against. And the
6
+ * whole point of the design is that a person never opens the tool to set that standard.
7
+ * It is cut by an act he already performs — saying ship.
8
+ *
9
+ * FOUR DECISIONS WERE HIDING INSIDE THE WORD "APPROVE", and only the first one lives here.
10
+ *
11
+ * 1. What counts as working a person, by shipping. THIS FILE.
12
+ * 2. Is this difference real or noise the machine, arithmetically. observation.js
13
+ * 3. Did my own change cause it the agent, by proving it. cause.js
14
+ * 4. Is an unintended difference fine a person, a few times a month. the summary
15
+ *
16
+ * So there is no function in here called `approve`, and there is no way for an agent to
17
+ * reach one. An agent may write a WAIVER — a provisional, fingerprinted, budgeted,
18
+ * expiring note saying "I meant that" — and the gates on those live in the MCP surface.
19
+ * A waiver never becomes the standard on its own. It becomes the standard the moment a
20
+ * build ships with it still standing, and at that moment it stops being a waiver and
21
+ * starts being what the product does.
22
+ *
23
+ * WHY THE STABILITY RECORD IS STORED WITH THE REFERENCE, and not left to be recomputed.
24
+ * A reference that only remembers what the product DID cannot answer the one question no
25
+ * other tool asks: "this address gave the same answer twice back then, and it does not
26
+ * now." That finding — the change made something unpredictable — is invisible unless the
27
+ * reference remembers how steady it was as well as what it said. Captures get pruned,
28
+ * disks get cleared, and a recomputation months later can quietly come back empty and
29
+ * read as "nothing became unstable". So the measurement is taken at the moment the
30
+ * reference is cut and written down beside it. When it cannot be taken, the reference
31
+ * says so in those words rather than storing a zero that looks like good news.
32
+ *
33
+ * WHAT THIS FILE WILL REFUSE TO DO. It will not make a build the standard when that build
34
+ * was never checked, or was checked and found broken, unless somebody forces it and
35
+ * accepts that the forcing goes on the record. A safety net that will accept a broken
36
+ * build as the definition of correct has not become slightly less useful; it has become a
37
+ * rubber stamp, and it will now report the broken behaviour as normal every day, silently,
38
+ * for as long as it runs.
39
+ *
40
+ * ON DISK, all inside the store folder (`.staysfixed/v2`):
41
+ *
42
+ * references.json the pointer per product. Written by store.js, never by hand.
43
+ * reference-log.json every cut ever made, with its stability record. This file.
44
+ * waivers.json the agent's provisional notes. Retired here when a reference moves.
45
+ * waivers-expired.json the overflow archive, so nothing is ever actually thrown away.
46
+ * check-log.json what the last few checks concluded, so a ship can tell whether
47
+ * the build it is about to bless was ever actually checked.
48
+ */
49
+
50
+ import fsp from 'node:fs/promises';
51
+ import path from 'node:path';
52
+ import crypto from 'node:crypto';
53
+
54
+ import { StaysFixedError } from '../core/errors.js';
55
+ import { safeName } from '../core/paths.js';
56
+ import { setReference, referencePointer, loadBuild, listBuilds, listCaptures, loadCapture, ensureStore } from './store.js';
57
+ import { measureWobble } from './observation.js';
58
+
59
+ /** @typedef {import('./types.js').Store} Store */
60
+ /** @typedef {import('./types.js').Capture} Capture */
61
+ /** @typedef {import('./types.js').BuildFingerprint} BuildFingerprint */
62
+ /** @typedef {import('./types.js').BuildRecord} BuildRecord */
63
+ /** @typedef {import('./types.js').ReferencePointer} ReferencePointer */
64
+ /** @typedef {import('./types.js').Finding} Finding */
65
+
66
+ /**
67
+ * How many unstable addresses are written into a reference's stability record before the
68
+ * list is cut short. The COUNT is always exact; the list is for reading, and a reference
69
+ * carrying forty thousand path strings helps nobody.
70
+ */
71
+ const MAX_UNSTABLE_LISTED = 500;
72
+
73
+ /** How many cuts stay in the log. Older ones move to the archive rather than being deleted. */
74
+ const MAX_LOG_ENTRIES = 200;
75
+
76
+ /** How many retired waivers stay visible in waivers.json before they move to the archive. */
77
+ const KEEP_RETIRED = 40;
78
+
79
+ /** How many check conclusions are remembered, so a ship can look one up. */
80
+ const MAX_CHECK_LOG = 40;
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Shapes
84
+ // ---------------------------------------------------------------------------
85
+
86
+ /**
87
+ * How steady one journey was, the last time this build walked it twice.
88
+ *
89
+ * @typedef {object} JourneyStability
90
+ * @property {string} journey
91
+ * @property {boolean} measured False when the build was only ever walked once.
92
+ * @property {string} [why] Plain English, present whenever `measured` is false.
93
+ * @property {number} paths Addresses seen. The denominator.
94
+ * @property {number} steady Addresses that answered the same way twice.
95
+ * @property {number} unstableCount
96
+ * @property {string[]} unstable The addresses themselves, cut short after MAX_UNSTABLE_LISTED.
97
+ * @property {boolean} [truncated] True when `unstable` is shorter than `unstableCount`.
98
+ * @property {[string, string]} [runs] The two captures that were compared.
99
+ */
100
+
101
+ /**
102
+ * What a build disagreed with itself about, at the moment it became the standard.
103
+ *
104
+ * @typedef {object} StabilityRecord
105
+ * @property {boolean} measured False when NOTHING could be measured. Never a zero
106
+ * dressed up as calm.
107
+ * @property {number} journeys
108
+ * @property {number} measuredJourneys
109
+ * @property {number} paths
110
+ * @property {number} steady
111
+ * @property {number} unstable
112
+ * @property {string[]} unstablePaths
113
+ * @property {JourneyStability[]} byJourney
114
+ * @property {string} note One plain sentence. Says outright when there is no
115
+ * record rather than implying the build was steady.
116
+ */
117
+
118
+ /**
119
+ * One cut: the moment a build became this product's definition of working.
120
+ *
121
+ * @typedef {object} ReferenceCut
122
+ * @property {string} id File-safe and sortable: 'ref-20260829-013245-a1b2c3'.
123
+ * @property {string} product
124
+ * @property {string} buildId
125
+ * @property {BuildFingerprint} [build]
126
+ * @property {string} at ISO.
127
+ * @property {string} [setBy] 'ship-everywhere', 'staysfixed ship', a person.
128
+ * @property {string} [why] What the release was, in a person's words.
129
+ * @property {string[]} journeys What had actually been walked against this build.
130
+ * @property {StabilityRecord} stability
131
+ * @property {number} waiversRetired Counted out loud, because a waiver that expires quietly
132
+ * is indistinguishable from one that was never written.
133
+ * @property {string} [previousBuildId]
134
+ * @property {boolean} [forced] Somebody cut this past a refusal. It stays on the record.
135
+ * @property {string} [forcedPast] The exact refusal that was overridden.
136
+ * @property {boolean} [unchanged] This build was already the reference; nothing moved.
137
+ * @property {string} summary One line for the closing summary he already reads.
138
+ */
139
+
140
+ /**
141
+ * Whether a build may become the standard, and if not, exactly why not.
142
+ *
143
+ * @typedef {object} CutDecision
144
+ * @property {boolean} ok
145
+ * @property {'clean'|'accounted-for'|'already-the-reference'|'never-checked'|'broken'|'blocked'|'not-stored'} state
146
+ * @property {string} why Plain English, whichever way it went.
147
+ * @property {string} [refusal] The full refusal, present only when `ok` is false.
148
+ * @property {boolean} needsForce True when only `force: true` would get past this.
149
+ * @property {string} buildId
150
+ * @property {number} [findings] Differences the last check left unaccounted for.
151
+ * @property {number} [waived] Differences an agent had waived, which this ship adopts.
152
+ * @property {string} [checkedAt]
153
+ */
154
+
155
+ /**
156
+ * A provisional "I meant that", as the MCP surface writes it. Repeated here rather than
157
+ * imported: this file must keep working in a copy where the MCP surface is not installed,
158
+ * and the two only ever meet through the JSON on disk.
159
+ *
160
+ * @typedef {object} Waiver
161
+ * @property {string} id
162
+ * @property {string} fingerprint
163
+ * @property {string} summary
164
+ * @property {string} because
165
+ * @property {string} intentId
166
+ * @property {string} at
167
+ * @property {string} reference
168
+ * @property {string} [retiredAt]
169
+ * @property {string} [retiredBy] The id of the cut that retired it.
170
+ * @property {string} [retiredWhy]
171
+ */
172
+
173
+ /**
174
+ * What a check concluded, kept so that a ship can find out whether the build it is about
175
+ * to bless was ever checked at all.
176
+ *
177
+ * @typedef {object} CheckNote
178
+ * @property {string} at
179
+ * @property {string} buildId
180
+ * @property {string} [product]
181
+ * @property {boolean} ok
182
+ * @property {boolean} [blocked]
183
+ * @property {number} findings Total findings the engine reported.
184
+ * @property {number} unaccounted Findings nobody accounted for. This is the number that decides.
185
+ * @property {number} [waived]
186
+ * @property {number} [sealed]
187
+ * @property {string} [by] 'staysfixed check', 'staysfixed_check', the self-check corpus.
188
+ */
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // Small disk helpers. Deliberately local — nothing here may fail a release.
192
+ // ---------------------------------------------------------------------------
193
+
194
+ /**
195
+ * @param {Store} store
196
+ * @param {string} name
197
+ * @returns {string}
198
+ */
199
+ function fileIn(store, name) {
200
+ return path.join(store.dir, name);
201
+ }
202
+
203
+ /**
204
+ * Write so that nobody can ever read the file half-finished.
205
+ * @param {string} file
206
+ * @param {unknown} value
207
+ */
208
+ async function writeJsonAtomic(file, value) {
209
+ await fsp.mkdir(path.dirname(file), { recursive: true });
210
+ const temp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.part`;
211
+ await fsp.writeFile(temp, JSON.stringify(value, null, 2) + '\n');
212
+ await fsp.rename(temp, file);
213
+ }
214
+
215
+ /**
216
+ * Read JSON, and treat anything unreadable as absent.
217
+ *
218
+ * A hand-edited waiver file must not be able to stop a release being recorded. Losing a
219
+ * waiver is safe — it errs towards a person looking at something. Refusing to record what
220
+ * shipped is not safe: it leaves the next check comparing against yesterday.
221
+ *
222
+ * @template T
223
+ * @param {string} file
224
+ * @param {T} fallback
225
+ * @returns {Promise<T>}
226
+ */
227
+ async function readJson(file, fallback) {
228
+ try {
229
+ const parsed = JSON.parse(await fsp.readFile(file, 'utf8'));
230
+ return parsed === null || parsed === undefined ? fallback : parsed;
231
+ } catch {
232
+ return fallback;
233
+ }
234
+ }
235
+
236
+ /**
237
+ * A sortable, file-safe id for one cut.
238
+ * @param {Date} [now]
239
+ * @returns {string}
240
+ */
241
+ function newCutId(now = new Date()) {
242
+ /** @param {number} n */
243
+ const p = (n) => String(n).padStart(2, '0');
244
+ const stamp =
245
+ `${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-` +
246
+ `${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
247
+ return `ref-${stamp}-${crypto.randomBytes(3).toString('hex')}`;
248
+ }
249
+
250
+ /**
251
+ * A build, named the way a person would name it.
252
+ * @param {BuildFingerprint|undefined} build
253
+ * @param {string} buildId
254
+ * @returns {string}
255
+ */
256
+ function nameOf(build, buildId) {
257
+ if (build?.version) return build.version;
258
+ if (build?.gitSha) return build.gitSha.slice(0, 7);
259
+ return buildId;
260
+ }
261
+
262
+ /**
263
+ * @param {number} n
264
+ * @param {string} one
265
+ * @param {string} many
266
+ * @returns {string}
267
+ */
268
+ function plural(n, one, many) {
269
+ return n === 1 ? one : many;
270
+ }
271
+
272
+ /**
273
+ * A build id out of whatever the caller had to hand.
274
+ * @param {string|BuildFingerprint} build
275
+ * @returns {string}
276
+ */
277
+ function idOfBuild(build) {
278
+ return typeof build === 'string' ? build : build.id;
279
+ }
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // The stability record — how steady this build was with itself
283
+ // ---------------------------------------------------------------------------
284
+
285
+ /**
286
+ * Measure how much a stored build disagreed with itself, journey by journey.
287
+ *
288
+ * This reads only what is already on disk. It never runs the product: by the time a
289
+ * reference is cut the build has shipped, and re-walking it would be measuring a different
290
+ * afternoon. If the two runs are not both there, that journey says so and the reference
291
+ * carries the admission instead of a comforting zero.
292
+ *
293
+ * @param {Store} store
294
+ * @param {string} buildId
295
+ * @returns {Promise<StabilityRecord>}
296
+ */
297
+ export async function measureStability(store, buildId) {
298
+ const record = await loadBuild(store, buildId);
299
+ /** @type {string[]} */
300
+ const journeys = record?.journeys ?? [];
301
+
302
+ /** @type {JourneyStability[]} */
303
+ const byJourney = [];
304
+ /** @type {string[]} */
305
+ const unstablePaths = [];
306
+ let paths = 0;
307
+ let steady = 0;
308
+ let measuredJourneys = 0;
309
+
310
+ for (const journey of journeys) {
311
+ const pair = await twoRunsOf(store, buildId, journey);
312
+ if (!pair) {
313
+ byJourney.push({
314
+ journey,
315
+ measured: false,
316
+ why: 'This build only ever walked this journey once, so nothing here says how steady it was.',
317
+ paths: 0,
318
+ steady: 0,
319
+ unstableCount: 0,
320
+ unstable: [],
321
+ });
322
+ continue;
323
+ }
324
+
325
+ /** @type {import('./types.js').Wobble} */
326
+ let wobble;
327
+ try {
328
+ wobble = measureWobble(pair.a, pair.b);
329
+ } catch (e) {
330
+ byJourney.push({
331
+ journey,
332
+ measured: false,
333
+ why: `The two stored runs of this journey could not be compared: ${e instanceof Error ? e.message : String(e)}`,
334
+ paths: 0,
335
+ steady: 0,
336
+ unstableCount: 0,
337
+ unstable: [],
338
+ });
339
+ continue;
340
+ }
341
+
342
+ measuredJourneys++;
343
+ const seen = wobble.steady + wobble.unstable.length;
344
+ paths += seen;
345
+ steady += wobble.steady;
346
+ for (const p of wobble.unstable) unstablePaths.push(p);
347
+
348
+ /** @type {JourneyStability} */
349
+ const entry = {
350
+ journey,
351
+ measured: true,
352
+ paths: seen,
353
+ steady: wobble.steady,
354
+ unstableCount: wobble.unstable.length,
355
+ unstable: wobble.unstable.slice(0, MAX_UNSTABLE_LISTED),
356
+ runs: wobble.runs,
357
+ };
358
+ if (wobble.unstable.length > entry.unstable.length) entry.truncated = true;
359
+ byJourney.push(entry);
360
+ }
361
+
362
+ const measured = measuredJourneys > 0;
363
+ const listed = unstablePaths.slice(0, MAX_UNSTABLE_LISTED);
364
+
365
+ return {
366
+ measured,
367
+ journeys: journeys.length,
368
+ measuredJourneys,
369
+ paths,
370
+ steady,
371
+ unstable: unstablePaths.length,
372
+ unstablePaths: listed,
373
+ byJourney,
374
+ note: stabilityNote(measured, journeys.length, measuredJourneys, steady, unstablePaths.length),
375
+ };
376
+ }
377
+
378
+ /**
379
+ * The sentence that goes in the reference. It has one job: never let "we did not measure"
380
+ * read like "nothing wobbled".
381
+ *
382
+ * @param {boolean} measured
383
+ * @param {number} journeys
384
+ * @param {number} measuredJourneys
385
+ * @param {number} steady
386
+ * @param {number} unstable
387
+ * @returns {string}
388
+ */
389
+ function stabilityNote(measured, journeys, measuredJourneys, steady, unstable) {
390
+ if (journeys === 0) {
391
+ return 'Nothing has ever been walked against this build, so this reference has no record of what it does or how steady it is.';
392
+ }
393
+ if (!measured) {
394
+ return `This build was walked once, never twice, so there is NO record of how steady it was. A later run cannot tell you that an address which used to answer the same way twice has stopped doing so — not because nothing became unpredictable, but because nobody wrote down what steady looked like here.`;
395
+ }
396
+ const partial =
397
+ measuredJourneys < journeys
398
+ ? ` ${journeys - measuredJourneys} of its ${journeys} ${plural(journeys, 'journey', 'journeys')} ran only once and carry no steadiness record.`
399
+ : '';
400
+ if (unstable === 0) {
401
+ const all = steady === 1 ? 'the one address it was watched at' : `all ${steady} addresses`;
402
+ return `Measured across ${measuredJourneys} ${plural(measuredJourneys, 'journey', 'journeys')}: ${all} answered the same way twice.${partial}`;
403
+ }
404
+ return `Measured across ${measuredJourneys} ${plural(measuredJourneys, 'journey', 'journeys')}: ${steady} ${plural(steady, 'address', 'addresses')} answered the same way twice and ${unstable} did not. ${unstable === 1 ? 'That one was' : `Those ${unstable} were`} already unpredictable when this shipped, so a later run must not blame a change for ${plural(unstable, 'it', 'them')}.${partial}`;
405
+ }
406
+
407
+ /**
408
+ * The two runs of one journey that belong together.
409
+ *
410
+ * Pairing matters more than it looks. The store keeps several captures per journey, and
411
+ * grabbing the newest 'a' and the newest 'b' can straddle two different checks — which
412
+ * would measure the difference between two afternoons and call it wobble. So: take the
413
+ * newest second run, then the newest first run that came before it.
414
+ *
415
+ * @param {Store} store
416
+ * @param {string} buildId
417
+ * @param {string} journey
418
+ * @returns {Promise<{a: Capture, b: Capture}|null>}
419
+ */
420
+ async function twoRunsOf(store, buildId, journey) {
421
+ const refs = await listCaptures(store, { buildId, journey });
422
+ if (refs.length < 2) return null;
423
+
424
+ /** @type {Capture[]} */
425
+ const captures = [];
426
+ for (const ref of refs) {
427
+ /** @type {Capture|null} */
428
+ let capture = null;
429
+ try {
430
+ capture = await loadCapture(store, ref);
431
+ } catch {
432
+ // One unreadable file must never take the whole stability record with it.
433
+ continue;
434
+ }
435
+ if (capture) captures.push(capture);
436
+ }
437
+
438
+ for (let i = captures.length - 1; i >= 0; i--) {
439
+ if (captures[i].run !== 'b') continue;
440
+ for (let j = i - 1; j >= 0; j--) {
441
+ if (captures[j].run === 'a') return { a: captures[j], b: captures[i] };
442
+ }
443
+ }
444
+ return null;
445
+ }
446
+
447
+ // ---------------------------------------------------------------------------
448
+ // Waivers — every one of them dies when the reference moves
449
+ // ---------------------------------------------------------------------------
450
+
451
+ /**
452
+ * Retire every waiver, because the reference has moved.
453
+ *
454
+ * This is the mechanism that stops a waiver becoming a permanent blind spot. A waiver says
455
+ * "I meant that, this once, against this standard". The moment the standard moves, the
456
+ * sentence stops being true of anything: either the difference shipped, in which case it
457
+ * IS the standard now and needs no waiver, or it did not, in which case waving it through
458
+ * a second time is a decision somebody should make again on purpose.
459
+ *
460
+ * WHICH WAIVERS. This product's, wherever they are kept. Waivers live per product at
461
+ * `waivers/<product>.json`, and an older flat `waivers.json` exists in copies where the MCP
462
+ * surface wrote them before they were split up. Both are swept, because a waiver the tool
463
+ * cannot see is a waiver that outlives its subject, and that is the one failure mode this
464
+ * whole mechanism exists to prevent.
465
+ *
466
+ * The waiver files also stamp each waiver with the reference in force when it was written,
467
+ * so a moved reference already retires them arithmetically. This function is the belt to
468
+ * that pair of braces AND the audit trail: it writes down WHEN each one died and WHY, so
469
+ * "this was waived once and then a build shipped" is a sentence somebody can read six
470
+ * months later, rather than an inference from two hashes not matching.
471
+ *
472
+ * Nothing is deleted. Retired waivers stay in place with the reason on them, and the
473
+ * overflow moves to an archive beside them.
474
+ *
475
+ * @param {Store} store
476
+ * @param {string} product
477
+ * @param {string} newReferenceId The id of the cut that retired them.
478
+ * @returns {Promise<{retired: number, live: number, archived: number, waivers: Waiver[], files: string[], note: string}>}
479
+ */
480
+ export async function expireWaivers(store, product, newReferenceId) {
481
+ const files = [path.join(store.dir, 'waivers', `${safeName(product)}.json`), fileIn(store, 'waivers.json')];
482
+
483
+ /** @type {Waiver[]} */
484
+ const retired = [];
485
+ /** @type {string[]} */
486
+ const touched = [];
487
+ let liveLeft = 0;
488
+ let archived = 0;
489
+
490
+ for (const file of files) {
491
+ /** @type {Waiver[]} */
492
+ const waivers = await readJson(file, /** @type {Waiver[]} */ ([]));
493
+ if (!Array.isArray(waivers) || waivers.length === 0) continue;
494
+ touched.push(file);
495
+
496
+ const at = new Date().toISOString();
497
+ for (const waiver of waivers) {
498
+ if (!waiver || typeof waiver !== 'object') continue;
499
+ if (waiver.retiredAt) continue;
500
+ waiver.retiredAt = at;
501
+ waiver.retiredBy = newReferenceId;
502
+ waiver.retiredWhy = `${product} shipped, so the reference moved and this waiver stopped covering anything. What it described either shipped — in which case it is now simply what the product does — or it did not, in which case waiving it again is a fresh decision.`;
503
+ retired.push(waiver);
504
+ }
505
+
506
+ // Keep the recent dead ones where a summary can still count them; move the rest to the
507
+ // archive. Kept, not deleted: a waiver is a record of a judgement call, and "why did
508
+ // nobody catch this" is a question that gets asked months later.
509
+ const live = waivers.filter((w) => w && !w.retiredAt);
510
+ const dead = waivers.filter((w) => Boolean(w && w.retiredAt)).sort((a, b) => (a.retiredAt ?? '').localeCompare(b.retiredAt ?? ''));
511
+ const overflow = dead.slice(0, Math.max(0, dead.length - KEEP_RETIRED));
512
+ liveLeft += live.length;
513
+ archived += overflow.length;
514
+
515
+ if (overflow.length > 0) {
516
+ const archiveFile = `${file.slice(0, -'.json'.length)}-expired.json`;
517
+ /** @type {Waiver[]} */
518
+ const archive = await readJson(archiveFile, /** @type {Waiver[]} */ ([]));
519
+ await writeJsonAtomic(archiveFile, [...(Array.isArray(archive) ? archive : []), ...overflow]);
520
+ }
521
+ await writeJsonAtomic(file, [...live, ...dead.slice(-KEEP_RETIRED)]);
522
+ }
523
+
524
+ return {
525
+ retired: retired.length,
526
+ live: liveLeft,
527
+ archived,
528
+ waivers: retired,
529
+ files: touched,
530
+ note:
531
+ retired.length === 0
532
+ ? 'No waivers were outstanding, so nothing had to be retired.'
533
+ : `${retired.length} ${plural(retired.length, 'waiver', 'waivers')} retired: whatever ${plural(retired.length, 'it', 'they')} covered has now either shipped and become normal, or has to be decided again.`,
534
+ };
535
+ }
536
+
537
+ // ---------------------------------------------------------------------------
538
+ // Remembering what a check concluded
539
+ // ---------------------------------------------------------------------------
540
+
541
+ /**
542
+ * Write down what a check concluded, so that a ship can find out whether the build it is
543
+ * about to make the standard was ever actually checked.
544
+ *
545
+ * Without this, `shouldCut` has only the MCP surface's `last-check.json` to go on, which
546
+ * means a person who runs `staysfixed check` on the command line and then ships gets told
547
+ * their build was never checked. Wiring this into the two front doors is a one-line call
548
+ * each, and it is listed in the handover.
549
+ *
550
+ * @param {Store} store
551
+ * @param {{buildId: string, product?: string, ok: boolean, blocked?: boolean, findings?: number, unaccounted?: number, waived?: number, sealed?: number, by?: string, at?: string}} note
552
+ * @returns {Promise<CheckNote>}
553
+ */
554
+ export async function recordCheck(store, note) {
555
+ const file = fileIn(store, 'check-log.json');
556
+ /** @type {CheckNote[]} */
557
+ const log = await readJson(file, /** @type {CheckNote[]} */ ([]));
558
+ const findings = note.findings ?? 0;
559
+ /** @type {CheckNote} */
560
+ const entry = {
561
+ at: note.at ?? new Date().toISOString(),
562
+ buildId: note.buildId,
563
+ ok: note.ok === true,
564
+ findings,
565
+ unaccounted: note.unaccounted ?? (note.ok === true ? 0 : findings),
566
+ };
567
+ if (note.product) entry.product = note.product;
568
+ if (note.blocked !== undefined) entry.blocked = note.blocked;
569
+ if (note.waived !== undefined) entry.waived = note.waived;
570
+ if (note.sealed !== undefined) entry.sealed = note.sealed;
571
+ if (note.by) entry.by = note.by;
572
+
573
+ const next = [...(Array.isArray(log) ? log : []), entry].slice(-MAX_CHECK_LOG);
574
+ await writeJsonAtomic(file, next);
575
+ return entry;
576
+ }
577
+
578
+ /**
579
+ * What the checks on disk say about one build, from both places a check is recorded.
580
+ *
581
+ * @param {Store} store
582
+ * @param {string} buildId
583
+ * @returns {Promise<CheckNote|null>}
584
+ */
585
+ async function checkFor(store, buildId) {
586
+ /** @type {CheckNote[]} */
587
+ const log = await readJson(fileIn(store, 'check-log.json'), /** @type {CheckNote[]} */ ([]));
588
+ /** @type {CheckNote|null} */
589
+ let best = null;
590
+ if (Array.isArray(log)) {
591
+ for (const entry of log) {
592
+ if (!entry || entry.buildId !== buildId) continue;
593
+ if (!best || entry.at > best.at) best = entry;
594
+ }
595
+ }
596
+
597
+ const fromSurface = await lastCheckOf(store, buildId);
598
+ if (fromSurface && (!best || fromSurface.at > best.at)) best = fromSurface;
599
+ return best;
600
+ }
601
+
602
+ /**
603
+ * The agent surface's record of the last check, read back as a CheckNote.
604
+ *
605
+ * WHY THIS IS NOT SIMPLY `result.ok`. A build that ships with a waiver standing has
606
+ * `ok: false` on it, and refusing to cut a reference for that would refuse the normal case:
607
+ * shipping IS the moment a provisional waiver stops being provisional and becomes what the
608
+ * product does. So what decides is how many differences were left UNACCOUNTED FOR — nobody's
609
+ * judgement, no agent's opinion, the count the check itself already worked out.
610
+ *
611
+ * Two shapes are read, because the record has been through one revision and a copy of the
612
+ * tool in the wild may hold either. The newer one carries an `accounting` block with the
613
+ * numbers already worked out; the older one carries findings with the fingerprint a waiver
614
+ * pins to, and the numbers are recomputed from the live waivers. Reading one shape and
615
+ * silently returning null on the other would look exactly like "this was never checked",
616
+ * which refuses every ship for a reason nobody could work out.
617
+ *
618
+ * @param {Store} store
619
+ * @param {string} buildId
620
+ * @returns {Promise<CheckNote|null>}
621
+ */
622
+ async function lastCheckOf(store, buildId) {
623
+ /**
624
+ * @type {{
625
+ * at?: string,
626
+ * product?: string,
627
+ * verdict?: string,
628
+ * accounting?: {reported?: number, waived?: number, unwaivable?: number},
629
+ * findings?: (Finding & {fingerprint?: string, unwaivable?: boolean})[],
630
+ * result?: {ok?: boolean, blocked?: boolean, candidate?: BuildFingerprint, product?: string}
631
+ * }|null}
632
+ */
633
+ const last = await readJson(fileIn(store, 'last-check.json'), /** @type {any} */ (null));
634
+ if (!last || typeof last !== 'object') return null;
635
+
636
+ const candidate = last.result?.candidate;
637
+ if (!candidate || candidate.id !== buildId) return null;
638
+
639
+ const findings = Array.isArray(last.findings) ? last.findings : [];
640
+ const at = typeof last.at === 'string' ? last.at : new Date(0).toISOString();
641
+ const blocked = last.result?.blocked === true || last.verdict === 'blocked';
642
+
643
+ // The newer record has already done the arithmetic, and it did it against the waivers that
644
+ // were live at the time — which is more accurate than anything recomputed now.
645
+ if (last.accounting && typeof last.accounting === 'object') {
646
+ const reported = last.accounting.reported ?? 0;
647
+ return {
648
+ at,
649
+ buildId,
650
+ ok: !blocked && reported === 0,
651
+ blocked,
652
+ findings: findings.length,
653
+ unaccounted: reported,
654
+ waived: last.accounting.waived ?? 0,
655
+ sealed: last.accounting.unwaivable ?? 0,
656
+ by: 'the agent surface',
657
+ ...(last.product ? { product: last.product } : {}),
658
+ };
659
+ }
660
+
661
+ /** @type {Waiver[]} */
662
+ const flat = await readJson(fileIn(store, 'waivers.json'), /** @type {Waiver[]} */ ([]));
663
+ const product = last.product ?? last.result?.product ?? candidate.product;
664
+ /** @type {Waiver[]} */
665
+ const perProduct = product
666
+ ? await readJson(path.join(store.dir, 'waivers', `${safeName(product)}.json`), /** @type {Waiver[]} */ ([]))
667
+ : [];
668
+ const covered = new Set(
669
+ [...(Array.isArray(perProduct) ? perProduct : []), ...(Array.isArray(flat) ? flat : [])]
670
+ .filter((w) => w && !w.retiredAt && w.fingerprint)
671
+ .map((w) => w.fingerprint)
672
+ );
673
+
674
+ let waived = 0;
675
+ let sealed = 0;
676
+ let unaccounted = 0;
677
+ for (const f of findings) {
678
+ if (f?.sealed || f?.unwaivable) {
679
+ sealed++;
680
+ unaccounted++;
681
+ continue;
682
+ }
683
+ if (f?.fingerprint && covered.has(f.fingerprint)) {
684
+ waived++;
685
+ continue;
686
+ }
687
+ unaccounted++;
688
+ }
689
+
690
+ return {
691
+ at,
692
+ buildId,
693
+ ok: !blocked && unaccounted === 0,
694
+ blocked,
695
+ findings: findings.length,
696
+ unaccounted,
697
+ waived,
698
+ sealed,
699
+ by: 'the agent surface',
700
+ ...(product ? { product } : {}),
701
+ };
702
+ }
703
+
704
+ // ---------------------------------------------------------------------------
705
+ // shouldCut — the refusal that keeps this from becoming a rubber stamp
706
+ // ---------------------------------------------------------------------------
707
+
708
+ /**
709
+ * The sentence this whole file exists to be able to say. Written once so every refusal
710
+ * says it in the same words, and so nobody has to reconstruct the reasoning at 3am.
711
+ */
712
+ const RUBBER_STAMP =
713
+ 'Cutting a broken build as the standard is how a safety net silently becomes a rubber stamp: ' +
714
+ 'from tomorrow the tool would call this behaviour correct, stop reporting it, and go on ' +
715
+ 'quietly passing every run that keeps it broken.';
716
+
717
+ /**
718
+ * May this build become what "working" means for this product?
719
+ *
720
+ * @param {Store} store
721
+ * @param {string} product
722
+ * @param {string|BuildFingerprint} build
723
+ * @returns {Promise<CutDecision>}
724
+ */
725
+ export async function shouldCut(store, product, build) {
726
+ const buildId = idOfBuild(build);
727
+ const fingerprint = typeof build === 'string' ? undefined : build;
728
+ const name = nameOf(fingerprint, buildId);
729
+
730
+ const pointer = await referencePointer(store, product);
731
+ if (pointer?.buildId === buildId) {
732
+ return {
733
+ ok: true,
734
+ state: 'already-the-reference',
735
+ why: `${name} is already what ${product} calls working, so there is nothing to move.`,
736
+ needsForce: false,
737
+ buildId,
738
+ };
739
+ }
740
+
741
+ const record = await loadBuild(store, buildId);
742
+ if (!record) {
743
+ return {
744
+ ok: false,
745
+ state: 'not-stored',
746
+ needsForce: true,
747
+ buildId,
748
+ why: `Nothing has ever been observed against ${name}.`,
749
+ refusal: [
750
+ `Refusing to make ${name} the standard for ${product}: nothing has ever been observed against it.`,
751
+ 'A reference is not a label, it is a record — the observations everything afterwards gets compared with. Pointing at a build that was never walked would leave the next check comparing today against nothing and reporting that as a pass.',
752
+ `Run a check against this build first, then ship. Nothing about ${product} is being checked in the meantime.`,
753
+ ].join(' '),
754
+ };
755
+ }
756
+
757
+ const check = await checkFor(store, buildId);
758
+ if (!check) {
759
+ return {
760
+ ok: false,
761
+ state: 'never-checked',
762
+ needsForce: true,
763
+ buildId,
764
+ why: `${name} was walked, but no check ever concluded anything about it.`,
765
+ refusal: [
766
+ `Refusing to make ${name} the standard for ${product}: it was observed, but no check ever compared it with anything, so nobody — machine or person — has said this build works.`,
767
+ RUBBER_STAMP,
768
+ 'Run `staysfixed check` against this build and ship again, or force it and it goes on the record as forced.',
769
+ ].join(' '),
770
+ };
771
+ }
772
+
773
+ if (check.blocked) {
774
+ return {
775
+ ok: false,
776
+ state: 'blocked',
777
+ needsForce: true,
778
+ buildId,
779
+ checkedAt: check.at,
780
+ findings: check.unaccounted,
781
+ why: `The last check on ${name} could not be completed.`,
782
+ refusal: [
783
+ `Refusing to make ${name} the standard for ${product}: the last check on it was BLOCKED — it could not be run, which is neither a pass nor a failure.`,
784
+ 'Treating "I could not test this" as "this is correct" is the exact mistake this tool exists to prevent.',
785
+ RUBBER_STAMP,
786
+ 'Fix whatever blocked the check, run it, and ship again.',
787
+ ].join(' '),
788
+ };
789
+ }
790
+
791
+ if (check.unaccounted > 0) {
792
+ const sealed = check.sealed ?? 0;
793
+ return {
794
+ ok: false,
795
+ state: 'broken',
796
+ needsForce: true,
797
+ buildId,
798
+ checkedAt: check.at,
799
+ findings: check.unaccounted,
800
+ waived: check.waived,
801
+ why: `The last check on ${name} left ${check.unaccounted} ${plural(check.unaccounted, 'difference', 'differences')} unaccounted for.`,
802
+ refusal: [
803
+ `Refusing to make ${name} the standard for ${product}: the last check left ${check.unaccounted} ${plural(check.unaccounted, 'difference', 'differences')} nobody accounted for${sealed > 0 ? `, ${sealed} of which ${plural(sealed, 'is', 'are')} in a class nobody may wave through` : ''}.`,
804
+ RUBBER_STAMP,
805
+ 'Fix them, or force the cut and accept that it is recorded as forced with the reason above kept beside it.',
806
+ ].join(' '),
807
+ };
808
+ }
809
+
810
+ const waived = check.waived ?? 0;
811
+ return {
812
+ ok: true,
813
+ state: waived > 0 ? 'accounted-for' : 'clean',
814
+ needsForce: false,
815
+ buildId,
816
+ checkedAt: check.at,
817
+ findings: 0,
818
+ waived,
819
+ why:
820
+ waived > 0
821
+ ? `The last check on ${name} found nothing unaccounted for. ${waived} ${plural(waived, 'difference was', 'differences were')} waived as intended, and shipping is what turns ${plural(waived, 'that', 'those')} from a provisional note into simply what the product does.`
822
+ : `The last check on ${name} found nothing unaccounted for.`,
823
+ };
824
+ }
825
+
826
+ // ---------------------------------------------------------------------------
827
+ // cutReference — the one act that changes what "working" means
828
+ // ---------------------------------------------------------------------------
829
+
830
+ /**
831
+ * Make this build the definition of working for this product.
832
+ *
833
+ * Called by the ship hook and by nothing else that an agent can reach. It runs `shouldCut`
834
+ * itself rather than trusting its caller to have done so, because the refusal is a safety
835
+ * property and a safety property that depends on being called correctly is not one.
836
+ *
837
+ * @param {Store} store
838
+ * @param {object} opts
839
+ * @param {string} opts.product
840
+ * @param {string|BuildFingerprint} opts.build
841
+ * @param {string} [opts.why] What the release was, in a person's words.
842
+ * @param {string} [opts.setBy] Who or what did it: 'ship-everywhere', 'staysfixed ship'.
843
+ * @param {boolean} [opts.force] Cut past a refusal, on the record.
844
+ * @param {string} [opts.at] ISO, for tests and for a hook recording a past release.
845
+ * @returns {Promise<ReferenceCut>}
846
+ */
847
+ export async function cutReference(store, opts) {
848
+ const product = opts.product;
849
+ if (!product) throw new StaysFixedError('A reference has to belong to a product, and none was named.');
850
+
851
+ const buildId = idOfBuild(opts.build);
852
+ if (!buildId) throw new StaysFixedError(`Cannot cut a reference for ${product}: the build has no id.`);
853
+
854
+ await ensureStore(store);
855
+
856
+ const decision = await shouldCut(store, product, opts.build);
857
+ if (!decision.ok && opts.force !== true) {
858
+ throw new StaysFixedError(decision.refusal ?? decision.why, {
859
+ hint: 'If this really is the build you shipped, cut it with force and the refusal stays on the record beside it.',
860
+ });
861
+ }
862
+
863
+ const previous = await referencePointer(store, product);
864
+ const record = await loadBuild(store, buildId);
865
+ const fingerprint = record?.fingerprint ?? (typeof opts.build === 'string' ? undefined : opts.build);
866
+ const name = nameOf(fingerprint, buildId);
867
+
868
+ // Already the standard: say so and change nothing. A release script that runs twice, or a
869
+ // git hook that fires on both the tag and the push, must not retire a second round of
870
+ // waivers or write a second entry that makes the history look like two releases.
871
+ if (decision.state === 'already-the-reference') {
872
+ const existing = (await referenceHistory(store, product)).find((c) => c.buildId === buildId);
873
+ if (existing) return { ...existing, unchanged: true };
874
+ }
875
+
876
+ const stability = await measureStability(store, buildId);
877
+ const id = newCutId(opts.at ? new Date(opts.at) : undefined);
878
+ const at = opts.at ?? new Date().toISOString();
879
+
880
+ // The pointer moves first. Everything after this — retiring waivers, writing the log — is
881
+ // bookkeeping about a move that has already happened, and if the process dies between the
882
+ // two the worst case is a reference with a thinner record beside it, never a waiver that
883
+ // outlives the standard it was written against.
884
+ await setReference(store, buildId, {
885
+ product,
886
+ setBy: opts.setBy ?? 'ship',
887
+ note: [opts.why, stability.note].filter(Boolean).join(' — '),
888
+ at,
889
+ });
890
+
891
+ const waivers = await expireWaivers(store, product, id);
892
+
893
+ /** @type {ReferenceCut} */
894
+ const cut = {
895
+ id,
896
+ product,
897
+ buildId,
898
+ at,
899
+ journeys: record?.journeys ?? [],
900
+ stability,
901
+ waiversRetired: waivers.retired,
902
+ summary: '',
903
+ };
904
+ if (fingerprint) cut.build = fingerprint;
905
+ if (opts.setBy) cut.setBy = opts.setBy;
906
+ if (opts.why) cut.why = opts.why;
907
+ if (previous?.buildId) cut.previousBuildId = previous.buildId;
908
+ if (!decision.ok && opts.force === true) {
909
+ cut.forced = true;
910
+ cut.forcedPast = decision.refusal ?? decision.why;
911
+ }
912
+ cut.summary = summarise(cut, name, decision);
913
+
914
+ await appendToLog(store, cut);
915
+ return cut;
916
+ }
917
+
918
+ /**
919
+ * The one line that goes into the closing summary he already reads.
920
+ *
921
+ * @param {ReferenceCut} cut
922
+ * @param {string} name
923
+ * @param {CutDecision} decision
924
+ * @returns {string}
925
+ */
926
+ function summarise(cut, name, decision) {
927
+ const parts = [`${name} is now what ${cut.product} calls working.`];
928
+ if (cut.previousBuildId) parts.push(`It replaces ${cut.previousBuildId}.`);
929
+ else parts.push('Nothing was being compared against before this — from now on it is.');
930
+
931
+ if (cut.stability.measured) {
932
+ parts.push(
933
+ cut.stability.unstable === 0
934
+ ? cut.stability.steady === 1
935
+ ? 'The one address it was watched at answered the same way twice.'
936
+ : `All ${cut.stability.steady} addresses it was watched at answered the same way twice.`
937
+ : `${cut.stability.unstable} of the ${cut.stability.paths} ${plural(cut.stability.paths, 'address', 'addresses')} it was watched at ${plural(cut.stability.unstable, 'was', 'were')} already unpredictable, and that is written down so nothing blames a future change for ${plural(cut.stability.unstable, 'it', 'them')}.`
938
+ );
939
+ } else {
940
+ parts.push('It carries no steadiness record, so "this used to be steady and now it wobbles" cannot be reported against it.');
941
+ }
942
+
943
+ if (cut.waiversRetired > 0) {
944
+ parts.push(
945
+ `${cut.waiversRetired} ${plural(cut.waiversRetired, 'waiver', 'waivers')} retired — whatever ${plural(cut.waiversRetired, 'it', 'they')} covered has shipped and is simply how the product behaves now.`
946
+ );
947
+ }
948
+ if (decision.waived && decision.waived > 0 && cut.waiversRetired === 0) {
949
+ parts.push(`${decision.waived} waived ${plural(decision.waived, 'difference', 'differences')} became normal with this ship.`);
950
+ }
951
+ if (cut.forced) parts.push('This was FORCED past a refusal, and the refusal is on the record beside it.');
952
+ return parts.join(' ');
953
+ }
954
+
955
+ // ---------------------------------------------------------------------------
956
+ // The log — so a regression can be traced to the release that introduced it
957
+ // ---------------------------------------------------------------------------
958
+
959
+ /**
960
+ * @param {Store} store
961
+ * @param {ReferenceCut} cut
962
+ */
963
+ async function appendToLog(store, cut) {
964
+ const file = fileIn(store, 'reference-log.json');
965
+ /** @type {ReferenceCut[]} */
966
+ const log = await readJson(file, /** @type {ReferenceCut[]} */ ([]));
967
+ const all = [...(Array.isArray(log) ? log : []), cut];
968
+
969
+ if (all.length > MAX_LOG_ENTRIES) {
970
+ const overflow = all.slice(0, all.length - MAX_LOG_ENTRIES);
971
+ const archiveFile = fileIn(store, 'reference-log-archive.json');
972
+ /** @type {ReferenceCut[]} */
973
+ const archive = await readJson(archiveFile, /** @type {ReferenceCut[]} */ ([]));
974
+ await writeJsonAtomic(archiveFile, [...(Array.isArray(archive) ? archive : []), ...overflow]);
975
+ await writeJsonAtomic(file, all.slice(-MAX_LOG_ENTRIES));
976
+ return;
977
+ }
978
+ await writeJsonAtomic(file, all);
979
+ }
980
+
981
+ /**
982
+ * Every reference ever cut for this product, newest first.
983
+ *
984
+ * This is what makes a regression traceable to a release. A difference that appeared
985
+ * between two references narrows the search to the commits between them, which is a
986
+ * different order of problem from "somewhere in the last four months".
987
+ *
988
+ * @param {Store} store
989
+ * @param {string} product
990
+ * @param {{includeArchive?: boolean}} [opts]
991
+ * @returns {Promise<ReferenceCut[]>}
992
+ */
993
+ export async function referenceHistory(store, product, opts = {}) {
994
+ /** @type {ReferenceCut[]} */
995
+ const log = await readJson(fileIn(store, 'reference-log.json'), /** @type {ReferenceCut[]} */ ([]));
996
+ /** @type {ReferenceCut[]} */
997
+ const archive = opts.includeArchive
998
+ ? await readJson(fileIn(store, 'reference-log-archive.json'), /** @type {ReferenceCut[]} */ ([]))
999
+ : [];
1000
+
1001
+ const all = [...(Array.isArray(archive) ? archive : []), ...(Array.isArray(log) ? log : [])];
1002
+ return all
1003
+ .filter((c) => c && c.product === product)
1004
+ .sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0));
1005
+ }
1006
+
1007
+ /**
1008
+ * Which reference is in force, and everything a summary needs to say about it.
1009
+ *
1010
+ * Returns null on a product that has never been shipped with the hook in place. That is the
1011
+ * cold start, it is expected on every existing product, and a caller has to say so out loud
1012
+ * rather than quietly comparing against nothing.
1013
+ *
1014
+ * @param {Store} store
1015
+ * @param {string} product
1016
+ * @returns {Promise<{pointer: ReferencePointer, cut: ReferenceCut|null, note: string}|null>}
1017
+ */
1018
+ export async function currentReference(store, product) {
1019
+ const pointer = await referencePointer(store, product);
1020
+ if (!pointer) return null;
1021
+ const history = await referenceHistory(store, product);
1022
+ const cut = history.find((c) => c.buildId === pointer.buildId) ?? null;
1023
+ const note = cut
1024
+ ? cut.summary
1025
+ : `${product} compares against ${pointer.buildId}, set ${pointer.setAt}${pointer.setBy ? ` by ${pointer.setBy}` : ''}. There is no cut record beside it, so how steady that build was is not known.`;
1026
+ return { pointer, cut, note };
1027
+ }
1028
+
1029
+ /**
1030
+ * Every product this store knows a build of, whether or not it has a reference yet.
1031
+ *
1032
+ * The ship hook uses this to work out which product it is looking at when nobody said, and
1033
+ * a `doctor` or summary uses it to name the products that are not being checked at all.
1034
+ *
1035
+ * @param {Store} store
1036
+ * @returns {Promise<{product: string, hasReference: boolean, builds: number}[]>}
1037
+ */
1038
+ export async function productsKnown(store) {
1039
+ const builds = await listBuilds(store);
1040
+ /** @type {Map<string, {product: string, hasReference: boolean, builds: number}>} */
1041
+ const seen = new Map();
1042
+ for (const record of builds) {
1043
+ const product = record.fingerprint?.product;
1044
+ if (!product) continue;
1045
+ const entry = seen.get(product) ?? { product, hasReference: false, builds: 0 };
1046
+ entry.builds++;
1047
+ if (record.isReference) entry.hasReference = true;
1048
+ seen.set(product, entry);
1049
+ }
1050
+ return [...seen.values()].sort((a, b) => a.product.localeCompare(b.product));
1051
+ }