staysfixed 0.7.2 → 0.9.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +429 -0
  2. package/README.md +193 -57
  3. package/docs/design-v2.md +24 -4
  4. package/docs/getting-started.md +19 -6
  5. package/docs/guards.md +2 -2
  6. package/docs/how-v2-works.md +12 -11
  7. package/docs/mcp.md +17 -8
  8. package/docs/settings.md +564 -0
  9. package/docs/watching.md +10 -4
  10. package/examples/staysfixed.config.electron.js +17 -6
  11. package/examples/staysfixed.config.web.js +22 -5
  12. package/package.json +2 -1
  13. package/src/cli/index.js +55 -46
  14. package/src/cli/status.js +45 -1
  15. package/src/cli/watch-flags.js +54 -0
  16. package/src/core/config.js +54 -3
  17. package/src/core/paths.js +15 -0
  18. package/src/guard/run.js +70 -3
  19. package/src/report/console.js +50 -6
  20. package/src/run.js +11 -0
  21. package/src/types.js +3 -0
  22. package/src/v2/adapters/android-driver.js +6 -1
  23. package/src/v2/adapters/android.js +97 -2
  24. package/src/v2/adapters/child.js +101 -0
  25. package/src/v2/adapters/contract.js +42 -5
  26. package/src/v2/adapters/electron.js +72 -6
  27. package/src/v2/adapters/http.js +18 -11
  28. package/src/v2/adapters/ios-driver.js +64 -14
  29. package/src/v2/adapters/ios.js +247 -25
  30. package/src/v2/adapters/process.js +783 -71
  31. package/src/v2/adapters/python.js +495 -0
  32. package/src/v2/adapters/source.js +373 -18
  33. package/src/v2/adapters/web-driver.js +134 -24
  34. package/src/v2/adapters/web.js +149 -18
  35. package/src/v2/adapters/windows.js +18 -1
  36. package/src/v2/browsers.js +66 -3
  37. package/src/v2/cause.js +61 -17
  38. package/src/v2/check.js +653 -69
  39. package/src/v2/ci.js +130 -35
  40. package/src/v2/cli.js +65 -42
  41. package/src/v2/cluster.js +220 -14
  42. package/src/v2/coverage.js +43 -176
  43. package/src/v2/detect.js +308 -60
  44. package/src/v2/doctor.js +353 -54
  45. package/src/v2/escalate.js +5 -1
  46. package/src/v2/init.js +183 -66
  47. package/src/v2/intent.js +9 -23
  48. package/src/v2/journeys/from-suite.js +336 -30
  49. package/src/v2/journeys/index.js +99 -6
  50. package/src/v2/mcp/tools.js +90 -16
  51. package/src/v2/normalise.js +169 -23
  52. package/src/v2/observation.js +19 -33
  53. package/src/v2/rank.js +216 -23
  54. package/src/v2/reference.js +160 -24
  55. package/src/v2/remote.js +113 -18
  56. package/src/v2/run.js +103 -14
  57. package/src/v2/sealed.js +0 -20
  58. package/src/v2/selfcheck.js +190 -13
  59. package/src/v2/ship.js +55 -5
  60. package/src/v2/store.js +67 -1
  61. package/src/v2/types.js +12 -2
  62. package/src/v2/waiver.js +64 -54
  63. package/src/v2/watch/events.js +60 -215
  64. package/src/v2/watch/focus.js +14 -4
  65. package/src/v2/watch/panel.js +167 -17
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "staysfixed",
3
- "version": "0.7.2",
3
+ "version": "0.9.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",
@@ -55,6 +55,7 @@
55
55
  },
56
56
  "dependencies": {
57
57
  "pixelmatch": "^7.2.0",
58
+ "playwright-core": "^1.56.0",
58
59
  "pngjs": "^7.0.0"
59
60
  },
