staysfixed 0.11.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +108 -2
  2. package/README.md +77 -19
  3. package/docs/design-v2.md +8 -7
  4. package/docs/getting-started.md +5 -3
  5. package/docs/guards.md +18 -0
  6. package/docs/how-v2-works.md +43 -10
  7. package/docs/mcp.md +6 -4
  8. package/docs/settings.md +11 -2
  9. package/package.json +1 -1
  10. package/src/cli/approve.js +4 -1
  11. package/src/cli/flake.js +4 -1
  12. package/src/cli/mark.js +5 -1
  13. package/src/cli/status.js +53 -1
  14. package/src/cli/trace.js +27 -2
  15. package/src/core/config.js +136 -25
  16. package/src/core/stop-tree.js +109 -0
  17. package/src/drive/browser.js +20 -31
  18. package/src/drive/page.js +74 -2
  19. package/src/guard/api.js +14 -9
  20. package/src/types.js +1 -1
  21. package/src/v2/adapters/android.js +220 -11
  22. package/src/v2/adapters/child.js +15 -17
  23. package/src/v2/adapters/contract.js +122 -1
  24. package/src/v2/adapters/extension.js +1988 -0
  25. package/src/v2/adapters/http.js +152 -30
  26. package/src/v2/adapters/ios-driver.js +95 -12
  27. package/src/v2/adapters/ios.js +220 -10
  28. package/src/v2/adapters/isolate.js +169 -14
  29. package/src/v2/adapters/linux-driver.js +1028 -0
  30. package/src/v2/adapters/linux.js +1324 -0
  31. package/src/v2/adapters/macos-driver.js +913 -0
  32. package/src/v2/adapters/macos.js +1374 -0
  33. package/src/v2/adapters/process.js +72 -8
  34. package/src/v2/adapters/source.js +254 -7
  35. package/src/v2/adapters/web.js +69 -19
  36. package/src/v2/browsers.js +145 -25
  37. package/src/v2/cause.js +46 -5
  38. package/src/v2/check.js +465 -47
  39. package/src/v2/cli.js +21 -1
  40. package/src/v2/coverage.js +556 -19
  41. package/src/v2/detect.js +742 -42
  42. package/src/v2/doctor.js +125 -18
  43. package/src/v2/escalate.js +57 -11
  44. package/src/v2/init.js +574 -23
  45. package/src/v2/journeys/answers-probe.js +376 -0
  46. package/src/v2/journeys/from-exports.js +456 -0
  47. package/src/v2/journeys/from-suite.js +9 -1
  48. package/src/v2/journeys/index.js +3 -3
  49. package/src/v2/journeys/record-session.js +839 -0
  50. package/src/v2/journeys/record.js +12 -0
  51. package/src/v2/mcp/tools.js +193 -27
  52. package/src/v2/observation.js +145 -0
  53. package/src/v2/run.js +133 -9
  54. package/src/v2/selfcheck.js +297 -11
  55. package/src/v2/store.js +16 -1
  56. package/src/v2/types.js +1 -1
  57. package/src/v2/watch/events.js +6 -0
package/src/v2/run.js CHANGED
@@ -30,7 +30,9 @@ import {
30
30
  subtractWobble,
31
31
  sameValue,
32
32
  indexByPath,
33
- wobbleStorm,
33
+ noAnswerJourneys,
34
+ populationDriftNote,
35
+ wobbleShape,
34
36
  } from './observation.js';
35
37
  // `diffCaptures` is no longer called from here directly. Everything goes through
36
38
  // `compareAnswers`, which is that same comparison with one rule around it: an address where
@@ -268,6 +270,20 @@ export async function runCheck(opts) {
268
270
  const comparedJourneys = [];
269
271
  /** @type {Wobble[]} */
270
272
  const wobbles = [];
273
+ // JOURNEYS THAT PRODUCED NO ANSWER, kept as a list rather than folded into a total.
274
+ //
275
+ // This is the scope fix of 2026-08-31. The storm rule — more addresses wobbled than held
276
+ // still — was asked once, of every journey added together, and on a real Next.js site
277
+ // 179 unsteady addresses out of 2849 is not a storm by any reading. Underneath that
278
+ // total sat four journeys unsteady at 69%, 75% and twice 100% of their own addresses,
279
+ // every one of them printed in the coverage list as "could not be compared... there is
280
+ // no answer here" — while the run exited 0 and said `ok`. A page where every single
281
+ // address disagreed with itself was folded into a pass by the pages that behaved.
282
+ // Reproduced here the same day on a six-page Next.js site with two pages made unsteady
283
+ // on the server, where the browser freeze cannot reach them: four journeys at 61%, 98%,
284
+ // 98% and 100%, one timed-out walk, `ok: true`, exit code 0.
285
+ /** @type {{journey: string, why: string, looked: number}[]} */
286
+ const noAnswer = [];
271
287
  /** @type {Wobble[]} */
272
288
  const referenceWobbles = [];
273
289
  /** @type {string[]} */
@@ -312,11 +328,19 @@ export async function runCheck(opts) {
312
328
  surface: journey.surface,
313
329
  });
314
330
  }
