staysfixed 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -2
- package/docs/guards.md +18 -0
- package/docs/how-v2-works.md +10 -0
- package/package.json +1 -1
- package/src/cli/approve.js +4 -1
- package/src/cli/flake.js +4 -1
- package/src/cli/mark.js +5 -1
- package/src/cli/status.js +53 -1
- package/src/cli/trace.js +27 -2
- package/src/core/config.js +136 -25
- package/src/core/stop-tree.js +109 -0
- package/src/drive/browser.js +20 -31
- package/src/drive/page.js +74 -2
- package/src/guard/api.js +14 -9
- package/src/types.js +1 -1
- package/src/v2/adapters/child.js +15 -17
- package/src/v2/adapters/contract.js +122 -1
- package/src/v2/adapters/http.js +152 -30
- package/src/v2/adapters/isolate.js +169 -14
- package/src/v2/adapters/process.js +72 -8
- package/src/v2/adapters/source.js +254 -7
- package/src/v2/adapters/web.js +69 -19
- package/src/v2/browsers.js +136 -24
- package/src/v2/cause.js +46 -5
- package/src/v2/check.js +332 -34
- package/src/v2/cli.js +19 -1
- package/src/v2/coverage.js +555 -18
- package/src/v2/detect.js +737 -40
- package/src/v2/doctor.js +3 -3
- package/src/v2/escalate.js +57 -11
- package/src/v2/init.js +562 -21
- package/src/v2/journeys/answers-probe.js +376 -0
- package/src/v2/journeys/from-exports.js +456 -0
- package/src/v2/journeys/from-suite.js +9 -1
- package/src/v2/mcp/tools.js +185 -12
- package/src/v2/observation.js +145 -0
- package/src/v2/run.js +133 -9
- package/src/v2/selfcheck.js +297 -11
- package/src/v2/store.js +16 -1
package/src/v2/adapters/http.js
CHANGED
|
@@ -33,7 +33,7 @@ import net from 'node:net';
|
|
|
33
33
|
import path from 'node:path';
|
|
34
34
|
import {
|
|
35
35
|
defineAdapter, joinPath, notCovered, observation, sizeBucket, stableValue,
|
|
36
|
-
howLongItTook, timeBucket, trimForStorage, undoOurFootprint,
|
|
36
|
+
howLongItTook, timeBucket, trimForStorage, undoOurFootprint, whatItSaid,
|
|
37
37
|
} from './contract.js';
|
|
38
38
|
import {
|
|
39
39
|
compareTrees, copyForScratch, frozenEnvironment, readWatcher, snapshotTree, watcherScript,
|
|
@@ -533,7 +533,16 @@ export const httpAdapter = defineAdapter({
|
|
|
533
533
|
const framework = ['express', 'fastify', 'hono', 'koa', 'next', 'polka', '@hapi/hapi']
|
|
534
534
|
.find((name) => name in dependencies);
|
|
535
535
|
|
|
536
|
-
|
|
536
|
+
// The folders the settings name, not the built-in guess. Route discovery read only src,
|
|
537
|
+
// lib, app, bin, server, pages, api, electron, main and packages, so a project that keeps
|
|
538
|
+
// its routes in a folder outside that list had them read as not existing — measured
|
|
539
|
+
// 2026-08-31 with routes in a top-level `routes/` folder, where `GET /api/orders` and
|
|
540
|
+
// `POST /api/orders` were both invisible and the run reported one route where there were
|
|
541
|
+
// three. NOTE, and this is a real limit of the fix: an adapter is only handed the settings
|
|
542
|
+
// under its OWN name, so this reads `http.folders` and cannot see `source.folders`, which
|
|
543
|
+
// is where `staysfixed init` writes them today. Naming them under `http` works now; making
|
|
544
|
+
// `source.folders` reach here needs one line in check.js, which this lane does not own.
|
|
545
|
+
const reading = await readContract({ root: project.root, folders: project.config?.folders });
|
|
537
546
|
const routes = [...reading.doors.filter((d) => d.kind === 'route'), ...(await readFileRoutes(project.root)).doors];
|
|
538
547
|
|
|
539
548
|
if (!config.start) {
|
|
@@ -582,7 +591,16 @@ export const httpAdapter = defineAdapter({
|
|
|
582
591
|
async journeys(project) {
|
|
583
592
|
const config = project.config ?? {};
|
|
584
593
|
const samples = config.samples ?? {};
|
|
585
|
-
|
|
594
|
+
// The folders the settings name, not the built-in guess. Route discovery read only src,
|
|
595
|
+
// lib, app, bin, server, pages, api, electron, main and packages, so a project that keeps
|
|
596
|
+
// its routes in a folder outside that list had them read as not existing — measured
|
|
597
|
+
// 2026-08-31 with routes in a top-level `routes/` folder, where `GET /api/orders` and
|
|
598
|
+
// `POST /api/orders` were both invisible and the run reported one route where there were
|
|
599
|
+
// three. NOTE, and this is a real limit of the fix: an adapter is only handed the settings
|
|
600
|
+
// under its OWN name, so this reads `http.folders` and cannot see `source.folders`, which
|
|
601
|
+
// is where `staysfixed init` writes them today. Naming them under `http` works now; making
|
|
602
|
+
// `source.folders` reach here needs one line in check.js, which this lane does not own.
|
|
603
|
+
const reading = await readContract({ root: project.root, folders: project.config?.folders });
|
|
586
604
|
const routes = [...reading.doors.filter((d) => d.kind === 'route'), ...(await readFileRoutes(project.root)).doors];
|
|
587
605
|
|
|
588
606
|
/** @type {Map<string, import('./contract.js').Journey>} */
|
|
@@ -733,9 +751,21 @@ export const httpAdapter = defineAdapter({
|
|
|
733
751
|
|
|
734
752
|
if (!up.up) {
|
|
735
753
|
await stopServer(child);
|
|
754
|
+
// WHAT THE SERVER SAID GOES FIRST, ahead of the wait's own account of the port it knocked
|
|
755
|
+
// on. Measured 2026-08-31: a server whose source has a syntax error prints `SyntaxError`
|
|
756
|
+
// and the failing line on its standard error, and this sentence used to bury that behind
|
|
757
|
+
// a hundred and fifty characters about loopback addresses. `staysfixed coverage` prints
|
|
758
|
+
// the sentence built from this with a 160 character budget, so buried meant gone: the
|
|
759
|
+
// owner of a product that would not boot was never told why, on any surface, even with
|
|
760
|
+
// `--verbose`. The port is the tool's own business; the syntax error is the product's,
|
|
761
|
+
// and it is the only line here that anybody can act on.
|
|
762
|
+
const printed = Buffer.concat(bootErr).toString('utf8') || Buffer.concat(bootOut).toString('utf8');
|
|
763
|
+
const headline = whatItSaid(printed, { mostLines: 1 });
|
|
736
764
|
return {
|
|
737
765
|
build, root: work, ready: false,
|
|
738
|
-
why: `${up.why} What it printed while trying: ${
|
|
766
|
+
why: `${headline ? `It said: ${headline}. ` : ''}${up.why} What it printed while trying, in full: ${
|
|
767
|
+
trimForStorage(printed, 1500).text || '(nothing)'
|
|
768
|
+
}`,
|
|
739
769
|
dispose: async () => { await stopServer(child); await fsp.rm(base, { recursive: true, force: true }); },
|
|
740
770
|
};
|
|
741
771
|
}
|
|
@@ -807,7 +837,20 @@ export const httpAdapter = defineAdapter({
|
|
|
807
837
|
channel: 'effects',
|
|
808
838
|
path: joinPath('api', journey.name, 'answered at all'),
|
|
809
839
|
reason: 'irreversible',
|
|
810
|
-
|
|
840
|
+
// The sentence names the setting, because this is the one place where the guess costs
|
|
841
|
+
// something. The list under "irreversible" is written by `staysfixed init` by matching
|
|
842
|
+
// WORDS IN A ROUTE'S NAME, and a name can be wrong: measured 2026-08-31, a pure
|
|
843
|
+
// arithmetic route called `/api/invoice/estimate` was put on that list because the word
|
|
844
|
+
// "invoice" is in it. On a Node server that costs nothing — the route is walked anyway,
|
|
845
|
+
// behind a refusal boundary that is proven to be in force — but here nothing is
|
|
846
|
+
// watching, and a wrong guess costs the whole route silently. Telling somebody a
|
|
847
|
+
// control exists without telling them where it is leaves them exactly where they were.
|
|
848
|
+
says:
|
|
849
|
+
`${detail.method} ${detail.route} was left alone. It is on the "irreversible" list under "http" in the ` +
|
|
850
|
+
`settings — routes that spend money, send a message or destroy data — and nothing is watching this server ` +
|
|
851
|
+
`from the inside, so there is no way to stop it happening for real. This is a hole in what was checked, not ` +
|
|
852
|
+
`a pass. That list is first written by matching words in a route's name, which is a guess: if this route ` +
|
|
853
|
+
`only reads or works something out, take it off the list and it starts being checked.`,
|
|
811
854
|
})];
|
|
812
855
|
}
|
|
813
856
|
|
|
@@ -877,6 +920,23 @@ async function snapshotForFolders(root, folders) {
|
|
|
877
920
|
// Turning one request into observations
|
|
878
921
|
// ---------------------------------------------------------------------------
|
|
879
922
|
|
|
923
|
+
/**
|
|
924
|
+
* The verbs that carry a body. Asking one of these with no body at all is asking a question
|
|
925
|
+
* the route was never designed to answer, so whatever comes back is about the question.
|
|
926
|
+
*/
|
|
927
|
+
const CARRIES_A_BODY = new Set(['POST', 'PUT', 'PATCH']);
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* What a route answers when what it was handed is not what it needs: 400 Bad Request, 411
|
|
931
|
+
* Length Required, 415 Unsupported Media Type, 422 Unprocessable Content.
|
|
932
|
+
*
|
|
933
|
+
* Deliberately short, and deliberately NOT including 401 or 403. Those mean "you are not
|
|
934
|
+
* signed in", which is a different hole with a different fix, and folding them in here would
|
|
935
|
+
* quietly stop comparing every route of every product behind a login wall on the strength of a
|
|
936
|
+
* guess made in this file.
|
|
937
|
+
*/
|
|
938
|
+
const NOT_WHAT_IT_NEEDS = new Set([400, 411, 415, 422]);
|
|
939
|
+
|
|
880
940
|
/**
|
|
881
941
|
* @param {object} input
|
|
882
942
|
* @param {import('./contract.js').Journey} input.journey
|
|
@@ -907,39 +967,95 @@ export function describeRequest(input) {
|
|
|
907
967
|
return out;
|
|
908
968
|
}
|
|
909
969
|
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
970
|
+
// A ROUTE THAT REFUSED THE CALL IS NOT A ROUTE THAT WAS WALKED.
|
|
971
|
+
//
|
|
972
|
+
// Measured 2026-08-31 on a small quote API. The route list comes out of the source, and the
|
|
973
|
+
// source says a route's address and its verb and nothing whatever about the body it expects —
|
|
974
|
+
// so `POST /api/quote` was asked with no body at all. It answered 400, correctly, and this
|
|
975
|
+
// function wrote that 400 down as the route's behaviour. From then on every run compared the
|
|
976
|
+
// new build's 400 against the old build's 400, agreed, and reported the route as walked in
|
|
977
|
+
// the coverage ledger. The route's real work — the arithmetic that decides what a customer is
|
|
978
|
+
// charged — had never once been run, and a rounding bug in it was invisible for ever, under a
|
|
979
|
+
// result that said the route was covered.
|
|
980
|
+
//
|
|
981
|
+
// A 400 to a request this tool got wrong is an observation of THIS TOOL, not of the product.
|
|
982
|
+
// So it is recorded as what it is: a door found and not opened, with the reason. Everything
|
|
983
|
+
// about this request is marked refused, which is what puts the route in the ledger's
|
|
984
|
+
// never-opened list instead of its walked list — see `walkFromCapture` in coverage.js, where
|
|
985
|
+
// a capture whose whole non-contract record is refusals counts as a walk that did nothing.
|
|
986
|
+
//
|
|
987
|
+
// NARROW ON PURPOSE, in three ways at once, because the cost of getting this wrong is that a
|
|
988
|
+
// real answer stops being compared. It only applies to a verb that carries a body; only when
|
|
989
|
+
// this tool sent no body, so a body somebody wrote into the settings themselves is always the
|
|
990
|
+
// product's own answer and is always compared; and only to the four codes that mean "that is
|
|
991
|
+
// not what I need". A route that answers 400 to a request that was properly formed is
|
|
992
|
+
// untouched by this and goes on being compared exactly as before.
|
|
993
|
+
const verb = String(detail.method ?? 'GET').toUpperCase();
|
|
994
|
+
const weSentNoBody = detail.body === undefined;
|
|
995
|
+
const ourOwnFault = CARRIES_A_BODY.has(verb) && weSentNoBody && NOT_WHAT_IT_NEEDS.has(answer.status);
|
|
996
|
+
if (ourOwnFault) {
|
|
997
|
+
// What the route said about it, in the route's own words. It is usually the exact list of
|
|
998
|
+
// fields it wanted, which is the fastest way for somebody to write the body it needs — so
|
|
999
|
+
// it is quoted rather than summarised, for the same reason a crash's own words are.
|
|
1000
|
+
const complained = whatItSaid(undoOurFootprint(input.text, footprint), { mostLines: 2 });
|
|
1001
|
+
out.push(notCovered({
|
|
1002
|
+
channel: 'results',
|
|
1003
|
+
path: joinPath('api', id, 'answered at all'),
|
|
1004
|
+
reason: 'needs a sample',
|
|
1005
|
+
says:
|
|
1006
|
+
`${asked} was not really walked. It answered ${answer.status}, and that is this tool's request being turned ` +
|
|
1007
|
+
`away rather than anything the route does: it was asked with no body at all, because a route's address and ` +
|
|
1008
|
+
`verb can be read out of the source and the body it expects cannot.${complained ? ` It said: ${complained}.` : ''} ` +
|
|
1009
|
+
`Whatever is behind that check has never run, so a bug in the route's own working — the kind that charges ` +
|
|
1010
|
+
`somebody the wrong amount — would not be seen here, and this route is counted as a door found and not ` +
|
|
1011
|
+
`opened. Put a real body under "requests" in the "http" settings — ` +
|
|
1012
|
+
`{ name: '${asked}', method: '${verb}', url: '${detail.route}', body: { ... } } — and it starts being checked.`,
|
|
1013
|
+
detail: complained,
|
|
1014
|
+
}));
|
|
1015
|
+
} else {
|
|
919
1016
|
out.push(observation({
|
|
920
1017
|
channel: 'results',
|
|
921
|
-
path: joinPath('api', id, '
|
|
922
|
-
value:
|
|
923
|
-
says: `${asked} answered
|
|
1018
|
+
path: joinPath('api', id, 'status'),
|
|
1019
|
+
value: answer.status,
|
|
1020
|
+
says: `${asked} answered ${answer.status}${answer.status >= 400 ? ', which is a refusal' : ''}.`,
|
|
924
1021
|
}));
|
|
925
1022
|
}
|
|
926
1023
|
|
|
927
|
-
|
|
928
|
-
out
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
1024
|
+
// The headers, the body and its shape describe the answer to a request the route rejected,
|
|
1025
|
+
// so on a rejected request they describe this tool and not the product. They are left out
|
|
1026
|
+
// rather than compared, which is what every other never-really-tried branch in this adapter
|
|
1027
|
+
// does — and what the route said for itself is quoted in the sentence above, where somebody
|
|
1028
|
+
// will actually read it. Comparing them would be worse than useless: two builds whose
|
|
1029
|
+
// validation message was reworded would report a difference nobody caused, at an address
|
|
1030
|
+
// standing in for a route neither run has ever been inside.
|
|
1031
|
+
if (!ourOwnFault) {
|
|
1032
|
+
const headers = headersThatMatter(answer.headers);
|
|
1033
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
1034
|
+
out.push(observation({
|
|
1035
|
+
channel: 'results',
|
|
1036
|
+
path: joinPath('api', id, 'header', name),
|
|
1037
|
+
value: undoOurFootprint(Array.isArray(value) ? value.join(', ') : value, footprint),
|
|
1038
|
+
says: `${asked} answered with ${name}: ${Array.isArray(value) ? value.join(', ') : value}.`,
|
|
1039
|
+
}));
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
const body = readBody(answer.headers.get('content-type') ?? '', undoOurFootprint(input.text, footprint));
|
|
937
1043
|
out.push(observation({
|
|
938
1044
|
channel: 'results',
|
|
939
|
-
path: joinPath('api', id, '
|
|
940
|
-
value: body.
|
|
941
|
-
says:
|
|
1045
|
+
path: joinPath('api', id, 'body'),
|
|
1046
|
+
value: body.value,
|
|
1047
|
+
says: body.truncated
|
|
1048
|
+
? `What ${asked} sent back, with the middle left out — the whole of it is ${sizeBucket(body.bytes)}.`
|
|
1049
|
+
: `What ${asked} sent back.`,
|
|
942
1050
|
}));
|
|
1051
|
+
if (body.shape !== undefined) {
|
|
1052
|
+
out.push(observation({
|
|
1053
|
+
channel: 'results',
|
|
1054
|
+
path: joinPath('api', id, 'shape'),
|
|
1055
|
+
value: body.shape,
|
|
1056
|
+
says: `The fields ${asked} sends back and what type each one is. This stays the same while the values change, so a renamed or dropped field shows up on its own instead of buried in a diff of the whole body.`,
|
|
1057
|
+
}));
|
|
1058
|
+
}
|
|
943
1059
|
}
|
|
944
1060
|
|
|
945
1061
|
for (const change of input.changes) {
|
|
@@ -950,6 +1066,12 @@ export function describeRequest(input) {
|
|
|
950
1066
|
says: change.what === 'deleted'
|
|
951
1067
|
? `Answering ${asked} deleted ${change.file}.`
|
|
952
1068
|
: `Answering ${asked} ${change.what} ${change.file}. A route that still answers correctly but has stopped writing this file is broken, and only this line sees it.`,
|
|
1069
|
+
// A file written while the route was turning our request away is a real thing the product
|
|
1070
|
+
// did, and it is kept on the record — but it is not evidence that the route works, and if
|
|
1071
|
+
// it were left as a plain observation this one line would put the route back in the
|
|
1072
|
+
// ledger's walked column and undo the whole fix above.
|
|
1073
|
+
covered: ourOwnFault ? false : undefined,
|
|
1074
|
+
reason: ourOwnFault ? 'needs a sample' : undefined,
|
|
953
1075
|
}));
|
|
954
1076
|
}
|
|
955
1077
|
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
32
|
import crypto from 'node:crypto';
|
|
33
|
+
import fs from 'node:fs';
|
|
33
34
|
import fsp from 'node:fs/promises';
|
|
34
35
|
import net from 'node:net';
|
|
35
36
|
import os from 'node:os';
|
|
@@ -141,11 +142,20 @@ function announce(app) {
|
|
|
141
142
|
*/
|
|
142
143
|
export function appNameFor(binary) {
|
|
143
144
|
const text = String(binary ?? '');
|
|
144
|
-
|
|
145
|
+
// Both separators, not just this machine's. A path is a fact about the machine it came
|
|
146
|
+
// from, and this one is asked about a Mac app bundle — so on Windows, where `path.sep` is a
|
|
147
|
+
// backslash, `/Applications/Widget.app/Contents/MacOS/Widget` did not split at all and the
|
|
148
|
+
// app was announced to the person's screen as "Widget" only by luck, or as the wrong name
|
|
149
|
+
// when the two differ. Measured on a real Windows 11 machine on 2026-08-31, where it
|
|
150
|
+
// answered "Electron" for an app called Terminal Deck.
|
|
151
|
+
const parts = text.split(/[\\/]/);
|
|
145
152
|
for (let i = parts.length - 1; i >= 0; i -= 1) {
|
|
146
153
|
if (parts[i].toLowerCase().endsWith('.app')) return parts[i].slice(0, -4);
|
|
147
154
|
}
|
|
148
|
-
|
|
155
|
+
// The last part of the same split, rather than `path.basename`, so the whole function reads
|
|
156
|
+
// a path the same way from end to end. `path.basename` only knows this machine's separator,
|
|
157
|
+
// and half a function that understands both is worse than either.
|
|
158
|
+
const base = parts[parts.length - 1] ?? '';
|
|
149
159
|
const dot = base.lastIndexOf('.');
|
|
150
160
|
return dot > 0 ? base.slice(0, dot) : base;
|
|
151
161
|
}
|
|
@@ -264,6 +274,52 @@ export async function takePort() {
|
|
|
264
274
|
// Who is holding what
|
|
265
275
|
// ---------------------------------------------------------------------------
|
|
266
276
|
|
|
277
|
+
/**
|
|
278
|
+
* The whole process table, on Windows: who is running, who started them, and with what.
|
|
279
|
+
*
|
|
280
|
+
* This is Windows' `ps -axo pid=,ppid=,command=`. There is no `ps` there, and the two callers
|
|
281
|
+
* below used to hand back an empty list rather than an answer — which reads exactly like
|
|
282
|
+
* "nothing is running", the most dangerous wrong answer either of them could give. PowerShell
|
|
283
|
+
* is on every Windows machine and answers all three columns in one call; JSON rather than a
|
|
284
|
+
* table, because a command line is full of spaces and quotes and columns cannot survive it.
|
|
285
|
+
*
|
|
286
|
+
* An unanswerable question still returns an empty list, because a sweep that cannot be done is
|
|
287
|
+
* not a reason to fail somebody's check — but it is now the rare case rather than every case.
|
|
288
|
+
*
|
|
289
|
+
* @returns {Promise<{pid: number, ppid: number, command: string}[]>}
|
|
290
|
+
*/
|
|
291
|
+
async function windowsProcessTable() {
|
|
292
|
+
try {
|
|
293
|
+
const { stdout } = await execFileAsync(
|
|
294
|
+
'powershell.exe',
|
|
295
|
+
[
|
|
296
|
+
'-NoProfile',
|
|
297
|
+
'-NonInteractive',
|
|
298
|
+
'-ExecutionPolicy',
|
|
299
|
+
'Bypass',
|
|
300
|
+
'-Command',
|
|
301
|
+
'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CommandLine | ConvertTo-Json -Compress -Depth 2',
|
|
302
|
+
],
|
|
303
|
+
{ timeout: 20_000, maxBuffer: 32 * 1024 * 1024, windowsHide: true },
|
|
304
|
+
);
|
|
305
|
+
const text = String(stdout).trim();
|
|
306
|
+
if (text === '') return [];
|
|
307
|
+
const parsed = JSON.parse(text);
|
|
308
|
+
// One process comes back as an object rather than a list of one, which is PowerShell being
|
|
309
|
+
// helpful in a way that would otherwise crash the loop below.
|
|
310
|
+
const rows = Array.isArray(parsed) ? parsed : [parsed];
|
|
311
|
+
return rows
|
|
312
|
+
.map((row) => ({
|
|
313
|
+
pid: Number(row?.ProcessId ?? 0),
|
|
314
|
+
ppid: Number(row?.ParentProcessId ?? 0),
|
|
315
|
+
command: String(row?.CommandLine ?? ''),
|
|
316
|
+
}))
|
|
317
|
+
.filter((row) => Number.isInteger(row.pid) && row.pid > 0);
|
|
318
|
+
} catch {
|
|
319
|
+
return [];
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
267
323
|
/**
|
|
268
324
|
* Every process on this machine whose command line contains `marker`.
|
|
269
325
|
*
|
|
@@ -276,6 +332,15 @@ export async function takePort() {
|
|
|
276
332
|
*/
|
|
277
333
|
export async function whoIsUsing(marker) {
|
|
278
334
|
if (!marker || marker.length < 8) return [];
|
|
335
|
+
// Windows has no `ps`, and asking it nothing at all was the same as answering "nobody is
|
|
336
|
+
// using this folder" — which is the one answer this function must never invent, because it
|
|
337
|
+
// is what the run trusts when it decides it is alone. Measured on a real Windows 11 machine
|
|
338
|
+
// on 2026-08-31: every call here failed silently and returned an empty list.
|
|
339
|
+
if (process.platform === 'win32') {
|
|
340
|
+
return (await windowsProcessTable())
|
|
341
|
+
.filter((row) => row.pid !== process.pid && row.command.includes(marker))
|
|
342
|
+
.map((row) => ({ pid: row.pid, command: row.command }));
|
|
343
|
+
}
|
|
279
344
|
try {
|
|
280
345
|
const { stdout } = await execFileAsync('/bin/ps', ['-axo', 'pid=,command='], {
|
|
281
346
|
timeout: 8000,
|
|
@@ -321,19 +386,29 @@ export async function descendantsOf(pids) {
|
|
|
321
386
|
if (roots.length === 0) return [];
|
|
322
387
|
/** @type {Map<number, number[]>} */
|
|
323
388
|
const childrenOf = new Map();
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
389
|
+
/** @param {number} pid @param {number} parent */
|
|
390
|
+
const note = (pid, parent) => {
|
|
391
|
+
const list = childrenOf.get(parent) ?? [];
|
|
392
|
+
list.push(pid);
|
|
393
|
+
childrenOf.set(parent, list);
|
|
394
|
+
};
|
|
395
|
+
// The same table, asked for in Windows' words. Returning nothing here meant the app's own
|
|
396
|
+
// children — the renderer processes a browser or an Electron app starts — were never found
|
|
397
|
+
// and never closed, so every check on Windows left a handful of them behind. Measured on a
|
|
398
|
+
// real Windows 11 machine on 2026-08-31.
|
|
399
|
+
if (process.platform === 'win32') {
|
|
400
|
+
for (const row of await windowsProcessTable()) note(row.pid, row.ppid);
|
|
401
|
+
} else {
|
|
402
|
+
try {
|
|
403
|
+
const { stdout } = await execFileAsync('/bin/ps', ['-axo', 'pid=,ppid='], { timeout: 8000, maxBuffer: 8 * 1024 * 1024 });
|
|
404
|
+
for (const line of stdout.split('\n')) {
|
|
405
|
+
const match = line.trim().match(/^(\d+)\s+(\d+)$/);
|
|
406
|
+
if (!match) continue;
|
|
407
|
+
note(Number(match[1]), Number(match[2]));
|
|
408
|
+
}
|
|
409
|
+
} catch {
|
|
410
|
+
return [];
|
|
334
411
|
}
|
|
335
|
-
} catch {
|
|
336
|
-
return [];
|
|
337
412
|
}
|
|
338
413
|
/** @type {Set<number>} */
|
|
339
414
|
const found = new Set();
|
|
@@ -379,6 +454,21 @@ function refuseIfNotScratch(scratchDir, dir) {
|
|
|
379
454
|
'A run always gets its own throwaway settings folder — never a real one.',
|
|
380
455
|
);
|
|
381
456
|
}
|
|
457
|
+
// Anything inside the machine's own temp folder is throwaway by definition, and the
|
|
458
|
+
// settings check below is skipped for it.
|
|
459
|
+
//
|
|
460
|
+
// This is not a loophole, it is the difference between the two operating systems. On
|
|
461
|
+
// Windows the temp folder lives INSIDE the settings folder — `C:\Users\me\AppData\Local\Temp`
|
|
462
|
+
// sits under `C:\Users\me\AppData` — so the rule "refuse anything under AppData" refused
|
|
463
|
+
// every scratch folder the tool makes for itself. Measured on a real Windows 11 machine on
|
|
464
|
+
// 2026-08-31: all 18 isolation cases failed with "that is where real settings live" about a
|
|
465
|
+
// folder the tool had just created for its own use, which means isolation, and therefore
|
|
466
|
+
// every check that opens an app, could never have worked on Windows at all.
|
|
467
|
+
//
|
|
468
|
+
// The guard itself is unchanged everywhere else, and is still the strict one: the folder
|
|
469
|
+
// has to be inside the scratch folder the engine handed us, and if it is not somewhere the
|
|
470
|
+
// operating system itself calls temporary, it may not be anywhere near real settings.
|
|
471
|
+
if (isUnderTemp(inside)) return;
|
|
382
472
|
for (const real of [
|
|
383
473
|
path.join(os.homedir(), 'Library', 'Application Support'),
|
|
384
474
|
path.join(os.homedir(), '.config'),
|
|
@@ -390,6 +480,28 @@ function refuseIfNotScratch(scratchDir, dir) {
|
|
|
390
480
|
}
|
|
391
481
|
}
|
|
392
482
|
|
|
483
|
+
/**
|
|
484
|
+
* Is this path inside the folder the operating system itself hands out for throwaway files?
|
|
485
|
+
*
|
|
486
|
+
* Both spellings are compared, because Windows hands the same folder out under two names: the
|
|
487
|
+
* long one and the old eight-character one (`C:\Users\RUNNER~1\...`), and a path that came
|
|
488
|
+
* back from one call can be spelled the other way from the next.
|
|
489
|
+
*
|
|
490
|
+
* @param {string} resolvedWithSeparator An already-resolved path, ending in a separator.
|
|
491
|
+
* @returns {boolean}
|
|
492
|
+
*/
|
|
493
|
+
function isUnderTemp(resolvedWithSeparator) {
|
|
494
|
+
const temp = os.tmpdir();
|
|
495
|
+
/** @type {string[]} */
|
|
496
|
+
const spellings = [temp];
|
|
497
|
+
try {
|
|
498
|
+
spellings.push(fs.realpathSync.native(temp));
|
|
499
|
+
} catch {
|
|
500
|
+
// No second spelling available, which is the ordinary case everywhere but Windows.
|
|
501
|
+
}
|
|
502
|
+
return spellings.some((t) => resolvedWithSeparator.startsWith(path.resolve(t) + path.sep));
|
|
503
|
+
}
|
|
504
|
+
|
|
393
505
|
/**
|
|
394
506
|
* The flags that make one desktop app run alone, and paint the same way twice.
|
|
395
507
|
*
|
|
@@ -423,6 +535,41 @@ export function isolationArgs(isolation, opts = {}) {
|
|
|
423
535
|
];
|
|
424
536
|
}
|
|
425
537
|
|
|
538
|
+
/**
|
|
539
|
+
* The Windows half of "its own settings folder".
|
|
540
|
+
*
|
|
541
|
+
* Everything in the list above is POSIX — HOME, TMPDIR, the XDG folders — and a Windows
|
|
542
|
+
* program reads none of it. It reads USERPROFILE, APPDATA and LOCALAPPDATA. So on Windows the
|
|
543
|
+
* promise at the very top of this file was quietly not being kept: the run got its own
|
|
544
|
+
* folders, and the app carried on writing into the person's real ones. Found on 2026-08-31,
|
|
545
|
+
* the first day anything in this file had ever run on Windows.
|
|
546
|
+
*
|
|
547
|
+
* The second group is not isolation, it is the machine. The environment handed to a child here
|
|
548
|
+
* REPLACES the child's own rather than adding to it, and these are the variables a Windows
|
|
549
|
+
* program is entitled to assume are there — SystemRoot above all, which is where it looks for
|
|
550
|
+
* the libraries that open a socket. `node` and `npm` were measured starting perfectly well
|
|
551
|
+
* without them on 2026-08-31, so this is not a fault anybody has hit; it is a hole left open
|
|
552
|
+
* that a real desktop app is much more likely to fall into than a command-line tool is. They
|
|
553
|
+
* are copied from this process rather than invented, because they describe the machine and
|
|
554
|
+
* not the run.
|
|
555
|
+
*
|
|
556
|
+
* @param {string} homeDir The throwaway home this run was given.
|
|
557
|
+
* @returns {Record<string, string>}
|
|
558
|
+
*/
|
|
559
|
+
function windowsIsolationEnv(homeDir) {
|
|
560
|
+
/** @type {Record<string, string>} */
|
|
561
|
+
const env = {
|
|
562
|
+
USERPROFILE: homeDir,
|
|
563
|
+
APPDATA: path.join(homeDir, 'AppData', 'Roaming'),
|
|
564
|
+
LOCALAPPDATA: path.join(homeDir, 'AppData', 'Local'),
|
|
565
|
+
};
|
|
566
|
+
for (const name of ['SystemRoot', 'windir', 'SystemDrive', 'COMSPEC', 'PATHEXT', 'NUMBER_OF_PROCESSORS', 'PROCESSOR_ARCHITECTURE']) {
|
|
567
|
+
const value = process.env[name];
|
|
568
|
+
if (value) env[name] = value;
|
|
569
|
+
}
|
|
570
|
+
return env;
|
|
571
|
+
}
|
|
572
|
+
|
|
426
573
|
/**
|
|
427
574
|
* Set one run up: its own folders, its own ports, its own identity.
|
|
428
575
|
*
|
|
@@ -458,6 +605,13 @@ export async function reserveIsolation(opts) {
|
|
|
458
605
|
for (const folder of [userDataDir, homeDir, tmpDir, cacheDir, crashDir]) {
|
|
459
606
|
await fsp.mkdir(folder, { recursive: true });
|
|
460
607
|
}
|
|
608
|
+
// The two folders a Windows program expects to find already there. It is handed a home of
|
|
609
|
+
// its own below, and a home with no AppData in it is not one any Windows app has ever seen.
|
|
610
|
+
if (process.platform === 'win32') {
|
|
611
|
+
for (const folder of [path.join(homeDir, 'AppData', 'Roaming'), path.join(homeDir, 'AppData', 'Local')]) {
|
|
612
|
+
await fsp.mkdir(folder, { recursive: true });
|
|
613
|
+
}
|
|
614
|
+
}
|
|
461
615
|
|
|
462
616
|
const debugPort = await takePort();
|
|
463
617
|
const inspectPort = await takePort();
|
|
@@ -516,6 +670,7 @@ export async function reserveIsolation(opts) {
|
|
|
516
670
|
// Nothing being checked should be phoning home about itself.
|
|
517
671
|
ELECTRON_NO_ATTACH_CONSOLE: '1',
|
|
518
672
|
ELECTRON_ENABLE_LOGGING: '1',
|
|
673
|
+
...(process.platform === 'win32' ? windowsIsolationEnv(homeDir) : {}),
|
|
519
674
|
...identityEnv,
|
|
520
675
|
...opts.env,
|
|
521
676
|
},
|
|
@@ -40,7 +40,7 @@ import crypto from 'node:crypto';
|
|
|
40
40
|
import { spawn } from 'node:child_process';
|
|
41
41
|
import {
|
|
42
42
|
defineAdapter, howLongItTook, joinPath, notCovered, observation, sizeBucket,
|
|
43
|
-
trimForStorage, undoOurFootprint,
|
|
43
|
+
trimForStorage, undoOurFootprint, whatItSaid,
|
|
44
44
|
} from './contract.js';
|
|
45
45
|
// The harvest writes the test-file steps this adapter walks, and it owns reading a runner's
|
|
46
46
|
// output back. One place on purpose: a journey read differently from the way it was
|
|
@@ -1382,7 +1382,16 @@ export function importProbeCommand(moduleId) {
|
|
|
1382
1382
|
"}",
|
|
1383
1383
|
"process.stdout.write('\\n' + " + JSON.stringify(EXPORTS_MARKER) + " + '\\n' + JSON.stringify(out, null, 2));",
|
|
1384
1384
|
].join('\n');
|
|
1385
|
-
|
|
1385
|
+
// The probe travels as base64 inside a data: URL rather than as its own text.
|
|
1386
|
+
//
|
|
1387
|
+
// It is many lines long, and a `cmd.exe` command line cannot carry a newline at all — the
|
|
1388
|
+
// first one ends the command. It also contains quotes, colons and angle brackets, every one
|
|
1389
|
+
// of which means something to a shell. Base64 is letters, digits and three punctuation marks
|
|
1390
|
+
// that no shell reads as anything, so what arrives is what was sent, on every machine.
|
|
1391
|
+
// Measured on a real Windows 11 machine on 2026-08-31.
|
|
1392
|
+
const carried = Buffer.from(probe, 'utf8').toString('base64');
|
|
1393
|
+
const load = `await import('data:text/javascript;base64,${carried}')`;
|
|
1394
|
+
return `node --input-type=module -e ${shellQuote(load)} ${shellQuote(moduleId)}`;
|
|
1386
1395
|
}
|
|
1387
1396
|
|
|
1388
1397
|
/**
|
|
@@ -1568,8 +1577,20 @@ async function fingerprintOf(file) {
|
|
|
1568
1577
|
}
|
|
1569
1578
|
}
|
|
1570
1579
|
|
|
1571
|
-
/**
|
|
1580
|
+
/**
|
|
1581
|
+
* One argument, quoted the way THIS machine's shell reads quotes.
|
|
1582
|
+
*
|
|
1583
|
+
* Single quotes everywhere except Windows, where `cmd.exe` does not treat them as quotes at
|
|
1584
|
+
* all — it hands the quote through as part of the word. Measured on a real Windows 11 machine
|
|
1585
|
+
* on 2026-08-31: every command built here arrived as literal text, the probe came back
|
|
1586
|
+
* `SyntaxError: Invalid or unexpected token` on the text `'const`, and the run reported the
|
|
1587
|
+
* product broken when nothing had been run. Windows uses double quotes, and a double quote
|
|
1588
|
+
* inside the value is doubled, which is how `cmd.exe` spells one.
|
|
1589
|
+
*
|
|
1590
|
+
* @param {string} text
|
|
1591
|
+
*/
|
|
1572
1592
|
function shellQuote(text) {
|
|
1593
|
+
if (process.platform === 'win32') return `"${String(text).replace(/"/g, '""')}"`;
|
|
1573
1594
|
return `'${text.split("'").join(`'\\''`)}'`;
|
|
1574
1595
|
}
|
|
1575
1596
|
|
|
@@ -1686,15 +1707,45 @@ export async function describeRun(input) {
|
|
|
1686
1707
|
: result.signal
|
|
1687
1708
|
? `it was killed by ${result.signal} having printed nothing`
|
|
1688
1709
|
: `it exited ${result.code} without printing anything at all`;
|
|
1710
|
+
// AND SAY WHAT IT ACTUALLY SAID. This was the whole gap, measured 2026-08-31 on three
|
|
1711
|
+
// deliberately broken products — a Node server with a syntax error, a Python command
|
|
1712
|
+
// importing a module that is not installed, and a Node command importing a package that is
|
|
1713
|
+
// not installed. Every one of them was correctly refused, and every one of their owners was
|
|
1714
|
+
// told only "it fell over", six times over, with `--verbose` on. All three had printed the
|
|
1715
|
+
// reason on their own standard error, in one line, and this sentence threw it away. Being
|
|
1716
|
+
// told "it fell over" instead of "line 18 of server.js has a syntax error" sends a person
|
|
1717
|
+
// off to find it themselves, which is the one thing this tool exists not to do.
|
|
1718
|
+
//
|
|
1719
|
+
// TWO RENDERINGS, because they are read in two places with two different budgets.
|
|
1720
|
+
// `staysfixed coverage` prints this sentence with 160 characters and the refusal's reason
|
|
1721
|
+
// underneath it with 400. So the sentence gets the ONE line that names the thing — a
|
|
1722
|
+
// runtime puts that last, which is why it is the last line — and the reason underneath
|
|
1723
|
+
// gets the fuller quote, file and line and all. Neither is ever compared: both live in
|
|
1724
|
+
// `meta`, so a crash worded differently on two machines cannot register as a change in the
|
|
1725
|
+
// product.
|
|
1726
|
+
const spoke = input.quieten
|
|
1727
|
+
? input.quieten(undoOurFootprint(result.stderr, footprint))
|
|
1728
|
+
: undoOurFootprint(result.stderr, footprint);
|
|
1729
|
+
// Standard error first, because that is where a runtime puts the reason. Standard output is
|
|
1730
|
+
// the fallback for a program that complains on the wrong channel, and there is nothing to
|
|
1731
|
+
// fall back to when a command never started at all — the machine's own words are in `why`.
|
|
1732
|
+
const spokeOrPrinted = spoke.trim() === '' ? undoOurFootprint(result.stdout, footprint) : spoke;
|
|
1733
|
+
const headline = whatItSaid(spokeOrPrinted, { mostLines: 1 });
|
|
1734
|
+
const inFull = whatItSaid(spokeOrPrinted);
|
|
1689
1735
|
out.push(notCovered({
|
|
1690
1736
|
channel: 'complaints',
|
|
1691
1737
|
path: joinPath('cli', id, 'ran at all'),
|
|
1692
1738
|
reason: 'crashed',
|
|
1739
|
+
// What it said leads, ahead of the journey's own name, and that ordering is the point:
|
|
1740
|
+
// the name of the journey is something the reader already knows, and the error is the
|
|
1741
|
+
// only new fact in the sentence. Put second, it is what a trim cuts off.
|
|
1693
1742
|
says:
|
|
1694
|
-
`
|
|
1695
|
-
`
|
|
1696
|
-
`
|
|
1697
|
-
`address, and that agreement reads exactly
|
|
1743
|
+
`Nothing was seen of the product${headline ? `. It said: ${headline}` : ''}. That is as far as ` +
|
|
1744
|
+
`"${journey.describe}" got — ${why}. What it complained about and how it finished are recorded below and are ` +
|
|
1745
|
+
`facts about the crash, not about the product — so nothing here is compared with the other build. A command ` +
|
|
1746
|
+
`that fails the same way on both builds otherwise agrees at every address, and that agreement reads exactly ` +
|
|
1747
|
+
`like a clean run.`,
|
|
1748
|
+
detail: inFull,
|
|
1698
1749
|
}));
|
|
1699
1750
|
}
|
|
1700
1751
|
out.push(observation({
|
|
@@ -1871,11 +1922,24 @@ export function apiSurface(journey, result) {
|
|
|
1871
1922
|
surface = JSON.parse(at === -1 ? result.stdout : result.stdout.slice(at + EXPORTS_MARKER.length));
|
|
1872
1923
|
if (surface === null || typeof surface !== 'object' || Array.isArray(surface)) throw new Error('not a list of names');
|
|
1873
1924
|
} catch {
|
|
1925
|
+
// A module that will not import is the same defect as a command that will not run, and it
|
|
1926
|
+
// was answered the same unhelpful way: "could not be imported", with the reason sitting
|
|
1927
|
+
// unread in the complaints channel. "Whatever it printed instead is under printed" is
|
|
1928
|
+
// directions to go and look, and a person who has to go and look has not been told
|
|
1929
|
+
// anything. Measured 2026-08-31 alongside the three broken products; the reason a module
|
|
1930
|
+
// fails to import is almost always one line — a missing package, a syntax error — and it is
|
|
1931
|
+
// the one line that fixes it.
|
|
1932
|
+
const spoke = result.stderr.trim() === '' ? result.stdout : result.stderr;
|
|
1933
|
+
const headline = whatItSaid(spoke, { mostLines: 1 });
|
|
1874
1934
|
return [notCovered({
|
|
1875
1935
|
channel: 'results',
|
|
1876
1936
|
path: joinPath('export', journey.name, 'readable at all'),
|
|
1877
1937
|
reason: 'crashed',
|
|
1878
|
-
says:
|
|
1938
|
+
says:
|
|
1939
|
+
`Nothing is known about what "${journey.describe}" exports, because it could not be imported${
|
|
1940
|
+
headline ? `. It said: ${headline}` : ''
|
|
1941
|
+
}. Whatever else it printed is under "printed".`,
|
|
1942
|
+
detail: whatItSaid(spoke),
|
|
1879
1943
|
})];
|
|
1880
1944
|
}
|
|
1881
1945
|
const names = Object.keys(surface).sort();
|