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.
@@ -9,6 +9,27 @@
9
9
  * Everything below is arranged around that: one device, one build at a time, the whole
10
10
  * machine put back in between.
11
11
  *
12
+ * IS THAT GOOD ENOUGH FOR A PAIRED RUN? YES, AND IT HAS NOW BEEN MEASURED.
13
+ *
14
+ * Until 2026-08-31 this file said a paired run was not offered here, because nobody had proven
15
+ * that two emulator snapshots come back the same. That was a guess. It was measured that day,
16
+ * on an Apple Silicon Mac, against Terminal Deck's own Android app (dev.terminaldeck.apk.debug
17
+ * 0.13.0) on the `sfx_a33` virtual device — Android 13, Google APIs, arm64, no Play Store.
18
+ *
19
+ * One build was walked TEN times, and the device was put back from its own snapshot between
20
+ * every one of them, which is exactly what happens between the two builds of a paired run.
21
+ * Five pairs, 309 addresses in each walk. 301 of those 309 agreed in every single pair. The
22
+ * eight that did not were one thing seen at eight checkpoints: the identity code this app
23
+ * makes fresh for itself every time it is installed. A control run with the snapshot restore
24
+ * switched OFF produced the same eight and no others — so the restore contributed exactly
25
+ * zero disagreements, and the eight belong to the app, not to the emulator.
26
+ *
27
+ * Eight addresses out of 309 that move on their own is ordinary wobble, and the engine
28
+ * already measures and subtracts it: the new build is always walked twice and anything that
29
+ * cannot answer the same way twice is taken out of the comparison before a single difference
30
+ * is reported. So a paired Android run is offered, and what actually limits it is not the
31
+ * device at all — it is getting hold of the OLD build's package. See `prepare`.
32
+ *
12
33
  * What is read, and in which channel:
13
34
  *
14
35
  * CONTRACT Everything the APK declares, read out of the file without installing it and
