staysfixed 0.11.0 → 0.12.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.
@@ -373,16 +373,54 @@ export async function ensureDevice(opts = {}) {
373
373
  const typeId = await pickDeviceType(opts.deviceType, { signal: opts.signal });
374
374
  if (!typeId.ok) return { ok: false, device: null, why: typeId.why };
375
375
 
376
- const made = await simctl(['create', wanted, typeId.id, runtime.id], { timeoutMs: 120_000, signal: opts.signal });
377
- if (!made.ok) return { ok: false, device: null, why: `A simulator called ${wanted} could not be made: ${firstLine(made.stderr) || made.why}` };
378
- const udid = made.stdout.trim();
376
+ // EVERY kind of iPhone is tried, newest first, not just the first one.
377
+ //
378
+ // Not every phone runs on every version of iOS, and Apple says so with a number and no
379
+ // words: measured on this Mac on 2026-08-31, asking for an iPhone 6s on iOS 27.0 came back
380
+ // as `SimError 403` with an empty message. One attempt meant one number, and the sentence
381
+ // handed to a person was "a simulator could not be made" on a Mac that could perfectly well
382
+ // make several. So the loop walks down the list, and if every phone this Mac has is refused
383
+ // by every runtime it has, the refusal that comes back names what was tried and how many.
384
+ //
385
+ // SIX, not all of them. A refusal comes back instantly, but a simulator tool that has
386
+ // wedged does not — it takes the full two minutes before this gives up on it — and this Mac
387
+ // lists forty kinds of iPhone. Forty of those in a row is eighty minutes of a check that
388
+ // looks like it has hung, which is worse than a clear failure. Six covers every real case:
389
+ // if the six newest phones a Mac has all refuse a runtime, the seventh will too.
390
+ const MOST_TRIED = 6;
391
+ /** @type {string[]} */
392
+ const refused = [];
393
+ let udid = '';
394
+ let label = '';
395
+ for (const candidate of typeId.candidates.slice(0, MOST_TRIED)) {
396
+ const made = await simctl(['create', wanted, candidate.id, runtime.id], { timeoutMs: 120_000, signal: opts.signal });
397
+ if (made.ok && made.stdout.trim() !== '') {
398
+ udid = made.stdout.trim();
399
+ label = candidate.label;
400
+ break;
401
+ }
402
+ refused.push(`${candidate.label} (${firstLine(made.stderr) || made.why})`);
403
+ }
404
+ if (udid === '') {
405
+ return {
406
+ ok: false,
407
+ device: null,
408
+ why:
409
+ `A simulator called ${wanted} could not be made. ${refused.length} kind${refused.length === 1 ? '' : 's'} of iPhone ` +
410
+ `${refused.length === 1 ? 'was' : 'were'} tried on ${runtime.name}${typeId.candidates.length > MOST_TRIED ? ` (the newest ${MOST_TRIED} of the ${typeId.candidates.length} this Mac has)` : ''} and every one was refused. ` +
411
+ `The first was: ${refused[0] ?? 'nothing at all was tried'}. ` +
412
+ 'This usually means the iOS version installed here is newer than every phone this Mac knows about, or older than all of them — opening Xcode once and letting it finish installing its simulator components fixes it.',
413
+ };
414
+ }
379
415
 
380
416
  const booted = await bootDevice(udid, { signal: opts.signal });
381
417
  if (!booted.ok) return { ok: false, device: null, why: booted.why };
382
418
  return {
383
419
  ok: true,
384
420
  device: { udid, name: wanted, runtimeName: runtime.name, weMadeIt: true, weBootedIt: true, why: booted.why },
385
- why: `Made a new ${typeId.label} on ${runtime.name} called ${wanted}, and booted it.`,
421
+ why:
422
+ `Made a new ${label} on ${runtime.name} called ${wanted}, and booted it.` +
423
+ (refused.length > 0 ? ` ${refused.length} newer kind${refused.length === 1 ? '' : 's'} of iPhone would not run on ${runtime.name}, so ${label} was used instead.` : ''),
386
424
  };
387
425
  }
