morphit/apps/web/scripts/no-stale-top-level-routes-smoke.ts

216 lines
7.4 KiB
TypeScript

#!/usr/bin/env tsx
/**
* Smoke for the post-cp7 routes/ structural invariant (Part 121
* cp21, 2026-05-15).
*
* Background: cp7 (per-locale prerendering, ADR design doc
* `docs/PER-LOCALE-PRERENDERING-DESIGN.md`) MOVED every page-
* level route from `apps/web/src/routes/<route>/` to
* `apps/web/src/routes/[lang]/<route>/`. The cleanup left only
* three files at the top-level routes directory — the locale-
* detection redirect shell — plus the `[lang]/` subtree itself.
*
* The bug: delta-tarballs from cp11 onwards (changed/added
* files only) couldn't communicate the cp7 deletions, so any
* recipient who applied the cp8+ deltas on top of a pre-cp7
* working tree accumulated BOTH the old top-level routes AND
* the new `[lang]/` routes. Discovered Part 121 cp21
* (2026-05-15) when a fresh tarball was pulled apart for
* audit and 23 leaf routes + the dynamic account route were
* found duplicated.
*
* What this smoke locks down: `apps/web/src/routes/` must
* contain EXACTLY the locale-detection redirect shell plus the
* `[lang]/` directory. Any other top-level directory means
* either (a) the cp7 cleanup never landed, (b) it was
* partially reverted, or (c) a future restructure introduced
* fresh top-level routes that have not been mirrored into
* `[lang]/` — all three are bugs.
*
* Why a smoke and not a one-shot fix: SvelteKit doesn't
* complain when both `apps/web/src/routes/orderbook/` and
* `apps/web/src/routes/[lang]/orderbook/` exist — both
* prerender, both ship as static HTML, and the redirect shell
* still sends fresh visitors into the localized copy. The
* stale top-level copy goes unnoticed in build output but
* silently drifts as the localized copy gets edits. A smoke
* is the only failure mode that catches this before tarball.
*/
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const ROUTES_DIR = join(__dirname, '..', 'src', 'routes');
const LANG_DIR = join(ROUTES_DIR, '[lang]');
// The locale-detection redirect shell — these three top-level
// route entries are legitimate and must stay. Anything else at
// top level is stale debris from before cp7's restructure
// landed (or a future restructure missed the [lang]/ mirror).
const ALLOWED_TOP_LEVEL_FILES: ReadonlySet<string> = new Set([
'+layout.svelte',
'+layout.ts',
'+page.svelte'
]);
const ALLOWED_TOP_LEVEL_DIRS: ReadonlySet<string> = new Set([
'[lang]',
// `pair/` is the QR-pairing landing shell — deliberately
// language-agnostic (a scanned QR code carries no locale), so it
// lives OUTSIDE [lang]/ alongside the root locale-detection shell.
// prerender=true + ssr=false + trailingSlash='never'; it bounces
// client-side off window.location.search. There is no [lang]/pair
// counterpart, so this is not a stale top-level duplicate.
'pair'
]);
interface Scenario {
readonly name: string;
readonly ok: boolean;
readonly detail?: string;
}
function listTopLevel(): { files: string[]; dirs: string[] } {
const files: string[] = [];
const dirs: string[] = [];
const entries = readdirSync(ROUTES_DIR);
for (const entry of entries) {
const full = join(ROUTES_DIR, entry);
const st = statSync(full);
if (st.isDirectory()) dirs.push(entry);
else if (st.isFile()) files.push(entry);
}
return { files: files.sort(), dirs: dirs.sort() };
}
function readShellContent(): { layout: string; page: string } {
return {
layout: readFileSync(join(ROUTES_DIR, '+layout.svelte'), 'utf8'),
page: readFileSync(join(ROUTES_DIR, '+page.svelte'), 'utf8')
};
}
console.log('');
console.log('── no-stale-top-level-routes smoke ─────────────────────');
console.log('');
const { files, dirs } = listTopLevel();
const unexpectedFiles = files.filter((f) => !ALLOWED_TOP_LEVEL_FILES.has(f));
const unexpectedDirs = dirs.filter((d) => !ALLOWED_TOP_LEVEL_DIRS.has(d));
const missingFiles = [...ALLOWED_TOP_LEVEL_FILES].filter((f) => !files.includes(f));
const langExists = (() => {
try {
return statSync(LANG_DIR).isDirectory();
} catch {
return false;
}
})();
const langChildCount = langExists ? readdirSync(LANG_DIR).length : 0;
let shell: { layout: string; page: string } | null = null;
try {
shell = readShellContent();
} catch {
shell = null;
}
const scenarios: Scenario[] = [
{
name: 'apps/web/src/routes/ has NO unexpected top-level directories (only [lang]/ allowed)',
ok: unexpectedDirs.length === 0,
detail:
unexpectedDirs.length > 0
? `unexpected: ${unexpectedDirs.slice(0, 30).join(', ')}${unexpectedDirs.length > 30 ? `, ... ${unexpectedDirs.length - 30} more` : ''}`
: undefined
},
{
name: 'apps/web/src/routes/ has NO unexpected top-level files (only the redirect shell allowed)',
ok: unexpectedFiles.length === 0,
detail: unexpectedFiles.length > 0 ? `unexpected: ${unexpectedFiles.join(', ')}` : undefined
},
{
name: 'apps/web/src/routes/+layout.svelte exists (redirect-shell wrapper)',
ok: files.includes('+layout.svelte') && !missingFiles.includes('+layout.svelte')
},
{
name: 'apps/web/src/routes/+layout.ts exists (redirect-shell prerender config)',
ok: files.includes('+layout.ts') && !missingFiles.includes('+layout.ts')
},
{
name: 'apps/web/src/routes/+page.svelte exists (locale-detection redirect)',
ok: files.includes('+page.svelte') && !missingFiles.includes('+page.svelte')
},
{
name: 'apps/web/src/routes/[lang]/ directory exists',
ok: langExists
},
{
name: 'apps/web/src/routes/[lang]/ contains the expected localized subtree (≥20 entries)',
ok: langChildCount >= 20,
detail: `[lang]/ has ${langChildCount} entries`
},
{
name: 'redirect shell +page.svelte references pickLocaleFromAcceptLanguages (cp7 design)',
ok: shell !== null && shell.page.includes('pickLocaleFromAcceptLanguages')
},
{
name: 'redirect shell +layout.svelte explains the minimal-chrome rationale',
ok: shell !== null && shell.layout.includes('redirect shell')
}
];
// Spot-check that the most commonly drifted leaf routes are
// NOT at top level (post-cleanup invariant). This is partial-
// overlap with the "no unexpected directories" check above, but
// it gives a more readable failure when this specific
// regression recurs.
const COMMONLY_DRIFTED_LEAVES = [
'orderbook',
'post',
'chat',
'my',
'settings',
'support',
'login',
'onboarding',
'about-this-instance',
'run-a-node'
];
for (const leaf of COMMONLY_DRIFTED_LEAVES) {
const present = dirs.includes(leaf);
scenarios.push({
name: `no stale top-level /${leaf}/ directory (must live under [lang]/${leaf}/ only)`,
ok: !present,
detail: present ? `found at apps/web/src/routes/${leaf}/` : undefined
});
}
let passed = 0;
let failed = 0;
const failures: string[] = [];
for (const s of scenarios) {
if (s.ok) {
passed++;
} else {
failed++;
failures.push(`${s.name}${s.detail ? `\n ${s.detail}` : ''}`);
}
}
if (failures.length > 0) {
console.log(failures.join('\n'));
console.log('');
}
console.log('────────────────────────────────────────────────────────');
if (failed === 0) {
console.log(`✓ all ${passed} scenarios passed`);
process.exit(0);
} else {
console.log(`${failed} of ${passed + failed} scenarios failed`);
process.exit(1);
}