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.
Files changed (57) hide show
  1. package/CHANGELOG.md +108 -2
  2. package/README.md +77 -19
  3. package/docs/design-v2.md +8 -7
  4. package/docs/getting-started.md +5 -3
  5. package/docs/guards.md +18 -0
  6. package/docs/how-v2-works.md +43 -10
  7. package/docs/mcp.md +6 -4
  8. package/docs/settings.md +11 -2
  9. package/package.json +1 -1
  10. package/src/cli/approve.js +4 -1
  11. package/src/cli/flake.js +4 -1
  12. package/src/cli/mark.js +5 -1
  13. package/src/cli/status.js +53 -1
  14. package/src/cli/trace.js +27 -2
  15. package/src/core/config.js +136 -25
  16. package/src/core/stop-tree.js +109 -0
  17. package/src/drive/browser.js +20 -31
  18. package/src/drive/page.js +74 -2
  19. package/src/guard/api.js +14 -9
  20. package/src/types.js +1 -1
  21. package/src/v2/adapters/android.js +220 -11
  22. package/src/v2/adapters/child.js +15 -17
  23. package/src/v2/adapters/contract.js +122 -1
  24. package/src/v2/adapters/extension.js +1988 -0
  25. package/src/v2/adapters/http.js +152 -30
  26. package/src/v2/adapters/ios-driver.js +95 -12
  27. package/src/v2/adapters/ios.js +220 -10
  28. package/src/v2/adapters/isolate.js +169 -14
  29. package/src/v2/adapters/linux-driver.js +1028 -0
  30. package/src/v2/adapters/linux.js +1324 -0
  31. package/src/v2/adapters/macos-driver.js +913 -0
  32. package/src/v2/adapters/macos.js +1374 -0
  33. package/src/v2/adapters/process.js +72 -8
  34. package/src/v2/adapters/source.js +254 -7
  35. package/src/v2/adapters/web.js +69 -19
  36. package/src/v2/browsers.js +145 -25
  37. package/src/v2/cause.js +46 -5
  38. package/src/v2/check.js +465 -47
  39. package/src/v2/cli.js +21 -1
  40. package/src/v2/coverage.js +556 -19
  41. package/src/v2/detect.js +742 -42
  42. package/src/v2/doctor.js +125 -18
  43. package/src/v2/escalate.js +57 -11
  44. package/src/v2/init.js +574 -23
  45. package/src/v2/journeys/answers-probe.js +376 -0
  46. package/src/v2/journeys/from-exports.js +456 -0
  47. package/src/v2/journeys/from-suite.js +9 -1
  48. package/src/v2/journeys/index.js +3 -3
  49. package/src/v2/journeys/record-session.js +839 -0
  50. package/src/v2/journeys/record.js +12 -0
  51. package/src/v2/mcp/tools.js +193 -27
  52. package/src/v2/observation.js +145 -0
  53. package/src/v2/run.js +133 -9
  54. package/src/v2/selfcheck.js +297 -11
  55. package/src/v2/store.js +16 -1
  56. package/src/v2/types.js +1 -1
  57. package/src/v2/watch/events.js +6 -0