@@ -517,6 +538,93 @@ export function screenObservations(input) {
517
538
  /** Keyed by build id, emptied on teardown. Never shared between two builds. */
518
539
  const open = new Map();
519
540
 
541
+ /**
542
+ * Which file each half of a comparison was walked from, remembered across the whole run.
543
+ *
544
+ * It has to live out here rather than inside a prepared build, because the engine prepares
545
+ * ONE build, walks ONE journey against it and throws it away again before the next: nothing
546
+ * that lives on a prepared build survives long enough to notice that the old build and the
547
+ * new build were the same file.
548
+ *
549
+ * And that is the thing worth noticing. `android.apk` in the settings is very often an
550
+ * absolute path, and an absolute path does not move when the old commit is checked out
551
+ * somewhere else — so both halves of a paired run read the SAME package, find no differences
552
+ * at all, and the run says "nothing that worked has changed" about a comparison that never
553
+ * happened. That is the one failure this tool exists to prevent, arriving through the front
554
+ * door. Anything found here is reported as a hole on every journey the reference walked; see
555
+ * `sameFileAsTheOtherHalf` below.
556
+ *
557
+ * Keyed by role — 'candidate' or 'reference' — and emptied by `teardown`.
558
+ *
559
+ * @type {Map<string, string>}
560
+ */
561
+ const walkedFrom = new Map();
562
+
563
+ /**
564
+ * The build ids that turned out to be the other half of the comparison, and the sentence
565
+ * that says so.
566
+ *
567
+ * Separate from `open` above because `open` only holds builds that reached a device, and the
568
+ * dangerous case is the one that does NOT: on a machine with no emulator this adapter still
569
+ * reads everything the package declares, and one package compared against itself declares
570
+ * exactly the same things. That is a clean, confident, meaningless all-clear, and it has to
571
+ * carry the warning like every other journey does.
572
+ *
573
+ * @type {Map<string, string>}
574
+ */
575
+ const sameFileFor = new Map();
576
+
577
+ /**
578
+ * Which file this really is: the path with the links and the `..`s taken out.
579
+ *
580
+ * A symlink, a `./` and a `..` must not be able to make one file look like two, because one
581
+ * file looking like two is the whole failure being guarded against here.
582
+ *
583
+ * @param {string} file
584
+ * @returns {Promise<string>}
585
+ */
586
+ async function artifactIdentity(file) {
587
+ try {
588
+ return await fsp.realpath(file);
589
+ } catch {
590
+ // A path that will not resolve is still worth remembering exactly as it was typed. The
591
+ // question below is whether the two halves agree, not whether the file is there.
592
+ return file;
593
+ }
594
+ }
595
+
596
+ /**
597
+ * Did this half of the comparison read the same package as the other half?
598
+ *
599
+ * Returns the sentence to put in front of a reader, or null when the two halves really are
600
+ * two different files.
601
+ *
602
+ * WHAT IT DOES NOT CATCH, said here rather than left to be discovered: two different paths
603
+ * holding the same build. Somebody whose release script copies today's package to
604
+ * `builds/latest.apk` and points `reference` at it is comparing one build against itself with
605
+ * two names, and nothing here notices. That was left alone on purpose — two different files
606
+ * are usually two builds somebody produced on purpose, and refusing them on a guess would
607
+ * block real comparisons to prevent an unusual one.
608
+ *
609
+ * @param {'reference'|'candidate'} role
610
+ * @param {string} mine
611
+ * @returns {string|null}
612
+ */
613
+ function sameFileAsTheOtherHalf(role, mine) {
614
+ // Only ever asked about the OLD build's half, and that is not squeamishness — it is the
615
+ // only answer that stays the same from one journey to the next. The engine prepares a
616
+ // build, walks ONE journey against it and throws it away, and it always does the new build
617
+ // first: new-run-a, new-run-b, old-run-a, old-run-b, then the same four again for the next
618
+ // journey. So from the second journey onwards the new build's half would find the previous
619
+ // journey's old half sitting in the map and flag itself as well — the warning would be
620
+ // absent on the first journey and doubled on every one after it, which reads like a bug in
621
+ // the tool rather than a fact about the run.
622
+ if (role !== 'reference') return null;
623
+ const other = walkedFrom.get('candidate');
624
+ if (!other || other !== mine) return null;
625
+ return `Both halves of this comparison were walked from the same file: ${mine}. Nothing in it is older or newer than anything else in it, so no difference between the two builds could possibly show up.`;
626
+ }
627
+
520
628
  /**
521
629
  * Read the size out of a PNG without decoding it.
522
630
  *
@@ -532,6 +640,25 @@ export function pngSize(png) {
532
640
  return { width: png.readUInt32BE(16), height: png.readUInt32BE(20) };
533
641
  }
534
642
 
643
+ /**
644
+ * Where a kept copy of the OLD build's package lives, if the settings name one.
645
+ *
646
+ * Two spellings, because both read naturally and neither is worth an argument:
647
+ * `{"reference": "builds/0.14.0.apk"}` and `{"reference": {"apk": "builds/0.14.0.apk"}}`.
648
+ * A relative path is resolved against whatever `findApk` is given, which for the reference
649
+ * half is the checkout of the old commit — so a project that DOES commit its package can
650
+ * simply leave this out and everything still works.
651
+ *
652
+ * @param {Record<string, any>} config
653
+ * @returns {string|undefined}
654
+ */
655
+ export function referenceArtifact(config) {
656
+ const said = config?.reference;
657
+ if (typeof said === 'string' && said.trim() !== '') return said;
658
+ if (said && typeof said === 'object' && typeof said.apk === 'string' && said.apk.trim() !== '') return said.apk;
659
+ return undefined;
660
+ }
661
+
535
662
  /**
536
663
  * Choose how the device gets put back between builds, and say what that costs.
537
664
  *
@@ -657,6 +784,7 @@ export const androidAdapter = defineAdapter({
657
784
  ...notes,
658
785
  'What is compared is what the screen MEANS — the roles, names and states a screen reader would read — and every control is found by what it is, never by where it sits. Moving something, restyling it, or wrapping it in another layout reports nothing.',
659
786
  'The two builds are never installed at once. Android allows one app of a given name on a device, so each build is installed, walked and removed, and the whole device is put back in between.',
787
+ '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 emulator with a snapshot restore between every walk: 301 of 309 addresses agreed in all five pairs, and the eight that did not were the app\'s own freshly-made identity code — a control run with no restore at all produced exactly the same eight. So a paired run is offered here. What it needs from you is a copy of the OLD build\'s package, because an APK is a build output and a checkout of the old commit does not contain one: name it with {"reference": "path/to/the-old.apk"} under "android" in the settings.',
660
788
  'Nothing is allowed off this machine. Every call the app makes is written down at a proxy and stopped there — which also means what is inside an encrypted request is never seen, and is reported as unchecked rather than as fine.',
661
789
  ],
662
790
  };
@@ -690,14 +818,51 @@ export const androidAdapter = defineAdapter({
690
818
  const base = path.join(ctx.scratchDir, `android-${build.id.slice(0, 12).replace(/[^A-Za-z0-9_-]/g, '-')}`);
691
819
  await fsp.mkdir(base, { recursive: true });
692
820
 
821
+ // Worked out a few lines below, and captured here so that even a build which could not
822
+ // be got ready still says it. A reference half that failed AND was the same file as the
823
+ // candidate is two separate pieces of bad news, and the second one is the one that would
824
+ // otherwise be lost — the run would report "could not be prepared", somebody would fix
825
+ // that, and the comparison would come back green for the wrong reason.
826
+ /** @type {string|null} */
827
+ let sameFile = null;
828
+
693
829
  /** @param {string} why */
694
830
  const notReady = (why) => ({
695
- build, root: base, ready: false, why,
696
- dispose: async () => { await fsp.rm(base, { recursive: true, force: true }); },
831
+ build,
832
+ root: base,
833
+ ready: false,
834
+ why: sameFile ? `${why} ${sameFile}` : why,
835
+ ...(build.role === 'reference' ? { facts: { paired: sameFile === null } } : {}),
836
+ dispose: async () => {
837
+ sameFileFor.delete(build.id);
838
+ await fsp.rm(base, { recursive: true, force: true });
839
+ },
697
840
  });
698
841
 
699
- const found = await findApk(build.artifact ? path.dirname(build.artifact) : build.root, { ...config, apk: build.artifact ?? config.apk });
700
- if (!found.path) return notReady(`There is no APK for ${build.label}: ${found.why}.`);
842
+ // WHICH package this half of the comparison walks.
843
+ //
844
+ // For the build you have, that is whatever the settings point at. For the build you were
845
+ // happy with it is different, and the difference is the whole of paired mode on a phone:
846
+ // the engine hands over a checkout of the old commit, and an APK is a BUILD OUTPUT that
847
+ // nobody commits, so a checkout of the old commit contains no app at all. `android.reference`
848
+ // is where a kept copy of the old build's package goes, and it is looked at first for the
849
+ // reference half and never for the candidate.
850
+ const wanted = build.role === 'reference' ? (referenceArtifact(config) ?? config.apk) : config.apk;
851
+ const found = await findApk(build.artifact ? path.dirname(build.artifact) : build.root, { ...config, apk: build.artifact ?? wanted });
852
+ if (!found.path) {
853
+ return notReady(
854
+ `There is no APK for ${build.label}: ${found.why}.` +
855
+ (build.role === 'reference'
856
+ ? ' A paired run walks the OLD build here, and an APK 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 package and point at it with {"reference": "path/to/the-old.apk"} under "android" 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.'
857
+ : ''),
858
+ );
859
+ }
860
+
861
+ // Two halves, one file. Worked out here, said on every journey — see `run`.
862
+ const mine = await artifactIdentity(found.path);
863
+ sameFile = sameFileAsTheOtherHalf(/** @type {'reference'|'candidate'} */ (build.role), mine);
864
+ walkedFrom.set(build.role, mine);
865
+ if (sameFile) sameFileFor.set(build.id, sameFile);
701
866
  const apk = await readApk(found.path);
702
867
  if (!apk.ok) return notReady(`The APK for ${build.label} could not be read: ${apk.why}`);
703
868
 
@@ -708,8 +873,16 @@ export const androidAdapter = defineAdapter({
708
873
  return {
709
874
  build, root: base, ready: true,
710
875
  why: `adb is not on this machine, so ${build.label} cannot be installed or opened. What it declares — its version, its permissions and all ${apk.components.length} of its components — is still read straight out of the file, and every journey that needs a device is reported as unchecked rather than passed.`,
711
- facts: { apk: found.path, pkg: apk.pkg, deviceless: true },
712
- dispose: async () => { await fsp.rm(base, { recursive: true, force: true }); },
876
+ facts: {
877
+ apk: found.path,
878
+ pkg: apk.pkg,
879
+ deviceless: true,
880
+ ...(build.role === 'reference' ? { paired: sameFile === null } : {}),
881
+ },
882
+ dispose: async () => {
883
+ sameFileFor.delete(build.id);
884
+ await fsp.rm(base, { recursive: true, force: true });
885
+ },
713
886
  };
714
887
  }
715
888
 
@@ -791,7 +964,7 @@ export const androidAdapter = defineAdapter({
791
964
  build,
792
965
  root: base,
793
966
  ready: true,
794
- why: `${build.label} is installed on ${serial} (${apk.pkg} ${apk.versionName ?? ''} build ${apk.versionCode ?? '?'}). The clock, the time zone, the text size and every animation are pinned, the first launch has been used up and thrown away, and between builds the device is put back ${reset === 'snapshot' ? 'completely, from a snapshot taken before anything was installed' : 'by removing the app — which does not undo anything it changed elsewhere on the device'}.${caveats.length > 0 ? ` Worth knowing: ${caveats.join('; ')}.` : ''}`,
967
+ why: `${build.label} is installed on ${serial} (${apk.pkg} ${apk.versionName ?? ''} build ${apk.versionCode ?? '?'}). The clock, the time zone, the text size and every animation are pinned, the first launch has been used up and thrown away, and between builds the device is put back ${reset === 'snapshot' ? 'completely, from a snapshot taken before anything was installed' : 'by removing the app — which does not undo anything it changed elsewhere on the device'}.${sameFile ? ` ${sameFile}` : ''}${caveats.length > 0 ? ` Worth knowing: ${caveats.join('; ')}.` : ''}`,
795
968
  facts: {
796
969
  serial: /** @type {string} */ (serial),
797
970
  pkg: apk.pkg,
@@ -800,9 +973,13 @@ export const androidAdapter = defineAdapter({
800
973
  rooted: device.rooted === true,
801
974
  firstLaunchMs: warmMs,
802
975
  ownsDevice,
976
+ // Only ever set on the OLD build's half, because that is the half the question is
977
+ // about: was there really a second build here, or did both halves read one file.
978
+ ...(build.role === 'reference' ? { paired: sameFile === null } : {}),
803
979
  },
804
980
  dispose: async () => {
805
981
  open.delete(build.id);
982
+ sameFileFor.delete(build.id);
806
983
  try {
807
984
  await resetDevice(session);
808
985
  } catch {
@@ -828,18 +1005,44 @@ export const androidAdapter = defineAdapter({
828
1005
  const out = [];
829
1006
  const session = open.get(prepared.build.id);
830
1007
 
1008
+ // FIRST, BEFORE ANY JOURNEY INCLUDING THE ONE THAT NEEDS NO DEVICE.
1009
+ //
1010
+ // Said on every journey rather than once at the start, which is the same rule the web
1011
+ // adapter follows for an app read at a fixed address. A run that compared one file
1012
+ // against itself finds no differences, and "no differences" is the sentence this whole
1013
+ // tool is believed for.
1014
+ //
1015
+ // It has to come before the contract journey below rather than after it, and that
1016
+ // ordering is the whole point. The contract journey needs no emulator at all — it reads
1017
+ // the package's own manifest — so on a machine with no device it is the ONLY thing that
1018
+ // runs, and it is exactly where one file compared against itself produces a complete,
1019
+ // confident, meaningless all-clear. This check first went in after that early return, on
1020
+ // 2026-08-31, and the deviceless case was still wide open; it was caught by running it
1021
+ // against a real emulator and reading the answer rather than the code.
1022
+ const sameFile = sameFileFor.get(prepared.build.id);
1023
+ if (sameFile) {
1024
+ out.push(notCovered({
1025
+ channel: 'meaning',
1026
+ path: joinPath('screen', journey.name, 'which build this was'),
1027
+ reason: 'not supported here',
1028
+ says:
1029
+ `${sameFile} An APK 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. ` +
1030
+ 'Keep a copy of the package you shipped and name it with {"reference": "path/to/the-old.apk"} under "android" in the settings, and this becomes a real comparison.',
1031
+ }));
1032
+ }
1033
+
831
1034
  // The one journey that needs nothing but the file.
832
1035
  if (journey.name === DECLARED) {
833
1036
  const apkPath = String(prepared.facts?.apk ?? session?.apkPath ?? '');
834
1037
  const apk = session?.apk ?? (apkPath ? await readApk(apkPath) : null);
835
1038
  if (!apk?.ok) {
836
- return [notCovered({ channel: 'contract', path: ['manifest', 'read'], reason: 'missing tool', says: 'the app file could not be read, so nothing it declares was checked' })];
1039
+ return [...out, notCovered({ channel: 'contract', path: ['manifest', 'read'], reason: 'missing tool', says: 'the app file could not be read, so nothing it declares was checked' })];
837
1040
  }
838
- return declaredObservations(apk, apkPath);
1041
+ return [...out, ...declaredObservations(apk, apkPath)];
839
1042
  }
840
1043
 
841
1044
  if (!session) {
842
- return [notCovered({
1045
+ return [...out, notCovered({
843
1046
  channel: 'meaning',
844
1047
  path: joinPath('screen', journey.name, 'walked'),
845
1048
  reason: 'missing tool',
@@ -850,8 +1053,9 @@ export const androidAdapter = defineAdapter({
850
1053
  const { device, apk } = session;
851
1054
  const started = Date.now();
852
1055
 
1056
+
853
1057
  if (journey.irreversible && ctx.allowIrreversible !== true) {
854
- return [notCovered({
1058
+ return [...out, notCovered({
855
1059
  channel: 'effects',
856
1060
  path: joinPath('screen', journey.name, 'walked'),
857
1061
  reason: 'irreversible',
@@ -1238,6 +1442,11 @@ export const androidAdapter = defineAdapter({
1238
1442
  }
1239
1443
  open.delete(id);
1240
1444
  }
1445
+ // One run's memory of which file each half was walked from. It must not survive into the
1446
+ // next run in the same process — the MCP server and the watch panel both call check()
1447
+ // more than once — or a second run would report the first run's packages as its own.
1448
+ walkedFrom.clear();
1449
+ sameFileFor.clear();
1241
1450
  },
1242
1451
  });
1243
1452