staysfixed 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -2
- package/docs/guards.md +18 -0
- package/docs/how-v2-works.md +10 -0
- package/package.json +1 -1
- package/src/cli/approve.js +4 -1
- package/src/cli/flake.js +4 -1
- package/src/cli/mark.js +5 -1
- package/src/cli/status.js +53 -1
- package/src/cli/trace.js +27 -2
- package/src/core/config.js +136 -25
- package/src/core/stop-tree.js +109 -0
- package/src/drive/browser.js +20 -31
- package/src/drive/page.js +74 -2
- package/src/guard/api.js +14 -9
- package/src/types.js +1 -1
- package/src/v2/adapters/child.js +15 -17
- package/src/v2/adapters/contract.js +122 -1
- package/src/v2/adapters/http.js +152 -30
- package/src/v2/adapters/isolate.js +169 -14
- package/src/v2/adapters/process.js +72 -8
- package/src/v2/adapters/source.js +254 -7
- package/src/v2/adapters/web.js +69 -19
- package/src/v2/browsers.js +136 -24
- package/src/v2/cause.js +46 -5
- package/src/v2/check.js +332 -34
- package/src/v2/cli.js +19 -1
- package/src/v2/coverage.js +555 -18
- package/src/v2/detect.js +737 -40
- package/src/v2/doctor.js +3 -3
- package/src/v2/escalate.js +57 -11
- package/src/v2/init.js +562 -21
- package/src/v2/journeys/answers-probe.js +376 -0
- package/src/v2/journeys/from-exports.js +456 -0
- package/src/v2/journeys/from-suite.js +9 -1
- package/src/v2/mcp/tools.js +185 -12
- package/src/v2/observation.js +145 -0
- package/src/v2/run.js +133 -9
- package/src/v2/selfcheck.js +297 -11
- package/src/v2/store.js +16 -1
package/src/v2/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
|
-
|
|
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
|
-
|
|
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
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
//
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
|
|
677
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
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
|
-
//
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
2233
|
-
* switches to it. Not an address: a click, and it is written as one.
|
|
2234
|
-
*
|
|
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:
|
|
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
|
-
// ──
|
|
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
|
-
// ──
|
|
3022
|
+
// ── 4: a strip of tabs ────────────────────────────────────────────────────
|
|
2326
3023
|
/** @type {Map<string, Screen>} */
|
|
2327
3024
|
const tabs = new Map();
|
|
2328
3025
|
/** @type {string|null} */
|