Files
Edu/apps/portal-shell/scripts/check-page-count.ts
SpecialX 062d9e9582 feat(portal-shell): 管理域 §9.4 B5 全量迁移(24 + 21 补充批次共 44 页 + 42 features)
按 ARCHITECTURE.md §9.4 规划口径 + admin-NeedTodo.md §四补充批次完成管理域全量页面迁移:

【§9.4 规划 24 页(B5)】
- users(2) + roles(1) + permissions(1) + audit-logs(4) + invitation-codes(1)
- school(6: redirect/schools/classes/departments/academic-year/grades)
- announcements(1) + files(1) + ai-settings(1) + system(1) + viewports(1)
- students(1) + teachers(1) + organization(1) + plugins(1, config-service)
- 仪表盘已存在(/shell/admin/page.tsx)

【§四补充批次 21 页】
- course-plans(4) + elective(4) + questions(1) + lesson-plans(2) + error-book(1)
- scheduling(3: auto/changes/rules) + attendance(1) + curriculum-map(1)
- announcements 详情/编辑(2) + roles/[id] 详情(1) + users/import(1)

【实现要点】
- 全部使用 ListPageShell + loading/error/empty 三态规范(§11.3 DoD)
- 走 lib/api hooks;未就绪契约走 MSW + @contract-pending 注释(§11.4)
- 文案走 useTranslations(zh-CN + en 两份同步更新)
- 42 个 features/<domain>/transformations.ts 纯函数 + 配套 vitest 单测
- catch 块统一 notify.error;无空 catch;lint:tokens 通过
- 路由全部登记到 route-permissions.ts(39 EXACT + 8 PREFIX)

【验收】
- tsc --noEmit: 0 errors
- ESLint src: 0 errors (4 generated-files warnings, pre-existing)
- lint:tokens: 0 errors
- vitest: 1639/1639 passed (含 23 admin 测试文件 671 用例)
- check:routes: PASS (143 routes, 4 ghost entries pre-existing)
- check:pages: PASS (146 pages)
- check:codegen: PASS
- arch:scan: 24 modules, 8262 symbols

关联:ARCHITECTURE.md §9.4 / §10 P5 / §11.3 DoD / §11.6
2026-07-24 23:07:20 +08:00

163 lines
4.3 KiB
TypeScript

// 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<string, { pattern: string; min: number; label: string }>;
}
// Baseline as of B2 末 教师域 (2026-07-24, proctoring + student-diagnostic
// added: 2 new pages).
// Update when adding pages.
const BASELINE: Baseline = {
total: 69,
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();