#!/usr/bin/env bun /** * Navigation guard: outside the auth flow every route change must go through * react-router (`Link` / `useNavigate`) so the SPA never pays a full document * reload. Raw anchors, `href=` attributes and `window.location` writes are * reported as errors. Auth pages are exempt (e.g. the `#terms` anchors). * * Usage: bun scripts/lint-nav.mjs [dir] (default: src) */ import { readdirSync, readFileSync, statSync } from 'node:fs' import { dirname, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') const scanRoot = resolve(process.argv[2] ?? join(repoRoot, 'src')) const AUTH_EXEMPT = [ 'src/features/auth/', 'src/pages/login/', 'src/pages/sign-up/', 'src/pages/forgot-password/', 'src/pages/reset-password/', 'src/pages/email-verification/', 'src/pages/two-factor/', ] const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', 'pow-solver', 'livekit']) const PATTERNS = [ ['raw anchor', /]/], ['href attribute', /\bhref=["'{]/], ['full-page navigation', /(?:window\.)?location\s*\.\s*(?:href\s*=|assign\s*\(|replace\s*\()/], // A reload is a browser-level refresh: route state must be changed through // react-router instead (see pages/workspace/model/repo-location). ['browser-level refresh', /(?:window\.|document\.)?location\s*\.\s*reload\s*\(/], ] function* walk(dir) { for (const entry of readdirSync(dir)) { const path = join(dir, entry) if (statSync(path).isDirectory()) { if (!SKIP_DIRS.has(entry)) yield* walk(path) } else if (/\.tsx?$/.test(entry) && !entry.endsWith('.d.ts')) { yield path } } } const violations = [] for (const file of walk(scanRoot)) { const rel = relative(repoRoot, file).replaceAll('\\', '/') if (rel.startsWith('src/shared/api/') || AUTH_EXEMPT.some(prefix => rel.startsWith(prefix))) continue readFileSync(file, 'utf8') .split('\n') .forEach((line, i) => { // Fragment-only anchors (`href="#L12"`) never trigger a document reload, // so they are allowed outside the auth flow as in-page permalinks. const fragmentOnly = line.includes('href={`#') || /href=["'{]#/.test(line) for (const [label, re] of PATTERNS) { if (fragmentOnly && label !== 'full-page navigation' && label !== 'browser-level refresh') continue if (re.test(line)) violations.push(`${rel}:${i + 1}: ${label} — ${line.trim()}`) } }) } if (violations.length > 0) { console.error('navigation guard: use / useNavigate() outside auth pages') for (const v of violations) console.error(' ' + v) process.exit(1) } console.log('navigation guard: OK — no href/location navigation outside auth pages')