388
426
 
@@ -419,9 +457,25 @@ function compareVersions(a, b) {
419
457
  }
420
458
 
421
459
  /**
460
+ * Which kinds of iPhone this Mac could make, best first.
461
+ *
462
+ * A LIST rather than one answer, and the reason was measured on this Mac on 2026-08-31.
463
+ * `simctl list devicetypes` prints the newest iPhone first — iPhone 17 Pro at the top,
464
+ * iPhone 6s at the bottom — and this function used to take the LAST phone in that list.
465
+ * So on a Mac whose only iOS runtime was 27.0, it asked for an iPhone 6s on iOS 27, which
466
+ * Apple refuses outright: `simctl create` came back with SimError 403 and no explanation,
467
+ * `prepare` reported "a simulator called staysfixed-ios could not be made", and the whole
468
+ * iPhone surface was dark on a machine with Xcode, a runtime and a built app all sitting
469
+ * there ready. Nothing said the pairing was the problem, so the message read like the Mac
470
+ * was broken.
471
+ *
472
+ * Two things changed. The newest phone is picked first, because a runtime always supports
473
+ * the hardware of its own year. And every other phone is handed back behind it in order, so
474
+ * a caller that gets refused can try the next one instead of giving up on the platform.
475
+ *
422
476
  * @param {string|undefined} wanted
423
477
  * @param {{signal?: AbortSignal}} opts
424
- * @returns {Promise<{ok: boolean, id: string, label: string, why: string}>}
478
+ * @returns {Promise<{ok: boolean, id: string, label: string, why: string, candidates: {id: string, label: string}[]}>}
425
479
  */
426
480
  async function pickDeviceType(wanted, opts) {
427
481
  const listed = await simctl(['list', '-j', 'devicetypes'], { timeoutMs: 45_000, signal: opts.signal });
@@ -432,17 +486,46 @@ async function pickDeviceType(wanted, opts) {
432
486
  identifier: String(t.identifier), name: String(t.name),
433
487
  }));
434
488
  } catch {
435
- return { ok: false, id: '', label: '', why: 'The list of device kinds could not be read, so no device can be made.' };
489
+ return { ok: false, id: '', label: '', why: 'The list of device kinds could not be read, so no device can be made.', candidates: [] };
436
490
  }
437
491
  if (wanted) {
438
492
  const found = types.find((t) => t.identifier === wanted || t.name === wanted);
439
- if (found) return { ok: true, id: found.identifier, label: found.name, why: '' };
440
- return { ok: false, id: '', label: '', why: `This machine has no simulator called "${wanted}".` };
493
+ if (found) return { ok: true, id: found.identifier, label: found.name, why: '', candidates: [{ id: found.identifier, label: found.name }] };
494
+ return { ok: false, id: '', label: '', why: `This machine has no simulator called "${wanted}".`, candidates: [] };
495
+ }
496
+ const candidates = phoneKindsToTry(types);
497
+ const pick = candidates[0];
498
+ if (!pick) return { ok: false, id: '', label: '', why: 'This machine has no iPhone simulator kind at all.', candidates: [] };
499
+ return { ok: true, id: pick.id, label: pick.label, why: '', candidates };
500
+ }
501
+
502
+ /**
503
+ * Every kind of iPhone worth trying, best first.
504
+ *
505
+ * Pulled out of `pickDeviceType` so the ORDER can be tested on any machine, including one
506
+ * with no Xcode on it. The order is the whole bug: `simctl list devicetypes` prints the
507
+ * newest iPhone first and the oldest last, and taking the last one asked for an iPhone 6s on
508
+ * iOS 27.0, which Apple refuses with `SimError 403` and no words. Measured on this Mac on
509
+ * 2026-08-31, where it left the entire iPhone surface dark on a machine that had Xcode, a
510
+ * runtime and a built app all sitting there ready.
511
+ *
512
+ * A plain iPhone comes before a Plus, a Max, a mini or an `e`, because those are the same
513
+ * year's hardware in an awkward shape and a plain one is the least surprising thing to
514
+ * compare on; within each group the list's own order is kept, which is newest first.
515
+ *
516
+ * @param {{identifier: string, name: string}[]} types
517
+ * @returns {{id: string, label: string}[]}
518
+ */
519
+ export function phoneKindsToTry(types) {
520
+ const plain = types.filter((t) => /SimDeviceType\.iPhone-\d/.test(t.identifier) && !/Plus|Max|mini|e$/.test(t.name));
521
+ const anyPhone = types.filter((t) => t.identifier.includes('iPhone'));
522
+ /** @type {{id: string, label: string}[]} */
523
+ const candidates = [];
524
+ for (const t of [...plain, ...anyPhone]) {
525
+ if (candidates.some((c) => c.id === t.identifier)) continue;
526
+ candidates.push({ id: t.identifier, label: t.name });
441
527
  }
442
- const phones = types.filter((t) => /SimDeviceType\.iPhone-\d/.test(t.identifier) && !/Plus|Max|mini|e$/.test(t.name));
443
- const pick = phones[phones.length - 1] ?? types.find((t) => t.identifier.includes('iPhone'));
444
- if (!pick) return { ok: false, id: '', label: '', why: 'This machine has no iPhone simulator kind at all.' };
445
- return { ok: true, id: pick.identifier, label: pick.name, why: '' };
528
+ return candidates;
446
529
  }
