$ErrorActionPreference = 'Stop' $root = Split-Path -Parent $PSScriptRoot $pagesPath = Join-Path $root 'pages.json' $routesPath = Join-Path $root 'utils/navigation-routes.js' if (-not (Test-Path -LiteralPath $routesPath -PathType Leaf)) { throw '缺少路由语义注册表:utils/navigation-routes.js' } <# NODE-CONTRACT-BEGIN "use strict"; const assert = require("assert"); const fs = require("fs"); (async () => { const [pagesPath, routesPath] = process.argv.slice(2); const pages = JSON.parse(fs.readFileSync(pagesPath, "utf8")); const routesSource = fs.readFileSync(routesPath, "utf8"); const moduleUrl = `data:text/javascript;base64,${Buffer.from(routesSource).toString("base64")}`; const { NOTICE_TARGETS, ROUTES, ROOT_ROUTE_KEYS, getRoute, getRouteKeyByPath } = await import(moduleUrl); const registeredPaths = pages.pages.map(({ path }) => `/${path}`); const expectedRouteKeys = registeredPaths.map((path) => { const filename = path.split("/").pop(); const match = /^([a-z]\d{2})-/i.exec(filename); assert(match, `页面文件名缺少语义编号:${path}`); return match[1].toUpperCase(); }); const routeKeys = Object.keys(ROUTES); const routePaths = routeKeys.map((routeKey) => ROUTES[routeKey].path); assert.strictEqual(routeKeys.length, 52, "路由注册表必须恰好包含 52 条活动路由"); assert.deepStrictEqual(routeKeys, expectedRouteKeys, "路由键集合和顺序必须来自 pages.json 的页面编号"); assert.deepStrictEqual(routePaths, registeredPaths, "路由路径集合和顺序必须与 pages.json 精确一致"); assert(Object.isFrozen(ROUTES), "ROUTES 必须冻结"); assert(Object.isFrozen(ROOT_ROUTE_KEYS), "ROOT_ROUTE_KEYS 必须冻结"); assert(Object.isFrozen(NOTICE_TARGETS), "NOTICE_TARGETS 必须冻结"); assert.deepStrictEqual(Object.keys(NOTICE_TARGETS), ["GENEALOGY_REVIEW", "GENEALOGY_HOME"], "通知目标类型必须是封闭白名单"); assert.deepStrictEqual( Object.fromEntries(Object.entries(NOTICE_TARGETS).map(([key, value]) => [key, { routeKey: value.routeKey, params: Array.from(value.params) }])), { GENEALOGY_REVIEW: { routeKey: "G10", params: ["genealogyId"] }, GENEALOGY_HOME: { routeKey: "G01", params: ["genealogyId"] }, }, "通知只能映射到登记业务语义及精确参数", ); for (const target of Object.values(NOTICE_TARGETS)) { assert(Object.isFrozen(target), "通知目标定义必须冻结"); assert(Object.isFrozen(target.params), "通知目标参数表必须冻结"); } const routeFields = [ "path", "kind", "parent", "parentParamMap", "requiredParams", "optionalParams", "allowedSources", "resultOperations", ]; const routeKinds = new Set(["auth-root", "root", "page", "flow", "single"]); const forbiddenParams = new Set([ "previous", "returnUrl", "fallbackUrl", "targetUrl", "state", "count", "saveResult", "step", "genealogyName", "sourceKey", "personName", "giftId", "ritualId", ]); const discoveredRoots = routeKeys.filter((routeKey) => ROUTES[routeKey].parent === null); assert.strictEqual(discoveredRoots.length, 4, "活动路由必须保持四个互不伪造历史的根语义"); assert.deepStrictEqual(Array.from(ROOT_ROUTE_KEYS), ["A01", "G01", "F01", "M01"], "根安全边界必须固定"); assert.deepStrictEqual(Array.from(ROOT_ROUTE_KEYS), discoveredRoots, "ROOT_ROUTE_KEYS 必须精确列出 parent=null 的路由"); assert.strictEqual(ROUTES.A01.kind, "auth-root", "A01 必须是认证根语义"); assert.deepStrictEqual(Array.from(ROUTES.G03.optionalParams), [], "G03 不得保留未消费的 genealogyId 或 step 参数"); for (const routeKey of ["G01", "G05", "G09", "T01"]) { assert.deepStrictEqual(Array.from(ROUTES[routeKey].resultOperations), [], `${routeKey} 不得预留没有真实 API 生产者的结果能力`); } assert.deepStrictEqual( Array.from(ROUTES.T03.resultOperations), ["member-open-requested"], "T03 当前只能保留 single 页面激活实际使用的页内打开结果", ); const expectedFamilyParams = { F01: { required: [], optional: ["genealogyId"] }, F02: { required: ["genealogyId"], optional: [] }, F03: { required: ["genealogyId", "feedId"], optional: [] }, F04: { required: ["genealogyId"], optional: [] }, F05: { required: ["genealogyId", "articleId"], optional: [] }, F06: { required: ["genealogyId", "mode"], optional: ["articleId"] }, F07: { required: ["genealogyId"], optional: [] }, F08: { required: ["genealogyId", "albumId"], optional: [] }, F09: { required: ["genealogyId", "albumId"], optional: [] }, F10: { required: ["genealogyId"], optional: [] }, }; for (const [routeKey, expected] of Object.entries(expectedFamilyParams)) { assert.deepStrictEqual( Array.from(ROUTES[routeKey].requiredParams), expected.required, `${routeKey} 必填参数必须精确表达家谱复合身份`, ); assert.deepStrictEqual( Array.from(ROUTES[routeKey].optionalParams), expected.optional, `${routeKey} 可选参数不得保留身份旁路`, ); assert.deepStrictEqual( Array.from(ROUTES[routeKey].resultOperations), [], `${routeKey} 在真实写接口落地前不得预留伪成功结果`, ); } assert.deepStrictEqual( Array.from(ROUTES.F08.allowedSources), ["F07"], "F09 无结果返回 F08,不得伪装成新的 F08 打开来源", ); const expectedRecordParams = { R01: { required: ["genealogyId"], optional: [] }, R02: { required: ["genealogyId", "mode"], optional: ["personId"] }, R03: { required: ["genealogyId"], optional: [] }, R04: { required: ["genealogyId", "mode"], optional: ["relativeId"] }, R05: { required: ["genealogyId"], optional: [] }, R06: { required: ["genealogyId", "ceremonyId"], optional: [] }, R07: { required: ["genealogyId", "mode"], optional: ["ceremonyId"] }, R08: { required: ["genealogyId", "personId"], optional: [] }, R09: { required: ["genealogyId", "personId"], optional: [] }, R10: { required: ["genealogyId"], optional: [] }, R11: { required: ["genealogyId"], optional: [] }, }; for (const [routeKey, expected] of Object.entries(expectedRecordParams)) { assert.deepStrictEqual( Array.from(ROUTES[routeKey].requiredParams), expected.required, `${routeKey} 必填参数必须精确表达 R 系列复合身份`, ); assert.deepStrictEqual( Array.from(ROUTES[routeKey].optionalParams), expected.optional, `${routeKey} 可选参数不得保留旧 ID 或状态旁路`, ); assert.deepStrictEqual( Array.from(ROUTES[routeKey].resultOperations), [], `${routeKey} 在真实写接口落地前不得预留伪成功结果`, ); } for (const routeKey of ["N01", "N02", "M01", "M02", "M03", "M04", "M05", "M06", "M07", "M08", "M09", "M10"]) { assert.deepStrictEqual( Array.from(ROUTES[routeKey].resultOperations), [], `${routeKey} 在真实写接口落地前不得预留伪成功结果`, ); } assert(ROUTES.G09.allowedSources.includes("G05"), "G05 审核中公开预览必须能进入 G09 申请进度"); for (const routeKey of ["G01", "F01", "M01"]) { assert.strictEqual(ROUTES[routeKey].kind, "root", `${routeKey} 必须是业务根语义`); } assert.deepStrictEqual(routeKeys.filter((routeKey) => ROUTES[routeKey].kind === "single"), ["T03"], "T03 必须是唯一单实例页面"); for (const [index, routeKey] of routeKeys.entries()) { const route = ROUTES[routeKey]; assert(Object.isFrozen(route), `${routeKey} 路由项必须冻结`); assert.deepStrictEqual(Object.keys(route).sort(), [...routeFields].sort(), `${routeKey} 路由字段集合不精确`); assert(routeKinds.has(route.kind), `${routeKey} 页面类型非法`); assert.strictEqual(route.path, registeredPaths[index], `${routeKey} 路径与 pages.json 不一致`); for (const field of ["requiredParams", "optionalParams", "allowedSources", "resultOperations"]) { const values = route[field]; assert(Array.isArray(values), `${routeKey}.${field} 必须是数组`); assert(Object.isFrozen(values), `${routeKey}.${field} 必须冻结`); assert.strictEqual(new Set(values).size, values.length, `${routeKey}.${field} 不得重复`); assert(values.every((value) => typeof value === "string" && value.length > 0), `${routeKey}.${field} 只能包含非空字符串`); } assert(Object.isFrozen(route.parentParamMap), `${routeKey}.parentParamMap 必须冻结`); assert(route.parentParamMap && Object.getPrototypeOf(route.parentParamMap) === Object.prototype, `${routeKey}.parentParamMap 必须是普通对象`); const businessParams = [...route.requiredParams, ...route.optionalParams]; assert.strictEqual(new Set(businessParams).size, businessParams.length, `${routeKey} 必填与可选参数不得重复`); for (const name of businessParams) { assert(/^[A-Za-z][A-Za-z0-9]*$/.test(name), `${routeKey} 参数名非法:${name}`); assert(!forbiddenParams.has(name), `${routeKey} 不得声明旧参数 ${name}`); } for (const sourceKey of route.allowedSources) { assert(getRoute(sourceKey), `${routeKey} 引用了不存在的来源 ${sourceKey}`); assert.notStrictEqual(sourceKey, routeKey, `${routeKey} 不得把自身声明为来源`); } for (const operation of route.resultOperations) { assert(/^[a-z]+(?:-[a-z]+)*$/.test(operation), `${routeKey} 结果操作必须使用小写 kebab-case:${operation}`); } if (route.parent === null) { assert(["auth-root", "root"].includes(route.kind), `${routeKey} 根语义类型非法`); assert.strictEqual(route.allowedSources.length, 0, `${routeKey} 根语义不得伪造允许来源`); } else { assert(typeof route.parent === "string" && getRoute(route.parent), `${routeKey} 引用了不存在的父页`); assert(routeKinds.has(route.kind) && !["auth-root", "root"].includes(route.kind), `${routeKey} 非根页面类型非法`); assert(routeKeys.indexOf(route.parent) < index, `${routeKey} 父页必须先于子页注册`); assert(route.allowedSources.length > 0, `${routeKey} 非根页面必须声明至少一个允许来源`); } const mappedChildParams = []; for (const [parentParam, childParam] of Object.entries(route.parentParamMap)) { const parent = getRoute(route.parent); assert(parent, `${routeKey} 声明父参数映射但没有父页`); assert([...parent.requiredParams, ...parent.optionalParams].includes(parentParam), `${routeKey} 的父参数 ${parentParam} 未在父页声明`); assert(businessParams.includes(childParam), `${routeKey} 的子参数 ${childParam} 未在本页声明`); mappedChildParams.push(childParam); } assert.strictEqual(new Set(mappedChildParams).size, mappedChildParams.length, `${routeKey} 同一子参数不得映射到多个父参数`); } // 每条父链必须无环并最终到达已登记根页;这比维护第二张父页表更能约束结构安全。 for (const routeKey of routeKeys) { const visited = new Set(); let cursor = routeKey; while (cursor !== null) { assert(!visited.has(cursor), `${routeKey} 父链存在环:${cursor}`); visited.add(cursor); const route = getRoute(cursor); assert(route, `${routeKey} 父链包含未知路由:${cursor}`); cursor = route.parent; } const terminal = Array.from(visited).pop(); assert(ROOT_ROUTE_KEYS.includes(terminal), `${routeKey} 父链未落到登记根页`); } // allowedSources 是进入权限边。所有页面都必须能从四根之一沿合法入口到达, // 防止若干非根页面组成自洽但与真实产品入口隔离的来源环。 const sourceReachable = new Set(ROOT_ROUTE_KEYS); let sourceReachabilityChanged = true; while (sourceReachabilityChanged) { sourceReachabilityChanged = false; for (const routeKey of routeKeys) { if (sourceReachable.has(routeKey)) continue; if (ROUTES[routeKey].allowedSources.some((sourceKey) => sourceReachable.has(sourceKey))) { sourceReachable.add(routeKey); sourceReachabilityChanged = true; } } } assert.deepStrictEqual(Array.from(sourceReachable).sort(), routeKeys.slice().sort(), "存在无法从任何根语义合法进入的页面"); assert.strictEqual(getRoute(routeKeys[0]), ROUTES[routeKeys[0]], "getRoute 必须返回注册表中的同一冻结对象"); assert.strictEqual(getRoute("UNKNOWN"), null, "getRoute 对未知键必须返回 null"); for (const inheritedKey of ["constructor", "toString", "valueOf", "__proto__"]) { assert.strictEqual(getRoute(inheritedKey), null, `getRoute 不得把原型链键 ${inheritedKey} 当成活动路由`); } for (const invalidKey of [null, 42, [routeKeys[0]], new String(routeKeys[0]), { toString: () => routeKeys[0] }]) { assert.strictEqual(getRoute(invalidKey), null, "getRoute 只接受原始字符串路由键"); } const samplePath = routePaths[Math.floor(routePaths.length / 2)]; const sampleKey = routeKeys[Math.floor(routeKeys.length / 2)]; assert.strictEqual(getRouteKeyByPath(samplePath.slice(1)), sampleKey, "路径查询必须接受无前导斜杠路径"); assert.strictEqual(getRouteKeyByPath(samplePath), sampleKey, "路径查询必须接受有前导斜杠路径"); assert.strictEqual(getRouteKeyByPath("/pages/unknown"), null, "未知路径必须返回 null"); for (const invalidPath of [null, 42, [samplePath], { toString: () => samplePath }]) { assert.strictEqual(getRouteKeyByPath(invalidPath), null, "路径查询只接受原始字符串路径"); } console.log(`NAVIGATION-ROUTES-CONTRACT PASS ROUTES=${routeKeys.length}`); })().catch((error) => { console.error(error); process.exitCode = 1; }); NODE-CONTRACT-END #> # 注册表是导航语义的唯一数据所有者。本合同真实导入 ESM,并以 pages.json 交叉和 # 通用图不变量验收边界,避免用一张必须同步维护的同构影子表复制合同。 $contractPath = Join-Path $root 'tests/navigation-routes-contract.ps1' $contractFile = Get-Content -Raw -Encoding UTF8 -LiteralPath $contractPath $startMarker = '<# NODE-CONTRACT-BEGIN' $endMarker = 'NODE-CONTRACT-END #>' $startIndex = $contractFile.IndexOf($startMarker) $endIndex = $contractFile.IndexOf($endMarker) if ($startIndex -lt 0 -or $endIndex -le $startIndex) { throw '无法读取内嵌的路由注册表运行合同' } $startIndex += $startMarker.Length $nodeContract = $contractFile.Substring( $startIndex, $endIndex - $startIndex ).Trim() $previousErrorActionPreference = $ErrorActionPreference $ErrorActionPreference = 'Continue' $output = $nodeContract | & node - $pagesPath $routesPath 2>&1 $nodeExitCode = $LASTEXITCODE $ErrorActionPreference = $previousErrorActionPreference if ($nodeExitCode -ne 0) { throw ($output -join "`n") } Write-Output ($output -join "`n")