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.
@@ -532,6 +532,37 @@ function installGuards() {
532
532
  }
533
533
  }
534
534
 
535
+ /**
536
+ * Take the throwaway profile away, and keep taking it away until it stays gone.
537
+ *
538
+ * One `rm` is a snapshot. A browser is not one process: the parent exiting says nothing about
539
+ * its renderers, and while they are being reaped they are still writing into the profile — so
540
+ * the sweep starts, a file appears behind it, the folder is not empty, and the profile
541
+ * outlives the run. That is the one thing "nothing it opened outlives the run" promises.
542
+ *
543
+ * The last-resort path (`killNow`) learned this and this one did not, so the polite close —
544
+ * which is the one every ordinary run uses — swallowed the failure with a bare `.catch()` and
545
+ * left the folder behind. It passed on macOS and on an idle Linux box, and failed on a loaded
546
+ * CI runner, which is exactly how a race behaves. Measured 2026-08-31.
547
+ *
548
+ * @param {string} home
549
+ * @returns {Promise<void>}
550
+ */
551
+ async function removeStubbornly(home) {
552
+ for (let attempt = 0; attempt < 6; attempt += 1) {
553
+ try {
554
+ await fsp.rm(home, { recursive: true, force: true });
555
+ if (!fs.existsSync(home)) return;
556
+ } catch {
557
+ // A file recreated a millisecond after the sweep began. Wait for whoever wrote it to
558
+ // finish dying, and go round again.
559
+ }
560
+ await new Promise((resolve) => setTimeout(resolve, 25 * (attempt + 1)));
561
+ }
562
+ // Still there. Untidy rather than harmful, and exactly what `staysfixed browsers --clean`
563
+ // is for — but never reported as success.
564
+ }
565
+
535
566
  /**
536
567
  * The last-resort cleanup: no promises, no awaiting, no politeness.
537
568
  * @param {number|null} pid
@@ -648,10 +679,18 @@ function argsFor(ctx) {
648
679
  // Nothing pops up, and the browser itself talks to nobody. The second half
649
680
  // matters more than it looks: without it a run depends on somebody else's
650
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
+
651
690
  args.push(
652
691
  '--no-first-run',
653
692
  '--no-default-browser-check',
654
- '--disable-extensions',
693
+ ...(loadingAnExtension ? [] : ['--disable-extensions']),
655
694
  '--disable-background-networking',
656
695
  '--disable-component-update',
657
696
  '--disable-default-apps',
@@ -900,7 +939,7 @@ export async function openBrowser(opts = {}) {
900
939
  closing ??= (async () => {
901
940
  live.delete(id);
902
941
  await stopProcess(child, GRACE_MS);
903
- await fsp.rm(home, { recursive: true, force: true }).catch(() => {});
942
+ await removeStubbornly(home);
904
943
  })();
905
944
  return closing;
906
945
  };
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';
@@ -898,9 +900,15 @@ function findDesktopApp(cwd) {
898
900
  * install thirty gigabytes of Xcode is asking for work that changes nothing, and the whole
899
901
  * design turns on never doing that.
900
902
  *
903
+ * Windows is answered here too, for the same reason and by the same rule: a repository with
904
+ * no Windows program in it does not need a Windows machine, and saying "no Windows desktop
905
+ * can be reached from here" about one sends somebody looking for a machine they will never
906
+ * use. Only the settings can answer it — a native Windows build is not something this file
907
+ * can go and find in a folder.
908
+ *
901
909
  * @param {string} root
902
910
  * @param {string|null} settingsText
903
- * @returns {Promise<{android: FoundApp|null, ios: FoundApp|null}>}
911
+ * @returns {Promise<{android: FoundApp|null, ios: FoundApp|null, windows: FoundApp|null, linux: FoundApp|null, macos: FoundApp|null}>}
904
912
  */
