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/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
- const product = opts.product ?? (await productName(root));
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 this build, so it did not become the reference. Nothing about ${product} is being compared against anything yet.${because}`,
178
- 'Run `staysfixed check` once before the next release and it will record itself from then on.',
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 = `${product} shipped ${release.what}, but Stays Fixed had never seen this build, so what "working" means has not moved.${because} Run a check before the next release.`;
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
- result.summary = `${product} shipped ${release.what}. What "working" means did NOT move: ${decision.why} Future checks still compare against the previous reference.`;
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: opts.why ?? opts.note ?? release.describe,
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
- * Which product is this? The settings file first, because one repo builds five things and
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
- * @param {string} root
527
- * @returns {Promise<string>}
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 productName(root) {
530
- const configFile = findConfigFile(root);
531
- if (configFile && configFile.endsWith('.json')) {
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
- const parsed = JSON.parse(await fsp.readFile(configFile, 'utf8'));
534
- if (typeof parsed?.product === 'string' && parsed.product) return parsed.product;
773
+ builds = await listBuilds(store, { product: other.name });
535
774
  } catch {
536
- // A settings file nobody can parse is somebody else's problem to report. Falling
537
- // through to the package name keeps the release recorded either way.
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
- const pkg = await packageJson(root);
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
- const product = ctx.str('product') ?? (await productName(root));
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
- observations.push(/** @type {Observation} */ (parsed));
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);
@@ -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
- entry.story = (kind === 'guard' && ev.status === 'failed' && ev.because) ? ev.because : '';
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);