60
61
  "devDependencies": {
package/src/cli/index.js CHANGED
@@ -12,6 +12,8 @@ import { setLogLevel } from '../core/log.js';
12
12
  import { V2_COMMANDS } from '../v2/cli.js';
13
13
  import { SHIP_COMMANDS } from '../v2/ship.js';
14
14
  import { INIT_COMMANDS } from '../v2/init.js';
15
+ import { BROWSERS_COMMAND } from '../v2/browsers.js';
16
+ export { watchFlags } from './watch-flags.js';
15
17
 
16
18
  /** @type {{version?: string}} */
17
19
  const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
@@ -61,9 +63,22 @@ export const VERSION = pkg.version ?? '0.0.0';
61
63
  * @property {() => Promise<{run: (ctx: CliContext) => Promise<number>}>} [load]
62
64
  */
63
65
 
64
- /** @type {ArgSpec} */
66
+ /**
67
+ * The flags every command takes.
68
+ *
69
+ * `no-color` is declared as its own switch rather than as `color`, and that is not a
70
+ * spelling choice. Colour is settled in bin/staysfixed.js, before anything else is
71
+ * imported, because src/core/log.js decides once at load whether it may paint — so by the
72
+ * time a command is parsed the answer is already fixed and nothing here could change it.
73
+ * Declaring `color` made `--color` a real flag that turned nothing on, which is the worst
74
+ * kind: a person types it, the tool accepts it, and nothing happens. There is no way to
75
+ * force colour ON from here, so the only honest thing to offer is the half that works.
76
+ * `--no-color` is named so `--help` and the parser agree; the work is already done.
77
+ *
78
+ * @type {ArgSpec}
79
+ */
65
80
  const GLOBAL_SPEC = {
66
- booleans: ['verbose', 'quiet', 'help', 'version', 'color'],
81
+ booleans: ['verbose', 'quiet', 'help', 'version', 'no-color'],
67
82
  strings: ['config', 'cwd'],
68
83
  alias: { v: 'verbose', q: 'quiet', h: 'help', V: 'version' },
69
84
  };
@@ -76,7 +91,7 @@ const GLOBAL_VALUE_FLAGS = new Set(['--config', '--cwd']);
76
91
  * are declared once here so the two commands cannot drift apart.
77
92
  */
78
93
  const WATCH_SPEC = {
79
- booleans: ['watch', 'watch-front', 'keep-open', 'profile'],
94
+ booleans: ['watch', 'watch-front', 'keep-open', 'profile', 'snap'],
80
95
  strings: ['watch-side', 'watch-width'],
81
96
  };
82
97
 
@@ -86,12 +101,20 @@ const WATCH_OPTIONS = [
86
101
  ['--watch-side <side>', 'Which side of the app the panel sits on: left or right. Default right.'],
87
102
  ['--watch-width <n>', 'How wide the panel is, in pixels. Default 460.'],
88
103
  ['--no-keep-open', 'Close the panel as soon as the run finishes.'],
104
+ ['--no-snap', 'Leave both windows exactly where they are instead of putting them side by side.'],
89
105
  ['--watch-front', 'Bring the panel to the front. By default it opens behind your work.'],
90
106
  ['--profile', 'Print where the time went when the run is over.'],
91
107
  ];
92
108
 
93
- /** @type {Record<string, CommandEntry>} */
94
- const COMMANDS = {
109
+ /**
110
+ * Every command, and the only list of them. `--help` is printed from this, and every
111
+ * flag any command accepts is declared in its `spec` here — so a flag that prints in the
112
+ * help and a flag the parser knows about cannot drift apart. It is exported so that can
113
+ * be checked from outside rather than by reading two lists side by side.
114
+ *
115
+ * @type {Record<string, CommandEntry>}
116
+ */
117
+ export const COMMANDS = {
95
118
  init: {
96
119
  summary: 'Set this project up. Takes about thirty seconds.',
97
120
  usage: 'staysfixed init [--force] [--json]',
@@ -227,6 +250,16 @@ const COMMANDS = {
227
250
  examples: ['staysfixed mcp', 'staysfixed mcp --v1'],
228
251
  spec: { booleans: ['v1'] },
229
252
  },
253
+
254
+ /*
255
+ * `browsers` was written, tested, given a finished command entry in src/v2/browsers.js
256
+ * with a comment saying "wiring it up is one line" — and that line was never written.
257
+ * README.md told people to run `npx staysfixed browsers` and `--clean` to tidy up after
258
+ * an interrupted run, and both answered "There is no command called browsers". Somebody
259
+ * whose disk was filling with abandoned browser profiles had no way to clear them and no
260
+ * reason to doubt the page telling them there was.
261
+ */
262
+ browsers: BROWSERS_COMMAND,
230
263
  };
231
264
 
232
265
  /*
@@ -352,45 +385,8 @@ function contextFor(parsed, cwd, configFile) {
352
385
  * @property {boolean} [foreground]
353
386
  */
354
387
 
355
- /**
356
- * Read the panel flags. Shared by `check` and `walk` so the two behave the same.
357
- * @param {CliContext} ctx
358
- * @returns {WatchFlags}
359
- */
360
- export function watchFlags(ctx) {
361
- /** @type {WatchFlags} */
362
- const flags = { enabled: ctx.bool('watch') };
363
-
364
- const side = ctx.str('watch-side');
365
- if (side !== undefined) {
366
- if (side !== 'left' && side !== 'right') {
367
- throw new StaysFixedError(`--watch-side has to be left or right, not "${side}".`, {
368
- hint: 'Write it as `--watch-side left` or `--watch-side right`.',
369
- });
370
- }
371
- flags.side = side;
372
- }
373
-
374
- const width = ctx.str('watch-width');
375
- if (width !== undefined) {
376
- const n = Number(width);
377
- // A panel narrower than this cannot show the before-and-after pictures side
378
- // by side, which is the only reason to open it.
379
- if (!Number.isFinite(n) || n < 240) {
380
- throw new StaysFixedError(`--watch-width has to be a number of pixels, 240 or more — I got "${width}".`, {
381
- hint: 'Write it as `--watch-width 520`.',
382
- });
383
- }
384
- flags.width = Math.round(n);
385
- }
388
+ // `watchFlags` moved to ./watch-flags.js — see the note there about the import cycle.
386
389
 
387
- // Only mention these when they were actually typed, so --no-keep-open turns the
388
- // panel off at the end without a bare --watch turning it on against the settings.
389
- if (ctx.flags['keep-open'] !== undefined) flags.keepOpen = ctx.flags['keep-open'] === true;
390
- if (ctx.bool('watch-front')) flags.foreground = true;
391
-
392
- return flags;
393
- }
394
390
 
395
391
  /**
396
392
  * The panel settings a project's settings file carries, if it carries any.
@@ -446,15 +442,28 @@ function splitCommand(argv) {
446
442
  }
447
443
 
448
444
  /**
445
+ * The global flags plus one command's own — with the command winning any name they share.
446
+ *
447
+ * That last part is the whole reason this is not a concatenation. `--version` is global and
448
+ * means "print the tool's version"; `staysfixed ship --version 0.14.0` means "the release
449
+ * that went out was called 0.14.0", and it is in that command's own help. Merged naively,
450
+ * the name landed in both lists, the parser reads booleans first, and `staysfixed ship
451
+ * --version 0.14.0` printed `0.7.2` and shipped nothing at all — no error, no clue, and the
452
+ * release script that called it carried on. A command's own list of flags is the more
453
+ * specific statement of what that command means, so it wins.
454
+ *
449
455
  * @param {ArgSpec} base
450
456
  * @param {ArgSpec} extra
451
457
  * @returns {ArgSpec}
452
458
  */
453
459
  function mergeSpec(base, extra) {
460
+ const claimed = new Set([...(extra.booleans ?? []), ...(extra.strings ?? []), ...(extra.arrays ?? [])]);
461
+ /** @param {string[]|undefined} names */
462
+ const keep = (names) => (names ?? []).filter((name) => !claimed.has(name));
454
463
  return {
455
- booleans: [...(base.booleans ?? []), ...(extra.booleans ?? [])],
456
- strings: [...(base.strings ?? []), ...(extra.strings ?? [])],
457
- arrays: [...(base.arrays ?? []), ...(extra.arrays ?? [])],
464
+ booleans: [...keep(base.booleans), ...(extra.booleans ?? [])],
465
+ strings: [...keep(base.strings), ...(extra.strings ?? [])],
466
+ arrays: [...keep(base.arrays), ...(extra.arrays ?? [])],
458
467
  alias: { ...(base.alias ?? {}), ...(extra.alias ?? {}) },
459
468
  };
460
469
  }
package/src/cli/status.js CHANGED
@@ -2,11 +2,55 @@
2
2
  * `staysfixed status` — reads what is on disk and says it. Launches nothing.
3
3
  */
4
4
 
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+
5
8
  import { loadProject } from '../core/config.js';
6
9
  import { projectStatus } from '../run.js';
7
10
  import { printStatus } from '../report/console.js';
8
11
  import { EXIT } from '../core/errors.js';
9
12
 
13
+ /**
14
+ * What version 2 has recorded here, if anything.
15
+ *
16
+ * `status` only ever counted version 1's things — approved pictures, screens, guards,
17
+ * markers — so on a project that had just been checked and shipped it said "Nothing has been
18
+ * checked here yet. Start with: staysfixed check". Measured on 2026-08-30, one command after
19
+ * a run that walked 36 addresses and a ship that cut the reference. The command whose whole
20
+ * promise is to say instantly what is going on here was the one saying nothing had happened.
21
+ *
22
+ * @param {string} root
23
+ * @returns {{at: string, verdict: string, reference: string|null, findings: number}|null}
24
+ */
25
+ export function versionTwoState(root) {
26
+ try {
27
+ const file = path.join(root, '.staysfixed', 'v2', 'last-check.json');
28
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
29
+ if (!raw || typeof raw.at !== 'string') return null;
30
+ // The reference is read from where it is KEPT, not from the last check's memory of it.
31
+ // A check writes what it compared against at the time; ship cuts a reference after
32
+ // that, so on the ordinary first-run order — check, then ship — the check's field still
33
+ // says "no-reference-yet" while a reference plainly exists.
34
+ let reference = null;
35
+ try {
36
+ const cuts = JSON.parse(fs.readFileSync(path.join(root, '.staysfixed', 'v2', 'reference-log.json'), 'utf8'));
37
+ const newest = Array.isArray(cuts) && cuts.length ? cuts[cuts.length - 1] : null;
38
+ if (newest && typeof newest.id === 'string') reference = newest.id;
39
+ } catch {
40
+ if (typeof raw.reference === 'string' && raw.reference !== 'no-reference-yet') reference = raw.reference;
41
+ }
42
+ return {
43
+ at: raw.at,
44
+ verdict: typeof raw.verdict === 'string' ? raw.verdict : 'ran',
45
+ reference,
46
+ findings: Array.isArray(raw.findings) ? raw.findings.length : 0,
47
+ };
48
+ } catch {
49
+ // Nothing recorded, or nothing readable. Either way there is nothing to add.
50
+ return null;
51
+ }
52
+ }
53
+
10
54
  /**
11
55
  * @param {import('./index.js').CliContext} ctx
12
56
  * @returns {Promise<number>}
@@ -14,6 +58,6 @@ import { EXIT } from '../core/errors.js';
14
58
  export async function run(ctx) {
15
59
  const project = await loadProject({ cwd: ctx.cwd, configFile: ctx.configFile });
16
60
  const status = await projectStatus(project);
17
- printStatus(/** @type {any} */ (status));
61
+ printStatus(/** @type {any} */ ({ ...status, v2: versionTwoState(project.paths?.root ?? ctx.cwd) }));
18
62
  return EXIT.ok;
19
63
  }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Reading the --watch flags off a command line.
3
+ *
4
+ * This lives on its own, and not in `src/cli/index.js` where it started, because both
5
+ * halves of the tool need it and the import went in a circle: version 1's command table
6
+ * imports version 2's commands, and version 2's command file imported this back out of
7
+ * version 1's table. That worked only while the modules happened to load in a helpful
8
+ * order — the day another import was added to version 2, the whole command line failed
9
+ * with "cannot access V2_COMMANDS before initialization" and nothing ran at all.
10
+ *
11
+ * A shared thing that both sides need belongs to neither of them.
12
+ */
13
+
14
+ import { StaysFixedError } from '../core/errors.js';
15
+
16
+ /**
17
+ * Read the panel flags. Shared by `check` and `walk` so the two behave the same.
18
+ * @param {import('./index.js').CliContext} ctx
19
+ * @returns {import('./index.js').WatchFlags}
20
+ */
21
+ export function watchFlags(ctx) {
22
+ /** @type {import('./index.js').WatchFlags} */
23
+ const flags = { enabled: ctx.bool('watch') };
24
+
25
+ const side = ctx.str('watch-side');
26
+ if (side !== undefined) {
27
+ if (side !== 'left' && side !== 'right') {
28
+ throw new StaysFixedError(`--watch-side has to be left or right, not "${side}".`, {
29
+ hint: 'Write it as `--watch-side left` or `--watch-side right`.',
30
+ });
31
+ }
32
+ flags.side = side;
33
+ }
34
+
35
+ const width = ctx.str('watch-width');
36
+ if (width !== undefined) {
37
+ const n = Number(width);
38
+ // A panel narrower than this cannot show the before-and-after pictures side
39
+ // by side, which is the only reason to open it.
40
+ if (!Number.isFinite(n) || n < 240) {
41
+ throw new StaysFixedError(`--watch-width has to be a number of pixels, 240 or more — I got "${width}".`, {
42
+ hint: 'Write it as `--watch-width 520`.',
43
+ });
44
+ }
45
+ flags.width = Math.round(n);
46
+ }
47
+
48
+ // Only mention these when they were actually typed, so --no-keep-open turns the
49
+ // panel off at the end without a bare --watch turning it on against the settings.
50
+ if (ctx.flags['keep-open'] !== undefined) flags.keepOpen = ctx.flags['keep-open'] === true;
51
+ if (ctx.bool('watch-front')) flags.foreground = true;
52
+
53
+ return flags;
54
+ }
@@ -49,9 +49,29 @@ export const DEFAULT_FREEZE = {
49
49
 
50
50
  /** @type {Required<Omit<import('../types.js').ToleranceConfig,'maxPixels'>>} */
51
51
  export const DEFAULT_TOLERANCE = {
52
- // 0.05% of pixels. On a 1440x900 @2x picture that is about 1300 pixels enough
53
- // for font hinting noise, nowhere near enough to hide a missing stylesheet.
54
- pixels: 0.0005,
52
+ // Nothing is allowed through by default, and that is a change made after measuring.
53
+ //
54
+ // This used to be 0.0005 — 0.05% of the picture — with a comment saying it was "enough for
55
+ // font hinting noise, nowhere near enough to hide a missing stylesheet". The first half was
56
+ // a guess and the second half was wrong. On a 2880x1800 picture, 0.05% is **2,592 pixels**.
57
+ // Changing `<h1>Welcome</h1>` to `<h1>Welcom</h1>` — one letter missing from the main
58
+ // heading of the page, plainly visible to anybody looking at it — moves **593**. So the
59
+ // check reported "Everything that worked still works" over a page that was visibly wrong.
60
+ // That is the exact failure this tool exists to prevent, produced by the tool itself.
61
+ //
62
+ // The number that replaces it was measured rather than chosen. Ten fresh takes of the same
63
+ // build, on a real page, compared against the approved picture: **zero differing pixels,
64
+ // every time.** The freeze layer underneath — the stopped clock, the killed motion, the
65
+ // seeded randomness, the pinned text rendering, and the settle loop that keeps shooting
66
+ // until two frames come back identical — is what makes that true. Where nothing wobbles,
67
+ // an allowance buys nothing at all and costs you the one thing you came for.
68
+ //
69
+ // Version 2 answers this properly by measuring each product's own wobble and subtracting
70
+ // it, which is why it has no tolerance setting and never will. Version 1 cannot do that
71
+ // without becoming version 2, so it does the honest next-best thing: allow nothing, and
72
+ // let a project that genuinely wobbles say so out loud with `tolerance.pixels`. A run that
73
+ // uses an allowance now says so, and says how much of it was used.
74
+ pixels: 0,
55
75
  threshold: 0.12,
56
76
  antialiasing: true,
57
77
  };
@@ -126,6 +146,27 @@ export function resolveConfig(raw, file = '(inline)') {
126
146
  }
127
147
  const c = /** @type {import('../types.js').StaysFixedConfig} */ (raw);
128
148
 
149
+ // Version 2's settings describe a website under `web:` and a desktop app under
150
+ // `electron:`. These commands are version 1's and only ever knew about `app:` — so on the
151
+ // settings file `staysfixed init` writes for a website, `status`, `walk` and `flake` all
152
+ // answered "these settings do not name anything to open", and then listed `process,
153
+ // source` as the shape of the project. `init` had said, one command earlier, "The website
154
+ // can be checked here now ... watched by opening it in a throwaway browser". Both about
155
+ // the same file, seconds apart, and `status` is the command whose whole promise is to say
156
+ // instantly what is set up here.
157
+ //
158
+ // Where the address is actually knowable, take it and let the command work. Booting is
159
+ // version 2's job and these commands cannot do it, so `web.start` alone is not enough —
160
+ // that case falls through to the message below, which now says so honestly.
161
+ if ((!c.app || typeof c.app !== 'object')) {
162
+ const v2 = /** @type {Record<string, any>} */ (/** @type {unknown} */ (c));
163
+ if (v2.web && typeof v2.web === 'object' && typeof v2.web.url === 'string' && v2.web.url) {
164
+ c.app = { kind: 'web', url: v2.web.url };
165
+ } else if (v2.electron && typeof v2.electron === 'object' && typeof v2.electron.binary === 'string' && v2.electron.binary) {
166
+ c.app = { kind: 'electron', binary: v2.electron.binary };
167
+ }
168
+ }
169
+
129
170
  if (!c.app || typeof c.app !== 'object') {
130
171
  // Every command that lands here — status, walk, approve, mark, trace, flake, and
131
172
  // `check --pictures` — works by OPENING something and photographing it. A settings
@@ -135,6 +176,16 @@ export function resolveConfig(raw, file = '(inline)') {
135
176
  // half of the tool needs it, and names the half that does not.
136
177
  const anything = /** @type {Record<string, unknown>} */ (/** @type {unknown} */ (c));
137
178
  const notVisual = ['process', 'http', 'source', 'android', 'ios', 'windows'].filter((k) => anything[k] && typeof anything[k] === 'object');
179
+ // A project that DOES have a screen, described the version 2 way, must never be told it
180
+ // has none. It is told the true thing instead: this half of the tool photographs an
181
+ // address you can point it at, and version 2 finds the address by booting the product,
182
+ // which is why `check` covers it and these do not.
183
+ const started = anything.web && typeof anything.web === 'object' && typeof (/** @type {any} */ (anything.web).start) === 'string';
184
+ if (started) {
185
+ throw new StaysFixedError('This project has a website, but these settings start it rather than name an address, and this command photographs an address.', {
186
+ hint: "`staysfixed check` covers it exactly as it is — it boots `web.start` and finds the address itself. These picture commands need one they can point at, so add `url: 'http://localhost:3000'` beside `start` in the `web` block if you want them too.",
187
+ });
188
+ }
138
189
  throw new StaysFixedError('These settings do not name anything to open, and this command works by opening your product and photographing it.', {
139
190
  hint: notVisual.length
140
191
  ? `That is the right shape for what this project is — ${notVisual.join(', ')} settings need nothing to open. Run \`staysfixed check\`, which covers it without a picture. If there IS a screen here too, add \`app: { kind: 'web', url: 'http://localhost:3000' }\` or \`app: { kind: 'electron', binary: '...' }\`.`
package/src/core/paths.js CHANGED
@@ -139,4 +139,19 @@ export const GITIGNORE_LINES = [
139
139
  // Where one person dragged the watch panel on one screen. Nobody else's business,
140
140
  // and it would otherwise turn up in their commits.
141
141
  '.staysfixed/watch-window.json',
142
+ // Version 2's evidence, which none of the lines above match — they were all written for
143
+ // version 1's folders. Measured 2026-08-30 on a Next.js project: 151 untracked files and
144
+ // 1.9 MB of run evidence sitting in `git status` after nine checks, and every release then
145
+ // warning that it was made from a dirty tree. `builds/` is the bulk of it: one folder per
146
+ // build, holding what each run observed.
147
+ '.staysfixed/v2/builds/',
148
+ '.staysfixed/v2/last-check.json',
149
+ // The lock a run holds while it changes the record. Never anybody's to commit.
150
+ '.staysfixed/**/*.lock',
142
151
  ];
152
+
153
+ /*
154
+ * Deliberately NOT ignored: `references.json` and `reference-log.json`. Those are the record
155
+ * of what this product calls working and which release said so — the one thing here a team
156
+ * genuinely shares, and the thing a new clone needs in order to compare against anything.
157
+ */
package/src/guard/run.js CHANGED
@@ -28,6 +28,7 @@ const FRESH_KEY = 'fresh';
28
28
  /**
29
29
  * @typedef {import('../types.js').GuardResult & {
30
30
  * retriedToPass?: boolean,
31
+ * assertedNothing?: boolean,
31
32
  * checks?: import('../types.js').CheckStep[],
32
33
  * }} GuardRunResult
33
34
  */
@@ -136,10 +137,22 @@ export async function runGuards(project, app, guards, opts = {}) {
136
137
  if (opts.signal?.aborted) break;
137
138
  }
138
139
 
140
+ // A guard that asserted NOTHING has not held. It cannot hold, and it cannot fail — it is
141
+ // a name over an empty room. Measured on 2026-08-30: a guard called "the checkout total
142
+ // is never charged twice", whose `run()` was an empty function, came back as
143
+ // "ok ... still holds". That is a false all-clear wearing the friendliest face this tool
144
+ // has, and it would go on saying it every day for ever. The whole promise here is one
145
+ // plain-English rule per bug somebody already had; a rule that checks nothing is worse
146
+ // than no rule, because somebody believes it.
147
+ // Its OWN questions, not the runner's. Every guard gets a "fresh start" step from this
148
+ // file whether it asks anything or not, so counting the whole list would always find one.
149
+ const asked = checks.filter((c) => c.key !== FRESH_KEY && !String(c.key ?? '').endsWith(`-${FRESH_KEY}`));
150
+ const assertedNothing = outcome.ok && asked.length === 0;
151
+
139
152
  /** @type {GuardRunResult} */
140
153
  const result = {
141
154
  name: guard.name,
142
- status: outcome.ok ? 'passed' : 'failed',
155
+ status: outcome.ok && !assertedNothing ? 'passed' : 'failed',
143
156
  file: guard.file,
144
157
  because: guard.because,
145
158
  durationMs: Date.now() - startedAt,
@@ -147,7 +160,13 @@ export async function runGuards(project, app, guards, opts = {}) {
147
160
  };
148
161
  if (checks.length > 0) result.checks = checks;
149
162
 
150
- if (outcome.ok) {
163
+ if (assertedNothing) {
164
+ result.assertedNothing = true;
165
+ result.message =
166
+ `This guard checked nothing. Its \`run()\` finished without asking a single question, so it cannot fail ` +
167
+ `and it is not protecting anything — it would report "still holds" every day for ever. ` +
168
+ `Give it at least one \`expect(...)\`. ${guard.because ? `What it is meant to protect: ${guard.because}` : ''}`.trim();
169
+ } else if (outcome.ok) {
151
170
  // Passing only on the second go is not passing. The flake register picks
152
171
  // this up and condemns the guard, because a guard nobody trusts is worse
153
172
  // than no guard: people learn to re-run it until it goes green.
@@ -289,7 +308,7 @@ async function attemptGuard(project, app, guard, baseUrl, timeoutMs, onStep) {
289
308
  };
290
309
  }
291
310
  const raw = error instanceof Error ? error.message : String(error);
292
- return { ok: false, message: `${raw}${consoleNote(app)}` };
311
+ return { ok: false, message: `${explainApiSlip(raw, app.page, project)}${consoleNote(app)}` };
293
312
  } finally {
294
313
  // The losing side of the race keeps running otherwise, and a stray timer
295
314
  // holds the process open long after the run is reported.
@@ -299,6 +318,54 @@ async function attemptGuard(project, app, guard, baseUrl, timeoutMs, onStep) {
299
318
  return { ok: true };
300
319
  }
301
320
 
321
+ /**
322
+ * Turn "page.goto is not a function" into one sentence a person can act on.
323
+ *
324
+ * A guard is the first code most people write against this tool, and the object it is handed
325
+ * is not the shape anybody arrives expecting. Reach for a name from a browser library that is
326
+ * not there and JavaScript answers with its own sentence, which is true, useless, and exactly
327
+ * the kind of raw error this project promises never to print. The first guard written against
328
+ * it while proving the tool still worked failed this way.
329
+ *
330
+ * Two things it must not do, both learned by getting them wrong first:
331
+ *
332
+ * - **Do not trust the receiver's name.** The guard above called its one parameter `page`,
333
+ * so the error read `page.goto is not a function` — but the parameter holds `app`, and the
334
+ * honest answer is `app.open()`. Reading that name as if it meant the page suggested
335
+ * "did you mean goto()", which is the very thing they had just written.
336
+ * - **Do not list every method.** An earlier version printed all thirty names on the page
337
+ * handle inline. It was complete, unreadable, and it destroyed the results table it sat in.
338
+ *
339
+ * So: name the six things on `app`, say where the rest live, and stop.
340
+ *
341
+ * @param {string} raw
342
+ * @param {import('../types.js').PageHandle} page
343
+ * @param {import('../types.js').Project} project
344
+ * @returns {string}
345
+ */
346
+ export function explainApiSlip(raw, page, project) {
347
+ const missing = /^(?:\w+\.)?(\w+) is not a function$/.exec(String(raw || ''));
348
+ if (!missing) return raw;
349
+ const method = missing[1];
350
+ const api = makeGuardApi(page, project, {});
351
+ const onApp = Object.keys(api).filter((k) => typeof (/** @type {any} */ (api))[k] === 'function').sort();
352
+ if (onApp.includes(method)) return raw;
353
+
354
+ // Only ever suggested from what `app` itself offers, and only when one name is clearly the
355
+ // one meant. A guess between three is worse than no guess.
356
+ const near = onApp.filter((n) => n.toLowerCase().includes(method.toLowerCase()) || method.toLowerCase().includes(n.toLowerCase()));
357
+ const browserish = /^(goto|navigate|visit|load|open|click|type|fill|press|hover|wait|screenshot|querySelector|\$)/i.test(method);
358
+ const meant = near.length === 1 ? ` You probably want \`app.${near[0]}()\`.`
359
+ : browserish ? ' To go to a page it is `app.open(\'/path\')`; anything a browser does is on `app.page`.'
360
+ : '';
361
+
362
+ return (
363
+ `This guard called \`${method}()\` on what it was handed, and there is no such thing there.${meant} ` +
364
+ `A guard is given one object — call it \`app\` — with ${onApp.map((n) => `\`${n}()\``).join(', ')}. ` +
365
+ 'The whole page is `app.page`. There is a worked example in `examples/guards/`.'
366
+ );
367
+ }
368
+
302
369
  /** A timeout, kept apart from a real error so the wording stays ours. */
303
370
  class TookTooLong extends Error {
304
371
  /** @param {string} message */
@@ -124,7 +124,11 @@ function tally(run) {
124
124
  missing: pictures.filter((p) => p.status === 'missing').length,
125
125
  broken: pictures.filter((p) => p.status === 'failed').length,
126
126
  wobbled: pictures.filter((p) => p.status === 'flaky').length,
127
- guardsFailed: guards.filter((g) => g.status === 'failed').length,
127
+ // Two different things wear the same status, and calling both of them "a bug is back"
128
+ // sends somebody hunting a regression that never happened. A guard that asked no
129
+ // question at all has not caught anything; it has admitted it cannot.
130
+ guardsFailed: guards.filter((g) => g.status === 'failed' && !(/** @type {any} */ (g).assertedNothing)).length,
131
+ guardsEmpty: guards.filter((g) => /** @type {any} */ (g).assertedNothing === true).length,
128
132
  };
129
133
  }
130
134
 
@@ -139,6 +143,20 @@ export function verdictFor(run) {
139
143
  const parts = [];
140
144
  if (t.guardsFailed === 1) parts.push({ n: 1, text: '1 guard failed — a bug that was already fixed is back.' });
141
145
  else if (t.guardsFailed > 1) parts.push({ n: t.guardsFailed, text: `${countText(t.guardsFailed)} guards failed — bugs that were already fixed are back.` });
146
+ // Said even on a green run, because that is the run it changes the meaning of.
147
+ const left = /** @type {any} */ (run).leftOut;
148
+ if (left && (left.screens > 0 || left.guards > 0)) {
149
+ const bits = [];
150
+ if (left.screens > 0) bits.push(`${left.screens} ${left.screens === 1 ? 'screen' : 'screens'}`);
151
+ if (left.guards > 0) bits.push(`${left.guards} ${left.guards === 1 ? 'guard' : 'guards'}`);
152
+ const how_many = (left.screens ?? 0) + (left.guards ?? 0);
153
+ parts.push({
154
+ n: 0,
155
+ text: `${bits.join(' and ')} ${how_many === 1 ? 'was' : 'were'} left out by --only, so this covers a slice and not the whole.`,
156
+ });
157
+ }
158
+ if (t.guardsEmpty === 1) parts.push({ n: 1, text: '1 guard checks nothing, so it is not protecting anything.' });
159
+ else if (t.guardsEmpty > 1) parts.push({ n: t.guardsEmpty, text: `${countText(t.guardsEmpty)} guards check nothing, so they are not protecting anything.` });
142
160
  if (t.changed === 1) parts.push({ n: 1, text: '1 thing changed. Look at it before you ship.' });
143
161
  else if (t.changed > 1) parts.push({ n: t.changed, text: `${countText(t.changed)} things changed. Look at them before you ship.` });
144
162
  if (t.fresh === 1) parts.push({ n: 1, text: '1 new screen is waiting for a person to approve it.' });
@@ -162,9 +180,12 @@ export function verdictFor(run) {
162
180
  * @param {import('../types.js').RunSummary} run
163
181
  * @returns {boolean}
164
182
  */
165
- function allClear(run) {
183
+ export function allClear(run) {
166
184
  const t = tally(run);
167
- return t.changed + t.fresh + t.missing + t.broken + t.wobbled + t.guardsFailed === 0;
185
+ // `guardsEmpty` counts too. Splitting it out of `guardsFailed` was so the SENTENCE could
186
+ // tell a returned bug from a guard that asks nothing — not so that one of them could
187
+ // quietly become a pass.
188
+ return t.changed + t.fresh + t.missing + t.broken + t.wobbled + t.guardsFailed + t.guardsEmpty === 0;
168
189
  }
169
190
 
170
191
  /**
@@ -210,9 +231,22 @@ export function printPictureResult(r) {
210
231
  const time = paint.grey(duration(r.durationMs ?? 0));
211
232
  const line = `${r.name.padEnd(NAME_WIDTH)} ${pictureOutcome(r)}`;
212
233
  switch (r.status) {
213
- case 'passed':
214
- say(`${paint.green(sym(mark.pass))} ${r.name.padEnd(NAME_WIDTH)} ${paint.grey('still the same')} ${time}`);
234
+ case 'passed': {
235
+ // "Still the same" has to mean the same, or it is the most expensive sentence here.
236
+ //
237
+ // A picture that differs and is waved through by an allowance was reported as
238
+ // identical, in the same words as one that matched byte for byte. That is how a
239
+ // missing letter in a heading — 593 pixels, plainly visible — came back as "still the
240
+ // same" while an allowance of 2,592 quietly absorbed it. Nothing is allowed through by
241
+ // default any more, so this is rare; when a project sets `tolerance.pixels` because its
242
+ // product genuinely wobbles, the line says what its setting just swallowed.
243
+ const swallowed = r.diffPixels ?? 0;
244
+ const note = swallowed > 0
245
+ ? paint.grey(`the same, apart from ${swallowed} ${swallowed === 1 ? 'pixel your tolerance allowed' : 'pixels your tolerance allowed'}`)
246
+ : paint.grey('still the same');
247
+ say(`${paint.green(sym(mark.pass))} ${r.name.padEnd(NAME_WIDTH)} ${note} ${time}`);
215
248
  break;
249
+ }
216
250
  case 'changed':
217
251
  say(`${paint.red(sym(mark.fail))} ${paint.red(line)} ${time}`);
218
252
  if (r.approvedSize && r.size && (r.approvedSize.width !== r.size.width || r.approvedSize.height !== r.size.height)) {
@@ -548,6 +582,8 @@ export function printFlakes(history, flakeLimit = 2) {
548
582
  * @property {number} [markers] Known-good markers saved.
549
583
  * @property {{label: string, at?: string}|null} [lastMarker]
550
584
  * @property {import('../types.js').RunSummary|null} [lastRun]
585
+ * @property {{at: string, verdict: string, reference: string|null, findings: number}|null} [v2]
586
+ * What version 2 has recorded here. Version 1's counts say nothing about it.
551
587
  * @property {string[]} [condemned] Names from the flake register, if the CLI already has them.
552
588
  * @property {string} [configFile]
553
589
  * @property {string} [root]
@@ -578,7 +614,15 @@ export function printStatus(status) {
578
614
 
579
615
  blank();
580
616
  const run = s.lastRun ?? null;
581
- if (!run) {
617
+ if (!run && s.v2) {
618
+ // Version 2 has run here even though version 1's picture record has not. Saying
619
+ // "nothing has been checked here yet" one command after a real run is the sort of
620
+ // wrongness that costs a person their trust in everything else the tool says.
621
+ say(paint.grey(` last checked ${ago(s.v2.at)} — ${s.v2.verdict}`));
622
+ if (s.v2.findings > 0) say(paint.grey(` ${s.v2.findings} ${plural(s.v2.findings, 'thing', 'things')} nobody had accounted for`));
623
+ if (s.v2.reference) say(paint.grey(` compared against ${s.v2.reference}`));
624
+ else say(` Nothing is on record as working yet — run ${paint.cyan('staysfixed check')}, then ${paint.cyan('staysfixed ship')}.`);
625
+ } else if (!run) {
582
626
  say(' Nothing has been checked here yet.');
583
627
  say(` Start with: ${paint.cyan('staysfixed check')}`);
584
628
  } else {
package/src/run.js CHANGED
@@ -226,6 +226,17 @@ export async function runCheck(project, opts = {}) {
226
226
  tool: TOOL,
227
227
  platform: platformTag(),
228
228
  condemned: condemnedNames,
229
+ // What `--only` left out. A narrowed run that says "everything that worked still works"
230
+ // is describing a slice and sounding like the whole: measured 2026-08-30 with five of
231
+ // six guards filtered away and one of the five failing, and the run still exited 0
232
+ // saying everything works. A pass has to carry the size of what it looked at.
233
+ leftOut: terms
234
+ ? {
235
+ screens: Math.max(0, allScreens.length - screens.length),
236
+ guards: Math.max(0, allGuards.length - guards.length),
237
+ terms,
238
+ }
239
+ : undefined,
229
240
  // Read here rather than at the very end: what follows is writing files, and
230
241
  // where the run spent its time is a fact about the run, not about the report.
231
242
  timings: timings.get(),
package/src/types.js CHANGED
@@ -328,6 +328,9 @@
328
328
  * @property {string} tool
329
329
  * @property {string} platform
330
330
  * @property {string[]} [condemned] Names of checks that have flaked past the limit.
331
+ * @property {{screens: number, guards: number, terms: string[]}} [leftOut]
332
+ * What `--only` filtered away. A narrowed run that reads as a full pass is describing a
333
+ * slice and sounding like the whole.
331
334
  */
332
335
 
333
336
  /**
@@ -1393,8 +1393,13 @@ export async function permissionsHeld(device, pkg) {
1393
1393
  * and pids are stripped from the text kept, since all three differ on every run and none of
1394
1394
  * them is ever the finding.
1395
1395
  *
1396
+ * There is no time window here, and there used to be a `sinceMs` in this signature that
1397
+ * nothing read. The window comes from the other end: the adapter clears both log buffers
1398
+ * before it walks anything, so what is left is this run. An option that silently does
1399
+ * nothing is worse than no option, because a caller passing it believes it worked.
1400
+ *
1396
1401
  * @param {Device} device
1397
- * @param {{pkg: string, pid?: number, sinceMs?: number}} what
1402
+ * @param {{pkg: string, pid?: number}} what
1398
1403
  * @returns {Promise<{crashes: string[], anrs: string[], errors: string[], lines: string[], raw: string}>}
1399
1404
  */
1400
1405
  export async function complaints(device, what) {