// Page count baseline check (ARCHITECTURE.md §10 P1-8 / §11.6) // // Asserts that the total page.tsx count never drops below the baseline. // Prevents accidental route deletion. When adding new pages, update the // baseline in BASELINE.total. Per-category minimums catch regressions // in specific areas (dashboards, login, etc.). // // Usage: tsx scripts/check-page-count.ts // Exit: 0 = pass, 1 = below baseline // // Related: ARCHITECTURE.md §10 P1-8, §11.6 验收纪律 import * as fs from "node:fs"; import * as path from "node:path"; const APP_DIR = path.resolve(process.cwd(), "src/app"); interface Baseline { total: number; categories: Record; } // Baseline as of P2 B2 (2026-07-22, attendance + classes + students modules added). // Update when adding pages. const BASELINE: Baseline = { total: 48, categories: { dashboards: { pattern: "shell/{admin,teacher,student,parent}/page.tsx", min: 4, label: "Role dashboards (admin/teacher/student/parent)", }, login: { pattern: "login/page.tsx", min: 1, label: "Login page", }, root: { pattern: "page.tsx", min: 1, label: "Root redirect page", }, forbidden: { pattern: "shell/forbidden/page.tsx", min: 1, label: "Forbidden page", }, catchAll: { pattern: "shell/[[...route]]/page.tsx", min: 1, label: "Shell catch-all", }, devTemplates: { pattern: "shell/dev/templates/**/page.tsx", min: 5, label: "Dev template pages", }, }, }; function scanPages(): string[] { const pages: string[] = []; function walk(dir: string, base: string): void { const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const full = path.join(dir, entry.name); const rel = path.relative(base, full).replace(/\\/g, "/"); if (entry.isDirectory()) { walk(full, base); } else if (entry.name === "page.tsx") { pages.push(rel); } } } walk(APP_DIR, APP_DIR); return pages.sort(); } function matchGlob(pattern: string, relPath: string): boolean { // Glob → regex: ** (any path), * (within segment), {a,b} (alternation) let result = ""; let i = 0; while (i < pattern.length) { const c = pattern[i]; if (c === "*" && pattern[i + 1] === "*") { // ** — match anything including /; skip trailing / result += ".*"; i += 2; if (pattern[i] === "/") i++; } else if (c === "*") { result += "[^/]*"; i++; } else if (c === "{") { const end = pattern.indexOf("}", i); if (end === -1) { result += "\\{"; i++; } else { const opts = pattern .slice(i + 1, end) .split(",") .map((s) => s.trim()); result += `(${opts.join("|")})`; i = end + 1; } } else if (".+?^$()[]|\\".includes(c)) { result += `\\${c}`; i++; } else { result += c; i++; } } return new RegExp(`^${result}$`).test(relPath); } function main(): void { const pages = scanPages(); const total = pages.length; console.log("=== Page Count Baseline Check ==="); console.log(`Total page.tsx files: ${total} (baseline: ${BASELINE.total})`); console.log(""); // Per-category check let categoryFail = false; for (const [, cat] of Object.entries(BASELINE.categories)) { const matched = pages.filter((p) => matchGlob(cat.pattern, p)); const count = matched.length; const status = count >= cat.min ? "✅" : "❌"; if (count < cat.min) categoryFail = true; console.log(` ${status} ${cat.label}: ${count} (min ${cat.min})`); } console.log(""); // Total check const totalOk = total >= BASELINE.total; if (!totalOk) { console.log(`❌ Total ${total} < baseline ${BASELINE.total}`); } // List all pages console.log("Pages:"); for (const p of pages) { console.log( ` /${p.replace(/\/page\.tsx$/, "").replace(/^page\.tsx$/, "")}`, ); } console.log(""); if (!totalOk || categoryFail) { console.log("Result: FAIL"); process.exit(1); } else { console.log(`Result: PASS (${total} pages, all categories meet minimum)`); process.exit(0); } } main();