staysfixed 0.9.1 → 0.10.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.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,63 @@ numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  Nothing yet.
10
10
 
11
+ ## [0.10.0] — 2026-08-31
12
+
13
+ The night the four surfaces nobody had pointed it at were pointed at it, and the biggest
14
+ remaining lie in the coverage ledger was closed.
15
+
16
+ ### Fixed — the ledger said things had been checked that had not
17
+
18
+ - **Knocking counted as walking.** A door counted as walked because a step said it knocked on
19
+ it, whatever came back. So a route the source declares and the running build answers 404 to
20
+ was counted as covered — and behind a sign-in wall, where every request is bounced to
21
+ /login, every door in the product counted as walked and the run came back clean. What
22
+ answered was on the record all along. **404, 405, 410 and 501** now leave the door shut and
23
+ say that the source and the build disagree about whether it exists. **A redirect** is walked
24
+ — the bounce is real behaviour — but named as a bounce, and when every door that answered
25
+ did it, the run says so: that is what a sign-in wall looks like from outside. **A 500 is
26
+ still walked**, deliberately: the route exists and it broke, which is what a check is for.
27
+ - **A product that never started was reported as twelve regressions.** A server that will not
28
+ boot produces a difference at every address it used to answer at, and not one of them said
29
+ the server had not started. The verdict leads with the cause now.
30
+ - **A build older than its source passed as "nothing that worked has changed".** This tool
31
+ runs your product and never builds it, so `node dist/server.js` runs yesterday's build
32
+ against today's code and compares it against a reference cut from the same stale output. It
33
+ says so now. Following `npm run start` down into package.json is what made it fire at all.
34
+
35
+ ### Added — the headline feature actually runs
36
+
37
+ - **Guards run on `staysfixed check`**, and over MCP, whenever the settings give an address to
38
+ open. A guard that fails becomes a finding **sealed by name**, which no agent may wave
39
+ through. Where there is no address to open it still says so, and names the one line that
40
+ would change it.
41
+
42
+ ### Fixed — it asked for what it had already been given
43
+
44
+ - **`doctor` asked every adapter with an EMPTY config** unless the settings were JSON — and
45
+ `init` writes JavaScript. So on almost every project it asked for the very thing the
46
+ settings already named, and one sentence managed to say an app was present and missing at
47
+ once. It reads the few values it needs out of the text now, scoped per block, without
48
+ running anybody's code.
49
+ - **`ios.app` was invisible**: the lookup only ever searched for `xcworkspace`, while this
50
+ tool's own advice says to name the built app under `ios.app`.
51
+
52
+ ### Fixed — noise that buried the news
53
+
54
+ - **A bundler renames its output whenever the source changes**, so one edit produced four
55
+ extra findings, all of them chunk filenames, with the real change underneath. Normalised
56
+ inside a bundler's own asset paths only — a standalone content hash still changes and still
57
+ shows.
58
+ - **On a monorepo `init` announced five products and wrote settings for one.** What is
59
+ announced and what is written now have to agree.
60
+
61
+ ### Still wrong, and written down rather than hidden
62
+
63
+ - `init` still names a container folder (`packages/`) as a product on a workspaces monorepo.
64
+ - An Electron check deadlocked once with no output and could not be reproduced.
65
+ - The Windows surface is reachable and signed in but its screen is locked, so nothing can be
66
+ driven there yet.
67
+
11
68
  ## [0.9.1] — 2026-08-30
12
69
 
13
70
  Eleven more, from the same hunt that produced 0.9.0 — the findings it had already paid for.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "staysfixed",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "description": "Prove that what already worked still works after an agent changed the code. Picture checks, guards for fixed bugs, a pre-release walkthrough, and known-good markers — as a CLI and as an MCP server.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/v2/check.js CHANGED
