staysfixed 0.7.1 → 0.8.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 (59) hide show
  1. package/CHANGELOG.md +364 -0
  2. package/README.md +193 -55
  3. package/docs/design-v2.md +24 -4
  4. package/docs/getting-started.md +18 -5
  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 +549 -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/watch-flags.js +54 -0
  15. package/src/core/config.js +23 -3
  16. package/src/guard/run.js +49 -1
  17. package/src/report/console.js +15 -2
  18. package/src/v2/adapters/android-driver.js +6 -1
  19. package/src/v2/adapters/android.js +97 -2
  20. package/src/v2/adapters/contract.js +42 -5
  21. package/src/v2/adapters/electron.js +72 -6
  22. package/src/v2/adapters/http.js +11 -2
  23. package/src/v2/adapters/ios-driver.js +64 -14
  24. package/src/v2/adapters/ios.js +247 -25
  25. package/src/v2/adapters/process.js +728 -66
  26. package/src/v2/adapters/python.js +495 -0
  27. package/src/v2/adapters/source.js +373 -18
  28. package/src/v2/adapters/web-driver.js +94 -24
  29. package/src/v2/adapters/web.js +142 -9
  30. package/src/v2/adapters/windows.js +18 -1
  31. package/src/v2/browsers.js +9 -1
  32. package/src/v2/cause.js +61 -17
  33. package/src/v2/check.js +530 -66
  34. package/src/v2/ci.js +130 -35
  35. package/src/v2/cli.js +42 -24
  36. package/src/v2/cluster.js +164 -13
  37. package/src/v2/coverage.js +43 -176
  38. package/src/v2/detect.js +308 -60
  39. package/src/v2/doctor.js +345 -47
  40. package/src/v2/init.js +162 -61
  41. package/src/v2/intent.js +9 -23
  42. package/src/v2/journeys/from-suite.js +336 -30
  43. package/src/v2/journeys/index.js +99 -6
  44. package/src/v2/mcp/tools.js +10 -11
  45. package/src/v2/normalise.js +169 -23
  46. package/src/v2/observation.js +19 -33
  47. package/src/v2/rank.js +216 -23
  48. package/src/v2/reference.js +40 -10
  49. package/src/v2/remote.js +113 -18
  50. package/src/v2/run.js +103 -14
  51. package/src/v2/sealed.js +0 -20
  52. package/src/v2/selfcheck.js +190 -13
  53. package/src/v2/ship.js +29 -5
  54. package/src/v2/store.js +67 -1
  55. package/src/v2/types.js +12 -2
  56. package/src/v2/waiver.js +64 -54
  57. package/src/v2/watch/events.js +60 -215
  58. package/src/v2/watch/focus.js +14 -4
  59. package/src/v2/watch/panel.js +167 -17
@@ -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;
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
  *
package/src/v2/types.js CHANGED
@@ -152,7 +152,6 @@
152
152
  * @property {string|null} [branch]
153
153
  * @property {boolean} [dirty] Working tree had uncommitted changes.
154
154
  * @property {string} [artifact] Path to the built thing. NOT stored here — see store.js.
155
- * @property {string} [artifactSha256]
156
155
  * @property {string} [builtAt] ISO timestamp.
157
156
  * @property {string} [platform] e.g. 'darwin-arm64'. Comparing across platforms warns.
158
157
  * @property {string} [tool] Stays Fixed version that captured it.
@@ -183,7 +182,12 @@
183
182
  * @property {Coverage} [coverage] What this capture did NOT manage to look at.
184
183
  * @property {boolean} [complete] False when the file was read back torn — see store.js.
185
184
  * @property {string} [note]
186
- * @property {string} [rules] Id of the normalisation rule set applied, if any.
185
+ * @property {string} [rules] Fingerprint of what the normalisation rules DO, if any
186
+ * were applied. Scope is stamped separately — see rulesScope.
187
+ * @property {Record<string, string[]>} [rulesScope]
188
+ * Where each scoped rule applied, by rule id. Absent on
189
+ * captures written before this was stamped, which is a real
190
+ * state and says "cannot be compared" rather than "nothing".
187
191
  */
188
192
 
189
193
  // ---------------------------------------------------------------------------
@@ -438,6 +442,12 @@ export {};
438
442
  * '$.items.3.name'. Used by sort, round and drop.
439
443
  * @property {boolean} [off] Shipped, documented, and not switched on.
440
444
  * @property {string} [whyOff] Why it is not on by default.
445
+ * @property {boolean} [machine] This rule's pattern is a fact about THIS machine — where
446
+ * the project is checked out, where home is, where the temp
447
+ * folder went today — rather than a decision about what to
448
+ * tidy. Two machines running the same rule write the same
449
+ * placeholder, so the rule is the same rule and its pattern
450
+ * must not reach the fingerprint. See rulesFingerprint.
441
451
  */
442
452
 
443
453
  /**
package/src/v2/waiver.js CHANGED
@@ -36,7 +36,6 @@
36
36
  * different difference and is reported.
37
37
  */
38
38
 