447
530
 
448
531
  /**
@@ -26,6 +26,30 @@
26
26
  * never on screen during a run.
27
27
  * - COUNTERS AND PICTURES, coarse and last.
28
28
  *
29
+ * IS A PAIRED RUN POSSIBLE HERE? YES, AND IT HAS NOW BEEN MEASURED.
30
+ *
31
+ * A paired run means the old build is put back on this machine and walked minutes before the
32
+ * new one, so nothing that drifted in between — the weather, a dependency, the clock — can be
33
+ * mistaken for somebody's change. On a phone that means one device, two builds one after the
34
+ * other, and the device put back in between. Until 2026-08-31 this was not offered, because
35
+ * nobody had ever checked whether the device really does come back to the same place.
36
+ *
37
+ * It has been checked. On an Apple Silicon Mac, on 2026-08-31, against Terminal Deck's own
38
+ * iPhone app (0.15.0, build 2608221311) on a simulator this adapter made for itself — an
39
+ * iPhone 17 Pro on iOS 27.0 — ONE build was walked ten times, with the device put back
40
+ * between every walk exactly the way it is put back between two builds: the app and
41
+ * everything it had written removed, and every permission it had been granted taken back.
42
+ *
43
+ * Five pairs. 725 addresses in each walk, 215 of them read out of the RUNNING app and the
44
+ * rest out of the bundle and the source. 725 of 725 agreed, in all five pairs — 3,625
45
+ * comparisons and not one disagreement. The Mac was carrying a load average of about 500 at
46
+ * the time, which makes that the harsher version of the result rather than the flattering
47
+ * one.
48
+ *
49
+ * So a paired iOS run is offered. What actually limits it is not the simulator — it is
50
+ * getting hold of the OLD build's app bundle, because a `.app` is a build output and a
51
+ * checkout of the old commit does not contain one. See `prepare` and `ios.reference`.
52
+ *
29
53
  * WHAT IT CANNOT SEE, and these are not hedges.
30
54
  *
31
55
  * - A REAL iPHONE. Nothing here touches a device somebody is holding. A paired run means
@@ -101,6 +125,111 @@ import {
101
125
  /** Everything prepared, per build, so `run` can be called many times without re-installing. */
102
126
  const ready = new Map();
103
127
 
