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
|
@@ -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
|
+
}
|