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