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 +87 -0
- package/README.md +2 -2
- package/docs/getting-started.md +1 -1
- package/docs/settings.md +15 -0
- package/package.json +1 -1
- package/src/cli/status.js +45 -1
- package/src/core/config.js +31 -0
- package/src/core/paths.js +15 -0
- package/src/guard/run.js +21 -2
- package/src/report/console.js +35 -4
- package/src/run.js +11 -0
- package/src/types.js +3 -0
- package/src/v2/adapters/child.js +101 -0
- package/src/v2/adapters/http.js +7 -9
- package/src/v2/adapters/process.js +55 -5
- package/src/v2/adapters/web-driver.js +43 -3
- package/src/v2/adapters/web.js +7 -9
- package/src/v2/browsers.js +57 -2
- package/src/v2/check.js +123 -3
- package/src/v2/cli.js +23 -18
- package/src/v2/cluster.js +56 -1
- package/src/v2/doctor.js +68 -9
- package/src/v2/escalate.js +5 -1
- package/src/v2/init.js +21 -5
- package/src/v2/mcp/tools.js +80 -5
- package/src/v2/reference.js +120 -14
- package/src/v2/ship.js +26 -0
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
|
|
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 **
|
|
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
|
package/docs/getting-started.md
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
}
|
package/src/core/config.js
CHANGED
|
@@ -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 (
|
|
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.
|
package/src/report/console.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/v2/adapters/http.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
613
|
+
await stopServer(held.child);
|
|
616
614
|
}
|
|
617
615
|
running.clear();
|
|
618
616
|
},
|