staysfixed 0.7.2 → 0.9.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 (65) hide show
  1. package/CHANGELOG.md +429 -0
  2. package/README.md +193 -57
  3. package/docs/design-v2.md +24 -4
  4. package/docs/getting-started.md +19 -6
  5. package/docs/guards.md +2 -2
  6. package/docs/how-v2-works.md +12 -11
  7. package/docs/mcp.md +17 -8
  8. package/docs/settings.md +564 -0
  9. package/docs/watching.md +10 -4
  10. package/examples/staysfixed.config.electron.js +17 -6
  11. package/examples/staysfixed.config.web.js +22 -5
  12. package/package.json +2 -1
  13. package/src/cli/index.js +55 -46
  14. package/src/cli/status.js +45 -1
  15. package/src/cli/watch-flags.js +54 -0
  16. package/src/core/config.js +54 -3
  17. package/src/core/paths.js +15 -0
  18. package/src/guard/run.js +70 -3
  19. package/src/report/console.js +50 -6
  20. package/src/run.js +11 -0
  21. package/src/types.js +3 -0
  22. package/src/v2/adapters/android-driver.js +6 -1
  23. package/src/v2/adapters/android.js +97 -2
  24. package/src/v2/adapters/child.js +101 -0
  25. package/src/v2/adapters/contract.js +42 -5
  26. package/src/v2/adapters/electron.js +72 -6
  27. package/src/v2/adapters/http.js +18 -11
  28. package/src/v2/adapters/ios-driver.js +64 -14
  29. package/src/v2/adapters/ios.js +247 -25
  30. package/src/v2/adapters/process.js +783 -71
  31. package/src/v2/adapters/python.js +495 -0
  32. package/src/v2/adapters/source.js +373 -18
  33. package/src/v2/adapters/web-driver.js +134 -24
  34. package/src/v2/adapters/web.js +149 -18
  35. package/src/v2/adapters/windows.js +18 -1
  36. package/src/v2/browsers.js +66 -3
  37. package/src/v2/cause.js +61 -17
  38. package/src/v2/check.js +653 -69
  39. package/src/v2/ci.js +130 -35
  40. package/src/v2/cli.js +65 -42
  41. package/src/v2/cluster.js +220 -14
  42. package/src/v2/coverage.js +43 -176
  43. package/src/v2/detect.js +308 -60
  44. package/src/v2/doctor.js +353 -54
  45. package/src/v2/escalate.js +5 -1
  46. package/src/v2/init.js +183 -66
  47. package/src/v2/intent.js +9 -23
  48. package/src/v2/journeys/from-suite.js +336 -30
  49. package/src/v2/journeys/index.js +99 -6
  50. package/src/v2/mcp/tools.js +90 -16
  51. package/src/v2/normalise.js +169 -23
  52. package/src/v2/observation.js +19 -33
  53. package/src/v2/rank.js +216 -23
  54. package/src/v2/reference.js +160 -24
  55. package/src/v2/remote.js +113 -18
  56. package/src/v2/run.js +103 -14
  57. package/src/v2/sealed.js +0 -20
  58. package/src/v2/selfcheck.js +190 -13
  59. package/src/v2/ship.js +55 -5
  60. package/src/v2/store.js +67 -1
  61. package/src/v2/types.js +12 -2
  62. package/src/v2/waiver.js +64 -54
  63. package/src/v2/watch/events.js +60 -215
  64. package/src/v2/watch/focus.js +14 -4
  65. package/src/v2/watch/panel.js +167 -17
package/src/v2/run.js CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  wobbleStorm,
35
35
  } from './observation.js';
36
36
  import { ensureStore, saveBuild, saveCapture, latestCapture, referenceFor, listBuilds } from './store.js';
37
+ import { describeRuleChange } from './normalise.js';
37
38
  import { clusterDifferences } from './cluster.js';
38
39
  import { rankFindings } from './rank.js';
39
40
 
