staysfixed 0.7.2 → 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 +342 -0
  2. package/README.md +191 -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 +285 -45
  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
package/src/v2/ci.js CHANGED
@@ -415,7 +415,12 @@ export async function referenceForCI(opts = {}) {
415
415
  }
416
416
 
417
417
  // ---- released ----------------------------------------------------------
418
- const released = await releasedCommit(cwd, opts.product);
418
+ // Anything the store could not read travels with the answer and is said out loud, whether
419
+ // the answer was found or not. A run that says "this project has nothing on record as
420
+ // working" while a build folder sat there unreadable is stating a fact it does not have.
421
+ const releasedLook = await releasedCommit(cwd, opts.product);
422
+ const released = releasedLook.found;
423
+ const storeTrouble = releasedLook.trouble;
419
424
  if (released) {
420
425
  const found = await resolveCommit(cwd, released.sha);
421
426
  const sha = notThisBuild(found);
@@ -443,11 +448,12 @@ export async function referenceForCI(opts = {}) {
443
448
  'The fork point of this branch could not be worked out, so this was compared against the last shipped build instead. Anything else that was merged since that release will show up here as well, even though this branch did not do it.';
444
449
  result.unlockedBy = deepen;
445
450
  }
451
+ if (storeTrouble) result.caveat = result.caveat ? `${result.caveat} ${storeTrouble}` : storeTrouble;
446
452
  return result;
447
453
  }
448
- else missed('released', `The project's reference points at ${short(released.sha)}, and that commit is not in this checkout.`, shallow ? deepen : 'Make sure the commit that was shipped is still in this repository.');
454
+ else missed('released', withTrouble(`The project's reference points at ${short(released.sha)}, and that commit is not in this checkout.`, storeTrouble), shallow ? deepen : 'Make sure the commit that was shipped is still in this repository.');
449
455
  } else {
450
- missed('released', 'This project has no build on record as working. Only its owner can set one, by shipping.');
456
+ missed('released', withTrouble('This project has no build on record as working. Only its owner can set one, by shipping.', storeTrouble));
451
457
  }
452
458
 
453
459
  // ---- last tag ----------------------------------------------------------
