staysfixed 0.8.0 → 0.9.1
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 +131 -0
- package/README.md +2 -2
- package/docs/getting-started.md +1 -1
- package/docs/mcp.md +13 -0
- package/docs/running-it-in-ci.md +123 -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/name.js +41 -1
- 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 +135 -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 +29 -6
- package/src/v2/mcp/tools.js +88 -6
- package/src/v2/reference.js +120 -14
- package/src/v2/sealed.js +7 -1
- package/src/v2/ship.js +58 -0
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
|
|
@@ -1796,6 +1916,18 @@ async function gatherJourneys({ root, config, options }) {
|
|
|
1796
1916
|
|
|
1797
1917
|
const named =
|
|
1798
1918
|
options.journeys && !['code', 'config', 'suite'].includes(options.journeys) ? options.journeys : null;
|
|
1919
|
+
// `recorded` is a word this tool knows and `check --help` offers it — it is simply not
|
|
1920
|
+
// wired into a run yet. The MCP surface says exactly that; the command line fell through to
|
|
1921
|
+
// the branch above, treated the word as a FILE PATH, and answered that a file called
|
|
1922
|
+
// "recorded" was missing. The same question has to get the same answer on both.
|
|
1923
|
+
if (options.journeys === 'recorded') {
|
|
1924
|
+
throw new StaysFixedError(
|
|
1925
|
+
'Replaying a recorded session is written and not wired into a run yet, so nothing was checked.',
|
|
1926
|
+
{
|
|
1927
|
+
hint: 'Leave --journeys out to use the steps each adapter reads from your source, pass `suite` to walk your own test suite, or pass the path to a journeys file.',
|
|
1928
|
+
},
|
|
1929
|
+
);
|
|
1930
|
+
}
|
|
1799
1931
|
if (named) journeys.push(...(await readJourneyFile(path.resolve(root, named))));
|
|
1800
1932
|
|
|
1801
1933
|
// The project's own test suite, when somebody asked for it in those words and never
|
|
@@ -1996,7 +2128,7 @@ async function readConfig(configFile) {
|
|
|
1996
2128
|
* @param {string} product
|
|
1997
2129
|
* @returns {Promise<BuildFingerprint>}
|
|
1998
2130
|
*/
|
|
1999
|
-
async function fingerprintWorkingTree(root, product) {
|
|
2131
|
+
export async function fingerprintWorkingTree(root, product) {
|
|
2000
2132
|
const sha = await git(root, ['rev-parse', 'HEAD']);
|
|
2001
2133
|
if (!sha) {
|
|
2002
2134
|
// 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.
|
package/src/v2/doctor.js
CHANGED
|
@@ -196,7 +196,7 @@ export const CHANNELS = [
|
|
|
196
196
|
* should be able to read it once and know what to call, what it will get back,
|
|
197
197
|
* and what it must not bother asking for here.
|
|
198
198
|
*
|
|
199
|
-
* @param {{cwd?: string, configFile?: string, offline?: boolean}} [opts]
|
|
199
|
+
* @param {{cwd?: string, configFile?: string, offline?: boolean, machines?: boolean}} [opts]
|
|
200
200
|
* @returns {Promise<Capabilities>}
|
|
201
201
|
*/
|
|
202
202
|
export async function capabilities(opts = {}) {
|
|
@@ -218,7 +218,12 @@ export async function capabilities(opts = {}) {
|
|
|
218
218
|
|
|
219
219
|
const [tools, hosts, repo, reference, drivers, phones, asked] = await Promise.all([
|
|
220
220
|
findTools(cwd, browsers),
|
|
221
|
-
|
|
221
|
+
// Only a product that could actually run somewhere else is a reason to go looking for
|
|
222
|
+
// somewhere else. A website or a command-line tool never needs a Windows desktop, and
|
|
223
|
+
// the hosts list feeds exactly one surface: that one.
|
|
224
|
+
offline
|
|
225
|
+
? Promise.resolve(/** @type {HostReport[]} */ ([]))
|
|
226
|
+
: reachableHosts({ dial: opts.machines === true || desktopApp !== null }),
|
|
222
227
|
isRepo(root).catch(() => false),
|
|
223
228
|
findReference(root),
|
|
224
229
|
whatThisCopyCanDrive(),
|
|
@@ -249,7 +254,7 @@ export async function capabilities(opts = {}) {
|
|
|
249
254
|
},
|
|
250
255
|
surfaces,
|
|
251
256
|
drivers,
|
|
252
|
-
covers: whatThisRunActuallyCovers(surfaces),
|
|
257
|
+
covers: whatThisRunActuallyCovers(surfaces, configFile !== null),
|
|
253
258
|
browsers: {
|
|
254
259
|
willOpen: browsers.chosen,
|
|
255
260
|
borrowingYourOwn: browsers.borrowingHis,
|
|
@@ -1175,13 +1180,40 @@ function androidSdkTool(folder, name) {
|
|
|
1175
1180
|
* answers is a runner the tool already has, and it must never appear in the
|
|
1176
1181
|
* result as something to go and set up.
|
|
1177
1182
|
*
|
|
1183
|
+
* @param {{dial?: boolean}} [opts]
|
|
1178
1184
|
* @returns {Promise<HostReport[]>}
|
|
1179
1185
|
*/
|
|
1180
|
-
export async function reachableHosts() {
|
|
1186
|
+
export async function reachableHosts(opts = {}) {
|
|
1181
1187
|
if (!onPath('ssh')) return [];
|
|
1182
1188
|
const names = await sshConfigHosts();
|
|
1183
1189
|
if (names.length === 0) return [];
|
|
1184
1190
|
|
|
1191
|
+
// READING the ssh config is free and tells nobody anything. DIALLING is neither, and it
|
|
1192
|
+
// is not something this tool may do to somebody who has just installed it.
|
|
1193
|
+
//
|
|
1194
|
+
// The first command a stranger runs is `doctor`. On a brand-new scratch project with no
|
|
1195
|
+
// settings file and nothing that could possibly need a second machine, this opened ssh
|
|
1196
|
+
// connections to every host in their `~/.ssh/config` and ran a command on each — measured
|
|
1197
|
+
// on 2026-08-30: ten hosts configured, connections out within seconds of the first run,
|
|
1198
|
+
// nothing said before or after. Those are production servers in a lot of people's configs,
|
|
1199
|
+
// and in a lot of workplaces that alone is a policy breach. `--offline` existed, but a way
|
|
1200
|
+
// out you only learn about afterwards is not consent.
|
|
1201
|
+
//
|
|
1202
|
+
// So it is asked for now rather than assumed, and the machines are still NAMED either way,
|
|
1203
|
+
// because a machine quietly left out of the answer is the same bug as a folder quietly
|
|
1204
|
+
// skipped while reading source: the list looks complete and the runner somebody needed is
|
|
1205
|
+
// simply not in it.
|
|
1206
|
+
if (opts.dial !== true) {
|
|
1207
|
+
return names.map(
|
|
1208
|
+
(name) =>
|
|
1209
|
+
/** @type {HostReport} */ ({
|
|
1210
|
+
name,
|
|
1211
|
+
reachable: false,
|
|
1212
|
+
how: 'named in your ssh config and deliberately NOT dialled. Nothing here needs a second machine, and this tool does not connect to yours unasked. `staysfixed doctor --machines` checks them.',
|
|
1213
|
+
})
|
|
1214
|
+
);
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1185
1217
|
const dialled = await Promise.all(names.slice(0, MAX_HOSTS).map((name) => describeHost(name)));
|
|
1186
1218
|
// Anything past the cap is NAMED rather than dropped. A machine quietly left out of
|
|
1187
1219
|
// this list is the same shape of bug as a folder quietly skipped while reading source:
|
|
@@ -1776,6 +1808,9 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
|
|
|
1776
1808
|
* @property {{name: string, why: string}[]} partly Looked at, but not completely, and why.
|
|
1777
1809
|
* @property {{name: string, why: string, whoFixes: SurfaceState}[]} notCovered
|
|
1778
1810
|
* @property {boolean} everything True only when nothing at all is left out.
|
|
1811
|
+
* @property {boolean} [canRunHere]
|
|
1812
|
+
* False when nothing is set up in this folder, so a check cannot run here at all
|
|
1813
|
+
* whatever this machine could otherwise drive.
|
|
1779
1814
|
*/
|
|
1780
1815
|
|
|
1781
1816
|
/**
|
|
@@ -1787,9 +1822,10 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
|
|
|
1787
1822
|
* covers your website; your iPhone app is not being checked, and here is why.
|
|
1788
1823
|
*
|
|
1789
1824
|
* @param {SurfaceReport[]} surfaces
|
|
1825
|
+
* @param {boolean} setUpHere Whether a check can actually run in this folder at all.
|
|
1790
1826
|
* @returns {Covers}
|
|
1791
1827
|
*/
|
|
1792
|
-
function whatThisRunActuallyCovers(surfaces) {
|
|
1828
|
+
function whatThisRunActuallyCovers(surfaces, setUpHere = true) {
|
|
1793
1829
|
// Three buckets, not two. Folding "partly" into "covered" is exactly the
|
|
1794
1830
|
// over-claim this function exists to stop: an iPhone app whose screens cannot
|
|
1795
1831
|
// be read is not a covered iPhone app.
|
|
@@ -1813,7 +1849,23 @@ function whatThisRunActuallyCovers(surfaces) {
|
|
|
1813
1849
|
|
|
1814
1850
|
/** @type {string[]} */
|
|
1815
1851
|
const parts = [];
|
|
1816
|
-
|
|
1852
|
+
// What this MACHINE can drive and what a check in THIS FOLDER would cover are two
|
|
1853
|
+
// different questions, and only one of them was being answered. In an empty folder — no
|
|
1854
|
+
// settings, no code — this said "A check here covers command-line tools and libraries and
|
|
1855
|
+
// web apps and sites in full" and doctor exited 0, while `check` in that same folder
|
|
1856
|
+
// refused to run at all: "No Stays Fixed config found here, so there is nothing to check."
|
|
1857
|
+
// Measured 2026-08-30. `doctor --json` is the first call an agent is told to make, which
|
|
1858
|
+
// is the worst place there is for a sentence with "here" in it to mean somewhere else.
|
|
1859
|
+
//
|
|
1860
|
+
// Everything below still says what it said — a reader needs the whole picture either way —
|
|
1861
|
+
// it is just no longer written as though a check could run.
|
|
1862
|
+
if (!setUpHere) {
|
|
1863
|
+
out.canRunHere = false;
|
|
1864
|
+
parts.push('Nothing is set up in this folder, so a check cannot run here at all and would cover nothing. Run `staysfixed init` first.');
|
|
1865
|
+
parts.push(full.length > 0 ? `Once it is set up, this machine could cover ${plainList(out.covered)} in full.` : 'Even set up, this machine could cover nothing in full.');
|
|
1866
|
+
} else {
|
|
1867
|
+
parts.push(full.length > 0 ? `A check here covers ${plainList(out.covered)} in full.` : 'A check here covers nothing in full.');
|
|
1868
|
+
}
|
|
1817
1869
|
if (some.length > 0) parts.push(`It covers ${plainList(some.map((s) => s.name))} only partly — read the summary for each before treating a clean result as proof.`);
|
|
1818
1870
|
if (missing.length > 0) {
|
|
1819
1871
|
parts.push(`It does NOT check ${plainList(missing.map((s) => s.name))} at all, so a clean result says nothing whatever about ${missing.length === 1 ? 'that' : 'those'}.`);
|
|
@@ -1888,8 +1940,15 @@ function nextSteps(surfaces, reference, repo) {
|
|
|
1888
1940
|
steps.push({
|
|
1889
1941
|
what: 'record a reference',
|
|
1890
1942
|
why: 'Until one build has been recorded there is nothing to compare a new one against, and a clean result would mean nothing.',
|
|
1891
|
-
|
|
1892
|
-
|
|
1943
|
+
// Both halves of this were wrong, and they were wrong in the direction that matters.
|
|
1944
|
+
// `check --paired` cannot record a reference — run it twice on a fresh project and
|
|
1945
|
+
// both runs answer that there is no build on record, with the reference id still
|
|
1946
|
+
// empty — so anybody following this went round in a circle. And `automatic: true` told
|
|
1947
|
+
// the agent this was its to do, when the one rule underneath this whole product is
|
|
1948
|
+
// that only shipping cuts a reference and no agent may bless its own work. Saying an
|
|
1949
|
+
// agent can do the single thing it must never do is worse than saying nothing.
|
|
1950
|
+
fix: 'staysfixed ship (only shipping records what "working" means — no agent may cut that reference)',
|
|
1951
|
+
automatic: false,
|
|
1893
1952
|
unlocks: 'Every check after this one has something to compare against, so "nothing changed" starts meaning something.',
|
|
1894
1953
|
});
|
|
1895
1954
|
}
|
|
@@ -2072,7 +2131,7 @@ export function describeCapabilities(caps) {
|
|
|
2072
2131
|
* @returns {Promise<number>}
|
|
2073
2132
|
*/
|
|
2074
2133
|
export async function run(ctx) {
|
|
2075
|
-
const caps = await capabilities({ cwd: ctx.cwd, configFile: ctx.configFile, offline: ctx.bool('offline') });
|
|
2134
|
+
const caps = await capabilities({ cwd: ctx.cwd, configFile: ctx.configFile, offline: ctx.bool('offline'), machines: ctx.bool('machines') });
|
|
2076
2135
|
|
|
2077
2136
|
if (ctx.bool('json')) {
|
|
2078
2137
|
// Nothing but the object may reach standard output. Doctor is the first call
|
package/src/v2/escalate.js
CHANGED
|
@@ -517,7 +517,11 @@ function buildEscalations(product, record, verdict) {
|
|
|
517
517
|
kind: 'no-reference',
|
|
518
518
|
what: `There is no build of ${product} on record as working yet, so this run had nothing to compare against.`,
|
|
519
519
|
why: 'Only you can say what "working" means, and you say it by shipping — no agent may cut that reference.',
|
|
520
|
-
|
|
520
|
+
// The order is said out loud because leaving it out sent people round a circle: `ship`
|
|
521
|
+
// on a build nothing has watched answers "run a check before the next release", and
|
|
522
|
+
// this line answered "you say it by shipping". Both are true and neither says which
|
|
523
|
+
// comes first. A check watches the build; shipping then blesses what was watched.
|
|
524
|
+
todo: 'Run `staysfixed check` once so there is a build to bless, then `staysfixed ship`. From the next change onwards it is automatic and you will not see this again.',
|
|
521
525
|
});
|
|
522
526
|
}
|
|
523
527
|
|
package/src/v2/init.js
CHANGED
|
@@ -824,7 +824,10 @@ function sortNeeds(readiness, project, machine) {
|
|
|
824
824
|
what: 'one build on record as working',
|
|
825
825
|
why: 'Until one build has been recorded there is nothing to compare a new one against, and a clean result would mean nothing at all.',
|
|
826
826
|
unlocks: 'every check from then on',
|
|
827
|
-
|
|
827
|
+
// `check --paired` was named here first, and it does not do this: run it twice on a
|
|
828
|
+
// fresh project and both runs answer "there is no build on record as working". Only
|
|
829
|
+
// shipping cuts a reference, on purpose — no agent may bless its own work.
|
|
830
|
+
fix: 'staysfixed ship (only shipping records what "working" means — run it once at the end of your release)',
|
|
828
831
|
who: 'the agent',
|
|
829
832
|
});
|
|
830
833
|
}
|
|
@@ -1148,7 +1151,10 @@ export function configText(project) {
|
|
|
1148
1151
|
w(' // "working" means, so the name is how two of them are told apart.');
|
|
1149
1152
|
if (project.products.length > 1) {
|
|
1150
1153
|
w(' // This repository makes more than one thing. A check covers whichever of them the');
|
|
1151
|
-
|
|
1154
|
+
// `--product` is not an option on `check` and never has been. Naming it here sent people
|
|
1155
|
+
// to a flag the CLI rejects, in the settings file the tool itself wrote for them.
|
|
1156
|
+
w(' // settings below describe. To check one of the others, run `staysfixed check` from');
|
|
1157
|
+
w(' // inside that package, or point at its settings with `--config <file>`.');
|
|
1152
1158
|
}
|
|
1153
1159
|
w(` product: ${JSON.stringify(project.name)},`);
|
|
1154
1160
|
w('');
|
|
@@ -1342,7 +1348,12 @@ export function configText(project) {
|
|
|
1342
1348
|
} else if (project.pages.length > 0) {
|
|
1343
1349
|
w(`${webOn}// ${project.pages.length} page address${project.pages.length === 1 ? '' : 'es'} are read out of your folder names automatically — nothing to list here.`);
|
|
1344
1350
|
w(`${webOn}// Add a screen only for something a walk has to DO rather than just open:`);
|
|
1345
|
-
|
|
1351
|
+
// `fill:` and `with:` are not words this tool knows — the verb is `type:` and the value
|
|
1352
|
+
// is `text:`. An unknown key used to be skipped in silence, so this example, handed to
|
|
1353
|
+
// every stranger with a sign-in, filled nothing, clicked Sign in on an empty form, and
|
|
1354
|
+
// then photographed the login page for every screen behind the wall while reporting a
|
|
1355
|
+
// clean run. The example that teaches the vocabulary has to be IN the vocabulary.
|
|
1356
|
+
w(`${webOn}// screens: [{ name: 'signing in', url: '/login', steps: [{ type: '#email', text: 'a@b.c' }, { type: '#password', text: 'secret' }, { click: 'Sign in' }] }],`);
|
|
1346
1357
|
} else {
|
|
1347
1358
|
w(`${webOn}// screens: [{ name: 'the front page', url: '/' }],`);
|
|
1348
1359
|
}
|
|
@@ -1568,7 +1579,14 @@ function whatItCovers(readiness) {
|
|
|
1568
1579
|
else parts.push('Right now a check here covers nothing in full.');
|
|
1569
1580
|
if (waiting.length > 0) parts.push(`${plainList(waiting, true)} ${waiting.length === 1 ? 'is' : 'are'} not covered yet, and the list below says exactly what is in the way and who has to do it.`);
|
|
1570
1581
|
if (notCovered.length > 0) parts.push(`${plainList(notCovered, true)} ${notCovered.length === 1 ? 'is' : 'are'} not checked at all, so a clean result says nothing whatever about ${notCovered.length === 1 ? 'it' : 'them'}.`);
|
|
1571
|
-
|
|
1582
|
+
// "on this machine", because that is the only thing this sentence knows. It is built from
|
|
1583
|
+
// SURFACES — which kinds of product can be watched here — and says nothing whatever about
|
|
1584
|
+
// how much of this project a run actually walks. Unqualified, it read as a coverage
|
|
1585
|
+
// promise, and `getting-started` tells the agent to repeat it to the person: measured
|
|
1586
|
+
// 2026-08-30 on a project the same run reported as five of six doors never opened.
|
|
1587
|
+
if (partly.length === 0 && notCovered.length === 0 && covered.length > 0) {
|
|
1588
|
+
parts.push('Nothing is being left out on this machine — though how much of the project a run actually walks is a separate question, and every check answers it.');
|
|
1589
|
+
}
|
|
1572
1590
|
|
|
1573
1591
|
// The hole that never closes, named with the language that causes it. "Not covered yet"
|
|
1574
1592
|
// reads as a job somebody will get to; this one is nobody's job and saying so is the
|
|
@@ -1600,9 +1618,14 @@ function nextCommands(readiness, project) {
|
|
|
1600
1618
|
if (reachable.length > 0) {
|
|
1601
1619
|
next.push({
|
|
1602
1620
|
command: 'staysfixed check --paired',
|
|
1621
|
+
// It does NOT record what "working" means, and saying so here sent everybody round a
|
|
1622
|
+
// loop: run it, be told there is no build on record, run it again, be told the same
|
|
1623
|
+
// thing. Only `ship` cuts a reference — that is the rule the whole product rests on,
|
|
1624
|
+
// because an agent that can bless its own work is not a safety net. So this says what
|
|
1625
|
+
// the run actually does, and `ship` below says what only it can do.
|
|
1603
1626
|
what: reachable.some((r) => r.state === 'ready')
|
|
1604
|
-
? 'The first real run. It
|
|
1605
|
-
: 'The first real run. Nothing here is fully set up yet, so it
|
|
1627
|
+
? 'The first real run. It walks everything and shows you what it sees. It cannot record what "working" means — only shipping does that.'
|
|
1628
|
+
: 'The first real run. Nothing here is fully set up yet, so it walks what it can reach and says plainly what it left out — which is more useful than waiting.',
|
|
1606
1629
|
});
|
|
1607
1630
|
}
|
|
1608
1631
|
if (project.tests.files > 0) {
|