39
- import fsp from 'node:fs/promises';
40
39
  import path from 'node:path';
41
40
  import crypto from 'node:crypto';
42
41
 
@@ -48,9 +47,10 @@ import {
48
47
  readIntent,
49
48
  readIntentById,
50
49
  referenceStamp,
50
+ fingerprintTree,
51
+ treeMovedSince,
51
52
  readJsonFile,
52
53
  writeJsonAtomic,
53
- shortDigest,
54
54
  } from './intent.js';
55
55
 
56
56
  /**
@@ -71,10 +71,15 @@ import {
71
71
  */
72
72
  export const WAIVER_BUDGET = 5;
73
73
 
74
- /** How many differences of one finding go into its fingerprint. Clusters can hold hundreds. */
75
- const FINGERPRINT_DIFFERENCES = 40;
76
-
77
- /** How many dead waivers are kept per product, so a summary can still say what expired. */
74
+ /**
75
+ * How many dead waivers are kept per product, so a summary can still say what expired.
76
+ *
77
+ * LIVE ONES ARE NEVER PRUNED, which is what matters: the budget, the gates and whether a
78
+ * difference is covered are all worked out from those alone and none of them can be affected
79
+ * by this number. What it can affect is the sentence "three waivers expired when you
80
+ * shipped", which counts the dead ones still on disk — so after fifty of them, spread over
81
+ * ten ships or more, that number is the recent history rather than the whole of it.
82
+ */
78
83
  const KEEP_EXPIRED = 50;
79
84
 
80
85
  // ---------------------------------------------------------------------------
@@ -94,12 +99,22 @@ const KEEP_EXPIRED = 50;
94
99
  * @property {string} fingerprint Pins the exact difference, values included.
95
100
  * @property {string} finding The finding's own id, when it had one.
96
101
  * @property {string} summary The finding's title, kept so this reads without a check.
97
- * @property {string[]} paths The addresses involved, trimmed.
102
+ * @property {string[]} paths The first few addresses involved, for somebody reading
103
+ * this back later. Nothing is decided from them: what the
104
+ * waiver actually covers is `fingerprint`, which takes in
105
+ * every difference with no ceiling at all.
98
106
  * @property {FindingClass} class What the engine called it. Always ordinary — see gate 1.
99
107
  * @property {string} why The agent's reason, in its own words.
100
108
  * @property {string} intentId
101
109
  * @property {string} intentSummary Copied, so a pruned intent does not orphan the waiver.
102
110
  * @property {string} ordering What was known about when the intent was sealed.
111
+ * @property {{moved: boolean, knowable: boolean, say: string}} codeSince
112
+ * Whether the code moved between sealing that intent and
113
+ * writing this. Not a gate — a moved tree is what an
114
+ * intent sealed BEFORE the work is supposed to look like.
115
+ * It is recorded because the alternative is that nobody
116
+ * reading this waiver a month later can tell whether the
117
+ * intent describes the build that was actually checked.
103
118
  * @property {IntentCoverage} coverage How well it matched what was declared, and how sure.
104
119
  * @property {string} at ISO. Written here, never supplied.
105
120
  * @property {string} [by]
@@ -284,6 +299,12 @@ export async function waive(store, what) {
284
299
  };
285
300
  }
286
301
 
302
+ // The last of the three things intent.js says its tree fingerprint makes checkable. The
303
+ // other two are gates above; this one cannot be, because both answers are legitimate — an
304
+ // intent sealed before the work SHOULD see a moved tree, and one sealed after it should
305
+ // not. So it is written down rather than judged, and a person reading the waiver decides.
306
+ const codeSince = treeMovedSince(intent, await fingerprintTree(store.root));
307
+
287
308
  /** @type {Waiver} */
