staysfixed 0.8.0 → 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.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,93 @@ numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  Nothing yet.
10
10
 
11
+ ## [0.9.0] — 2026-08-30
12
+
13
+ Found the same way as 0.8.0 and one better: the published build was installed as a stranger
14
+ installs it and used on real throwaway products — a café API, a static site, a library, a
15
+ server that spawns its own child — until it said something untrue. And this time the repo's
16
+ own CI was read, which had been **red for ten releases** while every summary said the tests
17
+ passed. It was red for two real reasons, both below.
18
+
19
+ **The tool was breaking the products it was sent to protect**, and on some shapes it never
20
+ came back at all. Those two are why this release exists.
21
+
22
+ ### Fixed — it broke the product, or itself
23
+
24
+ - **Refusing a connection killed the program that made it.** The refusal was delivered by
25
+ emitting `'error'` on the socket; at that instant nothing is listening, and in Node an
26
+ `'error'` with no listener is a thrown exception. `http.get` on Node 22 — the floor this
27
+ package declares — and a bare `net.connect` on every version died with exit 1, and the run
28
+ then reported the user's product as broken. The refusal is real now rather than simulated,
29
+ so the operating system produces it through Node's own plumbing.
30
+ - **`check` printed its whole answer and never exited.** A start command runs through a
31
+ shell, so the server is a grandchild; killing the shell left it alive holding the output
32
+ pipe, so the event loop never emptied. It also orphaned the server. The shell is started as
33
+ its own process group now and the whole group is signalled.
34
+ - **Two agents shipping at once lost what "working" means.** Six ships, all reporting
35
+ success, four records, and the "already the reference" path never firing. The whole cut is
36
+ one at a time per product now.
37
+ - **Every killed run left a whole copy of the project in the temporary folder, for ever** —
38
+ 777 MB of them on an ordinary machine. Abandoned copies are reclaimed; a copy a live run
39
+ owns is never touched.
40
+
41
+ ### Fixed — it said things that were not true
42
+
43
+ - **The agent was told "everything that worked before still works"** about a run that
44
+ compared nothing at all, while the terminal correctly called it no answer. The engine's
45
+ verdict is the floor for the machine surface now, so this class cannot come back one reason
46
+ at a time.
47
+ - **A sealed money change reached the agent labelled `"ordinary"`** — the human text said
48
+ sealed, `waive` refused it, and the JSON said it was waivable.
49
+ - **The seal that exists because somebody was burned before could never fire.** Guard names
50
+ were never passed to the decision, so that class was empty on every run this tool has ever
51
+ done. And a check on a project with a guard in `.staysfixed/guards` printed the word
52
+ "guard" zero times: not run, not counted, not mentioned.
53
+ - **A guard that asks nothing was reported as holding.** An empty `run()` came back as
54
+ "still holds", and would have said so every day for ever.
55
+ - **The library journey never imported anything.** `init` writes `module: "index.js"`; the
56
+ probe treated that as a package name, failed identically on both builds, and the check said
57
+ "Nothing that worked has changed" for ever.
58
+ - **`ship` blessed a build nothing had looked at.** It matched by git commit, so an
59
+ uncommitted edit resolved to an earlier build that had been checked and was clean.
60
+ - **`doctor` said a check "here" covers things in a folder where a check cannot run at all.**
61
+ - **`--only` printed "everything that worked still works"** about a slice.
62
+ - **`init` and `doctor` both offered `check --paired` as the way to record a reference.** It
63
+ cannot; only shipping cuts one, on purpose. `doctor` also marked it as something the agent
64
+ could do — the single thing an agent must never do.
65
+ - **`status` said nothing had happened** one command after a check and a ship.
66
+ - **It called the person's own browser "a separate application from the browser you use"**,
67
+ which is the one case where that sentence matters and the one case it was false.
68
+
69
+ ### Fixed — it could not see, or would not run
70
+
71
+ - **Full Chrome was invisible on Linux and Windows.** Playwright and Puppeteer both unpack
72
+ into `chrome-linux64` and `chrome-win64`; only macOS uses the names this looked for. On
73
+ Linux, the very command this tool tells people to run left a browser it could not find.
74
+ - **`status`, `walk`, `flake`, `approve`, `mark` and `trace` told a website it had no
75
+ screen** — they only knew version 1's `app:`, and `init` writes version 2's `web:`.
76
+ - **The sign-in example `init` writes used two words the tool does not know**, and a step made
77
+ only of unknown words was skipped in silence — so the form was never filled, every page
78
+ behind the login photographed the login page, and the run came back clean.
79
+ - **A browser's throwaway profile outlived an interrupted run.**
80
+
81
+ ### Fixed — it was noisy or unhelpful
82
+
83
+ - **Renaming one heading came back as five findings**, none of them saying "renamed". A thing
84
+ addressed by its own words is now recognised when it is renamed, and its children travel
85
+ with it.
86
+ - **`doctor` connected to every machine in your ssh config, unasked, on the first run.** Nine
87
+ connections on a brand-new scratch project. It is asked for now, and the machines are still
88
+ listed either way.
89
+ - **Every command-line check wrote two rows** to the log `ship` reads.
90
+ - **The `.gitignore` lines `init` writes matched nothing version 2 writes** — 151 untracked
91
+ files and 1.9 MB of run evidence in `git status` after nine checks.
92
+ - `check --json` now carries `notChecked` and `doorsNeverOpened`, which the README had
93
+ promised and only the MCP reply had.
94
+ - `init` no longer names `staysfixed check --product <name>`, which is not an option.
95
+ - The docs said the self-check builds seventeen products; it builds twenty.
96
+ - `process.alsoWatch` is documented.
97
+
11
98
  ## [0.8.0] — 2026-08-30