@@ -491,7 +497,8 @@ export async function referenceForCI(opts = {}) {
491
497
  missed('previous-commit', 'There is no earlier commit in this checkout.', shallow ? deepen : undefined);
492
498
 
493
499
  // ---- stored record -----------------------------------------------------
494
- const stored = await storedRecord(cwd, opts.product);
500
+ const storedLook = await storedRecord(cwd, opts.product);
501
+ const stored = storedLook.found;
495
502
  if (stored) {
496
503
  considered.push({ mode: 'stored-record', available: true, why: `There are stored observations for ${stored.buildId}.` });
497
504
  const same = stored.machine === 'same';
@@ -513,9 +520,14 @@ export async function referenceForCI(opts = {}) {
513
520
  machine: stored.machine,
514
521
  };
515
522
  }
516
- missed('stored-record', 'There are no stored observations in this checkout either.', 'Commit the .staysfixed folder, or restore it from a cache written by a job on your main branch.');
523
+ missed('stored-record', withTrouble('There are no stored observations in this checkout either.', storedLook.trouble), 'Commit the .staysfixed folder, or restore it from a cache written by a job on your main branch.');
517
524
 
518
525
  // ---- nothing -----------------------------------------------------------
526
+ // The last line of this file is the one that says a whole checkout holds nothing to
527
+ // compare against, and it is read as final. It must not be said over the top of a store
528
+ // that was sitting right there with records nobody could open, so whatever the two looks
529
+ // in the store could not see is repeated here where a person will actually meet it.
530
+ const blindSpots = [storeTrouble, storedLook.trouble].filter((t) => t !== '');
519
531
  return {
520
532
  mode: 'none',
521
533
  against: null,
@@ -523,13 +535,27 @@ export async function referenceForCI(opts = {}) {
523
535
  strength: 'none',
524
536
  how: 'Nothing was found to compare against.',
525
537
  why: 'No named commit, no pull request base, no reference, no tag, no earlier commit and no stored record. There is nothing in this checkout that says what this product used to do.',
526
- caveat: 'This run proves nothing about your product either way. It is not a pass.',
527
- unlockedBy: deepen,
538
+ caveat:
539
+ blindSpots.length === 0
540
+ ? 'This run proves nothing about your product either way. It is not a pass.'
541
+ : `This run proves nothing about your product either way. It is not a pass. AND IT COULD NOT SEE EVERYTHING THERE IS: ${[...new Set(blindSpots)].join(' ')}`,
542
+ unlockedBy: blindSpots.length === 0 ? deepen : `${deepen} And repair or delete the build records named above, so the next run can read them.`,
528
543
  considered,
529
544
  shallow,
530
545
  };
531
546
  }
532
547
 
548
+ /**
549
+ * Add what could not be seen to a sentence about what was not found.
550
+ *
551
+ * @param {string} said
552
+ * @param {string} trouble
553
+ * @returns {string}
554
+ */
555
+ function withTrouble(said, trouble) {
556
+ return trouble ? `${said} ${trouble}` : said;
557
+ }
558
+
533
559
  /**
534
560
  * The commit this branch forked from.
535
561
  *
@@ -585,6 +611,19 @@ async function findMergeBase(cwd, ci) {
585
611
  return null;
586
612
  }
587
613
 
614
+ /**
615
+ * What a look in the store found, and what it could not see while looking.
616
+ *
617
+ * `trouble` is empty almost always. It carries a sentence when a build folder had to be
618
+ * skipped, or when the store would not open at all — and that sentence has to travel,
619
+ * because both of those turn "this project has nothing on record as working" into a claim
620
+ * the code is in no position to make. The build being asked for may well be one of the ones
621
+ * that could not be read.
622
+ *
623
+ * @template T
624
+ * @typedef {{found: T|null, trouble: string}} LookedInTheStore
625
+ */
626
+
588
627
  /**
589
628
  * The commit this project's own reference points at — the build somebody shipped.
590
629
  *
@@ -592,23 +631,35 @@ async function findMergeBase(cwd, ci) {
592
631
  *
593
632
  * @param {string} cwd
594
633
  * @param {string} [product]
595
- * @returns {Promise<{sha: string, note: string}|null>}
634
+ * @returns {Promise<LookedInTheStore<{sha: string, note: string}>>}
596
635
  */
597
636
  async function releasedCommit(cwd, product) {
637
+ /** @type {string[]} */
638
+ const skipped = [];
598
639
  try {
599
640
  const store = openStore({ root: cwd });
600
641
  const name = product ?? (await productName(cwd));
601
- if (!name) return null;
642
+ if (!name) return { found: null, trouble: '' };
602
643
  const pointer = await referencePointer(store, name);
603
- if (!pointer) return null;
604
- const builds = await listBuilds(store, { product: name });
644
+ if (!pointer) return { found: null, trouble: '' };
645
+ // Until 2026-08-30 nothing was passed here, so a build folder that could not be read was
646
+ // simply not in the list — and "not in the list" is how this file spells "never existed".
647
+ // The build the reference pointer names is exactly the one most likely to have been
648
+ // written to most recently, and so exactly the one most likely to be the damaged folder.
649
+ const builds = await listBuilds(store, { product: name, onProblem: (m) => skipped.push(m) });
605
650
  const hit = builds.find((b) => b.fingerprint.id === pointer.buildId);
606
651
  const sha = hit?.fingerprint.gitSha ?? shaFromBuildId(pointer.buildId);
607
- if (!sha) return null;
608
- return { sha, note: hit?.fingerprint.version ? `version ${hit.fingerprint.version}` : '' };
609
- } catch {
610
- // A store that will not open is a reason to try the next mode, never a reason to fail.
611
- return null;
652
+ if (!sha) return { found: null, trouble: troubleFrom(skipped, '') };
653
+ return {
654
+ found: { sha, note: hit?.fingerprint.version ? `version ${hit.fingerprint.version}` : '' },
655
+ // Named even on the way to a good answer. A reference found without the record that
656
+ // was supposed to describe it is a weaker answer than one found with it.
657
+ trouble: hit ? troubleFrom(skipped, '') : troubleFrom(skipped, `The record of build ${pointer.buildId} itself was not among them.`),
658
+ };
659
+ } catch (e) {
660
+ // A store that will not open is a reason to try the next mode, never a reason to fail —
661
+ // but it is never a reason to then say this project has nothing on record either.
662
+ return { found: null, trouble: troubleFrom(skipped, `The store here could not be read at all: ${messageOf(e)}`) };
612
663
  }
613
664
  }
614
665
 
@@ -617,26 +668,47 @@ async function releasedCommit(cwd, product) {
617
668
  *
618
669
  * @param {string} cwd
619
670
  * @param {string} [product]
620
- * @returns {Promise<{buildId: string, machine: 'same'|'different'|'unknown'}|null>}
671
+ * @returns {Promise<LookedInTheStore<{buildId: string, machine: 'same'|'different'|'unknown'}>>}
621
672
  */
622
673
  async function storedRecord(cwd, product) {
674
+ /** @type {string[]} */
675
+ const skipped = [];
623
676
  try {
624
677
  const store = openStore({ root: cwd });
625
678
  const name = product ?? (await productName(cwd));
626
- if (!name) return null;
679
+ if (!name) return { found: null, trouble: '' };
627
680
  const pointer = await referencePointer(store, name);
628
- const builds = await listBuilds(store, { product: name });
629
- if (builds.length === 0) return null;
681
+ const builds = await listBuilds(store, { product: name, onProblem: (m) => skipped.push(m) });
682
+ if (builds.length === 0) return { found: null, trouble: troubleFrom(skipped, '') };
630
683
  const hit = (pointer && builds.find((b) => b.fingerprint.id === pointer.buildId)) || builds[0];
631
684
  const here = `${process.platform}-${process.arch}`;
632
685
  /** @type {'same'|'different'|'unknown'} */
633
686
  const machine = !hit.fingerprint.platform ? 'unknown' : hit.fingerprint.platform === here ? 'same' : 'different';
634
- return { buildId: hit.fingerprint.id, machine };
635
- } catch {
636
- return null;
687
+ return { found: { buildId: hit.fingerprint.id, machine }, trouble: troubleFrom(skipped, '') };
688
+ } catch (e) {
689
+ return { found: null, trouble: troubleFrom(skipped, `The store here could not be read at all: ${messageOf(e)}`) };
637
690
  }
638
691
  }
639
692
 
693
+ /**
694
+ * One sentence about what could not be seen, or nothing at all when everything could.
695
+ *
696
+ * @param {string[]} skipped
697
+ * @param {string} extra
698
+ * @returns {string}
699
+ */
700
+ function troubleFrom(skipped, extra) {
701
+ /** @type {string[]} */
702
+ const parts = [];
703
+ if (skipped.length > 0) {
704
+ parts.push(
705
+ `${skipped.length} stored build ${skipped.length === 1 ? 'record was' : 'records were'} skipped because ${skipped.length === 1 ? 'it' : 'they'} could not be read, so what is on record here may be more than this run could see: ${skipped.join(' ')}`,
706
+ );
707
+ }
708
+ if (extra) parts.push(extra);
709
+ return parts.join(' ');
710
+ }
711
+
640
712
  // ---------------------------------------------------------------------------
641
713
  // The report
642
714
  // ---------------------------------------------------------------------------
@@ -816,23 +888,31 @@ function findingLines(f) {
816
888
  /**
817
889
  * Append the report to the job summary, when the build server has one.
818
890
  *
819
- * Returns the file it wrote to, or null when there is nowhere to write. Null is a normal
820
- * answer on GitLab and CircleCI, neither of which has a summary page.
891
+ * TWO ANSWERS THAT USED TO BE ONE NULL. "This server has no summary page" is the normal
892
+ * state on GitLab and CircleCI and needs saying to nobody. "This server HAS one and the
893
+ * report did not reach it" is a report that has vanished — the person opens the job, sees
894
+ * the tab they expect, finds nothing under it, and concludes the check did not run. Both
895
+ * came back as null, so nothing anywhere could tell them apart or mention the second.
896
+ *
897
+ * The exit code is untouched either way. Losing the page must never cost the half that
898
+ * actually stops the merge.
821
899
  *
822
900
  * @param {CIReport} report
823
901
  * @param {CIEnvironment} [env]
824
- * @returns {Promise<string|null>}
902
+ * @returns {Promise<{file: string|null, why: string}>} `why` is empty unless there was a
903
+ * page to write to and writing failed.
825
904
  */
826
905
  export async function writeJobSummary(report, env) {
827
906
  const where = (env ?? detectCI()).summaryFile;
828
- if (!where) return null;
907
+ if (!where) return { file: null, why: '' };
829
908
  try {
830
909
  await fsp.appendFile(where, `${report.markdown}\n`);
831
- return where;
832
- } catch {
833
- // A summary page is a nicety. Losing it must never cost the exit code, which is the
834
- // half that actually stops the merge.
835
- return null;
910
+ return { file: where, why: '' };
911
+ } catch (e) {
912
+ return {
913
+ file: null,
914
+ why: `The report could not be written to this job's summary page (${where}): ${messageOf(e)}. The answer below is the whole of it.`,
915
+ };
836
916
  }
837
917
  }
838
918
 
@@ -931,12 +1011,27 @@ export async function runCI(opts = {}) {
931
1011
 
932
1012
  /** @type {string|null} */
933
1013
  let evidence = null;
1014
+ /** @type {string[]} */
1015
+ const lost = [];
934
1016
  try {
935
1017
  evidence = await saveEvidence({ cwd, dir: opts.evidenceDir, verdict, reference, report, env: where });
936
- } catch {
937
- // Losing the attachment must never change the answer.
1018
+ } catch (e) {
1019
+ // Losing the attachment must never change the answer — and it must never be lost in
1020
+ // silence either, because the report above says in so many words that the rest of the
1021
+ // gaps are "in the evidence attached to this run". A run that swallowed this printed
1022
+ // that sentence pointing at a folder that was never written.
1023
+ lost.push(`THE EVIDENCE FOR THIS RUN WAS NOT SAVED: ${messageOf(e)} Anything the report says is in the attached evidence is not there, so what you can read above is all there is.`);
1024
+ }
1025
+ const summaryPage = await writeJobSummary(report, where);
1026
+ if (summaryPage.why) lost.push(summaryPage.why);
1027
+
1028
+ // Onto the report itself, both shapes of it, rather than only into a log line. The
1029
+ // markdown is what a person opens later and the text is what the job log keeps.
1030
+ if (lost.length > 0) {
1031
+ const said = lost.join(' ');
1032
+ report.markdown = `${report.markdown}\n\n> **${said}**\n`;
1033
+ report.text = `${report.text}\n${said}\n`;
938
1034
  }
939
- await writeJobSummary(report, where);
940
1035
 
941
1036
  if (opts.quiet !== true) process.stdout.write(`${report.text}\n`);
942
1037
  return { exitCode: report.exitCode, report, reference, verdict, evidence };
package/src/v2/cli.js CHANGED
@@ -35,7 +35,8 @@ import { escalationBlock, escalationsFor, productFor, writeEscalations } from '.
35
35
  // to mean two different things depending on which check you ran. src/cli/index.js imports
36
36
  // this file in turn; that circle is safe because nothing here touches it while either
37
37
  // module is still being evaluated.
38
- import { watchFlags } from '../cli/index.js';
38
+ import { watchFlags } from '../cli/watch-flags.js';
39
+ import { INIT_COMMANDS } from './init.js';
39
40
 
40
41
  /**
41
42
  * What comes back from a check. Everything that did not change never appears
@@ -102,35 +103,42 @@ const V1_OPTIONS = [
102
103
  ['--pictures', 'The version 1 picture check, unchanged.'],
103
104
  ['--guards', 'The version 1 guards, unchanged.'],
104
105
  ['--only <name>', 'Just this journey, screen or guard. Repeat it for several.'],
106
+ ['--record', 'The version 1 run that records network fixtures.'],
107
+ ['--report / --no-report', 'Write the version 1 HTML report. Version 1 checks only.'],
108
+ ['--profile', 'Print where the time went. Version 1 checks only.'],
105
109
  ];
106
110
 
107
111
  /**
108
- * The commands version 2 replaces, in exactly the shape `src/cli/index.js`
109
- * already uses for its own. Merging this over the existing table is the whole
110
- * of the wiring:
111
- *
112
- * import { V2_COMMANDS } from '../v2/cli.js';
113
- * Object.assign(COMMANDS, V2_COMMANDS);
114
- *
115
- * @type {Record<string, {summary: string, usage: string, describe: string, options: [string,string][], examples: string[], spec: {booleans?: string[], strings?: string[], arrays?: string[]}, load: () => Promise<{run: (ctx: any) => Promise<number>}>}>}
112
+ * Flags only version 1's check reads, by the name the parser knows them under.
113
+ *
114
+ * They are accepted on every `check` because the two halves share one spec — which is right,
115
+ * since `--pictures --profile` has to work. What was wrong is that typing one WITHOUT
116
+ * `--pictures` or `--guards` did nothing at all and said nothing at all: `staysfixed check
117
+ * --profile` ran a perfectly ordinary difference-engine check, printed no profile, and gave
118
+ * no hint that the flag had been ignored. A flag that is accepted and does nothing is the
119
+ * same lie as a flag that does not exist, and a slower one to find.
120
+ */
121
+ const V1_ONLY_FLAGS = [
122
+ ['profile', '--profile'],
123
+ ['report', '--report'],
124
+ ['record', '--record'],
125
+ ];
126
+
127
+ /**
128
+ * The commands version 2 contributes. `src/cli/index.js` merges these over version 1's,
129
+ * so `check` and `doctor` become the difference engine while everything version 1 did
130
+ * stays reachable behind `--pictures`, `--guards` and `--watch`.
116
131
  */
117
132
  export const V2_COMMANDS = {
118
- // `staysfixed ship` comes from src/v2/ship.js. It is merged in here rather than into the
119
- // version 1 command table so that wiring version 2 into the front door stays the one
120
- // import it has always been.
133
+ // Version 2's init replaces version 1's.
121
134
  //
122
- // `staysfixed init` from src/v2/init.js is deliberately NOT merged in yet, and this is
123
- // the same call as the MCP server: version 2's init is a BREAKING change. Version 1's
124
- // init writes settings for any folder; version 2's reads the project first and writes
125
- // nothing when it cannot tell what the project is, which is better and is not what the
126
- // three tests in test/cli.test.js describe. Somebody installed this last week and has
127
- // `staysfixed init` in a setup script. Adding one line here —
128
- //
129
- // import { INIT_COMMANDS } from './init.js'; ...INIT_COMMANDS,
130
- //
131
- // switches it over, and those three tests have to be rewritten in the same change to
132
- // say what the new one does. That is a decision, not an oversight, and it belongs in a
133
- // change of its own rather than arriving as a side effect of wiring up the phones.
135
+ // It was held back on the grounds that somebody might have `staysfixed init` in a
136
+ // setup script and would get a different file. True, and not a reason: the old one
137
+ // writes settings for photographing screens, which is not what this tool does any
138
+ // more, so leaving it in place hands a new project the wrong shape and calls it done.
139
+ // The three tests in test/cli.test.js were rewritten in the same change to say what
140
+ // the new one actually does.
141
+ ...INIT_COMMANDS,
134
142
  ...SHIP_COMMANDS,
135
143
 
136
144
  check: {
@@ -203,6 +211,16 @@ export async function run(ctx) {
203
211
  return await v1.run(ctx);
204
212
  }
205
213
 
214
+ // Say so when a flag was accepted and will do nothing.
215
+ //
216
+ // This goes to standard error, so it cannot corrupt --json, and it is a warning rather
217
+ // than a refusal: the person asked for a real check and they should still get one.
218
+ for (const [flag, written] of V1_ONLY_FLAGS) {
219
+ if (ctx.flags[flag] !== undefined) {
220
+ warn(`${written} only applies to the version 1 check. This run is the difference engine, so it was ignored — add --pictures or --guards if that is what you wanted.`);
221
+ }
222
+ }
223
+
206
224
  // --json means the answer belongs to a machine. Every line meant for a person
207
225
  // is switched off before anything else runs, rather than trusted not to
208
226
  // print: one stray sentence on standard output and the JSON will not parse.
package/src/v2/cluster.js CHANGED
@@ -27,14 +27,6 @@ import { splitPath, sameValue } from './observation.js';
27
27
  /** @typedef {import('./types.js').Channel} Channel */
28
28
  /** @typedef {import('./types.js').ObservedValue} ObservedValue */
29
29
 
30
- /** How much of a cluster a finding carries with it. Enough to orient, not enough to bury. */
31
- const KEEP_NEAR_FILES = 5;
32
-
33
- /**
34
- * How many of a cluster's addresses travel with the finding. `count` always says how
35
- * many there really are, so a long cluster cannot hide its size behind a short list.
36
- */
37
- const KEEP_PATHS = 20;
38
30
 
39
31
  /**
40
32
  * How each channel introduces itself. This is the first half of every sentence
@@ -55,8 +47,25 @@ const CHANNEL_WORDS = {
55
47
  /**
56
48
  * Last segments too vague to identify anything alone. When an address ends in
57
49
  * one of these, the segment before it comes along for the ride.
50
+ *
51
+ * WHAT PUTS A WORD IN HERE. Every address ends either in something the PRODUCT
52
+ * named — an exported function, a route, a control, a field — or in a word this
53
+ * tool wrote to say what was being asked about it. The first kind identifies
54
+ * something on its own. The second kind is the same word on every journey, every
55
+ * door and every screen in the product, so a sentence built out of it names
56
+ * nothing: `"declared" is gone` was a real title, about a route that had been
57
+ * renamed, and the route was sitting one segment to the left the whole time.
58
+ * That sentence is not only read by an agent — it goes verbatim into the block a
59
+ * person reads in the closing summary, and the owner of this tool is not a coder.
60
+ *
61
+ * So this holds the words the tool writes, checked against the addresses the
62
+ * adapters actually produce rather than guessed at. `smartLeaf names the half a
63
+ * reader can act on` in the tests sweeps one real address of every shape through
64
+ * here; a new adapter that ends an address in a shared word belongs in that list
65
+ * and in this set on the same day.
58
66
  */
59
67
  const VAGUE = new Set([
68
+ // What was asked about a thing: the thing itself is one segment to the left.
60
69
  'value',
61
70
  'text',
62
71
  'name',
@@ -64,14 +73,45 @@ const VAGUE = new Set([
64
73
  'label',
65
74
  'title',
66
75
  'count',
76
+ 'size',
77
+ 'reason',
67
78
  'enabled',
68
79
  'visible',
69
80
  'status',
70
81
  'type',
71
82
  'id',
72
83
  'body',
84
+ 'shape',
73
85
  'result',
74
86
  'exit',
87
+ // Doors, which all end the same way whatever the door is: a route, a command,
88
+ // an IPC channel, a named control on a phone.
89
+ 'declared',
90
+ 'registered',
91
+ 'reached',
92
+ // What happened when we asked, on every surface there is.
93
+ 'asked',
94
+ 'answered',
95
+ 'answered at all',
96
+ 'ran at all',
97
+ 'opened at all',
98
+ 'walked',
99
+ 'read',
100
+ 'typed',
101
+ 'pressed',
102
+ 'done',
103
+ 'started',
104
+ 'finished',
105
+ 'refused',
106
+ 'settled',
107
+ 'held still',
108
+ 'looks like',
109
+ 'written',
110
+ 'still running',
111
+ 'stdout',
112
+ 'stderr',
113
+ 'controls',
114
+ 'picture',
75
115
  ]);
76
116
 
77
117
  /**
@@ -194,7 +234,15 @@ export function journeysOf(finding) {
194
234
  */
195
235
  function buildFinding(signature, members, rename, sources) {
196
236
  const head = members[0];
197
- const nearFiles = unique(members.map((m) => sources[m.path])).slice(0, KEEP_NEAR_FILES);
237
+ // EVERY source file this finding touches, not the first five. The short list was written
238
+ // as "enough to orient, not enough to bury", and the things reading it are not orienting.
239
+ // `sealed.js` searches these names for the words nobody may wave through, so a finding
240
+ // whose sixth file was src/billing/refund.js was classified ordinary and became waivable;
241
+ // `intent.js` matches them against what the agent declared it was changing, and `cause.js`
242
+ // uses them to work out which edit caused what. A cap on the input to the one gate that
243
+ // cannot have a ceiling is the same bug that was closed for differences. Everything that
244
+ // DISPLAYS this list already cuts it itself and says "and N more" when it does.
245
+ const nearFiles = unique(members.map((m) => sources[m.path]));
198
246
  const evidence = members.find((m) => typeof m.evidence === 'string' && m.evidence.length > 0)?.evidence;
199
247
  // Half the differences in a rename are the "vanished" side, so the count of
200
248
  // places is the count of pairs, not of rows.
@@ -225,7 +273,16 @@ function buildFinding(signature, members, rename, sources) {
225
273
  // everything downstream — the MCP reply lists them, and the self-check corpus
226
274
  // matches its patterns against them — so they are filled in here rather than
227
275
  // left for each reader to dig out of `differences` in its own way.
228
- paths: members.map((m) => m.path).slice(0, KEEP_PATHS),
276
+ // EVERY address, not the first twenty. The short list was there to keep a finding small,
277
+ // and it was buying nothing: the finding already carries `differences`, which holds the
278
+ // same addresses AND both values at each of them, so cutting this list saved a fraction
279
+ // of what was being stored anyway. What it cost was real. A waiver is pinned partly to
280
+ // this list, so two three-hundred-address findings that agreed about their first twenty
281
+ // pinned to the same thing; and the reply an agent reads prints the length of this list
282
+ // under the heading "every address that moved", which was a count of twenty about a
283
+ // finding with three hundred. Readers that want a short list still cut it themselves,
284
+ // and every one of them says "and N more" when it does.
285
+ paths: members.map((m) => m.path),
229
286
  sample: head,
230
287
  };
231
288
  if (nearFiles.length > 0) finding.nearFiles = nearFiles;
@@ -259,8 +316,23 @@ export function describe(d, count, rename, identical = true) {
259
316
  if (rename) return `${where}, "${rename.from}" is now called "${rename.to}".${spread}`;
260
317
 
261
318
  switch (d.kind) {
262
- case 'changed':
263
- return `${where}, "${name}" is now ${describeValue(d.candidate)} where it was ${describeValue(d.reference)}.${spread}`;
319
+ case 'changed': {
320
+ const now = describeValue(d.candidate);
321
+ const was = describeValue(d.reference);
322
+ if (now !== was) return `${where}, "${name}" is now ${now} where it was ${was}.${spread}`;
323
+ // BOTH SIDES CAME OUT IN THE SAME WORDS, so this sentence would say nothing changed
324
+ // while sitting on top of a difference. It is what happens whenever the two values
325
+ // are summarised by their SHAPE and the shape held still: an invoice line that went
326
+ // from "£49.99" to "49.99 GBP" read "is now a set of details (one field: line) where
327
+ // it was a set of details (one field: line)" — twice the same words, on the tool's own
328
+ // flagship example, in the paragraph a person reads rather than an agent. So the
329
+ // summary is put down and the thing that actually moved is named instead.
330
+ const moved = whatMoved(d.reference, d.candidate);
331
+ if (!moved) return `${where}, "${name}" changed, and both versions of it read the same at this length.${spread}`;
332
+ return moved.what === ''
333
+ ? `${where}, "${name}" now reads ${moved.now} where it read ${moved.was}.${spread}`
334
+ : `${where}, "${name}" now has "${moved.what}" reading ${moved.now} where it read ${moved.was}.${spread}`;
335
+ }
264
336
  case 'appeared':
265
337
  return `${where}, "${name}" is there now and was not before. It says ${describeValue(d.candidate)}.${spread}`;
266
338
  case 'vanished':
@@ -354,7 +426,86 @@ export function describeValue(value) {
354
426
  if (Array.isArray(value)) return `a list of ${value.length}`;
355
427
  const keys = Object.keys(/** @type {object} */ (value));
356
428
  if (keys.length === 0) return 'an empty set of details';
357
- return `a set of details (${keys.slice(0, 4).join(', ')}${keys.length > 4 ? ', and more' : ''})`;
429
+ // The field names are quoted and counted. Bare, they run into the sentence around them and
430
+ // stop looking like names at all: a shape whose fields are the words "a list of" and "each
431
+ // one" came out as "a set of details (a list of, each one)", which is not a thing anybody
432
+ // can picture. Quoted, it reads as what it is.
433
+ const shown = keys.slice(0, 4).map((k) => JSON.stringify(k)).join(', ');
434
+ return `a set of details (${keys.length === 1 ? 'one field' : `${keys.length} fields`}: ${shown}${keys.length > 4 ? ', and more' : ''})`;
435
+ }
436
+
437
+ /**
438
+ * The smallest thing that actually moved between two values.
439
+ *
440
+ * Only reached when a summary of the two whole values comes out identical, which is exactly
441
+ * when a summary is the wrong thing to print. It walks in until it finds the one field, or
442
+ * the one stretch of text, that is not the same, and hands back that piece with a name for
443
+ * it. `sameValue` is the tool's one comparison, so what counts as "not the same" here is
444
+ * what counts as a difference everywhere else.
445
+ *
446
+ * @param {ObservedValue|undefined} reference
447
+ * @param {ObservedValue|undefined} candidate
448
+ * @param {string[]} [trail]
449
+ * @returns {{what: string, was: string, now: string}|null}
450
+ */
451
+ function whatMoved(reference, candidate, trail = []) {
452
+ if (isSetOfDetails(reference) && isSetOfDetails(candidate)) {
453
+ for (const key of [...new Set([...Object.keys(reference), ...Object.keys(candidate)])].sort()) {
454
+ const a = /** @type {Record<string, any>} */ (reference)[key];
455
+ const b = /** @type {Record<string, any>} */ (candidate)[key];
456
+ if (sameValue(a, b)) continue;
457
+ return whatMoved(a, b, [...trail, key]);
458
+ }
459
+ return null;
460
+ }
461
+ if (Array.isArray(reference) && Array.isArray(candidate)) {
462
+ for (let i = 0; i < Math.max(reference.length, candidate.length); i += 1) {
463
+ if (sameValue(reference[i], candidate[i])) continue;
464
+ return whatMoved(reference[i], candidate[i], [...trail, `number ${i + 1}`]);
465
+ }
466
+ return null;
467
+ }
468
+ const what = trail.join(' / ');
469
+ if (typeof reference === 'string' && typeof candidate === 'string') {
470
+ // Two long strings summarise to their first sixty-odd characters, so if they agree that
471
+ // far they read the same however differently they end. A window round the first place
472
+ // they part company says what neither summary can.
473
+ const spot = firstDifference(reference, candidate);
474
+ return { what, was: JSON.stringify(spot.was), now: JSON.stringify(spot.now) };
475
+ }
476
+ const was = describeValue(reference);
477
+ const now = describeValue(candidate);
478
+ return was === now ? null : { what, was, now };
479
+ }
480
+
481
+ /**
482
+ * A window round the first character two pieces of text stop agreeing at, with enough either
483
+ * side to recognise the place.
484
+ *
485
+ * @param {string} a
486
+ * @param {string} b
487
+ * @returns {{was: string, now: string}}
488
+ */
489
+ function firstDifference(a, b) {
490
+ // Short enough to read whole, so read it whole. A window round the difference is only
491
+ // worth its ellipses when there is genuinely too much text to print.
492
+ if (a.length <= 70 && b.length <= 70) return { was: a, now: b };
493
+ let at = 0;
494
+ while (at < a.length && at < b.length && a[at] === b[at]) at += 1;
495
+ const from = Math.max(0, at - 20);
496
+ /** @param {string} text */
497
+ const window = (text) => `${from > 0 ? '…' : ''}${text.slice(from, at + 40)}${at + 40 < text.length ? '…' : ''}`;
498
+ return { was: window(a), now: window(b) };
499
+ }
500
+
501
+ /**
502
+ * A value made of named fields, as opposed to a list, a number, or a piece of text.
503
+ *
504
+ * @param {unknown} value
505
+ * @returns {value is Record<string, unknown>}
506
+ */
507
+ function isSetOfDetails(value) {
508
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
358
509
  }
359
510
 
360
511
  /**