@@ -326,16 +326,38 @@ export async function check(options = {}) {
326
326
  // the word "guard" exactly zero times. A tool built to catch silent breakage must not do
327
327
  // nothing silently.
328
328
  const named = await guardNames(project.root);
329
- if (named.length > 0 && verdict.coverage) {
330
- verdict.coverage.gaps = [
331
- ...(verdict.coverage.gaps ?? []),
332
- {
333
- what: `${named.length} guard${named.length === 1 ? '' : 's'} written against bugs that already happened once`,
334
- why:
335
- `They are sealed by name, so nothing touching one can be waved through quietly — but they were not RUN on this check. ` +
336
- `\`staysfixed check --guards\` walks them. ${named.map((n) => `"${n}"`).join(', ')}`,
337
- },
338
- ];
329
+ if (named.length > 0) {
330
+ const walked = await walkTheGuards(project.root, options);
331
+ if (walked.ran) {
332
+ // A guard that failed is a bug somebody already had, coming back. It is sealed by
333
+ // name one of the five classes no agent may wave through, whatever the reason.
334
+ for (const bad of walked.failed) {
335
+ verdict.findings = [
336
+ ...(verdict.findings ?? []),
337
+ /** @type {any} */ ({
338
+ id: `guard-${sha256(bad.name).slice(0, 6)}`,
339
+ title: `A bug that was already fixed is back: ${bad.name}`,
340
+ why: bad.message ?? 'The guard written for it does not hold any more.',
341
+ class: 'named guard',
342
+ guard: bad.name,
343
+ differences: [],
344
+ rank: 0,
345
+ count: 1,
346
+ }),
347
+ ];
348
+ }
349
+ verdict.summary = `${walked.said} ${verdict.summary}`;
350
+ if (walked.failed.length > 0) verdict.ok = false;
351
+ } else if (verdict.coverage) {
352
+ verdict.coverage.gaps = [
353
+ ...(verdict.coverage.gaps ?? []),
354
+ {
355
+ what: `${named.length} guard${named.length === 1 ? '' : 's'} written against bugs that already happened once`,
356
+ why: `They are sealed by name, so nothing touching one can be waved through quietly — but they were not RUN on this check: ${walked.why}`,
357
+ unlockedBy: 'Give the settings an address to open — `url` beside `start` in the `web` block, or `electron.binary` — and every check walks them from then on.',
358
+ },
359
+ ];
360
+ }
339
361
  }
340
362
 
341
363
  /** @type {CheckOutcome} */
@@ -471,6 +493,21 @@ async function settle(verdict, store, product, guards) {
471
493
  : `NOTHING WAS ACTUALLY COMPARED. Every journey was walked on the build you have, and not one of them had anything on record from the build you were happy with, so there was nothing to hold them against. This is not a pass and not a failure — it is no answer. ${verdict.summary}`;
472
494
  }
473
495
 
496
+ // THE CAUSE FIRST, when the cause is that the product never answered.
497
+ //
498
+ // A server that will not start produces a difference at every address it used to answer
499
+ // at — the content type gone, the body gone, "answered at all" arriving. Measured
500
+ // 2026-08-31 on a product whose start command throws: twelve findings, not one of them
501
+ // saying the server had not started, and a person reads "12 things behave differently"
502
+ // and goes looking for a regression in code that is fine. The symptoms are real and they
503
+ // belong in the list; they are just not the news.
504
+ const silent = didNotAnswer(verdict);
505
+ if (silent.length > 0) {
506
+ verdict.summary =
507
+ `THE PRODUCT DID NOT ANSWER. ${silent.length} ${silent.length === 1 ? 'way in was' : 'ways in were'} not tried at all — ${silent.slice(0, 3).join('; ')}${silent.length > 3 ? '; and more' : ''}. Most of what follows is that one fact wearing different clothes, not ${silent.length === 1 ? 'a separate change' : 'separate changes'}: fix the start and check again before reading any of it as a regression. ` +
508
+ verdict.summary;
509
+ }
510
+
474
511
  // And what was NOT looked at, in the same breath as the good news, on every run
475
512
  // including the clean ones. A green verdict on a product with three hundred doors
476
513
  // nobody has ever opened is true and it is not what it looks like, and the only place
@@ -659,6 +696,45 @@ function comparedNothing(verdict) {
659
696
  return nothingToCompare >= walked ? 'no stored record' : null;
660
697
  }
661
698
 
699
+ /**
700
+ * Walk the guards, if this project can be opened.
701
+ *
702
+ * Guards are the headline of this whole product — one plain-English rule per bug somebody
703
+ * already had — and the default command never ran them. It said so, which was better than
704
+ * pretending, but saying it is not doing it. Version 1 knows how to drive an app and run
705
+ * them; what was missing was anybody calling it from here.
706
+ *
707
+ * It needs an address it can open. Where the settings only say how to START the product,
708
+ * that is version 2's job and this path cannot do it — so it says so instead, and names the
709
+ * one line that would change it.
710
+ *
711
+ * @param {string} root
712
+ * @param {any} options
713
+ * @returns {Promise<{ran: boolean, why: string, said: string, failed: {name: string, message?: string}[]}>}
714
+ */
715
+ async function walkTheGuards(root, options) {
716
+ try {
717
+ const { loadProject } = await import('../core/config.js');
718
+ const { runCheck } = await import('../run.js');
719
+ const project = await loadProject({ cwd: root, configFile: options?.configFile });
720
+ const run = await runCheck(project, { guardsOnly: true, writeReport: false, quiet: true, signal: options?.signal });
721
+ const guards = run.guards ?? [];
722
+ const failed = guards.filter((g) => g.status === 'failed').map((g) => ({ name: g.name, message: g.message }));
723
+ const held = guards.length - failed.length;
724
+ return {
725
+ ran: true,
726
+ why: '',
727
+ said:
728
+ failed.length === 0
729
+ ? `All ${guards.length} guard${guards.length === 1 ? '' : 's'} still hold.`
730
+ : `${failed.length} of ${guards.length} guards failed — ${failed.length === 1 ? 'a bug' : 'bugs'} that ${failed.length === 1 ? 'was' : 'were'} already fixed ${failed.length === 1 ? 'is' : 'are'} back, and no agent may wave ${failed.length === 1 ? 'it' : 'them'} through. ${held} still hold.`,
731
+ failed,
732
+ };
733
+ } catch (e) {
734
+ return { ran: false, why: messageOf(e), said: '', failed: [] };
735
+ }
736
+ }
737
+
662
738
  /**
663
739
  * The guards this project has, by name.
664
740
  *
@@ -681,6 +757,125 @@ export async function guardNames(root) {
681
757
  }
682
758
  }
683
759
 
760
+ /**
761
+ * Is the thing that ran older than the code it was built from?
762
+ *
763
+ * This tool never builds anything — deliberately, because building somebody's project is not
764
+ * its business. But a project whose start command runs `dist/server.js` and whose source
765
+ * lives in `src/` will happily run YESTERDAY's build against today's source, compare it
766
+ * against a reference cut from the same stale output, and answer "Nothing that worked has
767
+ * changed" — about code it has never once executed. Measured 2026-08-31.
768
+ *
769
+ * It cannot be fixed by building; it can be SAID, which is all a coverage gap has to do.
770
+ *
771
+ * @param {string} root
772
+ * @param {any} config
773
+ * @returns {Promise<CoverageGap|null>}
774
+ */
775
+ async function builtBeforeItsSource(root, config) {
776
+ try {
777
+ let pkg = {};
778
+ try {
779
+ pkg = JSON.parse(await fsp.readFile(path.join(root, 'package.json'), 'utf8'));
780
+ } catch {
781
+ return null;
782
+ }
783
+ const scripts = /** @type {any} */ (pkg).scripts ?? {};
784
+ // `npm run start` says nothing about where the product lives; the answer is one level
785
+ // down, in the script it runs. Following that indirection is the difference between this
786
+ // check firing and never firing, because `init` writes exactly `npm run start`.
787
+ const through = (/** @type {string} */ line) => {
788
+ const run = /(?:npm run|yarn|pnpm run|pnpm)\s+([\w:-]+)/.exec(line);
789
+ const named = run ? scripts[run[1]] : line.includes('npm start') ? scripts.start : null;
790
+ return `${line} ${typeof named === 'string' ? named : ''}`;
791
+ };
792
+ const starts = [
793
+ ...(config?.process?.commands ?? []).map((/** @type {any} */ c) => String(c?.run ?? '')),
794
+ String(config?.http?.start ?? ''),
795
+ String(config?.web?.start ?? ''),
796
+ ].map(through).join(' ');
797
+ const named = /\b(dist|build|out|lib)\b/.exec(starts);
798
+ const builds = typeof scripts.build === 'string';
799
+ if (!named || !builds) return null;
800
+
801
+ const outDir = path.join(root, named[1]);
802
+ const srcDir = path.join(root, 'src');
803
+ const [built, source] = await Promise.all([newestUnder(outDir), newestUnder(srcDir)]);
804
+ if (built === 0 || source === 0 || source <= built) return null;
805
+
806
+ const behind = Math.round((source - built) / 1000);
807
+ /** @param {number} n @param {string} unit */
808
+ const plural = (n, unit) => `${n} ${unit}${n === 1 ? '' : 's'}`;
809
+ const howLong =
810
+ behind > 86400 ? plural(Math.round(behind / 86400), 'day') : behind > 3600 ? plural(Math.round(behind / 3600), 'hour') : plural(Math.max(1, Math.round(behind / 60)), 'minute');
811
+ return {
812
+ what: `What ran is older than the code it was built from — \`${named[1]}/\` is ${howLong} behind \`src/\`.`,
813
+ why: `This tool runs your product, it never builds it. So the build in \`${named[1]}/\` is what was walked, and your newer source was not executed at all. A clean result here says nothing whatever about the code you have just written — and the reference it was compared against was cut from the same stale output.`,
814
+ unlockedBy: 'Run your build before the check — `npm run build && npx staysfixed check` — or put the build into the start command in your settings.',
815
+ };
816
+ } catch {
817
+ return null;
818
+ }
819
+ }
820
+
821
+ /**
822
+ * The newest modification time anywhere under a folder, or 0 if there is nothing there.
823
+ * @param {string} dir
824
+ * @returns {Promise<number>}
825
+ */
826
+ async function newestUnder(dir) {
827
+ let newest = 0;
828
+ /** @param {string} at @param {number} depth */
829
+ const walk = async (at, depth) => {
830
+ if (depth > 6) return;
831
+ let entries = [];
832
+ try {
833
+ entries = await fsp.readdir(at, { withFileTypes: true });
834
+ } catch {
835
+ return;
836
+ }
837
+ for (const entry of entries) {
838
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
839
+ const full = path.join(at, entry.name);
840
+ if (entry.isDirectory()) await walk(full, depth + 1);
841
+ else {
842
+ try {
843
+ const at2 = (await fsp.stat(full)).mtimeMs;
844
+ if (at2 > newest) newest = at2;
845
+ } catch {
846
+ // gone between the listing and the question
847
+ }
848
+ }
849
+ }
850
+ };
851
+ await walk(dir, 0);
852
+ return newest;
853
+ }
854
+
855
+ /**
856
+ * The ways into the product that were never tried, and why.
857
+ *
858
+ * A build that would not start does not produce one finding — it produces one at every
859
+ * address it used to answer at, and the real news is nowhere in the list.
860
+ *
861
+ * @param {CheckOutcome} verdict
862
+ * @returns {string[]}
863
+ */
864
+ function didNotAnswer(verdict) {
865
+ /** @type {Set<string>} */
866
+ const said = new Set();
867
+ for (const finding of verdict.findings ?? []) {
868
+ for (const d of finding.differences ?? []) {
869
+ const path = String(d.path ?? '');
870
+ if (!path.endsWith('answered at all')) continue;
871
+ const name = path.split('.').slice(1, -1).join('.') || path;
872
+ const why = typeof d.candidate === 'string' ? d.candidate : typeof d.describe === 'string' ? d.describe : '';
873
+ said.add(why ? `${name} (${String(why).slice(0, 90)})` : name);
874
+ }
875
+ }
876
+ return [...said];
877
+ }
878
+
684
879
  /**
685
880
  * Copies left behind by runs that never finished.
686
881
  *
@@ -1591,6 +1786,8 @@ async function openProject(options) {
1591
1786
  unlockedBy: `Fix package.json, or put the name you want in your settings file as product: '<name>'. Until then every comparison starts from nothing.`,
1592
1787
  });
1593
1788
  }
1789
+ const stale = await builtBeforeItsSource(root, config);
1790
+ if (stale) gaps.push(stale);
1594
1791
  if (storeTrouble.length > 0) {
1595
1792
  gaps.push({
1596
1793
  what: 'This run was NOT written down, so the next check has nothing from today to compare against.',
@@ -101,6 +101,14 @@ import { listBuilds, listCaptures, loadCapture, referencePointer } from './store
101
101
  * @property {string[]} [doors] Door keys the journey's steps name, for steps that
102
102
  * named a door with nothing to tell apart from
103
103
  * another of the same name. See doorKey.
104
+ * @property {{door: string, status: number}[]} [onlyRedirected]
105
+ * Doors that answered with a redirect. Walked — the
106
+ * bounce is real behaviour — but what is behind them
107
+ * was never seen.
108
+ * @property {{door: string, status: number}[]} [knockedShut]
109
+ * Doors a step knocked on where the running build
110
+ * answered that they are not there. Knocking is not
111
+ * walking, and these have proved nothing.
104
112
  * @property {string[]} [doorAddresses] Full door addresses, for steps that were specific
105
113
  * enough to build one. A route step knows its verb,
106
114
  * and GET /x and POST /x are two doors that share a
@@ -401,6 +409,15 @@ export function addressesTouched(observations) {
401
409
  return { paths: [...paths], byChannel };
402
410
  }
403
411
 
412
+ /**
413
+ * Answers that mean the door is not there in the build that ran.
414
+ *
415
+ * A 500 is deliberately NOT here: the route exists and it broke, which is a real difference
416
+ * and exactly what a check is for. These four are the codes that say the thing the source
417
+ * declares was never reachable, so nothing has been proved about it either way.
418
+ */
419
+ const NOTHING_THERE = new Set([404, 405, 410, 501]);
420
+
404
421
  /**
405
422
  * A walk, built from one stored capture and, when it is to hand, the journey behind it.
406
423
  *
@@ -426,8 +443,55 @@ export function walkFromCapture(capture, journey) {
426
443
  // would report POST as walked too, which is the coverage ledger lying in the one direction
427
444
  // it must never lie in.
428
445
  const named = journey.steps.filter((s) => typeof s.door === 'string' && typeof s.kind === 'string');
429
- const exact = named.filter((s) => typeof s.doorDetail === 'string' && s.doorDetail !== '');
430
- const byName = named.filter((s) => typeof s.doorDetail !== 'string' || s.doorDetail === '');
446
+
447
+ // KNOCKING IS NOT WALKING.
448
+ //
449
+ // A door was counted as walked because a STEP said it knocked on it — whatever came back.
450
+ // So a route the source declares and the running build answers 404 counted as covered; and
451
+ // behind a login wall, where every request is bounced to /login, every door in the product
452
+ // counted as walked and the run came back clean. That is the coverage ledger lying in the
453
+ // one direction this file says it must never lie in.
454
+ //
455
+ // What answered is on the record: the http adapter writes `api.<door>.status`. A 404, 405,
456
+ // 410 or 501 means the thing the code declares is not there in the build that ran, so
457
+ // nothing was proved about it and it stays shut.
458
+ const answered = new Map();
459
+ for (const ob of capture.observations ?? []) {
460
+ const found = /^api\.(.+)\.status$/.exec(String(ob?.path ?? ''));
461
+ if (found) answered.set(found[1], Number(ob.value));
462
+ }
463
+ /** @type {{door: string, status: number}[]} */
464
+ const shut = [];
465
+ /** @type {{door: string, status: number}[]} */
466
+ const bounced = [];
467
+ /** @param {any} s @returns {boolean} */
468
+ const reallyWalked = (s) => {
469
+ // The door is the ROUTE (`/reports`); the observation is addressed by method and route
470
+ // together (`api.GET /reports.status`), because GET and POST on one path are two doors.
471
+ // Try the composite first and the bare name after, so both shapes of step are covered.
472
+ const keys = [
473
+ typeof s.doorDetail === 'string' && s.doorDetail ? `${s.doorDetail} ${s.door}` : null,
474
+ typeof s.method === 'string' && s.method ? `${s.method} ${s.door}` : null,
475
+ String(s.door),
476
+ ].filter(Boolean);
477
+ const key = keys.find((k) => answered.has(/** @type {string} */ (k)));
478
+ const code = key === undefined ? undefined : answered.get(/** @type {string} */ (key));
479
+ // A redirect is real behaviour and it IS walked — but what you saw is the bounce, not the
480
+ // thing behind it. Behind a login wall every door answers 302 to /login, and the run
481
+ // then reports full coverage of a product it never got into.
482
+ if (typeof code === 'number' && code >= 300 && code < 400) {
483
+ bounced.push({ door: String(key), status: code });
484
+ return true;
485
+ }
486
+ if (typeof code !== 'number' || !NOTHING_THERE.has(code)) return true;
487
+ shut.push({ door: String(key), status: code });
488
+ return false;
489
+ };
490
+
491
+ const exact = named.filter((s) => typeof s.doorDetail === 'string' && s.doorDetail !== '').filter(reallyWalked);
492
+ const byName = named.filter((s) => typeof s.doorDetail !== 'string' || s.doorDetail === '').filter(reallyWalked);
493
+ if (shut.length > 0) walk.knockedShut = shut;
494
+ if (bounced.length > 0) walk.onlyRedirected = bounced;
431
495
  if (byName.length > 0) walk.doors = byName.map((s) => doorKey({ kind: String(s.kind), name: String(s.door) }));
432
496
  if (exact.length > 0) {
433
497
  walk.doorAddresses = exact.map((s) =>
@@ -613,6 +677,27 @@ export function buildLedger(input) {
613
677
  `${cutFunctions} functions that really did run were cut from the coverage lists to keep them readable, so up to that many of the doors counted as never opened were in fact opened. This ledger undercounts, and it undercounts by no more than ${cutFunctions}.`,
614
678
  );
615
679
  }
680
+ // Named, never silently dropped. A route the code declares and the build answers 404 to is
681
+ // not a covered route and it is not an absent one either — it is a disagreement between the
682
+ // source and the thing that ran, and that is worth more than most differences.
683
+ /** @type {Map<string, number>} */
684
+ const shutDoors = new Map();
685
+ for (const { walk } of walks) for (const d of walk.knockedShut ?? []) shutDoors.set(d.door, d.status);
686
+ if (shutDoors.size > 0) {
687
+ const listed = [...shutDoors.entries()].slice(0, 6).map(([door, code]) => `${door} answered ${code}`).join(', ');
688
+ caveats.push(
689
+ `${shutDoors.size} ${shutDoors.size === 1 ? 'door the code declares was' : 'doors the code declares were'} knocked on and answered as not being there (${listed}${shutDoors.size > 6 ? ', and more' : ''}). Knocking is not walking: nothing has been proved about ${shutDoors.size === 1 ? 'it' : 'them'}, and the source and the build that ran disagree about whether ${shutDoors.size === 1 ? 'it exists' : 'they exist'}.`,
690
+ );
691
+ }
692
+ /** @type {Map<string, number>} */
693
+ const bouncedDoors = new Map();
694
+ for (const { walk } of walks) for (const d of walk.onlyRedirected ?? []) bouncedDoors.set(d.door, d.status);
695
+ if (bouncedDoors.size > 0) {
696
+ const all = bouncedDoors.size >= Math.max(1, opened);
697
+ caveats.push(
698
+ `${bouncedDoors.size} ${bouncedDoors.size === 1 ? 'door' : 'doors'} answered with a redirect rather than with ${bouncedDoors.size === 1 ? 'a page' : 'pages'} — ${[...bouncedDoors.entries()].slice(0, 5).map(([door, code]) => `${door} answered ${code}`).join(', ')}${bouncedDoors.size > 5 ? ', and more' : ''}. What was seen is the bounce, not what is behind it.${all ? ' EVERY door that answered did this, which is what a sign-in wall looks like from out here: this run has not been inside the product at all.' : ''}`,
699
+ );
700
+ }
616
701
  if (input.doors.length === 0) {
617
702
  caveats.push('No doors are known at all, so this ledger cannot say what is uncovered — which is not the same as there being nothing uncovered.');
618
703
  }
package/src/v2/doctor.js CHANGED
@@ -900,6 +900,18 @@ async function phoneApps(root, configFile) {
900
900
  return found ? { where: found[1], how: `your settings name it under ${key}` } : null;
901
901
  };
902
902
 
903
+ /**
904
+ * A named key whose value has to look right, so one settings word cannot be mistaken for
905
+ * another block's.
906
+ * @param {string} key
907
+ * @param {(value: string) => boolean} looksRight
908
+ * @returns {FoundApp|null}
909
+ */
910
+ const namedPath = (key, looksRight) => {
911
+ const found = new RegExp(`["']?${key}["']?\\s*:\\s*["'\`]([^"'\`]+)["'\`]`).exec(settings);
912
+ return found && looksRight(found[1]) ? { where: found[1], how: `your settings name it under ${key}` } : null;
913
+ };
914
+
903
915
  /**
904
916
  * @param {string[]} folders
905
917
  * @param {(name: string) => boolean} wanted
@@ -933,6 +945,14 @@ async function phoneApps(root, configFile) {
933
945
  : null);
934
946
 
935
947
  const ios =
948
+ // `ios.app` FIRST, because that is the key the adapter reads and the key this very file
949
+ // tells people to write: "name the built .app in your settings under ios.app". It then
950
+ // looked only for `xcworkspace`, so a settings file naming a real built app was invisible
951
+ // and doctor answered "no iPhone app was found in this project, and the settings do not
952
+ // name one" about a project whose settings named one. Measured 2026-08-31 against a real
953
+ // TerminalDeck.app. The value has to end in `.app` so a bare `app:` belonging to some
954
+ // other block can never be mistaken for this one.
955
+ namedPath('app', (v) => v.endsWith('.app')) ??
936
956
  named('xcworkspace') ??
937
957
  built(['dist', 'out', 'build', 'release'], (name) => name.endsWith('.app')) ??
938
958
  (there(path.join('ios', 'Podfile')) || readdirSafe(path.join(root, 'ios')).some((n) => n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace'))
@@ -1065,6 +1085,16 @@ async function askTheAdapters(root) {
1065
1085
  // Read as text and parsed only when it is JSON. Doctor never runs a person's code to
1066
1086
  // answer a question about their machine, and a settings file may be JavaScript.
1067
1087
  if (file && file.endsWith('.json')) config = JSON.parse(readFileSync(file, 'utf8'));
1088
+ // But "not JSON" was being treated as "says nothing", and `init` writes JavaScript — so
1089
+ // for almost every project every adapter was asked what it needs while being handed an
1090
+ // EMPTY config. It then asked for the very thing the settings already named: a project
1091
+ // whose settings pointed at a real built TerminalDeck.app was told, in one sentence,
1092
+ // "the app is here. What is missing is a built iPhone app to check". Measured 2026-08-31.
1093
+ //
1094
+ // The few values the adapters need to answer honestly are read out of the TEXT instead,
1095
+ // scoped to their own block so one block's `app` can never be read as another's. Still no
1096
+ // code is run, which was the whole point of the rule.
1097
+ else if (file) config = { ...config, ...settingsFromText(readFileSync(file, 'utf8')) };
1068
1098
  } catch {
1069
1099
  config = {};
1070
1100
  }
@@ -1384,6 +1414,54 @@ export function readHostProbe(name, alive, look) {
1384
1414
  return report;
1385
1415
  }
1386
1416
 
1417
+ /**
1418
+ * The handful of settings an adapter needs to say what it is missing, read out of a
1419
+ * JavaScript settings file WITHOUT running it.
1420
+ *
1421
+ * Scoped per block on purpose: `app` means one thing under `ios` and nothing under `web`, and
1422
+ * a flat search would hand the wrong path to the wrong adapter.
1423
+ *
1424
+ * @param {string} text
1425
+ * @returns {Record<string, any>}
1426
+ */
1427
+ function settingsFromText(text) {
1428
+ const clean = withoutComments(text);
1429
+ /** @type {Record<string, any>} */
1430
+ const out = {};
1431
+ /** @type {Record<string, string[]>} */
1432
+ const wanted = {
1433
+ ios: ['app'],
1434
+ android: ['apk', 'package'],
1435
+ electron: ['binary'],
1436
+ web: ['url', 'start'],
1437
+ http: ['start', 'url'],
1438
+ };
1439
+ for (const [block, keys] of Object.entries(wanted)) {
1440
+ const at = new RegExp(`["']?${block}["']?\\s*:\\s*\\{`).exec(clean);
1441
+ if (!at) continue;
1442
+ // The block's own text: from its brace to the matching one, counted rather than guessed,
1443
+ // so a nested object cannot end the block early.
1444
+ let depth = 0;
1445
+ let end = at.index + at[0].length;
1446
+ for (; end < clean.length; end += 1) {
1447
+ if (clean[end] === '{') depth += 1;
1448
+ else if (clean[end] === '}') {
1449
+ if (depth === 0) break;
1450
+ depth -= 1;
1451
+ }
1452
+ }
1453
+ const inside = clean.slice(at.index + at[0].length, end);
1454
+ /** @type {Record<string, string>} */
1455
+ const found = {};
1456
+ for (const key of keys) {
1457
+ const hit = new RegExp(`["']?${key}["']?\\s*:\\s*["'\`]([^"'\`]+)["'\`]`).exec(inside);
1458
+ if (hit) found[key] = hit[1];
1459
+ }
1460
+ if (Object.keys(found).length > 0) out[block] = found;
1461
+ }
1462
+ return out;
1463
+ }
1464
+
1387
1465
  /**
1388
1466
  * Host names out of ~/.ssh/config. Patterns are skipped: `Host *` is a rule, not
1389
1467
  * a machine, and dialling it would be meaningless.
@@ -1704,7 +1782,14 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
1704
1782
  ? 'Cannot run here: no usable iOS runtime was found, so there is no simulator to boot the app on.'
1705
1783
  : iosReady
1706
1784
  ? `Covered on the simulator, against the stored record. It boots ${phones.ios.where} on a simulator of its own and reads what each control on the screen is and does. A real iPhone in your hand cannot be compared side by side and never will be — two builds cannot exist on it at once.`
1707
- : `The simulator is here and the app is here. What is missing is ${plainList(iosWants.map((n) => n.what))}, so a clean result would cover less than it looks like.`,
1785
+ : // "and the app is here" was said because a PATH was found, while the adapter was
1786
+ // still asking for a built app — so one sentence claimed the app was present and
1787
+ // missing at once. Measured 2026-08-31 on a settings file pointing at a
1788
+ // TerminalDeck.app that turned out to be an empty folder left by an old build.
1789
+ // A path is not a bundle, and only the adapter can tell the difference.
1790
+ iosWants.some((n) => /built iPhone app/i.test(String(n.what)))
1791
+ ? `The simulator is here and the settings name an app, but there is no built app bundle at that path — an empty folder from an old build looks exactly like this. What is missing is ${plainList(iosWants.map((n) => n.what))}.`
1792
+ : `The simulator is here and the app is here. What is missing is ${plainList(iosWants.map((n) => n.what))}, so a clean result would cover less than it looks like.`,
1708
1793
  canCheck: iosReady || iosPartly ? [...withoutADriver, 'meaning', 'pixels'] : [],
1709
1794
  cannotCheck: iosReady || iosPartly ? [] : CHANNELS.map((c) => c.id),
1710
1795
  needs:
package/src/v2/init.js CHANGED
@@ -1807,7 +1807,21 @@ export async function run(ctx) {
1807
1807
  }
1808
1808
 
1809
1809
  blank();
1810
+ // What was ANNOUNCED and what was WRITTEN have to agree. On a workspaces monorepo this
1811
+ // said "can be checked here now" about four things — including a folder that is only a
1812
+ // container and holds no product at all — and then wrote one settings file describing one
1813
+ // product. Measured 2026-08-31. Everything above is what was FOUND; this says what is
1814
+ // actually covered, and how to cover the rest.
1815
+ const others = (result.plan?.readiness ?? [])
1816
+ .map((/** @type {any} */ r) => String(r.product ?? ''))
1817
+ .filter((/** @type {string} */ n, /** @type {number} */ i, /** @type {string[]} */ all) => n !== '' && all.indexOf(n) === i);
1810
1818
  if (result.written.length > 0) ok(`Written: ${result.written.map((f) => shortPath(f)).join(', ')}`);
1819
+ if (others.length > 1) {
1820
+ warn(
1821
+ `Those settings describe ONE product. ${others.length} were found here (${others.join(', ')}), and the others are not covered by this file. ` +
1822
+ 'Run `staysfixed init` from inside each of the others to give it its own settings, and check it from there.',
1823
+ );
1824
+ }
1811
1825
  if (result.kept.length > 0) warn(`Left exactly as it was: ${result.kept.map((f) => shortPath(f)).join(', ')}`);
1812
1826
  for (const problem of result.problems) fail(problem);
1813
1827
 
@@ -114,6 +114,17 @@ export const DEFAULT_RULES = [
114
114
  pattern: '\\b[0-9a-fA-F]{16,31}\\b',
115
115
  with: '<hex>',
116
116
  },
117
+ {
118
+ id: 'asset.bundled',
119
+ kind: 'replace',
120
+ what: 'The hash in a bundler\'s own asset filename — /_next/static/chunks/main-9f2c1a.js, /assets/index-4b8e21.css, app.7d3f9a1c.js.',
121
+ why:
122
+ 'A bundler renames its output whenever the source changes, so editing one line renames several files. Measured on a Next.js app: one source edit produced four extra findings, all of them chunk filenames, and the change a person actually made was underneath them. The rename is not news — the code change is, and that is reported on its own.',
123
+ wouldHide:
124
+ 'A deliberate change to an asset filename, which nobody makes by hand. Deliberately narrow: the hash is only taken inside a path that a bundler owns, so a content hash anywhere else still changes and still shows — see id.hex, which leaves 32-and-longer hex alone for exactly that reason.',
125
+ pattern: '(/_next/static/[^"\'\\s]*?|/assets/[^"\'\\s]*?|\\b[\\w.-]+)[.-][0-9a-fA-F]{6,}(\\.(?:js|mjs|css|map))',
126
+ with: '$1.<asset>$2',
127
+ },
117
128
  {
118
129
  id: 'id.pid',
119
130
  kind: 'replace',