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
|
@@ -180,10 +180,48 @@ export function watcherScript(opts) {
|
|
|
180
180
|
" write('reached out', { host: host || 'somewhere it did not name', port: port ?? null });",
|
|
181
181
|
" // Refused, not allowed through. Whatever this was going to do out there, it does not",
|
|
182
182
|
" // do it twice, and the run is reported as having a hole rather than as having passed.",
|
|
183
|
-
"
|
|
184
|
-
"
|
|
185
|
-
"
|
|
186
|
-
"
|
|
183
|
+
" //",
|
|
184
|
+
" // HOW the refusal arrives matters as much as that it happens. Emitting 'error' on the",
|
|
185
|
+
" // socket ourselves reads correctly and kills the product: at that moment nothing is",
|
|
186
|
+
" // listening on the socket yet, and in Node an 'error' event with no listener is a",
|
|
187
|
+
" // thrown exception. `http.get` and `https.get` on Node 22 - the floor this package",
|
|
188
|
+
" // declares in its own engines field - and a bare `net.connect` on EVERY version all",
|
|
189
|
+
" // died that way, exit 1, and the run then reported the product as broken. A tool",
|
|
190
|
+
" // blaming a product for something the tool itself did is the exact failure this whole",
|
|
191
|
+
" // package exists to prevent. Measured 2026-08-30 against the published 0.8.0 watcher;",
|
|
192
|
+
" // its own CI had been red on this for four releases and nobody read it.",
|
|
193
|
+
" //",
|
|
194
|
+
" // So the refusal is made real rather than simulated: the socket is pointed at a port on",
|
|
195
|
+
" // this machine that nothing can be listening on, and the operating system produces the",
|
|
196
|
+
" // refusal through Node's own plumbing - by which time every listener the runtime wires",
|
|
197
|
+
" // up is in place. The product gets an ordinary ECONNREFUSED, which is exactly what it",
|
|
198
|
+
" // would get if the host were unreachable, and cannot tell the difference.",
|
|
199
|
+
" const named = host || 'an unnamed host';",
|
|
200
|
+
" const explain = 'Stays Fixed refused a connection to ' + named + ': nothing irreversible is allowed out during a check.';",
|
|
201
|
+
" const refusal = () => Object.assign(new Error(explain), { code: 'ECONNREFUSED', refusedBy: 'staysfixed' });",
|
|
202
|
+
" // Said in the error the product actually catches, without swallowing it: prepending a",
|
|
203
|
+
" // listener rewrites the message and still leaves every other handler to run as it would.",
|
|
204
|
+
" this.prependListener('error', (e) => {",
|
|
205
|
+
" if (e && e.code === 'ECONNREFUSED') { e.message = explain; e.refusedBy = 'staysfixed'; }",
|
|
206
|
+
" });",
|
|
207
|
+
" // Belt and braces. If something really is listening down there, the connection is cut",
|
|
208
|
+
" // before one byte can cross it: a boundary that fails open is not a boundary.",
|
|
209
|
+
" this.prependOnceListener('connect', () => { this.destroy(refusal()); });",
|
|
210
|
+
" // And a machine where that port is silently dropped rather than refused would hang",
|
|
211
|
+
" // here instead of failing, which is worse than the bug this replaced: a check that",
|
|
212
|
+
" // never finishes tells you nothing at all. A refusal is owed promptly, so if the",
|
|
213
|
+
" // operating system has not produced one shortly, produce it. Safe to do now, and only",
|
|
214
|
+
" // now, because the listener above means this can never be an unhandled error.",
|
|
215
|
+
" const soon = setTimeout(() => { if (!this.destroyed) this.destroy(refusal()); }, 250);",
|
|
216
|
+
" if (typeof soon.unref === 'function') soon.unref();",
|
|
217
|
+
" this.once('close', () => clearTimeout(soon));",
|
|
218
|
+
" try {",
|
|
219
|
+
" return connect.call(this, { port: 1, host: '127.0.0.1' });",
|
|
220
|
+
" } catch {",
|
|
221
|
+
" // Even the refusal failed. Still never throw into the product.",
|
|
222
|
+
" process.nextTick(() => { if (!this.destroyed) this.destroy(refusal()); });",
|
|
223
|
+
" return this;",
|
|
224
|
+
" }",
|
|
187
225
|
" };",
|
|
188
226
|
"} catch { /* no net module, nothing to refuse */ }",
|
|
189
227
|
"",
|
|
@@ -1090,7 +1128,19 @@ export const processAdapter = defineAdapter({
|
|
|
1090
1128
|
*/
|
|
1091
1129
|
export function importProbeCommand(moduleId) {
|
|
1092
1130
|
const probe = [
|
|
1093
|
-
|
|
1131
|
+
// A FILE unless it is really a package. The old rule was "starts with a dot, or has a
|
|
1132
|
+
// slash in it" — and `index.js` has neither, so Node was asked for a PACKAGE called
|
|
1133
|
+
// "index.js" and answered ERR_MODULE_NOT_FOUND. `staysfixed init` writes exactly
|
|
1134
|
+
// `{ module: "index.js" }` for an ordinary package entry, so on those projects this
|
|
1135
|
+
// journey failed on every run, failed the SAME way on both builds, produced no
|
|
1136
|
+
// difference, and the check said "Nothing that worked has changed" for ever. Measured
|
|
1137
|
+
// 2026-08-30. So: if a file of that name is really there, it is a file.
|
|
1138
|
+
"const id = process.argv[1];",
|
|
1139
|
+
"const { existsSync } = await import('node:fs');",
|
|
1140
|
+
"const { fileURLToPath } = await import('node:url');",
|
|
1141
|
+
"const asFile = new URL(id, 'file://' + process.cwd() + '/').href;",
|
|
1142
|
+
"const onDisk = (() => { try { return existsSync(fileURLToPath(asFile)); } catch { return false; } })();",
|
|
1143
|
+
"const m = await import(id.startsWith('.') || id.startsWith('/') || id.includes('/') || onDisk ? asFile : id);",
|
|
1094
1144
|
"const out = {};",
|
|
1095
1145
|
"for (const key of Object.keys(m).sort()) {",
|
|
1096
1146
|
" const v = m[key];",
|
|
@@ -174,6 +174,8 @@ export async function loadPlaywright(opts = {}) {
|
|
|
174
174
|
// So: ask it. Only when there is no browser on the machine at all is this a real "no".
|
|
175
175
|
/** @type {string|undefined} */
|
|
176
176
|
let borrowedFrom;
|
|
177
|
+
/** True when the browser found is the one the PERSON uses, not a separate one. */
|
|
178
|
+
let borrowedTheirOwn = false;
|
|
177
179
|
if (!there) {
|
|
178
180
|
try {
|
|
179
181
|
const { surveyBrowsers } = await import('../browsers.js');
|
|
@@ -181,6 +183,7 @@ export async function loadPlaywright(opts = {}) {
|
|
|
181
183
|
if (survey.chosen?.binary && (await exists(survey.chosen.binary))) {
|
|
182
184
|
executable = survey.chosen.binary;
|
|
183
185
|
borrowedFrom = survey.chosen.name;
|
|
186
|
+
borrowedTheirOwn = survey.borrowingHis === true || survey.chosen.everyday === true;
|
|
184
187
|
there = true;
|
|
185
188
|
}
|
|
186
189
|
} catch {
|
|
@@ -206,12 +209,35 @@ export async function loadPlaywright(opts = {}) {
|
|
|
206
209
|
chromium: mod.chromium,
|
|
207
210
|
version,
|
|
208
211
|
executable,
|
|
209
|
-
why: borrowedFrom
|
|
210
|
-
? `The browser driver ${version ?? ''} is here and it will open ${borrowedFrom}, which is a separate application from the browser you use, so pages can be opened.`.trim()
|
|
211
|
-
: `The browser driver ${version ?? ''} is here and its Chromium is downloaded, so pages can be opened.`.trim(),
|
|
212
|
+
why: browserNote(version, borrowedFrom, borrowedTheirOwn),
|
|
212
213
|
};
|
|
213
214
|
}
|
|
214
215
|
|
|
216
|
+
/**
|
|
217
|
+
* What to say about the browser a check will open.
|
|
218
|
+
*
|
|
219
|
+
* "a separate application from the browser you use" used to be said whatever was found — and
|
|
220
|
+
* the one case where that sentence matters is the case where it is false. With no downloaded
|
|
221
|
+
* test browser anywhere, the survey falls back to the person's OWN browser, and this then
|
|
222
|
+
* told them the opposite of what was about to happen. A reassurance is only worth anything
|
|
223
|
+
* if it is withheld when it is not true.
|
|
224
|
+
*
|
|
225
|
+
* Exported so the wording is a test rather than a thing somebody has to notice.
|
|
226
|
+
*
|
|
227
|
+
* @param {string|undefined} version The driver version, if it said one.
|
|
228
|
+
* @param {string|undefined} borrowedFrom The browser found, if one had to be borrowed.
|
|
229
|
+
* @param {boolean} borrowedTheirOwn True when that browser is the person's own.
|
|
230
|
+
* @returns {string}
|
|
231
|
+
*/
|
|
232
|
+
export function browserNote(version, borrowedFrom, borrowedTheirOwn) {
|
|
233
|
+
const v = version ?? '';
|
|
234
|
+
if (!borrowedFrom) return `The browser driver ${v} is here and its Chromium is downloaded, so pages can be opened.`.trim();
|
|
235
|
+
if (borrowedTheirOwn) {
|
|
236
|
+
return `The browser driver ${v} is here and the only browser on this machine is the one you use yourself (${borrowedFrom}). It will be opened invisibly with a throwaway profile, so your own settings, cookies and tabs are never touched — but it is your browser, not a separate one. \`npx playwright install chromium\` gives checks one of their own.`.trim();
|
|
237
|
+
}
|
|
238
|
+
return `The browser driver ${v} is here and it will open ${borrowedFrom}, which is a separate application from the browser you use, so pages can be opened.`.trim();
|
|
239
|
+
}
|
|
240
|
+
|
|
215
241
|
/**
|
|
216
242
|
* @param {string} file
|
|
217
243
|
* @returns {Promise<boolean>}
|
|
@@ -1489,6 +1515,20 @@ export async function runStep(page, step, opts = {}) {
|
|
|
1489
1515
|
break;
|
|
1490
1516
|
}
|
|
1491
1517
|
}
|
|
1518
|
+
// A step that did nothing at all is almost always a word this tool does not know, and
|
|
1519
|
+
// saying nothing about it is the worst outcome available: the journey walks on, the sign-in
|
|
1520
|
+
// never happens, every page behind the login wall photographs the login page, and the run
|
|
1521
|
+
// comes back clean. `staysfixed init` itself shipped `{ fill: '#email', with: 'a@b.c' }`
|
|
1522
|
+
// as its sign-in example, and neither word is in the vocabulary.
|
|
1523
|
+
if (did.length === 0) {
|
|
1524
|
+
const known = new Set([...ACTION_ORDER, 'text', 'timeoutMs', 'name', 'note', 'act', 'checkpoint', 'describe']);
|
|
1525
|
+
const unknown = Object.keys(step).filter((k) => !known.has(k));
|
|
1526
|
+
if (unknown.length > 0) {
|
|
1527
|
+
throw new Error(
|
|
1528
|
+
`This step does nothing: ${unknown.map((k) => `\`${k}\``).join(', ')} ${unknown.length === 1 ? 'is not a word' : 'are not words'} this tool knows, so the step was skipped and whatever it was meant to do did not happen. The steps it understands are: ${ACTION_ORDER.join(', ')} — with \`text\` beside \`type\`. To type into a field: { type: '#email', text: 'a@b.c' }.`,
|
|
1529
|
+
);
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1492
1532
|
return did;
|
|
1493
1533
|
}
|
|
1494
1534
|
|
package/src/v2/adapters/web.js
CHANGED
|
@@ -40,7 +40,6 @@ import crypto from 'node:crypto';
|
|
|
40
40
|
import fs from 'node:fs';
|
|
41
41
|
import fsp from 'node:fs/promises';
|
|
42
42
|
import path from 'node:path';
|
|
43
|
-
import { spawn } from 'node:child_process';
|
|
44
43
|
|
|
45
44
|
import {
|
|
46
45
|
countBucket, defineAdapter, howLongItTook, joinPath, notCovered, observation, sizeBucket,
|
|
@@ -50,6 +49,7 @@ import { copyForScratch, frozenEnvironment } from './process.js';
|
|
|
50
49
|
import { freePort, looksDestructive, waitForServer } from './http.js';
|
|
51
50
|
import { applyFreeze, prepareForShutter } from '../../freeze/index.js';
|
|
52
51
|
import { settle } from '../../freeze/settle.js';
|
|
52
|
+
import { spawnServer, stopServer } from './child.js';
|
|
53
53
|
import {
|
|
54
54
|
actOf, countRoles, flattenAria, inkOf, loadPlaywright, openWindow, parseAria, runStep, short,
|
|
55
55
|
watchTheWire, whereItIs, withLimit,
|
|
@@ -537,7 +537,7 @@ export const webAdapter = defineAdapter({
|
|
|
537
537
|
if (!verdict.safe) notes.push(verdict.why);
|
|
538
538
|
else {
|
|
539
539
|
const done = await new Promise((resolve) => {
|
|
540
|
-
const child =
|
|
540
|
+
const child = spawnServer(String(config.restore), { cwd: work, env, stdio: 'ignore' });
|
|
541
541
|
child.on('error', () => resolve(false));
|
|
542
542
|
child.on('close', (code) => resolve(code === 0));
|
|
543
543
|
});
|
|
@@ -549,7 +549,7 @@ export const webAdapter = defineAdapter({
|
|
|
549
549
|
const said = [];
|
|
550
550
|
/** @type {string|null} */
|
|
551
551
|
let exited = null;
|
|
552
|
-
const child =
|
|
552
|
+
const child = spawnServer(String(config.start), { cwd: work, env });
|
|
553
553
|
child.stdout?.on('data', (c) => said.push(c));
|
|
554
554
|
child.stderr?.on('data', (c) => said.push(c));
|
|
555
555
|
child.on('close', (code, signal) => {
|
|
@@ -558,14 +558,14 @@ export const webAdapter = defineAdapter({
|
|
|
558
558
|
|
|
559
559
|
const up = await waitForServer(port, { timeoutMs: config.startTimeoutMs ?? 90000, crashed: () => exited });
|
|
560
560
|
if (!up.up) {
|
|
561
|
-
child
|
|
561
|
+
await stopServer(child);
|
|
562
562
|
return {
|
|
563
563
|
build,
|
|
564
564
|
root: work,
|
|
565
565
|
ready: false,
|
|
566
566
|
why: `${up.why} What it printed while trying: ${trimForStorage(Buffer.concat(said).toString('utf8'), 1500).text || '(nothing)'}`,
|
|
567
567
|
dispose: async () => {
|
|
568
|
-
child
|
|
568
|
+
await stopServer(child);
|
|
569
569
|
await fsp.rm(base, { recursive: true, force: true });
|
|
570
570
|
},
|
|
571
571
|
};
|
|
@@ -584,9 +584,7 @@ export const webAdapter = defineAdapter({
|
|
|
584
584
|
running.delete(build.id);
|
|
585
585
|
if (!held) return;
|
|
586
586
|
// Only ever the process we started ourselves.
|
|
587
|
-
held.child
|
|
588
|
-
await new Promise((r) => setTimeout(r, 400));
|
|
589
|
-
if (held.child && held.child.exitCode === null) held.child.kill('SIGKILL');
|
|
587
|
+
await stopServer(held.child);
|
|
590
588
|
await fsp.rm(base, { recursive: true, force: true });
|
|
591
589
|
},
|
|
592
590
|
};
|
|
@@ -779,7 +777,7 @@ export const webAdapter = defineAdapter({
|
|
|
779
777
|
},
|
|
780
778
|
|
|
781
779
|
async teardown() {
|
|
782
|
-
for (const [, held] of running) held.child
|
|
780
|
+
for (const [, held] of running) await stopServer(held.child);
|
|
783
781
|
running.clear();
|
|
784
782
|
},
|
|
785
783
|
});
|
package/src/v2/browsers.js
CHANGED
|
@@ -270,6 +270,16 @@ function testingBrowsers() {
|
|
|
270
270
|
take('chromium', path.join(inner, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'));
|
|
271
271
|
take('chrome-for-testing', path.join(inner, 'chrome-linux', 'chrome'));
|
|
272
272
|
take('chrome-for-testing', path.join(inner, 'chrome-win', 'chrome.exe'));
|
|
273
|
+
// The 64 matters, and leaving it off made this tool blind to full Chrome on every
|
|
274
|
+
// machine that is not a Mac. Playwright unpacks Linux into `chrome-linux64` and
|
|
275
|
+
// Windows into `chrome-win64`; Puppeteer does the same. Only macOS uses the names
|
|
276
|
+
// above, which is why it was never noticed here. On Linux this meant `npx playwright
|
|
277
|
+
// install chromium` - the command THIS FILE tells people to run - left a browser the
|
|
278
|
+
// survey could not see, and checks quietly fell back to the headless shell, or said
|
|
279
|
+
// there was no browser at all when the shell was not there too. Measured on a real
|
|
280
|
+
// Linux box on 2026-08-30: three Chromes present, none of them found.
|
|
281
|
+
take('chrome-for-testing', path.join(inner, 'chrome-linux64', 'chrome'));
|
|
282
|
+
take('chrome-for-testing', path.join(inner, 'chrome-win64', 'chrome.exe'));
|
|
273
283
|
take('headless-shell', path.join(inner, 'chrome-headless-shell-mac-arm64', 'chrome-headless-shell'), true);
|
|
274
284
|
take('headless-shell', path.join(inner, 'chrome-headless-shell-mac-x64', 'chrome-headless-shell'), true);
|
|
275
285
|
take('headless-shell', path.join(inner, 'chrome-headless-shell-linux64', 'chrome-headless-shell'), true);
|
|
@@ -534,11 +544,56 @@ function killNow(pid, home) {
|
|
|
534
544
|
} catch {
|
|
535
545
|
// Already gone. That is the outcome we wanted.
|
|
536
546
|
}
|
|
547
|
+
// SIGKILL is a request to the kernel, not something that has already happened, and a
|
|
548
|
+
// browser is not one process. While the parent is being reaped its children are still
|
|
549
|
+
// writing into the profile, so deleting the folder in the same breath loses the race:
|
|
550
|
+
// the sweep starts, a file appears behind it, the directory is not empty, and the
|
|
551
|
+
// profile outlives the run - which is the one thing `nothing it opened outlives the
|
|
552
|
+
// run` promises. It passed on macOS and on an idle Linux box and failed on a loaded CI
|
|
553
|
+
// runner for four releases, which is exactly how a race behaves.
|
|
554
|
+
for (let i = 0; i < 40 && stillThere(pid); i++) waitSync(10);
|
|
537
555
|
}
|
|
556
|
+
// Then remove it, and more than once. One rmSync is a snapshot; a file recreated a
|
|
557
|
+
// millisecond after the sweep began turns it into ENOTEMPTY and a leftover folder.
|
|
558
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
559
|
+
try {
|
|
560
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
561
|
+
return;
|
|
562
|
+
} catch {
|
|
563
|
+
waitSync(20);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
// A profile left in the temporary folder is untidy, not harmful — and it is exactly what
|
|
567
|
+
// `staysfixed browsers --clean` is for.
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Is that process still running? Signal 0 asks without sending anything.
|
|
572
|
+
* @param {number} pid
|
|
573
|
+
* @returns {boolean}
|
|
574
|
+
*/
|
|
575
|
+
function stillThere(pid) {
|
|
576
|
+
try {
|
|
577
|
+
process.kill(pid, 0);
|
|
578
|
+
return true;
|
|
579
|
+
} catch {
|
|
580
|
+
return false;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* A real sleep, in the one place nothing may be awaited: this runs inside
|
|
586
|
+
* `process.on('exit')`, where the event loop is already over and a promise never
|
|
587
|
+
* resolves. `Atomics.wait` is the only thing that actually pauses here.
|
|
588
|
+
*
|
|
589
|
+
* @param {number} ms
|
|
590
|
+
*/
|
|
591
|
+
function waitSync(ms) {
|
|
538
592
|
try {
|
|
539
|
-
|
|
593
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
540
594
|
} catch {
|
|
541
|
-
//
|
|
595
|
+
// No SharedArrayBuffer here. Falling straight through is still better than throwing
|
|
596
|
+
// out of a cleanup handler.
|
|
542
597
|
}
|
|
543
598
|
}
|
|
544
599
|
|
package/src/v2/check.js
CHANGED
|
@@ -95,7 +95,7 @@ const exec = promisify(execFile);
|
|
|
95
95
|
* quietly checked something else and found nothing", and those two read identically
|
|
96
96
|
* without this. It is only ever set when the run really did reach that surface.
|
|
97
97
|
*
|
|
98
|
-
* @typedef {Verdict & {blocked?: boolean, accounted?: import('./escalate.js').Accounting, target?: {surface: string, at: string|null}}} CheckOutcome
|
|
98
|
+
* @typedef {Verdict & {blocked?: boolean, comparedNothing?: 'no reference'|'no stored record'|null, accounted?: import('./escalate.js').Accounting, target?: {surface: string, at: string|null}}} CheckOutcome
|
|
99
99
|
*/
|
|
100
100
|
|
|
101
101
|
/**
|
|
@@ -315,8 +315,31 @@ export async function check(options = {}) {
|
|
|
315
315
|
// "nothing was walked" on a run that walked plenty.
|
|
316
316
|
await countTheDoors(verdict, project);
|
|
317
317
|
|
|
318
|
+
// THE GUARDS. `settle` takes the names so that a difference touching a bug somebody
|
|
319
|
+
// already had once is sealed and no agent may wave it through — and both call sites used
|
|
320
|
+
// to hand it nothing, so that class was empty on every run this tool has ever done. The
|
|
321
|
+
// one seal that exists because a person was burned before could never fire.
|
|
322
|
+
//
|
|
323
|
+
// Running them is version 1's job and needs its browser driver, which this path does not
|
|
324
|
+
// have. That is a real hole and it is now SAID rather than left silent: measured on
|
|
325
|
+
// 2026-08-30, a check on a project with a guard sitting in `.staysfixed/guards` printed
|
|
326
|
+
// the word "guard" exactly zero times. A tool built to catch silent breakage must not do
|
|
327
|
+
// nothing silently.
|
|
328
|
+
const named = await guardNames(project.root);
|
|
329
|
+
if (named.length > 0 && verdict.coverage) {
|
|
330
|
+
verdict.coverage.gaps = [
|
|
331
|
+
...(verdict.coverage.gaps ?? []),
|
|
332
|
+
{
|
|
333
|
+
what: `${named.length} guard${named.length === 1 ? '' : 's'} written against bugs that already happened once`,
|
|
334
|
+
why:
|
|
335
|
+
`They are sealed by name, so nothing touching one can be waved through quietly — but they were not RUN on this check. ` +
|
|
336
|
+
`\`staysfixed check --guards\` walks them. ${named.map((n) => `"${n}"`).join(', ')}`,
|
|
337
|
+
},
|
|
338
|
+
];
|
|
339
|
+
}
|
|
340
|
+
|
|
318
341
|
/** @type {CheckOutcome} */
|
|
319
|
-
const outcome = await settle(verdict, project.store, project.product);
|
|
342
|
+
const outcome = await settle(verdict, project.store, project.product, named);
|
|
320
343
|
// Only a run that really did reach the surface it was aimed at may say so. The
|
|
321
344
|
// confirmation is what lets a caller tell "it went there and found nothing" from
|
|
322
345
|
// "it checked something else and found nothing", and those are not the same answer.
|
|
@@ -432,6 +455,14 @@ async function settle(verdict, store, product, guards) {
|
|
|
432
455
|
// nothing came back different — and it is the exact sentence that would let a real
|
|
433
456
|
// regression through. It is not a pass. It is no answer at all.
|
|
434
457
|
const nothing = comparedNothing(verdict);
|
|
458
|
+
// Carried as a FIELD, not only folded into a sentence. Every other surface has to be
|
|
459
|
+
// able to ask this question without reading English: the MCP server rendered its own
|
|
460
|
+
// headline from the difference count alone, never looked at `ok`, and answered a
|
|
461
|
+
// machine "NOTHING UNACCOUNTED FOR. Everything that worked before still works" with
|
|
462
|
+
// `ok: true` and `isError: false` on a project that had nothing on record at all —
|
|
463
|
+
// while the terminal, on the same run, correctly said it was no answer. The agent is
|
|
464
|
+
// the reader that matters most here, and it was the one being told the untrue thing.
|
|
465
|
+
verdict.comparedNothing = nothing;
|
|
435
466
|
if (nothing) {
|
|
436
467
|
verdict.ok = false;
|
|
437
468
|
verdict.summary =
|
|
@@ -628,6 +659,92 @@ function comparedNothing(verdict) {
|
|
|
628
659
|
return nothingToCompare >= walked ? 'no stored record' : null;
|
|
629
660
|
}
|
|
630
661
|
|
|
662
|
+
/**
|
|
663
|
+
* The guards this project has, by name.
|
|
664
|
+
*
|
|
665
|
+
* Names only: sealing a difference by the guard it touches needs the name, and nothing on
|
|
666
|
+
* this path can run one. `loadGuards` wants only the folder, and it is the same reader
|
|
667
|
+
* version 1 uses, so a guard version 1 accepts is a guard this counts.
|
|
668
|
+
*
|
|
669
|
+
* @param {string} root
|
|
670
|
+
* @returns {Promise<string[]>}
|
|
671
|
+
*/
|
|
672
|
+
export async function guardNames(root) {
|
|
673
|
+
try {
|
|
674
|
+
const { loadGuards } = await import('../guard/load.js');
|
|
675
|
+
const guards = await loadGuards(/** @type {any} */ ({ paths: { guards: path.join(root, '.staysfixed', 'guards') } }));
|
|
676
|
+
return guards.map((g) => String(g?.name ?? '')).filter(Boolean);
|
|
677
|
+
} catch {
|
|
678
|
+
// A guards folder that will not load must never stop a check running. Version 1 says the
|
|
679
|
+
// same about the same folder, and a check that refuses is worse than one without a seal.
|
|
680
|
+
return [];
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Copies left behind by runs that never finished.
|
|
686
|
+
*
|
|
687
|
+
* A check copies the whole project into a scratch folder, and a run that is killed — Ctrl-C,
|
|
688
|
+
* a laptop closing, a CI job cancelled — never gets to delete it. Nothing else ever did
|
|
689
|
+
* either: measured on 2026-08-30, an ordinary machine had 777 MB of `staysfixed-check-*`
|
|
690
|
+
* sitting in the temporary folder, one copy of it 485 MB, and a later run added to the pile
|
|
691
|
+
* rather than clearing it. A tool that quietly fills somebody's disk is not one they keep.
|
|
692
|
+
*
|
|
693
|
+
* The rule is the one the browser sweep already uses: never touch something in use. A folder
|
|
694
|
+
* whose owner is still running is left completely alone, and one with no owner recorded is
|
|
695
|
+
* only taken once it is far older than any real run could be. A dead process id that has
|
|
696
|
+
* since been reused reads as "still running" and the folder survives, which is the safe way
|
|
697
|
+
* round to be wrong.
|
|
698
|
+
*
|
|
699
|
+
* @returns {Promise<void>}
|
|
700
|
+
*/
|
|
701
|
+
export async function sweepAbandonedScratch() {
|
|
702
|
+
const AN_HOUR = 60 * 60 * 1000;
|
|
703
|
+
const MOST_PER_RUN = 20;
|
|
704
|
+
let names = [];
|
|
705
|
+
try {
|
|
706
|
+
names = await fsp.readdir(os.tmpdir());
|
|
707
|
+
} catch {
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
let taken = 0;
|
|
711
|
+
for (const name of names) {
|
|
712
|
+
if (taken >= MOST_PER_RUN) break;
|
|
713
|
+
if (!name.startsWith('staysfixed-check-')) continue;
|
|
714
|
+
const dir = path.join(os.tmpdir(), name);
|
|
715
|
+
let abandoned = false;
|
|
716
|
+
try {
|
|
717
|
+
const owner = JSON.parse(await fsp.readFile(path.join(dir, 'owner.json'), 'utf8'));
|
|
718
|
+
abandoned = typeof owner?.pid !== 'number' || !processAlive(owner.pid);
|
|
719
|
+
} catch {
|
|
720
|
+
// No owner recorded: either an older copy or one that died before it could say. Age is
|
|
721
|
+
// all there is to go on, and an hour is well past the longest run this tool makes.
|
|
722
|
+
try {
|
|
723
|
+
abandoned = Date.now() - (await fsp.stat(dir)).mtimeMs > AN_HOUR;
|
|
724
|
+
} catch {
|
|
725
|
+
abandoned = false;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
if (!abandoned) continue;
|
|
729
|
+
await fsp.rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
730
|
+
taken += 1;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Is that process still running? Signal 0 asks without sending anything.
|
|
736
|
+
* @param {number} pid
|
|
737
|
+
* @returns {boolean}
|
|
738
|
+
*/
|
|
739
|
+
function processAlive(pid) {
|
|
740
|
+
try {
|
|
741
|
+
process.kill(pid, 0);
|
|
742
|
+
return true;
|
|
743
|
+
} catch {
|
|
744
|
+
return false;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
631
748
|
/**
|
|
632
749
|
* One gap's sentence, trimmed to something that fits inside another sentence.
|
|
633
750
|
* @param {string} what
|
|
@@ -1382,9 +1499,12 @@ async function openProject(options) {
|
|
|
1382
1499
|
storeTrouble.push(`The folder Stays Fixed keeps its records in could not be made: ${messageOf(e)}`);
|
|
1383
1500
|
}
|
|
1384
1501
|
|
|
1502
|
+
await sweepAbandonedScratch();
|
|
1385
1503
|
const scratch = await fsp.mkdtemp(path.join(os.tmpdir(), 'staysfixed-check-'));
|
|
1386
1504
|
const evidenceDir = path.join(scratch, 'evidence');
|
|
1387
1505
|
await fsp.mkdir(evidenceDir, { recursive: true });
|
|
1506
|
+
// Who this belongs to, so a later run can tell an abandoned copy from one in use.
|
|
1507
|
+
await fsp.writeFile(path.join(scratch, 'owner.json'), JSON.stringify({ pid: process.pid, at: new Date().toISOString() })).catch(() => {});
|
|
1388
1508
|
|
|
1389
1509
|
// Working out what there is to walk comes FIRST, before anything is asked of git. Somebody
|
|
1390
1510
|
// standing in a folder they have not set up yet should be told to run `init`, not told
|
|
@@ -1996,7 +2116,7 @@ async function readConfig(configFile) {
|
|
|
1996
2116
|
* @param {string} product
|
|
1997
2117
|
* @returns {Promise<BuildFingerprint>}
|
|
1998
2118
|
*/
|
|
1999
|
-
async function fingerprintWorkingTree(root, product) {
|
|
2119
|
+
export async function fingerprintWorkingTree(root, product) {
|
|
2000
2120
|
const sha = await git(root, ['rev-parse', 'HEAD']);
|
|
2001
2121
|
if (!sha) {
|
|
2002
2122
|
// REFUSING IS THE ONLY HONEST ANSWER HERE, and the alternative is the worst bug this
|
package/src/v2/cli.js
CHANGED
|
@@ -37,6 +37,7 @@ import { escalationBlock, escalationsFor, productFor, writeEscalations } from '.
|
|
|
37
37
|
// module is still being evaluated.
|
|
38
38
|
import { watchFlags } from '../cli/watch-flags.js';
|
|
39
39
|
import { INIT_COMMANDS } from './init.js';
|
|
40
|
+
import { whatWasNotChecked } from './check.js';
|
|
40
41
|
|
|
41
42
|
/**
|
|
42
43
|
* What comes back from a check. Everything that did not change never appears
|
|
@@ -167,16 +168,17 @@ export const V2_COMMANDS = {
|
|
|
167
168
|
|
|
168
169
|
doctor: {
|
|
169
170
|
summary: 'What this tool can and cannot check on this machine, and what would unlock more.',
|
|
170
|
-
usage: 'staysfixed doctor [--json] [--offline] [--fix]',
|
|
171
|
+
usage: 'staysfixed doctor [--json] [--machines] [--offline] [--fix]',
|
|
171
172
|
describe:
|
|
172
|
-
'Looks at this machine rather than at your project: what is installed, which other\nmachines it can already reach, what each of those lets it watch, and what exactly\nis in the way of the rest. It never asks you to set up something that already\nworks — everything it lists as missing failed a real check first.\n\n--json is the same answer as an object, and it is the first thing an agent\nshould call. --fix repairs the small things version 1 could repair.',
|
|
173
|
+
'Looks at this machine rather than at your project: what is installed, which other\nmachines it can already reach, what each of those lets it watch, and what exactly\nis in the way of the rest. It never asks you to set up something that already\nworks — everything it lists as missing failed a real check first.\n\n--json is the same answer as an object, and it is the first thing an agent\nshould call. --fix repairs the small things version 1 could repair.\n\nIt does not connect to the machines in your ssh config unless this project has a\ndesktop app that might need one, or you pass --machines. They are still listed.',
|
|
173
174
|
options: [
|
|
174
175
|
['--json', 'The whole answer as one JSON object. For agents.'],
|
|
176
|
+
['--machines', 'Also dial the machines in your ssh config, to find a runner for a desktop app.'],
|
|
175
177
|
['--offline', 'Do not dial any other machine. Faster, and reports no runners.'],
|
|
176
178
|
['--fix', 'Repair the small local things that can be repaired safely.'],
|
|
177
179
|
],
|
|
178
180
|
examples: ['staysfixed doctor', 'staysfixed doctor --json'],
|
|
179
|
-
spec: { booleans: ['json', 'offline', 'fix'] },
|
|
181
|
+
spec: { booleans: ['json', 'offline', 'fix', 'machines'] },
|
|
180
182
|
load: async () => ({ run: doctorRun }),
|
|
181
183
|
},
|
|
182
184
|
};
|
|
@@ -243,23 +245,26 @@ export async function run(ctx) {
|
|
|
243
245
|
// checks on the command line and then ships is told their build was "never
|
|
244
246
|
// checked", and the safeguard fires on the honest case instead of the careless
|
|
245
247
|
// one. The agent surface records its own; this is the command line's half.
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
ok: verdict.ok,
|
|
253
|
-
blocked: /** @type {any} */ (verdict).blocked === true,
|
|
254
|
-
findings: verdict.findings.length,
|
|
255
|
-
by: 'staysfixed check',
|
|
256
|
-
});
|
|
257
|
-
} catch {
|
|
258
|
-
// Never let bookkeeping cost somebody the result they came for.
|
|
259
|
-
}
|
|
248
|
+
// NOT recorded again here. The engine writes this line itself, for every surface, inside
|
|
249
|
+
// `rememberCheck` — and with more in it than this ever had: what was waived, what was
|
|
250
|
+
// sealed, how much went unaccounted for. This block was added on the belief that only the
|
|
251
|
+
// agent surface recorded, so every command-line check wrote TWO near-identical rows two
|
|
252
|
+
// milliseconds apart. Measured 2026-08-30: eight rows for four checks, and `ship` reads
|
|
253
|
+
// this log to decide whether the last check was clean.
|
|
260
254
|
|
|
261
255
|
if (asJson) {
|
|
262
|
-
|
|
256
|
+
// The README promises these as fields of their own — "a number an agent can read"
|
|
257
|
+
// rather than a sentence it has to parse — and only the MCP reply had them. So a human
|
|
258
|
+
// asking for JSON on the command line got a strictly worse answer about what was NOT
|
|
259
|
+
// checked than an agent asking over MCP, about the very same run.
|
|
260
|
+
const coverage = verdict.coverage ?? null;
|
|
261
|
+
process.stdout.write(
|
|
262
|
+
JSON.stringify({
|
|
263
|
+
...verdict,
|
|
264
|
+
notChecked: whatWasNotChecked(coverage),
|
|
265
|
+
doorsNeverOpened: Math.max(0, (coverage?.doorsKnown ?? 0) - (coverage?.doorsWalked ?? 0)),
|
|
266
|
+
}) + '\n',
|
|
267
|
+
);
|
|
263
268
|
} else {
|
|
264
269
|
report(verdict);
|
|
265
270
|
}
|
package/src/v2/cluster.js
CHANGED
|
@@ -186,20 +186,75 @@ export function findRenames(differences) {
|
|
|
186
186
|
|
|
187
187
|
/** @type {Map<Difference, {from: string, to: string}>} */
|
|
188
188
|
const found = new Map();
|
|
189
|
+
/** @type {{was: string, now: string, from: string, to: string}[]} */
|
|
190
|
+
const moves = [];
|
|
189
191
|
for (const place of places.values()) {
|
|
190
192
|
if (place.gone.length !== 1 || place.came.length !== 1) continue;
|
|
191
193
|
const gone = place.gone[0];
|
|
192
194
|
const came = place.came[0];
|
|
193
|
-
if (!sameValue(gone.reference, came.candidate)) continue;
|
|
194
195
|
const from = leafOf(gone.path);
|
|
195
196
|
const to = leafOf(came.path);
|
|
196
197
|
if (from === to) continue;
|
|
198
|
+
// "The values must match" is right for a thing addressed by its position, and wrong for
|
|
199
|
+
// one addressed by its own words. A heading lives at `heading:Nine Bakers` and its value
|
|
200
|
+
// reads `a heading called "Nine Bakers"` — rename it and BOTH move, so the values never
|
|
201
|
+
// match and the rename was never spotted. Measured 2026-08-30: renaming one heading on a
|
|
202
|
+
// page came back as five separate findings, one thing vanishing and a different thing
|
|
203
|
+
// appearing, with nothing anywhere saying "renamed". So a value that changed in exactly
|
|
204
|
+
// the same way the name did counts as the same value. Anything else is still two edits
|
|
205
|
+
// that happened to land side by side, which is what this test exists to keep out.
|
|
206
|
+
if (!sameValue(gone.reference, came.candidate) && !movedWithItsName(gone.reference, came.candidate, from, to)) continue;
|
|
197
207
|
found.set(gone, { from, to });
|
|
198
208
|
found.set(came, { from, to });
|
|
209
|
+
moves.push({ was: gone.path, now: came.path, from, to });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// A rename takes its children with it. Everything under the old address goes away and the
|
|
213
|
+
// same things arrive under the new one — true, and not a second piece of news. Renaming one
|
|
214
|
+
// heading on a page reported the heading AND the two halves of its own `level`, so one edit
|
|
215
|
+
// a person would describe in four words arrived as four findings. Anything that moved with
|
|
216
|
+
// it, unchanged, belongs to the rename that moved it.
|
|
217
|
+
for (const move of moves) {
|
|
218
|
+
for (const gone of differences) {
|
|
219
|
+
if (gone.kind !== 'vanished' || found.has(gone)) continue;
|
|
220
|
+
if (!gone.path.startsWith(`${move.was}.`)) continue;
|
|
221
|
+
const wanted = move.now + gone.path.slice(move.was.length);
|
|
222
|
+
const came = differences.find(
|
|
223
|
+
(d) => d.kind === 'appeared' && !found.has(d) && d.path === wanted && d.channel === gone.channel && sameValue(gone.reference, d.candidate),
|
|
224
|
+
);
|
|
225
|
+
if (!came) continue;
|
|
226
|
+
found.set(gone, { from: move.from, to: move.to });
|
|
227
|
+
found.set(came, { from: move.from, to: move.to });
|
|
228
|
+
}
|
|
199
229
|
}
|
|
200
230
|
return found;
|
|
201
231
|
}
|
|
202
232
|
|
|
233
|
+
/**
|
|
234
|
+
* Did the value change in exactly the way the name did?
|
|
235
|
+
*
|
|
236
|
+
* Only for two addresses naming the same KIND of thing — `heading:X` and `heading:Y`, never
|
|
237
|
+
* `heading:X` and `button:Y` — because the part before the colon is what the thing IS, and a
|
|
238
|
+
* heading becoming a button is not a rename.
|
|
239
|
+
*
|
|
240
|
+
* @param {unknown} before
|
|
241
|
+
* @param {unknown} after
|
|
242
|
+
* @param {string} from
|
|
243
|
+
* @param {string} to
|
|
244
|
+
* @returns {boolean}
|
|
245
|
+
*/
|
|
246
|
+
function movedWithItsName(before, after, from, to) {
|
|
247
|
+
if (typeof before !== 'string' || typeof after !== 'string') return false;
|
|
248
|
+
const wasNamed = /^([^:]+):(.+)$/.exec(from);
|
|
249
|
+
const nowNamed = /^([^:]+):(.+)$/.exec(to);
|
|
250
|
+
if (!wasNamed || !nowNamed) return false;
|
|
251
|
+
if (wasNamed[1] !== nowNamed[1]) return false;
|
|
252
|
+
const was = wasNamed[2];
|
|
253
|
+
const now = nowNamed[2];
|
|
254
|
+
if (!was || was === now) return false;
|
|
255
|
+
return before.split(was).join(now) === after;
|
|
256
|
+
}
|
|
257
|
+
|
|
203
258
|
/**
|
|
204
259
|
* The grouping key: channel, shape of change, what the address ends in, and the
|
|
205
260
|
* move from one value to another.
|