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.
@@ -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
- const reading = await readContract({ root: project.root });
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
- const reading = await readContract({ root: project.root });
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: ${trimForStorage(Buffer.concat(bootErr).toString('utf8') || Buffer.concat(bootOut).toString('utf8'), 1500).text || '(nothing)'}`,
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
- says: `${detail.method} ${detail.route} was left alone. The project marked it as spending money, sending a message or destroying data, and nothing is watching this server from the inside, so there is no way to stop it happening for real. This is a hole in what was checked, not a pass.`,
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
- out.push(observation({
911
- channel: 'results',
912
- path: joinPath('api', id, 'status'),
913
- value: answer.status,
914
- says: `${asked} answered ${answer.status}${answer.status >= 400 ? ', which is a refusal' : ''}.`,
915
- }));
916
-
917
- const headers = headersThatMatter(answer.headers);
918
- for (const [name, value] of Object.entries(headers)) {
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, 'header', name),
922
- value: undoOurFootprint(Array.isArray(value) ? value.join(', ') : value, footprint),
923
- says: `${asked} answered with ${name}: ${Array.isArray(value) ? value.join(', ') : value}.`,
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
- const body = readBody(answer.headers.get('content-type') ?? '', undoOurFootprint(input.text, footprint));
928
- out.push(observation({
929
- channel: 'results',
930
- path: joinPath('api', id, 'body'),
931
- value: body.value,
932
- says: body.truncated
933
- ? `What ${asked} sent back, with the middle left out the whole of it is ${sizeBucket(body.bytes)}.`
934
- : `What ${asked} sent back.`,
935
- }));
936
- if (body.shape !== undefined) {
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, 'shape'),
940
- value: body.shape,
941
- 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.`,
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
- const parts = text.split(path.sep);
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
- const base = path.basename(text);
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
- try {
325
- const { stdout } = await execFileAsync('/bin/ps', ['-axo', 'pid=,ppid='], { timeout: 8000, maxBuffer: 8 * 1024 * 1024 });
326
- for (const line of stdout.split('\n')) {
327
- const match = line.trim().match(/^(\d+)\s+(\d+)$/);
328
- if (!match) continue;
329
- const pid = Number(match[1]);
330
- const parent = Number(match[2]);
331
- const list = childrenOf.get(parent) ?? [];
332
- list.push(pid);
333
- childrenOf.set(parent, list);
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
- return `node --input-type=module -e ${shellQuote(probe)} ${shellQuote(moduleId)}`;
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
- /** @param {string} text */
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
- `"${journey.describe}" did not get far enough to observe the product: ${why}. What it complained about and how ` +
1695
- `it finished are recorded below and are facts about the crash, not about the product so nothing here is ` +
1696
- `compared with the other build. A command that fails the same way on both builds otherwise agrees at every ` +
1697
- `address, and that agreement reads exactly like a clean run.`,
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: `"${journey.describe}" could not be imported, so nothing is known about what it exports. Whatever it printed instead is under "printed".`,
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();