staysfixed 0.11.1 → 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.
@@ -679,10 +679,18 @@ function argsFor(ctx) {
679
679
  // Nothing pops up, and the browser itself talks to nobody. The second half
680
680
  // matters more than it looks: without it a run depends on somebody else's
681
681
  // server being awake.
682
+ // `--disable-extensions` is dropped when the caller is deliberately loading one. Chrome
683
+ // accepts both flags without a word and simply loads nothing, so an extension surface
684
+ // asked for through this function would have walked a browser with no extension in it and
685
+ // reported everything about it as unchanged — a clean answer about nothing, which is the
686
+ // one result this tool must never produce. Written on 2026-08-31, the day the extension
687
+ // surface landed, before anybody could route through here and be caught by it.
688
+ const loadingAnExtension = (ctx.extra ?? []).some((flag) => String(flag).startsWith('--load-extension'));
689
+
682
690
  args.push(
683
691
  '--no-first-run',
684
692
  '--no-default-browser-check',
685
- '--disable-extensions',
693
+ ...(loadingAnExtension ? [] : ['--disable-extensions']),
686
694
  '--disable-background-networking',
687
695
  '--disable-component-update',
688
696
  '--disable-default-apps',
package/src/v2/check.js CHANGED
@@ -56,6 +56,7 @@ import { sourceAdapter } from './adapters/source.js';
56
56
  import { httpAdapter } from './adapters/http.js';
57
57
  import { webAdapter } from './adapters/web.js';
58
58
  import { electronAdapter } from './adapters/electron.js';
59
+ import { extensionAdapter } from './adapters/extension.js';
59
60
 
60
61
  const exec = promisify(execFile);
61
62
 
@@ -124,7 +125,7 @@ const exec = promisify(execFile);
124
125
  */
125
126
 
126
127
  /** The adapters compiled into every copy, in the order the engine trusts them. Reading the code is free, so it is first. */
127
- const BUILT_IN = [sourceAdapter, processAdapter, httpAdapter, webAdapter, electronAdapter];
128
+ const BUILT_IN = [sourceAdapter, processAdapter, httpAdapter, webAdapter, electronAdapter, extensionAdapter];
128
129
 
129
130
  /**
130
131
  * The platforms that arrive as a file of their own.
@@ -158,6 +159,20 @@ const SEPARATE_ADAPTERS = [
158
159
  missing:
159
160
  'This copy has no native-Windows adapter in it. That is usually fine: a Windows product built with Electron is driven over its own debugging port by the Electron adapter and needs nothing else.',
160
161
  },
162
+ {
163
+ surface: 'macos',
164
+ file: './adapters/macos.js',
165
+ exports: ['macosAdapter', 'adapter', 'default'],
166
+ missing:
167
+ 'This copy has no native-Mac adapter in it, so nothing here can open a Swift or Objective-C app and read what is on its screen. That is usually fine: a Mac product built with Electron is driven over its own debugging port by the Electron adapter and needs nothing else.',
168
+ },
169
+ {
170
+ surface: 'linux',
171
+ file: './adapters/linux.js',
172
+ exports: ['linuxAdapter', 'adapter', 'default'],
173
+ missing:
174
+ 'This copy has no native-Linux adapter in it. That is usually fine: a Linux product built with Electron is driven over its own debugging port by the Electron adapter and needs nothing else.',
175
+ },
161
176
  ];
162
177
 
163
178
  /**
@@ -193,9 +208,12 @@ export const ADAPTER_FOR_SURFACE = {
193
208
  server: 'http',
194
209
  web: 'web',
195
210
  electron: 'electron',
211
+ extension: 'extension',
196
212
  android: 'android',
197
213
  ios: 'ios',
198
214
  windows: 'windows',
215
+ linux: 'linux',
216
+ macos: 'macos',
199
217
  };
200
218
 
201
219
  /**
@@ -2377,6 +2395,65 @@ async function walkOne(req, where) {
2377
2395
  };
2378
2396
  }
2379
2397
 
2398
+ /**
2399
+ * Something that can walk one journey in this project, right now, without a whole check.
2400
+ *
2401
+ * A check is the only thing that walked a journey until 2026-08-31, and that left the
2402
+ * recording command with a choice between running a full check to find out whether a fresh
2403
+ * recording repeats — minutes, a store write, a verdict nobody asked for — or writing a
2404
+ * second, simpler walker of its own, which would then be the walker that never gets fixed
2405
+ * when the real one is. Neither is acceptable, so the walk is handed out instead: the same
2406
+ * adapters, the same scratch-copy-per-walk rule, and the same normalisation a real check
2407
+ * applies, so what a recording is judged on is exactly what a later check will see.
2408
+ *
2409
+ * The caller closes it. Everything it made lives in one throwaway folder and `close` takes
2410
+ * that folder away.
2411
+ *
2412
+ * @param {{cwd?: string, root?: string, configFile?: string, config?: Record<string, any>}} [options]
2413
+ * @returns {Promise<{root: string, config: Record<string, any>, walk: (req: WalkRequest) => Promise<Capture>, close: () => Promise<void>}>}
2414
+ */
2415
+ export async function walkerFor(options = {}) {
2416
+ await loadAdapters();
2417
+ const root = projectRootFor(options);
2418
+ const config = options.config ?? (await readConfig(options.configFile ?? findConfigFile(root)));
2419
+ const scratch = await fsp.mkdtemp(path.join(os.tmpdir(), 'staysfixed-walk-'));
2420
+ const evidenceDir = path.join(scratch, 'evidence');
2421
+ await fsp.mkdir(evidenceDir, { recursive: true });
2422
+ // The same rewriting a check does, and for the same reason: every walk gets its own
2423
+ // throwaway folder, so a product that prints where it is running from would otherwise
2424
+ // look different on every single walk — including the two walks that are meant to prove a
2425
+ // recording repeats, which would then never repeat and no recording would ever be
2426
+ // accepted.
2427
+ const rules = mergeRules(DEFAULT_RULES, [
2428
+ ...pathRules({ root, scratch }),
2429
+ ...(await loadRules(path.join(root, '.staysfixed', 'rules.json'))),
2430
+ ]);
2431
+ return {
2432
+ root,
2433
+ config,
2434
+ walk: async (req) => normaliseCapture(await walkOne(req, { root, scratch, evidenceDir, config }), rules),
2435
+ close: async () => {
2436
+ await fsp.rm(scratch, { recursive: true, force: true }).catch(() => {});
2437
+ },
2438
+ };
2439
+ }
2440
+
2441
+ /**
2442
+ * This project's settings, found the way a check finds them.
2443
+ *
2444
+ * Exported so that nothing else has to re-implement "walk up from here looking for a config
2445
+ * file, and read it whether it is JSON or a module". Two readers of one settings file that
2446
+ * disagree about where it is, is a bug that only shows up in somebody else's repository.
2447
+ *
2448
+ * @param {{cwd?: string, root?: string, configFile?: string}} [options]
2449
+ * @returns {Promise<{root: string, configFile: string|null, config: Record<string, any>}>}
2450
+ */
2451
+ export async function settingsFor(options = {}) {
2452
+ const root = projectRootFor(options);
2453
+ const configFile = options.configFile ?? findConfigFile(root) ?? null;
2454
+ return { root, configFile, config: await readConfig(configFile) };
2455
+ }
2456
+
2380
2457
  /**
2381
2458
  * @param {Journey} journey
2382
2459
  * @returns {Adapter|null}
@@ -2409,19 +2486,58 @@ async function gatherJourneys({ root, config, options }) {
2409
2486
  const gaps = [];
2410
2487
 
2411
2488
  const named =
2412
- options.journeys && !['code', 'config', 'suite'].includes(options.journeys) ? options.journeys : null;
2413
- // `recorded` is a word this tool knows and `check --help` offers it — it is simply not
2414
- // wired into a run yet. The MCP surface says exactly that; the command line fell through to
2415
- // the branch above, treated the word as a FILE PATH, and answered that a file called
2416
- // "recorded" was missing. The same question has to get the same answer on both.
2489
+ options.journeys && !['code', 'config', 'suite', 'recorded'].includes(options.journeys) ? options.journeys : null;
2490
+
2491
+ // Sessions somebody actually performed, read back off the disk and walked like anything
2492
+ // else. This is the one source that knows how a person really uses the product — the four
2493
+ // screens they open every morning, in that order — and no amount of reading the source can
2494
+ // work that out, because the source only says which doors exist, never which ones anybody
2495
+ // opens. Until 2026-08-31 asking for it threw: the code to make a recording existed, and
2496
+ // nothing on the check path ever read one.
2497
+ //
2498
+ // A run that was ASKED for recorded sessions and found none stops and says so. Carrying on
2499
+ // with the journeys read out of the code would walk something the person did not ask for
2500
+ // and then report "nothing that worked has broken" — a clean answer about the wrong steps,
2501
+ // which is the one shape of reply this tool may never produce.
2417
2502
  if (options.journeys === 'recorded') {
2418
- throw new StaysFixedError(
2419
- 'Replaying a recorded session is written and not wired into a run yet, so nothing was checked.',
2420
- {
2421
- hint: 'Leave --journeys out to use the steps each adapter reads from your source, pass `suite` to walk your own test suite, or pass the path to a journeys file.',
2422
- },
2423
- );
2503
+ const { RECORDINGS_DIR, loadJourneyFolder, whatWillNotReplay } = await import('./journeys/record.js');
2504
+ const dir = path.join(root, RECORDINGS_DIR);
2505
+ const loaded = await loadJourneyFolder(dir);
2506
+ if (loaded.journeys.length === 0) {
2507
+ throw new StaysFixedError(
2508
+ `You asked for recorded sessions and there are none in ${shortPath(dir)}, so nothing was checked.`,
2509
+ {
2510
+ hint:
2511
+ 'Make one: `staysfixed record <a-name-for-it>` opens your product, follows what you do, walks it twice to prove it repeats, and writes it there. ' +
2512
+ `${loaded.problems.length > 0 ? `Something is already in that folder and could not be read: ${loaded.problems.join(' ')} ` : ''}` +
2513
+ 'Or leave --journeys out to use the steps each adapter reads from your source.',
2514
+ },
2515
+ );
2516
+ }
2517
+ for (const journey of loaded.journeys) {
2518
+ // Said before it is walked, not after it fails. A recording rots quietly: the ids,
2519
+ // ports and timestamps captured on the afternoon somebody made it go stale, and the
2520
+ // replay then fails for a reason that has nothing to do with the product.
2521
+ const willNotReplay = whatWillNotReplay(journey);
2522
+ if (willNotReplay.length > 0) {
2523
+ gaps.push({
2524
+ what: `The recorded session "${journey.name}" may not replay.`,
2525
+ why: willNotReplay.join(' '),
2526
+ unlockedBy: 'Record it again with `staysfixed record`, or reach the same thing from the code or the test suite, where nothing goes stale.',
2527
+ surface: journey.surface,
2528
+ });
2529
+ }
2530
+ }
2531
+ for (const problem of loaded.problems) {
2532
+ gaps.push({
2533
+ what: 'A file in the recordings folder was not walked.',
2534
+ why: problem,
2535
+ unlockedBy: 'Fix that file, or record the session again. A recording nothing can read is a hole, not a pass.',
2536
+ });
2537
+ }
2538
+ journeys.push(...loaded.journeys);
2424
2539
  }
2540
+
2425
2541
  if (named) journeys.push(...(await readJourneyFile(path.resolve(root, named))));
2426
2542
 
2427
2543
  // The project's own test suite, when somebody asked for it in those words and never
@@ -2501,7 +2617,11 @@ async function gatherJourneys({ root, config, options }) {
2501
2617
  continue;
2502
2618
  }
2503
2619
  if (!detection.applies) continue;
2504
- if (adapter !== sourceAdapter && named) continue;
2620
+ // A journeys file and a recorded session both name exactly what to walk, so no adapter
2621
+ // adds journeys of its own on top of them. The source reader is the exception: it runs
2622
+ // nothing, it cannot break anything, and it is the only channel that sees a door nobody
2623
+ // has ever walked through.
2624
+ if (adapter !== sourceAdapter && (named || options.journeys === 'recorded')) continue;
2505
2625
  try {
2506
2626
  journeys.push(...(await adapter.journeys(project)));
2507
2627
  } catch (e) {
package/src/v2/cli.js CHANGED
@@ -39,6 +39,7 @@ import { escalationBlock, escalationsFor, productFor, writeEscalations } from '.
39
39
  // module is still being evaluated.
40
40
  import { watchFlags } from '../cli/watch-flags.js';
41
41
  import { INIT_COMMANDS } from './init.js';
42
+ import { RECORD_COMMANDS } from './journeys/record-session.js';
42
43
  import { whatWasNotChecked } from './check.js';
43
44
 
44
45
  /**
@@ -143,6 +144,7 @@ export const V2_COMMANDS = {
143
144
  // the new one actually does.
144
145
  ...INIT_COMMANDS,
145
146
  ...SHIP_COMMANDS,
147
+ ...RECORD_COMMANDS,
146
148
 
147
149
  check: {
148
150
  summary: 'Prove nothing that already worked has changed. This is the one you run.',
@@ -1223,7 +1223,7 @@ function howToCover(kind, never, harvested = false) {
1223
1223
  case 'export':
1224
1224
  return harvested
1225
1225
  ? `The project's own tests have already been harvested and they do not reach these, so nothing existing covers them. Either they are dead code worth deleting, or they need a test that calls them — starting with "${first}".`
1226
- : `Write a journeys file that calls them and pass it with --journeys — starting with "${first}". (Harvesting the project's own tests would answer this for free, and that is written and not yet wired into a run.)`;
1226
+ : `Write a journeys file that calls them and pass it with --journeys — starting with "${first}". (Or ask for a source that answers this for free: --journeys suite harvests the project's own tests.)`;
1227
1227
  default:
1228
1228
  return `Add a journey that reaches "${first}" and the ones beside it.`;
1229
1229
  }
package/src/v2/detect.js CHANGED
@@ -60,7 +60,10 @@ export const PRODUCT_KINDS = Object.freeze({
60
60
  electron: { name: 'a desktop app built with Electron', surface: 'electron', adapter: 'electron', what: 'A desktop app. Watched by opening the built app on its own, reading its window, its menus and every private channel it registers.' },
61
61
  ios: { name: 'an iPhone or iPad app', surface: 'ios', adapter: 'ios', what: 'An Apple app. Driven on the simulator; a real device in your hand can never be compared side by side.' },
62
62
  android: { name: 'an Android app', surface: 'android', adapter: 'android', what: 'An Android app. Driven on an emulator against the stored record.' },
63
- desktopNative: { name: 'a native desktop app', surface: 'windows', adapter: 'windows', what: 'A desktop app that is not Electron — Swift, WinUI, Tauri, Qt. Only readable from the operating system it runs on.' },
63
+ desktopNative: { name: 'a native desktop app', surface: 'windows', adapter: 'windows', what: 'A desktop app that is not Electron — Swift, WinUI, Tauri, Qt. Only readable from the operating system it runs on — Windows here; see desktopNativeLinux for Linux.' },
64
+ extension: { name: 'a browser extension', surface: 'extension', adapter: 'extension', what: 'Something you install in a browser. Watched by reading its manifest as a contract, opening its own pages, and comparing a page with the extension loaded against the same page without it.' },
65
+ macNative: { name: 'a native Mac app', surface: 'macos', adapter: 'macos', what: 'A Mac app that is not Electron — Swift or Objective-C, AppKit or SwiftUI. Readable only on a Mac, one build at a time, and one person has to allow it once under Privacy & Security.' },
66
+ desktopNativeLinux: { name: 'a native Linux desktop app', surface: 'linux', adapter: 'linux', what: 'A Linux desktop app that is not Electron — GTK, Qt, Tauri. Read through the accessibility bus every screen reader already uses, on a machine somebody is logged in to.' },
64
67
  container: { name: 'a containerised service', surface: 'server', adapter: 'http', what: 'A service that ships as a container. Watched the same way as any server, once there is a command that starts it.' },
65
68
  other: { name: 'a product in a language this tool cannot drive yet', surface: 'cli', adapter: null, what: 'Recognised, named, and honestly not drivable here. It is listed so a clean run is never mistaken for full coverage.' },
66
69
  });
@@ -596,7 +599,7 @@ async function productsIn(input) {
596
599
  ...(gradlew || gradle ? { buildWith: `${gradlew ? './gradlew' : 'gradle'} ${folder('app') ? ':app:assembleDebug' : 'assembleDebug'}` } : {}),
597
600
  },
598
601
  blockers: available.has('android')
599
- ? ['It runs on an emulator. Whether two emulator snapshots restore identically is unproven, so a run says which mode it used.']
602
+ ? ['It runs on an emulator. A snapshot restore was measured on 2026-08-31 and repeats — 301 of 309 addresses agreed across five pairs, and the eight that moved were the app\'s own identity code — so a paired run is offered. It needs a kept copy of the old build\'s APK ("reference" under "android"), because a checkout of the old commit contains no build output.']
600
603
  : ['Nothing in this copy of the tool can drive an Android app yet. When it can, it will run on an emulator against the stored record.'],
601
604
  });
602
605
  }
package/src/v2/doctor.js CHANGED
@@ -39,6 +39,8 @@ import { surveyBrowsers, INSTALL_COMMAND, PORT_NEVER_USE } from './browsers.js';
39
39
  import { POWERSHELL_PATHS, describeRemote } from './remote.js';
40
40
  import { deviceToMake } from './adapters/android.js';
41
41
  import { describeWindows } from './adapters/windows.js';
42
+ import { describeLinuxDesktop } from './adapters/linux.js';
43
+ import { describeMacos } from './adapters/macos.js';
42
44
  import { messageOf, EXIT } from '../core/errors.js';
43
45
  import { say, ok, warn, fail, blank, heading, paint, mark, shortPath, setLogLevel } from '../core/log.js';
44
46
  import { loadPlaywright } from './adapters/web-driver.js';
@@ -906,7 +908,7 @@ function findDesktopApp(cwd) {
906
908
  *
907
909
  * @param {string} root
908
910
  * @param {string|null} settingsText
909
- * @returns {Promise<{android: FoundApp|null, ios: FoundApp|null, windows: FoundApp|null}>}
911
+ * @returns {Promise<{android: FoundApp|null, ios: FoundApp|null, windows: FoundApp|null, linux: FoundApp|null, macos: FoundApp|null}>}
910
912
  */
911
913
  async function phoneApps(root, settingsText) {
912
914
  // Comments taken away first, for the same reason `findDesktopApp` does it: a
@@ -923,15 +925,35 @@ async function phoneApps(root, settingsText) {
923
925
  };
924
926
 
925
927
  /**
926
- * A named key whose value has to look right, so one settings word cannot be mistaken for
927
- * another block's.
928
+ * A named key looked for INSIDE one settings block, never across the whole file.
929
+ *
930
+ * `remoteExe` means "the built program on that machine" under `windows:` and exactly the
931
+ * same thing under `linux:`, so a flat search finds one and reports it as the other — and
932
+ * doctor would tell somebody with a GTK app that they have a native Windows program.
933
+ * Written the day the Linux surface landed, 2026-08-31, before it could be true.
934
+ *
935
+ * @param {string} block
928
936
  * @param {string} key
929
- * @param {(value: string) => boolean} looksRight
937
+ * @param {(value: string) => boolean} [looksRight]
930
938
  * @returns {FoundApp|null}
931
939
  */
932
- const namedPath = (key, looksRight) => {
933
- const found = new RegExp(`["']?${key}["']?\\s*:\\s*["'\`]([^"'\`]+)["'\`]`).exec(settings);
934
- return found && looksRight(found[1]) ? { where: found[1], how: `your settings name it under ${key}` } : null;
940
+ const inBlock = (block, key, looksRight) => {
941
+ const at = new RegExp(`["']?${block}["']?\\s*:\\s*\\{`).exec(settings);
942
+ if (!at) return null;
943
+ let depth = 0;
944
+ let end = at.index + at[0].length;
945
+ for (; end < settings.length; end += 1) {
946
+ if (settings[end] === '{') depth += 1;
947
+ else if (settings[end] === '}') {
948
+ if (depth === 0) break;
949
+ depth -= 1;
950
+ }
951
+ }
952
+ const inside = settings.slice(at.index + at[0].length, end);
953
+ const found = new RegExp(`["']?${key}["']?\\s*:\\s*["'\`]([^"'\`]+)["'\`]`).exec(inside);
954
+ if (!found) return null;
955
+ if (looksRight && !looksRight(found[1])) return null;
956
+ return { where: found[1], how: `your settings name it under ${block}.${key}` };
935
957
  };
936
958
 
937
959
  /**
@@ -974,7 +996,11 @@ async function phoneApps(root, settingsText) {
974
996
  // name one" about a project whose settings named one. Measured 2026-08-31 against a real
975
997
  // TerminalDeck.app. The value has to end in `.app` so a bare `app:` belonging to some
976
998
  // other block can never be mistaken for this one.
977
- namedPath('app', (v) => v.endsWith('.app')) ??
999
+ // Scoped to the `ios` block, not searched across the whole file. `app` means an iPhone
1000
+ // bundle under `ios:` and a Mac bundle under `macos:` — both end in `.app`, so a flat
1001
+ // search finds one and announces the other. Written the day the Mac surface landed,
1002
+ // 2026-08-31, before it could be true.
1003
+ inBlock('ios', 'app', (v) => v.endsWith('.app')) ??
978
1004
  named('xcworkspace') ??
979
1005
  built(['dist', 'out', 'build', 'release'], (name) => name.endsWith('.app')) ??
980
1006
  (there(path.join('ios', 'Podfile')) || readdirSafe(path.join(root, 'ios')).some((n) => n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace'))
@@ -983,9 +1009,11 @@ async function phoneApps(root, settingsText) {
983
1009
  ? { where: root, how: 'there is an Xcode project here, but nothing says where the built app is' }
984
1010
  : null);
985
1011
 
986
- const windows = named('remoteExe') ?? namedPath('exe', (v) => /\.exe$/i.test(v));
1012
+ const windows = inBlock('windows', 'remoteExe') ?? inBlock('windows', 'exe', (v) => /\.exe$/i.test(v));
1013
+ const linux = inBlock('linux', 'remoteExe') ?? inBlock('linux', 'exe');
1014
+ const macos = inBlock('macos', 'app', (v) => v.endsWith('.app'));
987
1015
 
988
- return { android, ios, windows };
1016
+ return { android, ios, windows, linux, macos };
989
1017
  }
990
1018
 
991
1019
  /**
@@ -1071,9 +1099,9 @@ function couldNotAsk(name, why) {
1071
1099
  /**
1072
1100
  * The platforms that arrive as an adapter of their own, and know their own requirements.
1073
1101
  * The built-in five are described by hand above, because they are older than this
1074
- * mechanism and their wording is tested; these three answer for themselves.
1102
+ * mechanism and their wording is tested; these four answer for themselves.
1075
1103
  */
1076
- const ADAPTERS_THAT_ANSWER_FOR_THEMSELVES = ['android', 'ios', 'windows'];
1104
+ const ADAPTERS_THAT_ANSWER_FOR_THEMSELVES = ['android', 'ios', 'windows', 'linux', 'macos'];
1077
1105
 
1078
1106
  /**
1079
1107
  * Ask each separate adapter what IT is missing, in its own words.
@@ -1189,7 +1217,7 @@ async function whatThisCopyCanDrive() {
1189
1217
  // file that would not load, so a check on this copy is not running at all — and both
1190
1218
  // the command line and the MCP surface say that in their own words already.
1191
1219
  const why = `This copy could not be asked what it can drive: ${messageOf(e)}`;
1192
- for (const surface of ['android', 'ios', 'windows']) out.push({ surface, present: false, why });
1220
+ for (const surface of ['android', 'ios', 'windows', 'linux', 'macos']) out.push({ surface, present: false, why });
1193
1221
  }
1194
1222
  return out;
1195
1223
  }
@@ -1550,6 +1578,7 @@ function settingsFromText(text) {
1550
1578
  // as a result: it could not see the machine the settings named, nor the built program,
1551
1579
  // so it asked for both while both were sitting in the file.
1552
1580
  windows: ['host', 'remoteExe', 'exe'],
1581
+ linux: ['host', 'remoteExe', 'exe'],
1553
1582
  };
1554
1583
  for (const [block, keys] of Object.entries(wanted)) {
1555
1584
  const at = new RegExp(`["']?${block}["']?\\s*:\\s*\\{`).exec(clean);
@@ -1679,7 +1708,7 @@ async function findReference(root) {
1679
1708
  * @param {import('./browsers.js').BrowserSurvey} browsers
1680
1709
  * @param {{where: string, how: string}|null} desktopApp
1681
1710
  * @param {DriverReport[]} drivers What this copy of the tool can drive at all.
1682
- * @param {{android: FoundApp|null, ios: FoundApp|null, windows: FoundApp|null}} phones
1711
+ * @param {{android: FoundApp|null, ios: FoundApp|null, windows: FoundApp|null, linux: FoundApp|null, macos: FoundApp|null}} phones
1683
1712
  * @param {Map<string, Need[]>} asked What each separate adapter says IT is missing.
1684
1713
  * @param {{commands: number, imports: number}} [wires]
1685
1714
  * What this project's own settings wire for the command-line surface. A surface with
@@ -1861,7 +1890,7 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
1861
1890
  : !canDrive('android')
1862
1891
  ? `An Android app is here (${phones.android.how}), and this copy of Stays Fixed cannot drive one. ${noDriver('android')}`
1863
1892
  : androidReady
1864
- ? `Covered against the stored record. It installs ${phones.android.where} on a virtual device, walks it, and reads what each control on the screen is and does. Whether two emulator snapshots restore identically is still unproven, so a paired run is not offered — and the run says which mode it used.`
1893
+ ? `Covered against the stored record. It installs ${phones.android.where} on a virtual device, walks it, and reads what each control on the screen is and does. Whether a snapshot restore repeats was measured on 2026-08-31: one build walked ten times with a restore between every walk, 301 of 309 addresses agreeing in all five pairs, and the eight that moved were the app's own freshly-made identity code — ordinary wobble, which this tool already subtracts. So a paired run is offered. It also needs a copy of the OLD build's package, because an APK is a build output that no checkout of the old commit contains: name it with {"reference": "path/to/the-old.apk"} under "android" in the settings. Without it the run falls back to the stored record and says so.`
1865
1894
  : androidPartly
1866
1895
  ? `Most of your Android app can be checked: every screen another app can reach is opened and read. What is missing is ${plainList(androidMissing)}, and without ${androidMissing.length === 1 ? 'it' : 'them'} nothing is typed, pressed or saved — so a clean result covers the screens and not what the app DOES.`
1867
1896
  : `An Android app is here (${phones.android.how}), and ${plainList(androidMissing)} ${androidMissing.length === 1 ? 'is' : 'are'} still missing. ${androidWants.every((n) => n.automatic) ? `${androidMissing.length === 1 ? 'It installs' : 'They all install'} without anybody clicking anything, so nobody needs to be asked.` : 'Some of it needs a person, and each one says what it is and what it unlocks.'}`,
@@ -2039,6 +2068,84 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
2039
2068
  if (windowsHost && !windowsDriver) {
2040
2069
  impossible.set('windows', `${noDriver('windows')} Nothing you install on that machine changes it. Update Stays Fixed to a copy that has it.`);
2041
2070
  }
2071
+
2072
+ // ── native Mac apps ───────────────────────────────────────────────────────────────────
2073
+ //
2074
+ // No remote option here, unlike Windows and Linux: the Accessibility API only answers
2075
+ // inside a signed-in graphical session on the machine itself, so a Mac app is checked on
2076
+ // the Mac it is on or not at all. And one person has to allow it once — macOS will not let
2077
+ // any program grant itself permission to read another app's window.
2078
+ const macDriver = canDrive('macos');
2079
+ const onAMacHere = process.platform === 'darwin';
2080
+ const macNeeds = asked.get('macos') ?? [];
2081
+ const macAllowed = onAMacHere && macDriver && !macNeeds.some((n) => /permission/i.test(String(n.what)));
2082
+ const macUsable = phones.macos !== null && macAllowed;
2083
+ surfaces.push({
2084
+ id: 'macos',
2085
+ name: 'native Mac apps',
2086
+ status: macUsable ? 'ready' : 'unavailable',
2087
+ summary: phones.macos === null
2088
+ ? 'Nothing to check: this project names no native Mac app in its settings. Most Mac products are Electron, and those are covered over their debug port from any machine.'
2089
+ : !onAMacHere
2090
+ ? 'Cannot run here: a native Mac window can only be read from a Mac.'
2091
+ : !macDriver
2092
+ ? `A Mac app is named in the settings, and this copy of Stays Fixed cannot drive one. ${noDriver('macos')}`
2093
+ // The adapter's own paragraph, not a second one written here. It knows whether this
2094
+ // Mac has been allowed to read another app's window, and that changes the answer
2095
+ // completely.
2096
+ : describeMacos({ darwin: true, allowed: macAllowed }),
2097
+ canCheck: macUsable ? [...withoutADriver, 'meaning', 'pixels'] : [],
2098
+ cannotCheck: macUsable ? [] : CHANNELS.map((c) => c.id),
2099
+ needs: onAMacHere && macDriver && phones.macos !== null ? macNeeds : [],
2100
+ });
2101
+ if (phones.macos === null) {
2102
+ notInThisProject.add('macos');
2103
+ impossible.set('macos', 'This project names no native Mac app in its settings, so there is nothing to open. If yours is built somewhere else, name the built .app under macos.app. Most Mac products are Electron, and those are covered over their debug port from any machine.');
2104
+ } else if (!onAMacHere) {
2105
+ impossible.set('macos', 'A native Mac window can only be read from a Mac. Everything else on this list is unaffected — check the Mac app from a Mac, and let this machine cover the rest.');
2106
+ } else if (!macDriver) {
2107
+ impossible.set('macos', `${noDriver('macos')} Nothing you install on this machine changes it. Update Stays Fixed to a copy that has it.`);
2108
+ }
2109
+
2110
+ // ── native Linux desktop apps ─────────────────────────────────────────────────────────
2111
+ //
2112
+ // The project is asked before the machine, the same as everywhere else: a repository with
2113
+ // no native Linux program named in it does not need a Linux desktop, and telling somebody
2114
+ // to go and find one is work that changes nothing.
2115
+ const linuxDriver = canDrive('linux');
2116
+ const linuxHost = hosts.find((h) => h.reachable && h.windows !== true);
2117
+ const linuxUsable = phones.linux !== null && linuxHost !== undefined && linuxDriver;
2118
+ surfaces.push({
2119
+ id: 'linux',
2120
+ name: 'native Linux desktop apps',
2121
+ status: linuxUsable ? 'partial' : 'unavailable',
2122
+ summary: phones.linux === null
2123
+ ? 'Nothing to check: this project names no native Linux program in its settings. Most Linux desktop products are Electron, and those are covered over their debug port from any machine.'
2124
+ : !linuxDriver
2125
+ ? `A native Linux program is named (${phones.linux.how}), and this copy of Stays Fixed cannot drive one. ${noDriver('linux')}`
2126
+ : !linuxHost
2127
+ ? nobodyWasDialled
2128
+ ? 'No machine was dialled, so whether a Linux desktop can be reached from here is unknown. Name one under `linux: { host: "..." }` in your settings and it is asked every time, or run `staysfixed doctor --machines`.'
2129
+ : 'No Linux desktop is reachable from here. A native Linux window can only be read from the desktop it is running on.'
2130
+ // The adapter's own paragraph, never a second one written here — it knows whether
2131
+ // that machine has a desktop session at all, and a summary kept in this file could
2132
+ // only guess at it.
2133
+ : linuxHost.detail
2134
+ ? describeLinuxDesktop(linuxHost.detail)
2135
+ : `A Linux machine answers through "${linuxHost.name}". A native Linux app is read through the accessibility bus every screen reader already uses, and nothing has to be installed there.`,
2136
+ canCheck: linuxUsable ? withoutADriver : [],
2137
+ cannotCheck: linuxUsable ? ['meaning', 'pixels'] : CHANNELS.map((c) => c.id),
2138
+ needs: linuxUsable ? (asked.get('linux') ?? []) : [],
2139
+ });
2140
+ if (phones.linux === null) {
2141
+ notInThisProject.add('linux');
2142
+ impossible.set(
2143
+ 'linux',
2144
+ 'This project has no native Linux program named in its settings, so there is nothing to open on a Linux desktop. '
2145
+ + 'If yours is built somewhere else, name it under linux.remoteExe (already on that machine) or linux.exe (copied over each run). '
2146
+ + 'Most Linux desktop products are Electron, and those are covered over their debug port from any machine.'
2147
+ );
2148
+ }
2042
2149
  if (!windowsHost) {
2043
2150
  impossible.set(
2044
2151
  'windows',
package/src/v2/init.js CHANGED
@@ -1522,14 +1522,20 @@ export function configText(project) {
1522
1522
  const androidHere = android?.adapter === 'android';
1523
1523
  w(' // ───────────────────────────────────────────────────────────────────────');
1524
1524
  w(' // Android apps. Installed on an emulator of its own, walked, then removed.');
1525
- w(' // Compared against the stored record — whether two emulator snapshots come');
1526
- w(' // back byte for byte is unproven, and a run says which mode it used.');
1525
+ w(' // Compared against the stored record, or paired if you name the old build.');
1526
+ w(' // A snapshot restore was measured on 2026-08-31 and repeats: 301 of 309');
1527
+ w(" // addresses agreed across five pairs, and the eight that moved were the app's");
1528
+ w(' // own identity code, which the wobble subtraction already removes.');
1527
1529
  w(' // ───────────────────────────────────────────────────────────────────────');
1528
1530
  w(androidHere ? ' android: {' : ' // android: {');
1529
1531
  const andOn = androidHere ? ' ' : ' // ';
1530
1532
  const apk = android?.suggest?.apk;
1531
1533
  w(`${andOn}// The built package. Left out, it looks in the usual build folders.`);
1532
1534
  w(apk ? `${andOn}apk: ${JSON.stringify(String(apk))},` : `${andOn}// apk: 'app/build/outputs/apk/debug/app-debug.apk',`);
1535
+ w(`${andOn}// A kept copy of the OLD build's package, so a paired run has two builds to compare.`);
1536
+ w(`${andOn}// A checkout of the old commit contains no APK — an APK is a build output — so`);
1537
+ w(`${andOn}// without this a paired run falls back to the stored record and says so.`);
1538
+ w(`${andOn}// reference: 'builds/0.14.0.apk',`);
1533
1539
  w(`${andOn}// Which emulator to use. Left out, it takes the first one that is not a Play Store image.`);
1534
1540
  w(`${andOn}// avd: 'Pixel_7_API_35',`);
1535
1541
  w(`${andOn}// Or a device already plugged in or already running.`);
@@ -1577,6 +1583,10 @@ export function configText(project) {
1577
1583
  w(`${iosOn}// The built app bundle for the simulator. Left out, it looks where builds land.`);
1578
1584
  w(iosApp ? `${iosOn}app: ${JSON.stringify(String(iosApp))},` : `${iosOn}// app: 'build/Debug-iphonesimulator/YourApp.app',`);
1579
1585
  w(`${iosOn}// Which simulator to use, and which system to run it on. Left out, it takes a sensible`);
1586
+ w(`${iosOn}// A kept copy of the OLD build's bundle, so a paired run has two builds to compare.`);
1587
+ w(`${iosOn}// A checkout of the old commit contains no .app — a bundle is a build output — so`);
1588
+ w(`${iosOn}// without this a paired run falls back to the stored record and says so.`);
1589
+ w(`${iosOn}// reference: 'builds/0.14.0.app',`);
1580
1590
  w(`${iosOn}// one and says which.`);
1581
1591
  w(`${iosOn}// deviceType: 'iPhone 17', runtime: 'iOS 26.4',`);
1582
1592
  w(`${iosOn}// Walks through the app. Left out, it opens the app and reads the first screen.`);
@@ -33,7 +33,7 @@ import path from 'node:path';
33
33
  import { measureWobble } from '../observation.js';
34
34
  import { journeysFromCode } from './from-routes.js';
35
35
  import { DEFAULT_HARVEST_BUDGET_MS, harvestJourneys, testsNear } from './from-suite.js';
36
- import { loadJourneyFolder, whatWillNotReplay } from './record.js';
36
+ import { RECORDINGS_DIR, loadJourneyFolder, whatWillNotReplay } from './record.js';
37
37
 
38
38
  /** @typedef {import('../types.js').Journey} Journey */
39
39
  /** @typedef {import('../types.js').JourneySource} JourneySource */
@@ -47,7 +47,7 @@ import { loadJourneyFolder, whatWillNotReplay } from './record.js';
47
47
 
48
48
  export { journeysFromCode, journeysFromDoors, irreversibility } from './from-routes.js';
49
49
  export { detectRunner, harvestJourneys, listTestFiles, testsNear, DEFAULT_HARVEST_BUDGET_MS } from './from-suite.js';
50
- export { startRecording, recordSession, saveJourneys, loadJourneys, loadJourneyFolder, redact } from './record.js';
50
+ export { startRecording, recordSession, saveJourneys, loadJourneys, loadJourneyFolder, redact, RECORDINGS_DIR } from './record.js';
51
51
 
52
52
  /**
53
53
  * A journey with everything this folder knows about where it came from.
@@ -508,7 +508,7 @@ export async function gather(opts) {
508
508
 
509
509
  // ---- recorded sessions ---------------------------------------------------
510
510
  if (opts.recorded !== false) {
511
- const dir = opts.recorded?.dir ?? path.join(root, '.staysfixed', 'journeys');
511
+ const dir = opts.recorded?.dir ?? path.join(root, RECORDINGS_DIR);
512
512
  const loaded = await loadJourneyFolder(dir);
513
513
  for (const journey of loaded.journeys) {
514
514
  const willNotReplay = whatWillNotReplay(journey);