12
99
 
13
100
  The night this was pointed at itself. Everything below was found the same way:
package/README.md CHANGED
@@ -808,7 +808,7 @@ A tool that reports "nothing changed" looks exactly like a tool that is broken,
808
808
  and there is no way to tell the two apart from the outside. So:
809
809
 
810
810
  **It has to prove it still catches things.** `staysfixed check --selfcheck`
811
- builds seventeen tiny products — each a real repository with a working commit and
811
+ builds twenty tiny products — each a real repository with a working commit and
812
812
  an uncommitted change on top, which is the shape an agent actually points this
813
813
  tool at — and requires the engine to behave on every one.
814
814
 
@@ -843,7 +843,7 @@ a false accusation people learn to ignore. Measured on 2026-08-30: eleven of
843
843
  eleven, three times running, with the project's own suite running in parallel and
844
844
  the machine's load average between 227 and 334; then twelve of twelve after the
845
845
  second sweep of silences, three times running, with the suite in parallel again
846
- and the load average between 208 and 343; and finally **seventeen of seventeen**,
846
+ and the load average between 208 and 343; and finally **twenty of twenty**,
847
847
  with the suite in parallel, at the end of the same day.
848
848
 
849
849
  **The unstable app.** `fixtures/unstable-app` is a page built to be impossible to
@@ -229,7 +229,7 @@ watch it notice. There is a built-in version of the same idea:
229
229
  staysfixed check --selfcheck
230
230
  ```
231
231
 
232
- which builds seventeen deliberately broken products and proves the engine still behaves on
232
+ which builds twenty deliberately broken products and proves the engine still behaves on
233
233
  every one — twelve breaks it must catch, four clean pairs it must stay silent about, and one
234
234
  product so unsteady that the only correct answer is that the run says nothing at all. A case that
235
235
  misbehaves is built again and run again before that counts: fail twice and it is a real
package/docs/settings.md CHANGED
@@ -61,6 +61,21 @@ twice, and subtracted. And there is nothing to approve: the build you say
61
61
 
62
62
  ---
63
63
 
64
+
65
+ ### `process.alsoWatch`
66
+
67
+ A list of extra folders a command is allowed to touch, so that what it writes there is
68
+ watched rather than counted as a surprise. The tool's own run output names this option, and
69
+ until 2026-08-30 it appeared nowhere here — in a page whose promise is every option, per kind
70
+ of product.
71
+
72
+ ```js
73
+ process: {
74
+ commands: [{ name: 'build', run: 'npm run build' }],
75
+ alsoWatch: ['dist', '../shared/generated'],
76
+ }
77
+ ```
78
+
64
79
  ## `product`
65
80
 
66
81
  ```js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "staysfixed",
3
- "version": "0.8.0",
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",
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
  }
@@ -146,6 +146,27 @@ export function resolveConfig(raw, file = '(inline)') {
146
146
  }
147
147
  const c = /** @type {import('../types.js').StaysFixedConfig} */ (raw);