128
+ /**
129
+ * Which app bundle each half of a comparison was walked from, remembered across the run.
130
+ *
131
+ * It lives out here rather than on a prepared build because the engine prepares one build,
132
+ * walks one journey against it and throws it away before the next: nothing kept on a
133
+ * prepared build survives long enough to notice that the old build and the new build were
134
+ * the same folder on disk.
135
+ *
136
+ * And that is the thing worth noticing. `ios.app` in the settings is usually an absolute
137
+ * path — Xcode writes into DerivedData, which is nowhere near the project — and an absolute
138
+ * path does not move when the old commit is checked out somewhere else. So both halves of a
139
+ * paired run would read the SAME bundle, find nothing different, and the run would say
140
+ * "nothing that worked has changed" about a comparison that never took place. That is the
141
+ * one failure this tool exists to prevent, arriving through the front door. Anything caught
142
+ * here is reported as a hole on every journey; see `run`.
143
+ *
144
+ * Keyed by role — 'candidate' or 'reference' — and emptied by `teardown`.
145
+ *
146
+ * @type {Map<string, string>}
147
+ */
148
+ const walkedFrom = new Map();
149
+
150
+ /**
151
+ * The build ids that turned out to be the other half of the comparison, and the sentence
152
+ * that says so.
153
+ *
154
+ * Separate from `ready` above because `ready` only holds builds that reached a simulator,
155
+ * and the warning has to survive a build that did not: a reference half that failed to
156
+ * prepare AND was the same bundle as the candidate is two pieces of bad news, and losing the
157
+ * second one is how a comparison comes back green for the wrong reason.
158
+ *
159
+ * @type {Map<string, string>}
160
+ */
161
+ const sameBundleFor = new Map();
162
+
163
+ /**
164
+ * Which bundle this really is: the path with the links and the `..`s taken out.
165
+ *
166
+ * A symlink, a `./` or a `..` must not be able to make one bundle look like two, because one
167
+ * bundle looking like two is the whole failure being guarded against here.
168
+ *
169
+ * @param {string} appPath
170
+ * @returns {Promise<string>}
171
+ */
172
+ async function bundleIdentity(appPath) {
173
+ try {
174
+ return await fsp.realpath(appPath);
175
+ } catch {
176
+ // A path that will not resolve is still worth remembering exactly as it was typed. The
177
+ // question below is whether the two halves agree, not whether the bundle is there.
178
+ return appPath;
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Did this half of the comparison read the same app bundle as the other half?
184
+ *
185
+ * Returns the sentence to put in front of a reader, or null when the two halves really are
186
+ * two different bundles.
187
+ *
188
+ * WHAT IT DOES NOT CATCH, said here rather than left to be discovered: two different paths
189
+ * holding the same build. Somebody whose release script copies today's build to
190
+ * `builds/latest/App.app` and points `reference` at it is comparing one build against itself
191
+ * with two names, and nothing here notices. That was left alone on purpose — two different
192
+ * bundles are usually two builds somebody produced on purpose, and refusing them on a guess
193
+ * would block real comparisons to prevent an unusual one.
194
+ *
195
+ * @param {'reference'|'candidate'} role
196
+ * @param {string} mine
197
+ * @returns {string|null}
198
+ */
199
+ function sameBundleAsTheOtherHalf(role, mine) {
200
+ // Only ever asked about the OLD build's half, and that is not squeamishness — it is the
201
+ // only answer that stays the same from one journey to the next. The engine prepares a
202
+ // build, walks ONE journey against it and throws it away, and it always does the new build
203
+ // first: new-run-a, new-run-b, old-run-a, old-run-b, then the same four again for the next
204
+ // journey. So from the second journey onwards the new build's half would find the previous
205
+ // journey's old half sitting in the map and flag itself as well — the warning would be
206
+ // absent on the first journey and doubled on every one after it, which reads like a bug in
207
+ // the tool rather than a fact about the run.
208
+ if (role !== 'reference') return null;
209
+ const other = walkedFrom.get('candidate');
210
+ if (!other || other !== mine) return null;
211
+ return `Both halves of this comparison were walked from the same app bundle: ${mine}. Nothing in it is older or newer than anything else in it, so no difference between the two builds could possibly show up.`;
212
+ }
213
+
214
+ /**
215
+ * Where a kept copy of the OLD build's app bundle lives, if the settings name one.
216
+ *
217
+ * Two spellings, because both read naturally and neither is worth an argument:
218
+ * `{"reference": "builds/0.14.0/YourApp.app"}` and
219
+ * `{"reference": {"app": "builds/0.14.0/YourApp.app"}}`. A relative path is resolved against
220
+ * whatever `findAppBundle` is given, which for the reference half is the checkout of the old
221
+ * commit — so a project that DOES commit a simulator build can leave this out entirely.
222
+ *
223
+ * @param {Record<string, any>} config
224
+ * @returns {string|undefined}
225
+ */
226
+ export function referenceBundle(config) {
227
+ const said = config?.reference;
228
+ if (typeof said === 'string' && said.trim() !== '') return said;
229
+ if (said && typeof said === 'object' && typeof said.app === 'string' && said.app.trim() !== '') return said.app;
230
+ return undefined;
231
+ }
232
+
104
233
  // ---------------------------------------------------------------------------
105
234
  // Reading the doors out of the source
106
235
  // ---------------------------------------------------------------------------
@@ -542,6 +671,13 @@ export const iosAdapter = defineAdapter({
542
671
  /** @type {string[]} */
543
672
  const notes = [];
544
673
 
674
+ // What was MEASURED goes on before the machine is asked, so it survives every early
675
+ // return below. These notes are what `doctor` prints and what an agent reads, and on a
676
+ // machine that is not a Mac they were dropped entirely — so somebody planning where to
677
+ // run their checks was told only "not here", never what a Mac would actually do or what
678
+ // it would need from them. A fact that exists only in a comment is a fact nobody sees.
679
+ notes.push('Putting the device back really does put it back, and that is measured rather than assumed. On 2026-08-31 one build was walked ten times on an iOS 27.0 simulator with the app removed and every permission taken back between walks: 725 of 725 addresses agreed in all five pairs — 3,625 comparisons, no disagreements. So a paired run is offered here. What it needs from you is a copy of the OLD build\'s app bundle, because a .app is a build output and a checkout of the old commit does not contain one: name it with {"reference": "path/to/TheOld.app"} under "ios" in the settings.');
680
+
545
681
  const machine = await readMachine();
546
682
  if (!machine.isMac) {
547
683
  return {
@@ -667,23 +803,61 @@ export const iosAdapter = defineAdapter({
667
803
  const scratch = path.join(ctx.scratchDir, `ios-${build.id.slice(0, 12).replace(/[^A-Za-z0-9_-]/g, '-')}`);
668
804
  await fsp.mkdir(scratch, { recursive: true });
669
805
 
806
+ // Worked out a few lines below, and captured here so that even a build which could not
807
+ // be got ready still says it. A reference half that failed AND was the same bundle as the
808
+ // candidate is two separate pieces of bad news, and the second one is the one that would
809
+ // otherwise be lost — the run would report "could not be prepared", somebody would fix
810
+ // that, and the comparison would come back green for the wrong reason.
811
+ /** @type {string|null} */
812
+ let sameBundle = null;
813
+
670
814
  /** @param {string} why */
671
815
  const notReady = (why) => ({
672
816
  build,
673
817
  root: scratch,
674
818
  ready: false,
675
- why,
819
+ why: sameBundle ? `${why} ${sameBundle}` : why,
820
+ ...(build.role === 'reference' ? { facts: { paired: sameBundle === null } } : {}),
676
821
  dispose: async () => {
822
+ sameBundleFor.delete(build.id);
677
823
  await fsp.rm(scratch, { recursive: true, force: true });
678
824
  },
679
825
  });
680
826
 
827
+ // WHICH bundle this half of the comparison walks.
828
+ //
829
+ // For the build you have, that is whatever the settings point at. For the build you were
830
+ // happy with it is different, and the difference is the whole of paired mode on a phone:
831
+ // the engine hands over a checkout of the old commit, and a `.app` is a BUILD OUTPUT that
832
+ // nobody commits, so a checkout of the old commit contains no app at all. `ios.reference`
833
+ // is where a kept copy of the old build's bundle goes, and it is looked at first for the
834
+ // reference half and never for the candidate.
835
+ const forThisHalf = build.role === 'reference' ? { ...config, app: referenceBundle(config) ?? config.app } : config;
836
+ const found = await findAppBundle(build.root, forThisHalf);
837
+ if (!found.ok) {
838
+ return notReady(
839
+ found.why +
840
+ (build.role === 'reference'
841
+ ? ' A paired run walks the OLD build here, and a .app is a build output that a repository does not commit — so a checkout of the old commit has no app in it. Keep a copy of each release\'s simulator build and point at it with {"reference": "path/to/TheOld.app"} under "ios" in the settings, and this becomes a real comparison. Without it this journey falls back to the record the old build left the last time it ran, which is weaker and says so.'
842
+ : ''),
843
+ );
844
+ }
845
+
846
+ // Two halves, one bundle. Worked out here, said on every journey — see `run`.
847
+ const mine = await bundleIdentity(found.appPath);
848
+ sameBundle = sameBundleAsTheOtherHalf(/** @type {'reference'|'candidate'} */ (build.role), mine);
849
+ walkedFrom.set(build.role, mine);
850
+ if (sameBundle) sameBundleFor.set(build.id, sameBundle);
851
+
852
+ // The machine is asked AFTER all of that, and the order is the point. Working out which
853
+ // bundle each half walks needs nothing but the filesystem, and it is true on every
854
+ // machine. Asking the machine first meant that anywhere an iPhone app cannot run — any
855
+ // Linux box, any Windows box, a Mac without Xcode — the reference half returned before
856
+ // the check and reported `paired: true`, which is a claim about a comparison it had not
857
+ // made. Caught by CI on Linux against a green Mac suite, 2026-08-31.
681
858
  const machine = await readMachine({ signal: ctx.signal });
682
859
  if (!machine.ok) return notReady(machine.why);
683
860
 
684
- const found = await findAppBundle(build.root, config);
685
- if (!found.ok) return notReady(found.why);
686
-
687
861
  const facts = await readAppBundle(found.appPath);
688
862
  if (!facts.ok) return notReady(facts.why);
689
863
 
@@ -720,7 +894,7 @@ export const iosAdapter = defineAdapter({
720
894
  build,
721
895
  root: scratch,
722
896
  ready: true,
723
- why: `${facts.name} ${facts.version} (${facts.build}) is on the simulator called ${device.device.name}, running ${device.device.runtimeName}. ${device.why} ${probe.ok ? 'The screen can be read by meaning.' : `The screen CANNOT be read by meaning: ${probe.why} Only pictures, logs, crashes and the files it writes are being checked, which is much less than it sounds.`}`,
897
+ why: `${facts.name} ${facts.version} (${facts.build}) is on the simulator called ${device.device.name}, running ${device.device.runtimeName}. ${device.why} ${probe.ok ? 'The screen can be read by meaning.' : `The screen CANNOT be read by meaning: ${probe.why} Only pictures, logs, crashes and the files it writes are being checked, which is much less than it sounds.`}${sameBundle ? ` ${sameBundle}` : ''}`,
724
898
  facts: {
725
899
  device: device.device.name,
726
900
  udid: device.device.udid,
@@ -729,10 +903,15 @@ export const iosAdapter = defineAdapter({
729
903
  version: facts.version,
730
904
  readsMeaning: probe.ok,
731
905
  weBootedIt: device.device.weBootedIt,
906
+ app: found.appPath,
907
+ // Only ever set on the OLD build's half, because that is the half the question is
908
+ // about: was there really a second build here, or did both halves read one bundle.
909
+ ...(build.role === 'reference' ? { paired: sameBundle === null } : {}),
732
910
  },
733
911
  dispose: async () => {
734
912
  const kept = ready.get(build.id);
735
913
  ready.delete(build.id);
914
+ sameBundleFor.delete(build.id);
736
915
  if (kept?.device) await releaseDevice(kept.device, { signal: ctx.signal });
737
916
  await fsp.rm(scratch, { recursive: true, force: true });
738
917
  },
@@ -748,28 +927,53 @@ export const iosAdapter = defineAdapter({
748
927
  * @returns {Promise<Observation[]>}
749
928
  */
750
929
  async run(journey, build, ctx) {
930
+ // FIRST, IN FRONT OF EVERY OTHER ANSWER THIS FUNCTION CAN GIVE.
931
+ //
932
+ // Said on every journey rather than once at the start, which is the same rule the web
933
+ // adapter follows for an app read at a fixed address. A run that compared one bundle
934
+ // against itself finds no differences, and "no differences" is the sentence this whole
935
+ // tool is believed for.
936
+ //
937
+ // It has to come before the "was this build ever got ready" answer below and not after
938
+ // it, because a reference half can fail to prepare AND have been the same bundle as the
939
+ // candidate. Say only the first and somebody fixes the preparation, runs it again, and
940
+ // gets a clean comparison of one bundle against itself with nothing anywhere to say so.
941
+ const sameBundle = sameBundleFor.get(build.build.id);
942
+ /** @type {Observation[]} */
943
+ const sameBundleSaid = sameBundle
944
+ ? [notCovered({
945
+ channel: 'meaning',
946
+ path: joinPath('screen', journey.name, 'which build this was'),
947
+ reason: 'not supported here',
948
+ says:
949
+ `${sameBundle} A .app is a build output, so a checkout of the old commit does not contain one and the settings' own path was used for both halves. ` +
950
+ 'Keep a copy of the simulator build you shipped and name it with {"reference": "path/to/TheOld.app"} under "ios" in the settings, and this becomes a real comparison.',
951
+ })]
952
+ : [];
953
+
751
954
  const kept = ready.get(build.build.id);
752
955
  if (!kept) {
753
- return [notCovered({
956
+ return [...sameBundleSaid, notCovered({
754
957
  channel: 'meaning',
755
958
  path: joinPath('screen', journey.name, 'walked'),
756
959
  reason: 'not supported here',
757
- says: 'This build was never got ready, so nothing about it could be walked.',
960
+ says: `This build was never got ready, so nothing about it could be walked. ${build.why}`,
758
961
  })];
759
962
  }
760
963
  if (journey.skip) {
761
- return [notCovered({
964
+ return [...sameBundleSaid, notCovered({
762
965
  channel: 'meaning',
763
966
  path: joinPath('screen', journey.name, 'walked'),
764
967
  reason: 'missing tool',
765
968
  says: journey.skip,
766
969
  })];
767
970
  }
971
+
768
972
  if (journey.name === 'what-the-app-declares') {
769
- return declaredObservations(kept.facts, kept.doors, journey.name, kept.limits ?? []);
973
+ return [...sameBundleSaid, ...declaredObservations(kept.facts, kept.doors, journey.name, kept.limits ?? [])];
770
974
  }
771
975
 
772
- return walkObservations(journey, kept, ctx);
976
+ return [...sameBundleSaid, ...(await walkObservations(journey, kept, ctx))];
773
977
  },
774
978
 
775
979
  async teardown() {
@@ -777,6 +981,12 @@ export const iosAdapter = defineAdapter({
777
981
  if (kept?.device) await releaseDevice(kept.device);
778
982
  ready.delete(id);
779
983
  }
984
+ // One run's memory of which bundle each half was walked from. It must not survive into
985
+ // the next run in the same process — the MCP server and the watch panel both call
986
+ // check() more than once — or a second run would report the first run's bundles as its
987
+ // own.
988
+ walkedFrom.clear();
989
+ sameBundleFor.clear();
780
990
  },
781
991
  });
782
992