staysfixed 0.10.0 → 0.11.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/CHANGELOG.md +125 -0
- package/README.md +17 -5
- package/docs/getting-started.md +10 -0
- package/docs/how-v2-works.md +5 -2
- package/package.json +2 -2
- package/src/guard/api.js +107 -3
- package/src/guard/run.js +154 -20
- package/src/report/console.js +235 -17
- package/src/report/html.js +75 -19
- package/src/types.js +5 -0
- package/src/v2/adapters/android-driver.js +62 -12
- package/src/v2/adapters/contract.js +18 -4
- package/src/v2/adapters/electron.js +96 -14
- package/src/v2/adapters/http.js +264 -23
- package/src/v2/adapters/ios-driver.js +22 -4
- package/src/v2/adapters/ios.js +5 -2
- package/src/v2/adapters/isolate.js +78 -5
- package/src/v2/adapters/process.js +350 -92
- package/src/v2/adapters/web-driver.js +23 -1
- package/src/v2/adapters/web.js +42 -3
- package/src/v2/adapters/windows.js +32 -15
- package/src/v2/check.js +319 -9
- package/src/v2/cli.js +345 -3
- package/src/v2/cluster.js +112 -4
- package/src/v2/coverage.js +208 -8
- package/src/v2/detect.js +182 -9
- package/src/v2/doctor.js +168 -30
- package/src/v2/init.js +88 -10
- package/src/v2/mcp/server.js +4 -1
- package/src/v2/mcp/tools.js +291 -24
- package/src/v2/observation.js +57 -5
- package/src/v2/reference.js +133 -14
- package/src/v2/refusal.js +389 -0
- package/src/v2/remote.js +24 -3
- package/src/v2/run.js +306 -16
- package/src/v2/sealed.js +14 -2
- package/src/v2/ship.js +286 -22
- package/src/v2/store.js +101 -2
- package/src/v2/types.js +5 -0
- package/src/v2/waiver.js +9 -2
- package/src/watch/panel.js +12 -1
package/src/v2/ship.js
CHANGED
|
@@ -44,7 +44,7 @@ import { promisify } from 'node:util';
|
|
|
44
44
|
import { EXIT, messageOf } from '../core/errors.js';
|
|
45
45
|
import { say, warn, ok, blank, heading, setLogLevel } from '../core/log.js';
|
|
46
46
|
import { findConfigFile, rootForConfig } from '../core/paths.js';
|
|
47
|
-
import { openStore, ensureStore, listBuilds } from './store.js';
|
|
47
|
+
import { openStore, ensureStore, listBuilds, listCaptures, latestCapture, productNameFor } from './store.js';
|
|
48
48
|
import { cutReference, shouldCut, referenceHistory, currentReference } from './reference.js';
|
|
49
49
|
|
|
50
50
|
const exec = promisify(execFile);
|
|
@@ -141,7 +141,13 @@ export async function onShip(opts = {}) {
|
|
|
141
141
|
};
|
|
142
142
|
|
|
143
143
|
try {
|
|
144
|
-
|
|
144
|
+
// ONE READER FOR THE NAME, SHARED WITH `check`. Ship used to work this out itself and
|
|
145
|
+
// read the settings only when the file ended in `.json` — and every settings file
|
|
146
|
+
// `staysfixed init` writes is JavaScript, so on a real project the two commands filed
|
|
147
|
+
// under two different names and never met. See `productNameFor` in store.js for the
|
|
148
|
+
// measurement.
|
|
149
|
+
const naming = await productNameFor(root, { product: opts.product });
|
|
150
|
+
const product = naming.name;
|
|
145
151
|
result.product = product;
|
|
146
152
|
|
|
147
153
|
const release = await detectRelease({ root, version: opts.version, tag: opts.tag, build: opts.build });
|
|
@@ -172,13 +178,37 @@ export async function onShip(opts = {}) {
|
|
|
172
178
|
const because = unreadable.length > 0
|
|
173
179
|
? ` ${unreadable.length} of its stored ${unreadable.length === 1 ? 'record' : 'records'} could not be read, which may be why.`
|
|
174
180
|
: '';
|
|
181
|
+
// WHAT THE PRODUCT IS STILL BEING COMPARED AGAINST, asked rather than assumed.
|
|
182
|
+
//
|
|
183
|
+
// This branch used to end every one of its sentences with "Nothing about <product> is
|
|
184
|
+
// being compared against anything yet", whether or not that was true. Measured on
|
|
185
|
+
// 2026-08-31: a project that had shipped once and had a reference sitting in its store
|
|
186
|
+
// was told exactly that after a second release nobody had checked — and the very next
|
|
187
|
+
// `staysfixed check` went on comparing against that reference and would have reported
|
|
188
|
+
// any regression it found. Telling somebody the safety net is off while it is on is the
|
|
189
|
+
// one direction of wrong answer that gets a broken build waved through, because a
|
|
190
|
+
// person who believes nothing is watching stops reading what it says.
|
|
191
|
+
const standing = await standingReference(store, product);
|
|
192
|
+
// WHICH NAME IT LOOKED UNDER, said out loud. "Stays Fixed had never seen this build"
|
|
193
|
+
// was true and useless: it named neither the drawer that was searched nor the fact
|
|
194
|
+
// that another drawer exists. When the settings and package.json disagree about what
|
|
195
|
+
// this product is called, that disagreement IS the answer — `check` files under one
|
|
196
|
+
// and `ship` looked under the other, and a project can sit like that for its whole
|
|
197
|
+
// life without either command mentioning it. Measured 2026-08-31 on a Windows app
|
|
198
|
+
// whose settings said `notepad` and whose package.json said `win-proof`.
|
|
199
|
+
const misnamed = await nameClash(store, naming);
|
|
175
200
|
result.lines = [
|
|
176
201
|
`${product} ${release.describe}`,
|
|
177
|
-
`Stays Fixed has no record of
|
|
178
|
-
|
|
202
|
+
`Stays Fixed has no record of a build of ${product}, so it did not become the reference. ${stillComparedAgainst(standing, product)}${because}`,
|
|
203
|
+
...(misnamed ? [misnamed.line] : []),
|
|
204
|
+
misnamed
|
|
205
|
+
? `Ship under the same name — \`staysfixed ship --product ${misnamed.other}\` — or make the two agree.`
|
|
206
|
+
: 'Run `staysfixed check` once before the next release and it will record itself from then on.',
|
|
179
207
|
...(unreadable.length > 0 ? [`What could not be read: ${unreadable.join('; ')}`] : []),
|
|
180
208
|
];
|
|
181
|
-
result.summary =
|
|
209
|
+
result.summary = misnamed
|
|
210
|
+
? `${product} shipped ${release.what}, but there is no record of a build of ${product}: ${misnamed.short} Nothing has been compared, and nothing will be until the two names agree.`
|
|
211
|
+
: `${product} shipped ${release.what}, but Stays Fixed had never seen this build, so what "working" means has not moved.${because} ${stillComparedAgainst(standing, product)} Run a check before the next release.`;
|
|
182
212
|
return result;
|
|
183
213
|
}
|
|
184
214
|
|
|
@@ -195,14 +225,66 @@ export async function onShip(opts = {}) {
|
|
|
195
225
|
`The reference did NOT move. ${result.refused}`,
|
|
196
226
|
'Your release is unaffected — this only decides what future checks compare against.',
|
|
197
227
|
];
|
|
198
|
-
|
|
228
|
+
// "Still compare against the previous reference" was said whether or not there was a
|
|
229
|
+
// previous one. On a first release that is refused there is none, and the sentence
|
|
230
|
+
// quietly promises a safety net that does not exist yet — the same wrong answer as the
|
|
231
|
+
// no-record branch above, reached the other way round.
|
|
232
|
+
result.summary = `${product} shipped ${release.what}. What "working" means did NOT move: ${decision.why} ${stillComparedAgainst(await standingReference(store, product), product)}`;
|
|
199
233
|
return result;
|
|
200
234
|
}
|
|
201
235
|
|
|
236
|
+
// A RUN THAT OBSERVED NOTHING IS NOT AN ANSWER, and must never become the standard.
|
|
237
|
+
//
|
|
238
|
+
// Everything above asks whether a check CONCLUDED something: was there one, was it
|
|
239
|
+
// blocked, did it leave differences unaccounted for. None of those questions is "did the
|
|
240
|
+
// product actually do anything while it was being watched", and a run where every journey
|
|
241
|
+
// was refused answers all three the way a healthy one does. It was not blocked — it ran to
|
|
242
|
+
// the end. It found no differences — there was nothing to differ.
|
|
243
|
+
//
|
|
244
|
+
// Measured on 2026-08-31 on a three-route server with a `throw` at the top of it, so it
|
|
245
|
+
// could not start. `staysfixed check` correctly recorded three refusals — "not checked, the
|
|
246
|
+
// thing being observed fell over before it could be read" — and `staysfixed ship` answered
|
|
247
|
+
// "1.0.0 is now what poisonshop calls working. All 6 addresses it was watched at answered
|
|
248
|
+
// the same way twice." Two refusals do answer the same way twice.
|
|
249
|
+
//
|
|
250
|
+
// Both halves of what follows were then measured on that same project. Leave the server
|
|
251
|
+
// broken and the next check compares one refusal with the other, finds them equal and says
|
|
252
|
+
// "Nothing that worked has changed. 6 addresses checked" — a clean result about a product
|
|
253
|
+
// that cannot start, which is the one sentence this tool exists never to say. Fix the
|
|
254
|
+
// server and every route it now answers is reported as a difference nobody asked for: 13
|
|
255
|
+
// of them, and a route whose name says money or signing in lands in a class no agent is
|
|
256
|
+
// allowed to wave through, so the phantom goes to a person and stays there.
|
|
257
|
+
const saw = await whatTheRunActuallySaw(store, build.id);
|
|
258
|
+
const nothingWasObserved = saw.refused.length > 0 && saw.walked.length === 0;
|
|
259
|
+
if (nothingWasObserved && opts.force !== true) {
|
|
260
|
+
result.cut = false;
|
|
261
|
+
result.refused = [
|
|
262
|
+
`Refusing to make ${release.what} the standard for ${product}: the run behind it never got the product to do anything.`,
|
|
263
|
+
`All ${saw.refused.length} of the ${plural(saw.refused.length, 'journey', 'journeys')} on record for this build came back refused — it did not start, or could not be reached — so what would be written down as "working" is the words "could not be read", ${saw.refused.length === 1 ? 'once' : `${saw.refused.length} times over`}: ${saw.refused.join(', ')}.`,
|
|
264
|
+
'A reference made of refusals is a reference that says nothing: every later check would find no answer on either side of every address, so nothing about this product would be watched at all, and the run would say so on every line of its coverage list instead of telling you anything.',
|
|
265
|
+
'Get the product running, run `staysfixed check`, and ship again. Or force it, and this refusal is kept on the record beside the reference.',
|
|
266
|
+
].join(' ');
|
|
267
|
+
result.lines = [
|
|
268
|
+
`${product} ${release.describe}`,
|
|
269
|
+
`The reference did NOT move. ${result.refused}`,
|
|
270
|
+
'Your release is unaffected — this only decides what future checks compare against.',
|
|
271
|
+
];
|
|
272
|
+
result.summary = `${product} shipped ${release.what}. What "working" means did NOT move: nothing was actually observed of this build — all ${saw.refused.length} of its ${plural(saw.refused.length, 'journey', 'journeys')} were refused. ${stillComparedAgainst(await standingReference(store, product), product)}`;
|
|
273
|
+
return result;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// A cut forced past the gate above has to SAY it was, on the record and for good.
|
|
277
|
+
// `cutReference` stamps `forced` only when the store's own decision refused, and the store
|
|
278
|
+
// knows nothing about refused journeys — so without this the one cut that most needs a
|
|
279
|
+
// reason beside it would be the one indistinguishable from a healthy release, months later
|
|
280
|
+
// when somebody is asking why the reference is full of "could not be read".
|
|
281
|
+
const why = opts.why ?? opts.note ?? release.describe;
|
|
202
282
|
const cut = await cutReference(store, {
|
|
203
283
|
product,
|
|
204
284
|
build,
|
|
205
|
-
why:
|
|
285
|
+
why: nothingWasObserved
|
|
286
|
+
? `${why} — FORCED: nothing was observed of this build. All ${saw.refused.length} of its ${plural(saw.refused.length, 'journey', 'journeys')} refused (${saw.refused.join(', ')}), so this reference records "could not be read" as what the product does.`
|
|
287
|
+
: why,
|
|
206
288
|
setBy: opts.setBy ?? 'staysfixed ship',
|
|
207
289
|
force: opts.force === true,
|
|
208
290
|
at: opts.at,
|
|
@@ -231,6 +313,23 @@ export async function onShip(opts = {}) {
|
|
|
231
313
|
// ran only once, so part of this reference has no steadiness record behind it.
|
|
232
314
|
...(cut.stability.measuredJourneys < cut.stability.journeys ? [cut.stability.note] : []),
|
|
233
315
|
'Nobody has to approve anything. The next check compares against this.',
|
|
316
|
+
// A reference with holes in it is still worth cutting, and is not worth cutting
|
|
317
|
+
// quietly. A journey that refused is going into the standard as the words "could not be
|
|
318
|
+
// read", so what it does is not in this reference at all, and the day it runs properly
|
|
319
|
+
// every one of its answers is reported as a difference nobody caused. Said here because
|
|
320
|
+
// the coverage caveat below counts doors, not refusals, and these are the ones that will
|
|
321
|
+
// come back as findings rather than as a gap.
|
|
322
|
+
...(saw.refused.length > 0 && saw.walked.length > 0
|
|
323
|
+
? [
|
|
324
|
+
`${saw.refused.length} of ${saw.refused.length + saw.walked.length} ${plural(saw.refused.length + saw.walked.length, 'journey', 'journeys')} refused and ${plural(saw.refused.length, 'is', 'are')} being recorded as part of this reference without having observed anything: ${saw.refused.join(', ')}. What ${plural(saw.refused.length, 'it does is', 'those do is')} not in the standard, so nothing behind ${plural(saw.refused.length, 'it', 'them')} is being watched until ${plural(saw.refused.length, 'it runs', 'they run')} — a check will say so in its coverage list rather than reporting it as a change.`,
|
|
325
|
+
]
|
|
326
|
+
: []),
|
|
327
|
+
// And the forced version of the same thing, said as plainly as it deserves.
|
|
328
|
+
...(nothingWasObserved
|
|
329
|
+
? [
|
|
330
|
+
`This was FORCED. Nothing was observed of this build — all ${saw.refused.length} of its ${plural(saw.refused.length, 'journey', 'journeys')} refused (${saw.refused.join(', ')}) — so what "working" now means for ${product} is the words "could not be read". Until it is shipped again from a run that saw something, a check of this product cannot tell you anything.`,
|
|
331
|
+
]
|
|
332
|
+
: []),
|
|
234
333
|
// Said in the same breath as the good news, exactly as every other surface says it.
|
|
235
334
|
...(missed ? [missed] : []),
|
|
236
335
|
];
|
|
@@ -279,6 +378,114 @@ async function whatTheCheckMissed(store) {
|
|
|
279
378
|
}
|
|
280
379
|
}
|
|
281
380
|
|
|
381
|
+
/**
|
|
382
|
+
* The channels only a running product can fill.
|
|
383
|
+
*
|
|
384
|
+
* The same line coverage.js draws, one notch further along. `contract` is the code being
|
|
385
|
+
* READ — routes and channels listed out of the source — and a door read out of a file has
|
|
386
|
+
* never been opened, so it can never be the evidence that anything ran. `counters` is
|
|
387
|
+
* arithmetic done on whatever was found, including on the contract, so it cannot be that
|
|
388
|
+
* evidence either: a source-only walk of this very repository files a `counters` observation
|
|
389
|
+
* saying it read two environment variables, and nothing was started.
|
|
390
|
+
*
|
|
391
|
+
* What is left is the product doing something where somebody could watch: what it gave back,
|
|
392
|
+
* what it printed, what it changed, what it drew, what a screen reader would read.
|
|
393
|
+
*/
|
|
394
|
+
const CHANNELS_ONLY_A_RUNNING_PRODUCT_FILLS = new Set(['meaning', 'effects', 'complaints', 'results', 'pixels']);
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Which of this build's journeys really watched the product, and which only refused.
|
|
398
|
+
*
|
|
399
|
+
* A journey counts as WALKED when its newest stored recording holds at least one observation
|
|
400
|
+
* that a running product had to produce and that was not refused. It counts as REFUSED when
|
|
401
|
+
* it holds observations of that kind and every one of them is a refusal — the adapter was
|
|
402
|
+
* asked, and said it could not. A journey with neither — the source reader, which only lists
|
|
403
|
+
* doors — is in neither list, because it is neither evidence that the product ran nor
|
|
404
|
+
* evidence that it would not.
|
|
405
|
+
*
|
|
406
|
+
* The newest recording per journey is the one read, because the newest recording per journey
|
|
407
|
+
* is what a later check compares against. Reading them all would say something truer about
|
|
408
|
+
* history and nothing truer about what this reference is going to mean.
|
|
409
|
+
*
|
|
410
|
+
* It never throws. A record that will not open leaves the journey out of both lists, which
|
|
411
|
+
* lands on the behaviour this file had before — the cut goes ahead — rather than turning a
|
|
412
|
+
* damaged file into a blocked release.
|
|
413
|
+
*
|
|
414
|
+
* @param {Store} store
|
|
415
|
+
* @param {string} buildId
|
|
416
|
+
* @returns {Promise<{walked: string[], refused: string[]}>} Journey names, sorted.
|
|
417
|
+
*/
|
|
418
|
+
async function whatTheRunActuallySaw(store, buildId) {
|
|
419
|
+
/** @type {string[]} */
|
|
420
|
+
const walked = [];
|
|
421
|
+
/** @type {string[]} */
|
|
422
|
+
const refused = [];
|
|
423
|
+
try {
|
|
424
|
+
const refs = await listCaptures(store, { buildId });
|
|
425
|
+
for (const journey of [...new Set(refs.map((r) => r.journey))].sort()) {
|
|
426
|
+
const capture = await latestCapture(store, { buildId, journey });
|
|
427
|
+
if (!capture) continue;
|
|
428
|
+
const fromTheProduct = capture.observations.filter((o) => CHANNELS_ONLY_A_RUNNING_PRODUCT_FILLS.has(o.channel));
|
|
429
|
+
if (fromTheProduct.length === 0) continue;
|
|
430
|
+
if (fromTheProduct.some((o) => o.meta?.refused !== true)) walked.push(journey);
|
|
431
|
+
else refused.push(journey);
|
|
432
|
+
}
|
|
433
|
+
} catch {
|
|
434
|
+
// See above: what could not be read is left out, never guessed at in either direction.
|
|
435
|
+
}
|
|
436
|
+
return { walked, refused };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* What this product currently compares against, or an honest admission that we cannot tell.
|
|
441
|
+
*
|
|
442
|
+
* Three answers, not two. 'none' and a real reference are the easy ones; a store that will
|
|
443
|
+
* not open is the third, and collapsing it into 'none' is what produced the sentence this
|
|
444
|
+
* helper exists to stop — a confident "nothing is being compared" from a reader that never
|
|
445
|
+
* managed to look.
|
|
446
|
+
*
|
|
447
|
+
* @param {Store} store
|
|
448
|
+
* @param {string} product
|
|
449
|
+
* @returns {Promise<{name: string, at: string}|'none'|'unknown'>}
|
|
450
|
+
*/
|
|
451
|
+
async function standingReference(store, product) {
|
|
452
|
+
try {
|
|
453
|
+
const current = await currentReference(store, product);
|
|
454
|
+
if (!current) return 'none';
|
|
455
|
+
return {
|
|
456
|
+
name: current.cut?.build?.version ?? current.pointer.buildId,
|
|
457
|
+
at: current.pointer.setAt.slice(0, 10),
|
|
458
|
+
};
|
|
459
|
+
} catch {
|
|
460
|
+
return 'unknown';
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* One sentence saying whether the safety net is on, for a release that did not move it.
|
|
466
|
+
*
|
|
467
|
+
* @param {{name: string, at: string}|'none'|'unknown'} standing
|
|
468
|
+
* @param {string} product
|
|
469
|
+
* @returns {string}
|
|
470
|
+
*/
|
|
471
|
+
function stillComparedAgainst(standing, product) {
|
|
472
|
+
if (standing === 'none') return `Nothing about ${product} is being compared against anything yet.`;
|
|
473
|
+
if (standing === 'unknown') {
|
|
474
|
+
return `What ${product} compares against could not be read just now, so this cannot say whether a standard is in place — check with \`staysfixed ship --history\` before trusting either answer.`;
|
|
475
|
+
}
|
|
476
|
+
return `Checks of ${product} go on comparing against ${standing.name}, the build shipped on ${standing.at} — that is still the standard, so a regression this release introduced would be reported rather than adopted.`;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* @param {number} n
|
|
481
|
+
* @param {string} one
|
|
482
|
+
* @param {string} many
|
|
483
|
+
* @returns {string}
|
|
484
|
+
*/
|
|
485
|
+
function plural(n, one, many) {
|
|
486
|
+
return n === 1 ? one : many;
|
|
487
|
+
}
|
|
488
|
+
|
|
282
489
|
// ---------------------------------------------------------------------------
|
|
283
490
|
|
|
284
491
|
/**
|
|
@@ -519,27 +726,66 @@ function projectRoot(from) {
|
|
|
519
726
|
return config ? rootForConfig(config) : start;
|
|
520
727
|
}
|
|
521
728
|
|
|
729
|
+
/*
|
|
730
|
+
* `productName` used to live here and is gone on purpose. It read `product` out of the
|
|
731
|
+
* settings only when the file ended in `.json`, which is a shape `staysfixed init` never
|
|
732
|
+
* writes, so ship and check named the same product two different things and filed into two
|
|
733
|
+
* drawers that never meet. The one reader both commands use is `productNameFor` in store.js,
|
|
734
|
+
* next to the keys it decides — see the measurement written out there.
|
|
735
|
+
*/
|
|
736
|
+
|
|
522
737
|
/**
|
|
523
|
-
*
|
|
524
|
-
* only the settings file knows what they are called.
|
|
738
|
+
* Is there a record of this build sitting under a DIFFERENT name for the same folder?
|
|
525
739
|
*
|
|
526
|
-
*
|
|
527
|
-
*
|
|
740
|
+
* Asked only when nothing was found under the name we resolved, and it answers the one
|
|
741
|
+
* question the old "Stays Fixed had never seen this build" never did: whether the build is
|
|
742
|
+
* missing or merely filed elsewhere. A project whose settings and package.json disagree hits
|
|
743
|
+
* this on every release and on every check, forever, and nothing else in the tool says a word
|
|
744
|
+
* about it.
|
|
745
|
+
*
|
|
746
|
+
* It reads the store and nothing else — no fingerprinting, no git — because the point is to
|
|
747
|
+
* name the clash, not to bless anything under a name nobody asked for. Cutting the reference
|
|
748
|
+
* under the other name would be the tool choosing what a product is called, and that is a
|
|
749
|
+
* decision it does not get to make.
|
|
750
|
+
*
|
|
751
|
+
* @param {Store} store
|
|
752
|
+
* @param {{name: string, settings: string|null, package: string|null, configFile: string|null}} naming
|
|
753
|
+
* @returns {Promise<{other: string, line: string, short: string}|null>}
|
|
528
754
|
*/
|
|
529
|
-
async function
|
|
530
|
-
|
|
531
|
-
|
|
755
|
+
async function nameClash(store, naming) {
|
|
756
|
+
/** @type {{name: string, where: string}[]} */
|
|
757
|
+
const others = [];
|
|
758
|
+
if (naming.settings && naming.settings !== naming.name) {
|
|
759
|
+
others.push({ name: naming.settings, where: `your settings file${naming.configFile ? ` (${path.basename(naming.configFile)})` : ''} calls this product` });
|
|
760
|
+
}
|
|
761
|
+
if (naming.package && naming.package !== naming.name && !others.some((o) => o.name === naming.package)) {
|
|
762
|
+
others.push({ name: naming.package, where: 'package.json calls this product' });
|
|
763
|
+
}
|
|
764
|
+
const here = path.basename(path.resolve(store.root));
|
|
765
|
+
if (here !== naming.name && !others.some((o) => o.name === here)) {
|
|
766
|
+
others.push({ name: here, where: 'this folder is called' });
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
for (const other of others) {
|
|
770
|
+
/** @type {{fingerprint: BuildFingerprint}[]} */
|
|
771
|
+
let builds = [];
|
|
532
772
|
try {
|
|
533
|
-
|
|
534
|
-
if (typeof parsed?.product === 'string' && parsed.product) return parsed.product;
|
|
773
|
+
builds = await listBuilds(store, { product: other.name });
|
|
535
774
|
} catch {
|
|
536
|
-
// A
|
|
537
|
-
//
|
|
775
|
+
// A store that will not list is not this function's problem to report; the caller has
|
|
776
|
+
// already said what it could not read.
|
|
777
|
+
continue;
|
|
538
778
|
}
|
|
779
|
+
if (builds.length === 0) continue;
|
|
780
|
+
return {
|
|
781
|
+
other: other.name,
|
|
782
|
+
line:
|
|
783
|
+
`There ${builds.length === 1 ? 'IS 1 stored build' : `ARE ${builds.length} stored builds`} here, filed under "${other.name}" — ${other.where} that. ` +
|
|
784
|
+
`\`staysfixed check\` records under one name and this release looked under "${naming.name}", so the two never meet: every check says there is nothing on record as working, and every ship says it has never seen the build.`,
|
|
785
|
+
short: `${builds.length} ${builds.length === 1 ? 'build is' : 'builds are'} on record under "${other.name}" instead — ${other.where} that, and check files under it.`,
|
|
786
|
+
};
|
|
539
787
|
}
|
|
540
|
-
|
|
541
|
-
if (typeof pkg?.name === 'string' && pkg.name) return pkg.name;
|
|
542
|
-
return path.basename(root);
|
|
788
|
+
return null;
|
|
543
789
|
}
|
|
544
790
|
|
|
545
791
|
/**
|
|
@@ -741,7 +987,10 @@ export async function run(ctx) {
|
|
|
741
987
|
*/
|
|
742
988
|
async function printHistory(ctx, root, asJson) {
|
|
743
989
|
const store = openStore({ root });
|
|
744
|
-
|
|
990
|
+
// The same one reader as the ship path itself. `staysfixed ship --history` used to work the
|
|
991
|
+
// name out separately, so on a project whose settings are JavaScript it printed the history
|
|
992
|
+
// of a product nothing had ever been filed under and answered "no references yet".
|
|
993
|
+
const product = (await productNameFor(root, { product: ctx.str('product') ?? undefined })).name;
|
|
745
994
|
const history = await referenceHistory(store, product, { includeArchive: true });
|
|
746
995
|
const current = await currentReference(store, product);
|
|
747
996
|
|
|
@@ -751,6 +1000,16 @@ async function printHistory(ctx, root, asJson) {
|
|
|
751
1000
|
}
|
|
752
1001
|
|
|
753
1002
|
if (history.length === 0) {
|
|
1003
|
+
// An empty log is not the same fact as an empty store. The pointer is what a check
|
|
1004
|
+
// actually reads, and the two can come apart — a log truncated by hand, a store restored
|
|
1005
|
+
// without it. Announcing "there is nothing to compare any build against" while the
|
|
1006
|
+
// pointer sits there is the same lie the ship summary used to tell, one command over.
|
|
1007
|
+
if (current) {
|
|
1008
|
+
warn(`${product} has no record of when its reference was cut, so this cannot list the history.`);
|
|
1009
|
+
say(current.note);
|
|
1010
|
+
say('Checks are still comparing against that build. Ship once more and the history starts again from there.');
|
|
1011
|
+
return EXIT.ok;
|
|
1012
|
+
}
|
|
754
1013
|
warn(`${product} has never had a reference cut, so there is nothing to compare any build against yet.`);
|
|
755
1014
|
say('Ship once with `staysfixed ship` at the end of your release script and the next check has a standard to work from.');
|
|
756
1015
|
return EXIT.ok;
|
|
@@ -761,6 +1020,11 @@ async function printHistory(ctx, root, asJson) {
|
|
|
761
1020
|
const marker = current?.pointer.buildId === cut.buildId ? '→ ' : ' ';
|
|
762
1021
|
say(`${marker}${cut.at.slice(0, 16).replace('T', ' ')} ${cut.build?.version ?? cut.buildId}${cut.forced ? ' (FORCED)' : ''}`);
|
|
763
1022
|
say(` ${cut.summary}`);
|
|
1023
|
+
// The reason the cut was made, which the summary never carries. It holds the person's own
|
|
1024
|
+
// words about the release, and — the case this is here for — the sentence saying a cut was
|
|
1025
|
+
// forced past a run that observed nothing. Dropping it left the only surface that answers
|
|
1026
|
+
// "why is this the standard" unable to answer it.
|
|
1027
|
+
if (cut.why && cut.why.trim() && cut.why.trim() !== cut.summary.trim()) say(` Why: ${cut.why.trim()}`);
|
|
764
1028
|
}
|
|
765
1029
|
return EXIT.ok;
|
|
766
1030
|
}
|
package/src/v2/store.js
CHANGED
|
@@ -33,9 +33,10 @@ import fs from 'node:fs';
|
|
|
33
33
|
import fsp from 'node:fs/promises';
|
|
34
34
|
import path from 'node:path';
|
|
35
35
|
import crypto from 'node:crypto';
|
|
36
|
-
import { safeName } from '../core/paths.js';
|
|
36
|
+
import { safeName, findConfigFile } from '../core/paths.js';
|
|
37
37
|
import { StaysFixedError } from '../core/errors.js';
|
|
38
38
|
import { sortObservations } from './observation.js';
|
|
39
|
+
import { asMarkedValue } from './refusal.js';
|
|
39
40
|
|
|
40
41
|
/**
|
|
41
42
|
* @typedef {import('./types.js').Store} Store
|
|
@@ -82,6 +83,92 @@ function buildDir(store, buildId) {
|
|
|
82
83
|
return path.join(store.buildsDir, safeName(buildId));
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// What this product is called — the key everything in here is filed under
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Which product is this?
|
|
92
|
+
*
|
|
93
|
+
* IT LIVES HERE BECAUSE THE NAME IS THE STORE'S KEY. References are filed under it, builds
|
|
94
|
+
* are listed by it, and two commands that work it out two different ways do not disagree
|
|
95
|
+
* politely — they file into two different drawers and never meet again.
|
|
96
|
+
*
|
|
97
|
+
* Which is exactly what happened. Until 2026-08-31 `staysfixed ship` read `product` out of
|
|
98
|
+
* the settings file ONLY when that file ended in `.json`, and every settings file
|
|
99
|
+
* `staysfixed init` writes is JavaScript. So on a real project — measured driving a native
|
|
100
|
+
* Windows app over ssh — the settings said `product: "notepad"`, package.json said
|
|
101
|
+
* `"win-proof"`, `check` recorded the run under `notepad`, `ship` blessed under `win-proof`
|
|
102
|
+
* and answered "Stays Fixed had never seen this build", and every later check answered
|
|
103
|
+
* "no build of notepad is on record as working" and exited 2. Forever: the project could
|
|
104
|
+
* ship and check for its whole life and never once compare anything, with nothing anywhere
|
|
105
|
+
* saying the two names disagreed. `ship --product notepad` cut the reference instantly,
|
|
106
|
+
* which is the proof that the name was the whole cause.
|
|
107
|
+
*
|
|
108
|
+
* The order is the one `check` has always used: what the caller was told, then the settings
|
|
109
|
+
* file, then package.json, then the folder. Everything a caller needs to EXPLAIN the answer
|
|
110
|
+
* comes back too, because "no record of this build" was true and useless — "no record of a
|
|
111
|
+
* build of win-proof; your settings call this product notepad" names the bug in one line.
|
|
112
|
+
*
|
|
113
|
+
* It never throws. A settings file that will not load leaves `settings` null and the answer
|
|
114
|
+
* falls through, which is the behaviour both callers had before.
|
|
115
|
+
*
|
|
116
|
+
* @param {string} root
|
|
117
|
+
* @param {{product?: string, configFile?: string|null}} [opts]
|
|
118
|
+
* @returns {Promise<{name: string, from: 'told'|'settings'|'package'|'folder', settings: string|null, package: string|null, configFile: string|null}>}
|
|
119
|
+
*/
|
|
120
|
+
export async function productNameFor(root, opts = {}) {
|
|
121
|
+
const configFile = opts.configFile ?? findConfigFile(root) ?? null;
|
|
122
|
+
const settings = await productInSettings(configFile);
|
|
123
|
+
const pkg = await nameInPackage(root);
|
|
124
|
+
const told = typeof opts.product === 'string' && opts.product ? opts.product : null;
|
|
125
|
+
const name = told ?? settings ?? pkg ?? path.basename(path.resolve(root));
|
|
126
|
+
/** @type {'told'|'settings'|'package'|'folder'} */
|
|
127
|
+
const from = told ? 'told' : settings ? 'settings' : pkg ? 'package' : 'folder';
|
|
128
|
+
return { name, from, settings, package: pkg, configFile };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The `product` field out of a settings file of any shape.
|
|
133
|
+
*
|
|
134
|
+
* JSON is parsed and JavaScript is imported, which is what `check` does and always did. The
|
|
135
|
+
* import is the half that was missing from ship: `.js` and `.mjs` are the two shapes
|
|
136
|
+
* `staysfixed init` writes, so a JSON-only reader covers essentially no real project.
|
|
137
|
+
*
|
|
138
|
+
* @param {string|null} configFile
|
|
139
|
+
* @returns {Promise<string|null>}
|
|
140
|
+
*/
|
|
141
|
+
async function productInSettings(configFile) {
|
|
142
|
+
if (!configFile) return null;
|
|
143
|
+
try {
|
|
144
|
+
if (configFile.endsWith('.json')) {
|
|
145
|
+
const parsed = JSON.parse(await fsp.readFile(configFile, 'utf8'));
|
|
146
|
+
return typeof parsed?.product === 'string' && parsed.product ? parsed.product : null;
|
|
147
|
+
}
|
|
148
|
+
const module = await import(`file://${configFile}`);
|
|
149
|
+
const raw = module.default ?? module.config ?? module;
|
|
150
|
+
return typeof raw?.product === 'string' && raw.product ? raw.product : null;
|
|
151
|
+
} catch {
|
|
152
|
+
// A settings file nobody can load is somebody else's problem to report — `check` says so
|
|
153
|
+
// loudly about the same file. Falling through keeps the release recorded under SOME name
|
|
154
|
+
// rather than failing somebody's release over their settings.
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* @param {string} root
|
|
161
|
+
* @returns {Promise<string|null>}
|
|
162
|
+
*/
|
|
163
|
+
async function nameInPackage(root) {
|
|
164
|
+
try {
|
|
165
|
+
const pkg = JSON.parse(await fsp.readFile(path.join(root, 'package.json'), 'utf8'));
|
|
166
|
+
return typeof pkg?.name === 'string' && pkg.name ? pkg.name : null;
|
|
167
|
+
} catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
85
172
|
/**
|
|
86
173
|
* A sortable capture id: when it ran, and which of the two runs it was.
|
|
87
174
|
* @param {CaptureRun} run
|
|
@@ -497,7 +584,19 @@ export async function loadCapture(store, where) {
|
|
|
497
584
|
continue;
|
|
498
585
|
}
|
|
499
586
|
if (typeof parsed?.path === 'string' && typeof parsed?.channel === 'string') {
|
|
500
|
-
|
|
587
|
+
// A REFUSAL COMES BACK OFF THE DISK AS A REFUSAL, not as a sentence that happens to
|
|
588
|
+
// begin "not checked —".
|
|
589
|
+
//
|
|
590
|
+
// Every store on every machine holds refusals written as plain strings, because that
|
|
591
|
+
// is what the adapters wrote before there was a kind for them. A string is comparable,
|
|
592
|
+
// and on 2026-08-31 two of them compared equal and a product that threw on its first
|
|
593
|
+
// line came back "Nothing that worked has changed". Marking it here, at the one door
|
|
594
|
+
// every stored observation comes through, means the comparison, the reference and the
|
|
595
|
+
// report all see the same kind whatever age the file is — and nothing on disk is
|
|
596
|
+
// rewritten, so a store stays readable by an older copy of the tool.
|
|
597
|
+
const observation = /** @type {Observation} */ (parsed);
|
|
598
|
+
const marked = asMarkedValue(observation.value);
|
|
599
|
+
observations.push(marked === observation.value ? observation : { ...observation, value: marked });
|
|
501
600
|
} else {
|
|
502
601
|
unreadable++;
|
|
503
602
|
}
|
package/src/v2/types.js
CHANGED
|
@@ -239,6 +239,11 @@
|
|
|
239
239
|
* False when we had no stability record for the reference,
|
|
240
240
|
* so `newlyUnstable` is empty for lack of evidence rather
|
|
241
241
|
* than because nothing became unstable.
|
|
242
|
+
* @property {boolean} [sameBuild] True when the build being checked and the build on record
|
|
243
|
+
* as working are the same build. Nothing has been edited, so
|
|
244
|
+
* the two runs compared are two runs of ONE build and no
|
|
245
|
+
* difference between them can be a change. Everything found
|
|
246
|
+
* is in `noise`, still counted and still named.
|
|
242
247
|
* @property {boolean} [couldNotTell] True when the wobble measurement was too big to be a
|
|
243
248
|
* measurement — the same build answered differently at
|
|
244
249
|
* most of its own addresses, so subtracting it subtracts
|
package/src/v2/waiver.js
CHANGED
|
@@ -174,8 +174,15 @@ const KEEP_EXPIRED = 50;
|
|
|
174
174
|
/**
|
|
175
175
|
* Try to record a difference as intended.
|
|
176
176
|
*
|
|
177
|
+
* `audience` changes no rule and no verdict — every gate below runs identically whoever
|
|
178
|
+
* asked — and reaches exactly one sentence: the sealed-class refusal, which used to open
|
|
179
|
+
* "No agent can wave this through". Read by a person who has just typed `staysfixed waive`
|
|
180
|
+
* that is an answer about somebody else, and it invites the obvious follow-up: fine, but how
|
|
181
|
+
* do I? The rule is that NOBODY waives a sealed class, and it has to say so to whoever is
|
|
182
|
+
* reading. Measured 2026-08-31, when `waive` first became a command a person could type.
|
|
183
|
+
*
|
|
177
184
|
* @param {Store} store
|
|
178
|
-
* @param {{product: string, finding: Finding, why: string, intentId?: string, check?: CheckStamp, guards?: string[], by?: string}} what
|
|
185
|
+
* @param {{product: string, finding: Finding, why: string, intentId?: string, check?: CheckStamp, guards?: string[], by?: string, audience?: 'agent'|'person'}} what
|
|
179
186
|
* @returns {Promise<WaiverDecision>}
|
|
180
187
|
*/
|
|
181
188
|
export async function waive(store, what) {
|
|
@@ -196,7 +203,7 @@ export async function waive(store, what) {
|
|
|
196
203
|
// anything else so that no amount of good paperwork can get a look-in first.
|
|
197
204
|
const sealed = classify(finding, { guards: what.guards ?? [] });
|
|
198
205
|
if (sealed) {
|
|
199
|
-
return { ok: false, gate: 'sealed', say: sayRefusal(sealed, finding), sealed };
|
|
206
|
+
return { ok: false, gate: 'sealed', say: sayRefusal(sealed, finding, what.audience), sealed };
|
|
200
207
|
}
|
|
201
208
|
|
|
202
209
|
const fingerprint = fingerprintFinding(finding);
|
package/src/watch/panel.js
CHANGED
|
@@ -1456,6 +1456,12 @@ const SCRIPT = `
|
|
|
1456
1456
|
if (kind === 'guard') {
|
|
1457
1457
|
if (status === 'passed') return 'still holds';
|
|
1458
1458
|
if (status === 'skipped') return 'left out on purpose';
|
|
1459
|
+
// Three different things wear the status 'failed', and calling all three "broken
|
|
1460
|
+
// again" states as a fact something nobody knows. A guard that ran out of time did
|
|
1461
|
+
// not answer the question, and a guard that asserted nothing never asked it. Saying
|
|
1462
|
+
// a bug is back on a healthy tree is how a person learns to stop reading this panel.
|
|
1463
|
+
if (ev.timedOut) return 'ran out of time, so nobody knows whether that bug is back';
|
|
1464
|
+
if (ev.assertedNothing) return 'asked nothing, so it proved nothing';
|
|
1459
1465
|
return ev.message || 'this one is broken again';
|
|
1460
1466
|
}
|
|
1461
1467
|
switch (status) {
|
|
@@ -1484,6 +1490,8 @@ const SCRIPT = `
|
|
|
1484
1490
|
if (kind === 'guard') {
|
|
1485
1491
|
if (status === 'passed') return 'still holds';
|
|
1486
1492
|
if (status === 'skipped') return 'left out';
|
|
1493
|
+
if (ev.timedOut) return 'no answer';
|
|
1494
|
+
if (ev.assertedNothing) return 'asked nothing';
|
|
1487
1495
|
return 'broken again';
|
|
1488
1496
|
}
|
|
1489
1497
|
switch (status) {
|
|
@@ -3109,7 +3117,10 @@ const SCRIPT = `
|
|
|
3109
3117
|
entry.outText = outcomeText(kind, ev);
|
|
3110
3118
|
if (entry.verdict) entry.verdict.textContent = shortOutcome(kind, ev);
|
|
3111
3119
|
entry.failedAt = (kind === 'guard' && ev.status === 'failed' && ev.failedAt) ? ev.failedAt : '';
|
|
3112
|
-
|
|
3120
|
+
// The story is the story of the BUG this guard exists to catch, and printing it under a
|
|
3121
|
+
// guard that never got an answer says that bug is back. It only belongs under a guard
|
|
3122
|
+
// that actually failed its own claim.
|
|
3123
|
+
entry.story = (kind === 'guard' && ev.status === 'failed' && ev.because && !ev.timedOut && !ev.assertedNothing) ? ev.because : '';
|
|
3113
3124
|
if (typeof ev.durationMs === 'number') tweenTo(entry.time, ev.durationMs, fmt);
|
|
3114
3125
|
entry.tone = tone;
|
|
3115
3126
|
redraw(entry);
|