288
309
  const waiver = {
289
310
  id: `waiver-${crypto.randomBytes(5).toString('hex')}`,
@@ -297,6 +318,7 @@ export async function waive(store, what) {
297
318
  intentId: intent.id,
298
319
  intentSummary: intent.summary,
299
320
  ordering: intent.ordering,
321
+ codeSince,
300
322
  coverage,
301
323
  at: new Date().toISOString(),
302
324
  reference: stamp,
@@ -318,6 +340,7 @@ export async function waive(store, what) {
318
340
  `Recorded as intended: ${waiver.summary}`,
319
341
  `Your reason, kept: ${why}`,
320
342
  `Matched against what you sealed: ${coverage.why} (${coverage.confidence} match)`,
343
+ codeSince.say,
321
344
  '',
322
345
  `${left} of your ${WAIVER_BUDGET} waivers left before the next ship. This one is pinned to the exact values that differ and to the reference in force now: if either moves, it stops covering anything.`,
323
346
  'This is not approval. Nothing becomes the new normal until a build ships. Say in what you report back that you waived this, and why.',
@@ -371,45 +394,6 @@ export function waiverFor(waivers, finding) {
371
394
  return waivers.find((w) => w.fingerprint === fingerprint) ?? null;
372
395
  }
373
396
 
374
- /**
375
- * What the closing summary needs: how many were waived, how many are left, what expired, and one
376
- * sentence saying so.
377
- *
378
- * Waivers must be visible, not quiet. This is the function that makes them so, and a summary
379
- * that does not use it is hiding something an agent decided on its own.
380
- *
381
- * @param {Store} store
382
- * @param {string} product
383
- * @returns {Promise<{budget: number, spent: number, left: number, active: Waiver[], expired: number, reference: string, line: string}>}
384
- */
385
- export async function countWaivers(store, product) {
386
- const stamp = await referenceStamp(store, product);
387
- const all = await allWaivers(store, product);
388
- const active = all.filter((w) => isLive(w, stamp));
389
- const expired = all.length - active.length;
390
- const left = Math.max(0, WAIVER_BUDGET - active.length);
391
-
392
- const line =
393
- active.length === 0
394
- ? `Nothing was waived${expired > 0 ? `, and ${expired} older waiver${expired === 1 ? '' : 's'} died when the reference last moved` : ''}.`
395
- : `${active.length} difference${active.length === 1 ? ' was' : 's were'} recorded as intended, not approved: ${active
396
- .map((w) => trim(w.summary, 90))
397
- .join('; ')}. ${left} of the ${WAIVER_BUDGET} allowed before a person has to look ${left === 1 ? 'is' : 'are'} left.`;
398
-
399
- return { budget: WAIVER_BUDGET, spent: active.length, left, active, expired, reference: stamp, line };
400
- }
401
-
402
- /**
403
- * Forget a product's waivers. Housekeeping, and the way a test starts clean.
404
- *
405
- * @param {Store} store
406
- * @param {string} product
407
- * @returns {Promise<void>}
408
- */
409
- export async function forgetWaivers(store, product) {
410
- await fsp.rm(waiversFile(store, product), { force: true });
411
- }
412
-
413
397
  /**
414
398
  * What a waiver is pinned to.
415
399
  *
@@ -418,20 +402,46 @@ export async function forgetWaivers(store, product) {
418
402
  * which errs towards a person looking at something they have already seen rather than towards a
419
403
  * new break hiding behind an old excuse. That is the right way round.
420
404
  *
405
+ * EVERY difference, and there is deliberately no ceiling on that. Until 2026-08-30 this took
406
+ * the first forty and stopped, and the sentence above was simply false: a waiver written about
407
+ * a three-hundred-address finding went on covering it after a value past the fortieth turned
408
+ * into something else. The addresses were all still there, the title still read the same, the
409
+ * cluster was still one finding — so the pin matched, the difference was filed as intended, and
410
+ * nobody was ever shown the one row that had actually broken. That is the whole failure this
411
+ * file exists to prevent, arriving through the file itself.
412
+ *
413
+ * The cost of having no ceiling is a hash over text the caller is already holding in memory,
414
+ * which is nothing next to being wrong. The tuples are sorted first so that two runs which
415
+ * found the same differences in a different order still pin to the same thing; the number of
416
+ * them goes in as well, so a cluster that merely GREW cannot match a waiver written about the
417
+ * smaller one.
418
+ *
421
419
  * @param {Finding} finding
422
420
  * @returns {string}
423
421
  */
424
422
  export function fingerprintFinding(finding) {
425
- const differences = (finding.differences ?? [])
426
- .slice(0, FINGERPRINT_DIFFERENCES)
427
- .map((d) => [d.path, d.kind, face(d.reference), face(d.candidate)]);
423
+ const differences = finding.differences ?? [];
424
+ const hash = crypto.createHash('sha256');
425
+ /** @param {unknown} part */
426
+ const eat = (part) => {
427
+ // JSON escapes every newline inside a value, so a newline is a separator nothing in the
428
+ // text can forge — two different findings cannot run together into one identical digest.
429
+ hash.update(`${JSON.stringify(part) ?? 'null'}\n`);
430
+ };
431
+
432
+ eat(finding.title ?? '');
433
+ eat([...(finding.paths ?? [])].sort());
434
+ eat(differences.length);
435
+ const rows = differences.map((d) => JSON.stringify([d.path, d.kind, face(d.reference), face(d.candidate)]));
436
+ rows.sort();
437
+ for (const row of rows) hash.update(`${row}\n`);
438
+
428
439
  // A finding with no differences attached, which some callers pass, still has to be pinnable,
429
440
  // so the sample and the paths stand in for them.
430
- const fallback =
431
- differences.length > 0
432
- ? []
433
- : [finding.sample?.path ?? '', finding.sample?.kind ?? '', face(finding.sample?.reference), face(finding.sample?.candidate)];
434
- return shortDigest([finding.title ?? '', [...(finding.paths ?? [])].sort(), differences, fallback]);
441
+ if (differences.length === 0) {
442
+ eat([finding.sample?.path ?? '', finding.sample?.kind ?? '', face(finding.sample?.reference), face(finding.sample?.candidate)]);
443
+ }
444
+ return hash.digest('hex').slice(0, 16);
435
445
  }
436
446
 
437
447
  // ---------------------------------------------------------------------------