@@ -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();
@@ -532,10 +532,27 @@ export function readFile(relFile, text) {
532
532
  // Command-line flags. These are a mention, not a proof — a string that looks like a flag
533
533
  // may be one this program accepts or one it passes on to something else. It gets its own
534
534
  // wording so nobody mistakes the two.
535
- for (const token of tokens) {
536
- if (token.t === 'string' && /^--[a-z0-9][a-z0-9-]*$/i.test(token.v)) {
537
- doors.push(door('command', token.v, 'a flag this file mentions', relFile, token.line, inTest, true, 'literal'));
538
- }
535
+ //
536
+ // A CSS CUSTOM PROPERTY IS NOT A FLAG. `style={{ '--px': '1rem' }}` is a component setting
537
+ // its own spacing, and it was read here as a command-line flag this program accepts.
538
+ // Measured 2026-08-31 on a real site: eleven of them became doors, padding the denominator
539
+ // of the coverage ledger with eleven things no journey can ever walk, so a site whose real
540
+ // coverage was good read as worse than it was and the real never-opened doors sat in a list
541
+ // of invented ones.
542
+ //
543
+ // An object key is what tells them apart, and it is decided by BOTH neighbours, not one. A
544
+ // key is a string with a colon after it AND an opening brace or a comma in front of it.
545
+ // Checking only the colon would also throw away `cond ? '--verbose' : '--quiet'`, where the
546
+ // first branch has a colon after it too — and dropping a real flag is the wrong mistake to
547
+ // make here: an invented door makes the ledger read worse than the truth, a missing one
548
+ // makes it read better, and only one of those two can end in somebody believing a clean
549
+ // result they should not have.
550
+ for (let i = 0; i < tokens.length; i++) {
551
+ const token = tokens[i];
552
+ if (token.t !== 'string' || !/^--[a-z0-9][a-z0-9-]*$/i.test(token.v)) continue;
553
+ const isAKey = tokens[i + 1]?.v === ':' && (tokens[i - 1]?.v === '{' || tokens[i - 1]?.v === ',');
554
+ if (isAKey) continue;
555
+ doors.push(door('command', token.v, 'a flag this file mentions', relFile, token.line, inTest, true, 'literal'));
539
556
  }
540
557
 
541
558
  // A server written straight on node:http registers nothing. There is no `app.get` for the
@@ -1285,6 +1302,19 @@ async function collectFiles(root, folders, maxFileBytes) {
1285
1302
  // Routes that live in the filesystem rather than in a call
1286
1303
  // ---------------------------------------------------------------------------
1287
1304
 
1305
+ /**
1306
+ * What each Next.js metadata file is actually served at. The file is named for what it is;
1307
+ * the address it answers on is a different word, and asking for the file's own name gets a
1308
+ * 404 — which would then be reported as a route the build has lost.
1309
+ */
1310
+ const SERVED_AS = /** @type {Record<string, string>} */ ({
1311
+ sitemap: 'sitemap.xml',
1312
+ robots: 'robots.txt',
1313
+ manifest: 'manifest.webmanifest',
1314
+ 'opengraph-image': 'opengraph-image',
1315
+ icon: 'icon',
1316
+ });
1317
+
1288
1318
  /**
1289
1319
  * Every route that is not written as a call in JavaScript.
1290
1320
  *
@@ -1292,6 +1322,14 @@ async function collectFiles(root, folders, maxFileBytes) {
1292
1322
  * Both layouts are handled: an app folder, where a `route` file's exported method names are
1293
1323
  * the verbs, and a pages/api folder, where the file itself is the route.
1294
1324
  *
1325
+ * SO DO THE OTHERS, AND UNTIL 2026-08-31 ONLY NEXT.JS WAS READ. Measured that day on a
1326
+ * three-page SvelteKit site with an endpoint and a form in it: "0 routes". Not "we could not
1327
+ * read them" — zero, printed as a fact, in a report that then called the site covered in
1328
+ * full. SvelteKit writes an endpoint as `+server.ts` and a form handler as an `actions`
1329
+ * export in `+page.server.ts`; Nuxt puts its under `server/api`; Astro puts its beside the
1330
+ * pages in `src/pages`. Every one of those is a door somebody can knock on, and a door this
1331
+ * tool cannot see is a door it cannot notice disappearing.
1332
+ *
1295
1333
  * Python's routes are read here too, and this is the reason they are read HERE rather than
1296
1334
  * somewhere of their own: five places in this tool ask for routes, and four of them would
1297
1335
  * have had to be found and changed. A Flask app that had routes in one of them and none in
@@ -1341,7 +1379,40 @@ export async function readFileRoutes(root) {
1341
1379
 
1342
1380
  for (const appDir of ['app', 'src/app']) {
1343
1381
  await walk(path.join(root, appDir), async (rel, full) => {
1344
- if (!/(^|\/)route\.[cm]?[jt]sx?$/.test(rel.split(path.sep).join('/'))) return;
1382
+ // METADATA FILES ARE ROUTES, and they were invisible. A Next.js site serves
1383
+ // `/sitemap.xml` and `/robots.txt` from `app/sitemap.ts` and `app/robots.ts`, and this
1384
+ // reader knew about neither, so neither was a door and no journey ever asked for either.
1385
+ // Measured 2026-08-31 on a real site: two entries were deleted out of the sitemap and the
1386
+ // run reported nothing whatever. A sitemap is how a search engine finds a product, so a
1387
+ // sitemap that silently stops listing half of it is exactly the kind of break that is
1388
+ // never noticed until the traffic has already gone.
1389
+ //
1390
+ // The file's name decides the address it is served at, and the folder it sits in decides
1391
+ // what comes in front of that — the same rule as `route.ts`, so route groups in brackets
1392
+ // and private folders starting with an underscore drop out the same way. `twitter-image`
1393
+ // and `apple-icon` work identically and are left out only because nothing has measured
1394
+ // them; adding one is adding a line to this list.
1395
+ const asPosix = rel.split(path.sep).join('/');
1396
+ const metadata = /(^|\/)(sitemap|robots|manifest|opengraph-image|icon)\.[cm]?[jt]sx?$/.exec(asPosix);
1397
+ if (metadata) {
1398
+ const under = '/' + path.dirname(rel)
1399
+ .split(path.sep)
1400
+ .filter((one) => one !== '.' && !(one.startsWith('(') && one.endsWith(')')) && !one.startsWith('_'))
1401
+ .join('/');
1402
+ const prefix = under === '/' ? '' : under.replace(/\/$/, '');
1403
+ doors.push({
1404
+ kind: 'route',
1405
+ name: `${prefix}/${SERVED_AS[metadata[2]]}`,
1406
+ detail: 'GET',
1407
+ file: path.relative(root, full),
1408
+ line: 1,
1409
+ inTest: false,
1410
+ named: true,
1411
+ via: 'a Next.js metadata file',
1412
+ });
1413
+ return;
1414
+ }
1415
+ if (!/(^|\/)route\.[cm]?[jt]sx?$/.test(asPosix)) return;
1345
1416
  // A folder in brackets is a grouping, not part of the address; one starting with an
1346
1417
  // underscore is private and is not routed at all.
1347
1418
  const url = '/' + path.dirname(rel)
@@ -1366,7 +1437,12 @@ export async function readFileRoutes(root) {
1366
1437
  });
1367
1438
  }
1368
1439
 
1369
- for (const pagesDir of ['pages/api', 'src/pages/api']) {
1440
+ // Astro keeps its endpoints in `src/pages` too, and an Astro project with an `api` folder in
1441
+ // there would otherwise have every endpoint counted twice — once as a Next.js api route with
1442
+ // the verb unknown, once as an Astro endpoint with the verb read. Two doors where there is
1443
+ // one is the same lie as none where there is one, told the other way round.
1444
+ const astro = await looksLikeAstro(root);
1445
+ for (const pagesDir of astro ? ['pages/api'] : ['pages/api', 'src/pages/api']) {
1370
1446
  await walk(path.join(root, pagesDir), async (rel, full) => {
1371
1447
  if (!/\.[cm]?[jt]sx?$/.test(rel)) return;
1372
1448
  const stem = rel.replace(/\.[cm]?[jt]sx?$/, '').split(path.sep).join('/');
@@ -1378,6 +1454,114 @@ export async function readFileRoutes(root) {
1378
1454
  });
1379
1455
  }
1380
1456
 
1457
+ // ── SvelteKit ─────────────────────────────────────────────────────────────
1458
+ // `+server.ts` answers requests on the address of the folder it sits in, and the functions
1459
+ // it exports are the verbs — exactly the same idea as a Next.js `route.ts`, spelled
1460
+ // differently. `+page.server.ts` is not an endpoint, but an `actions` export in one IS: it
1461
+ // is where every form on that page posts to, which makes it one of the busiest doors in a
1462
+ // SvelteKit app and the one most worth noticing the disappearance of.
1463
+ for (const routesDir of ['src/routes', 'routes']) {
1464
+ let anyHere = false;
1465
+ await walk(path.join(root, routesDir), async (rel, full) => {
1466
+ const posix = rel.split(path.sep).join('/');
1467
+ const name = posix.split('/').pop() ?? '';
1468
+ const endpoint = /^\+server\.[cm]?[jt]s$/.test(name);
1469
+ const pageServer = /^\+page(@[^.]*)?\.server\.[cm]?[jt]s$/.test(name);
1470
+ if (!endpoint && !pageServer) return;
1471
+ const url = '/' + posix.split('/').slice(0, -1)
1472
+ .filter((s) => s !== '.' && !(s.startsWith('(') && s.endsWith(')')) && !s.startsWith('_'))
1473
+ .join('/');
1474
+ /** @type {string[]} */
1475
+ let verbs = [];
1476
+ try {
1477
+ const reading = readFile(path.relative(root, full), await fsp.readFile(full, 'utf8'));
1478
+ const named = reading.doors
1479
+ .filter((d) => d.kind === 'export' && typeof d.name === 'string')
1480
+ .map((d) => String(d.name));
1481
+ if (endpoint) {
1482
+ verbs = named.filter((n) => /^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|fallback)$/.test(n));
1483
+ // An endpoint file that exports nothing readable is still an endpoint. Reporting no
1484
+ // door at all because the verbs would not parse is the failure this whole file
1485
+ // exists to prevent.
1486
+ if (verbs.length === 0) verbs = ['ANY'];
1487
+ } else if (named.includes('actions')) {
1488
+ verbs = ['POST'];
1489
+ }
1490
+ } catch {
1491
+ if (endpoint) verbs = ['ANY'];
1492
+ }
1493
+ if (verbs.length === 0) return;
1494
+ anyHere = true;
1495
+ for (const verb of verbs) {
1496
+ doors.push({
1497
+ kind: 'route', name: url === '/' ? '/' : url.replace(/\/$/, ''), detail: verb,
1498
+ file: path.relative(root, full), line: 1, inTest: false, named: true,
1499
+ via: 'the folder it lives in',
1500
+ });
1501
+ }
1502
+ });
1503
+ // A project has one routes folder, not two. Stopping after the one that had something in
1504
+ // it keeps a stray top-level `routes/` from being read as a second copy of the site.
1505
+ if (anyHere) break;
1506
+ }
1507
+
1508
+ // ── Nuxt ──────────────────────────────────────────────────────────────────
1509
+ // Nuxt names the verb in the filename — `login.post.ts` is a POST — and serves `server/api`
1510
+ // under `/api` while `server/routes` is served at the top level.
1511
+ //
1512
+ // Only read when the project says it is Nuxt, and that gate is not caution for its own sake.
1513
+ // `server/api/orders.js` is an ordinary place to keep an Express handler, and that file
1514
+ // already has its real route read out of the `router.get(...)` call inside it. Walking the
1515
+ // folder as well would invent a second address beside the true one — two doors reported
1516
+ // where one exists, which makes the door count a thing nobody can trust.
1517
+ const nuxt = await looksLikeNuxt(root);
1518
+ for (const [serverDir, prefix] of /** @type {[string, string][]} */ (nuxt ? [
1519
+ ['server/api', '/api'], ['server/routes', ''], ['src/server/api', '/api'], ['src/server/routes', ''],
1520
+ ] : [])) {
1521
+ await walk(path.join(root, serverDir), async (rel, full) => {
1522
+ if (!/\.[cm]?[jt]s$/.test(rel)) return;
1523
+ const stem = rel.replace(/\.[cm]?[jt]s$/, '').split(path.sep).join('/');
1524
+ const verbInName = stem.match(/\.(get|post|put|patch|delete|head|options)$/i);
1525
+ const cleaned = (verbInName ? stem.slice(0, -verbInName[0].length) : stem).replace(/\/?index$/, '');
1526
+ doors.push({
1527
+ kind: 'route', name: `${prefix}/${cleaned}`.replace(/\/{2,}/g, '/').replace(/(.)\/$/, '$1') || '/',
1528
+ detail: verbInName ? String(verbInName[1]).toUpperCase() : 'ANY',
1529
+ file: path.relative(root, full), line: 1, inTest: false, named: true,
1530
+ via: 'the folder it lives in',
1531
+ });
1532
+ });
1533
+ }
1534
+
1535
+ // ── Astro ─────────────────────────────────────────────────────────────────
1536
+ // Astro keeps its endpoints in the same folder as its pages, and tells them apart by
1537
+ // extension alone: `.astro` is a page, and a plain `.ts` beside it is an endpoint whose
1538
+ // exported function names are the verbs. Only read when the project says it is Astro,
1539
+ // because that same folder belongs to the Next.js pages router in other projects and a
1540
+ // page read as an endpoint would be counted twice.
1541
+ if (astro) {
1542
+ await walk(path.join(root, 'src/pages'), async (rel, full) => {
1543
+ const posix = rel.split(path.sep).join('/');
1544
+ if (!/\.[cm]?[jt]s$/.test(posix) || /\.d\.[cm]?ts$/.test(posix)) return;
1545
+ const stem = posix.replace(/\.[cm]?[jt]s$/, '').replace(/\/?index$/, '');
1546
+ /** @type {string[]} */
1547
+ let verbs = ['ANY'];
1548
+ try {
1549
+ const reading = readFile(path.relative(root, full), await fsp.readFile(full, 'utf8'));
1550
+ const named = reading.doors
1551
+ .filter((d) => d.kind === 'export' && typeof d.name === 'string' && /^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|ALL)$/.test(String(d.name)))
1552
+ .map((d) => String(d.name));
1553
+ if (named.length > 0) verbs = named;
1554
+ } catch { /* an unreadable endpoint is still an endpoint */ }
1555
+ for (const verb of verbs) {
1556
+ doors.push({
1557
+ kind: 'route', name: `/${stem}`.replace(/(.)\/$/, '$1'), detail: verb,
1558
+ file: path.relative(root, full), line: 1, inTest: false, named: true,
1559
+ via: 'the folder it lives in',
1560
+ });
1561
+ }
1562
+ });
1563
+ }
1564
+
1381
1565
  const python = await readPythonRoutes(root);