315
- const weather = wobbleStorm(wobble);
316
- if (weather.stormy) {
331
+ // ASKED OF THIS ONE JOURNEY, and the answer is kept. The same call used to happen
332
+ // here and go nowhere but the coverage list: the gap was written, the verdict never
333
+ // read it, and the run went out `ok: true` with "there is no answer here" printed
334
+ // four times inside it. A hole nothing refuses to pass over is a hole nobody acts on.
335
+ for (const dead of noAnswerJourneys([wobble])) {
336
+ noAnswer.push({ journey: journey.describe || journey.name, why: dead.why, looked: dead.looked });
337
+ // A journey that came back with nothing at all already has its own line, three
338
+ // lines above, in words that fit it better. Saying it twice teaches the reader to
339
+ // skim the one list in this tool that must never be skimmed.
340
+ if (dead.looked === 0) continue;
317
341
  gaps.push({
318
342
  what: `"${journey.describe || journey.name}" could not be compared: the new build did not answer it the same way twice.`,
319
- why: weather.why,
343
+ why: dead.why,
320
344
  unlockedBy: 'Run it again on a quiet machine. If it happens twice, something in the product does not survive being started a second time.',
321
345
  surface: journey.surface,
322
346
  });
@@ -488,6 +512,42 @@ export async function runCheck(opts) {
488
512
  // in the gap list is a fact most readers will never meet.
489
513
  /** @type {string[]} */
490
514
  const runNotes = [];
515
+ // THE NUMBER OF ADDRESSES THIS RUN LOOKED AT IS NOT ALLOWED TO MOVE IN SILENCE.
516
+ //
517
+ // Measured 2026-08-31, three checks of one untouched Next.js site minutes apart with
518
+ // nothing edited between them: 2364, 2684 and 2861 addresses looked at, and 0, 179 and
519
+ // 500 of them unsteady. A tool whose whole method is running one thing twice and
520
+ // subtracting what disagrees cannot give three answers to one question and expect to be
521
+ // believed. Part of that spread is the product genuinely wobbling, which is exactly what
522
+ // the measurement is for and is reported as wobble. The ADDRESS COUNT moving by five
523
+ // hundred is not the product: it is the two passes of one build walking over different
524
+ // ground, because a request was cancelled on one of them, a page finished loading on one
525
+ // of them, or the walk ran out of time on one of them. Every one of those already has a
526
+ // line in this list; what had no line at all was the drift itself, so the total simply
527
+ // came out different each run with nothing anywhere saying why.
528
+ //
529
+ // It cannot be made steady from here — the causes are in the walk, not the arithmetic —
530
+ // so it is named, and the count both passes actually reached is named beside it as the
531
+ // number worth quoting. A number that moves and says nothing is worse than a smaller one
532
+ // that is honest.
533
+ const drift = populationDriftNote(wobble);
534
+ if (drift) {
535
+ const worst = wobbles
536
+ .map((w) => ({ journey: w.journey, drifted: wobbleShape(w).drifted }))
537
+ .filter((w) => w.drifted > 0)
538
+ .sort((x, y) => y.drifted - x.drifted);
539
+ const named = worst.slice(0, 4).map((w) => `${w.journey} (${w.drifted})`).join(', ');
540
+ runNotes.push(drift);
541
+ gaps.push({
542
+ what: 'The two runs of the new build did not walk over the same addresses, so the number this run says it looked at will not be the same number next time.',
543
+ why:
544
+ `${drift} Worst in ${named}${worst.length > 4 ? `, and ${worst.length - 4} more` : ''}. ` +
545
+ 'Every address that only one of the two passes reached was never compared with anything, on either side.',
546
+ unlockedBy:
547
+ 'Find what makes an address turn up on one pass and not the other — a request the browser cancels when the page is torn down, a page that only sometimes finishes loading, a walk that runs out of time — and either make it steady or take it out of what is watched. The timeouts and torn walks that cause it are named separately in this same list.',
548
+ });
549
+ }
550
+
491
551
  const kept = await remember(opts, walked);
492
552
  if (kept.why) {
493
553
  runNotes.push(
@@ -798,6 +858,16 @@ export async function runCheck(opts) {
798
858
  subtraction.newlyUnstable.length === 0 &&
799
859
  subtraction.couldNotTell !== true &&
800
860
  answersLost === 0 &&
861
+ // AND NOT ONE JOURNEY MAY HAVE COME BACK WITH NO ANSWER. `couldNotTell` above is
862
+ // this same law asked of the whole run added together, and adding is what hid it:
863
+ // 179 unsteady addresses out of 2849 is not a storm, and four of that run's twelve
864
+ // journeys were unsteady at 69%, 75% and twice 100% of their own. Whatever those
865
+ // four disagreed with themselves about was dropped before it could be compared, so
866
+ // the quiet underneath them is the quiet of nothing having been looked at — and it
867
+ // was being counted towards a pass by the journeys that behaved. Measured on a real
868
+ // Next.js site, 2026-08-31, where the run exited 0 and printed `ok` with "there is
869
+ // no answer here" written inside it four times.
870
+ noAnswer.length === 0 &&
801
871
  // AND SOMETHING HAS TO HAVE BEEN COMPARED. There is already a branch above for the
802
872
  // case where no journey had an old-build side at all; this is the same law one notch
803
873
  // finer, for the run where every journey HAD a record and every address in it holds
@@ -825,6 +895,12 @@ export async function runCheck(opts) {
825
895
  // for as long as it did is that the silence looked exactly like agreement.
826
896
  unanswered: uncompared.length,
827
897
  lost: answersLost,
898
+ // Named, not counted. "4 journeys could not be compared" sends the reader to a
899
+ // list of thirty-odd coverage lines to find out which four; the names cost one
900
+ // line and are what somebody acts on. `looked` rides along because a journey that
901
+ // disagreed with itself and a journey that saw nothing at all are two different
902
+ // pieces of news and must not be described in one borrowed sentence.
903
+ noAnswer: noAnswer.map((d) => ({ journey: d.journey, looked: d.looked })),
828
904
  }),
829
905
  startedAt,
830
906
  started,
@@ -1304,15 +1380,44 @@ function warningGaps(mode, provedLive) {
1304
1380
  * @param {BuildFingerprint} reference
1305
1381
  * @param {boolean} provedLive
1306
1382
  * @param {number} dropped Suspicions the old build turned out to have as well.
1307
- * @param {{compared: number, asked: number, addresses: number, unanswered?: number, lost?: number}} how
1383
+ * @param {{compared: number, asked: number, addresses: number, unanswered?: number, lost?: number, noAnswer?: {journey: string, looked: number}[]}} how
1308
1384
  * How much of the run this sentence covers: journeys that were really put beside the old
1309
- * build, journeys asked for, the addresses really compared, and the addresses that could
1310
- * not be compared because one side of them was a refusal rather than an answer.
1385
+ * build, journeys asked for, the addresses really compared, the addresses that could not
1386
+ * be compared because one side of them was a refusal rather than an answer, and the
1387
+ * journeys that produced no answer at all because the new build would not answer them the
1388
+ * same way twice.
1311
1389
  * @returns {string}
1312
1390
  */
1313
1391
  function summarise(findings, subtraction, warning, notes, reference, provedLive, dropped, how) {
1314
1392
  const against = provedLive ? `${nameOf(reference)}, run live` : `the stored record of ${nameOf(reference)}`;
1315
1393
  const parts = [];
1394
+ // FIRST, ALWAYS, AND BEFORE THE HEADLINE. A journey with no answer in it is the one thing
1395
+ // that must not be reachable by reading one more sentence: the headline is all some
1396
+ // readers get, and "Nothing that worked has changed" sitting on top of four journeys that
1397
+ // were never compared is the exact false all-clear this whole tool exists to refuse.
1398
+ // Until 2026-08-31 that sentence was printed, `ok` was true, and the run exited 0.
1399
+ const dead = how.noAnswer ?? [];
1400
+ if (dead.length > 0) {
1401
+ const names = dead.map((d) => d.journey);
1402
+ const empty = dead.filter((d) => d.looked === 0).length;
1403
+ const stormy = dead.length - empty;
1404
+ const because = [];
1405
+ if (stormy > 0) {
1406
+ because.push(
1407
+ `The new build disagreed with itself about most of what ${stormy === dead.length ? plural(stormy, 'that journey looks', 'those journeys look') : `${stormy} of them look`} at, so almost everything ${plural(stormy, 'it', 'they')} saw was dropped before it could be compared with anything.`,
1408
+ );
1409
+ }
1410
+ if (empty > 0) {
1411
+ because.push(
1412
+ `${empty === dead.length ? `${plural(empty, 'It was', 'They were')}` : `${empty} of them ${plural(empty, 'was', 'were')}`} walked twice and came back with nothing at all to look at.`,
1413
+ );
1414
+ }
1415
+ parts.push(
1416
+ `NO ANSWER FOR ${dead.length} OF THE ${how.asked} ${plural(how.asked, 'JOURNEY', 'JOURNEYS')} HERE: ${names.slice(0, 4).join(', ')}${names.length > 4 ? `, and ${names.length - 4} more` : ''}. ` +
1417
+ `${because.join(' ')} ` +
1418
+ `The quiet underneath ${plural(dead.length, 'it', 'them')} is the quiet of nothing having been looked at, not of nothing having changed. This is not a pass and not a failure, and the journeys that did answer do not make it one.`,
1419
+ );
1420
+ }
1316
1421
  // How much of the run this sentence is actually about. A run that compared four of its
1317
1422
  // seventeen journeys is not a run that found nothing; it is a run that mostly did not look,
1318
1423
  // and the first sentence is the only one some readers get.
@@ -1357,10 +1462,29 @@ function summarise(findings, subtraction, warning, notes, reference, provedLive,
1357
1462
  // paragraphs down in a list.
1358
1463
  const n = how.lost ?? 0;
1359
1464
  parts.push(
1360
- `Nothing that COULD be compared has changed — but ${n} ${plural(n, 'address', 'addresses')} the old build answers at could not be answered by this build at all, so ${plural(n, 'it was', 'they were')} not compared. That is coverage this build has taken away, and it is not a pass. ${how.addresses} ${plural(how.addresses, 'address was', 'addresses were')} really put beside ${against}.${reach}`,
1465
+ `THIS BUILD ANSWERS AT FEWER PLACES THAN THE LAST ONE. Nothing that could still be compared has changed — but ${n} ${plural(n, 'address', 'addresses')} the old build answers at could not be answered by this build at all, so ${plural(n, 'it was', 'they were')} not compared. That is coverage this build has taken away, and it is not a pass. ${how.addresses} ${plural(how.addresses, 'address was', 'addresses were')} really put beside ${against}.${reach}`,
1466
+ );
1467
+ } else if (findings.length === 0 && dead.length > 0) {
1468
+ // "Nothing that worked has changed" is not available to a run that could not read part
1469
+ // of itself. What IS true is said instead, with the size of the hole beside it, so the
1470
+ // sentence cannot be quoted as an all-clear by anybody who reads only this far.
1471
+ parts.push(
1472
+ `SOME OF THIS PRODUCT COULD NOT BE READ AT ALL. Nothing that could be compared has changed: ${how.addresses} ${plural(how.addresses, 'address was', 'addresses were')} really put beside ${against} — and the ${plural(dead.length, 'journey', 'journeys')} named above ${plural(dead.length, 'is', 'are')} not among them, so a break inside ${plural(dead.length, 'it', 'them')} would look exactly like this.${reach}`,
1361
1473
  );
1362
1474
  } else if (findings.length === 0) {
1363
- parts.push(`Nothing that worked has changed. ${how.addresses} ${plural(how.addresses, 'address', 'addresses')} checked against ${against}.${reach}`);
1475
+ // A WHOLE JOURNEY WITH NOTHING ON THE OTHER SIDE IS NOT A PASS, and the headline is the
1476
+ // only line some readers get. This happens for real on the day somebody upgrades: the
1477
+ // tool learns to watch something new — calling a library's exported functions, say — and
1478
+ // the record made by the old version has no answers to hold the new ones against. The run
1479
+ // said so, three sentences down, under a headline reading "Nothing that worked has
1480
+ // changed". A broken library upgraded into this state read as clean. Measured 2026-08-31.
1481
+ if (missed > 0) {
1482
+ parts.push(
1483
+ `PART OF THIS RUN HAD NOTHING TO BE COMPARED AGAINST. Nothing that could be compared has changed — but ${missed} of ${how.asked} ${plural(how.asked, 'journey', 'journeys')} had nothing on the old build's side to be held against, so ${plural(missed, 'it was', 'they were')} not checked at all, and a break inside ${plural(missed, 'it', 'them')} would look exactly like this. That is missing coverage rather than a failure — it is what a journey the old build never had looks like, and it is what an upgrade that learned to watch something new looks like. Ship once from a build you are happy with, or run with --paired, and the next check covers ${plural(missed, 'it', 'them')} properly. ${how.addresses} ${plural(how.addresses, 'address was', 'addresses were')} really put beside ${against}.${reach}`,
1484
+ );
1485
+ } else {
1486
+ parts.push(`Nothing that worked has changed. ${how.addresses} ${plural(how.addresses, 'address', 'addresses')} checked against ${against}.${reach}`);
1487
+ }
1364
1488
  } else {
1365
1489
  const sealed = findings.filter((f) => f.sealed).length;
1366
1490
  parts.push(
@@ -6,16 +6,20 @@
6
6
  * as the evidence that it can still go red, and there is no other way to get
7
7
  * that evidence: you cannot test a difference engine by reading it.
8
8
  *
9
- * So this builds nine tiny products, each as a real repository with a working
10
- * commit and an uncommitted change on top - which is exactly the shape of the
11
- * thing an agent points this tool at - runs the engine over each, and fails
12
- * loudly if a break gets through.
9
+ * So this builds a tiny product per case, each as a real repository with a
10
+ * working commit and an uncommitted change on top - which is exactly the shape
11
+ * of the thing an agent points this tool at - runs the engine over each, and
12
+ * fails loudly if a break gets through. A few of them go further and ship,
13
+ * because what "working" MEANS is only ever written down by shipping, and the
14
+ * cases about that record cannot be set up any other way.
13
15
  *
14
- * Six of the nine are breaks that MUST be caught. Three are the other half of
15
- * the same promise, and they matter just as much: pairs that must produce NO
16
- * findings at all. A tool that cries wolf gets switched off, and a tool that is
17
- * switched off catches nothing, so a false alarm fails this run exactly the way
18
- * a miss does.
16
+ * Most are breaks that MUST be caught. The rest are the other half of the same
17
+ * promise, and they matter just as much: pairs that must produce NO findings at
18
+ * all. A tool that cries wolf gets switched off, and a tool that is switched off
19
+ * catches nothing, so a false alarm fails this run exactly the way a miss does.
20
+ * No count is given here on purpose - it was wrong within a fortnight of being
21
+ * written, and a comment that has to be edited every time a case is added is a
22
+ * comment that ends up lying. The list below is the count.
19
23
  *
20
24
  * staysfixed check --selfcheck
21
25
  * node src/v2/selfcheck.js --only rounded --keep
@@ -37,6 +41,11 @@ import { fileURLToPath } from 'node:url';
37
41
  // ever looked in different places, the corpus would be proving something other
38
42
  // than what an agent actually runs, which is worse than having no corpus at all.
39
43
  import { loadEngine } from './mcp/tools.js';
44
+ // Shipping is the one thing in this tool that decides what "working" means, and three of the
45
+ // cases below are about exactly that decision, so they have to make it for real. This is the
46
+ // same function `staysfixed ship` calls; going round it — writing a reference pointer by
47
+ // hand — would prove the engine works against a record no person could ever produce.
48
+ import { onShip } from './ship.js';
40
49
 
41
50
  const run = promisify(execFile);
42
51
 
@@ -71,6 +80,17 @@ const run = promisify(execFile);
71
80
  * @property {Record<string, unknown>[]} [journeys]
72
81
  * A journeys file of its own, for a case about what happens when a journey cannot be
73
82
  * walked. Left out, every case gets the one-step "run it" journey.
83
+ * @property {'the commit that works'|'whatever was shipped'} [compareWith]
84
+ * Which build this case holds the product against. Every case but the ones about what
85
+ * "working" MEANS uses the default and names the commit, because that is the shortest way
86
+ * to a fixture with two builds in it. A case about the record itself cannot: the record is
87
+ * only written by shipping, so those cases ship in `prepare` and then ask the engine the
88
+ * way a person does — by asking nothing, and letting it read what the project calls working.
89
+ * @property {boolean} [paired]
90
+ * Whether to boot the old build and walk it live. True unless a case says otherwise, because
91
+ * it is the strongest comparison there is. A case about a reference that CANNOT honestly be
92
+ * booted has to say false: asking for a paired run against one is refused outright, which is
93
+ * the right answer to give a person and a useless one to write a corpus case against.
74
94
  * @property {(dir: string, working: string) => Promise<{ready: boolean, why: string}>} [prepare]
75
95
  * Bend the machine around the product before the engine runs — take away permission to
76
96
  * write, for instance. Answering `ready: false` means this machine cannot be made to do
@@ -573,8 +593,271 @@ export const CASES = [
573
593
  'report.js': `console.log('${broken ? 'three' : 'two'} orders');\n`,
574
594
  }),
575
595
  },
596
+
597
+ {
598
+ name: 'a product that never started is not a product that has not changed',
599
+ breaks:
600
+ 'Nothing runs at all. This product throws on its first line, prints nothing and stops with a failure code, and it does it on BOTH builds — which is the ordinary shape of a project somebody is halfway through fixing. What the walk then wrote down was a stack trace and an exit code: two facts, both true, both about the crash rather than about the product. Two builds that crash the same way agree at every one of those addresses, and agreement is the silence this tool reads as "nothing changed". Measured on 2026-08-31 it came back "Nothing that worked has changed. 7 addresses checked" about a product that had done nothing whatsoever, `ship` blessed it as the standard, and fixing the product the next day produced four findings nobody had caused — every real value differing from the stored crash.',
601
+ expect: 'no answer',
602
+ // The word that has to be in the closing paragraph. A run may perfectly well come back
603
+ // with no findings here; what it may never do is present that as a comparison. Saying
604
+ // "refusal" is saying the addresses hold no answer on one side or the other, which is the
605
+ // whole difference between "I looked and found nothing" and "there was nothing to look at".
606
+ summaryMustSay: [/refusal/i],
607
+ build: (broken) => ({
608
+ // Its own package.json, without the `bin` entry every other fixture carries. That entry
609
+ // is a door, the source reader finds it without running anything, and two addresses that
610
+ // compare perfectly well are enough to stop this being a run where NOTHING was compared —
611
+ // which is the exact shape this case is about. The product here does nothing at all, and
612
+ // the fixture has to be a product that offers nothing else either.
613
+ 'package.json': JSON.stringify({ name: 'widget', version: '1.0.0', type: 'module' }, null, 2) + '\n',
614
+ 'cli.js': [
615
+ "import { readFileSync } from 'node:fs';",
616
+ '// The first line, and it throws: the settings file this needs is not there. Nothing',
617
+ '// below it ever runs, on either build.',
618
+ "const settings = JSON.parse(readFileSync('settings.json', 'utf8'));",
619
+ broken ? "console.log('orders: could not be loaded');" : 'console.log(`orders: ${settings.orders}`);',
620
+ '',
621
+ ].join('\n'),
622
+ }),
623
+ },
624
+
625
+ {
626
+ name: 'coverage this build took away is never handed back as a pass',
627
+ breaks:
628
+ 'Two journeys. One of them is untouched and agrees perfectly; the other stops answering, because the program behind it now throws on its first line. So there are no findings — there is nothing at those addresses to differ from — and the honest verdict is that this run is not a pass: an address that used to be watched and cannot be now is coverage this change took away. The engine works that out correctly and then handed it to the bookkeeping, and until 2026-08-31 the bookkeeping ASSIGNED the verdict rather than narrowing it: one line, `ok = no findings and no new wobble`, which threw away every not-a-pass already decided. Accounting may take a pass away. It may never hand one back.',
629
+ expect: 'no answer',
630
+ summaryMustSay: [/coverage this build has taken away/i],
631
+ journeys: [
632
+ {
633
+ name: 'run-it',
634
+ describe: 'Run the main program.',
635
+ source: 'code',
636
+ surface: 'cli',
637
+ steps: [{ act: 'run', run: 'node cli.js', note: 'the part of the product that still works' }],
638
+ },
639
+ {
640
+ name: 'run-the-report',
641
+ describe: 'Run the report.',
642
+ source: 'code',
643
+ surface: 'cli',
644
+ steps: [{ act: 'run', run: 'node report.js', note: 'the part that stops answering' }],
645
+ },
646
+ ],
647
+ build: (broken) => ({
648
+ 'package.json': PKG,
649
+ 'cli.js': "console.log('two orders');\n",
650
+ 'report.js': broken
651
+ ? [
652
+ "import { readFileSync } from 'node:fs';",
653
+ "const rows = JSON.parse(readFileSync('nowhere.json', 'utf8'));",
654
+ 'console.log(`report rows ${rows.length}`);',
655
+ '',
656
+ ].join('\n')
657
+ : "console.log('report rows 3');\n",
658
+ }),
659
+ },
660
+
661
+ {
662
+ name: 'a standard cut from a tree git does not have is never walked out of git',
663
+ breaks:
664
+ 'The product shipped from a working tree with uncommitted changes in it, which is how most things actually ship. The record is filed under a fingerprint of that TREE — an id like `work-76ac0155c8b9`, deliberately not the commit\'s — because the files that were checked are not the files git has. Then somebody threw the uncommitted work away, so the product is the old commit again and a field has gone from what it prints. That is a real regression against what shipped, and the record on disk holds the value it is missing. Until 2026-08-31 a run would export the COMMIT, boot it, call it the old build, see it missing the same field, and drop the finding as something the old build does too. A clean answer, produced by walking a build nobody ever shipped.',
665
+ expect: 'a finding',
666
+ mustSay: [/email/i],
667
+ compareWith: 'whatever was shipped',
668
+ // Not paired, and that is the case rather than a limitation of it. Asking for a paired run
669
+ // against a reference cut from a tree git does not have is refused in one sentence, which
670
+ // is the right answer and not one a corpus can learn anything from. The damage was always
671
+ // done on the ordinary run: the old build gets booted anyway, to prove the differences that
672
+ // survived, and that is the road this walks.
673
+ paired: false,
674
+ prepare: shipFromATreeWithUncommittedWork,
675
+ build: (broken) => ({ 'package.json': PKG, 'cli.js': theReply(broken) }),
676
+ },
677
+
678
+ {
679
+ name: 'with nothing edited, nothing can be a change',
680
+ breaks:
681
+ 'Nothing at all, and there is nothing on the other side of the comparison either: this is the build already on record as working, run again. Measured 2026-08-31 on a stock Next.js app with two pages and a link between them — its own prefetch is started by the browser and cancelled when the page is torn down, so it lands in about four runs in five. Ten checks of that untouched tree, minutes apart, gave three reports of a difference, one report that "the change made something non-deterministic", and six clean ones. Identical bytes, four different answers, and the one thing that was certainly true — that nothing had been edited — was the thing the report denied. A race is no use in a corpus that has to mean the same thing every time it runs, so the fixture stands in for it with something that behaves the same way and never flickers: a local file nobody commits, holding the name of the machine this was set up on. The working tree has it and a checkout of the commit does not, so the address answers one way here and another way there while the build is byte for byte the same build.',
682
+ expect: 'nothing',
683
+ // Both sentences, and each carries half of it. The first is the headline saying what this
684
+ // run really was; the second proves an address DID move and was filed as the build
685
+ // disagreeing with itself rather than dropped. A case that went quiet because nothing
686
+ // happened would pass the first and fail the second, which is the point of having it.
687
+ summaryMustSay: [/compared with itself/i, /answered differently across two runs of one build/i],
688
+ build: () => ({
689
+ 'package.json': PKG,
690
+ // Under `out/`, which the corpus gitignores, so it is in the working tree and not in
691
+ // the commit — exactly like the .env or the machine-local settings file every real
692
+ // project has one of.
693
+ 'out/machine.txt': 'lab-7\n',
694
+ 'cli.js': [
695
+ "import fs from 'node:fs';",
696
+ '// A product that says which machine it was set up on, out of a file nobody commits.',
697
+ "const machine = fs.existsSync('out/machine.txt') ? fs.readFileSync('out/machine.txt', 'utf8').trim() : 'unknown';",
698
+ 'console.log(`machine ${machine}`);',
699
+ "console.log('total 10.005');",
700
+ '',
701
+ ].join('\n'),
702
+ }),
703
+ },
704
+
705
+ {
706
+ name: 'what working means moves only when somebody ships',
707
+ breaks:
708
+ 'Nothing, and the product prints exactly what it printed on the day it shipped. What moved in between is the RECORD: somebody put the shipped build back on another machine and ran an ordinary check, which stored fresh captures of it. The store keeps every capture a build ever produced and the reader took the newest, so that run quietly became the definition of working — and the next check compared today against a stranger\'s afternoon rather than against what was blessed. Only `ship` decides what working means; a record that drifts on its own is that one rule leaking. Measured and fixed 2026-08-31, and the two captures blessed at ship time are written down beside the cut for exactly this reason.',
709
+ expect: 'nothing',
710
+ // Proof that it was quiet for the right reason. Silence over a comparison that never
711
+ // happened is the failure this whole corpus exists to make impossible, so the closing
712
+ // sentence has to name a count of addresses that really were put side by side.
713
+ summaryMustSay: [/addresses checked/i],
714
+ compareWith: 'whatever was shipped',
715
+ paired: false,
716
+ prepare: letTheRecordDriftAfterShipping,
717
+ build: () => ({
718
+ 'package.json': PKG,
719
+ 'cli.js': [
720
+ "import fs from 'node:fs';",
721
+ '// The machine name lives in a file nobody commits, so it can differ from one run of',
722
+ '// this very build to the next without the build itself changing at all.',
723
+ "const machine = fs.existsSync('out/machine.txt') ? fs.readFileSync('out/machine.txt', 'utf8').trim() : 'unknown';",
724
+ 'console.log(`machine ${machine}`);',
725
+ "console.log('two orders');",
726
+ '',
727
+ ].join('\n'),
728
+ }),
729
+ },
576
730
  ];
577
731
 
732
+ /**
733
+ * The reply this product prints, with and without the field that was shipped.
734
+ *
735
+ * One function because two cases need the same two versions of it: the build that is put in
736
+ * the commit, and the build that is really shipped on top of it.
737
+ *
738
+ * @param {boolean} withEmail
739
+ * @returns {string}
740
+ */
741
+ function theReply(withEmail) {
742
+ return [
743
+ 'const person = {',
744
+ ' id: 7,',
745
+ " name: 'Ada',",
746
+ withEmail ? " email: 'ada@example.com'," : null,
747
+ " city: 'London',",
748
+ '};',
749
+ 'console.log(JSON.stringify(person));',
750
+ '',
751
+ ]
752
+ .filter((line) => line !== null)
753
+ .join('\n');
754
+ }
755
+
756
+ /**
757
+ * Ship the working tree, uncommitted changes and all, and then throw the uncommitted work away.
758
+ *
759
+ * The state this leaves behind is an ordinary Tuesday: a release cut from what was actually on
760
+ * disk, and a tree that has since gone back to the commit. What makes it worth a case is that
761
+ * the record and the commit are two different builds, and everything downstream believed
762
+ * otherwise — so the value the record holds gets walked against a build that never had it.
763
+ *
764
+ * `ready: false` where the machine will not perform it. A refused cut is not a failed case: it
765
+ * means this fixture never reached the state the case is about, and calling that a pass would
766
+ * be the corpus telling the kind of lie it exists to catch.
767
+ *
768
+ * @param {string} dir
769
+ * @returns {Promise<{ready: boolean, why: string}>}
770
+ */
771
+ async function shipFromATreeWithUncommittedWork(dir) {
772
+ const engine = await loadEngine();
773
+ if (!engine.parts.check) return { ready: false, why: 'the difference engine is not in this build' };
774
+ const journeys = path.join(dir, 'journeys.json');
775
+
776
+ // Proved, not assumed. The whole case rests on the tree being one git does not have, and a
777
+ // fixture that came out clean would ship a plain commit and test nothing at all.
778
+ const changed = await git(dir, ['status', '--porcelain']);
779
+ if (changed.trim() === '') return { ready: false, why: 'the working tree came out clean, so there was no uncommitted change to ship from' };
780
+
781
+ // One walk, so the store has a record of what this build does. Nothing is compared: there is
782
+ // no reference yet, which is exactly where a project starts.
783
+ try {
784
+ await engine.parts.check({ cwd: dir, configFile: undefined, against: undefined, paired: false, journeys, only: [] });
785
+ } catch (e) {
786
+ return { ready: false, why: `the first walk did not work: ${why(e)}` };
787
+ }
788
+
789
+ /** @type {any} */
790
+ let shipped;
791
+ try {
792
+ // No product name is passed, deliberately. A person runs `staysfixed ship` and nothing
793
+ // else, so the name has to be worked out the same way `check` works it out — and the day
794
+ // those two readers disagreed, a project shipped and checked under different names for its
795
+ // whole life and never compared anything. Letting it derive here keeps that in the corpus.
796
+ shipped = await onShip({ root: dir, note: 'shipped from the working tree' });
797
+ } catch (e) {
798
+ return { ready: false, why: `shipping did not work: ${why(e)}` };
799
+ }
800
+ if (shipped?.cut !== true) return { ready: false, why: `nothing was made the standard, so there is no record to compare against: ${shipped?.summary ?? 'no reason given'}` };
801
+
802
+ // And now somebody throws the uncommitted work away — a discarded stash, a `git checkout .`,
803
+ // an agent tidying up. The product is the commit again, and a field that was in what shipped
804
+ // has gone with it.
805
+ await writeAll(dir, { 'cli.js': theReply(false) });
806
+ return { ready: true, why: '' };
807
+ }
808
+
809
+ /**
810
+ * Ship, then let somebody else run an ordinary check on the very same build.
811
+ *
812
+ * That second run stores fresh captures of the shipped build, and it is the whole case: the
813
+ * store keeps every capture a build ever produced, and the moment the reader takes the newest
814
+ * of them, a stranger's afternoon has quietly replaced what "working" means. Nobody shipped.
815
+ *
816
+ * The last two lines matter as much as the rest. The machine name is put back so the product
817
+ * really is doing what it did on the day it shipped — the case is about the RECORD moving, not
818
+ * the product — and a note file is added so this build is not the reference build itself, which
819
+ * would make the run silent for a completely different and correct reason.
820
+ *
821
+ * @param {string} dir
822
+ * @returns {Promise<{ready: boolean, why: string}>}
823
+ */
824
+ async function letTheRecordDriftAfterShipping(dir) {
825
+ const engine = await loadEngine();
826
+ if (!engine.parts.check) return { ready: false, why: 'the difference engine is not in this build' };
827
+ const journeys = path.join(dir, 'journeys.json');
828
+ /** @param {string} machine */
829
+ const setUpOn = async (machine) => writeAll(dir, { 'out/machine.txt': `${machine}\n` });
830
+
831
+ await setUpOn('lab-7');
832
+ try {
833
+ await engine.parts.check({ cwd: dir, configFile: undefined, against: undefined, paired: false, journeys, only: [] });
834
+ } catch (e) {
835
+ return { ready: false, why: `the walk before shipping did not work: ${why(e)}` };
836
+ }
837
+
838
+ /** @type {any} */
839
+ let shipped;
840
+ try {
841
+ shipped = await onShip({ root: dir, note: 'the build that works' });
842
+ } catch (e) {
843
+ return { ready: false, why: `shipping did not work: ${why(e)}` };
844
+ }
845
+ if (shipped?.cut !== true) return { ready: false, why: `nothing was made the standard, so there is no record to drift: ${shipped?.summary ?? 'no reason given'}` };
846
+
847
+ // Somebody else, on another machine, checks the same build out and runs a check. Nothing
848
+ // about that is unusual and nothing about it is shipping.
849
+ await setUpOn('lab-9');
850
+ try {
851
+ await engine.parts.check({ cwd: dir, configFile: undefined, against: undefined, paired: false, journeys, only: [] });
852
+ } catch (e) {
853
+ return { ready: false, why: `the second walk of the shipped build did not work: ${why(e)}` };
854
+ }
855
+
856
+ await setUpOn('lab-7');
857
+ await fsp.writeFile(path.join(dir, 'NOTES.md'), 'A note about the product. Nothing runs it.\n');
858
+ return { ready: true, why: '' };
859
+ }
860
+
578
861
  /**
579
862
  * Take away the store's permission to be written to, and say honestly when this machine
580
863
  * will not let that happen.
@@ -836,8 +1119,11 @@ async function runOne(check, workDir, c, attempt) {
836
1119
  result = await check({
837
1120
  cwd: dir,
838
1121
  configFile: undefined,
839
- against: working,
840
- paired: true,
1122
+ // Naming the commit is what gives most cases two builds without anybody shipping. A
1123
+ // case about what the RECORD says is working must not name one: it has to be answered
1124
+ // out of the store, which is the only place a person's `staysfixed ship` writes to.
1125
+ against: c.compareWith === 'whatever was shipped' ? undefined : working,
1126
+ paired: c.paired !== false,
841
1127
  journeys: path.join(dir, 'journeys.json'),
842
1128
  only: [],
843
1129
  });
package/src/v2/store.js CHANGED
@@ -33,6 +33,7 @@ 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 { pathToFileURL } from 'node:url';
36
37
  import { safeName, findConfigFile } from '../core/paths.js';
37
38
  import { StaysFixedError } from '../core/errors.js';
38
39
  import { sortObservations } from './observation.js';
@@ -145,7 +146,21 @@ async function productInSettings(configFile) {
145
146
  const parsed = JSON.parse(await fsp.readFile(configFile, 'utf8'));
146
147
  return typeof parsed?.product === 'string' && parsed.product ? parsed.product : null;
147
148
  }
148
- const module = await import(`file://${configFile}`);
149
+ // `pathToFileURL`, rather than gluing `file://` to the front of a path.
150
+ //
151
+ // Measured on a real Windows 11 machine on 2026-08-31, and the answer was not the one
152
+ // expected: `file://C:\Users\me\staysfixed.config.mjs` DOES load, because the URL rules
153
+ // treat a drive letter sitting where the host should be as part of the path and turn the
154
+ // backslashes round. Even a folder with a space in it survives that. So this was not the
155
+ // Windows bug it looked like.
156
+ //
157
+ // It is still wrong, and it is changed for the one shape where the luck runs out: a
158
+ // project on a network share. `\\server\share\app` glued to `file://` becomes
159
+ // `file:////server/share/app`, which names no host and no file; `pathToFileURL` makes it
160
+ // `file://server/share/app`, which is the file. The catch below would have turned that
161
+ // into a silent "no product name in the settings", and the release would have been
162
+ // recorded under the wrong name rather than the one the person wrote down.
163
+ const module = await import(pathToFileURL(configFile).href);
149
164
  const raw = module.default ?? module.config ?? module;
150
165
  return typeof raw?.product === 'string' && raw.product ? raw.product : null;
151
166
  } catch {
package/src/v2/types.js CHANGED
@@ -39,7 +39,7 @@
39
39
 
40
40
  /**
41
41
  * Where an observation came from, when it matters which platform produced it.
42
- * @typedef {'cli'|'library'|'server'|'web'|'electron'|'android'|'ios'|'windows'} Surface
42
+ * @typedef {'cli'|'library'|'server'|'web'|'electron'|'android'|'ios'|'windows'|'linux'|'macos'|'extension'} Surface
43
43
  */
44
44
 
45
45
  // ---------------------------------------------------------------------------
@@ -56,9 +56,12 @@ export const SURFACE_WORDS = Object.freeze({
56
56
  server: 'Server',
57
57
  web: 'Website',
58
58
  electron: 'Desktop app',
59
+ extension: 'Browser extension',
59
60
  android: 'Android phone',
60
61
  ios: 'iPhone',
61
62
  windows: 'Windows app',
63
+ linux: 'Linux desktop app',
64
+ macos: 'Mac app',
62
65
  });
63
66
 
64
67
  /**
@@ -71,9 +74,12 @@ export const SURFACE_NOTES = Object.freeze({
71
74
  server: 'started on its own port, watched through what it answered',
72
75
  web: 'opened in a browser, watched through what the page says its controls do',
73
76
  electron: 'launched with its own data folder, watched through the app and its channels',
77
+ extension: 'loaded into a throwaway browser, watched through its manifest, its own pages, and what it does to somebody else\'s page',
74
78
  android: 'installed on an emulator, watched through what is on the screen',
75
79
  ios: 'installed on a simulator, watched through what is on the screen',
76
80
  windows: 'driven on a real Windows desktop, watched through what is on the screen',
81
+ linux: 'opened on a real Linux desktop, watched through what the accessibility bus says its controls do',
82
+ macos: 'opened in the background on a Mac, watched through what is on the screen',
77
83
  });
78
84
 
79
85
  /**