Some checks failed
Deploy / deploy (push) Has been cancelled
- submission.ts 1416 行降到 751 行;统计三条路由拆成子路由,挂在原位置, 仍排在 /submissions/:id 之前 - check-route-shadowing 原来只认 index.ts 直接挂载,拆分后漏检 3 条(178→175); 现在按挂载位置展开嵌套的 .route(),恢复 178 条、无遮蔽 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
140 lines
4.7 KiB
TypeScript
140 lines
4.7 KiB
TypeScript
/**
|
||
* 检查有没有路由被先注册的同形路由「吃掉」。
|
||
*
|
||
* bun run --filter '@oj2/api' check:routes
|
||
*
|
||
* ## 为什么需要这个
|
||
*
|
||
* **Hono 按注册顺序匹配,不是静态优先**(已实测确认,别凭直觉假设)。所以
|
||
*
|
||
* problemRoutes.get("/problems/:id", …) // 先注册
|
||
* problemRoutes.get("/problems/random", …) // 永远进不去
|
||
*
|
||
* 第二条不会报错、不会警告,只是静默走进第一条的 handler,然后因为 "random"
|
||
* 不是合法 id 而返回 404 或者一堆看不懂的结果。阶段 4 真实发生过一次:
|
||
* 两个教师用的分析端点被 `/problems/:id` 吃掉,评审时才发现。
|
||
*
|
||
* 加路由时顺手跑一下,比事后靠人眼在 200 多条路由里看出顺序问题可靠。
|
||
*
|
||
* 局限:靠正则读源码,只认 `xxxRoutes.get("字面量", …)` 这种写法,
|
||
* 以及 `xxxRoutes.route("字面量", 子路由)` 的嵌套挂载(按挂载位置展开)。
|
||
* 动态拼出来的路径看不见 —— 但本仓库没有那种写法,加的时候请保持。
|
||
*/
|
||
|
||
import { readFileSync, readdirSync, statSync } from "node:fs"
|
||
import { join, resolve } from "node:path"
|
||
|
||
const SRC = resolve(import.meta.dir, "..")
|
||
|
||
interface Route {
|
||
method: string
|
||
path: string
|
||
file: string
|
||
}
|
||
|
||
function walk(dir: string, out: string[] = []) {
|
||
for (const entry of readdirSync(dir)) {
|
||
const path = join(dir, entry)
|
||
if (statSync(path).isDirectory()) walk(path, out)
|
||
else if (entry.endsWith(".ts")) out.push(path)
|
||
}
|
||
return out
|
||
}
|
||
|
||
/** 先注册的 pattern 会不会把后注册的 target 吃掉 */
|
||
export function shadows(pattern: string, target: string) {
|
||
const a = pattern.split("/").filter(Boolean)
|
||
const b = target.split("/").filter(Boolean)
|
||
if (a.length !== b.length) return false
|
||
let usedParam = false
|
||
for (let i = 0; i < a.length; i++) {
|
||
const seg = a[i]!
|
||
const other = b[i]!
|
||
if (seg.startsWith(":")) {
|
||
// 参数段吃得掉任何字面量段;两边都是参数说明本来就是同一条,不算遮蔽
|
||
if (other.startsWith(":")) continue
|
||
usedParam = true
|
||
continue
|
||
}
|
||
if (seg !== other) return false
|
||
}
|
||
return usedParam
|
||
}
|
||
|
||
function collect(): Route[] {
|
||
const routerFile = new Map<string, string>()
|
||
for (const file of walk(SRC)) {
|
||
for (const m of readFileSync(file, "utf8").matchAll(
|
||
/export const (\w+) = new Hono/g,
|
||
)) {
|
||
routerFile.set(m[1]!, file)
|
||
}
|
||
}
|
||
|
||
const routesOf = (router: string, prefix: string): Route[] => {
|
||
const file = routerFile.get(router)
|
||
if (!file) return []
|
||
const text = readFileSync(file, "utf8")
|
||
// 直接注册的路由和嵌套挂载(`router.route("/", child)`)放在一起按出现位置排序:
|
||
// 子路由挂在哪个位置,它的路由就在哪个位置参与匹配
|
||
const pattern = new RegExp(
|
||
`${router}\\.(get|post|put|delete|patch)\\(\\s*"([^"]+)"|${router}\\.route\\(\\s*"([^"]*)"\\s*,\\s*(\\w+)\\s*\\)`,
|
||
"g",
|
||
)
|
||
return [...text.matchAll(pattern)].flatMap((m) => {
|
||
if (m[4]) return routesOf(m[4], prefix + m[3]!)
|
||
return [
|
||
{
|
||
method: m[1]!.toUpperCase(),
|
||
path: (prefix + m[2]!).replace(/\/+/g, "/").replace(/\/$/, "") || "/",
|
||
file: file.replace(SRC + "/", ""),
|
||
},
|
||
]
|
||
})
|
||
}
|
||
|
||
// 挂载顺序就是匹配顺序,所以必须按 index.ts 里出现的先后来摊平
|
||
const index = readFileSync(join(SRC, "index.ts"), "utf8")
|
||
const adminIndex = readFileSync(join(SRC, "routes/admin/index.ts"), "utf8")
|
||
const adminMounts = [
|
||
...adminIndex.matchAll(/\.route\(\s*"([^"]*)"\s*,\s*(\w+)\s*\)/g),
|
||
]
|
||
|
||
const all: Route[] = []
|
||
for (const m of index.matchAll(
|
||
/app\.route\(\s*"([^"]+)"\s*,\s*(\w+)\s*\)/g,
|
||
)) {
|
||
const [, prefix, router] = m
|
||
if (router === "adminRoutes") {
|
||
for (const a of adminMounts) all.push(...routesOf(a[2]!, prefix! + a[1]!))
|
||
} else {
|
||
all.push(...routesOf(router!, prefix!))
|
||
}
|
||
}
|
||
return all
|
||
}
|
||
|
||
const routes = collect()
|
||
const hits: [Route, Route][] = []
|
||
for (let i = 0; i < routes.length; i++) {
|
||
for (let j = i + 1; j < routes.length; j++) {
|
||
if (routes[i]!.method !== routes[j]!.method) continue
|
||
if (shadows(routes[i]!.path, routes[j]!.path))
|
||
hits.push([routes[i]!, routes[j]!])
|
||
}
|
||
}
|
||
|
||
console.log(`按注册顺序检查了 ${routes.length} 条路由`)
|
||
if (hits.length === 0) {
|
||
console.log("✓ 没有路由被遮蔽")
|
||
process.exit(0)
|
||
}
|
||
for (const [first, second] of hits) {
|
||
console.log(`\n⚠ ${second.method} ${second.path} (${second.file})`)
|
||
console.log(
|
||
` 进不去:被先注册的 ${first.method} ${first.path} 吃掉(${first.file})`,
|
||
)
|
||
console.log(` 改法:把它挪到那条之前注册,或换一个不同形的路径`)
|
||
}
|
||
process.exit(1)
|