905
913
  async function phoneApps(root, settingsText) {
906
914
  // Comments taken away first, for the same reason `findDesktopApp` does it: a
@@ -917,15 +925,35 @@ async function phoneApps(root, settingsText) {
917
925
  };
918
926
 
919
927
  /**
920
- * A named key whose value has to look right, so one settings word cannot be mistaken for
921
- * 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
922
936
  * @param {string} key
923
- * @param {(value: string) => boolean} looksRight
937
+ * @param {(value: string) => boolean} [looksRight]
924
938
  * @returns {FoundApp|null}
925
939
  */
926
- const namedPath = (key, looksRight) => {
927
- const found = new RegExp(`["']?${key}["']?\\s*:\\s*["'\`]([^"'\`]+)["'\`]`).exec(settings);
928
- 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}` };
929
957
  };
930
958
 
931
959
  /**
@@ -968,7 +996,11 @@ async function phoneApps(root, settingsText) {
968
996
  // name one" about a project whose settings named one. Measured 2026-08-31 against a real
969
997
  // TerminalDeck.app. The value has to end in `.app` so a bare `app:` belonging to some
970
998
  // other block can never be mistaken for this one.
971
- 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')) ??
972
1004
  named('xcworkspace') ??
973
1005
  built(['dist', 'out', 'build', 'release'], (name) => name.endsWith('.app')) ??
974
1006
  (there(path.join('ios', 'Podfile')) || readdirSafe(path.join(root, 'ios')).some((n) => n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace'))
@@ -977,7 +1009,11 @@ async function phoneApps(root, settingsText) {
977
1009
  ? { where: root, how: 'there is an Xcode project here, but nothing says where the built app is' }
978
1010
  : null);
979
1011
 
980
- return { android, ios };
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'));
1015
+
1016
+ return { android, ios, windows, linux, macos };
981
1017
  }
982
1018
 
983
1019
  /**
@@ -1063,9 +1099,9 @@ function couldNotAsk(name, why) {
1063
1099
  /**
1064
1100
  * The platforms that arrive as an adapter of their own, and know their own requirements.
1065
1101
  * The built-in five are described by hand above, because they are older than this
1066
- * mechanism and their wording is tested; these three answer for themselves.
1102
+ * mechanism and their wording is tested; these four answer for themselves.
1067
1103
  */
1068
- const ADAPTERS_THAT_ANSWER_FOR_THEMSELVES = ['android', 'ios', 'windows'];
1104
+ const ADAPTERS_THAT_ANSWER_FOR_THEMSELVES = ['android', 'ios', 'windows', 'linux', 'macos'];
1069
1105
 
1070
1106
  /**
1071
1107
  * Ask each separate adapter what IT is missing, in its own words.
@@ -1181,7 +1217,7 @@ async function whatThisCopyCanDrive() {
1181
1217
  // file that would not load, so a check on this copy is not running at all — and both
1182
1218
  // the command line and the MCP surface say that in their own words already.
1183
1219
  const why = `This copy could not be asked what it can drive: ${messageOf(e)}`;
1184
- 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 });
1185
1221
  }
1186
1222
  return out;
1187
1223
  }
@@ -1542,6 +1578,7 @@ function settingsFromText(text) {
1542
1578
  // as a result: it could not see the machine the settings named, nor the built program,
1543
1579
  // so it asked for both while both were sitting in the file.
1544
1580
  windows: ['host', 'remoteExe', 'exe'],
1581
+ linux: ['host', 'remoteExe', 'exe'],
1545
1582
  };
1546
1583
  for (const [block, keys] of Object.entries(wanted)) {
1547
1584
  const at = new RegExp(`["']?${block}["']?\\s*:\\s*\\{`).exec(clean);
@@ -1671,7 +1708,7 @@ async function findReference(root) {
1671
1708
  * @param {import('./browsers.js').BrowserSurvey} browsers
1672
1709
  * @param {{where: string, how: string}|null} desktopApp
1673
1710
  * @param {DriverReport[]} drivers What this copy of the tool can drive at all.
1674
- * @param {{android: FoundApp|null, ios: FoundApp|null}} phones
1711
+ * @param {{android: FoundApp|null, ios: FoundApp|null, windows: FoundApp|null, linux: FoundApp|null, macos: FoundApp|null}} phones
1675
1712
  * @param {Map<string, Need[]>} asked What each separate adapter says IT is missing.
1676
1713
  * @param {{commands: number, imports: number}} [wires]
1677
1714
  * What this project's own settings wire for the command-line surface. A surface with
@@ -1853,7 +1890,7 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
1853
1890
  : !canDrive('android')
1854
1891
  ? `An Android app is here (${phones.android.how}), and this copy of Stays Fixed cannot drive one. ${noDriver('android')}`
1855
1892
  : androidReady
1856
- ? `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.`
1857
1894
  : androidPartly
1858
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.`
1859
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.'}`,
@@ -1884,7 +1921,20 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
1884
1921
  const iosBlocked = iosWants.some(blocks);
1885
1922
  const iosReady = iosMachine && canDrive('ios') && phones.ios !== null && iosWants.length === 0;
1886
1923
  const iosPartly = iosMachine && canDrive('ios') && phones.ios !== null && !iosReady && !iosBlocked;
1887
- if (!onAMac) {
1924
+ // THE PROJECT IS ASKED BEFORE THE MACHINE, and the order is the whole point. "There is no
1925
+ // iPhone app in this repository" and "this machine cannot run one" are different sentences
1926
+ // with different things to do about them, and a machine reason given for a project that has
1927
+ // no iPhone app in it sends somebody to install thirty gigabytes of Xcode for nothing. On a
1928
+ // Mac this was already right; on Linux the platform test came first, so every project on
1929
+ // every Linux machine was told its non-existent iPhone app was out of reach. Caught by CI
1930
+ // on 2026-08-31 — the Mac suite was green and said nothing about it.
1931
+ if (phones.ios === null) {
1932
+ notInThisProject.add('ios');
1933
+ impossible.set(
1934
+ 'ios',
1935
+ 'This project has no iPhone app in it, so there is nothing for a simulator to run. If yours is built somewhere else, name the built .app in your settings under ios.app.'
1936
+ );
1937
+ } else if (!onAMac) {
1888
1938
  impossible.set('ios', 'An iPhone build can only be run on a Mac. Everything else on this list is unaffected — check the iPhone app from a Mac, and let this machine cover the rest.');
1889
1939
  } else if (phones.ios === null) {
1890
1940
  notInThisProject.add('ios');
@@ -1902,10 +1952,10 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
1902
1952
  id: 'ios',
1903
1953
  name: 'iPhone apps, on the simulator',
1904
1954
  status: iosReady ? 'ready' : iosPartly ? 'partial' : 'unavailable',
1905
- summary: !onAMac
1906
- ? 'Cannot run here: iOS needs a Mac.'
1907
- : phones.ios === null
1908
- ? 'Nothing to check: no iPhone app was found in this project, and the settings do not name one.'
1955
+ summary: phones.ios === null
1956
+ ? 'Nothing to check: no iPhone app was found in this project, and the settings do not name one.'
1957
+ : !onAMac
1958
+ ? 'Cannot run here: iOS needs a Mac.'
1909
1959
  : !canDrive('ios')
1910
1960
  ? `An iPhone app is here (${phones.ios.how}), and this copy of Stays Fixed cannot drive one. ${noDriver('ios')}`
1911
1961
  : !iosMachine
@@ -1949,6 +1999,20 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
1949
1999
  // is "detect rather than ask" at its sharpest: a runner that already answers must never
1950
2000
  // be presented as something to go and set up.
1951
2001
  const windowsDriver = canDrive('windows');
2002
+ // Same rule as iPhone and Android: the project is asked before the machine. A repository
2003
+ // with no native Windows program in it does not need a Windows machine, and "no Windows
2004
+ // desktop is reachable from here" about one is a machine reason given for a project fact.
2005
+ // Caught by CI on 2026-08-31, where a Linux runner reported native Windows apps as out of
2006
+ // reach for a project that contains none.
2007
+ if (phones.windows === null) {
2008
+ notInThisProject.add('windows');
2009
+ impossible.set(
2010
+ 'windows',
2011
+ 'This project has no native Windows program named in its settings, so there is nothing to open on a Windows desktop. '
2012
+ + 'If yours is built somewhere else, name it under windows.remoteExe (already on that machine) or windows.exe (copied over each run). '
2013
+ + 'Most Windows products are Electron, and those are covered over their debug port from any machine.'
2014
+ );
2015
+ }
1952
2016
  // A Windows desktop nobody has signed into is not a runner. There is nothing on it to read
1953
2017
  // — no windows, no controls — so calling it "partly covered" would be the exact over-claim
1954
2018
  // this file exists to prevent. The question can only be asked when the runner started
@@ -1959,7 +2023,9 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
1959
2023
  id: 'windows',
1960
2024
  name: 'native Windows apps',
1961
2025
  status: windowsUsable ? 'partial' : 'unavailable',
1962
- summary: !windowsHost
2026
+ summary: phones.windows === null
2027
+ ? 'Nothing to check: this project names no native Windows program in its settings. Most Windows products are Electron, and those are covered over their debug port from any machine.'
2028
+ : !windowsHost
1963
2029
  ? nobodyWasDialled
1964
2030
  // Not "no Windows desktop is reachable" — nothing was dialled, so that is not known.
1965
2031
  // The two were the same sentence until 2026-08-31, and it stated as a fact about the
@@ -2002,6 +2068,84 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
2002
2068
  if (windowsHost && !windowsDriver) {
2003
2069
  impossible.set('windows', `${noDriver('windows')} Nothing you install on that machine changes it. Update Stays Fixed to a copy that has it.`);
2004
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
+ }
2005
2149
  if (!windowsHost) {
2006
2150
  impossible.set(
2007
2151
  'windows',