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.
@@ -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