staysfixed 0.12.0 → 0.14.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 +85 -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 +372 -35
- 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 +91 -12
- package/src/v2/init.js +577 -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/src/v2/watch/dialogs.js +410 -0
- package/src/v2/watch/panel.js +27 -1
package/src/v2/check.js
CHANGED
|
@@ -32,7 +32,7 @@ import { createHash } from 'node:crypto';
|
|
|
32
32
|
import { promisify } from 'node:util';
|
|
33
33
|
|
|
34
34
|
import { StaysFixedError, messageOf } from '../core/errors.js';
|
|
35
|
-
import { warn, detail, shortPath } from '../core/log.js';
|
|
35
|
+
import { say, warn, detail, shortPath } from '../core/log.js';
|
|
36
36
|
import { findConfigFile, rootForConfig } from '../core/paths.js';
|
|
37
37
|
import { sha256 } from '../core/hash.js';
|
|
38
38
|
|
|
@@ -46,11 +46,13 @@ import { whatChanged, NOT_THE_TOOLS_OWN_FOLDER } from './rank.js';
|
|
|
46
46
|
|
|
47
47
|
import { attachWatcher, watchOptionsFrom } from './watch/index.js';
|
|
48
48
|
import { guardTheScreen, describeGuard } from './watch/focus.js';
|
|
49
|
+
import { watchForDialogs, describeDialogs } from './watch/dialogs.js';
|
|
49
50
|
import {
|
|
50
51
|
isOffScreen, moveWindowByPid, offScreen, windowBoundsByPid, withoutTakingTheScreen,
|
|
51
52
|
} from './watch/window.js';
|
|
52
53
|
import { onAppStarted, stillOpen } from './adapters/isolate.js';
|
|
53
54
|
|
|
55
|
+
import { isAnAnswerJourney, journeysFromExports, splitAnswerSheet } from './journeys/from-exports.js';
|
|
54
56
|
import { processAdapter } from './adapters/process.js';
|
|
55
57
|
import { sourceAdapter } from './adapters/source.js';
|
|
56
58
|
import { httpAdapter } from './adapters/http.js';
|
|
@@ -582,6 +584,7 @@ async function countTheDoors(verdict, project) {
|
|
|
582
584
|
const { ledger, toCoverage } = await import('./coverage.js');
|
|
583
585
|
const led = await ledger(project.store, project.product, {
|
|
584
586
|
root: project.root,
|
|
587
|
+
folders: project.sourceFolders,
|
|
585
588
|
journeys: project.journeys,
|
|
586
589
|
builds: [project.candidate.id],
|
|
587
590
|
});
|
|
@@ -1085,28 +1088,34 @@ async function stopWhateverIsStillRunningIn(dir) {
|
|
|
1085
1088
|
function noScratchFolder(e) {
|
|
1086
1089
|
const tmp = os.tmpdir();
|
|
1087
1090
|
const code = String(/** @type {any} */ (e)?.code ?? '');
|
|
1091
|
+
// The setting is not called the same thing everywhere, and naming the wrong one is advice
|
|
1092
|
+
// that cannot be followed. Windows reads TEMP and TMP; everything else reads TMPDIR. All
|
|
1093
|
+
// three sentences below said "TMPDIR" on every machine, so on Windows the only instruction
|
|
1094
|
+
// a stuck person was given named a setting their operating system does not read. Measured
|
|
1095
|
+
// on a real Windows 11 machine, 2026-08-31.
|
|
1096
|
+
const setting = process.platform === 'win32' ? 'TEMP' : 'TMPDIR';
|
|
1097
|
+
const trailing = process.platform === 'win32' ? /[\\/]$/ : /\/$/;
|
|
1088
1098
|
// Worth naming only when a setting in this shell is what chose the folder. On a machine
|
|
1089
|
-
// where nothing set it, saying
|
|
1099
|
+
// where nothing set it, saying the name sends somebody looking for a setting they have not
|
|
1090
1100
|
// got, and the folder is the operating system's own.
|
|
1091
|
-
const
|
|
1092
|
-
|
|
1093
|
-
: '';
|
|
1101
|
+
const chosenHere = (process.env[setting] ?? '').replace(trailing, '') === tmp.replace(trailing, '');
|
|
1102
|
+
const yours = chosenHere ? ` That folder is whatever ${setting} is set to in this shell.` : '';
|
|
1094
1103
|
/** @type {{why: string, hint: string}} */
|
|
1095
1104
|
const said =
|
|
1096
1105
|
code === 'ENOENT'
|
|
1097
1106
|
? {
|
|
1098
1107
|
why: `There is no folder at ${tmp}, so there was nowhere to put it.`,
|
|
1099
|
-
hint: `Make that folder, or point
|
|
1108
|
+
hint: `Make that folder, or point ${setting} at one that exists — or unset ${setting} to fall back to this machine's own — and run the check again.${yours}`,
|
|
1100
1109
|
}
|
|
1101
1110
|
: code === 'EACCES' || code === 'EPERM'
|
|
1102
1111
|
? {
|
|
1103
1112
|
why: `${tmp} is there, but this user is not allowed to write in it.`,
|
|
1104
|
-
hint: `Give yourself write access to that folder, or point
|
|
1113
|
+
hint: `Give yourself write access to that folder, or point ${setting} at one you can write to, and run the check again.${yours}`,
|
|
1105
1114
|
}
|
|
1106
1115
|
: code === 'EROFS'
|
|
1107
1116
|
? {
|
|
1108
1117
|
why: `${tmp} is on a disk that is mounted read-only, so nothing can be written there at all.`,
|
|
1109
|
-
hint: `Point
|
|
1118
|
+
hint: `Point ${setting} at a folder on a disk that takes writes and run the check again.${yours}`,
|
|
1110
1119
|
}
|
|
1111
1120
|
: code === 'ENOSPC'
|
|
1112
1121
|
? {
|
|
@@ -1214,7 +1223,7 @@ function blocked(options, e, storeTrouble) {
|
|
|
1214
1223
|
* for itself.
|
|
1215
1224
|
*
|
|
1216
1225
|
* @param {CheckOptions & {finding?: string, revert?: string[]}} options
|
|
1217
|
-
* @returns {Promise<{gone: boolean, detail?: string, verdict?: string, escalates?: boolean}>}
|
|
1226
|
+
* @returns {Promise<{gone: boolean, detail?: string, verdict?: string, escalates?: boolean, reran?: number, checked?: number}>}
|
|
1218
1227
|
*/
|
|
1219
1228
|
export async function prove(options = {}) {
|
|
1220
1229
|
const root = projectRootFor(options);
|
|
@@ -1225,6 +1234,12 @@ export async function prove(options = {}) {
|
|
|
1225
1234
|
if (!finding) {
|
|
1226
1235
|
return {
|
|
1227
1236
|
gone: false,
|
|
1237
|
+
// Said out loud rather than left absent. An absent verdict resolves to "could not test"
|
|
1238
|
+
// at the surface, deliberately, but a reader of this function should not have to know
|
|
1239
|
+
// that to see which of the three answers this is.
|
|
1240
|
+
verdict: 'could not test',
|
|
1241
|
+
reran: 0,
|
|
1242
|
+
checked: 0,
|
|
1228
1243
|
detail: `The last check has no finding called "${options.finding ?? ''}". Run a check first, then prove one of the ids it gives you.`,
|
|
1229
1244
|
};
|
|
1230
1245
|
}
|
|
@@ -1240,6 +1255,27 @@ export async function prove(options = {}) {
|
|
|
1240
1255
|
? { ...changed, hunks: changed.hunks.filter((h) => wanted.some((w) => h.file === w || h.file.startsWith(`${w}/`))) }
|
|
1241
1256
|
: changed;
|
|
1242
1257
|
|
|
1258
|
+
// A file named for reverting that is not among the changes is NOT "nothing has changed".
|
|
1259
|
+
// With an empty narrowing, proveCause said "Nothing has changed between the build you were
|
|
1260
|
+
// happy with and this one" — about a working tree with two edited files in it — which
|
|
1261
|
+
// sends somebody to debug their tree instead of the filename they just typed. Measured
|
|
1262
|
+
// 2026-08-31.
|
|
1263
|
+
if (wanted.length > 0 && narrowed.hunks.length === 0 && changed.hunks.length > 0) {
|
|
1264
|
+
const names = [...new Set(changed.hunks.map((h) => h.file))];
|
|
1265
|
+
return {
|
|
1266
|
+
gone: false,
|
|
1267
|
+
verdict: /** @type {const} */ ('could not test'),
|
|
1268
|
+
escalates: false,
|
|
1269
|
+
reran: 0,
|
|
1270
|
+
checked: 0,
|
|
1271
|
+
detail:
|
|
1272
|
+
`Nothing was re-run: ${wanted.join(', ')} ${wanted.length === 1 ? 'is' : 'are'} not among the files that changed `
|
|
1273
|
+
+ `between the build you were happy with and this one, so there was no change in ${wanted.length === 1 ? 'it' : 'them'} `
|
|
1274
|
+
+ `to undo. What did change: ${names.slice(0, 10).join(', ')}${names.length > 10 ? `, and ${names.length - 10} more` : ''}. `
|
|
1275
|
+
+ 'Name one of those and the claim can actually be tested.',
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1243
1279
|
const proof = await proveCause(finding, {
|
|
1244
1280
|
cwd: project.root,
|
|
1245
1281
|
walk: project.walk,
|
|
@@ -1253,7 +1289,13 @@ export async function prove(options = {}) {
|
|
|
1253
1289
|
gone: proof.verdict === 'caused by that change',
|
|
1254
1290
|
verdict: proof.verdict,
|
|
1255
1291
|
escalates: proof.escalates,
|
|
1256
|
-
|
|
1292
|
+
// How much was really walked again, carried through rather than left in a number the
|
|
1293
|
+
// reader never sees. A reply that took a second must never read like one that took ten
|
|
1294
|
+
// minutes, and the only way to tell them apart is to say so.
|
|
1295
|
+
reran: proof.reran,
|
|
1296
|
+
checked: proof.checked,
|
|
1297
|
+
// `proof.what` already ends with the reason. Gluing `why` on printed it twice.
|
|
1298
|
+
detail: proof.what,
|
|
1257
1299
|
};
|
|
1258
1300
|
} finally {
|
|
1259
1301
|
await project.close();
|
|
@@ -1406,7 +1448,29 @@ async function mindTheScreen(project, events) {
|
|
|
1406
1448
|
// Nothing will appear and nobody asked for a window: there is no screen to look after.
|
|
1407
1449
|
if (!wantsPanel && !couldShow) return null;
|
|
1408
1450
|
|
|
1451
|
+
// A WINDOW NOBODY KNOWS ABOUT IS A WINDOW THAT DOES NOT EXIST.
|
|
1452
|
+
//
|
|
1453
|
+
// This tool draws a live view of a run — surfaces, journeys ticking, the reference it is
|
|
1454
|
+
// measuring against, the findings as they land. It is off unless somebody types `--watch`,
|
|
1455
|
+
// and even then it opens BEHIND their work on purpose and never comes forward again. All
|
|
1456
|
+
// three of those are right on their own, and together they meant the owner ran this tool
|
|
1457
|
+
// for weeks and never once saw the window, because nothing anywhere told him it was there.
|
|
1458
|
+
// `staysfixed init` mentions it, which helps exactly once and only if you read the setup.
|
|
1459
|
+
//
|
|
1460
|
+
// So the run itself says it, once, on a project where there is something to watch.
|
|
1461
|
+
if (!wantsPanel) {
|
|
1462
|
+
say('There is a live window for this run — add --watch to open it beside what is being checked.');
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1409
1465
|
const guard = guardTheScreen();
|
|
1466
|
+
// THE BOXES THIS RUN CAUSES ARE THIS RUN'S PROBLEM.
|
|
1467
|
+
//
|
|
1468
|
+
// Every adapter starts the thing under test in a throwaway settings folder, which is exactly
|
|
1469
|
+
// the condition that makes an application ask the operating system for something it has never
|
|
1470
|
+
// been granted. On 2026-09-01 that was a keychain, and the alert it raised sat on screen for
|
|
1471
|
+
// two minutes of a four-minute run while a journey waited behind it for a person who was
|
|
1472
|
+
// never going to arrive. Only ours, only harmless buttons, and everything seen is reported.
|
|
1473
|
+
const dialogs = watchForDialogs({ elapsed: () => events.elapsed() });
|
|
1410
1474
|
// Said under --verbose rather than always, because a person who was not interrupted
|
|
1411
1475
|
// should not be told about the machinery that did not interrupt them. It is here at all
|
|
1412
1476
|
// so that "the guard is running" is something anybody can see rather than take on trust.
|
|
@@ -1432,6 +1496,14 @@ async function mindTheScreen(project, events) {
|
|
|
1432
1496
|
// AND --watch-front is somebody asking, in so many words, for this window in front.
|
|
1433
1497
|
// Claiming it would have the guard undoing the flag a second after it was obeyed.
|
|
1434
1498
|
onOpen: (browser) => {
|
|
1499
|
+
// AND SAY WHERE IT WENT. The window opens behind whatever the person is doing and
|
|
1500
|
+
// never asks for the screen again, which is the behaviour they asked for — but it
|
|
1501
|
+
// makes a window that came up correctly look exactly like one that never came up.
|
|
1502
|
+
// One line, at the moment it opens, is the whole difference between the two.
|
|
1503
|
+
say(
|
|
1504
|
+
`The live window is open on the ${watch.side === 'left' ? 'left' : 'right'} of your screen` +
|
|
1505
|
+
(watch.foreground === true ? '.' : ", behind what you are working on — it will not come forward on its own."),
|
|
1506
|
+
);
|
|
1435
1507
|
if (browser.borrowed || watch.foreground === true) return;
|
|
1436
1508
|
guard.claim(browser.name);
|
|
1437
1509
|
},
|
|
@@ -1448,6 +1520,7 @@ async function mindTheScreen(project, events) {
|
|
|
1448
1520
|
// Claimed the instant the process exists, before it has drawn anything. A moment
|
|
1449
1521
|
// later and its first appearance is read as the person choosing it.
|
|
1450
1522
|
guard.claim(app.name);
|
|
1523
|
+
dialogs.claim(app.name);
|
|
1451
1524
|
events.emit({
|
|
1452
1525
|
type: 'note',
|
|
1453
1526
|
at: events.elapsed(),
|
|
@@ -1465,7 +1538,14 @@ async function mindTheScreen(project, events) {
|
|
|
1465
1538
|
handingBack ??= (async () => {
|
|
1466
1539
|
stopped = true;
|
|
1467
1540
|
stopListening();
|
|
1541
|
+
// One last look before letting go: a box that came up during the final journey is the
|
|
1542
|
+
// one most likely to explain a missing answer, and the periodic sweep may not have
|
|
1543
|
+
// come round again before the run ended.
|
|
1544
|
+
await dialogs.sweepNow().catch(() => {});
|
|
1545
|
+
await dialogs.stop();
|
|
1468
1546
|
await guard.release();
|
|
1547
|
+
const said = describeDialogs(dialogs.report());
|
|
1548
|
+
if (said) events.emit({ type: 'note', at: events.elapsed(), message: said });
|
|
1469
1549
|
const line = describeGuard(guard.report());
|
|
1470
1550
|
if (line) events.emit({ type: 'note', at: events.elapsed(), message: line });
|
|
1471
1551
|
await Promise.allSettled(placing);
|
|
@@ -1588,6 +1668,9 @@ async function waitForItsWindow(pid, stopped) {
|
|
|
1588
1668
|
* @property {import('./types.js').Store} store
|
|
1589
1669
|
* @property {BuildFingerprint} candidate
|
|
1590
1670
|
* @property {string} [against] The reference build's own id, once a name has been resolved.
|
|
1671
|
+
* @property {string[]} [sourceFolders] The folders this run reads code from, straight from
|
|
1672
|
+
* the settings it was given, so nothing downstream has
|
|
1673
|
+
* to find them a second time and find different ones.
|
|
1591
1674
|
* @property {number} keepBuilds How many builds of this product other than the reference keep
|
|
1592
1675
|
* their full record. Everything older is thinned out at the end of a run.
|
|
1593
1676
|
* @property {string} [referenceSha] The commit the build you were happy with is at. It is
|
|
@@ -1800,6 +1883,112 @@ export function suiteBudgetFrom(config) {
|
|
|
1800
1883
|
return Number.isFinite(asked) && asked >= 0 ? Math.floor(asked) : null;
|
|
1801
1884
|
}
|
|
1802
1885
|
|
|
1886
|
+
/**
|
|
1887
|
+
* How long the harvest gets when NOBODY asked for it.
|
|
1888
|
+
*
|
|
1889
|
+
* A quarter of what somebody who typed `--journeys suite` gets, and that gap is the whole
|
|
1890
|
+
* design. Measured on this machine on 2026-08-31: twelve near-empty test files harvested in
|
|
1891
|
+
* 3.1 seconds. Twenty seconds therefore covers a small suite outright and takes a useful bite
|
|
1892
|
+
* out of a large one, and every file it does not reach is named in the coverage list with the
|
|
1893
|
+
* command that would reach it. The alternative — deciding from a file count whether to run at
|
|
1894
|
+
* all — guesses at how slow somebody's tests are and is wrong in both directions.
|
|
1895
|
+
*/
|
|
1896
|
+
const AUTO_HARVEST_BUDGET_MS = 20_000;
|
|
1897
|
+
|
|
1898
|
+
/**
|
|
1899
|
+
* How many harvested test files an unasked-for run will then WALK.
|
|
1900
|
+
*
|
|
1901
|
+
* The harvest budget bounds the harvest and not what comes after it: each harvested journey
|
|
1902
|
+
* is walked twice on the new build and again on the old one. Measured on this machine on
|
|
1903
|
+
* 2026-08-31, twelve harvested journeys took a check from 1.4 seconds to 8.2. Twelve is
|
|
1904
|
+
* therefore the cap, and the files past it are named rather than dropped in silence.
|
|
1905
|
+
*/
|
|
1906
|
+
const AUTO_HARVEST_JOURNEY_CAP = 12;
|
|
1907
|
+
|
|
1908
|
+
/**
|
|
1909
|
+
* How long ONE test file gets on an unasked-for run, harvesting and walking alike.
|
|
1910
|
+
*
|
|
1911
|
+
* The budget above is checked before a file STARTS, never in the middle of one, so without
|
|
1912
|
+
* this a single slow test file could walk straight through a twenty-second budget and spend
|
|
1913
|
+
* the runner's default two minutes doing it — turning a bounded default into an unbounded
|
|
1914
|
+
* one on exactly the projects where that hurts most. Thirty seconds is generous for one file
|
|
1915
|
+
* of a suite somebody runs on every change, and a file that needs longer is named as a hole
|
|
1916
|
+
* with the reason, which is the honest outcome rather than a silent wait.
|
|
1917
|
+
*/
|
|
1918
|
+
const AUTO_FILE_TIMEOUT_MS = 30_000;
|
|
1919
|
+
|
|
1920
|
+
/**
|
|
1921
|
+
* Should this run harvest the project's own tests without being asked?
|
|
1922
|
+
*
|
|
1923
|
+
* The question is only ever "can this be done at all", never "is this project's suite worth
|
|
1924
|
+
* it" — a suite that is too slow is handled by the budget and the cap above, not by refusing
|
|
1925
|
+
* to look. Everything here is cheap: package.json is read, a few filenames are tested for
|
|
1926
|
+
* existence, and nothing is run.
|
|
1927
|
+
*
|
|
1928
|
+
* IT CAN BE SWITCHED OFF, in two ways, because a default that cannot be turned off is a
|
|
1929
|
+
* default somebody works around by uninstalling. `--journeys code` says "read the source and
|
|
1930
|
+
* nothing else" for one run; `suite: { auto: false }` in the settings says it for good. Both
|
|
1931
|
+
* are reported as a hole in that run's coverage, so switching it off never quietly turns into
|
|
1932
|
+
* believing a check that no longer looks.
|
|
1933
|
+
*
|
|
1934
|
+
* @param {string} root
|
|
1935
|
+
* @param {Record<string, any>} config
|
|
1936
|
+
* @returns {Promise<{run: boolean, gap?: CoverageGap}>}
|
|
1937
|
+
*/
|
|
1938
|
+
async function suiteWorthRunningByDefault(root, config) {
|
|
1939
|
+
if (config?.suite?.auto === false) {
|
|
1940
|
+
return {
|
|
1941
|
+
run: false,
|
|
1942
|
+
gap: {
|
|
1943
|
+
what: "This project's own tests were not run, because the settings switch that off.",
|
|
1944
|
+
why: 'suite: { auto: false } in your settings file. Nothing your tests can see is being compared on this run, which on a library is most of what there is to see.',
|
|
1945
|
+
unlockedBy: 'Remove that line, or run `staysfixed check --journeys suite` once to see what it would find.',
|
|
1946
|
+
},
|
|
1947
|
+
};
|
|
1948
|
+
}
|
|
1949
|
+
try {
|
|
1950
|
+
const { detectRunner } = await import('./journeys/from-suite.js');
|
|
1951
|
+
const found = await detectRunner(root);
|
|
1952
|
+
if (found.runner === 'none') {
|
|
1953
|
+
// Said out loud, on every run, rather than passed over as "there was nothing to do".
|
|
1954
|
+
// A project with no tests is not a project where the tests are fine — it is a project
|
|
1955
|
+
// where a whole channel is empty, and on a library that channel is most of what there
|
|
1956
|
+
// is to look at. The reader is told which it is.
|
|
1957
|
+
return {
|
|
1958
|
+
run: false,
|
|
1959
|
+
gap: {
|
|
1960
|
+
what: "None of this project's own tests were run, because there are none this tool can find.",
|
|
1961
|
+
why: `${found.why} A test suite is the only source that walks this product with the arguments somebody actually thought about, so without one the check compares what it can read and call for itself, and no more.`,
|
|
1962
|
+
unlockedBy: "Point the project at vitest or Node's own test runner and every test file becomes a journey, run twice on each build and compared.",
|
|
1963
|
+
},
|
|
1964
|
+
};
|
|
1965
|
+
}
|
|
1966
|
+
const blocking = (found.missing ?? []).filter((m) => m.blocking);
|
|
1967
|
+
if (blocking.length > 0) {
|
|
1968
|
+
return {
|
|
1969
|
+
run: false,
|
|
1970
|
+
gap: {
|
|
1971
|
+
what: "This project has a test suite and none of it was run, so nothing here says anything about what those tests cover.",
|
|
1972
|
+
why: `${blocking.map((m) => m.what).join(', ')} ${blocking.length === 1 ? 'is' : 'are'} missing, and the harvest cannot run one test file at a time without ${blocking.length === 1 ? 'it' : 'them'}.`,
|
|
1973
|
+
unlockedBy: blocking.map((m) => m.howToGet).join(' '),
|
|
1974
|
+
},
|
|
1975
|
+
};
|
|
1976
|
+
}
|
|
1977
|
+
return { run: true };
|
|
1978
|
+
} catch (e) {
|
|
1979
|
+
// Being unable to work out whether a suite exists is a hole like any other. It must never
|
|
1980
|
+
// read as "this project has no tests", which is the same silence wearing a different hat.
|
|
1981
|
+
return {
|
|
1982
|
+
run: false,
|
|
1983
|
+
gap: {
|
|
1984
|
+
what: "Nothing could work out whether this project has a test suite, so none of it was run.",
|
|
1985
|
+
why: messageOf(e),
|
|
1986
|
+
unlockedBy: 'Run `staysfixed check --journeys suite` to see what it says, or `staysfixed doctor` for what this folder is missing.',
|
|
1987
|
+
},
|
|
1988
|
+
};
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1803
1992
|
/**
|
|
1804
1993
|
* Thin out the record of builds nobody is going to ask about again.
|
|
1805
1994
|
*
|
|
@@ -2139,6 +2328,12 @@ async function openProject(options) {
|
|
|
2139
2328
|
store,
|
|
2140
2329
|
candidate,
|
|
2141
2330
|
keepBuilds: keepBuildsFrom(config),
|
|
2331
|
+
// The folders THIS run is reading, carried so the coverage ledger counts the doors of the
|
|
2332
|
+
// same product the run walked. The ledger can find the settings itself, and does — but it
|
|
2333
|
+
// finds them by looking beside the project, and a run started with `--config elsewhere`
|
|
2334
|
+
// is reading a different file. Two answers to "what is in this project" is how the
|
|
2335
|
+
// ledger came to measure "78 of 78 doors" from 8 of 20 files. Measured 2026-08-31.
|
|
2336
|
+
sourceFolders: Array.isArray(config.source?.folders) ? config.source.folders : undefined,
|
|
2142
2337
|
referenceSha,
|
|
2143
2338
|
against: reference ? reference.id : options.against,
|
|
2144
2339
|
journeys,
|
|
@@ -2340,6 +2535,14 @@ async function walkOne(req, where) {
|
|
|
2340
2535
|
ctx,
|
|
2341
2536
|
);
|
|
2342
2537
|
observations = await adapter.run(req.journey, prepared, ctx);
|
|
2538
|
+
// An answer sheet arrives as one wall of text at one address, because that is what the
|
|
2539
|
+
// process adapter does with anything a command prints. Left that way, a library whose
|
|
2540
|
+
// every return value changed produced ONE finding, worded as a window onto the middle of
|
|
2541
|
+
// a string: "…eserved(\"admin\") -> false…" where it read "…eserved(\"admin\") -> true…".
|
|
2542
|
+
// True, and useless to the person who has to decide whether to ship. Taken apart, every
|
|
2543
|
+
// call gets the exported name's own address and the finding names the function, the
|
|
2544
|
+
// input and both answers. Measured 2026-08-31 — see `splitAnswerSheet`.
|
|
2545
|
+
if (isAnAnswerJourney(req.journey)) observations = splitAnswerSheet(observations, req.journey);
|
|
2343
2546
|
} catch (e) {
|
|
2344
2547
|
// A journey that fell over is a hole in the coverage, never a silent pass and never
|
|
2345
2548
|
// the end of the run — the other journeys' work is worth keeping.
|
|
@@ -2540,29 +2743,64 @@ async function gatherJourneys({ root, config, options }) {
|
|
|
2540
2743
|
|
|
2541
2744
|
if (named) journeys.push(...(await readJourneyFile(path.resolve(root, named))));
|
|
2542
2745
|
|
|
2543
|
-
// The project's own test suite
|
|
2544
|
-
// otherwise. This RUNS their tests — twice each, inside the same scratch clone everything
|
|
2545
|
-
// else uses, under a time budget — and that is a cost nobody gets charged by accident, so
|
|
2546
|
-
// it is off unless `--journeys suite` says so.
|
|
2746
|
+
// ---- The project's own test suite.
|
|
2547
2747
|
//
|
|
2548
|
-
//
|
|
2549
|
-
//
|
|
2550
|
-
//
|
|
2551
|
-
//
|
|
2748
|
+
// WHY THIS USED TO BE OFF BY DEFAULT, and the reasoning was right as far as it went: this
|
|
2749
|
+
// RUNS somebody else's tests — every file twice to harvest, and then every harvested
|
|
2750
|
+
// journey twice more on each build — and charging a stranger for that on a command they
|
|
2751
|
+
// ran to get a fast answer is how a tool gets uninstalled. So it waited for
|
|
2752
|
+
// `--journeys suite`.
|
|
2753
|
+
//
|
|
2754
|
+
// WHY IT IS NOW ON BY DEFAULT ANYWAY. The cost was measured against the wrong thing. It was
|
|
2755
|
+
// weighed against a slower check; it should have been weighed against a WRONG one. Measured
|
|
2756
|
+
// 2026-08-31 on a four-line library: two exported functions were rewritten so that every
|
|
2757
|
+
// web address the product produces came out different, and the default check answered
|
|
2758
|
+
// "Nothing that worked has changed" and exited 0, because no default channel had ever
|
|
2759
|
+
// called a function. A flag that is off by default cannot save anybody, and a false
|
|
2760
|
+
// all-clear is not a cheaper answer than a slow one — it is the one answer this tool may
|
|
2761
|
+
// never give.
|
|
2762
|
+
//
|
|
2763
|
+
// WHERE THE LINE IS DRAWN, and the measurement that drew it. Default-on is held to a
|
|
2764
|
+
// TIGHTER budget than an explicit `--journeys suite`, and to a cap on how many harvested
|
|
2765
|
+
// journeys are then walked, so the cost of a check nobody asked to slow down is bounded by
|
|
2766
|
+
// construction instead of by a guess about somebody's suite. Measured on this machine on
|
|
2767
|
+
// 2026-08-31, with twelve near-empty test files: harvesting them took 3.1 seconds, and the
|
|
2768
|
+
// whole check went from 1.4 seconds to 8.2 — about 570ms per test file, and that is the
|
|
2769
|
+
// FLOOR, because those tests did nothing. So the automatic path gets 20 seconds of harvest
|
|
2770
|
+
// and walks at most 12 of what comes out, which lands a default check at well under half a
|
|
2771
|
+
// minute on a project of that shape. Everything the budget or the cap left out is named as
|
|
2772
|
+
// a hole with the command that would reach it — never dropped quietly.
|
|
2773
|
+
//
|
|
2774
|
+
// Asking for it by name still gets the full, uncapped ninety seconds, because somebody who
|
|
2775
|
+
// typed `--journeys suite` has said what they are willing to wait for.
|
|
2552
2776
|
//
|
|
2553
2777
|
// Loaded here rather than at the top of the file: a copy of this tool without the harvest
|
|
2554
2778
|
// in it still runs every other kind of check, and saying so is better than failing to start.
|
|
2555
|
-
|
|
2779
|
+
const askedForTheSuite = options.journeys === 'suite';
|
|
2780
|
+
let autoSuite = null;
|
|
2781
|
+
if (!askedForTheSuite && !named && options.journeys !== 'recorded' && options.journeys !== 'code') {
|
|
2782
|
+
autoSuite = await suiteWorthRunningByDefault(root, config);
|
|
2783
|
+
if (autoSuite.gap) gaps.push(autoSuite.gap);
|
|
2784
|
+
}
|
|
2785
|
+
if (askedForTheSuite || autoSuite?.run) {
|
|
2786
|
+
const automatic = !askedForTheSuite;
|
|
2556
2787
|
try {
|
|
2557
2788
|
const { journeysFromSuite, DEFAULT_HARVEST_BUDGET_MS } = await import('./journeys/index.js');
|
|
2558
2789
|
// The settings file gets a say in how long this is allowed to take. Left out, the
|
|
2559
2790
|
// harvest applies its own default, which is why nothing is passed rather than the
|
|
2560
|
-
// default being copied to here — see `suiteBudgetFrom`.
|
|
2561
|
-
|
|
2791
|
+
// default being copied to here — see `suiteBudgetFrom`. On the automatic path the
|
|
2792
|
+
// tighter budget is used unless the settings ask for something of their own, because a
|
|
2793
|
+
// number somebody wrote down beats a number this file guessed.
|
|
2794
|
+
const asked = suiteBudgetFrom(config);
|
|
2795
|
+
const budgetMs = asked ?? (automatic ? AUTO_HARVEST_BUDGET_MS : null);
|
|
2796
|
+
const suiteOptions = {
|
|
2797
|
+
...(budgetMs === null ? {} : { budgetMs }),
|
|
2798
|
+
...(automatic ? { timeoutMs: AUTO_FILE_TIMEOUT_MS } : {}),
|
|
2799
|
+
};
|
|
2562
2800
|
const suite = await journeysFromSuite({
|
|
2563
2801
|
root,
|
|
2564
2802
|
surface: options.surface === 'auto' ? undefined : options.surface,
|
|
2565
|
-
...(
|
|
2803
|
+
...(Object.keys(suiteOptions).length === 0 ? {} : { suite: suiteOptions }),
|
|
2566
2804
|
// The harvest talks while it works, and it can take most of a minute. Its sentences
|
|
2567
2805
|
// go into the same stream as everything else rather than nowhere.
|
|
2568
2806
|
log: (message) => options.events?.emit({ type: 'note', at: options.events.elapsed(), message }),
|
|
@@ -2578,9 +2816,24 @@ async function gatherJourneys({ root, config, options }) {
|
|
|
2578
2816
|
message:
|
|
2579
2817
|
applied === 0
|
|
2580
2818
|
? 'The test-suite harvest was given no time budget at all, so every test file was run however long it took. Your settings asked for that with suite.budgetMs: 0.'
|
|
2581
|
-
: `The test-suite harvest was held to ${Math.round(applied / 1000)} seconds${
|
|
2819
|
+
: `The test-suite harvest was held to ${Math.round(applied / 1000)} seconds${
|
|
2820
|
+
asked !== null ? ', which your settings asked for' : automatic ? ', which is what an automatic run gets' : ', which is the default'
|
|
2821
|
+
}. Anything it did not reach in that time is named below rather than skipped quietly; change it with suite.budgetMs.`,
|
|
2582
2822
|
});
|
|
2583
|
-
|
|
2823
|
+
// The cap, and only on the automatic path. Somebody who typed the flag gets everything
|
|
2824
|
+
// their suite produced. Whoever did not type anything gets a bounded run and a list of
|
|
2825
|
+
// exactly which of their test files are therefore not being watched.
|
|
2826
|
+
let kept = suite.journeys;
|
|
2827
|
+
if (automatic && kept.length > AUTO_HARVEST_JOURNEY_CAP) {
|
|
2828
|
+
const dropped = kept.slice(AUTO_HARVEST_JOURNEY_CAP);
|
|
2829
|
+
kept = kept.slice(0, AUTO_HARVEST_JOURNEY_CAP);
|
|
2830
|
+
gaps.push({
|
|
2831
|
+
what: `${dropped.length} of this project's test files were harvested and then not walked, so nothing here says anything about what they cover: ${dropped.map((j) => j.name).join(', ')}.`,
|
|
2832
|
+
why: `A check nobody asked to slow down walks at most ${AUTO_HARVEST_JOURNEY_CAP} harvested test files, because each one is run twice on every build and the bill for a big suite would land on somebody who only wanted a quick answer.`,
|
|
2833
|
+
unlockedBy: 'Run `staysfixed check --journeys suite` to walk all of them, or narrow the suite to the files that matter.',
|
|
2834
|
+
});
|
|
2835
|
+
}
|
|
2836
|
+
journeys.push(...kept);
|
|
2584
2837
|
gaps.push(...suite.gaps);
|
|
2585
2838
|
} catch (e) {
|
|
2586
2839
|
// A harvest that fell over is a hole, never a pass. Everything else this project has is
|
|
@@ -2593,13 +2846,37 @@ async function gatherJourneys({ root, config, options }) {
|
|
|
2593
2846
|
}
|
|
2594
2847
|
}
|
|
2595
2848
|
|
|
2849
|
+
// ---- Calling what a library exports, rather than only reading its labels.
|
|
2850
|
+
//
|
|
2851
|
+
// See `from-exports.js` for the false all-clear that put this here. In one sentence: a
|
|
2852
|
+
// library was checked, shipped, rewritten so that every value it returns came out
|
|
2853
|
+
// different, and checked again — and the check passed, because every channel in the tool
|
|
2854
|
+
// compared the NAMES and SHAPES of the exports and none of them had ever called one.
|
|
2855
|
+
//
|
|
2856
|
+
// It costs one extra process per configured module per build, which is the cheapest thing
|
|
2857
|
+
// on this page, and it needs nothing configured that is not configured already: `init`
|
|
2858
|
+
// writes `process.imports` for every library it sets up.
|
|
2859
|
+
if (!named && options.journeys !== 'recorded') {
|
|
2860
|
+
const answers = journeysFromExports({ config: config.process });
|
|
2861
|
+
journeys.push(...answers.journeys);
|
|
2862
|
+
gaps.push(...answers.gaps);
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2596
2865
|
for (const adapter of ADAPTERS) {
|
|
2597
2866
|
if (adapter === sourceAdapter && named && options.journeys !== 'code') {
|
|
2598
2867
|
// A journeys file names exactly what to walk. The contract read is still added,
|
|
2599
2868
|
// because it cannot break anything and it sees what no journey does.
|
|
2600
2869
|
}
|
|
2601
2870
|
/** @type {import('./adapters/contract.js').AdapterProject} */
|
|
2602
|
-
|
|
2871
|
+
// The folders the settings name, handed to every adapter alongside its own block.
|
|
2872
|
+
//
|
|
2873
|
+
// An adapter is given only the settings under its own name, so `http` could see
|
|
2874
|
+
// `http.folders` and never `source.folders` — which is where `init` actually writes them.
|
|
2875
|
+
// Route discovery therefore read the folders it guesses at, and a route outside them was
|
|
2876
|
+
// never found on a project that had said, in its own settings, exactly where its code is.
|
|
2877
|
+
// The adapter's own block still wins, because a project that overrode this meant it.
|
|
2878
|
+
// Measured 2026-08-31.
|
|
2879
|
+
const project = { root, config: { folders: config.source?.folders, ...(config[adapter.name] ?? {}) } };
|
|
2603
2880
|
let detection;
|
|
2604
2881
|
try {
|
|
2605
2882
|
detection = await adapter.detect(project);
|
|
@@ -2825,6 +3102,69 @@ function nameOfReference(reference, asked) {
|
|
|
2825
3102
|
return asked && asked.trim() !== '' ? `${asked} (${reference.id})` : reference.id;
|
|
2826
3103
|
}
|
|
2827
3104
|
|
|
3105
|
+
/**
|
|
3106
|
+
* Put one commit's files into a folder, without a shell and without touching the repository.
|
|
3107
|
+
*
|
|
3108
|
+
* `git archive` writes a tar to its standard output and `tar` reads one from its standard
|
|
3109
|
+
* input, so the two are joined here directly. The archive never reaches the disk, which is why
|
|
3110
|
+
* a big repository does not cost twice the space to look at — the reason the shell pipeline was
|
|
3111
|
+
* there in the first place. Both programs are on every machine this runs on: Windows has
|
|
3112
|
+
* shipped `tar.exe` since Windows 10, and git is already required for anything here to work.
|
|
3113
|
+
*
|
|
3114
|
+
* @param {string} root The repository.
|
|
3115
|
+
* @param {string} sha The commit to put back.
|
|
3116
|
+
* @param {string} dir An empty folder to put it in.
|
|
3117
|
+
* @returns {Promise<void>}
|
|
3118
|
+
*/
|
|
3119
|
+
function gitArchiveInto(root, sha, dir) {
|
|
3120
|
+
return new Promise((resolve, reject) => {
|
|
3121
|
+
const git = spawn('git', ['-C', root, 'archive', '--format=tar', sha], {
|
|
3122
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
3123
|
+
windowsHide: true,
|
|
3124
|
+
});
|
|
3125
|
+
const untar = spawn('tar', ['-x', '-f', '-', '-C', dir], {
|
|
3126
|
+
stdio: ['pipe', 'ignore', 'pipe'],
|
|
3127
|
+
windowsHide: true,
|
|
3128
|
+
});
|
|
3129
|
+
|
|
3130
|
+
let said = '';
|
|
3131
|
+
for (const stream of [git.stderr, untar.stderr]) {
|
|
3132
|
+
stream?.setEncoding('utf8');
|
|
3133
|
+
stream?.on('data', (chunk) => { said = (said + chunk).slice(0, 4000); });
|
|
3134
|
+
}
|
|
3135
|
+
|
|
3136
|
+
let done = false;
|
|
3137
|
+
/** @param {Error|null} e */
|
|
3138
|
+
const finish = (e) => {
|
|
3139
|
+
if (done) return;
|
|
3140
|
+
done = true;
|
|
3141
|
+
clearTimeout(giveUp);
|
|
3142
|
+
try { git.kill('SIGKILL'); } catch { /* already gone */ }
|
|
3143
|
+
try { untar.kill('SIGKILL'); } catch { /* already gone */ }
|
|
3144
|
+
if (e) reject(e);
|
|
3145
|
+
else resolve();
|
|
3146
|
+
};
|
|
3147
|
+
const giveUp = setTimeout(
|
|
3148
|
+
() => finish(new Error(`putting ${sha.slice(0, 7)} back took longer than two minutes.`)),
|
|
3149
|
+
120_000,
|
|
3150
|
+
);
|
|
3151
|
+
|
|
3152
|
+
git.on('error', finish);
|
|
3153
|
+
untar.on('error', finish);
|
|
3154
|
+
git.stdout.pipe(untar.stdin);
|
|
3155
|
+
// A pipe that breaks because the other end has died is not news worth an unhandled error.
|
|
3156
|
+
git.stdout.on('error', () => {});
|
|
3157
|
+
untar.stdin.on('error', () => {});
|
|
3158
|
+
|
|
3159
|
+
git.on('exit', (code) => {
|
|
3160
|
+
if (code !== 0) finish(new Error(said.trim() || `git archive stopped with code ${code}`));
|
|
3161
|
+
});
|
|
3162
|
+
untar.on('exit', (code) => {
|
|
3163
|
+
finish(code === 0 ? null : new Error(said.trim() || `tar stopped with code ${code}`));
|
|
3164
|
+
});
|
|
3165
|
+
});
|
|
3166
|
+
}
|
|
3167
|
+
|
|
2828
3168
|
/**
|
|
2829
3169
|
* Put the old build back on this machine so it can be walked live.
|
|
2830
3170
|
*
|
|
@@ -2857,12 +3197,13 @@ async function exportBuild(root, reference, scratch) {
|
|
|
2857
3197
|
const dir = path.join(scratch, `reference-${sha.slice(0, 12)}`);
|
|
2858
3198
|
await fsp.mkdir(dir, { recursive: true });
|
|
2859
3199
|
try {
|
|
2860
|
-
// Straight through a pipe: the archive is never written to disk, so a big repository
|
|
2861
|
-
//
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
3200
|
+
// Straight through a pipe: the archive is never written to disk, so a big repository does
|
|
3201
|
+
// not cost twice the space to look at. The two programs are joined below rather than by a
|
|
3202
|
+
// shell, because there is no `/bin/sh` on Windows — and that one word was the whole of
|
|
3203
|
+
// paired mode there. Measured on a real Windows 11 machine on 2026-08-31: every `--paired`
|
|
3204
|
+
// run answered "<sha> cannot be built here" and fell back to the stored record, which is
|
|
3205
|
+
// the weaker comparison. The tool's strongest mode had never once run on Windows.
|
|
3206
|
+
await gitArchiveInto(root, sha, dir);
|
|
2866
3207
|
} catch (e) {
|
|
2867
3208
|
await fsp.rm(dir, { recursive: true, force: true });
|
|
2868
3209
|
throw new StaysFixedError(`${sha.slice(0, 7)} could not be put back on this machine, so it cannot be walked live. ${messageOf(e)}`, {
|
|
@@ -2983,10 +3324,6 @@ async function packageVersion(root) {
|
|
|
2983
3324
|
return typeof pkg?.version === 'string' ? pkg.version : null;
|
|
2984
3325
|
}
|
|
2985
3326
|
|
|
2986
|
-
/** @param {string} text */
|
|
2987
|
-
function quote(text) {
|
|
2988
|
-
return `'${text.split("'").join(`'\\''`)}'`;
|
|
2989
|
-
}
|
|
2990
3327
|
|
|
2991
3328
|
/** @param {string} name */
|
|
2992
3329
|
function safeSegment(name) {
|
package/src/v2/cli.js
CHANGED
|
@@ -259,7 +259,7 @@ export const V2_COMMANDS = {
|
|
|
259
259
|
summary: 'Test whether your own edit really caused a finding, by undoing it.',
|
|
260
260
|
usage: 'staysfixed prove <finding> --revert <file> [--revert <file>]',
|
|
261
261
|
describe:
|
|
262
|
-
'You believe your change to a particular file caused a difference. This puts that file\nback to the reference build, runs again, and says whether the difference went away.\
|
|
262
|
+
'You believe your change to a particular file caused a difference. This puts that file\nback to the reference build, runs again, and says whether the difference went away.\n\nIt gives you one of THREE answers, and only two of them are answers:\n PROVEN CAUSED undoing your change made the difference go away.\n PROVEN NOT CAUSED it was re-run without your change and the difference is still there,\n so you were about to fix the wrong file.\n NOT TESTED nothing was measured — the file you named was not among your changes,\n the old build would not build, or nothing was re-run at all. This\n never means your edit is innocent. It means nobody looked.\n\nIt is a real re-run, not a lookup: expect it to take about as long as a check.\nNothing is left reverted: the working tree is put back exactly as it was.\n\nIt answers 0 when it could test the claim and 2 when it could not. Which way it came out —\ncaused it, or did not — is in the words, not the exit code, because "your edit was\ninnocent" is not a failure and must not be read as one.',
|
|
263
263
|
options: [
|
|
264
264
|
['--revert <file>', 'A file to put back to the reference for one run. Repeat it for several.'],
|
|
265
265
|
],
|
|
@@ -475,8 +475,26 @@ export async function proveRun(ctx) {
|
|
|
475
475
|
});
|
|
476
476
|
}
|
|
477
477
|
|
|
478
|
+
// The price, said before it is charged rather than after.
|
|
479
|
+
//
|
|
480
|
+
// Proving a cause is not a lookup. It checks out the old build into a scratch copy, undoes
|
|
481
|
+
// the one change, and WALKS THE JOURNEYS AGAIN - on a real website that is eleven to
|
|
482
|
+
// twenty minutes, and somebody who thinks they typed a query sits watching a blank screen
|
|
483
|
+
// and kills it. On 2026-08-31 the opposite also happened and is worse: an answer came back
|
|
484
|
+
// in five seconds, having started no build and walked nothing, and read exactly like a
|
|
485
|
+
// measurement. Saying what this is about to cost is half of what stops a fast reply being
|
|
486
|
+
// mistaken for a cheap one - the reply itself now says what it actually ran.
|
|
487
|
+
say(paint.grey(`Undoing ${revert.join(', ')} in a scratch copy and walking this product again. That is a full re-run of the journeys this finding came from, so it costs about what a check costs. Nothing of yours is touched and nothing is left reverted.`));
|
|
488
|
+
blank();
|
|
489
|
+
|
|
478
490
|
const reply = await askTheToolSet(ctx, 'staysfixed_prove', { finding, revert });
|
|
479
491
|
sayReply(reply);
|
|
492
|
+
// Non-zero means "could not test", never "your edit was innocent". `staysfixed_prove`
|
|
493
|
+
// marks exactly one of its three answers as an error - the one that is not an answer -
|
|
494
|
+
// which is the promise this command's own help has always made: 0 when it could test the
|
|
495
|
+
// claim, 2 when it could not. Until 2026-08-31 it exited 0 on all three, so a CI step or
|
|
496
|
+
// an agent reading the code alone was told a question nobody had answered had come back
|
|
497
|
+
// clean.
|
|
480
498
|
return reply.isError ? EXIT.error : EXIT.ok;
|
|
481
499
|
}
|
|
482
500
|
|