Files
Edu/apps/portal-shell/scripts/check-route-table.ts
SpecialX 7c511e74bd feat(portal-shell): add CI structural checks for routes, pages, codegen (P1-8)
ARCHITECTURE.md §10 P1-8: three structural checks wired into CI to
prevent regressions identified in the §1.3 audit.

Scripts (apps/portal-shell/scripts/):
- check-route-table.ts: scans src/app/shell/**/page.tsx, parses
  route-permissions.ts (EXACT/PREFIX/DASHBOARD/PUBLIC_ROUTES), fails
  if any actual /shell/* route is unregistered. Reports ghost entries
  (EXACT declarations without page.tsx) as informational.
- check-page-count.ts: asserts total page.tsx >= 13 and per-category
  minimums (dashboards/login/root/forbidden/catch-all/dev-templates).
- check-codegen.ts: runs pnpm run codegen, fails if any output with
  skipDocumentsValidation:false has operations referencing non-existent
  schema fields (currently enforces dashboard-types.ts output from P1-7).

npm scripts: check:routes / check:pages / check:codegen / check:all

CI: .github/workflows/ci.yml quality-ts job — new "Portal-shell
structural checks (P1-8)" step between typecheck and test.

Acceptance (ARCHITECTURE.md §10 P1-8 — "CI 对预埋违规报红"):
- Route violation: planted /shell/test-violation/page.tsx → check:routes
  exits 1 with "unregistered route" error; reverted → PASS
- Codegen violation: planted non_existent_field in GetTeacherDashboard →
  check:codegen exits 1 with "Cannot query field" error; reverted → PASS
- Page count: baseline=13, deleting any page.tsx triggers FAIL
- Clean state: all 3 checks PASS (10 routes, 28 EXACT, 24 ghost entries
  informational, 13 pages, codegen 3 outputs SUCCESS)

Refs: ARCHITECTURE.md §5.3, §10 P1-8, §11.6, §11.7 红线 #5
2026-07-22 15:57:58 +08:00

170 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Route table consistency check (ARCHITECTURE.md §10 P1-8)
//
// Verifies that every actual /shell/* page.tsx route is registered in
// route-permissions.ts (EXACT / PREFIX / DASHBOARD / PUBLIC_ROUTES).
// Catches "unregistered routes" that would fall through to the catch-all
// and be denied by middleware (fail-closed) — developers get a clear CI
// error instead of a confusing runtime 403.
//
// Also reports "ghost entries" (EXACT table entries without a page.tsx)
// as informational output — these are planned future routes (P2-P5).
//
// Usage: tsx scripts/check-route-table.ts
// Exit: 0 = pass, 1 = violations found
//
// Related: ARCHITECTURE.md §3.4 V3-A1, §5.3, §10 P1-8, §11.7 红线 #5
import * as fs from "node:fs";
import * as path from "node:path";
const APP_DIR = path.resolve(process.cwd(), "src/app");
const ROUTE_PERMS_FILE = path.resolve(
process.cwd(),
"src/shared/lib/route-permissions.ts",
);
interface Violation {
type: "unregistered_route" | "ghost_entry";
route: string;
detail: string;
}
function scanActualRoutes(): Set<string> {
const routes = new Set<string>();
function walk(dir: string, prefix: string): void {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
// Skip catch-all [[...route]] directory
if (entry.name.startsWith("[[")) continue;
walk(full, `${prefix}/${entry.name}`);
} else if (entry.name === "page.tsx") {
routes.add(prefix || "/");
}
}
}
walk(APP_DIR, "");
return routes;
}
function extractRegisteredRoutes(): {
exact: Set<string>;
prefixes: string[];
publicRoutes: Set<string>;
} {
const content = fs.readFileSync(ROUTE_PERMS_FILE, "utf8");
const exact = new Set<string>();
const prefixes: string[] = [];
const publicRoutes = new Set<string>();
const routeKeyRe = new RegExp('"(/[^"]*?)":\\s*\\{', "g");
const prefixRe = new RegExp('prefix:\\s*"(/[^"]*?)"', "g");
let m: RegExpExecArray | null;
while ((m = routeKeyRe.exec(content)) !== null) {
exact.add(m[1]);
}
while ((m = prefixRe.exec(content)) !== null) {
prefixes.push(m[1]);
}
// PUBLIC_ROUTES array entries (skip past `readonly string[] =` to the real `[`)
const publicBlock = content.match(
new RegExp("PUBLIC_ROUTES[^=]*=\\s*\\[([\\s\\S]*?)\\]"),
)?.[1];
if (publicBlock) {
const re = new RegExp('"(/[^"]*?)"', "g");
while ((m = re.exec(publicBlock)) !== null) {
publicRoutes.add(m[1]);
}
}
return { exact, prefixes, publicRoutes };
}
function isRegistered(
route: string,
exact: Set<string>,
prefixes: string[],
publicRoutes: Set<string>,
): boolean {
if (publicRoutes.has(route)) return true;
if (exact.has(route)) return true;
for (const p of prefixes) {
if (route.startsWith(p)) return true;
}
return false;
}
function main(): void {
const actualRoutes = scanActualRoutes();
const { exact, prefixes, publicRoutes } = extractRegisteredRoutes();
const violations: Violation[] = [];
// Check A: every actual /shell/* route must be registered
for (const route of [...actualRoutes].sort()) {
if (!route.startsWith("/shell")) continue;
if (!isRegistered(route, exact, prefixes, publicRoutes)) {
violations.push({
type: "unregistered_route",
route,
detail:
"page.tsx exists but route not in EXACT/PREFIX/DASHBOARD/PUBLIC_ROUTES",
});
}
}
// Check B (informational): ghost entries (EXACT entries without page.tsx)
const ghostEntries: string[] = [];
for (const entry of [...exact].sort()) {
if (!entry.startsWith("/shell/")) continue;
if (!actualRoutes.has(entry)) {
ghostEntries.push(entry);
}
}
// Report
console.log("=== Route Table Consistency Check ===");
console.log(
`Actual /shell/* routes: ${[...actualRoutes].filter((r) => r.startsWith("/shell")).length}`,
);
console.log(
`EXACT entries: ${[...exact].filter((r) => r.startsWith("/shell/")).length}`,
);
console.log(`PREFIX entries: ${prefixes.length}`);
console.log(`PUBLIC_ROUTES: ${publicRoutes.size}`);
console.log("");
if (violations.length > 0) {
console.log("❌ VIOLATIONS (unregistered routes):");
for (const v of violations) {
console.log(` ${v.route}${v.detail}`);
}
console.log("");
}
if (ghostEntries.length > 0) {
console.log(
` GHOST ENTRIES (planned, no page.tsx yet): ${ghostEntries.length}`,
);
for (const g of ghostEntries) {
console.log(` ${g}`);
}
console.log("");
}
if (violations.length > 0) {
console.log(`Result: FAIL (${violations.length} violation(s))`);
process.exit(1);
} else {
console.log(
`Result: PASS (0 violations, ${ghostEntries.length} ghost entries)`,
);
process.exit(0);
}
}
main();