1382
1566
  doors.push(...python.doors);
1383
1567
  problems.push(...python.problems);
@@ -1385,6 +1569,53 @@ export async function readFileRoutes(root) {
1385
1569
  return { doors, problems };
1386
1570
  }
1387
1571
 
1572
+ /**
1573
+ * Is this a Nuxt project?
1574
+ *
1575
+ * Same reason as {@link looksLikeAstro}, for a different folder: `server/api` is Nuxt's
1576
+ * routing and also an ordinary place to keep an Express handler, and only one of those two
1577
+ * turns a filename into an address.
1578
+ *
1579
+ * @param {string} root
1580
+ * @returns {Promise<boolean>}
1581
+ */
1582
+ async function looksLikeNuxt(root) {
1583
+ for (const name of ['nuxt.config.ts', 'nuxt.config.js', 'nuxt.config.mjs']) {
1584
+ if (fs.existsSync(path.join(root, name))) return true;
1585
+ }
1586
+ try {
1587
+ const pkg = JSON.parse(await fsp.readFile(path.join(root, 'package.json'), 'utf8'));
1588
+ const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
1589
+ return 'nuxt' in deps || 'nuxt3' in deps || 'nuxt-edge' in deps;
1590
+ } catch {
1591
+ return false;
1592
+ }
1593
+ }
1594
+
1595
+ /**
1596
+ * Is this an Astro project?
1597
+ *
1598
+ * It matters for one reason only: `src/pages/thing.ts` is a server endpoint in Astro and a
1599
+ * whole page in the Next.js pages router, and the two folders are spelled identically. Read
1600
+ * the wrong way, a page gets counted as a door it is not, or a door goes unseen. package.json
1601
+ * settles it, and an `astro.config` file settles it for a project that has not installed
1602
+ * anything yet.
1603
+ *
1604
+ * @param {string} root
1605
+ * @returns {Promise<boolean>}
1606
+ */
1607
+ async function looksLikeAstro(root) {
1608
+ for (const name of ['astro.config.mjs', 'astro.config.js', 'astro.config.ts', 'astro.config.mts']) {
1609
+ if (fs.existsSync(path.join(root, name))) return true;
1610
+ }
1611
+ try {
1612
+ const pkg = JSON.parse(await fsp.readFile(path.join(root, 'package.json'), 'utf8'));
1613
+ return 'astro' in { ...pkg?.dependencies, ...pkg?.devDependencies };
1614
+ } catch {
1615
+ return false;
1616
+ }
1617
+ }
1618
+
1388
1619
  /**
1389
1620
  * The commands a package installs and the entries it exports, straight out of its own
1390
1621
  * package.json. Exact, because npm reads the same field.
@@ -1625,7 +1856,23 @@ export const sourceAdapter = defineAdapter({
1625
1856
  * @param {import('./contract.js').PreparedBuild} build
1626
1857
  */
