/** * 纠偏第三批 · 模块壳与三入口源码守卫(BR-50;AC-53/54/55)。 * * 这是**源码断言**,不是浏览器端到端测试(docs/v2/06-validation.md §2)。 * 它锁住四类可机械核对的约束: * * 1. **深色导航+浅色工作区**(AC-53):导航取原型 token,画布保持浅色。 * 2. **三个模块各有独立入口**(BR-50):AI 预审 / 历史经验加工 / 轨迹复盘, * 各自有路由、各自渲染**复用**的组件——不是把面板塞进旧页当页签。 * 3. **防重复页面**(AC-55):已合并的旧页必须仍是重定向;不得复活。 * 4. **权限与数据隔离**(AC-55):导航按角色裁剪;模块以 ?project= 为上下文, * 不把项目 id 硬编码,也不在前端自造授权。 */ const assert = require("node:assert/strict"); const fs = require("node:fs"); const path = require("node:path"); const root = path.resolve(__dirname, ".."); const read = (file) => fs.readFileSync(path.join(root, file), "utf8"); const exists = (file) => fs.existsSync(path.join(root, file)); const navigation = read("src/components/xingbang/navigation.css"); const nav = read("src/components/xingbang/AgentNav.tsx"); const home = read("src/app/page.tsx"); // ---------------------------------------------------------------- 1. 深色导航 assert.match(navigation, /\.xb-agent-nav\{[^}]*background:#102B27/, "主导航应使用原型的深色 nav.bg #102B27"); assert.match(navigation, /\.xb-agent-nav a\.selected\{[^}]*background:#244D41/, "选中项应使用 nav.active #244D41"); assert.match(nav, /智审工作台/, "品牌区应显示「智审工作台」(原型文案)"); // 深色底的文字对比度**算出来**,而不是在文档里声称"应该够清楚"(G-6 机械约束优先)。 // 阈值取 WCAG AA 正文 4.5:1;四组配色都要过。 function luminance(hex) { const channel = (value) => { const c = value / 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; }; const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)); return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); } function contrast(foreground, background) { const [a, b] = [luminance(foreground), luminance(background)]; return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); } const NAV_BG = "#102B27", NAV_ACTIVE = "#244D41"; for (const [label, fg, bg] of [ ["未选中导航项", "#DCE9E3", NAV_BG], ["选中导航项", "#FFFFFF", NAV_ACTIVE], ["次要文字(muted)", "#B1C6BE", NAV_BG], ["品牌标题", "#F3F7F5", NAV_BG], ]) { assert.ok(contrast(fg, bg) >= 4.5, `深色导航「${label}」对比度应 ≥ 4.5:1,实测 ${contrast(fg, bg).toFixed(2)}:1`); } assert.match(nav, /业务工作区/, "导航应保留「业务工作区」分组标签"); // 导航是深色,身份选择器在导航内必须用深色变体,否则白底白字 assert.match(nav, /]*dark/, "导航内的身份选择器应使用深色变体"); // 画布仍是浅色:不要把整页刷黑 assert.match(read("src/app/data-access/workspace.css"), /\.ra-app\{min-height:100vh;background:#f5f7f9/, "工作区画布应保持浅色"); // ---------------------------------------------------------------- 2. 三个独立入口 const MODULES = [ {key: "prescreen", href: "/prescreen-workbench", label: "AI 预审", component: "PrescreenPanel"}, {key: "experience", href: "/experience-center", label: "历史经验加工", component: "ExperienceCenter"}, {key: "trajectory", href: "/trajectory-center", label: "轨迹复盘", component: "TrajectoryPanel"}, ]; for (const item of MODULES) { assert.ok(exists(`src/app${item.href}/page.tsx`), `${item.label} 应有独立路由 ${item.href}`); assert.ok(nav.includes(`"${item.href}"`), `导航应提供 ${item.label} 入口`); assert.ok(nav.includes(item.label), `导航入口文案应为「${item.label}」`); const page = read(`src/app${item.href}/page.tsx`); // 复用组件而不是在路由里再写一份实现:import 自 @/components assert.match(page, /from "@\/components\/xingbang\//, `${item.label} 路由应复用既有组件`); assert.match(page, /AgentNav/, `${item.label} 路由应复用深色导航`); // 数据隔离:项目上下文来自 URL,不硬编码项目 id assert.match(page, /new URLSearchParams\(location\.search\)\.get\("project"\)/, `${item.label} 的项目上下文应来自 ?project=`); assert.ok(!/DEMO-[ABC]/.test(page), `${item.label} 不得硬编码演示项目 id`); } // 首页必须呈现三个入口(AC-53:不能只保留旧版落地页布局) for (const item of MODULES) { assert.ok(home.includes(item.href), `业务首页应提供 ${item.label} 入口卡片`); } assert.match(home, /MODULES/, "首页应以模块清单驱动入口卡片,而不是写死一段静态文案"); // 旧版落地页内容保留为独立入口,而不是被删掉 assert.ok(exists("src/app/assistant/page.tsx"), "原落地页内容应保留在 /assistant"); assert.match(read("src/app/assistant/page.tsx"), /BusinessAssistant/, "/assistant 应仍渲染业务助手"); // ---------------------------------------------------------------- 3. 防重复页面(AC-55) // 已合并的旧页必须仍是重定向,不得被重新实现成独立页面。 for (const merged of ["trajectory", "review-workbench", "report-workbench", "collateral-workbench"]) { assert.match(read(`src/app/${merged}/page.tsx`), /redirect\(/, `已合并页 src/app/${merged} 必须保持重定向,不得复活`); } // 不得把预审面板搬到与既有禁名冲突的目录(那会把旧的 S1/T09 结构重新引入) for (const banned of ["prescreen", "sandbox", "simulation", "import-batch"]) { assert.ok(!exists(`src/app/${banned}`), `不得新建与既有面板重名的路由 src/app/${banned}`); } // 同一个面板组件被两个宿主复用时,必须是同一份实现(import 复用),不是复制粘贴的第二份 const hosts = ["src/app/xingbang-workbench/page.tsx", "src/app/prescreen-workbench/page.tsx"]; for (const host of hosts) { assert.match(read(host), /import PrescreenPanel from "@\/components\/xingbang\/PrescreenPanel"/, `${host} 应复用同一个 PrescreenPanel 组件`); } // ---------------------------------------------------------------- 4. 权限(AC-55) assert.match(nav, /canConfigure/, "导航应按角色裁剪配置治理入口"); assert.match(nav, /request\.state|identity\?\.role/, "导航的角色判据应来自服务端身份而不是硬编码"); assert.ok(!/localStorage\.setItem\("xingbang-demo-role"/.test(nav), "导航不得自行设定演示身份"); // ---------------------------------------------------------------- 5. 轨迹复盘的可操作与诚实性(AC-54) const trajectory = read("src/components/xingbang/TrajectoryPanel.tsx"); assert.match(trajectory, /failed/, "轨迹面板必须能显示失败步骤(失败保留)"); assert.match(trajectory, /不填 0|未记录/, "成本缺失时必须留空,不得填 0"); assert.ok(!/cost\s*[:=]\s*0\b/.test(trajectory), "不得把缺失成本写成 0"); assert.match(trajectory, /只读/, "轨迹面板必须声明只读"); // 判据落在"是否渲染推理字段"上,而不是禁用词本身——否则解释规则的注释会把守卫自己绊倒。 assert.ok(!/\.\s*(reasoning|reasoning_content|thoughts?|chain_of_thought)\b/i.test(trajectory), "不得渲染模型隐藏推理字段"); // 服务端已声明轨迹不含内部推理,界面必须把它显示出来(而不是自己编一句) assert.match(trajectory, /trajectory\.note|note\}/, "应展示服务端对轨迹范围的说明"); assert.match(trajectory, /review-outcomes|会议结果/, "轨迹面板应与会议结果对照"); // 预审模块必须真的挂载预审面板(否则"AI 预审入口"是空壳) assert.match(read("src/app/prescreen-workbench/page.tsx"), /