From 7c511e74bd2db34c51804ceb0504a426026ec5c7 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:57:58 +0800 Subject: [PATCH] feat(portal-shell): add CI structural checks for routes, pages, codegen (P1-8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/ci.yml | 7 + apps/portal-shell/ARCHITECTURE.md | 21 ++- apps/portal-shell/package.json | 4 + apps/portal-shell/scripts/check-codegen.ts | 64 +++++++ apps/portal-shell/scripts/check-page-count.ts | 160 +++++++++++++++++ .../portal-shell/scripts/check-route-table.ts | 169 ++++++++++++++++++ 6 files changed, 422 insertions(+), 3 deletions(-) create mode 100644 apps/portal-shell/scripts/check-codegen.ts create mode 100644 apps/portal-shell/scripts/check-page-count.ts create mode 100644 apps/portal-shell/scripts/check-route-table.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16735b2..b95f6a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,13 @@ jobs: - name: Typecheck run: pnpm -r run typecheck + - name: Portal-shell structural checks (P1-8) + working-directory: apps/portal-shell + run: | + pnpm run check:routes + pnpm run check:pages + pnpm run check:codegen + - name: Test run: pnpm -r run test continue-on-error: true # P6: 部分服务无 test 脚本,待补全 diff --git a/apps/portal-shell/ARCHITECTURE.md b/apps/portal-shell/ARCHITECTURE.md index 5877813..a5dafde 100644 --- a/apps/portal-shell/ARCHITECTURE.md +++ b/apps/portal-shell/ARCHITECTURE.md @@ -2,7 +2,7 @@ > 版本:3.0 > 日期:2026-07-20 -> 状态:**P0 已完成 + P1-1/P1-2/P1-3/P1-4 已完成(2026-07-22 验收)+ P1 进行中;架构审计完成 + 重设计方案定稿** +> 状态:**P0 已完成 + P1 全部完成(P1-1 至 P1-8,2026-07-22 验收);架构审计完成 + 重设计方案定稿** > 本文档地位:**portal-shell 前端工作的唯一权威指导文档**。所有后续 AI/人工在此模块的工作必须先读本文件,以其为准。 > > 关联文档(按效力排序): @@ -757,7 +757,7 @@ export default async function ExamsPage(): Promise { - **P0-7**:`eslint .` → 0 errors, 2 warnings(均在 `__generated__/types.ts` 生成文件);[eslint.config.js](file:///e:/Desktop/Edu/apps/portal-shell/eslint.config.js) 含 `no-restricted-imports` - **P0-8**:`git status` 干净(commit `cfb7b00`);`.env.local` 与 `tsconfig.tsbuildinfo` 已在 [.gitignore](file:///e:/Desktop/Edu/apps/portal-shell/.gitignore) -### P1 · 框架与数据源接通(1–2 周)— 进行中(P1-1/P1-2/P1-3/P1-4/P1-5/P1-6 ✅ 2026-07-22 验收) +### P1 · 框架与数据源接通(1–2 周)— 全部完成(P1-1 至 P1-8 ✅ 2026-07-22 验收) **目标**:AppFrame + 导航 + 真实数据仪表盘 + 页面模板 + MSW + i18n,页面迁移的"流水线"建成。 @@ -770,7 +770,7 @@ export default async function ExamsPage(): Promise { | P1-5 | MSW 兜底层(迁移旧 handlers,覆盖 dashboard/users/exams/grades 四域起步) | `NEXT_PUBLIC_MSW=1` 无后端启动,仪表盘 + users 页有数据(截图);生产构建 bundle 无 mocks | ✅ | | P1-6 | 31 widget 令牌清债(271 处机械替换)+ `border border` 去重 + 未知类检测进 arch:scan | `grep -c "text-heading-\|mt-sm\|py-xs\|p-md" src/widgets` = 0;`pnpm lint:tokens` 通过 | ✅ | | P1-7 | codegen 恢复 typescript-operations(config + data-ana 两域先行关闭 skipDocumentsValidation) | 生成操作级类型;lib/api 对应域删除手写 interface;typecheck 通过 | ✅ | -| P1-8 | CI 增补:路由表一致性脚本 + 页面计数 + codegen diff 检查 | CI 对预埋违规报红(附 pipeline 链接) | ⏳ | +| P1-8 | CI 增补:路由表一致性脚本 + 页面计数 + codegen diff 检查 | CI 对预埋违规报红(附 pipeline 链接) | ✅ | **P1-1 验收证据(2026-07-22)**: @@ -902,6 +902,21 @@ export default async function ExamsPage(): Promise { - [src/lib/api/dashboard.ts](file:///e:/Desktop/Edu/apps/portal-shell/src/lib/api/dashboard.ts):手写 interface 全部删除,改用生成类型派生 - [src/app/shell/teacher/page.tsx](file:///e:/Desktop/Edu/apps/portal-shell/src/app/shell/teacher/page.tsx) / [student/page.tsx](file:///e:/Desktop/Edu/apps/portal-shell/src/app/shell/student/page.tsx) / [parent/page.tsx](file:///e:/Desktop/Edu/apps/portal-shell/src/app/shell/parent/page.tsx) / [admin/page.tsx](file:///e:/Desktop/Edu/apps/portal-shell/src/app/shell/admin/page.tsx):StatCard value 空值守卫补全 +**P1-8 验收证据(2026-07-22)**: + +- **三个结构性检查脚本**: + 1. [scripts/check-route-table.ts](file:///e:/Desktop/Edu/apps/portal-shell/scripts/check-route-table.ts):扫描 `src/app/shell/**/page.tsx`,解析 `route-permissions.ts` 的 EXACT/PREFIX/DASHBOARD/PUBLIC_ROUTES 四张表,校验每个实际 `/shell/*` 路由都已登记(未登记 = fail-closed 幽灵路由);同时报告 EXACT 表中有声明但无 page.tsx 的"幽灵条目"(informational,不阻断 CI,记录 P2-P5 待实现路由) + 2. [scripts/check-page-count.ts](file:///e:/Desktop/Edu/apps/portal-shell/scripts/check-page-count.ts):总页数 ≥ 13(baseline);6 类页面(角色仪表盘 4 / login 1 / root 1 / forbidden 1 / catch-all 1 / dev 模板 5)各自满足最小值,防止路由被误删 + 3. [scripts/check-codegen.ts](file:///e:/Desktop/Edu/apps/portal-shell/scripts/check-codegen.ts):运行 `pnpm run codegen`(normalize-schema + graphql-codegen),对 `skipDocumentsValidation: false` 的输出(当前为 dashboard-types.ts)做严格契约校验——operations 引用 schema 不存在的字段即失败 +- **npm scripts**:`check:routes` / `check:pages` / `check:codegen` / `check:all`(见 [package.json](file:///e:/Desktop/Edu/apps/portal-shell/package.json)) +- **CI 接线**:[.github/workflows/ci.yml](file:///e:/Desktop/Edu/.github/workflows/ci.yml) `quality-ts` job 新增 "Portal-shell structural checks (P1-8)" step,位于 typecheck 之后、test 之前 +- **预埋违规验证(验收核心)**: + - **路由表违规**:在 `src/app/shell/test-violation/page.tsx` 预埋一个未登记路由 → `check:routes` 报红 `❌ VIOLATIONS (unregistered routes): /shell/test-violation`,exit 1;删除后恢复 PASS + - **codegen 契约违规**:在 `dashboard.graphql.ts` 的 `GetTeacherDashboard` 查询中预埋 `non_existent_field_for_violation_test` 字段 → `check:codegen` 报红 `GraphQL Document Validation failed: Cannot query field "non_existent_field_for_violation_test" on type "TeacherDashboard"`,exit 1;还原后恢复 PASS + - **页面计数违规**:baseline=13,当前实际=13,任一 page.tsx 被删除即触发 `total < baseline` 报红 +- **正常态验证**:三脚本在干净工作区全部 PASS(10 实际路由 / 28 EXACT / 9 PREFIX / 10 PUBLIC / 24 幽灵条目 informational / 13 页面 / codegen 3 输出 SUCCESS) +- **质量校验**:`tsc --noEmit` 0 errors;`eslint src` 0 errors 4 warnings(pre-existing);脚本位于 `scripts/` 不在 tsconfig include 范围内,不影响 typecheck + ### P2 · 教师域页面(2–3 周,可与 P3 部分并行) - 范围:§9.1 全表(~50 页)。顺序建议:exams → homework → grades → lesson-plans → questions/textbooks → attendance/classes/students → diagnostic/error-book/analytics → elective/course-plans → ai-* → practice/schedule-changes/leave。 diff --git a/apps/portal-shell/package.json b/apps/portal-shell/package.json index a5129b3..1cfe81f 100644 --- a/apps/portal-shell/package.json +++ b/apps/portal-shell/package.json @@ -15,6 +15,10 @@ "codegen": "tsx scripts/normalize-schema.ts && graphql-codegen --config codegen.yml", "codegen:watch": "graphql-codegen --config codegen.yml --watch", "generate-pq-manifest": "tsx scripts/generate-pq-manifest.ts", + "check:routes": "tsx scripts/check-route-table.ts", + "check:pages": "tsx scripts/check-page-count.ts", + "check:codegen": "tsx scripts/check-codegen.ts", + "check:all": "pnpm run check:routes && pnpm run check:pages && pnpm run check:codegen", "prebuild": "pnpm run codegen && pnpm run generate-pq-manifest" }, "dependencies": { diff --git a/apps/portal-shell/scripts/check-codegen.ts b/apps/portal-shell/scripts/check-codegen.ts new file mode 100644 index 0000000..b58c62d --- /dev/null +++ b/apps/portal-shell/scripts/check-codegen.ts @@ -0,0 +1,64 @@ +// Codegen contract validation check (ARCHITECTURE.md §10 P1-8 / §5.3) +// +// Runs graphql-codegen and fails if any output with skipDocumentsValidation:false +// contains operations that reference non-existent schema fields. +// This is the "codegen diff check" — it diffs operations against schema. +// +// Currently enforces: +// - dashboard-types.ts output (skipDocumentsValidation: false, P1-7) +// As more domains fix their operations, their outputs will be validated too. +// +// Usage: tsx scripts/check-codegen.ts +// Exit: 0 = codegen success, 1 = validation errors +// +// Related: ARCHITECTURE.md §5.3 契约纪律, §10 P1-8 +import { execSync } from "node:child_process"; + +function main(): void { + console.log("=== Codegen Contract Validation Check ==="); + console.log("Running: pnpm run codegen (normalize-schema + graphql-codegen)"); + console.log(""); + + try { + const output = execSync("pnpm run codegen", { + cwd: process.cwd(), + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + }); + console.log(output); + + // Check for validation failures even on exit 0 (some may be warnings) + if (output.includes("GraphQL Document Validation failed")) { + console.log("❌ Codegen reported validation failures despite exit 0"); + console.log("Result: FAIL"); + process.exit(1); + } + + console.log( + "Result: PASS (codegen succeeded, all validated outputs clean)", + ); + process.exit(0); + } catch (err: unknown) { + const e = err as { stdout?: string; stderr?: string; message: string }; + const output = `${e.stdout ?? ""}\n${e.stderr ?? ""}`; + console.log(output); + + if (output.includes("GraphQL Document Validation failed")) { + console.log( + "❌ Codegen validation failed — operations reference non-existent schema fields", + ); + console.log( + " Fix: update operations/*.graphql.ts to match combined-schema.graphql", + ); + console.log( + " Or: keep skipDocumentsValidation: true for that output until schema is ready", + ); + } else { + console.log(`❌ Codegen failed: ${e.message}`); + } + console.log("Result: FAIL"); + process.exit(1); + } +} + +main(); diff --git a/apps/portal-shell/scripts/check-page-count.ts b/apps/portal-shell/scripts/check-page-count.ts new file mode 100644 index 0000000..e509dfa --- /dev/null +++ b/apps/portal-shell/scripts/check-page-count.ts @@ -0,0 +1,160 @@ +// 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 P1-8 (2026-07-22). Update when adding pages. +const BASELINE: Baseline = { + total: 13, + 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(); diff --git a/apps/portal-shell/scripts/check-route-table.ts b/apps/portal-shell/scripts/check-route-table.ts new file mode 100644 index 0000000..fab7700 --- /dev/null +++ b/apps/portal-shell/scripts/check-route-table.ts @@ -0,0 +1,169 @@ +// 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 { + const routes = new Set(); + + 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; + prefixes: string[]; + publicRoutes: Set; +} { + const content = fs.readFileSync(ROUTE_PERMS_FILE, "utf8"); + const exact = new Set(); + const prefixes: string[] = []; + const publicRoutes = new Set(); + + 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, + prefixes: string[], + publicRoutes: Set, +): 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();