1627
1858
  async run(journey, build) {
1628
- const reading = await readContract({ root: build.root });
1859
+ // THE FOLDERS THE JOURNEY NAMES, not the guessed ones.
1860
+ //
1861
+ // This walk read the project's source with no folders at all, so it fell back to the
1862
+ // built-in guess — src, lib, app, bin, server, pages, api, electron, main, packages —
1863
+ // while `journeys()` three functions above had already worked out the real list from the
1864
+ // settings and written it into the step. Measured 2026-08-31 on a real Next.js site: the
1865
+ // contract channel recorded 24 observations against 25 doors and never saw `format`,
1866
+ // `slugify` or `emptyCart`, because they live in a folder the guess does not name.
1867
+ //
1868
+ // This is the worst shape a hole in this tool can take. The contract channel exists for
1869
+ // exactly one job — noticing that an exported function has disappeared — and for every
1870
+ // export outside the guessed folders it was not doing that job, silently, on every run,
1871
+ // while the run reported a clean result. The step already carries the answer.
1872
+ const reading = await readContract({
1873
+ root: build.root,
1874
+ folders: Array.isArray(journey.steps?.[0]?.folders) ? journey.steps[0].folders : undefined,
1875
+ });
1629
1876
  const fileRoutes = await readFileRoutes(build.root);
1630
1877
  reading.doors.push(...fileRoutes.doors);
1631
1878
  // A folder the route walk could not open is a hole in the door list, and the door list is
@@ -43,7 +43,7 @@ import path from 'node:path';
43
43
 
44
44
  import {
45
45
  countBucket, defineAdapter, howLongItTook, joinPath, notCovered, observation, sizeBucket,
46
- timeBucket, trimForStorage, undoOurFootprint,
46
+ timeBucket, trimForStorage, undoOurFootprint, whatItSaid,
47
47
  } from './contract.js';
48
48
  import { copyForScratch, frozenEnvironment } from './process.js';
49
49
  import { freePort, looksDestructive, waitForServer } from './http.js';
@@ -298,7 +298,16 @@ export function journeysFrom(input) {
298
298
  surface: 'web',
299
299
  from: page.file,
300
300
  channels: ['meaning', 'effects', 'complaints', 'results', 'counters', 'pixels'],
301
- steps: /** @type {any} */ ([{ act: 'open', goto: url, note: `open ${page.url}`, unfilled }]),
301
+ // `door`, `kind` and `doorDetail` are how the coverage ledger learns that this journey
302
+ // walked that page. Without them a page counted as opened only if an observation happened
303
+ // to land at the page's own address, and this adapter writes everything under
304
+ // `screen.<journey name>` — so every page of every site read as never walked, on runs
305
+ // that had just opened all of them. The HTTP adapter has named its doors this way since
306
+ // it was written; this is the same three fields, for the same reason. A page is reached
307
+ // by asking for its address, so the verb is GET.
308
+ steps: /** @type {any} */ ([
309
+ { act: 'open', goto: url, note: `open ${page.url}`, unfilled, door: page.url, kind: 'route', doorDetail: 'GET' },
310
+ ]),
302
311
  });