@@ -135,6 +136,10 @@ const VERSION = /** @type {{version?: string}} */ (require('../../package.json')
135
136
  * @property {string} [journey]
136
137
  * @property {string} [run]
137
138
  * @property {number} [count]
139
+ * @property {number} [steady] Only on 'wobble'. Addresses this build answered the same
140
+ * way twice, counted rather than inferred by subtraction.
141
+ * @property {boolean} [measured] Only on 'wobble'. False when the wobble was never taken,
142
+ * which is not the same as a wobble of nothing.
138
143
  * @property {number} [durationMs]
139
144
  * @property {Verdict} [verdict]
140
145
  */
@@ -251,6 +256,11 @@ export async function runCheck(opts) {
251
256
  /** @type {string[]} */
252
257
  const steadyInReference = [];
253
258
  let referenceWobbleMeasured = true;
259
+ // One fact about one pair of rule sets, gathered here and said once at the end.
260
+ /** @type {Set<string>} */
261
+ const rulesMoved = new Set();
262
+ /** @type {string[]} */
263
+ const rulesMovedOn = [];
254
264
 
255
265
  for (const journey of journeys) {
256
266
  stop();
@@ -294,12 +304,22 @@ export async function runCheck(opts) {
294
304
  surface: journey.surface,
295
305
  });
296
306
  }
307
+ // ADDRESSES, not rows. This used to send `a.observations.length`, which counts every
308
+ // observation the adapter wrote down — and two observations at the SAME address are one
309
+ // address written down twice, not two addresses. Every other number on the page is
310
+ // counted by address: `foldCoverage` builds a Set, and the wobble arithmetic indexes by
311
+ // path. So a walk with two duplicate addresses put "18 addresses watched" in the header
312
+ // beside "14 addresses watched" in the coverage ledger and "18 answered the same way
313
+ // twice" for a build that only ever had 14 addresses to answer at. Three numbers about
314
+ // one run, disagreeing, on a page whose entire job is being believed. The duplicates
315
+ // themselves are not swallowed — `duplicateGaps` above reports each one.
316
+ const addresses = new Set(a.observations.map((o) => o.path)).size;
297
317
  say({
298
318
  type: 'journey:done',
299
319
  at: events.elapsed(),
300
320
  journey: journey.name,
301
- count: a.observations.length,
302
- message: `${a.observations.length} ${plural(a.observations.length, 'thing', 'things')} looked at, ${wobble.unstable.length} of which this build cannot answer the same way twice.`,
321
+ count: addresses,
322
+ message: `${addresses} ${plural(addresses, 'thing', 'things')} looked at, ${wobble.unstable.length} of which this build cannot answer the same way twice.`,
303
323
  });
304
324
 
305
325
  if (!reference) continue;
@@ -337,9 +357,8 @@ export async function runCheck(opts) {
337
357
  gaps.push({
338
358
  what: `The old build could not be walked for "${journey.describe || journey.name}", so this was not a paired comparison after all.`,
339
359
  why:
340
- `${nameOf(reference)} was put back on this machine, and then there was nothing there to run: ${
341
- wasA.observations[0]?.meta?.describe ?? 'the adapter could not open it'
342
- }. This usually means the product is BUILT rather than committed — an APK, a .app, a packaged desktop app — and a checkout of the old commit does not contain one.`,
360
+ `${nameOf(reference)} was put back on this machine, and then there was nothing there to run: ${whyNothingRan(wasA)} ` +
361
+ 'This usually means the product is BUILT rather than committed — an APK, a .app, a packaged desktop app — and a checkout of the old commit does not contain one.',
343
362
  unlockedBy:
344
363
  'Build the old commit before the run, or point the settings at a kept copy of the old build\'s artifact. Until then this journey falls back to the record the old build left last time.',
345
364
  surface: journey.surface,
@@ -383,20 +402,43 @@ export async function runCheck(opts) {
383
402
  // one normalised under another produces differences that are about the RULES — either a
384
403
  // wall of noise that reads like a regression, or, when the change was to add a rule,
385
404
  // quiet where there should not be any. Either way the reader has to be told.
386
- if (stored.capture.rules && a.rules && stored.capture.rules !== a.rules) {
387
- gaps.push({
388
- what: `"${journey.describe || journey.name}" is being compared across a change to the normalisation rules.`,
389
- why:
390
- `The stored record of the old build was tidied up by rule set ${stored.capture.rules} and this run used ${a.rules}. ` +
391
- 'Some of what you see may be the rules changing rather than the product, and a rule that was added since could be covering something up.',
392
- unlockedBy: 'Run a paired check, which walks the old build live under today\'s rules, or ship again to cut a fresh reference.',
393
- surface: journey.surface,
394
- });
405
+ //
406
+ // WHAT changed, not two hashes. This used to print "v1-fcf4b8000217 versus
407
+ // v1-29141a9ec069" and nothing else, which tells an agent nothing it can act on, so it
408
+ // acts on nothing. `describeRuleChange` owns the judgement — a rule that rewrites
409
+ // something differently makes the whole comparison suspect; a rule that merely reaches
410
+ // one more address makes only that address suspect and names it; and a record older
411
+ // than the scope stamp says so rather than reporting every glob as new.
412
+ //
413
+ // ONCE PER RUN, not once per journey. It is one fact about one pair of rule sets, and
414
+ // it was being written out again for every journey — four identical paragraphs in the
415
+ // coverage list on a four-journey project, on every run, saying the same thing. The
416
+ // coverage list is the one section that must never be skimmed, and nothing teaches a
417
+ // reader to skim it faster than a block that is always there and always the same.
418
+ const ruleChange = describeRuleChange(
419
+ { fingerprint: stored.capture.rules, scope: stored.capture.rulesScope },
420
+ { fingerprint: a.rules, scope: a.rulesScope },
421
+ );
422
+ if (!ruleChange.same) {
423
+ rulesMoved.add(ruleChange.say);
424
+ rulesMovedOn.push(journey.describe || journey.name);
395
425
  }
396
426
  if (stored.wobble) steadyInReference.push(...steadyPaths(stored.capture, stored.wobble));
397
427
  else referenceWobbleMeasured = false;
398
428
  }
399
429
 
430
+ if (rulesMovedOn.length > 0) {
431
+ const which =
432
+ rulesMovedOn.length === 1
433
+ ? `"${rulesMovedOn[0]}" is`
434
+ : `${rulesMovedOn.length} journeys are`;
435
+ gaps.push({
436
+ what: `${which} being compared across a change to the normalisation rules.`,
437
+ why: [...rulesMoved].join(' '),
438
+ unlockedBy: 'Run a paired check, which walks the old build live under today\'s rules, or ship again to cut a fresh reference.',
439
+ });
440
+ }
441
+
400
442
  stop();
401
443
  // Booting the old build is not the same as having walked it. When every live walk came
402
444
  // back holes-only — a built artifact that no checkout of the old commit contains — the
@@ -405,10 +447,19 @@ export async function runCheck(opts) {
405
447
  const walkedLive = liveWalks > 0;
406
448
  const mode = /** @type {'paired'|'stored-record'} */ (walkedLive ? 'paired' : 'stored-record');
407
449
  const wobble = wobbles.length > 0 ? mergeWobble(wobbles) : unmeasuredWobble(opts.candidate.id, '*');
450
+ // `steady` and `measured` are sent because they were MEASURED here, and nothing
451
+ // downstream can work them out. Anything drawing this event had to guess steady as
452
+ // "everything watched, minus the unstable ones" — a subtraction across two different
453
+ // populations, so it reported addresses as having answered the same way twice when the
454
+ // build had never been asked at them. And `measured: false` is the difference between a
455
+ // build that wobbled about nothing and a build whose wobble was never taken; two noughts
456
+ // cannot tell those apart, and only the first one is good news.
408
457
  say({
409
458
  type: 'wobble',
410
459
  at: events.elapsed(),
411
460
  count: wobble.unstable.length,
461
+ steady: wobble.steady,
462
+ measured: wobble.measured,
412
463
  message:
413
464
  wobble.unstable.length === 0
414
465
  ? 'This build gives the same answer twice, everywhere.'
@@ -599,6 +650,16 @@ export async function runCheck(opts) {
599
650
  cwd: opts.cwd,
600
651
  guards: opts.guards ?? [],
601
652
  touches: touchMap(walked),
653
+ // Where "your change" starts.
654
+ //
655
+ // Without this, "the change" means the working tree and nothing else — so the moment
656
+ // an agent commits its work, which is exactly what an agent does at the end of a task,
657
+ // the distance measure goes blind, the ranking loses its ordering, and every finding
658
+ // carries the sentence "nothing in the working tree has changed, so there is no edit
659
+ // to measure this against" over a change that is perfectly well known. The reference
660
+ // is a shipped build, which is a commit; the diff from there to here is the change,
661
+ // whether it has been committed or not.
662
+ since: reference?.gitSha ?? undefined,
602
663
  });
603
664
 
604
665
  const warning = modeWarning(mode, provedLive, reference);
@@ -920,6 +981,34 @@ const PROVEN_LIVE_WARNING =
920
981
  const NO_REFERENCE_WARNING =
921
982
  'Until you ship once with the reference hook in place there is nothing to compare against, so this run proves nothing about what still works.';
922
983
 
984
+ /**
985
+ * Why the old build came back with nothing that could be walked.
986
+ *
987
+ * The adapter always says why. It says it in the observation's own sentence, which
988
+ * `observation()` in adapters/contract.js files under `meta.describe` — "there is no APK in
989
+ * the exported checkout", "the runtime this needs is not on this machine". Reading only
990
+ * `observations[0]` threw that away in the two cases that matter most: a capture that came
991
+ * back completely EMPTY has no observation nought to read, so the reader got the generic
992
+ * "the adapter could not open it" while the real reason sat in the capture's note; and a
993
+ * capture whose first observation happens to be one the adapter did cover names the wrong
994
+ * hole. So the first REFUSED observation is the one that answers the question, its reason
995
+ * category stands in when it has no sentence of its own, and the capture's note is read
996
+ * before anything generic is said.
997
+ *
998
+ * The sentence is also finished properly. The adapter's `says` is already a whole sentence,
999
+ * and the old template appended a full stop of its own — the same fault already fixed once
1000
+ * in check.js's explain reply, which was giving agents "...real money..".
1001
+ *
1002
+ * @param {Capture} was The walk of the old build that produced nothing usable.
1003
+ * @returns {string} One finished sentence.
1004
+ */
1005
+ function whyNothingRan(was) {
1006
+ const refused = was?.observations?.find((o) => o.meta?.refused === true);
1007
+ const said = refused?.meta?.describe || refused?.meta?.refusedWhy || was?.note || 'the adapter could not open it';
1008
+ const text = String(said).trim();
1009
+ return /[.!?]$/.test(text) ? text : `${text}.`;
1010
+ }
1011
+
923
1012
  /**
924
1013
  * @param {'paired'|'stored-record'} mode
925
1014
  * @param {boolean} provedLive
package/src/v2/sealed.js CHANGED
@@ -295,26 +295,6 @@ export function classify(finding, opts = {}) {
295
295
  };
296
296
  }
297
297
 
298
- /**
299
- * The same answer as a plain class name, for callers that already work in `FindingClass`.
300
- *
301
- * @param {Finding} finding
302
- * @param {ClassifyOptions} [opts]
303
- * @returns {FindingClass}
304
- */
305
- export function sealedClassOf(finding, opts = {}) {
306
- return classify(finding, opts)?.class ?? 'ordinary';
307
- }
308
-
309
- /**
310
- * @param {Finding} finding
311
- * @param {ClassifyOptions} [opts]
312
- * @returns {boolean}
313
- */
314
- export function isSealed(finding, opts = {}) {
315
- return classify(finding, opts) !== null;
316
- }
317
-
318
298
  /**
319
299
  * The refusal, written out for whoever reads it — an agent that has just been told no, or a
320
300
  * person reading the closing summary.
@@ -116,6 +116,49 @@ export const CASES = [
116
116
  }),
117
117
  },
118
118
 
119
+ {
120
+ // Nothing this product prints changes, deliberately. A server written straight on
121
+ // node:http registers nothing — there is no `app.get` for a reader to find — so before
122
+ // the request handler itself was read, the only honest answer here was silence, and
123
+ // silence is the one answer this corpus exists to make impossible.
124
+ name: 'a route quietly renamed in a server written by hand',
125
+ breaks: 'A server built straight on node:http had one of its routes renamed. Nothing it prints changes, so the only way to see it is to read the handler.',
126
+ expect: 'a finding',
127
+ mustSay: [/health/i],
128
+ build: (broken) => ({
129
+ 'package.json': PKG,
130
+ 'cli.js': [
131
+ "import http from 'node:http';",
132
+ '',
133
+ 'const server = http.createServer((req, res) => {',
134
+ " if (req.url === '/orders') {",
135
+ " res.writeHead(200, { 'content-type': 'application/json' });",
136
+ ' res.end(\'{"orders":2}\');',
137
+ ' return;',
138
+ ' }',
139
+ broken
140
+ ? " if (req.url === '/healthz') {"
141
+ : " if (req.url === '/health') {",
142
+ ' res.writeHead(200);',
143
+ ' res.end(\'{"ok":true}\');',
144
+ ' return;',
145
+ ' }',
146
+ ' res.writeHead(404);',
147
+ " res.end('not found');",
148
+ '});',
149
+ '',
150
+ "await new Promise((done) => server.listen(0, '127.0.0.1', done));",
151
+ 'const address = server.address();',
152
+ "const port = typeof address === 'object' && address ? address.port : 0;",
153
+ 'const reply = await fetch(`http://127.0.0.1:${port}/orders`);',
154
+ 'console.log(`GET /orders -> ${reply.status}`);',
155
+ 'console.log(await reply.text());',
156
+ 'server.close();',
157
+ '',
158
+ ].join('\n'),
159
+ }),
160
+ },
161
+
119
162
  {
120
163
  name: 'a field dropped from a reply',
121
164
  breaks: 'A field quietly disappeared from a reply that everything downstream reads.',
@@ -425,6 +468,111 @@ export const CASES = [
425
468
  .join('\n'),
426
469
  }),
427
470
  },
471
+
472
+ {
473
+ name: "a break only the project's own tests can see",
474
+ breaks:
475
+ 'A total stops rounding. The command-line program only ever adds whole pounds, so what it prints does not move by one character and every journey this tool can discover on its own stays silent. The project\'s own test adds pennies, and it is the only thing in the repository that notices.',
476
+ expect: 'a finding',
477
+ mustSay: [/pennies/i],
478
+ journeys: [
479
+ {
480
+ name: 'suite-test-total-test',
481
+ describe: 'run the 3 checks in test/total.test.js and watch what they touch',
482
+ source: 'suite',
483
+ surface: 'cli',
484
+ from: 'test/total.test.js',
485
+ channels: ['results', 'complaints', 'counters'],
486
+ steps: [
487
+ {
488
+ act: 'run-tests',
489
+ runner: 'node:test',
490
+ file: 'test/total.test.js',
491
+ // Named rather than given as a path, so this case runs on a machine whose Node
492
+ // lives somewhere else. `node` is on the path of any machine that got this far.
493
+ command: 'node',
494
+ argv: ['--test', '--test-reporter=tap', 'test/total.test.js'],
495
+ note: 'Run this exactly as it was harvested. A test file run a different way is a different journey.',
496
+ },
497
+ ],
498
+ },
499
+ ],
500
+ build: (broken) => ({
501
+ 'package.json': JSON.stringify(
502
+ { name: 'widget', version: '1.0.0', type: 'module', bin: { widget: 'cli.js' }, scripts: { test: 'node --test' } },
503
+ null,
504
+ 2,
505
+ ) + '\n',
506
+ 'total.js': [
507
+ 'export function total(items) {',
508
+ ' let sum = 0;',
509
+ ' for (const item of items) sum += item.price;',
510
+ broken ? ' return sum;' : ' return Math.round(sum * 100) / 100;',
511
+ '}',
512
+ '',
513
+ ].join('\n'),
514
+ 'cli.js': [
515
+ "import { total } from './total.js';",
516
+ '// Whole pounds only, which is why running the product proves nothing here.',
517
+ 'console.log(total([{ price: 2 }, { price: 3 }]));',
518
+ '',
519
+ ].join('\n'),
520
+ 'test/total.test.js': [
521
+ "import { test } from 'node:test';",
522
+ "import assert from 'node:assert/strict';",
523
+ "import { total } from '../total.js';",
524
+ '',
525
+ "test('adds whole pounds', () => {",
526
+ ' assert.equal(total([{ price: 2 }, { price: 3 }]), 5);',
527
+ '});',
528
+ '',
529
+ "test('adds pennies without floating point dust', () => {",
530
+ ' assert.equal(total([{ price: 0.1 }, { price: 0.2 }]), 0.3);',
531
+ '});',
532
+ '',
533
+ ].join('\n'),
534
+ }),
535
+ },
536
+
537
+ {
538
+ name: 'a break behind an npm script',
539
+ breaks:
540
+ 'A message changes, behind `npm run`. Nothing about the product is unusual — what this case really watches is the watcher: it rides inside the child, and until 2026-08-30 the proxy it put over the environment had no `set` trap, so npm wrote npm_lifecycle_event, read back nothing, and exited 1 without printing a word. Every product whose start command went through npm looked like a product that would not boot, on both builds equally, which is a silence rather than a finding.',
541
+ expect: 'a finding',
542
+ mustSay: [/two orders|three orders/i],
543
+ journeys: [
544
+ {
545
+ name: 'run-it',
546
+ describe: 'Run the product the way its own package.json says to run it.',
547
+ source: 'code',
548
+ surface: 'cli',
549
+ steps: [
550
+ {
551
+ act: 'run',
552
+ run: 'npm run --silent report',
553
+ note: 'through npm, because that is what `staysfixed init` writes by default',
554
+ env: {
555
+ // npm's own update check reaches for the registry, which is refused - and an
556
+ // attempt that happens on one run and not the next is noise this case would
557
+ // then have to argue about. Switched off rather than subtracted.
558
+ NO_UPDATE_NOTIFIER: '1',
559
+ npm_config_update_notifier: 'false',
560
+ npm_config_audit: 'false',
561
+ npm_config_fund: 'false',
562
+ },
563
+ },
564
+ ],
565
+ },
566
+ ],
567
+ build: (broken) => ({
568
+ 'package.json': JSON.stringify(
569
+ { name: 'widget', version: '1.0.0', type: 'module', scripts: { report: 'node report.js' } },
570
+ null,
571
+ 2,
572
+ ) + '\n',
573
+ 'report.js': `console.log('${broken ? 'three' : 'two'} orders');\n`,
574
+ }),
575
+ },
428
576
  ];
429
577
 
430
578
  /**
@@ -477,22 +625,51 @@ async function makeStoreUnwritable(dir, working) {
477
625
  return { ready: false, why: `the warm-up run did not work: ${why(e)}` };
478
626
  }
479
627
 
480
- /** @type {string[]} */
481
- const shut = [];
482
- for (const build of await fsp.readdir(builds, { withFileTypes: true })) {
483
- if (!build.isDirectory()) continue;
484
- const inside = path.join(builds, build.name);
485
- for (const journey of await fsp.readdir(inside, { withFileTypes: true })) {
486
- if (!journey.isDirectory()) continue;
487
- const folder = path.join(inside, journey.name);
488
- await fsp.chmod(folder, 0o555);
489
- shut.push(folder);
490
- }
491
- }
492
- if (shut.length === 0) return { ready: false, why: 'the warm-up run stored nothing, so there was nothing to make read-only' };
628
+ // Shut every folder under the store, not only the ones the captures land in.
629
+ //
630
+ // This walked two levels by hand — builds, then journeys which left the build record
631
+ // itself writable. That is the wrong half: `openProject` writes the build record before a
632
+ // single journey runs, so a disk that has given out fails THERE first, and a case that
633
+ // only shuts the capture folders never exercises the path the real failure takes.
634
+ // Shutting the whole store is the mirror of `relax`, which is what puts it back.
635
+ const shut = await shutTight(path.join(dir, '.staysfixed', 'v2'));
636
+ if (shut === 0) return { ready: false, why: 'the warm-up run stored nothing, so there was nothing to make read-only' };
493
637
  return { ready: true, why: '' };
494
638
  }
495
639
 
640
+ /**
641
+ * Make a folder and everything under it read-only, and say how many folders that was.
642
+ *
643
+ * The exact mirror of `relax`, which undoes it. Failures are ignored on purpose: a folder
644
+ * that will not change permissions is one this case cannot use, and the count tells the
645
+ * caller whether enough of the store was shut for the case to mean anything.
646
+ *
647
+ * @param {string} dir
648
+ * @returns {Promise<number>}
649
+ */
650
+ async function shutTight(dir) {
651
+ /** @type {import('node:fs').Dirent[]} */
652
+ let inside = [];
653
+ try {
654
+ inside = await fsp.readdir(dir, { withFileTypes: true });
655
+ } catch {
656
+ return 0;
657
+ }
658
+ let shut = 0;
659
+ for (const entry of inside) {
660
+ if (entry.isDirectory()) shut += await shutTight(path.join(dir, entry.name));
661
+ }
662
+ // The folder itself last: shut it first and its own children become unreachable.
663
+ try {
664
+ await fsp.chmod(dir, 0o555);
665
+ shut += 1;
666
+ } catch {
667
+ // Read-only already, or a filesystem that will not say no. Either way, not a reason
668
+ // to stop — `relax` will still walk it, and the count tells the caller what happened.
669
+ }
670
+ return shut;
671
+ }
672
+
496
673
  // ---------------------------------------------------------------------------
497
674
  // Running it
498
675
  // ---------------------------------------------------------------------------
package/src/v2/ship.js CHANGED
@@ -156,15 +156,29 @@ export async function onShip(opts = {}) {
156
156
  const store = openStore({ root });
157
157
  await ensureStore(store);
158
158
 
159
- const build = await resolveBuild(store, product, release, opts.build);
159
+ /** @type {string[]} */
160
+ const unreadable = [];
161
+ const build = await resolveBuild(store, product, release, opts.build, (/** @type {string} */ why) => unreadable.push(String(why)));
162
+ if (unreadable.length > 0) {
163
+ // Said whether or not a build was found, because a damaged record changes what the
164
+ // answer is worth either way: if none was found it may be the reason, and if one was
165
+ // found it may not be the right one.
166
+ result.lines.push(
167
+ `${unreadable.length} stored ${unreadable.length === 1 ? 'record' : 'records'} of ${product} could not be read, so ${unreadable.length === 1 ? 'it was' : 'they were'} left out of this decision: ${unreadable.join('; ')}`,
168
+ );
169
+ }
160
170
  if (!build) {
161
171
  result.cut = false;
172
+ const because = unreadable.length > 0
173
+ ? ` ${unreadable.length} of its stored ${unreadable.length === 1 ? 'record' : 'records'} could not be read, which may be why.`
174
+ : '';
162
175
  result.lines = [
163
176
  `${product} ${release.describe}`,
164
- `Stays Fixed has no record of this build, so it did not become the reference. Nothing about ${product} is being compared against anything yet.`,
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}`,
165
178
  'Run `staysfixed check` once before the next release and it will record itself from then on.',
179
+ ...(unreadable.length > 0 ? [`What could not be read: ${unreadable.join('; ')}`] : []),
166
180
  ];
167
- result.summary = `${product} shipped ${release.what}, but Stays Fixed had never seen this build, so what "working" means has not moved. Run a check before the next release.`;
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.`;
168
182
  return result;
169
183
  }
170
184
 
@@ -381,10 +395,20 @@ async function versionBump(root) {
381
395
  * @param {string} product
382
396
  * @param {Release} release
383
397
  * @param {string|BuildFingerprint} [told]
398
+ * @param {(why: string) => void} [onProblem] Told about any stored record that would not open.
384
399
  * @returns {Promise<BuildFingerprint|null>}
385
400
  */
386
- async function resolveBuild(store, product, release, told) {
387
- const builds = await listBuilds(store, { product });
401
+ async function resolveBuild(store, product, release, told, onProblem) {
402
+ // A record nobody could read must never be silently absent here.
403
+ //
404
+ // This function decides which build becomes the definition of "working" for a whole
405
+ // product. `listBuilds` skips a damaged record rather than failing, which is right for a
406
+ // listing and wrong here twice over: a build whose record will not open looks exactly like
407
+ // a build that was never walked, so the answer is "Stays Fixed has never seen this build"
408
+ // when the truth is "it has, and the record is broken"; and where two builds share a
409
+ // commit, losing the clean one to a damaged record leaves the dirty one to be blessed in
410
+ // its place. Neither is a thing to work out from an empty list.
411
+ const builds = await listBuilds(store, { product, onProblem });
388
412
 
389
413
  if (told) {
390
414
  const id = typeof told === 'string' ? told : told.id;
@@ -393,6 +417,32 @@ async function resolveBuild(store, product, release, told) {
393
417
  return typeof told === 'string' ? { id: told, product } : told;
394
418
  }
395
419
 
420
+ // WHAT IS ACTUALLY HERE, before what the commit says is here.
421
+ //
422
+ // Matching on the commit alone blesses the wrong thing the moment the tree is dirty:
423
+ // several builds share one commit, this took the first of them, and that is usually an
424
+ // EARLIER build — one that was checked and came back clean. So editing a file and running
425
+ // `staysfixed ship` answered "was already the reference — nothing changed" and exited 0,
426
+ // about a tree nothing had ever looked at. Measured 2026-08-30 by deleting a button and
427
+ // shipping without a check.
428
+ //
429
+ // A dirty tree therefore has to match EXACTLY, on the fingerprint of what is on disk right
430
+ // now. If nothing on record is that tree, then this tree has not been checked, and the
431
+ // honest answer is the one the tool already knows how to give: no record of this build, so
432
+ // the reference does not move.
433
+ try {
434
+ const { fingerprintWorkingTree } = await import('./check.js');
435
+ const here = await fingerprintWorkingTree(store.root, product);
436
+ if (here?.id) {
437
+ const exact = builds.find((b) => b.fingerprint.id === here.id);
438
+ if (exact) return exact.fingerprint;
439
+ if (here.dirty) return null;
440
+ }
441
+ } catch {
442
+ // No git, or the tree could not be read. The joins below are all that is left, and they
443
+ // are better than refusing to record a release at all.
444
+ }
445
+
396
446
  const sha = release.gitSha;
397
447
  if (sha) {
398
448
  const sameCommit = builds.filter((b) => b.fingerprint.gitSha === sha);
package/src/v2/store.js CHANGED
@@ -256,7 +256,7 @@ export async function saveCapture(store, capture) {
256
256
  * clears those up.
257
257
  *
258
258
  * @param {Store} store
259
- * @param {{build: BuildFingerprint, journey: string, run: CaptureRun, id?: string, source?: JourneySource, startedAt?: string, rules?: string}} opts
259
+ * @param {{build: BuildFingerprint, journey: string, run: CaptureRun, id?: string, source?: JourneySource, startedAt?: string, rules?: string, rulesScope?: Record<string, string[]>}} opts
260
260
  * @returns {Promise<{ref: CaptureRef, append: (o: Observation) => Promise<void>, close: (end?: {durationMs?: number, coverage?: Coverage, note?: string}) => Promise<CaptureRef>, abandon: () => Promise<void>}>}
261
261
  */
262
262
  export async function openCaptureWriter(store, opts) {
@@ -282,6 +282,7 @@ export async function openCaptureWriter(store, opts) {
282
282
  };
283
283
  if (opts.source) shell.source = opts.source;
284
284
  if (opts.rules) shell.rules = opts.rules;
285
+ if (opts.rulesScope) shell.rulesScope = opts.rulesScope;
285
286
  await handle.write(JSON.stringify(headerOf(shell)) + '\n');
286
287
 
287
288
  let open = true;
@@ -367,6 +368,16 @@ async function bumpBuild(store, capture) {
367
368
  }
368
369
 
369
370
  /**
371
+ * The first line of a capture file.
372
+ *
373
+ * The field list is written out rather than spread, so that what reaches disk is a decision
374
+ * somebody made. The cost of that is this: a field added to `Capture` and not added here is
375
+ * silently dropped, and everything downstream reads its absence as a fact about the run. It
376
+ * happened to `rulesScope` — stamped on every capture, written to none of them, and the
377
+ * feature that reads it took the "this record predates the stamp" branch forever. If you add
378
+ * a field to Capture that a later run needs, add it here and to the read in `loadCapture`,
379
+ * and prove it with a write-then-read test rather than a unit test on the stamping.
380
+ *
370
381
  * @param {Capture} capture
371
382
  * @returns {Record<string, unknown>}
372
383
  */
@@ -381,6 +392,7 @@ function headerOf(capture) {
381
392
  run: capture.run,
382
393
  startedAt: capture.startedAt,
383
394
  rules: capture.rules,
395
+ rulesScope: capture.rulesScope,
384
396
  };
385
397
  }
386
398
 
@@ -512,6 +524,7 @@ export async function loadCapture(store, where) {
512
524
  };
513
525
  if (header.source) capture.source = header.source;
514
526
  if (header.rules) capture.rules = header.rules;
527
+ if (header.rulesScope) capture.rulesScope = header.rulesScope;
515
528
  if (end?.coverage) capture.coverage = end.coverage;
516
529
 
517
530
  const notes = [];
@@ -824,6 +837,59 @@ export async function pruneBuild(store, buildId, opts = {}) {
824
837
  return { removed, kept };
825
838
  }
826
839
 
840
+ /**
841
+ * Throw the whole of a build away: its record, its captures, its folder.
842
+ *
843
+ * The companion `pruneBuild` thins one build's captures down to the newest few, which is the
844
+ * right tool when a build is worth keeping and its hundred captures are not. It is the wrong
845
+ * tool for the growth anybody actually measures: one build FOLDER per check, forever, in a
846
+ * directory this tool asks people to commit. Nothing could remove a folder at all, so the only
847
+ * housekeeping that existed could not touch the thing that grows.
848
+ *
849
+ * The two refusals are the same two, for the same reason, and they are loud rather than quiet
850
+ * because deleting the evidence of what "working" means is not recoverable:
851
+ *
852
+ * - A build any product points at as its reference is never removed. Its captures are the
853
+ * only record of what working looked like, and once they are gone the next check has
854
+ * nothing to compare against.
855
+ * - A build whose own record cannot be read is never removed. An unreadable record is the
856
+ * one state where we do not know what we would be deleting, and "I could not tell, so I
857
+ * deleted it" is the wrong way round.
858
+ *
859
+ * WHAT TO KEEP IS NOT DECIDED HERE. This removes one build that has been named. Which builds
860
+ * are worth keeping — how many, how long, whether a `work-` build off a dirty tree is worth
861
+ * less than a `git-` one off a commit — is a policy about somebody's disk and their history,
862
+ * and it belongs where the run knows what it just did, not in the file that owns the folder.
863
+ *
864
+ * @param {Store} store
865
+ * @param {string} buildId
866
+ * @returns {Promise<{removed: true, captures: number}>}
867
+ */
868
+ export async function removeBuild(store, buildId) {
869
+ const record = await loadBuild(store, buildId);
870
+ const references = await loadReferences(store);
871
+
872
+ // Asked of the POINTERS rather than of the build's own record, for the reason spelled out in
873
+ // pruneBuild: a build whose build.json is missing answers nothing, and reading it the other
874
+ // way round would skip the guard exactly when it matters most.
875
+ const pointedAt = Object.values(references).filter((p) => p?.buildId === buildId);
876
+ if (pointedAt.length > 0) {
877
+ throw new StaysFixedError(
878
+ `${buildId} is the reference for ${pointedAt.map((p) => p.product).join(', ')}, so it cannot be thrown away.`,
879
+ { hint: 'Point the reference at a newer build first, with setReference.' },
880
+ );
881
+ }
882
+ if (!record) {
883
+ throw new StaysFixedError(`Nothing here says what ${buildId} is, so it will not be thrown away.`, {
884
+ hint: 'Its build.json is missing. Deleting a build on the strength of a record nobody can read is how the evidence for "this used to work" disappears. Run a check against it to rewrite the record, or delete the folder deliberately.',
885
+ });
886
+ }
887
+
888
+ const captures = (await listCaptures(store, { buildId })).length;
889
+ await fsp.rm(buildDir(store, buildId), { recursive: true, force: true });
890
+ return { removed: true, captures };
891
+ }
892
+
827
893
  /**
828
894
  * Delete `.part` files left behind by runs that died.
829
895
  *