staysfixed 0.11.1 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +108 -2
- package/README.md +77 -19
- package/docs/design-v2.md +8 -7
- package/docs/getting-started.md +5 -3
- package/docs/guards.md +18 -0
- package/docs/how-v2-works.md +43 -10
- package/docs/mcp.md +6 -4
- package/docs/settings.md +11 -2
- 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/android.js +220 -11
- package/src/v2/adapters/child.js +15 -17
- package/src/v2/adapters/contract.js +122 -1
- package/src/v2/adapters/extension.js +1988 -0
- package/src/v2/adapters/http.js +152 -30
- package/src/v2/adapters/ios-driver.js +95 -12
- package/src/v2/adapters/ios.js +220 -10
- package/src/v2/adapters/isolate.js +169 -14
- package/src/v2/adapters/linux-driver.js +1028 -0
- package/src/v2/adapters/linux.js +1324 -0
- package/src/v2/adapters/macos-driver.js +913 -0
- package/src/v2/adapters/macos.js +1374 -0
- 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 +145 -25
- package/src/v2/cause.js +46 -5
- package/src/v2/check.js +465 -47
- package/src/v2/cli.js +21 -1
- package/src/v2/coverage.js +556 -19
- package/src/v2/detect.js +742 -42
- package/src/v2/doctor.js +125 -18
- package/src/v2/escalate.js +57 -11
- package/src/v2/init.js +574 -23
- 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/journeys/index.js +3 -3
- package/src/v2/journeys/record-session.js +839 -0
- package/src/v2/journeys/record.js +12 -0
- package/src/v2/mcp/tools.js +193 -27
- 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/types.js +1 -1
- package/src/v2/watch/events.js +6 -0
package/src/v2/check.js
CHANGED
|
@@ -51,11 +51,13 @@ import {
|
|
|
51
51
|
} from './watch/window.js';
|
|
52
52
|
import { onAppStarted, stillOpen } from './adapters/isolate.js';
|
|
53
53
|
|
|
54
|
+
import { isAnAnswerJourney, journeysFromExports, splitAnswerSheet } from './journeys/from-exports.js';
|
|
54
55
|
import { processAdapter } from './adapters/process.js';
|
|
55
56
|
import { sourceAdapter } from './adapters/source.js';
|
|
56
57
|
import { httpAdapter } from './adapters/http.js';
|
|
57
58
|
import { webAdapter } from './adapters/web.js';
|
|
58
59
|
import { electronAdapter } from './adapters/electron.js';
|
|
60
|
+
import { extensionAdapter } from './adapters/extension.js';
|
|
59
61
|
|
|
60
62
|
const exec = promisify(execFile);
|
|
61
63
|
|
|
@@ -124,7 +126,7 @@ const exec = promisify(execFile);
|
|
|
124
126
|
*/
|
|
125
127
|
|
|
126
128
|
/** The adapters compiled into every copy, in the order the engine trusts them. Reading the code is free, so it is first. */
|
|
127
|
-
const BUILT_IN = [sourceAdapter, processAdapter, httpAdapter, webAdapter, electronAdapter];
|
|
129
|
+
const BUILT_IN = [sourceAdapter, processAdapter, httpAdapter, webAdapter, electronAdapter, extensionAdapter];
|
|
128
130
|
|
|
129
131
|
/**
|
|
130
132
|
* The platforms that arrive as a file of their own.
|
|
@@ -158,6 +160,20 @@ const SEPARATE_ADAPTERS = [
|
|
|
158
160
|
missing:
|
|
159
161
|
'This copy has no native-Windows adapter in it. That is usually fine: a Windows product built with Electron is driven over its own debugging port by the Electron adapter and needs nothing else.',
|
|
160
162
|
},
|
|
163
|
+
{
|
|
164
|
+
surface: 'macos',
|
|
165
|
+
file: './adapters/macos.js',
|
|
166
|
+
exports: ['macosAdapter', 'adapter', 'default'],
|
|
167
|
+
missing:
|
|
168
|
+
'This copy has no native-Mac adapter in it, so nothing here can open a Swift or Objective-C app and read what is on its screen. That is usually fine: a Mac product built with Electron is driven over its own debugging port by the Electron adapter and needs nothing else.',
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
surface: 'linux',
|
|
172
|
+
file: './adapters/linux.js',
|
|
173
|
+
exports: ['linuxAdapter', 'adapter', 'default'],
|
|
174
|
+
missing:
|
|
175
|
+
'This copy has no native-Linux adapter in it. That is usually fine: a Linux product built with Electron is driven over its own debugging port by the Electron adapter and needs nothing else.',
|
|
176
|
+
},
|
|
161
177
|
];
|
|
162
178
|
|
|
163
179
|
/**
|
|
@@ -193,9 +209,12 @@ export const ADAPTER_FOR_SURFACE = {
|
|
|
193
209
|
server: 'http',
|
|
194
210
|
web: 'web',
|
|
195
211
|
electron: 'electron',
|
|
212
|
+
extension: 'extension',
|
|
196
213
|
android: 'android',
|
|
197
214
|
ios: 'ios',
|
|
198
215
|
windows: 'windows',
|
|
216
|
+
linux: 'linux',
|
|
217
|
+
macos: 'macos',
|
|
199
218
|
};
|
|
200
219
|
|
|
201
220
|
/**
|
|
@@ -564,6 +583,7 @@ async function countTheDoors(verdict, project) {
|
|
|
564
583
|
const { ledger, toCoverage } = await import('./coverage.js');
|
|
565
584
|
const led = await ledger(project.store, project.product, {
|
|
566
585
|
root: project.root,
|
|
586
|
+
folders: project.sourceFolders,
|
|
567
587
|
journeys: project.journeys,
|
|
568
588
|
builds: [project.candidate.id],
|
|
569
589
|
});
|
|
@@ -1067,28 +1087,34 @@ async function stopWhateverIsStillRunningIn(dir) {
|
|
|
1067
1087
|
function noScratchFolder(e) {
|
|
1068
1088
|
const tmp = os.tmpdir();
|
|
1069
1089
|
const code = String(/** @type {any} */ (e)?.code ?? '');
|
|
1090
|
+
// The setting is not called the same thing everywhere, and naming the wrong one is advice
|
|
1091
|
+
// that cannot be followed. Windows reads TEMP and TMP; everything else reads TMPDIR. All
|
|
1092
|
+
// three sentences below said "TMPDIR" on every machine, so on Windows the only instruction
|
|
1093
|
+
// a stuck person was given named a setting their operating system does not read. Measured
|
|
1094
|
+
// on a real Windows 11 machine, 2026-08-31.
|
|
1095
|
+
const setting = process.platform === 'win32' ? 'TEMP' : 'TMPDIR';
|
|
1096
|
+
const trailing = process.platform === 'win32' ? /[\\/]$/ : /\/$/;
|
|
1070
1097
|
// Worth naming only when a setting in this shell is what chose the folder. On a machine
|
|
1071
|
-
// where nothing set it, saying
|
|
1098
|
+
// where nothing set it, saying the name sends somebody looking for a setting they have not
|
|
1072
1099
|
// got, and the folder is the operating system's own.
|
|
1073
|
-
const
|
|
1074
|
-
|
|
1075
|
-
: '';
|
|
1100
|
+
const chosenHere = (process.env[setting] ?? '').replace(trailing, '') === tmp.replace(trailing, '');
|
|
1101
|
+
const yours = chosenHere ? ` That folder is whatever ${setting} is set to in this shell.` : '';
|
|
1076
1102
|
/** @type {{why: string, hint: string}} */
|
|
1077
1103
|
const said =
|
|
1078
1104
|
code === 'ENOENT'
|
|
1079
1105
|
? {
|
|
1080
1106
|
why: `There is no folder at ${tmp}, so there was nowhere to put it.`,
|
|
1081
|
-
hint: `Make that folder, or point
|
|
1107
|
+
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}`,
|
|
1082
1108
|
}
|
|
1083
1109
|
: code === 'EACCES' || code === 'EPERM'
|
|
1084
1110
|
? {
|
|
1085
1111
|
why: `${tmp} is there, but this user is not allowed to write in it.`,
|
|
1086
|
-
hint: `Give yourself write access to that folder, or point
|
|
1112
|
+
hint: `Give yourself write access to that folder, or point ${setting} at one you can write to, and run the check again.${yours}`,
|
|
1087
1113
|
}
|
|
1088
1114
|
: code === 'EROFS'
|
|
1089
1115
|
? {
|
|
1090
1116
|
why: `${tmp} is on a disk that is mounted read-only, so nothing can be written there at all.`,
|
|
1091
|
-
hint: `Point
|
|
1117
|
+
hint: `Point ${setting} at a folder on a disk that takes writes and run the check again.${yours}`,
|
|
1092
1118
|
}
|
|
1093
1119
|
: code === 'ENOSPC'
|
|
1094
1120
|
? {
|
|
@@ -1196,7 +1222,7 @@ function blocked(options, e, storeTrouble) {
|
|
|
1196
1222
|
* for itself.
|
|
1197
1223
|
*
|
|
1198
1224
|
* @param {CheckOptions & {finding?: string, revert?: string[]}} options
|
|
1199
|
-
* @returns {Promise<{gone: boolean, detail?: string, verdict?: string, escalates?: boolean}>}
|
|
1225
|
+
* @returns {Promise<{gone: boolean, detail?: string, verdict?: string, escalates?: boolean, reran?: number, checked?: number}>}
|
|
1200
1226
|
*/
|
|
1201
1227
|
export async function prove(options = {}) {
|
|
1202
1228
|
const root = projectRootFor(options);
|
|
@@ -1207,6 +1233,12 @@ export async function prove(options = {}) {
|
|
|
1207
1233
|
if (!finding) {
|
|
1208
1234
|
return {
|
|
1209
1235
|
gone: false,
|
|
1236
|
+
// Said out loud rather than left absent. An absent verdict resolves to "could not test"
|
|
1237
|
+
// at the surface, deliberately, but a reader of this function should not have to know
|
|
1238
|
+
// that to see which of the three answers this is.
|
|
1239
|
+
verdict: 'could not test',
|
|
1240
|
+
reran: 0,
|
|
1241
|
+
checked: 0,
|
|
1210
1242
|
detail: `The last check has no finding called "${options.finding ?? ''}". Run a check first, then prove one of the ids it gives you.`,
|
|
1211
1243
|
};
|
|
1212
1244
|
}
|
|
@@ -1222,6 +1254,27 @@ export async function prove(options = {}) {
|
|
|
1222
1254
|
? { ...changed, hunks: changed.hunks.filter((h) => wanted.some((w) => h.file === w || h.file.startsWith(`${w}/`))) }
|
|
1223
1255
|
: changed;
|
|
1224
1256
|
|
|
1257
|
+
// A file named for reverting that is not among the changes is NOT "nothing has changed".
|
|
1258
|
+
// With an empty narrowing, proveCause said "Nothing has changed between the build you were
|
|
1259
|
+
// happy with and this one" — about a working tree with two edited files in it — which
|
|
1260
|
+
// sends somebody to debug their tree instead of the filename they just typed. Measured
|
|
1261
|
+
// 2026-08-31.
|
|
1262
|
+
if (wanted.length > 0 && narrowed.hunks.length === 0 && changed.hunks.length > 0) {
|
|
1263
|
+
const names = [...new Set(changed.hunks.map((h) => h.file))];
|
|
1264
|
+
return {
|
|
1265
|
+
gone: false,
|
|
1266
|
+
verdict: /** @type {const} */ ('could not test'),
|
|
1267
|
+
escalates: false,
|
|
1268
|
+
reran: 0,
|
|
1269
|
+
checked: 0,
|
|
1270
|
+
detail:
|
|
1271
|
+
`Nothing was re-run: ${wanted.join(', ')} ${wanted.length === 1 ? 'is' : 'are'} not among the files that changed `
|
|
1272
|
+
+ `between the build you were happy with and this one, so there was no change in ${wanted.length === 1 ? 'it' : 'them'} `
|
|
1273
|
+
+ `to undo. What did change: ${names.slice(0, 10).join(', ')}${names.length > 10 ? `, and ${names.length - 10} more` : ''}. `
|
|
1274
|
+
+ 'Name one of those and the claim can actually be tested.',
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1225
1278
|
const proof = await proveCause(finding, {
|
|
1226
1279
|
cwd: project.root,
|
|
1227
1280
|
walk: project.walk,
|
|
@@ -1235,7 +1288,13 @@ export async function prove(options = {}) {
|
|
|
1235
1288
|
gone: proof.verdict === 'caused by that change',
|
|
1236
1289
|
verdict: proof.verdict,
|
|
1237
1290
|
escalates: proof.escalates,
|
|
1238
|
-
|
|
1291
|
+
// How much was really walked again, carried through rather than left in a number the
|
|
1292
|
+
// reader never sees. A reply that took a second must never read like one that took ten
|
|
1293
|
+
// minutes, and the only way to tell them apart is to say so.
|
|
1294
|
+
reran: proof.reran,
|
|
1295
|
+
checked: proof.checked,
|
|
1296
|
+
// `proof.what` already ends with the reason. Gluing `why` on printed it twice.
|
|
1297
|
+
detail: proof.what,
|
|
1239
1298
|
};
|
|
1240
1299
|
} finally {
|
|
1241
1300
|
await project.close();
|
|
@@ -1570,6 +1629,9 @@ async function waitForItsWindow(pid, stopped) {
|
|
|
1570
1629
|
* @property {import('./types.js').Store} store
|
|
1571
1630
|
* @property {BuildFingerprint} candidate
|
|
1572
1631
|
* @property {string} [against] The reference build's own id, once a name has been resolved.
|
|
1632
|
+
* @property {string[]} [sourceFolders] The folders this run reads code from, straight from
|
|
1633
|
+
* the settings it was given, so nothing downstream has
|
|
1634
|
+
* to find them a second time and find different ones.
|
|
1573
1635
|
* @property {number} keepBuilds How many builds of this product other than the reference keep
|
|
1574
1636
|
* their full record. Everything older is thinned out at the end of a run.
|
|
1575
1637
|
* @property {string} [referenceSha] The commit the build you were happy with is at. It is
|
|
@@ -1782,6 +1844,112 @@ export function suiteBudgetFrom(config) {
|
|
|
1782
1844
|
return Number.isFinite(asked) && asked >= 0 ? Math.floor(asked) : null;
|
|
1783
1845
|
}
|
|
1784
1846
|
|
|
1847
|
+
/**
|
|
1848
|
+
* How long the harvest gets when NOBODY asked for it.
|
|
1849
|
+
*
|
|
1850
|
+
* A quarter of what somebody who typed `--journeys suite` gets, and that gap is the whole
|
|
1851
|
+
* design. Measured on this machine on 2026-08-31: twelve near-empty test files harvested in
|
|
1852
|
+
* 3.1 seconds. Twenty seconds therefore covers a small suite outright and takes a useful bite
|
|
1853
|
+
* out of a large one, and every file it does not reach is named in the coverage list with the
|
|
1854
|
+
* command that would reach it. The alternative — deciding from a file count whether to run at
|
|
1855
|
+
* all — guesses at how slow somebody's tests are and is wrong in both directions.
|
|
1856
|
+
*/
|
|
1857
|
+
const AUTO_HARVEST_BUDGET_MS = 20_000;
|
|
1858
|
+
|
|
1859
|
+
/**
|
|
1860
|
+
* How many harvested test files an unasked-for run will then WALK.
|
|
1861
|
+
*
|
|
1862
|
+
* The harvest budget bounds the harvest and not what comes after it: each harvested journey
|
|
1863
|
+
* is walked twice on the new build and again on the old one. Measured on this machine on
|
|
1864
|
+
* 2026-08-31, twelve harvested journeys took a check from 1.4 seconds to 8.2. Twelve is
|
|
1865
|
+
* therefore the cap, and the files past it are named rather than dropped in silence.
|
|
1866
|
+
*/
|
|
1867
|
+
const AUTO_HARVEST_JOURNEY_CAP = 12;
|
|
1868
|
+
|
|
1869
|
+
/**
|
|
1870
|
+
* How long ONE test file gets on an unasked-for run, harvesting and walking alike.
|
|
1871
|
+
*
|
|
1872
|
+
* The budget above is checked before a file STARTS, never in the middle of one, so without
|
|
1873
|
+
* this a single slow test file could walk straight through a twenty-second budget and spend
|
|
1874
|
+
* the runner's default two minutes doing it — turning a bounded default into an unbounded
|
|
1875
|
+
* one on exactly the projects where that hurts most. Thirty seconds is generous for one file
|
|
1876
|
+
* of a suite somebody runs on every change, and a file that needs longer is named as a hole
|
|
1877
|
+
* with the reason, which is the honest outcome rather than a silent wait.
|
|
1878
|
+
*/
|
|
1879
|
+
const AUTO_FILE_TIMEOUT_MS = 30_000;
|
|
1880
|
+
|
|
1881
|
+
/**
|
|
1882
|
+
* Should this run harvest the project's own tests without being asked?
|
|
1883
|
+
*
|
|
1884
|
+
* The question is only ever "can this be done at all", never "is this project's suite worth
|
|
1885
|
+
* it" — a suite that is too slow is handled by the budget and the cap above, not by refusing
|
|
1886
|
+
* to look. Everything here is cheap: package.json is read, a few filenames are tested for
|
|
1887
|
+
* existence, and nothing is run.
|
|
1888
|
+
*
|
|
1889
|
+
* IT CAN BE SWITCHED OFF, in two ways, because a default that cannot be turned off is a
|
|
1890
|
+
* default somebody works around by uninstalling. `--journeys code` says "read the source and
|
|
1891
|
+
* nothing else" for one run; `suite: { auto: false }` in the settings says it for good. Both
|
|
1892
|
+
* are reported as a hole in that run's coverage, so switching it off never quietly turns into
|
|
1893
|
+
* believing a check that no longer looks.
|
|
1894
|
+
*
|
|
1895
|
+
* @param {string} root
|
|
1896
|
+
* @param {Record<string, any>} config
|
|
1897
|
+
* @returns {Promise<{run: boolean, gap?: CoverageGap}>}
|
|
1898
|
+
*/
|
|
1899
|
+
async function suiteWorthRunningByDefault(root, config) {
|
|
1900
|
+
if (config?.suite?.auto === false) {
|
|
1901
|
+
return {
|
|
1902
|
+
run: false,
|
|
1903
|
+
gap: {
|
|
1904
|
+
what: "This project's own tests were not run, because the settings switch that off.",
|
|
1905
|
+
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.',
|
|
1906
|
+
unlockedBy: 'Remove that line, or run `staysfixed check --journeys suite` once to see what it would find.',
|
|
1907
|
+
},
|
|
1908
|
+
};
|
|
1909
|
+
}
|
|
1910
|
+
try {
|
|
1911
|
+
const { detectRunner } = await import('./journeys/from-suite.js');
|
|
1912
|
+
const found = await detectRunner(root);
|
|
1913
|
+
if (found.runner === 'none') {
|
|
1914
|
+
// Said out loud, on every run, rather than passed over as "there was nothing to do".
|
|
1915
|
+
// A project with no tests is not a project where the tests are fine — it is a project
|
|
1916
|
+
// where a whole channel is empty, and on a library that channel is most of what there
|
|
1917
|
+
// is to look at. The reader is told which it is.
|
|
1918
|
+
return {
|
|
1919
|
+
run: false,
|
|
1920
|
+
gap: {
|
|
1921
|
+
what: "None of this project's own tests were run, because there are none this tool can find.",
|
|
1922
|
+
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.`,
|
|
1923
|
+
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.",
|
|
1924
|
+
},
|
|
1925
|
+
};
|
|
1926
|
+
}
|
|
1927
|
+
const blocking = (found.missing ?? []).filter((m) => m.blocking);
|
|
1928
|
+
if (blocking.length > 0) {
|
|
1929
|
+
return {
|
|
1930
|
+
run: false,
|
|
1931
|
+
gap: {
|
|
1932
|
+
what: "This project has a test suite and none of it was run, so nothing here says anything about what those tests cover.",
|
|
1933
|
+
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'}.`,
|
|
1934
|
+
unlockedBy: blocking.map((m) => m.howToGet).join(' '),
|
|
1935
|
+
},
|
|
1936
|
+
};
|
|
1937
|
+
}
|
|
1938
|
+
return { run: true };
|
|
1939
|
+
} catch (e) {
|
|
1940
|
+
// Being unable to work out whether a suite exists is a hole like any other. It must never
|
|
1941
|
+
// read as "this project has no tests", which is the same silence wearing a different hat.
|
|
1942
|
+
return {
|
|
1943
|
+
run: false,
|
|
1944
|
+
gap: {
|
|
1945
|
+
what: "Nothing could work out whether this project has a test suite, so none of it was run.",
|
|
1946
|
+
why: messageOf(e),
|
|
1947
|
+
unlockedBy: 'Run `staysfixed check --journeys suite` to see what it says, or `staysfixed doctor` for what this folder is missing.',
|
|
1948
|
+
},
|
|
1949
|
+
};
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1785
1953
|
/**
|
|
1786
1954
|
* Thin out the record of builds nobody is going to ask about again.
|
|
1787
1955
|
*
|
|
@@ -2121,6 +2289,12 @@ async function openProject(options) {
|
|
|
2121
2289
|
store,
|
|
2122
2290
|
candidate,
|
|
2123
2291
|
keepBuilds: keepBuildsFrom(config),
|
|
2292
|
+
// The folders THIS run is reading, carried so the coverage ledger counts the doors of the
|
|
2293
|
+
// same product the run walked. The ledger can find the settings itself, and does — but it
|
|
2294
|
+
// finds them by looking beside the project, and a run started with `--config elsewhere`
|
|
2295
|
+
// is reading a different file. Two answers to "what is in this project" is how the
|
|
2296
|
+
// ledger came to measure "78 of 78 doors" from 8 of 20 files. Measured 2026-08-31.
|
|
2297
|
+
sourceFolders: Array.isArray(config.source?.folders) ? config.source.folders : undefined,
|
|
2124
2298
|
referenceSha,
|
|
2125
2299
|
against: reference ? reference.id : options.against,
|
|
2126
2300
|
journeys,
|
|
@@ -2322,6 +2496,14 @@ async function walkOne(req, where) {
|
|
|
2322
2496
|
ctx,
|
|
2323
2497
|
);
|
|
2324
2498
|
observations = await adapter.run(req.journey, prepared, ctx);
|
|
2499
|
+
// An answer sheet arrives as one wall of text at one address, because that is what the
|
|
2500
|
+
// process adapter does with anything a command prints. Left that way, a library whose
|
|
2501
|
+
// every return value changed produced ONE finding, worded as a window onto the middle of
|
|
2502
|
+
// a string: "…eserved(\"admin\") -> false…" where it read "…eserved(\"admin\") -> true…".
|
|
2503
|
+
// True, and useless to the person who has to decide whether to ship. Taken apart, every
|
|
2504
|
+
// call gets the exported name's own address and the finding names the function, the
|
|
2505
|
+
// input and both answers. Measured 2026-08-31 — see `splitAnswerSheet`.
|
|
2506
|
+
if (isAnAnswerJourney(req.journey)) observations = splitAnswerSheet(observations, req.journey);
|
|
2325
2507
|
} catch (e) {
|
|
2326
2508
|
// A journey that fell over is a hole in the coverage, never a silent pass and never
|
|
2327
2509
|
// the end of the run — the other journeys' work is worth keeping.
|
|
@@ -2377,6 +2559,65 @@ async function walkOne(req, where) {
|
|
|
2377
2559
|
};
|
|
2378
2560
|
}
|
|
2379
2561
|
|
|
2562
|
+
/**
|
|
2563
|
+
* Something that can walk one journey in this project, right now, without a whole check.
|
|
2564
|
+
*
|
|
2565
|
+
* A check is the only thing that walked a journey until 2026-08-31, and that left the
|
|
2566
|
+
* recording command with a choice between running a full check to find out whether a fresh
|
|
2567
|
+
* recording repeats — minutes, a store write, a verdict nobody asked for — or writing a
|
|
2568
|
+
* second, simpler walker of its own, which would then be the walker that never gets fixed
|
|
2569
|
+
* when the real one is. Neither is acceptable, so the walk is handed out instead: the same
|
|
2570
|
+
* adapters, the same scratch-copy-per-walk rule, and the same normalisation a real check
|
|
2571
|
+
* applies, so what a recording is judged on is exactly what a later check will see.
|
|
2572
|
+
*
|
|
2573
|
+
* The caller closes it. Everything it made lives in one throwaway folder and `close` takes
|
|
2574
|
+
* that folder away.
|
|
2575
|
+
*
|
|
2576
|
+
* @param {{cwd?: string, root?: string, configFile?: string, config?: Record<string, any>}} [options]
|
|
2577
|
+
* @returns {Promise<{root: string, config: Record<string, any>, walk: (req: WalkRequest) => Promise<Capture>, close: () => Promise<void>}>}
|
|
2578
|
+
*/
|
|
2579
|
+
export async function walkerFor(options = {}) {
|
|
2580
|
+
await loadAdapters();
|
|
2581
|
+
const root = projectRootFor(options);
|
|
2582
|
+
const config = options.config ?? (await readConfig(options.configFile ?? findConfigFile(root)));
|
|
2583
|
+
const scratch = await fsp.mkdtemp(path.join(os.tmpdir(), 'staysfixed-walk-'));
|
|
2584
|
+
const evidenceDir = path.join(scratch, 'evidence');
|
|
2585
|
+
await fsp.mkdir(evidenceDir, { recursive: true });
|
|
2586
|
+
// The same rewriting a check does, and for the same reason: every walk gets its own
|
|
2587
|
+
// throwaway folder, so a product that prints where it is running from would otherwise
|
|
2588
|
+
// look different on every single walk — including the two walks that are meant to prove a
|
|
2589
|
+
// recording repeats, which would then never repeat and no recording would ever be
|
|
2590
|
+
// accepted.
|
|
2591
|
+
const rules = mergeRules(DEFAULT_RULES, [
|
|
2592
|
+
...pathRules({ root, scratch }),
|
|
2593
|
+
...(await loadRules(path.join(root, '.staysfixed', 'rules.json'))),
|
|
2594
|
+
]);
|
|
2595
|
+
return {
|
|
2596
|
+
root,
|
|
2597
|
+
config,
|
|
2598
|
+
walk: async (req) => normaliseCapture(await walkOne(req, { root, scratch, evidenceDir, config }), rules),
|
|
2599
|
+
close: async () => {
|
|
2600
|
+
await fsp.rm(scratch, { recursive: true, force: true }).catch(() => {});
|
|
2601
|
+
},
|
|
2602
|
+
};
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
/**
|
|
2606
|
+
* This project's settings, found the way a check finds them.
|
|
2607
|
+
*
|
|
2608
|
+
* Exported so that nothing else has to re-implement "walk up from here looking for a config
|
|
2609
|
+
* file, and read it whether it is JSON or a module". Two readers of one settings file that
|
|
2610
|
+
* disagree about where it is, is a bug that only shows up in somebody else's repository.
|
|
2611
|
+
*
|
|
2612
|
+
* @param {{cwd?: string, root?: string, configFile?: string}} [options]
|
|
2613
|
+
* @returns {Promise<{root: string, configFile: string|null, config: Record<string, any>}>}
|
|
2614
|
+
*/
|
|
2615
|
+
export async function settingsFor(options = {}) {
|
|
2616
|
+
const root = projectRootFor(options);
|
|
2617
|
+
const configFile = options.configFile ?? findConfigFile(root) ?? null;
|
|
2618
|
+
return { root, configFile, config: await readConfig(configFile) };
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2380
2621
|
/**
|
|
2381
2622
|
* @param {Journey} journey
|
|
2382
2623
|
* @returns {Adapter|null}
|
|
@@ -2409,44 +2650,118 @@ async function gatherJourneys({ root, config, options }) {
|
|
|
2409
2650
|
const gaps = [];
|
|
2410
2651
|
|
|
2411
2652
|
const named =
|
|
2412
|
-
options.journeys && !['code', 'config', 'suite'].includes(options.journeys) ? options.journeys : null;
|
|
2413
|
-
|
|
2414
|
-
//
|
|
2415
|
-
// the
|
|
2416
|
-
//
|
|
2653
|
+
options.journeys && !['code', 'config', 'suite', 'recorded'].includes(options.journeys) ? options.journeys : null;
|
|
2654
|
+
|
|
2655
|
+
// Sessions somebody actually performed, read back off the disk and walked like anything
|
|
2656
|
+
// else. This is the one source that knows how a person really uses the product — the four
|
|
2657
|
+
// screens they open every morning, in that order — and no amount of reading the source can
|
|
2658
|
+
// work that out, because the source only says which doors exist, never which ones anybody
|
|
2659
|
+
// opens. Until 2026-08-31 asking for it threw: the code to make a recording existed, and
|
|
2660
|
+
// nothing on the check path ever read one.
|
|
2661
|
+
//
|
|
2662
|
+
// A run that was ASKED for recorded sessions and found none stops and says so. Carrying on
|
|
2663
|
+
// with the journeys read out of the code would walk something the person did not ask for
|
|
2664
|
+
// and then report "nothing that worked has broken" — a clean answer about the wrong steps,
|
|
2665
|
+
// which is the one shape of reply this tool may never produce.
|
|
2417
2666
|
if (options.journeys === 'recorded') {
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2667
|
+
const { RECORDINGS_DIR, loadJourneyFolder, whatWillNotReplay } = await import('./journeys/record.js');
|
|
2668
|
+
const dir = path.join(root, RECORDINGS_DIR);
|
|
2669
|
+
const loaded = await loadJourneyFolder(dir);
|
|
2670
|
+
if (loaded.journeys.length === 0) {
|
|
2671
|
+
throw new StaysFixedError(
|
|
2672
|
+
`You asked for recorded sessions and there are none in ${shortPath(dir)}, so nothing was checked.`,
|
|
2673
|
+
{
|
|
2674
|
+
hint:
|
|
2675
|
+
'Make one: `staysfixed record <a-name-for-it>` opens your product, follows what you do, walks it twice to prove it repeats, and writes it there. ' +
|
|
2676
|
+
`${loaded.problems.length > 0 ? `Something is already in that folder and could not be read: ${loaded.problems.join(' ')} ` : ''}` +
|
|
2677
|
+
'Or leave --journeys out to use the steps each adapter reads from your source.',
|
|
2678
|
+
},
|
|
2679
|
+
);
|
|
2680
|
+
}
|
|
2681
|
+
for (const journey of loaded.journeys) {
|
|
2682
|
+
// Said before it is walked, not after it fails. A recording rots quietly: the ids,
|
|
2683
|
+
// ports and timestamps captured on the afternoon somebody made it go stale, and the
|
|
2684
|
+
// replay then fails for a reason that has nothing to do with the product.
|
|
2685
|
+
const willNotReplay = whatWillNotReplay(journey);
|
|
2686
|
+
if (willNotReplay.length > 0) {
|
|
2687
|
+
gaps.push({
|
|
2688
|
+
what: `The recorded session "${journey.name}" may not replay.`,
|
|
2689
|
+
why: willNotReplay.join(' '),
|
|
2690
|
+
unlockedBy: 'Record it again with `staysfixed record`, or reach the same thing from the code or the test suite, where nothing goes stale.',
|
|
2691
|
+
surface: journey.surface,
|
|
2692
|
+
});
|
|
2693
|
+
}
|
|
2694
|
+
}
|
|
2695
|
+
for (const problem of loaded.problems) {
|
|
2696
|
+
gaps.push({
|
|
2697
|
+
what: 'A file in the recordings folder was not walked.',
|
|
2698
|
+
why: problem,
|
|
2699
|
+
unlockedBy: 'Fix that file, or record the session again. A recording nothing can read is a hole, not a pass.',
|
|
2700
|
+
});
|
|
2701
|
+
}
|
|
2702
|
+
journeys.push(...loaded.journeys);
|
|
2424
2703
|
}
|
|
2704
|
+
|
|
2425
2705
|
if (named) journeys.push(...(await readJourneyFile(path.resolve(root, named))));
|
|
2426
2706
|
|
|
2427
|
-
// The project's own test suite
|
|
2428
|
-
//
|
|
2429
|
-
//
|
|
2430
|
-
//
|
|
2707
|
+
// ---- The project's own test suite.
|
|
2708
|
+
//
|
|
2709
|
+
// WHY THIS USED TO BE OFF BY DEFAULT, and the reasoning was right as far as it went: this
|
|
2710
|
+
// RUNS somebody else's tests — every file twice to harvest, and then every harvested
|
|
2711
|
+
// journey twice more on each build — and charging a stranger for that on a command they
|
|
2712
|
+
// ran to get a fast answer is how a tool gets uninstalled. So it waited for
|
|
2713
|
+
// `--journeys suite`.
|
|
2714
|
+
//
|
|
2715
|
+
// WHY IT IS NOW ON BY DEFAULT ANYWAY. The cost was measured against the wrong thing. It was
|
|
2716
|
+
// weighed against a slower check; it should have been weighed against a WRONG one. Measured
|
|
2717
|
+
// 2026-08-31 on a four-line library: two exported functions were rewritten so that every
|
|
2718
|
+
// web address the product produces came out different, and the default check answered
|
|
2719
|
+
// "Nothing that worked has changed" and exited 0, because no default channel had ever
|
|
2720
|
+
// called a function. A flag that is off by default cannot save anybody, and a false
|
|
2721
|
+
// all-clear is not a cheaper answer than a slow one — it is the one answer this tool may
|
|
2722
|
+
// never give.
|
|
2431
2723
|
//
|
|
2432
|
-
//
|
|
2433
|
-
//
|
|
2434
|
-
//
|
|
2435
|
-
//
|
|
2724
|
+
// WHERE THE LINE IS DRAWN, and the measurement that drew it. Default-on is held to a
|
|
2725
|
+
// TIGHTER budget than an explicit `--journeys suite`, and to a cap on how many harvested
|
|
2726
|
+
// journeys are then walked, so the cost of a check nobody asked to slow down is bounded by
|
|
2727
|
+
// construction instead of by a guess about somebody's suite. Measured on this machine on
|
|
2728
|
+
// 2026-08-31, with twelve near-empty test files: harvesting them took 3.1 seconds, and the
|
|
2729
|
+
// whole check went from 1.4 seconds to 8.2 — about 570ms per test file, and that is the
|
|
2730
|
+
// FLOOR, because those tests did nothing. So the automatic path gets 20 seconds of harvest
|
|
2731
|
+
// and walks at most 12 of what comes out, which lands a default check at well under half a
|
|
2732
|
+
// minute on a project of that shape. Everything the budget or the cap left out is named as
|
|
2733
|
+
// a hole with the command that would reach it — never dropped quietly.
|
|
2734
|
+
//
|
|
2735
|
+
// Asking for it by name still gets the full, uncapped ninety seconds, because somebody who
|
|
2736
|
+
// typed `--journeys suite` has said what they are willing to wait for.
|
|
2436
2737
|
//
|
|
2437
2738
|
// Loaded here rather than at the top of the file: a copy of this tool without the harvest
|
|
2438
2739
|
// in it still runs every other kind of check, and saying so is better than failing to start.
|
|
2439
|
-
|
|
2740
|
+
const askedForTheSuite = options.journeys === 'suite';
|
|
2741
|
+
let autoSuite = null;
|
|
2742
|
+
if (!askedForTheSuite && !named && options.journeys !== 'recorded' && options.journeys !== 'code') {
|
|
2743
|
+
autoSuite = await suiteWorthRunningByDefault(root, config);
|
|
2744
|
+
if (autoSuite.gap) gaps.push(autoSuite.gap);
|
|
2745
|
+
}
|
|
2746
|
+
if (askedForTheSuite || autoSuite?.run) {
|
|
2747
|
+
const automatic = !askedForTheSuite;
|
|
2440
2748
|
try {
|
|
2441
2749
|
const { journeysFromSuite, DEFAULT_HARVEST_BUDGET_MS } = await import('./journeys/index.js');
|
|
2442
2750
|
// The settings file gets a say in how long this is allowed to take. Left out, the
|
|
2443
2751
|
// harvest applies its own default, which is why nothing is passed rather than the
|
|
2444
|
-
// default being copied to here — see `suiteBudgetFrom`.
|
|
2445
|
-
|
|
2752
|
+
// default being copied to here — see `suiteBudgetFrom`. On the automatic path the
|
|
2753
|
+
// tighter budget is used unless the settings ask for something of their own, because a
|
|
2754
|
+
// number somebody wrote down beats a number this file guessed.
|
|
2755
|
+
const asked = suiteBudgetFrom(config);
|
|
2756
|
+
const budgetMs = asked ?? (automatic ? AUTO_HARVEST_BUDGET_MS : null);
|
|
2757
|
+
const suiteOptions = {
|
|
2758
|
+
...(budgetMs === null ? {} : { budgetMs }),
|
|
2759
|
+
...(automatic ? { timeoutMs: AUTO_FILE_TIMEOUT_MS } : {}),
|
|
2760
|
+
};
|
|
2446
2761
|
const suite = await journeysFromSuite({
|
|
2447
2762
|
root,
|
|
2448
2763
|
surface: options.surface === 'auto' ? undefined : options.surface,
|
|
2449
|
-
...(
|
|
2764
|
+
...(Object.keys(suiteOptions).length === 0 ? {} : { suite: suiteOptions }),
|
|
2450
2765
|
// The harvest talks while it works, and it can take most of a minute. Its sentences
|
|
2451
2766
|
// go into the same stream as everything else rather than nowhere.
|
|
2452
2767
|
log: (message) => options.events?.emit({ type: 'note', at: options.events.elapsed(), message }),
|
|
@@ -2462,9 +2777,24 @@ async function gatherJourneys({ root, config, options }) {
|
|
|
2462
2777
|
message:
|
|
2463
2778
|
applied === 0
|
|
2464
2779
|
? '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.'
|
|
2465
|
-
: `The test-suite harvest was held to ${Math.round(applied / 1000)} seconds${
|
|
2780
|
+
: `The test-suite harvest was held to ${Math.round(applied / 1000)} seconds${
|
|
2781
|
+
asked !== null ? ', which your settings asked for' : automatic ? ', which is what an automatic run gets' : ', which is the default'
|
|
2782
|
+
}. Anything it did not reach in that time is named below rather than skipped quietly; change it with suite.budgetMs.`,
|
|
2466
2783
|
});
|
|
2467
|
-
|
|
2784
|
+
// The cap, and only on the automatic path. Somebody who typed the flag gets everything
|
|
2785
|
+
// their suite produced. Whoever did not type anything gets a bounded run and a list of
|
|
2786
|
+
// exactly which of their test files are therefore not being watched.
|
|
2787
|
+
let kept = suite.journeys;
|
|
2788
|
+
if (automatic && kept.length > AUTO_HARVEST_JOURNEY_CAP) {
|
|
2789
|
+
const dropped = kept.slice(AUTO_HARVEST_JOURNEY_CAP);
|
|
2790
|
+
kept = kept.slice(0, AUTO_HARVEST_JOURNEY_CAP);
|
|
2791
|
+
gaps.push({
|
|
2792
|
+
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(', ')}.`,
|
|
2793
|
+
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.`,
|
|
2794
|
+
unlockedBy: 'Run `staysfixed check --journeys suite` to walk all of them, or narrow the suite to the files that matter.',
|
|
2795
|
+
});
|
|
2796
|
+
}
|
|
2797
|
+
journeys.push(...kept);
|
|
2468
2798
|
gaps.push(...suite.gaps);
|
|
2469
2799
|
} catch (e) {
|
|
2470
2800
|
// A harvest that fell over is a hole, never a pass. Everything else this project has is
|
|
@@ -2477,13 +2807,37 @@ async function gatherJourneys({ root, config, options }) {
|
|
|
2477
2807
|
}
|
|
2478
2808
|
}
|
|
2479
2809
|
|
|
2810
|
+
// ---- Calling what a library exports, rather than only reading its labels.
|
|
2811
|
+
//
|
|
2812
|
+
// See `from-exports.js` for the false all-clear that put this here. In one sentence: a
|
|
2813
|
+
// library was checked, shipped, rewritten so that every value it returns came out
|
|
2814
|
+
// different, and checked again — and the check passed, because every channel in the tool
|
|
2815
|
+
// compared the NAMES and SHAPES of the exports and none of them had ever called one.
|
|
2816
|
+
//
|
|
2817
|
+
// It costs one extra process per configured module per build, which is the cheapest thing
|
|
2818
|
+
// on this page, and it needs nothing configured that is not configured already: `init`
|
|
2819
|
+
// writes `process.imports` for every library it sets up.
|
|
2820
|
+
if (!named && options.journeys !== 'recorded') {
|
|
2821
|
+
const answers = journeysFromExports({ config: config.process });
|
|
2822
|
+
journeys.push(...answers.journeys);
|
|
2823
|
+
gaps.push(...answers.gaps);
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2480
2826
|
for (const adapter of ADAPTERS) {
|
|
2481
2827
|
if (adapter === sourceAdapter && named && options.journeys !== 'code') {
|
|
2482
2828
|
// A journeys file names exactly what to walk. The contract read is still added,
|
|
2483
2829
|
// because it cannot break anything and it sees what no journey does.
|
|
2484
2830
|
}
|
|
2485
2831
|
/** @type {import('./adapters/contract.js').AdapterProject} */
|
|
2486
|
-
|
|
2832
|
+
// The folders the settings name, handed to every adapter alongside its own block.
|
|
2833
|
+
//
|
|
2834
|
+
// An adapter is given only the settings under its own name, so `http` could see
|
|
2835
|
+
// `http.folders` and never `source.folders` — which is where `init` actually writes them.
|
|
2836
|
+
// Route discovery therefore read the folders it guesses at, and a route outside them was
|
|
2837
|
+
// never found on a project that had said, in its own settings, exactly where its code is.
|
|
2838
|
+
// The adapter's own block still wins, because a project that overrode this meant it.
|
|
2839
|
+
// Measured 2026-08-31.
|
|
2840
|
+
const project = { root, config: { folders: config.source?.folders, ...(config[adapter.name] ?? {}) } };
|
|
2487
2841
|
let detection;
|
|
2488
2842
|
try {
|
|
2489
2843
|
detection = await adapter.detect(project);
|
|
@@ -2501,7 +2855,11 @@ async function gatherJourneys({ root, config, options }) {
|
|
|
2501
2855
|
continue;
|
|
2502
2856
|
}
|
|
2503
2857
|
if (!detection.applies) continue;
|
|
2504
|
-
|
|
2858
|
+
// A journeys file and a recorded session both name exactly what to walk, so no adapter
|
|
2859
|
+
// adds journeys of its own on top of them. The source reader is the exception: it runs
|
|
2860
|
+
// nothing, it cannot break anything, and it is the only channel that sees a door nobody
|
|
2861
|
+
// has ever walked through.
|
|
2862
|
+
if (adapter !== sourceAdapter && (named || options.journeys === 'recorded')) continue;
|
|
2505
2863
|
try {
|
|
2506
2864
|
journeys.push(...(await adapter.journeys(project)));
|
|
2507
2865
|
} catch (e) {
|
|
@@ -2705,6 +3063,69 @@ function nameOfReference(reference, asked) {
|
|
|
2705
3063
|
return asked && asked.trim() !== '' ? `${asked} (${reference.id})` : reference.id;
|
|
2706
3064
|
}
|
|
2707
3065
|
|
|
3066
|
+
/**
|
|
3067
|
+
* Put one commit's files into a folder, without a shell and without touching the repository.
|
|
3068
|
+
*
|
|
3069
|
+
* `git archive` writes a tar to its standard output and `tar` reads one from its standard
|
|
3070
|
+
* input, so the two are joined here directly. The archive never reaches the disk, which is why
|
|
3071
|
+
* a big repository does not cost twice the space to look at — the reason the shell pipeline was
|
|
3072
|
+
* there in the first place. Both programs are on every machine this runs on: Windows has
|
|
3073
|
+
* shipped `tar.exe` since Windows 10, and git is already required for anything here to work.
|
|
3074
|
+
*
|
|
3075
|
+
* @param {string} root The repository.
|
|
3076
|
+
* @param {string} sha The commit to put back.
|
|
3077
|
+
* @param {string} dir An empty folder to put it in.
|
|
3078
|
+
* @returns {Promise<void>}
|
|
3079
|
+
*/
|
|
3080
|
+
function gitArchiveInto(root, sha, dir) {
|
|
3081
|
+
return new Promise((resolve, reject) => {
|
|
3082
|
+
const git = spawn('git', ['-C', root, 'archive', '--format=tar', sha], {
|
|
3083
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
3084
|
+
windowsHide: true,
|
|
3085
|
+
});
|
|
3086
|
+
const untar = spawn('tar', ['-x', '-f', '-', '-C', dir], {
|
|
3087
|
+
stdio: ['pipe', 'ignore', 'pipe'],
|
|
3088
|
+
windowsHide: true,
|
|
3089
|
+
});
|
|
3090
|
+
|
|
3091
|
+
let said = '';
|
|
3092
|
+
for (const stream of [git.stderr, untar.stderr]) {
|
|
3093
|
+
stream?.setEncoding('utf8');
|
|
3094
|
+
stream?.on('data', (chunk) => { said = (said + chunk).slice(0, 4000); });
|
|
3095
|
+
}
|
|
3096
|
+
|
|
3097
|
+
let done = false;
|
|
3098
|
+
/** @param {Error|null} e */
|
|
3099
|
+
const finish = (e) => {
|
|
3100
|
+
if (done) return;
|
|
3101
|
+
done = true;
|
|
3102
|
+
clearTimeout(giveUp);
|
|
3103
|
+
try { git.kill('SIGKILL'); } catch { /* already gone */ }
|
|
3104
|
+
try { untar.kill('SIGKILL'); } catch { /* already gone */ }
|
|
3105
|
+
if (e) reject(e);
|
|
3106
|
+
else resolve();
|
|
3107
|
+
};
|
|
3108
|
+
const giveUp = setTimeout(
|
|
3109
|
+
() => finish(new Error(`putting ${sha.slice(0, 7)} back took longer than two minutes.`)),
|
|
3110
|
+
120_000,
|
|
3111
|
+
);
|
|
3112
|
+
|
|
3113
|
+
git.on('error', finish);
|
|
3114
|
+
untar.on('error', finish);
|
|
3115
|
+
git.stdout.pipe(untar.stdin);
|
|
3116
|
+
// A pipe that breaks because the other end has died is not news worth an unhandled error.
|
|
3117
|
+
git.stdout.on('error', () => {});
|
|
3118
|
+
untar.stdin.on('error', () => {});
|
|
3119
|
+
|
|
3120
|
+
git.on('exit', (code) => {
|
|
3121
|
+
if (code !== 0) finish(new Error(said.trim() || `git archive stopped with code ${code}`));
|
|
3122
|
+
});
|
|
3123
|
+
untar.on('exit', (code) => {
|
|
3124
|
+
finish(code === 0 ? null : new Error(said.trim() || `tar stopped with code ${code}`));
|
|
3125
|
+
});
|
|
3126
|
+
});
|
|
3127
|
+
}
|
|
3128
|
+
|
|
2708
3129
|
/**
|
|
2709
3130
|
* Put the old build back on this machine so it can be walked live.
|
|
2710
3131
|
*
|
|
@@ -2737,12 +3158,13 @@ async function exportBuild(root, reference, scratch) {
|
|
|
2737
3158
|
const dir = path.join(scratch, `reference-${sha.slice(0, 12)}`);
|
|
2738
3159
|
await fsp.mkdir(dir, { recursive: true });
|
|
2739
3160
|
try {
|
|
2740
|
-
// Straight through a pipe: the archive is never written to disk, so a big repository
|
|
2741
|
-
//
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
3161
|
+
// Straight through a pipe: the archive is never written to disk, so a big repository does
|
|
3162
|
+
// not cost twice the space to look at. The two programs are joined below rather than by a
|
|
3163
|
+
// shell, because there is no `/bin/sh` on Windows — and that one word was the whole of
|
|
3164
|
+
// paired mode there. Measured on a real Windows 11 machine on 2026-08-31: every `--paired`
|
|
3165
|
+
// run answered "<sha> cannot be built here" and fell back to the stored record, which is
|
|
3166
|
+
// the weaker comparison. The tool's strongest mode had never once run on Windows.
|
|
3167
|
+
await gitArchiveInto(root, sha, dir);
|
|
2746
3168
|
} catch (e) {
|
|
2747
3169
|
await fsp.rm(dir, { recursive: true, force: true });
|
|
2748
3170
|
throw new StaysFixedError(`${sha.slice(0, 7)} could not be put back on this machine, so it cannot be walked live. ${messageOf(e)}`, {
|
|
@@ -2863,10 +3285,6 @@ async function packageVersion(root) {
|
|
|
2863
3285
|
return typeof pkg?.version === 'string' ? pkg.version : null;
|
|
2864
3286
|
}
|
|
2865
3287
|
|
|
2866
|
-
/** @param {string} text */
|
|
2867
|
-
function quote(text) {
|
|
2868
|
-
return `'${text.split("'").join(`'\\''`)}'`;
|
|
2869
|
-
}
|
|
2870
3288
|
|
|
2871
3289
|
/** @param {string} name */
|
|
2872
3290
|
function safeSegment(name) {
|