148
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
+
149
170
  if (!c.app || typeof c.app !== 'object') {
150
171
  // Every command that lands here — status, walk, approve, mark, trace, flake, and
151
172
  // `check --pictures` — works by OPENING something and photographing it. A settings
@@ -155,6 +176,16 @@ export function resolveConfig(raw, file = '(inline)') {
155
176
  // half of the tool needs it, and names the half that does not.
156
177
  const anything = /** @type {Record<string, unknown>} */ (/** @type {unknown} */ (c));
157
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
+ }
158
189
  throw new StaysFixedError('These settings do not name anything to open, and this command works by opening your product and photographing it.', {
159
190
  hint: notVisual.length
160
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.
@@ -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
  /**
@@ -561,6 +582,8 @@ export function printFlakes(history, flakeLimit = 2) {
561
582
  * @property {number} [markers] Known-good markers saved.
562
583
  * @property {{label: string, at?: string}|null} [lastMarker]
563
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.
564
587
  * @property {string[]} [condemned] Names from the flake register, if the CLI already has them.
565
588
  * @property {string} [configFile]
566
589
  * @property {string} [root]
@@ -591,7 +614,15 @@ export function printStatus(status) {
591
614
 
592
615
  blank();
593
616
  const run = s.lastRun ?? null;
594
- 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) {
595
626
  say(' Nothing has been checked here yet.');
596
627
  say(` Start with: ${paint.cyan('staysfixed check')}`);
597
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
  /**
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Starting and stopping the product's own server.
3
+ *
4
+ * A start command is run through a shell, because that is what people write: `npm run dev`,
5
+ * `sh dev.sh`, `poetry run uvicorn ...`. So the thing that is spawned is the SHELL, and the
6
+ * server is its child — often its grandchild, since `npm run dev` is npm, which runs next,
7
+ * which runs node.
8
+ *
9
+ * Killing the shell therefore does not kill the server. And because the shell's stdout and
10
+ * stderr are pipes, every survivor inherits the writing end of them — so the pipes never
11
+ * close, this process's event loop never empties, and `staysfixed check` prints its whole
12
+ * answer and then hangs for ever at nothing per cent of a CPU. Measured on 2026-08-30 on a
13
+ * start command that spawns its server and waits, which is the shape `npm run dev` has: the
14
+ * verdict appeared in about thirty seconds and the command never returned.
15
+ *
16
+ * So the shell is started as its own process GROUP and the whole group is signalled. And
17
+ * after that, the pipes are torn down here rather than trusted to close, because a survivor
18
+ * this file did not start — a stray `node` somebody's dev server left behind — must not be
19
+ * able to hold a finished check open.
20
+ */
21
+
22
+ import { spawn } from 'node:child_process';
23
+
24
+ /**
25
+ * Start the product, in a group of its own.
26
+ *
27
+ * @param {string} command
28
+ * @param {{cwd: string, env: any, stdio?: any}} opts
29
+ * @returns {import('node:child_process').ChildProcess}
30
+ */
31
+ export function spawnServer(command, opts) {
32
+ return spawn(String(command), {
33
+ shell: true,
34
+ cwd: opts.cwd,
35
+ env: opts.env,
36
+ stdio: opts.stdio ?? ['ignore', 'pipe', 'pipe'],
37
+ // The whole point. On Windows there are no process groups of this kind, and killing the
38
+ // child is the best that can be done there.
39
+ detached: process.platform !== 'win32',
40
+ });
41
+ }
42
+
43
+ /**
44
+ * Stop it, and everything it started.
45
+ *
46
+ * @param {import('node:child_process').ChildProcess|null|undefined} child
47
+ * @param {{graceMs?: number}} [opts]
48
+ * @returns {Promise<void>}
49
+ */
50
+ export async function stopServer(child, opts = {}) {
51
+ if (!child) return;
52
+ const pid = child.pid;
53
+ const graceMs = opts.graceMs ?? 500;
54
+
55
+ /** @param {NodeJS.Signals} signal */
56
+ const tellTheGroup = (signal) => {
57
+ if (!pid) return;
58
+ try {
59
+ // A negative pid is the GROUP. This is the line that makes the difference.
60
+ if (process.platform === 'win32') child.kill(signal);
61
+ else process.kill(-pid, signal);
62
+ } catch {
63
+ // No group, or already gone. Ask the one process we definitely know about.
64
+ try {
65
+ child.kill(signal);
66
+ } catch {
67
+ // Already gone, which is the outcome wanted.
68
+ }
69
+ }
70
+ };
71
+
72
+ if (child.exitCode === null && child.signalCode === null) {
73
+ tellTheGroup('SIGTERM');
74
+ await new Promise((done) => {
75
+ let settled = false;
76
+ const finish = () => {
77
+ if (settled) return;
78
+ settled = true;
79
+ done(undefined);
80
+ };
81
+ child.once('exit', finish);
82
+ const timer = setTimeout(finish, graceMs);
83
+ if (typeof timer.unref === 'function') timer.unref();
84
+ });
85
+ if (child.exitCode === null && child.signalCode === null) tellTheGroup('SIGKILL');
86
+ }
87
+
88
+ // And never let what it left behind hold this process open.
89
+ for (const stream of [child.stdout, child.stderr, child.stdin]) {
90
+ try {
91
+ stream?.destroy();
92
+ } catch {
93
+ // Nothing to close.
94
+ }
95
+ }
96
+ try {
97
+ child.unref();
98
+ } catch {
99
+ // Not every child can be unreferenced. It has been signalled either way.
100
+ }
101
+ }
@@ -31,7 +31,6 @@
31
31
  import fsp from 'node:fs/promises';
32
32
  import net from 'node:net';
33
33
  import path from 'node:path';
34
- import { spawn } from 'node:child_process';
35
34
  import {
36
35
  defineAdapter, joinPath, notCovered, observation, sizeBucket, stableValue,
37
36
  howLongItTook, timeBucket, trimForStorage, undoOurFootprint,
@@ -40,6 +39,7 @@ import {
40
39
  compareTrees, copyForScratch, frozenEnvironment, readWatcher, snapshotTree, watcherScript,
41
40
  } from './process.js';
42
41
  import { readContract, readFileRoutes } from './source.js';
42
+ import { spawnServer, stopServer } from './child.js';
43
43
 
44
44
  // ---------------------------------------------------------------------------
45
45
  // Headers
@@ -456,7 +456,7 @@ export const httpAdapter = defineAdapter({
456
456
  notes.push(verdict.why);
457
457
  } else {
458
458
  const result = await new Promise((resolve) => {
459
- const child = spawn(String(config.restore), { shell: true, cwd: work, env, stdio: ['ignore', 'pipe', 'pipe'] });
459
+ const child = spawnServer(String(config.restore), { cwd: work, env });
460
460
  /** @type {Buffer[]} */
461
461
  const err = [];
462
462
  child.stderr?.on('data', (c) => err.push(c));
@@ -483,7 +483,7 @@ export const httpAdapter = defineAdapter({
483
483
  /** @type {Buffer[]} */
484
484
  const bootOut = [];
485
485
  let exited = /** @type {string|null} */ (null);
486
- const child = spawn(String(config.start), { shell: true, cwd: work, env, stdio: ['ignore', 'pipe', 'pipe'] });
486
+ const child = spawnServer(String(config.start), { cwd: work, env });
487
487
  child.stdout?.on('data', (c) => bootOut.push(c));
488
488
  child.stderr?.on('data', (c) => bootErr.push(c));
489
489
  child.on('close', (code, signal) => {
@@ -496,11 +496,11 @@ export const httpAdapter = defineAdapter({
496
496
  });
497
497
 
498
498
  if (!up.up) {
499
- child.kill('SIGTERM');
499
+ await stopServer(child);
500
500
  return {
501
501
  build, root: work, ready: false,
502
502
  why: `${up.why} What it printed while trying: ${trimForStorage(Buffer.concat(bootErr).toString('utf8') || Buffer.concat(bootOut).toString('utf8'), 1500).text || '(nothing)'}`,
503
- dispose: async () => { child.kill('SIGKILL'); await fsp.rm(base, { recursive: true, force: true }); },
503
+ dispose: async () => { await stopServer(child); await fsp.rm(base, { recursive: true, force: true }); },
504
504
  };
505
505
  }
506
506
 
@@ -522,9 +522,7 @@ export const httpAdapter = defineAdapter({
522
522
  if (!held) return;
523
523
  // Only ever the process we started. Somebody else's server on this machine is
524
524
  // somebody else's business.
525
- held.child.kill('SIGTERM');
526
- await new Promise((r) => setTimeout(r, 500));
527
- if (held.child.exitCode === null) held.child.kill('SIGKILL');
525
+ await stopServer(held.child);
528
526
  await fsp.rm(base, { recursive: true, force: true });
529
527
  },
530
528
  };
@@ -612,7 +610,7 @@ export const httpAdapter = defineAdapter({
612
610
 
613
611
  async teardown() {
614
612
  for (const [, held] of running) {
615
- held.child.kill('SIGTERM');
613
+ await stopServer(held.child);
616
614
  }
617
615
  running.clear();
618
616
  },