staysfixed 0.12.0 → 0.13.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 +32 -2
- package/docs/guards.md +18 -0
- package/docs/how-v2-works.md +10 -0
- package/package.json +1 -1
- package/src/cli/approve.js +4 -1
- package/src/cli/flake.js +4 -1
- package/src/cli/mark.js +5 -1
- package/src/cli/status.js +53 -1
- package/src/cli/trace.js +27 -2
- package/src/core/config.js +136 -25
- package/src/core/stop-tree.js +109 -0
- package/src/drive/browser.js +20 -31
- package/src/drive/page.js +74 -2
- package/src/guard/api.js +14 -9
- package/src/types.js +1 -1
- package/src/v2/adapters/child.js +15 -17
- package/src/v2/adapters/contract.js +122 -1
- package/src/v2/adapters/http.js +152 -30
- package/src/v2/adapters/isolate.js +169 -14
- package/src/v2/adapters/process.js +72 -8
- package/src/v2/adapters/source.js +254 -7
- package/src/v2/adapters/web.js +69 -19
- package/src/v2/browsers.js +136 -24
- package/src/v2/cause.js +46 -5
- package/src/v2/check.js +332 -34
- package/src/v2/cli.js +19 -1
- package/src/v2/coverage.js +555 -18
- package/src/v2/detect.js +737 -40
- package/src/v2/doctor.js +3 -3
- package/src/v2/escalate.js +57 -11
- package/src/v2/init.js +562 -21
- package/src/v2/journeys/answers-probe.js +376 -0
- package/src/v2/journeys/from-exports.js +456 -0
- package/src/v2/journeys/from-suite.js +9 -1
- package/src/v2/mcp/tools.js +185 -12
- package/src/v2/observation.js +145 -0
- package/src/v2/run.js +133 -9
- package/src/v2/selfcheck.js +297 -11
- package/src/v2/store.js +16 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,7 +6,35 @@ numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
### The self-check corpus knows about the five false all-clears found on 2026-08-31
|
|
10
|
+
|
|
11
|
+
Five defects were found and fixed that day, each of which could have blessed a broken build,
|
|
12
|
+
and not one of them would have been caught by the corpus that is supposed to make that
|
|
13
|
+
impossible. There is a case for each now, and every one of them was made to fail against the
|
|
14
|
+
code as it stood before its fix before it was believed.
|
|
15
|
+
|
|
16
|
+
- **A build that crashes on its first line is no longer a build that agrees with itself.** Two
|
|
17
|
+
builds that crash the same way used to agree at every address, and agreement is the silence
|
|
18
|
+
this tool reads as "nothing changed".
|
|
19
|
+
- **Coverage this build took away is never handed back as a pass.** The engine decided that
|
|
20
|
+
correctly and the bookkeeping then assigned the verdict rather than narrowing it.
|
|
21
|
+
- **A standard cut from a tree git does not have is never walked out of git.** The old build
|
|
22
|
+
was exported from the commit, walked, and a real regression was dropped as something it does
|
|
23
|
+
too.
|
|
24
|
+
- **With nothing edited, nothing can be a change.** The build being checked and the build on
|
|
25
|
+
record were one build, and whatever flickered between two runs was reported as a change
|
|
26
|
+
nobody made.
|
|
27
|
+
- **What "working" means moves only when somebody ships.** Re-running a check on the shipped
|
|
28
|
+
build quietly replaced the standard with whatever that run happened to see.
|
|
29
|
+
|
|
30
|
+
### A guard can hand `evaluate` a function
|
|
31
|
+
|
|
32
|
+
`page.evaluate` took JavaScript as text and nothing else. Passing it a function — the obvious
|
|
33
|
+
thing, and what every other tool in this space accepts — answered `The app refused the request
|
|
34
|
+
"Runtime.evaluate": Invalid parameters`, which is the debug protocol talking about its own wire
|
|
35
|
+
format to somebody who had done nothing wrong. It now takes either, and the two things it
|
|
36
|
+
genuinely cannot run — a built-in, and a function that asks for an argument it has no way to
|
|
37
|
+
be given — say so in a sentence with an example in it.
|
|
10
38
|
|
|
11
39
|
## [0.12.0] — 2026-08-31
|
|
12
40
|
|
|
@@ -140,7 +168,9 @@ adapter and tests turned out to hold at least one defect that only running it co
|
|
|
140
168
|
record is filed under a fingerprint of the tree that was walked, because the files checked
|
|
141
169
|
are not the files git has. Paired mode walked the commit and called it the old build, so an
|
|
142
170
|
address the record holds a real value for was reported as *"is there now and was not
|
|
143
|
-
before"*.
|
|
171
|
+
before"*. Asking for a paired run against such a reference is now refused outright, naming
|
|
172
|
+
the reason and what to do instead, rather than quietly giving you something weaker; an
|
|
173
|
+
ordinary check still falls back to the stored record and says so.
|
|
144
174
|
|
|
145
175
|
### It reported things as checked that were never checked
|
|
146
176
|
|
package/docs/guards.md
CHANGED
|
@@ -126,6 +126,24 @@ export default {
|
|
|
126
126
|
| `app.read(file)` | Read a project file as text. |
|
|
127
127
|
| `app.project` | The resolved config and paths. |
|
|
128
128
|
|
|
129
|
+
### Running your own JavaScript in the page
|
|
130
|
+
|
|
131
|
+
`page.evaluate` takes it either way round — as a piece of JavaScript written as
|
|
132
|
+
text, or as a function it will call inside the app:
|
|
133
|
+
|
|
134
|
+
```js
|
|
135
|
+
const title = await app.page.evaluate('document.title');
|
|
136
|
+
const title = await app.page.evaluate(() => document.title);
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The function is run with nothing passed to it, so one that asks for an argument
|
|
140
|
+
is refused rather than quietly handed `undefined`. Close over what it needs, or
|
|
141
|
+
write the value into the JavaScript itself:
|
|
142
|
+
|
|
143
|
+
```js
|
|
144
|
+
await app.page.evaluate(`document.title === ${JSON.stringify(expected)}`);
|
|
145
|
+
```
|
|
146
|
+
|
|
129
147
|
---
|
|
130
148
|
|
|
131
149
|
## The `expect` style
|
package/docs/how-v2-works.md
CHANGED
|
@@ -309,6 +309,16 @@ RUN`, and the reason is in the coverage as a hole. This is not a tolerance — n
|
|
|
309
309
|
number here decides whether any single difference is real. It decides one thing:
|
|
310
310
|
whether this run has earned the right to use the word clean.
|
|
311
311
|
|
|
312
|
+
**It is asked once per journey, not once per run.** Asking it only of everything
|
|
313
|
+
added together is how a page that told you nothing gets averaged away by nine
|
|
314
|
+
pages that behaved: 179 unsteady addresses out of 2849 is not a storm, while four
|
|
315
|
+
of that run's twelve journeys were unsteady at 69%, 75% and twice 100% of their
|
|
316
|
+
own. `noAnswerJourneys(wobbles)` is the same one comparison asked journey by
|
|
317
|
+
journey, and any journey it names takes the pass away and is listed by name in the
|
|
318
|
+
summary's first sentence. A journey walked twice that came back with no addresses
|
|
319
|
+
at all is in that list too — two empty walks agree about everything, and that is
|
|
320
|
+
not a measurement.
|
|
321
|
+
|
|
312
322
|
### A finding
|
|
313
323
|
|
|
314
324
|
A cluster of differences that share a cause, which is what the agent reads:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "staysfixed",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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 \u2014 as a CLI and as an MCP server.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/cli/approve.js
CHANGED
|
@@ -16,7 +16,10 @@ import { EXIT } from '../core/errors.js';
|
|
|
16
16
|
* @returns {Promise<number>}
|
|
17
17
|
*/
|
|
18
18
|
export async function run(ctx) {
|
|
19
|
-
|
|
19
|
+
// `opening: false` — approving moves a picture the last check already took. It launches
|
|
20
|
+
// nothing. On a project with no screen there is simply never anything waiting, and saying
|
|
21
|
+
// that in one line is the true answer; refusing to load the settings at all was not.
|
|
22
|
+
const project = await loadProject({ cwd: ctx.cwd, configFile: ctx.configFile, opening: false });
|
|
20
23
|
const all = ctx.bool('all');
|
|
21
24
|
const names = ctx.args.filter((a) => a.trim() !== '');
|
|
22
25
|
|
package/src/cli/flake.js
CHANGED
|
@@ -17,7 +17,10 @@ import { EXIT } from '../core/errors.js';
|
|
|
17
17
|
* @returns {Promise<number>}
|
|
18
18
|
*/
|
|
19
19
|
export async function run(ctx) {
|
|
20
|
-
|
|
20
|
+
// `opening: false` — the register is a file on disk and reading it opens nothing. Without
|
|
21
|
+
// this, the one command whose whole job is to name unreliable checks was itself refused on
|
|
22
|
+
// every command-line tool and library this tool sets up. Measured 2026-08-31.
|
|
23
|
+
const project = await loadProject({ cwd: ctx.cwd, configFile: ctx.configFile, opening: false });
|
|
21
24
|
const history = await loadHistory(project.paths.historyFile);
|
|
22
25
|
|
|
23
26
|
const forgive = ctx.str('clear');
|
package/src/cli/mark.js
CHANGED
|
@@ -13,7 +13,11 @@ import { StaysFixedError, EXIT } from '../core/errors.js';
|
|
|
13
13
|
* @returns {Promise<number>}
|
|
14
14
|
*/
|
|
15
15
|
export async function run(ctx) {
|
|
16
|
-
|
|
16
|
+
// `opening: false` — a marker is a commit, a date and a set of fingerprints written to a
|
|
17
|
+
// file. Nothing is opened and nothing is photographed, so a project with no screen can pin
|
|
18
|
+
// a known-good version exactly like any other. It was refused on every one of them until
|
|
19
|
+
// 2026-08-31, which took `trace` down with it: there was nothing to trace back to.
|
|
20
|
+
const project = await loadProject({ cwd: ctx.cwd, configFile: ctx.configFile, opening: false });
|
|
17
21
|
|
|
18
22
|
if (ctx.bool('list')) return showAll(project);
|
|
19
23
|
|
package/src/cli/status.js
CHANGED
|
@@ -8,6 +8,7 @@ import path from 'node:path';
|
|
|
8
8
|
import { loadProject } from '../core/config.js';
|
|
9
9
|
import { projectStatus } from '../run.js';
|
|
10
10
|
import { printStatus } from '../report/console.js';
|
|
11
|
+
import { say, blank, heading, paint } from '../core/log.js';
|
|
11
12
|
import { EXIT } from '../core/errors.js';
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -56,8 +57,59 @@ export function versionTwoState(root) {
|
|
|
56
57
|
* @returns {Promise<number>}
|
|
57
58
|
*/
|
|
58
59
|
export async function run(ctx) {
|
|
59
|
-
|
|
60
|
+
// `opening: false` — this command reads files and prints them. It says so in its own first
|
|
61
|
+
// line. Loading the settings through the check that insists on something to open made the
|
|
62
|
+
// fastest command in the tool refuse outright on every command-line tool, library and
|
|
63
|
+
// server it had just set up, with a paragraph about an `app` key version 2 never writes.
|
|
64
|
+
const project = await loadProject({ cwd: ctx.cwd, configFile: ctx.configFile, opening: false });
|
|
60
65
|
const status = await projectStatus(project);
|
|
61
66
|
printStatus(/** @type {any} */ ({ ...status, v2: versionTwoState(project.paths?.root ?? ctx.cwd) }));
|
|
67
|
+
printWhatIsSetUp(project.config);
|
|
62
68
|
return EXIT.ok;
|
|
63
69
|
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* What these settings actually cover, in one short list.
|
|
73
|
+
*
|
|
74
|
+
* The promise of this command is "what is set up here", and on a project with no screen the
|
|
75
|
+
* picture counters answer it with four zeroes — every one of them true, and together they
|
|
76
|
+
* read as "nothing is set up" about a project whose settings name three commands and two
|
|
77
|
+
* folders of source. So the settings are read back in the words they were written in.
|
|
78
|
+
*
|
|
79
|
+
* Everything here is read out of the settings file. Nothing is inferred and nothing is
|
|
80
|
+
* counted that is not there, because a status line that overstates what is covered is the
|
|
81
|
+
* one kind of wrong this tool cannot afford.
|
|
82
|
+
*
|
|
83
|
+
* @param {import('../types.js').ResolvedConfig} config
|
|
84
|
+
* @returns {void}
|
|
85
|
+
*/
|
|
86
|
+
function printWhatIsSetUp(config) {
|
|
87
|
+
const any = /** @type {Record<string, any>} */ (/** @type {unknown} */ (config));
|
|
88
|
+
/** @type {string[]} */
|
|
89
|
+
const lines = [];
|
|
90
|
+
|
|
91
|
+
const commands = Array.isArray(any.process?.commands) ? any.process.commands.filter((/** @type {any} */ c) => c && !c.skip) : [];
|
|
92
|
+
const imports = Array.isArray(any.process?.imports) ? any.process.imports : [];
|
|
93
|
+
if (commands.length > 0) lines.push(`${commands.length} ${commands.length === 1 ? 'command' : 'commands'} to run and compare word for word`);
|
|
94
|
+
if (imports.length > 0) lines.push(`${imports.length} ${imports.length === 1 ? 'library entry' : 'library entries'} to import and compare what ${imports.length === 1 ? 'it exports' : 'they export'}`);
|
|
95
|
+
if (any.source && typeof any.source === 'object') {
|
|
96
|
+
const folders = Array.isArray(any.source.folders) ? any.source.folders : [];
|
|
97
|
+
lines.push(folders.length > 0 ? `the code in ${folders.join(', ')}, read without running it` : 'the code, read without running it');
|
|
98
|
+
}
|
|
99
|
+
if (any.http && typeof any.http === 'object') lines.push('a server, booted on a spare port and asked for every route');
|
|
100
|
+
if (any.web && typeof any.web === 'object') {
|
|
101
|
+
const screens = Array.isArray(any.web.screens) ? any.web.screens.length : 0;
|
|
102
|
+
lines.push(screens > 0 ? `a website, and ${screens} ${screens === 1 ? 'screen' : 'screens'} of it` : 'a website');
|
|
103
|
+
}
|
|
104
|
+
if (any.electron && typeof any.electron === 'object') lines.push('a desktop app');
|
|
105
|
+
if (any.android && typeof any.android === 'object') lines.push('an Android app');
|
|
106
|
+
if (any.ios && typeof any.ios === 'object') lines.push('an iPhone app');
|
|
107
|
+
if (any.windows && typeof any.windows === 'object') lines.push('a native Windows app');
|
|
108
|
+
|
|
109
|
+
if (lines.length === 0) return;
|
|
110
|
+
heading('What these settings cover');
|
|
111
|
+
for (const line of lines) say(` ${paint.grey('·')} ${line}`);
|
|
112
|
+
blank();
|
|
113
|
+
say(paint.grey(` Run ${paint.cyan('staysfixed check')} to walk it, and ${paint.cyan('staysfixed coverage')} for what a run did NOT look at.`));
|
|
114
|
+
blank();
|
|
115
|
+
}
|
package/src/cli/trace.js
CHANGED
|
@@ -6,7 +6,7 @@ import { loadProject } from '../core/config.js';
|
|
|
6
6
|
import { projectStatus } from '../run.js';
|
|
7
7
|
import { traceScreens } from '../marker/trace.js';
|
|
8
8
|
import { printTrace } from '../report/console.js';
|
|
9
|
-
import { say, paint } from '../core/log.js';
|
|
9
|
+
import { say, blank, paint } from '../core/log.js';
|
|
10
10
|
import { resultPicture } from '../core/paths.js';
|
|
11
11
|
import { sha256File } from '../core/hash.js';
|
|
12
12
|
import { EXIT } from '../core/errors.js';
|
|
@@ -16,12 +16,37 @@ import { EXIT } from '../core/errors.js';
|
|
|
16
16
|
* @returns {Promise<number>}
|
|
17
17
|
*/
|
|
18
18
|
export async function run(ctx) {
|
|
19
|
-
|
|
19
|
+
// `opening: false` — tracing compares fingerprints already written down against markers
|
|
20
|
+
// already written down. It opens nothing. Where there is nothing recorded to trace, the
|
|
21
|
+
// report says so in its own words further down; being refused before it could even look
|
|
22
|
+
// was the wrong answer, and it named a settings key that version 2 never writes.
|
|
23
|
+
const project = await loadProject({ cwd: ctx.cwd, configFile: ctx.configFile, opening: false });
|
|
20
24
|
|
|
21
25
|
const asked = ctx.args.filter((a) => a.trim() !== '');
|
|
22
26
|
const changed = asked.length > 0 ? [] : await changedInLastRun(project);
|
|
23
27
|
const names = asked.length > 0 ? asked : changed;
|
|
24
28
|
|
|
29
|
+
// A project with nothing to photograph cannot be traced, and it is owed a sentence saying
|
|
30
|
+
// why rather than a shrug and a suggestion it has already followed.
|
|
31
|
+
//
|
|
32
|
+
// The generic ending is "There is nothing to trace yet. Pin a good version first with
|
|
33
|
+
// `staysfixed mark`." — which, on a project that had just pinned one, told somebody to go
|
|
34
|
+
// and do the thing they had done thirty seconds earlier. Measured 2026-08-31 on a Python
|
|
35
|
+
// command-line tool. Tracing follows a SCREEN backwards, so on a product with no screen
|
|
36
|
+
// the answer is not "not yet", it is "not this kind of project", and saying the second one
|
|
37
|
+
// stops somebody working through a list that was never going to end.
|
|
38
|
+
if (asked.length === 0 && names.length === 0 && project.config.screens.length === 0) {
|
|
39
|
+
blank();
|
|
40
|
+
say('There is no screen in this project to trace.');
|
|
41
|
+
say(paint.grey('Tracing follows one screen back through your markers to the commit where it stopped'));
|
|
42
|
+
say(paint.grey('looking right, so it needs a picture to follow. These settings name none.'));
|
|
43
|
+
blank();
|
|
44
|
+
say(`For what changed in a project without a screen, run ${paint.cyan('staysfixed check')} — it compares every`);
|
|
45
|
+
say('word a command printed, what it exited with and every file it touched.');
|
|
46
|
+
blank();
|
|
47
|
+
return EXIT.ok;
|
|
48
|
+
}
|
|
49
|
+
|
|
25
50
|
if (asked.length === 0) {
|
|
26
51
|
if (names.length === 0) {
|
|
27
52
|
say(paint.grey('Nothing is different right now, so this looks at every screen there is a record of.'));
|
package/src/core/config.js
CHANGED
|
@@ -83,9 +83,40 @@ export const DEFAULT_MCP = {
|
|
|
83
83
|
allowMark: false,
|
|
84
84
|
};
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* The `app.kind` of a project that has nothing to open at all.
|
|
88
|
+
*
|
|
89
|
+
* A command-line tool, a library and a plain server are all perfectly ordinary products with
|
|
90
|
+
* no screen anywhere in them, and every command that only READS what is on disk — status,
|
|
91
|
+
* flake, mark, trace, approve — works on one exactly as well as it works on a website. They
|
|
92
|
+
* were all refused anyway, because the only way to load settings was through a check that
|
|
93
|
+
* insisted on something to open. This value is how a command says "I open nothing, so do not
|
|
94
|
+
* ask", and it is a made-up word on purpose: nothing can accidentally match it, and anything
|
|
95
|
+
* that reads it and does not understand it fails loudly rather than photographing a guess.
|
|
96
|
+
*/
|
|
97
|
+
export const NOTHING_TO_OPEN = 'nothing-to-open';
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Do these settings name anything this tool could open and photograph?
|
|
101
|
+
*
|
|
102
|
+
* Exported so a command can ask before it tries, and say something true about this project
|
|
103
|
+
* instead of the same refusal for every shape of product.
|
|
104
|
+
*
|
|
105
|
+
* @param {import('../types.js').ResolvedConfig} config
|
|
106
|
+
* @returns {boolean}
|
|
107
|
+
*/
|
|
108
|
+
export function hasSomethingToOpen(config) {
|
|
109
|
+
// Read as a plain string on purpose. The declared shape of `app.kind` is version 1's two
|
|
110
|
+
// words, and this third one is deliberately outside it — see NOTHING_TO_OPEN above.
|
|
111
|
+
return /** @type {string} */ (config?.app?.kind) !== NOTHING_TO_OPEN;
|
|
112
|
+
}
|
|
113
|
+
|
|
86
114
|
/**
|
|
87
115
|
* Find, import and resolve the config.
|
|
88
|
-
* @param {{cwd?: string, configFile?: string}} [opts]
|
|
116
|
+
* @param {{cwd?: string, configFile?: string, opening?: boolean}} [opts]
|
|
117
|
+
* `opening: false` is a command promising it will not open or photograph anything — it
|
|
118
|
+
* only reads what is already on disk. Settings with no screen in them are then a normal,
|
|
119
|
+
* correct shape rather than a reason to refuse.
|
|
89
120
|
* @returns {Promise<import('../types.js').Project>}
|
|
90
121
|
*/
|
|
91
122
|
export async function loadProject(opts = {}) {
|
|
@@ -98,7 +129,7 @@ export async function loadProject(opts = {}) {
|
|
|
98
129
|
}
|
|
99
130
|
const raw = await importConfig(file);
|
|
100
131
|
const root = rootForConfig(file);
|
|
101
|
-
const config = resolveConfig(raw, file);
|
|
132
|
+
const config = resolveConfig(raw, file, { opening: opts.opening });
|
|
102
133
|
const paths = pathsFor(root, file, config.dir);
|
|
103
134
|
// `guards` is the one folder a project is free to move, so the config wins over the
|
|
104
135
|
// default layout. Without this the setting silently did nothing and the tool reported
|
|
@@ -136,9 +167,11 @@ async function importConfig(file) {
|
|
|
136
167
|
* Fill in defaults and reject anything that would fail later in a confusing way.
|
|
137
168
|
* @param {unknown} raw
|
|
138
169
|
* @param {string} file
|
|
170
|
+
* @param {{opening?: boolean}} [opts]
|
|
171
|
+
* `opening: false` from a command that only reads what is on disk. See {@link loadProject}.
|
|
139
172
|
* @returns {import('../types.js').ResolvedConfig}
|
|
140
173
|
*/
|
|
141
|
-
export function resolveConfig(raw, file = '(inline)') {
|
|
174
|
+
export function resolveConfig(raw, file = '(inline)', opts = {}) {
|
|
142
175
|
if (!raw || typeof raw !== 'object') {
|
|
143
176
|
throw new StaysFixedError(`${path.basename(file)} did not export a config object.`, {
|
|
144
177
|
hint: 'It should `export default { app: { ... }, screens: [ ... ] }`.',
|
|
@@ -158,8 +191,8 @@ export function resolveConfig(raw, file = '(inline)') {
|
|
|
158
191
|
// Where the address is actually knowable, take it and let the command work. Booting is
|
|
159
192
|
// version 2's job and these commands cannot do it, so `web.start` alone is not enough —
|
|
160
193
|
// that case falls through to the message below, which now says so honestly.
|
|
194
|
+
const v2 = /** @type {Record<string, any>} */ (/** @type {unknown} */ (c));
|
|
161
195
|
if ((!c.app || typeof c.app !== 'object')) {
|
|
162
|
-
const v2 = /** @type {Record<string, any>} */ (/** @type {unknown} */ (c));
|
|
163
196
|
if (v2.web && typeof v2.web === 'object' && typeof v2.web.url === 'string' && v2.web.url) {
|
|
164
197
|
c.app = { kind: 'web', url: v2.web.url };
|
|
165
198
|
} else if (v2.electron && typeof v2.electron === 'object' && typeof v2.electron.binary === 'string' && v2.electron.binary) {
|
|
@@ -167,15 +200,49 @@ export function resolveConfig(raw, file = '(inline)') {
|
|
|
167
200
|
}
|
|
168
201
|
}
|
|
169
202
|
|
|
203
|
+
// The screens come across with the address, and they did not before.
|
|
204
|
+
//
|
|
205
|
+
// Half a bridge is worse than none: on a version 2 website `walk` and `approve` were
|
|
206
|
+
// handed the address and then found nothing to photograph, because version 2 keeps its
|
|
207
|
+
// screens under `web` and these commands only ever looked at the top level. So the
|
|
208
|
+
// commands ran, opened a browser, and reported an empty walk of a site with six pages in
|
|
209
|
+
// its settings — measured 2026-08-31. Only a screen with a name and a plain address is
|
|
210
|
+
// carried over; anything reached by clicking is version 2's to walk, not this half's, and
|
|
211
|
+
// inventing a journey out of one would put a screen in the report nobody can reach.
|
|
212
|
+
if (c.app && typeof c.app === 'object' && !Array.isArray(c.screens)) {
|
|
213
|
+
const fromV2 = Array.isArray(v2.web?.screens) ? v2.web.screens : [];
|
|
214
|
+
const carried = fromV2.filter((/** @type {any} */ s) => s && typeof s.name === 'string' && typeof s.url === 'string' && s.url !== '');
|
|
215
|
+
if (carried.length > 0) c.screens = carried.map((/** @type {any} */ s) => ({ name: s.name, url: s.url }));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// A command that opens nothing must never be refused for having nothing to open.
|
|
219
|
+
//
|
|
220
|
+
// `status`, `flake`, `mark`, `trace` and `approve` all do their whole job by reading files
|
|
221
|
+
// this tool has already written. Every one of them was dead on the settings this tool's own
|
|
222
|
+
// `init` writes for a command-line tool, a library or a server — five commands offered in
|
|
223
|
+
// `--help`, all answering with a paragraph about an `app` key that version 2 never writes
|
|
224
|
+
// and that nobody running them had ever seen. Measured 2026-08-31 on a Python command-line
|
|
225
|
+
// tool and on a plain Node one; both were set up by `staysfixed init` seconds earlier.
|
|
226
|
+
if ((!c.app || typeof c.app !== 'object') && opts.opening === false) {
|
|
227
|
+
c.app = /** @type {any} */ ({ kind: NOTHING_TO_OPEN });
|
|
228
|
+
}
|
|
229
|
+
|
|
170
230
|
if (!c.app || typeof c.app !== 'object') {
|
|
171
|
-
//
|
|
172
|
-
// `check --pictures
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
231
|
+
// Only the commands that really do open something land here now — `walk`, and
|
|
232
|
+
// `check --pictures`. They photograph a screen, so a project with no screen anywhere in
|
|
233
|
+
// it genuinely cannot be walked, and the honest thing is to say which product this is
|
|
234
|
+
// and stop offering it.
|
|
235
|
+
//
|
|
236
|
+
// What this message must never do is name a key the person has not got. `app` is version
|
|
237
|
+
// 1's word for the thing to open; the settings `staysfixed init` writes today have no
|
|
238
|
+
// `app` in them and never will, so telling somebody to add one sends them editing a file
|
|
239
|
+
// against a shape nothing else in the tool uses. Where there IS advice worth giving, it
|
|
240
|
+
// is given in the words their own settings file already uses — `url` inside the `web`
|
|
241
|
+
// block — and only for a file that is written that way.
|
|
177
242
|
const anything = /** @type {Record<string, unknown>} */ (/** @type {unknown} */ (c));
|
|
178
|
-
const
|
|
243
|
+
const versionTwo = ['process', 'source', 'http', 'web', 'electron', 'android', 'ios', 'windows']
|
|
244
|
+
.filter((k) => anything[k] && typeof anything[k] === 'object');
|
|
245
|
+
const notVisual = ['process', 'http', 'source', 'android', 'ios', 'windows'].filter((k) => versionTwo.includes(k));
|
|
179
246
|
// A project that DOES have a screen, described the version 2 way, must never be told it
|
|
180
247
|
// has none. It is told the true thing instead: this half of the tool photographs an
|
|
181
248
|
// address you can point it at, and version 2 finds the address by booting the product,
|
|
@@ -186,23 +253,36 @@ export function resolveConfig(raw, file = '(inline)') {
|
|
|
186
253
|
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
254
|
});
|
|
188
255
|
}
|
|
256
|
+
if (versionTwo.length > 0) {
|
|
257
|
+
throw new StaysFixedError(
|
|
258
|
+
`This command photographs a screen, and this project has none — these settings describe ${plainList(versionTwo.map(describeBlock))}.`,
|
|
259
|
+
{
|
|
260
|
+
hint: `Nothing is missing and nothing needs adding. Run \`staysfixed check\`, which covers ${notVisual.length === versionTwo.length ? 'exactly what is here' : 'all of it'} without a picture.`
|
|
261
|
+
+ (versionTwo.includes('web') ? " If you want the picture commands on the site too, add `url: 'http://localhost:3000'` inside the `web` block so there is an address to point at." : ''),
|
|
262
|
+
},
|
|
263
|
+
);
|
|
264
|
+
}
|
|
189
265
|
throw new StaysFixedError('These settings do not name anything to open, and this command works by opening your product and photographing it.', {
|
|
190
|
-
hint:
|
|
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: '...' }\`.`
|
|
192
|
-
: "Add `app: { kind: 'web', url: 'http://localhost:3000' }` or `app: { kind: 'electron', binary: '...' }`. If your product has no screen at all, `staysfixed check` covers it without one.",
|
|
266
|
+
hint: "Add `app: { kind: 'web', url: 'http://localhost:3000' }` or `app: { kind: 'electron', binary: '...' }`. If your product has no screen at all, `staysfixed check` covers it without one.",
|
|
193
267
|
});
|
|
194
268
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
269
|
+
// Read as a plain string, because a project with nothing to open carries a third value
|
|
270
|
+
// that is deliberately outside version 1's two — see NOTHING_TO_OPEN at the top.
|
|
271
|
+
const kind = /** @type {string} */ (c.app.kind);
|
|
272
|
+
// A command that told us it opens nothing gets no further questions. Asking a project with
|
|
273
|
+
// no screen for an address or a binary is the refusal this whole branch exists to stop.
|
|
274
|
+
if (kind !== NOTHING_TO_OPEN) {
|
|
275
|
+
if (kind !== 'web' && kind !== 'electron') {
|
|
276
|
+
throw new StaysFixedError(`app.kind must be 'web' or 'electron' (found ${JSON.stringify(kind)}).`);
|
|
277
|
+
}
|
|
278
|
+
if (kind === 'web' && !c.app.url && !c.app.attach) {
|
|
279
|
+
throw new StaysFixedError('A web app needs `app.url` — the address to open.');
|
|
280
|
+
}
|
|
281
|
+
if (kind === 'electron' && !c.app.binary && !c.app.attach) {
|
|
282
|
+
throw new StaysFixedError('An Electron app needs `app.binary` — the path to the executable.', {
|
|
283
|
+
hint: 'On macOS that is inside the bundle: /Applications/Your App.app/Contents/MacOS/Your App',
|
|
284
|
+
});
|
|
285
|
+
}
|
|
206
286
|
}
|
|
207
287
|
|
|
208
288
|
const screens = (c.screens ?? []).map((s, i) => resolveScreen(s, i));
|
|
@@ -285,3 +365,34 @@ export function settingsForScreen(config, screen) {
|
|
|
285
365
|
masks: [...config.masks, ...(screen.masks ?? [])],
|
|
286
366
|
};
|
|
287
367
|
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* What one block of version 2 settings actually is, in words somebody who did not write this
|
|
371
|
+
* tool would use. Said out loud in a refusal so the sentence names this project rather than
|
|
372
|
+
* naming a missing key.
|
|
373
|
+
*
|
|
374
|
+
* @param {string} key
|
|
375
|
+
* @returns {string}
|
|
376
|
+
*/
|
|
377
|
+
function describeBlock(key) {
|
|
378
|
+
return /** @type {Record<string,string>} */ ({
|
|
379
|
+
process: 'commands to run and libraries to import',
|
|
380
|
+
source: 'code to read without running it',
|
|
381
|
+
http: 'a server to boot and ask for its routes',
|
|
382
|
+
web: 'a website to open',
|
|
383
|
+
electron: 'a desktop app to open',
|
|
384
|
+
android: 'an Android app',
|
|
385
|
+
ios: 'an iPhone app',
|
|
386
|
+
windows: 'a native Windows app',
|
|
387
|
+
})[key] ?? key;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* A list a person reads out loud: "a, b and c". Two commas and an "and" beats three commas.
|
|
392
|
+
* @param {string[]} items
|
|
393
|
+
* @returns {string}
|
|
394
|
+
*/
|
|
395
|
+
function plainList(items) {
|
|
396
|
+
if (items.length <= 1) return items[0] ?? '';
|
|
397
|
+
return `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`;
|
|
398
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stopping a command AND everything that command started — on every operating system.
|
|
3
|
+
*
|
|
4
|
+
* Almost every command this tool runs is really a shell: `npm test`, `npm run dev`,
|
|
5
|
+
* `poetry run uvicorn ...`. The shell then starts the real program, which often starts
|
|
6
|
+
* another one. So the process this tool holds a handle to is the shell, and the work is
|
|
7
|
+
* happening in its children and grandchildren.
|
|
8
|
+
*
|
|
9
|
+
* Killing the shell is not killing the work. On Linux the children carry on with a new
|
|
10
|
+
* parent; on a Mac they usually die with the shell, which is exactly why this was invisible
|
|
11
|
+
* for so long. The answer there is to put the shell and everything it starts into one
|
|
12
|
+
* process GROUP and signal the group, which is what a negative process id means.
|
|
13
|
+
*
|
|
14
|
+
* WINDOWS HAS NO PROCESS GROUPS OF THAT KIND, and the code here used to say so and give up —
|
|
15
|
+
* it killed the one process it knew about and hoped. It does not work: measured on a real
|
|
16
|
+
* Windows 11 machine on 2026-08-31, a command the guard had already given up on kept running,
|
|
17
|
+
* finished its work and wrote its file, because only `cmd.exe` had been killed and the `node`
|
|
18
|
+
* underneath it never noticed. Windows does have an answer, it is just spelled differently:
|
|
19
|
+
* `taskkill /T` walks the tree of children and stops all of them. This file is the one place
|
|
20
|
+
* that difference is written down, so no caller has to remember it again.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { spawnSync } from 'node:child_process';
|
|
24
|
+
|
|
25
|
+
/** Windows spells "stop this and everything under it" as a command, not a signal. */
|
|
26
|
+
const TASKKILL = 'taskkill';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Stop a process and every process it started.
|
|
30
|
+
*
|
|
31
|
+
* Safe to call more than once, safe to call on something already gone, and safe to call from
|
|
32
|
+
* an exit handler — nothing here is asynchronous, because a process on its way out has no
|
|
33
|
+
* event loop left to wait on.
|
|
34
|
+
*
|
|
35
|
+
* @param {number|null|undefined} pid The process this tool started.
|
|
36
|
+
* @param {'SIGTERM'|'SIGKILL'} signal SIGTERM asks; SIGKILL insists.
|
|
37
|
+
* @param {{child?: import('node:child_process').ChildProcess|null}} [opts]
|
|
38
|
+
* The handle, when the caller has one. It is the fallback if the tree walk fails.
|
|
39
|
+
* @returns {boolean} true when something was asked to stop, false when there was nothing to ask.
|
|
40
|
+
*/
|
|
41
|
+
export function stopTree(pid, signal, opts = {}) {
|
|
42
|
+
const child = opts.child ?? null;
|
|
43
|
+
if (!pid) {
|
|
44
|
+
if (!child) return false;
|
|
45
|
+
return tryKill(child, signal);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (process.platform === 'win32') {
|
|
49
|
+
// `/T` takes the children, `/F` insists — and on Windows BOTH are always used, including
|
|
50
|
+
// for the polite SIGTERM. There is no polite stop for a console program there: `taskkill`
|
|
51
|
+
// without `/F` sends a window a close message, which `node` has no window to receive, so
|
|
52
|
+
// it refuses with "this process can only be terminated forcefully" and nothing stops.
|
|
53
|
+
//
|
|
54
|
+
// Nothing is lost by that, because Node's own `child.kill('SIGTERM')` on Windows is
|
|
55
|
+
// already an outright TerminateProcess — the ONLY difference this line makes is that the
|
|
56
|
+
// children go too. Measured on a real Windows 11 machine on 2026-08-31: the first version
|
|
57
|
+
// of this file tried the polite form, watched it refuse, and fell back to killing the
|
|
58
|
+
// shell alone — which left the server running AND made it look like it had stopped,
|
|
59
|
+
// because the handle the caller was watching had gone. The scratch folder could then not
|
|
60
|
+
// be deleted, and the whole of `waiting.test.js` failed on it.
|
|
61
|
+
const ran = spawnSync(TASKKILL, ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true, timeout: 10_000 });
|
|
62
|
+
if (ran.status === 0) return true;
|
|
63
|
+
// taskkill was not there, or the process had already finished — which is the outcome the
|
|
64
|
+
// caller wanted anyway. Stopping the one process this tool definitely knows about is
|
|
65
|
+
// weaker than stopping the tree, and it is a great deal better than leaving it running.
|
|
66
|
+
return child ? tryKill(child, signal) : false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
// A negative process id is the GROUP. This is the line that takes the watchers,
|
|
71
|
+
// bundlers and servers down with the shell that started them.
|
|
72
|
+
process.kill(-pid, signal);
|
|
73
|
+
return true;
|
|
74
|
+
} catch {
|
|
75
|
+
// No group — which happens when the caller did not start it detached — or it is already
|
|
76
|
+
// gone. Either way, ask the one process we know about.
|
|
77
|
+
if (child) return tryKill(child, signal);
|
|
78
|
+
try {
|
|
79
|
+
process.kill(pid, signal);
|
|
80
|
+
return true;
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {import('node:child_process').ChildProcess} child
|
|
89
|
+
* @param {'SIGTERM'|'SIGKILL'} signal
|
|
90
|
+
* @returns {boolean}
|
|
91
|
+
*/
|
|
92
|
+
function tryKill(child, signal) {
|
|
93
|
+
try {
|
|
94
|
+
return child.kill(signal);
|
|
95
|
+
} catch {
|
|
96
|
+
// Already gone, which is the outcome wanted.
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Should a command be started in a process group of its own?
|
|
103
|
+
*
|
|
104
|
+
* Only where process groups exist. On Windows `detached: true` does something else entirely —
|
|
105
|
+
* it gives the child its own console WINDOW, which flashes up on the person's screen in the
|
|
106
|
+
* middle of a check and is never what this tool wants. Windows gets its tree walk from
|
|
107
|
+
* `stopTree` instead, which needs nothing at spawn time.
|
|
108
|
+
*/
|
|
109
|
+
export const OWN_PROCESS_GROUP = process.platform !== 'win32';
|