303
312
  }
304
313
 
@@ -578,11 +587,20 @@ export const webAdapter = defineAdapter({
578
587
  });
579
588
  if (!up.up) {
580
589
  await stopServer(child);
590
+ // The app's own words go first, ahead of the wait's account of which loopback address it
591
+ // knocked on. Same measurement, same date as the HTTP adapter's copy of this: the reason
592
+ // a site will not boot is one line the runtime printed, the sentence built from this is
593
+ // trimmed to 160 characters where a person reads it, and anything after the first
594
+ // sentence was therefore never seen by anybody.
595
+ const printed = Buffer.concat(said).toString('utf8');
596
+ const headline = whatItSaid(printed, { mostLines: 1 });
581
597
  return {
582
598
  build,
583
599
  root: work,
584
600
  ready: false,
585
- why: `${up.why} What it printed while trying: ${trimForStorage(Buffer.concat(said).toString('utf8'), 1500).text || '(nothing)'}`,
601
+ why: `${headline ? `It said: ${headline}. ` : ''}${up.why} What it printed while trying, in full: ${
602
+ trimForStorage(printed, 1500).text || '(nothing)'
603
+ }`,
586
604
  dispose: async () => {
587
605
  await stopServer(child);
588
606
  await fsp.rm(base, { recursive: true, force: true });
@@ -984,10 +1002,34 @@ async function lookAt(input) {
984
1002
  export function describeTraffic(journey, calls, footprint) {
985
1003
  /** @type {Observation[]} */
986
1004
  const out = [];
1005
+ /** Addresses that only exist because of a request WE cancelled. See below. */
1006
+ const ourOwnFootprint = [];
987
1007
  for (const call of calls) {
988
1008
  const asked = `${call.method} ${call.pattern}`;
989
1009
  const where = joinPath('net', journey.name, asked);
990
1010
 
1011
+ // A REQUEST OUR OWN TEARDOWN CANCELLED IS NOT PART OF THE PRODUCT'S ADDRESS LIST.
1012
+ //
1013
+ // Next.js starts a prefetch behind every internal link, on its own, with nobody asking.
1014
+ // Closing the page cancels whatever is still in flight. Whether a given prefetch got far
1015
+ // enough to become a call at all is therefore a race between it and our own teardown — so
1016
+ // the ADDRESSES it produces exist on one pass of a build and not on the other. Measured
1017
+ // 2026-08-31 across eight runs a side: with nothing timing out the total is 852 every time,
1018
+ // and about 90 of those 852 — roughly one in nine — were reached by only one of the two
1019
+ // passes. Two byte-identical passes disagreeing about which addresses exist is the
1020
+ // measurement contradicting its own method.
1021
+ //
1022
+ // An earlier fix stopped these being reported as the product complaining, which killed the
1023
+ // phantom findings; it could not stop them moving the count, because the address is still
1024
+ // created on the pass that saw it. This is that fix taken one step further: the whole call
1025
+ // contributes nothing, because none of it is a fact about the product — it is a fact about
1026
+ // when we closed the browser. Nothing is dropped silently: how many there were, and which,
1027
+ // is said in one observation at a fixed address at the end of this function.
1028
+ if (call.unfinishedAtTeardown && !call.refused && !call.failed) {
1029
+ ourOwnFootprint.push(asked);
1030
+ continue;
1031
+ }
1032
+
991
1033
  if (call.refused) {
992
1034
  out.push(
993
1035
  notCovered({
@@ -1045,22 +1087,6 @@ export function describeTraffic(journey, calls, footprint) {
1045
1087
  surface: 'web',
1046
1088
  }),
1047
1089
  );
1048
- } else if (call.unfinishedAtTeardown) {
1049
- // Missing coverage, not a complaint. The page was still asking for this when the walk
1050
- // ended, and the abort that followed is this tool closing the page — reporting it as
1051
- // the product failing made an unchanged tree look broken in four runs out of five.
1052
- // Recorded rather than dropped: something was being asked for and how it ended was
1053
- // never seen, which is a hole, and a hole is louder than a complaint, never quieter.
1054
- out.push(
1055
- notCovered({
1056
- channel: 'effects',
1057
- path: `${where}.how it finished`,
1058
- reason: 'refused',
1059
- says:
1060
- `The page was still asking for ${asked} when the walk ended, so how that finished was never seen. ` +
1061
- `The request was cancelled by this tool closing the page, which is not the product doing anything wrong.`,
1062
- }),
1063
- );
1064
1090
  }
1065
1091
  if (call.status !== undefined) {
1066
1092
  out.push(
@@ -1100,6 +1126,30 @@ export function describeTraffic(journey, calls, footprint) {
1100
1126
  );
1101
1127
  }
1102
1128
  }
1129
+
1130
+ // Said out loud, at ONE address that does not move, rather than at an address per request.
1131
+ // The count and the list live in the sentence, which is never compared, so this can report a
1132
+ // different number on two passes without ever reporting a difference. It is a hole, and it is
1133
+ // named as one: something was being asked for and how it ended was never seen.
1134
+ if (ourOwnFootprint.length > 0) {
1135
+ const many = ourOwnFootprint.length !== 1;
1136
+ out.push(
1137
+ notCovered({
1138
+ channel: 'effects',
1139
+ path: joinPath('net', journey.name, 'requests still in flight when the walk ended'),
1140
+ reason: 'refused',
1141
+ says:
1142
+ `${ourOwnFootprint.length} request${many ? 's were' : ' was'} still in flight when this walk ended, and ` +
1143
+ `${many ? 'they were' : 'it was'} cancelled by this tool closing the page rather than by anything the ` +
1144
+ `product did — a framework that prefetches behind every link starts these on its own. How ${many ? 'they' : 'it'} ` +
1145
+ `would have finished was never seen: ${ourOwnFootprint.sort().slice(0, 8).join(', ')}` +
1146
+ `${ourOwnFootprint.length > 8 ? `, and ${ourOwnFootprint.length - 8} more` : ''}. ` +
1147
+ `Each one is kept out of the address list on purpose, because whether a cancelled request got far enough ` +
1148
+ `to make an address is a race with our own teardown, and two passes of one build must not disagree about ` +
1149
+ `which addresses exist.`,
1150
+ }),
1151
+ );
1152
+ }
1103
1153
  return out;
1104
1154
  }
1105
1155