staysfixed 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/v2/detect.js CHANGED
@@ -31,6 +31,8 @@
31
31
  import fs from 'node:fs';
32
32
  import fsp from 'node:fs/promises';
33
33
  import path from 'node:path';
34
+
35
+ import { whatItCallsItself, pythonEntryPoints } from './init.js';
34
36
  import { fileURLToPath } from 'node:url';
35
37
 
36
38
  /** @typedef {import('./types.js').Surface} Surface */
@@ -407,7 +409,10 @@ export async function detectProject(options = {}) {
407
409
 
408
410
  return {
409
411
  root,
410
- name: String(pkg?.name ?? path.basename(root)),
412
+ // The name the project GIVES ITSELF, not the folder it happens to sit in. Every
413
+ // non-Node project was named after its folder, so a Python tool that calls itself
414
+ // `lint-lens` was described to its owner as `pytool` throughout. Measured 2026-08-31.
415
+ name: String(pkg?.name ?? whatItCallsItself(root).name),
411
416
  version: pkg?.version ? String(pkg.version) : null,
412
417
  isGitRepo: fs.existsSync(path.join(root, '.git')),
413
418
  products: merged,
@@ -634,7 +639,15 @@ async function productsIn(input) {
634
639
  const bundler = ['vite', 'webpack', 'parcel', 'esbuild', 'rollup', '@rsbuild/core'].find(has) ?? null;
635
640
  const indexHtml = file('index.html') || fs.existsSync(path.join(dir, 'public', 'index.html'));
636
641
  const hostConfig = anyFile(/^(vercel|netlify|firebase|now|wrangler)\.(json|toml)$/);
637
- const routeFolders = folder('pages') || (folder('app') && (fs.existsSync(path.join(dir, 'app', 'page.tsx')) || fs.existsSync(path.join(dir, 'app', 'page.jsx')) || fs.existsSync(path.join(dir, 'app', 'layout.tsx'))));
642
+ // A folder that only ever exists because a framework routes out of it. `src/routes` is
643
+ // SvelteKit's, `src/pages` is Astro's, `app/routes` is Remix's — and on 2026-08-31 none of
644
+ // the three was on this list, so a project that had one but did not name its framework in
645
+ // package.json was not read as a website at all.
646
+ const routeFolders = folder('pages')
647
+ || fs.existsSync(path.join(dir, 'src', 'routes'))
648
+ || fs.existsSync(path.join(dir, 'src', 'pages'))
649
+ || fs.existsSync(path.join(dir, 'app', 'routes'))
650
+ || (folder('app') && (fs.existsSync(path.join(dir, 'app', 'page.tsx')) || fs.existsSync(path.join(dir, 'app', 'page.jsx')) || fs.existsSync(path.join(dir, 'app', 'layout.tsx'))));
638
651
  const webish = Boolean(webFramework) || indexHtml || Boolean(hostConfig) || routeFolders || pages.length > 0;
639
652
  if (webish) {
640
653
  /** @type {Clue[]} */
@@ -662,19 +675,22 @@ async function productsIn(input) {
662
675
  const flat = listing.files.filter((f) => f.endsWith('.html')).map((f) => (f === 'index.html' ? '/' : `/${f}`));
663
676
  if (flat.length > 1) clues.push({ where: at('*.html'), means: `${flat.length} pages are plain HTML files sitting in this folder.` });
664
677
 
665
- // Where the screens come from. Folder names answer for a framework that builds its
666
- // addresses out of them; for everything else the router is read, and where there is no
667
- // router the screens are read off the control that switches between them. Reading the
668
- // folder names and stopping there is what reported a four-screen app as a one-page one.
669
- const reading = pages.length > 0
670
- ? { screens: /** @type {Screen[]} */ ([]), needValues: /** @type {{url: string, names: string[]}[]} */ ([]), router: /** @type {Router} */ ({ kind: 'files', where: 'the page folders', why: `${pages.length} addresses are built out of folder names, the way Next.js and its cousins do it, and every one of them is opened.` }) }
671
- : fromHere(await readScreens(dir, has), where);
678
+ // Where the screens come from, and this one call is the difference between a site being
679
+ // walked and a site being glanced at. It asks the folder layout first, the router next,
680
+ // and the strip of tabs last see {@link readScreens}. The pages `readPageRoutes`
681
+ // already found are handed in so they are not listed a second time: they are already
682
+ // turned into journeys by the web adapter, and listing them again would walk every page
683
+ // of a Next.js site twice.
684
+ const reading = fromHere(await readScreens(dir, has, pages.map((page) => page.url)), where);
672
685
  /** @type {Screen[]} */
673
686
  const screens = flat.length > 1
674
687
  ? flat.map((url) => ({ name: url === '/' ? 'the front page' : url, url }))
675
688
  : reading.screens;
676
- if (reading.router.kind === 'tabs' || reading.router.kind === 'hash' || reading.router.kind === 'declared') {
677
- clues.push({ where: at(reading.router.where ?? 'the source'), means: reading.router.why });
689
+ // The sentence that says WHERE the screen list came from is evidence in its own right,
690
+ // and the folder-layout reading was the one kind missing from this list — so a
691
+ // SvelteKit site's addresses were found and then never explained to anybody.
692
+ if (reading.router.where) {
693
+ clues.push({ where: at(reading.router.where), means: reading.router.why });
678
694
  }
679
695
 
680
696
  add('web', {
@@ -684,12 +700,23 @@ async function productsIn(input) {
684
700
  webFramework ? `It uses ${webFramework}` : 'There is a page here',
685
701
  pages.length > 0 ? ` and ${pages.length} page address${pages.length === 1 ? '' : 'es'} were read out of the folder names` : '',
686
702
  pages.length === 0 && screens.length > 0 ? ` and ${screens.length} screen${screens.length === 1 ? '' : 's'} were read out of ${reading.router.kind === 'tabs' ? 'the strip of tabs that switches between them' : reading.router.kind === 'files' ? 'the folder names' : 'its router'}` : '',
703
+ // An address nobody can open yet belongs in the FIRST sentence about this product,
704
+ // not only in the ledger further down. A summary that counts what was found and
705
+ // stays quiet about what cannot be reached is how "covers the website in full"
706
+ // ended up printed over a site with two unopened pages on 2026-08-31.
707
+ reading.needValues.length > 0 ? ` and ${reading.needValues.length} more ${reading.needValues.length === 1 ? 'address is' : 'addresses are'} waiting on a real value before ${reading.needValues.length === 1 ? 'it' : 'they'} can be opened at all` : '',
687
708
  flat.length > 1 && pages.length === 0 ? ` and ${flat.length} more are plain HTML files` : '',
688
709
  hostConfig ? `, it is set up to deploy to ${hostConfig.split('.')[0]}` : '',
689
710
  start ? `, and \`${start}\` starts it` : '',
690
711
  ].join('') + '.',
691
712
  evidence: clues,
692
- router: pages.length > 0 || flat.length > 1
713
+ // A site of plain .html files is the one case the screen reading has nothing to say
714
+ // about: there is no framework, no router and no route folder, only files. Everywhere
715
+ // else `reading.router` is kept, because it names the file it came from and says how
716
+ // many addresses are waiting on a value — and that sentence is copied straight into
717
+ // the settings. Overwriting it with a fixed line, which is what happened until
718
+ // 2026-08-31, threw away the only place a person was told what was NOT being opened.
719
+ router: flat.length > 1 && reading.router.kind !== 'files'
693
720
  ? { kind: 'files', where: null, why: 'Every page here is a file with an address of its own, so each one is opened directly.' }
694
721
  : reading.router,
695
722
  startNote: booting.why,
@@ -961,7 +988,13 @@ async function findMembers(root, globs, listing) {
961
988
  if (glob.endsWith('/*') || glob.endsWith('/**')) {
962
989
  const parent = glob.replace(/\/\*+$/, '');
963
990
  const inner = await listOnce(path.join(root, parent));
964
- for (const dir of inner.dirs) folders.add(path.join(parent, dir));
991
+ // `${parent}/${dir}`, not path.join. This is a repository-relative ADDRESS: it comes
992
+ // out of the workspace globs written with forward slashes, is compared against things
993
+ // like `apps/` further down, is stored in the record and is shown to people. On Windows
994
+ // path.join gave it a backslash, so a product under `packages/api` was not found at all
995
+ // and `apps/` was reported as code nobody was checking while every package inside it
996
+ // was being checked. Measured on a real Windows 11 machine, 2026-08-31.
997
+ for (const dir of inner.dirs) folders.add(`${parent}/${dir}`);
965
998
  } else if (!glob.includes('*')) {
966
999
  folders.add(glob);
967
1000
  }
@@ -1936,7 +1969,11 @@ async function foreignProjectIn(dir, listing) {
1936
1969
  if (!language) return null;
1937
1970
  const spec = FOREIGN_LANGUAGES[/** @type {keyof typeof FOREIGN_LANGUAGES} */ (language)];
1938
1971
  const manifest = spec.manifests.find((m) => listing.files.includes(m)) ?? spec.manifests[0];
1972
+ // Read for the languages below that still scrape their manifest by hand. Python no longer
1973
+ // does — its entry points are read by `pythonEntryPoints`, which knows all three of the
1974
+ // places a Python project can declare them.
1939
1975
  const manifestText = await readTextIfSmall(path.join(dir, manifest)) ?? '';
1976
+ void manifestText;
1940
1977
  /** @type {Clue[]} */
1941
1978
  const evidence = [{ where: manifest, means: `This is how a ${language} project declares itself.` }];
1942
1979
  /** @type {{name: string, run: string, describe: string}[]} */
@@ -1983,15 +2020,26 @@ async function foreignProjectIn(dir, listing) {
1983
2020
  }
1984
2021
  for (const entry of reading.entries) {
1985
2022
  const name = path.basename(entry, '.py');
2023
+ // Written as the plain path here, deliberately. `commandsThatRun` in init.js judges every
2024
+ // suggested command against the project and repairs it — turning a module run by path
2025
+ // into one Python can actually import, and carrying a src layout as PYTHONPATH rather
2026
+ // than in front of the command. Repairing it twice, in two places, is how the two come
2027
+ // to disagree: doing it here as well dropped the PYTHONPATH that the repair adds.
2028
+ // Measured by the gate on 2026-09-01.
1986
2029
  commands.push({ name: `${name} --help`, run: `${typed} ${entry} --help`, describe: `ask ${name} to print its help, and compare every word of it` });
1987
2030
  }
1988
- // A console script is a command somebody types after installing, exactly like package.json's bin.
1989
- const scripts = /\[project\.scripts\]([\s\S]*?)(\n\[|$)/.exec(manifestText);
1990
- for (const line of (scripts?.[1] ?? '').split('\n')) {
1991
- const named = /^\s*([A-Za-z0-9_-]+)\s*=/.exec(line);
1992
- if (named && !commands.some((c) => c.name.startsWith(`${named[1]} `))) {
1993
- commands.push({ name: `${named[1]} --help`, run: `${named[1]} --help`, describe: `ask ${named[1]} to print its help, and compare every word of it` });
1994
- }
2031
+ // A console script is a command somebody types AFTER INSTALLING pip writes those files
2032
+ // at install time, so on a checkout the name is simply not there. `judgeCommand` knows
2033
+ // that and rewrites it into something runnable; what matters here is that Poetry's and
2034
+ // setuptools' own spellings are read at all, because reading only `[project.scripts]`
2035
+ // meant a Poetry project had no commands worked out for it whatsoever.
2036
+ for (const [scriptName] of pythonEntryPoints(dir)) {
2037
+ if (commands.some((c) => c.name.startsWith(`${scriptName} `))) continue;
2038
+ commands.push({
2039
+ name: `${scriptName} --help`,
2040
+ run: `${scriptName} --help`,
2041
+ describe: `ask ${scriptName} to print its help, and compare every word of it`,
2042
+ });
1995
2043
  }
1996
2044
  } else {
1997
2045
  const listens = await foreignServerIn(dir, spec.sources, language);
@@ -2188,7 +2236,592 @@ async function looksLikeAServer(dir) {
2188
2236
  }
2189
2237
 
2190
2238
  // ---------------------------------------------------------------------------
2191
- // Screens: read the router, not the folder names
2239
+ // Routes the folder layout declares
2240
+ // ---------------------------------------------------------------------------
2241
+
2242
+ /**
2243
+ * One address a framework builds out of a folder or a filename.
2244
+ *
2245
+ * @typedef {object} FolderRoute
2246
+ * @property {string} url The address as the framework spells it, changing parts and
2247
+ * all — `/blog/[slug]`. This is the identity of the route, and
2248
+ * it is what the coverage ledger names when nobody can open it.
2249
+ * @property {string|null} open The address that can actually be typed into a browser, or
2250
+ * null when a changing part still has no value. Never guessed.
2251
+ * @property {string[]} needs The changing parts still waiting on a value, by name.
2252
+ * @property {string|null} from Where the value came from, in plain English, so nobody has to
2253
+ * wonder whether the tool invented it.
2254
+ * @property {string} file The file the address came out of, relative to the app folder.
2255
+ * @property {string} family Which framework's spelling this was read in.
2256
+ */
2257
+
2258
+ /**
2259
+ * How many route files are read before the walk stops and says so. A route tree is filenames
2260
+ * only — nothing is opened — so this is generous; it exists to stop a walk that has wandered
2261
+ * into somebody's photo library, not to ration normal work.
2262
+ */
2263
+ const MOST_ROUTE_FILES = 5_000;
2264
+
2265
+ /** How deep a route tree is followed. Real ones are five or six folders; twelve is slack. */
2266
+ const DEEPEST_ROUTE = 12;
2267
+
2268
+ /** Files that sit beside a route for company and are not addresses of their own. */
2269
+ const NOT_A_ROUTE_FILE = /\.(test|spec|stories|d)\.|\.(css|scss|sass|less|styl|json|png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|txt|map)$|\.(server|client)\.[cm]?[jt]sx?$/;
2270
+
2271
+ /**
2272
+ * Every filename under one folder, as POSIX paths relative to it, plus the folders that would
2273
+ * not open.
2274
+ *
2275
+ * A folder that refuses to open is a hole in the route list, and the route list is the thing
2276
+ * that decides how much of a website gets walked — so it is carried back rather than
2277
+ * swallowed. On 2026-08-31 a three-page SvelteKit site reported "0 routes" and walked one
2278
+ * page; a reader that hides its own blind spots is how that stays invisible.
2279
+ *
2280
+ * @param {string} base
2281
+ * @returns {Promise<{files: string[], unreadable: string[], hitTheCap: boolean}>}
2282
+ */
2283
+ async function everyFileUnder(base) {
2284
+ /** @type {string[]} */
2285
+ const files = [];
2286
+ /** @type {string[]} */
2287
+ const unreadable = [];
2288
+ let hitTheCap = false;
2289
+ if (!fs.existsSync(base)) return { files, unreadable, hitTheCap };
2290
+ /** @type {{dir: string, depth: number}[]} */
2291
+ const stack = [{ dir: base, depth: 0 }];
2292
+ while (stack.length > 0) {
2293
+ const here = /** @type {{dir: string, depth: number}} */ (stack.pop());
2294
+ if (here.depth > DEEPEST_ROUTE) continue;
2295
+ /** @type {import('node:fs').Dirent[]} */
2296
+ let entries;
2297
+ try {
2298
+ entries = await fsp.readdir(here.dir, { withFileTypes: true });
2299
+ } catch {
2300
+ unreadable.push(path.relative(base, here.dir) || '.');
2301
+ continue;
2302
+ }
2303
+ for (const entry of entries) {
2304
+ if (entry.isSymbolicLink()) continue;
2305
+ const whole = path.join(here.dir, entry.name);
2306
+ if (entry.isDirectory()) {
2307
+ if (SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
2308
+ stack.push({ dir: whole, depth: here.depth + 1 });
2309
+ continue;
2310
+ }
2311
+ if (!entry.isFile()) continue;
2312
+ if (files.length >= MOST_ROUTE_FILES) { hitTheCap = true; continue; }
2313
+ files.push(path.relative(base, whole).split(path.sep).join('/'));
2314
+ }
2315
+ }
2316
+ return { files, unreadable, hitTheCap };
2317
+ }
2318
+
2319
+ /**
2320
+ * The changing parts of an address, by name, and whether the address works without them.
2321
+ *
2322
+ * Every family in this file spells the same idea differently — `[slug]`, `[...path]`,
2323
+ * `[[...slug]]` — and the difference that matters is not the spelling. It is whether the
2324
+ * address can be opened at all with nothing filled in. An OPTIONAL catch-all (`[[...slug]]`)
2325
+ * answers on its own parent address, so it can be opened today. A required one cannot, and
2326
+ * saying which is which is the whole job.
2327
+ *
2328
+ * @param {string} url
2329
+ * @returns {{names: string[], optionalOnly: boolean}}
2330
+ */
2331
+ function changingPartsOf(url) {
2332
+ /** @type {string[]} */
2333
+ const names = [];
2334
+ let optionalOnly = true;
2335
+ for (const segment of url.split('/')) {
2336
+ if (segment === '' || !segment.startsWith('[')) continue;
2337
+ const optional = /^\[\[.*\]\]$/.test(segment);
2338
+ const inner = optional ? segment.slice(2, -2) : segment.slice(1, -1);
2339
+ // `[slug=integer]` in SvelteKit names a checker for the value, never a second value.
2340
+ const bare = inner.replace(/^\.{3}/, '').replace(/=.*$/, '');
2341
+ names.push(bare === '' ? 'the rest of the address' : bare);
2342
+ if (!optional) optionalOnly = false;
2343
+ }
2344
+ return { names, optionalOnly };
2345
+ }
2346
+
2347
+ /**
2348
+ * The address an optional catch-all answers on when nothing is filled in.
2349
+ *
2350
+ * `/shop/[[...slug]]` is `/shop`, and `/shop` is a page somebody can open right now. Dropping
2351
+ * it into the "waiting on a value" pile would be true of the deeper addresses and false of
2352
+ * this one, and it would leave a page that works today reported as never looked at.
2353
+ *
2354
+ * @param {string} url
2355
+ * @returns {string}
2356
+ */
2357
+ function withoutTheOptionalParts(url) {
2358
+ const kept = url.split('/').filter((segment) => !/^\[\[.*\]\]$/.test(segment));
2359
+ const joined = kept.join('/').replace(/\/{2,}/g, '/').replace(/(.)\/$/, '$1');
2360
+ return joined === '' ? '/' : joined;
2361
+ }
2362
+
2363
+ /**
2364
+ * A route folder name turned into one address segment, or nothing at all.
2365
+ *
2366
+ * The three things every one of these families does with a folder name, in the same order:
2367
+ * a grouping that is not part of the address, a folder that is not routed at all, and a
2368
+ * changing part that is. Getting the first two wrong invents addresses that 404, and an
2369
+ * address that 404s reports a difference nobody caused.
2370
+ *
2371
+ * @param {string} name
2372
+ * @returns {string|null} The segment, or null when the name contributes nothing to the URL.
2373
+ */
2374
+ function segmentFromFolder(name) {
2375
+ if (name === '' || name === '.') return null;
2376
+ // `(marketing)` in Next, `(app)` in SvelteKit: a grouping that organises files and never
2377
+ // appears in the address.
2378
+ if (name.startsWith('(') && name.endsWith(')')) return null;
2379
+ // `(.)photo`, `(..)photo`: Next's intercepting routes. They re-use another route's address
2380
+ // rather than adding one, so counting them would list the same page twice.
2381
+ if (/^\(\.{1,3}\)/.test(name)) return null;
2382
+ // `@modal`: a parallel slot. It renders INSIDE its parent's address and has none of its own.
2383
+ if (name.startsWith('@')) return null;
2384
+ // `_components`: private, and never routed.
2385
+ if (name.startsWith('_')) return null;
2386
+ // `[x+2e]`: SvelteKit's way of writing a character that cannot be a folder name. Decoded
2387
+ // rather than treated as a changing part, because it is a literal full stop.
2388
+ const escaped = name.match(/^\[x\+([0-9a-fA-F]{2})\]$/);
2389
+ if (escaped) return String.fromCharCode(parseInt(escaped[1], 16));
2390
+ return name;
2391
+ }
2392
+
2393
+ /**
2394
+ * Read every address this app builds out of its folder layout.
2395
+ *
2396
+ * WHY THIS EXISTS, AND IT IS THE POINT OF THE WHOLE SECTION. Until 2026-08-31 this tool found
2397
+ * a site's pages by reading code that DECLARES routes — a route table, a `<Route path=...>`.
2398
+ * That works for Express and for the older React routers and finds nothing whatever for the
2399
+ * entire modern family where the folder layout IS the routing. Measured that day by somebody
2400
+ * using the tool as a stranger: a three-page SvelteKit site reported 0 routes, opened the
2401
+ * front page, and called that the website covered in full. Two of its three pages were never
2402
+ * opened and the coverage ledger never named them. One page walked out of three, reported as
2403
+ * all of them, is the exact false all-clear this product exists to make impossible.
2404
+ *
2405
+ * Six spellings of one idea, and each one is a place a whole site can go unseen:
2406
+ *
2407
+ * SvelteKit src/routes/about/+page.svelte becomes /about
2408
+ * Next app router app/(marketing)/about/page.tsx becomes /about
2409
+ * Next pages router pages/about.tsx becomes /about
2410
+ * Nuxt pages/about.vue becomes /about
2411
+ * Astro src/pages/about.astro becomes /about
2412
+ * Remix app/routes/blog.$slug.tsx becomes /blog/[slug]
2413
+ *
2414
+ * Nothing is opened and nothing is run. This reads filenames.
2415
+ *
2416
+ * @param {string} dir
2417
+ * @param {(name: string) => boolean} [has] Is this a dependency? Three frameworks own a
2418
+ * folder called `pages`, and a `.ts` file inside it is a page in one of them and a server
2419
+ * endpoint in another. package.json is the only thing that can settle that.
2420
+ * @returns {Promise<{routes: FolderRoute[], families: string[], where: string|null, unreadable: string[], hitTheCap: boolean}>}
2421
+ */
2422
+ async function readFolderRoutes(dir, has = () => false) {
2423
+ /** @type {Map<string, FolderRoute>} */
2424
+ const found = new Map();
2425
+ /** @type {Set<string>} */
2426
+ const families = new Set();
2427
+ /** @type {string[]} */
2428
+ const unreadable = [];
2429
+ /** @type {string|null} */
2430
+ let where = null;
2431
+ let hitTheCap = false;
2432
+
2433
+ /**
2434
+ * @param {string} url
2435
+ * @param {string} file
2436
+ * @param {string} family
2437
+ */
2438
+ const add = (url, file, family) => {
2439
+ // Every family joins a folder path to a filename, and a page at the top of the tree makes
2440
+ // that "/" plus "about". Collapsing the repeats here rather than in six callers is the
2441
+ // difference between `/about` and `//about`, and `//about` is an address that 404s.
2442
+ const clean = url === '' ? '/' : url.replace(/\/{2,}/g, '/').replace(/(.)\/+$/, '$1');
2443
+ if (found.has(clean)) return;
2444
+ const { names, optionalOnly } = changingPartsOf(clean);
2445
+ found.set(clean, {
2446
+ url: clean,
2447
+ // An address with nothing changing in it opens as written. One whose only changing
2448
+ // parts are optional opens on its parent address. Anything else waits for a value, and
2449
+ // waits VISIBLY — `needs` is carried all the way into the settings file and the ledger.
2450
+ open: names.length === 0 ? clean : optionalOnly ? withoutTheOptionalParts(clean) : null,
2451
+ needs: names.length === 0 || optionalOnly ? [] : names,
2452
+ from: names.length > 0 && optionalOnly
2453
+ ? 'the changing part of this address may be left out entirely, so this is the address it answers on with nothing filled in'
2454
+ : null,
2455
+ file,
2456
+ family,
2457
+ });
2458
+ families.add(family);
2459
+ where = where ?? file;
2460
+ };
2461
+
2462
+ /**
2463
+ * @param {string} folder
2464
+ * @returns {Promise<string[]>}
2465
+ */
2466
+ const filesUnder = async (folder) => {
2467
+ const reading = await everyFileUnder(path.join(dir, folder));
2468
+ for (const bad of reading.unreadable) unreadable.push(bad === '.' ? folder : path.posix.join(folder, bad));
2469
+ hitTheCap = hitTheCap || reading.hitTheCap;
2470
+ return reading.files;
2471
+ };
2472
+
2473
+ /**
2474
+ * Fold a route file's folder path into an address, dropping the parts that are not in it.
2475
+ *
2476
+ * @param {string} rel
2477
+ * @returns {string|null} Null when the file sits under a folder that is not routed at all,
2478
+ * in which case the file has no address rather than an address one level up.
2479
+ */
2480
+ const urlFromFolders = (rel) => {
2481
+ const folders = rel.split('/').slice(0, -1);
2482
+ /** @type {string[]} */
2483
+ const kept = [];
2484
+ for (const part of folders) {
2485
+ const segment = segmentFromFolder(part);
2486
+ // A private, slot or intercepting folder does not just lose a segment — nothing under
2487
+ // it is an address, so the whole file is dropped rather than hoisted up a level.
2488
+ if (segment === null && (part.startsWith('_') || part.startsWith('@') || /^\(\.{1,3}\)/.test(part))) return null;
2489
+ if (segment !== null) kept.push(segment);
2490
+ }
2491
+ return `/${kept.join('/')}`;
2492
+ };
2493
+
2494
+ // ── SvelteKit: src/routes/**/+page.svelte ────────────────────────────────
2495
+ // The `+` files are SvelteKit's whole vocabulary, and only `+page.svelte` renders something
2496
+ // a person can open. `+layout` wraps other routes and has no address of its own; `+server`
2497
+ // answers requests rather than showing a screen. Both are left to the door reader in
2498
+ // adapters/source.js, because walking them as screens would photograph a JSON body and
2499
+ // call it a page.
2500
+ for (const routesDir of ['src/routes', 'routes']) {
2501
+ const files = await filesUnder(routesDir);
2502
+ for (const rel of files) {
2503
+ const name = rel.split('/').pop() ?? '';
2504
+ // `+page@.svelte` and `+page@named.svelte` reset which layout wraps the page. The part
2505
+ // after the @ says which layout, never which address, so it is ignored here.
2506
+ if (!/^\+page(@[^.]*)?\.(svelte|md|svx)$/.test(name)) continue;
2507
+ const url = urlFromFolders(rel);
2508
+ if (url === null) continue;
2509
+ add(url, path.posix.join(routesDir, rel), 'SvelteKit');
2510
+ }
2511
+ if (found.size > 0) break;
2512
+ }
2513
+
2514
+ // ── Next.js app router: app/**/page.tsx ─────────────────────────────────
2515
+ for (const appDir of ['app', 'src/app']) {
2516
+ const files = await filesUnder(appDir);
2517
+ for (const rel of files) {
2518
+ const name = rel.split('/').pop() ?? '';
2519
+ if (!/^page\.([cm]?[jt]sx?|mdx?)$/.test(name)) continue;
2520
+ const url = urlFromFolders(rel);
2521
+ if (url === null) continue;
2522
+ add(url, path.posix.join(appDir, rel), 'the Next.js app router');
2523
+ }
2524
+ }
2525
+
2526
+ // ── Remix and React Router file routes: app/routes/** ───────────────────
2527
+ // The dots in `blog.$slug.tsx` are the slashes. This is read only when the project says it
2528
+ // uses one of these routers, or when nothing in `app/` looks like a Next.js page — both
2529
+ // families own a folder called `app/`, and guessing between them invents addresses.
2530
+ const remixish = has('@remix-run/react') || has('@remix-run/node') || has('@remix-run/dev')
2531
+ || has('react-router') || has('@react-router/dev') || has('@react-router/fs-routes') || has('@remix-run/router');
2532
+ if (fs.existsSync(path.join(dir, 'app', 'routes')) && (remixish || !has('next'))) {
2533
+ const files = await filesUnder('app/routes');
2534
+ /** @type {Set<string>} */
2535
+ const ids = new Set();
2536
+ for (const rel of files) {
2537
+ const parts = rel.split('/');
2538
+ const name = /** @type {string} */ (parts[parts.length - 1]);
2539
+ if (NOT_A_ROUTE_FILE.test(name) || !/\.([cm]?[jt]sx?|mdx?)$/.test(name)) continue;
2540
+ const stem = name.replace(/\.([cm]?[jt]sx?|mdx?)$/, '');
2541
+ // Two shapes mean the same route: `blog.$slug.tsx` sitting on its own, and
2542
+ // `blog.$slug/route.tsx` with its helpers beside it. Anything else inside a route
2543
+ // folder is a file kept next to its route rather than a route.
2544
+ if (parts.length === 1) ids.add(stem);
2545
+ else if (stem === 'route' && parts.length === 2) ids.add(/** @type {string} */ (parts[0]));
2546
+ }
2547
+ for (const id of ids) {
2548
+ const url = remixUrl(id);
2549
+ if (url === null) continue;
2550
+ add(url, `app/routes/${id}`, 'Remix file routes');
2551
+ }
2552
+ }
2553
+
2554
+ // ── Nuxt, Astro and the Next.js pages router: a folder called `pages` ───
2555
+ // Three frameworks, one folder name. Which one it is decides whether `pages/thing.ts` is a
2556
+ // page or a server endpoint, and only package.json knows. Where package.json says nothing,
2557
+ // the file extension does: `.vue` is Nuxt's and `.astro` is Astro's, and neither is ever
2558
+ // the other's.
2559
+ const astroish = has('astro');
2560
+ const nuxtish = has('nuxt') || has('nuxt3') || has('nuxt-edge');
2561
+ // A project routes one way, not three. Once SvelteKit's or Remix's tree has answered, a
2562
+ // folder called `pages` beside it is a folder of components — and reading `src/pages/utils.ts`
2563
+ // in a SvelteKit app as the address `/utils` would put a page that does not exist into the
2564
+ // settings and then report a 404 as a difference nobody caused.
2565
+ // Next.js is the exception: an app router and a pages router genuinely coexist in one
2566
+ // project, and half the site lives in each. Only the two families that own the WHOLE tree
2567
+ // rule the `pages` folder out.
2568
+ const alreadyRouted = families.has('SvelteKit') || families.has('Remix file routes');
2569
+ // `app/pages` is Nuxt 4's home and nobody else's. A Next.js app-router project with a real
2570
+ // folder called `app/pages` would otherwise gain an invented `/page` address on top of the
2571
+ // `/pages` one it genuinely has.
2572
+ const pagesFolders = alreadyRouted ? [] : nuxtish ? ['src/pages', 'pages', 'app/pages'] : ['src/pages', 'pages'];
2573
+ for (const pagesDir of pagesFolders) {
2574
+ const files = await filesUnder(pagesDir);
2575
+ for (const rel of files) {
2576
+ const name = rel.split('/').pop() ?? '';
2577
+ if (NOT_A_ROUTE_FILE.test(name) || name.startsWith('.')) continue;
2578
+ // `pages/api/**` is the Next.js pages router's server half, and there is no screen
2579
+ // behind it. It is a door, and doors are read in adapters/source.js.
2580
+ if (rel.startsWith('api/')) continue;
2581
+
2582
+ const astroPage = /\.(astro|mdx?|markdown|html)$/.test(name);
2583
+ const vuePage = /\.vue$/.test(name);
2584
+ const scriptPage = /\.[cm]?[jt]sx?$/.test(name);
2585
+ /** @type {string|null} */
2586
+ let family = null;
2587
+ if (astroPage && (astroish || !nuxtish)) family = 'Astro';
2588
+ else if (vuePage) family = 'Nuxt';
2589
+ // A plain script in `src/pages` is an Astro ENDPOINT rather than a page, so under Astro
2590
+ // it is skipped here and left to the door reader. Under everything else this folder
2591
+ // belongs to a router where a script IS the page.
2592
+ else if (scriptPage && !astroish) family = nuxtish ? 'Nuxt' : 'the Next.js pages router';
2593
+ if (family === null) continue;
2594
+
2595
+ const stem = name.replace(/\.[^.]+$/, '');
2596
+ if (stem.startsWith('_')) {
2597
+ // Nuxt 2 spelled a changing part `_id.vue` where everything else writes `[id]`. Under
2598
+ // Nuxt that underscore is a parameter; under everything else it is a framework hook
2599
+ // like `_app` or `_document`. Reading it the wrong way either invents an address or
2600
+ // loses one.
2601
+ const nuxtParam = family === 'Nuxt' && stem.length > 1 && !['_app', '_document', '_error', '_middleware'].includes(stem);
2602
+ if (!nuxtParam) continue;
2603
+ }
2604
+ const folders = urlFromFolders(rel);
2605
+ if (folders === null) continue;
2606
+ const leaf = stem === 'index' ? null : segmentFromLeaf(stem, family);
2607
+ if (stem !== 'index' && leaf === null) continue;
2608
+ add(leaf === null ? folders : `${folders}/${leaf}`, path.posix.join(pagesDir, rel), family);
2609
+ }
2610
+ }
2611
+
2612
+ return {
2613
+ routes: [...found.values()].sort((a, b) => a.url.localeCompare(b.url)),
2614
+ families: [...families],
2615
+ where,
2616
+ unreadable,
2617
+ hitTheCap,
2618
+ };
2619
+ }
2620
+
2621
+ /**
2622
+ * The last part of a page's address, taken from the filename rather than the folder.
2623
+ *
2624
+ * Only Nuxt 2 needs a rule of its own here: it wrote a changing part as `_id.vue` where every
2625
+ * other family writes `[id]`. Translating it means the coverage ledger names it the same way
2626
+ * as all the others and asks for a value in the same sentence.
2627
+ *
2628
+ * @param {string} stem
2629
+ * @param {string} family
2630
+ * @returns {string|null}
2631
+ */
2632
+ function segmentFromLeaf(stem, family) {
2633
+ if (family === 'Nuxt' && stem.startsWith('_') && stem.length > 1) return `[${stem.slice(1)}]`;
2634
+ return segmentFromFolder(stem);
2635
+ }
2636
+
2637
+ /**
2638
+ * A Remix route id turned into an address.
2639
+ *
2640
+ * Remix writes the whole path in the filename and uses dots for slashes, which makes five
2641
+ * rules that look like typos and are not:
2642
+ *
2643
+ * `_index` the index of whatever it sits under, so it adds nothing to the address
2644
+ * `_auth.login` a leading underscore is a layout with no address, so this is /login
2645
+ * `app_.projects` a trailing underscore only opts out of a layout, so this is /app/projects
2646
+ * `sitemap[.]xml` brackets escape a real full stop, so this is /sitemap.xml
2647
+ * `$slug` and `$` a changing part, and a catch-all for everything below
2648
+ *
2649
+ * @param {string} id
2650
+ * @returns {string|null}
2651
+ */
2652
+ function remixUrl(id) {
2653
+ // Escaped literals come out first, because the dots inside them are full stops while every
2654
+ // other dot in the name is a slash. A space cannot appear in a route id, so it is safe to
2655
+ // stand in for one while the rest is split.
2656
+ /** @type {string[]} */
2657
+ const literals = [];
2658
+ const masked = id.replace(/\[([^\]]*)\]/g, (_, inner) => {
2659
+ literals.push(String(inner));
2660
+ return ` ${literals.length - 1} `;
2661
+ });
2662
+ /** @type {string[]} */
2663
+ const kept = [];
2664
+ for (const raw of masked.split('.')) {
2665
+ const piece = raw.replace(/ (\d+) /g, (_, n) => literals[Number(n)] ?? '');
2666
+ if (piece === '' || piece === '_index') continue;
2667
+ // A leading underscore is a layout that wraps other routes without adding to the address.
2668
+ if (piece.startsWith('_')) continue;
2669
+ // A trailing underscore says "do not nest inside the parent's layout". The address is the
2670
+ // same either way.
2671
+ const bare = piece.replace(/_$/, '');
2672
+ if (bare === '') continue;
2673
+ if (bare === '$') { kept.push('[...rest]'); continue; }
2674
+ if (bare.startsWith('$')) { kept.push(`[${bare.slice(1)}]`); continue; }
2675
+ kept.push(bare);
2676
+ }
2677
+ return `/${kept.join('/')}`.replace(/(.)\/$/, '$1');
2678
+ }
2679
+
2680
+ /**
2681
+ * Values this project itself uses for the changing parts of its own addresses.
2682
+ *
2683
+ * WHY GUESSING IS NOT ALLOWED HERE. `/blog/[slug]` cannot be opened until somebody says which
2684
+ * post. Inventing one opens a page that does not exist, and a 404 compared against a 404
2685
+ * agrees with itself forever — a green tick over a page nobody has ever seen. So a value is
2686
+ * only ever taken from somewhere the project already wrote it down, and where it came from is
2687
+ * carried beside it and printed. Two places, in this order:
2688
+ *
2689
+ * 1. A LINK. `<a href="/blog/hello-world">` in the project's own source. The strongest
2690
+ * evidence there is: the project ships that address to a person to click.
2691
+ * 2. A NAMED VALUE. `slug: 'hello-world'` in the project's own code, tests or fixtures.
2692
+ * Weaker, so it is only used when no link fits, and it is only accepted when it is
2693
+ * short and plainly a value rather than a sentence or a path.
2694
+ *
2695
+ * When neither exists the address is left unopened ON PURPOSE and reported as a door that was
2696
+ * found and not opened. That is the difference between this tool and a green tick.
2697
+ *
2698
+ * @param {{rel: string, text: string}[]} source
2699
+ * @returns {{links: {url: string, file: string}[], named: Map<string, {value: string, file: string}>}}
2700
+ */
2701
+ function valuesTheProjectUses(source) {
2702
+ /** @type {Map<string, string>} url -> the file it was written in */
2703
+ const links = new Map();
2704
+ /** @type {Map<string, {value: string, file: string}>} */
2705
+ const named = new Map();
2706
+
2707
+ // Only places that are unmistakably an address. A bare string beginning with a slash turns
2708
+ // up as a file path, a glob and a regular expression far more often than as a link, and
2709
+ // three wrong values are worse than none.
2710
+ const asAddress = /(?:href|to|action|formaction|goto|pathname)\s*[=:]\s*["'`](\/[^"'`\s{}]*)["'`]|(?:goto|push|replace|redirect|navigate|visit)\s*\(\s*(?:\d+\s*,\s*)?["'`](\/[^"'`\s{}]*)["'`]/gi;
2711
+ const asNamedValue = /\b(?:"|')?([A-Za-z_$][\w$]{0,40})(?:"|')?\s*[:=]\s*["'`]([A-Za-z0-9][A-Za-z0-9._~-]{0,63})["'`]/g;
2712
+
2713
+ for (const one of source) {
2714
+ for (const hit of one.text.matchAll(asAddress)) {
2715
+ const url = (hit[1] ?? hit[2] ?? '').replace(/[?#].*$/, '').replace(/(.)\/$/, '$1');
2716
+ if (url === '' || url === '/' || url.includes('//')) continue;
2717
+ if (!links.has(url)) links.set(url, one.rel);
2718
+ }
2719
+ for (const hit of one.text.matchAll(asNamedValue)) {
2720
+ const name = /** @type {string} */ (hit[1]);
2721
+ if (named.has(name)) continue;
2722
+ named.set(name, { value: /** @type {string} */ (hit[2]), file: one.rel });
2723
+ }
2724
+ }
2725
+ return { links: [...links].map(([url, file]) => ({ url, file })), named };
2726
+ }
2727
+
2728
+ /**
2729
+ * Fill in the changing parts of an address from a value the project itself uses, or leave it
2730
+ * unopened and say why.
2731
+ *
2732
+ * @param {FolderRoute[]} routes
2733
+ * @param {{links: {url: string, file: string}[], named: Map<string, {value: string, file: string}>}} known
2734
+ * @returns {FolderRoute[]} The same routes, with `open` and `from` filled in where a real
2735
+ * value was found. Anything still unopened keeps its `needs` so the ledger can name it.
2736
+ */
2737
+ function fillChangingParts(routes, known) {
2738
+ // An address that is a route in its own right is never used as a value for another one.
2739
+ // `/blog/archive` beside `/blog/[slug]` is a page, not a slug, and borrowing it would open
2740
+ // the wrong page and call the changing one covered.
2741
+ const realRoutes = new Set(routes.map((route) => route.url));
2742
+ return routes.map((route) => {
2743
+ if (route.open !== null || route.needs.length === 0) return route;
2744
+
2745
+ for (const link of known.links) {
2746
+ if (realRoutes.has(link.url)) continue;
2747
+ const values = matchAddress(route.url, link.url);
2748
+ if (values === null) continue;
2749
+ return {
2750
+ ...route,
2751
+ open: link.url,
2752
+ from: `${describeValues(values)}, which is the address ${link.file} links to`,
2753
+ };
2754
+ }
2755
+
2756
+ // No link fits, so each changing part is asked for by name. All of them have to be
2757
+ // answered: half an address is not an address, and opening `/blog/[slug]` with the slug
2758
+ // still in it would ask the site for a page whose name is a pair of square brackets.
2759
+ /** @type {Record<string, string>} */
2760
+ const values = {};
2761
+ /** @type {string[]} */
2762
+ const files = [];
2763
+ for (const need of route.needs) {
2764
+ const guessFree = known.named.get(need);
2765
+ if (guessFree === undefined) return route;
2766
+ values[need] = guessFree.value;
2767
+ if (!files.includes(guessFree.file)) files.push(guessFree.file);
2768
+ }
2769
+ let open = route.url;
2770
+ for (const [name, value] of Object.entries(values)) {
2771
+ open = open.replace(new RegExp(`\\[\\.{0,3}${name}(=[^\\]]*)?\\]`), encodeURIComponent(value));
2772
+ }
2773
+ return { ...route, open, from: `${describeValues(values)}, taken from ${plainly(files)} where this project sets it` };
2774
+ });
2775
+ }
2776
+
2777
+ /**
2778
+ * Does this literal address fit that pattern, and if so what does each changing part become?
2779
+ *
2780
+ * @param {string} pattern `/blog/[slug]`
2781
+ * @param {string} literal `/blog/hello-world`
2782
+ * @returns {Record<string, string>|null}
2783
+ */
2784
+ function matchAddress(pattern, literal) {
2785
+ const want = pattern.split('/').filter((s) => s !== '');
2786
+ const got = literal.split('/').filter((s) => s !== '');
2787
+ /** @type {Record<string, string>} */
2788
+ const values = {};
2789
+ for (let i = 0; i < want.length; i += 1) {
2790
+ const segment = /** @type {string} */ (want[i]);
2791
+ if (!segment.startsWith('[')) {
2792
+ if (got[i] !== segment) return null;
2793
+ continue;
2794
+ }
2795
+ const inner = segment.replace(/^\[\[(.*)\]\]$/, '$1').replace(/^\[(.*)\]$/, '$1');
2796
+ const name = inner.replace(/^\.{3}/, '').replace(/=.*$/, '');
2797
+ if (inner.startsWith('...')) {
2798
+ // A catch-all swallows everything left, so it has to be last and there has to be
2799
+ // something for it to swallow.
2800
+ const rest = got.slice(i);
2801
+ if (i !== want.length - 1 || rest.length === 0) return null;
2802
+ values[name] = rest.join('/');
2803
+ return values;
2804
+ }
2805
+ const value = got[i];
2806
+ if (value === undefined || value === '') return null;
2807
+ values[name] = value;
2808
+ }
2809
+ return got.length === want.length ? values : null;
2810
+ }
2811
+
2812
+ /**
2813
+ * "the slug is hello-world" — the sentence that goes beside an address so nobody has to
2814
+ * wonder whether a value was invented.
2815
+ *
2816
+ * @param {Record<string, string>} values
2817
+ * @returns {string}
2818
+ */
2819
+ function describeValues(values) {
2820
+ return plainly(Object.entries(values).map(([name, value]) => `${name} is "${value}"`));
2821
+ }
2822
+
2823
+ // ---------------------------------------------------------------------------
2824
+ // Screens: read whatever this app actually uses to decide what to show
2192
2825
  // ---------------------------------------------------------------------------
2193
2826
 
2194
2827
  /**
@@ -2213,37 +2846,101 @@ async function looksLikeAServer(dir) {
2213
2846
  /**
2214
2847
  * Every route a router declares, plus the screens a router does not declare at all.
2215
2848
  *
2216
- * THE FAILURE THIS EXISTS TO STOP. A single-page app is one HTML file, so reading folder
2217
- * names finds one screen and reports it as the whole product. Terminal Deck's phone client is
2218
- * exactly that: one `index.html`, and four screens a person moves between all day. It was
2219
- * checked for months of pretend coverage — one page walked, three unwatched, and a clean run
2220
- * every time. Where the app declares a router, the router is read. Where it does not, the
2221
- * screens are read out of the strip of tabs that switches between them — and the fact that
2222
- * they are reached by CLICKING rather than by an address is said out loud, because a made-up
2223
- * `#address` that silently lands on the same page is worse than nothing: it turns three
2224
- * unchecked screens into three identical checks that agree with each other forever.
2849
+ * TWO FAILURES THIS EXISTS TO STOP, and they are opposite halves of one mistake.
2225
2850
  *
2226
- * Four readings, and every one names the file it came from:
2851
+ * READING ONLY THE FOLDERS. A single-page app is one HTML file, so reading folder names finds
2852
+ * one screen and reports it as the whole product. Terminal Deck's phone client is exactly
2853
+ * that: one `index.html`, and four screens a person moves between all day. It was checked for
2854
+ * months of pretend coverage — one page walked, three unwatched, and a clean run every time.
2227
2855
  *
2228
- * 1. DECLARED ROUTES `path: '/x'` in a route table, `<Route path="/x">`, the shape every
2856
+ * READING ONLY THE ROUTERS. The correction to the first one went too far, and for a year this
2857
+ * function looked for a route TABLE and nothing else. Every modern framework builds its
2858
+ * addresses out of the folder layout instead, and there is no table anywhere to find.
2859
+ * Measured on 2026-08-31 by somebody using the tool as a stranger: a three-page SvelteKit
2860
+ * site, 0 routes reported, the front page opened, "the website covered in full". Two pages
2861
+ * of three never opened, and the coverage ledger never named them.
2862
+ *
2863
+ * Five readings, in order of how much the app itself has settled the question, and every one
2864
+ * names the file it came from:
2865
+ *
2866
+ * 1. THE FOLDER LAYOUT — SvelteKit, both Next.js routers, Nuxt, Astro, Remix. Where the
2867
+ * folders ARE the routing there is nothing to interpret: the layout is the answer.
2868
+ * 2. DECLARED ROUTES — `path: '/x'` in a route table, `<Route path="/x">`, the shape every
2229
2869
  * router library from React Router to Vue Router to Angular writes.
2230
- * 2. HASH ROUTES — a `#name` compared against the address bar. A real address, reachable by
2870
+ * 3. HASH ROUTES — a `#name` compared against the address bar. A real address, reachable by
2231
2871
  * opening it, and only reported when the code actually reads `location.hash`.
2232
- * 3. TABS — an object literal pairing a screen name with the label on the control that
2233
- * switches to it. Not an address: a click, and it is written as one.
2234
- * 4. NOTHING FOUND one page, said plainly, so the coverage ledger can say so too.
2872
+ * 4. TABS — an object literal pairing a screen name with the label on the control that
2873
+ * switches to it. Not an address: a click, and it is written as one. A made-up
2874
+ * `#address` that silently lands on the same page is worse than nothing: it turns three
2875
+ * unchecked screens into three identical checks that agree with each other forever.
2876
+ * 5. NOTHING FOUND — one page, said plainly, so the coverage ledger can say so too.
2235
2877
  *
2236
2878
  * @param {string} dir
2237
2879
  * @param {(name: string) => boolean} [has] Is this package a dependency? A project that
2238
2880
  * installs a router library is a project whose route tables mean what they say, and that
2239
2881
  * fact lives in package.json rather than in the file the table is written in.
2882
+ * @param {string[]} [alreadyListed] Addresses something else in this run already turns into
2883
+ * a journey — today that is the page reader in adapters/web.js, which knows the two Next.js
2884
+ * routers. Those are not repeated in the settings, because a page listed twice is walked
2885
+ * twice and every run costs double for nothing. They are still COUNTED, so the sentence
2886
+ * this function writes about the router is about the whole site rather than the remainder.
2240
2887
  * @returns {Promise<{screens: Screen[], router: Router, needValues: {url: string, names: string[]}[]}>}
2241
2888
  */
2242
- async function readScreens(dir, has = () => false) {
2889
+ async function readScreens(dir, has = () => false, alreadyListed = []) {
2243
2890
  const { files, tooBig } = await readSome(dir, { most: 200, depth: 5 });
2244
2891
  const source = files.filter((f) => !/\.(test|spec)\.[cm]?[jt]sx?$/.test(f.rel));
2245
2892
 
2246
- // ── 1: routes a router library declares ───────────────────────────────────
2893
+ // ── 1: the folder layout, where the folder layout IS the routing ──────────
2894
+ // This runs first and, when it finds anything, it is the final answer. A SvelteKit project
2895
+ // that also happens to install a router library still gets its addresses from its folders,
2896
+ // and reading the library's table instead would invent a second, wrong list.
2897
+ const layout = await readFolderRoutes(dir, has);
2898
+ if (layout.routes.length > 0) {
2899
+ // Values are hunted for in the project's OWN files, tests and fixtures included — a test
2900
+ // that opens `/blog/hello-world` is the project telling us that post exists. Nothing is
2901
+ // ever invented; see {@link valuesTheProjectUses}.
2902
+ const filled = fillChangingParts(layout.routes, valuesTheProjectUses(files));
2903
+ const already = new Set(alreadyListed);
2904
+ /** @type {Screen[]} */
2905
+ const screens = [];
2906
+ /** @type {{url: string, names: string[]}[]} */
2907
+ const needValues = [];
2908
+ for (const route of filled) {
2909
+ if (route.open === null) {
2910
+ // FOUND AND NOT OPENED, and named as such. Skipping it silently would be the same
2911
+ // false all-clear in a smaller box: the ledger would count the pages it walked and
2912
+ // never mention the one it could not.
2913
+ needValues.push({ url: route.url, names: route.needs });
2914
+ continue;
2915
+ }
2916
+ if (already.has(route.url)) continue;
2917
+ screens.push({
2918
+ name: route.url === '/' ? 'the front page' : route.url,
2919
+ url: route.open,
2920
+ describe: route.from === null
2921
+ ? `open ${route.open} and read what the screen says every control is and does`
2922
+ : `open ${route.open} and read what the screen says every control is and does — ${route.from}`,
2923
+ });
2924
+ }
2925
+ const walked = filled.filter((route) => route.open !== null).length;
2926
+ const holes = [
2927
+ layout.unreadable.length > 0
2928
+ ? ` ${plainly(layout.unreadable.slice(0, 3))}${layout.unreadable.length > 3 ? ' and others' : ''} could not be opened, so any page under ${layout.unreadable.length === 1 ? 'it' : 'them'} is missing from this list entirely.`
2929
+ : '',
2930
+ layout.hitTheCap ? ` There were more than ${MOST_ROUTE_FILES} files in the route folders, so the walk stopped early and this list may be short.` : '',
2931
+ ].join('');
2932
+ return {
2933
+ screens,
2934
+ needValues,
2935
+ router: {
2936
+ kind: 'files',
2937
+ where: layout.where,
2938
+ why: `${filled.length} address${filled.length === 1 ? ' is' : 'es are'} built out of the folder layout, the way ${plainly(layout.families)} does it — starting at ${layout.where}. ${walked} of them can be opened as ${walked === 1 ? 'it stands' : 'they stand'}${needValues.length > 0 ? `, and ${needValues.length} ${needValues.length === 1 ? 'has a changing part in it and is' : 'have a changing part in them and are'} waiting on a real value, listed below rather than dropped` : ''}.${holes}`,
2939
+ },
2940
+ };
2941
+ }
2942
+
2943
+ // ── 2: routes a router library declares ───────────────────────────────────
2247
2944
  /** @type {Map<string, Screen>} */
2248
2945
  const declared = new Map();
2249
2946
  /** @type {string|null} */
@@ -2297,7 +2994,7 @@ async function readScreens(dir, has = () => false) {
2297
2994
  };
2298
2995
  }
2299
2996
 
2300
- // ── 2: hash routes ────────────────────────────────────────────────────────
2997
+ // ── 3: hash routes ────────────────────────────────────────────────────────
2301
2998
  const readsHash = source.find((f) => /location\.hash|hashchange|useHashLocation|createWebHashHistory|HashRouter/.test(f.text));
2302
2999
  if (readsHash) {
2303
3000
  /** @type {Map<string, Screen>} */
@@ -2322,7 +3019,7 @@ async function readScreens(dir, has = () => false) {
2322
3019
  }
2323
3020
  }
2324
3021
 
2325
- // ── 3: a strip of tabs ────────────────────────────────────────────────────
3022
+ // ── 4: a strip of tabs ────────────────────────────────────────────────────
2326
3023
  /** @type {Map<string, Screen>} */
2327
3024
  const tabs = new Map();
2328
3025
  /** @type {string|null} */