app:路由注册表 —— 评审反馈修复(三个 Important findings)

补充了三处测试密度不足的地方:

① has/lookup 区分(Finding 1):
   参数名换一个时,lookup 仍能匹配(参数段认任意值),has 说没有。
   两个接口的语义差异这次被真的逼出来。

② 字面优先级压力测试(Finding 2):
   注册 /api/gov/:zone 和 /api/gov/staff,两条都能匹配同一个路径时,
   验证字面优先于参数段这个优先级是否真的生效。

③ 建表自检防卫(Finding 3):
   两条参数段模版互撞时,命中谁只看声明顺序 —— 静默判错权限。
   添加 canCollide() 函数与入口自检,撞了就 build fail loud。
   验证:真实 95 条路由建得起来(no collision)。

测试统计:9 → 18 条(+9),全量 1757 → 1766(+9),门禁全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015kAzS35kMnZNt4YGgJXtfw
This commit is contained in:
xcs
2026-08-25 16:56:15 +08:00
co-authored by Claude Opus 5
parent 2eef8b4544
commit 3a34362eec
2 changed files with 103 additions and 1 deletions
@@ -39,12 +39,32 @@ describe('路由注册表', () => {
it('数组归属原样回', () => { it('数组归属原样回', () => {
expect(registry.lookup('GET', '/api/gov/staff')).toEqual(['design', 'ops']); expect(registry.lookup('GET', '/api/gov/staff')).toEqual(['design', 'ops']);
}); });
it('has 按注册时的模版查', () => { it('has 认的是注册时的模版,lookup 认的是实际路径', () => {
expect(registry.has('POST', '/api/gov/tasks/:taskId/control')).toBe(true); expect(registry.has('POST', '/api/gov/tasks/:taskId/control')).toBe(true);
expect(registry.has('GET', '/api/gov/tasks/:taskId/control')).toBe(false); expect(registry.has('GET', '/api/gov/tasks/:taskId/control')).toBe(false);
expect(registry.has('GET', '/api/health')).toBe(true); expect(registry.has('GET', '/api/health')).toBe(true);
expect(registry.size).toBe(9); expect(registry.size).toBe(9);
}); });
it('参数名换一个:lookup 还是能匹配(参数段认任意值),has 就说没有', () => {
// 注册的模版是 `:taskId`,lookup('/api/gov/tasks/:otherName/control') 的
// `:otherName` 满足参数段的 [^/]+ 正则,lookup 会命中;
// 但 has 只认注册时的模版名,两者语义不同
expect(registry.lookup('POST', '/api/gov/tasks/:otherName/control')).toBe('ops');
expect(registry.has('POST', '/api/gov/tasks/:otherName/control')).toBe(false);
});
});
describe('字面优先于参数段 —— 同一个路径两边都能匹配时', () => {
const collide = buildRouteRegistry([
{ method: 'GET', path: '/api/gov/:zone', access: 'design' },
{ method: 'GET', path: '/api/gov/staff', access: ['design', 'ops'] },
]);
it('字面赢', () => {
expect(collide.lookup('GET', '/api/gov/staff')).toEqual(['design', 'ops']);
});
it('没有字面可命中时才落到参数段', () => {
expect(collide.lookup('GET', '/api/gov/tools')).toBe('design');
});
}); });
describe('routesOf —— 从契约命名空间挑路由', () => { describe('routesOf —— 从契约命名空间挑路由', () => {
@@ -61,3 +81,50 @@ describe('routesOf —— 从契约命名空间挑路由', () => {
for (const r of all) expect(reg.has(r.method, r.path), `${r.method} ${r.path}`).toBe(true); for (const r of all) expect(reg.has(r.method, r.path), `${r.method} ${r.path}`).toBe(true);
}); });
}); });
describe('建表自检 —— 两条模版能撞就不许建', () => {
const reg = (routes: Parameters<typeof buildRouteRegistry>[0]) => () => buildRouteRegistry(routes);
it('同方法、同段数、逐位兼容 → 抛错', () => {
expect(
reg([
{ method: 'GET', path: '/api/a/:x', access: 'ops' },
{ method: 'GET', path: '/api/:y/b', access: 'design' },
]),
).toThrow(/同一个具体路径/);
});
it('方法不同不算撞', () => {
expect(
reg([
{ method: 'GET', path: '/api/a/:x', access: 'ops' },
{ method: 'POST', path: '/api/:y/b', access: 'design' },
]),
).not.toThrow();
});
it('段数不同不算撞', () => {
expect(
reg([
{ method: 'GET', path: '/api/a/:x', access: 'ops' },
{ method: 'GET', path: '/api/:y/b/c', access: 'design' },
]),
).not.toThrow();
});
it('字面段对不上不算撞', () => {
expect(
reg([
{ method: 'GET', path: '/api/a/:x', access: 'ops' },
{ method: 'GET', path: '/api/b/:y', access: 'design' },
]),
).not.toThrow();
});
it('字面与参数并存不算撞 —— 那是有意的优先级', () => {
expect(
reg([
{ method: 'GET', path: '/api/gov/:zone', access: 'design' },
{ method: 'GET', path: '/api/gov/staff', access: 'ops' },
]),
).not.toThrow();
});
it('真实 95 条契约路由建得起来(没有暗撞)', () => {
expect(() => buildRouteRegistry(routesOf(Object.values(contracts)))).not.toThrow();
});
});
+35
View File
@@ -53,6 +53,21 @@ function matcherOf(pattern: string): RegExp {
return new RegExp(`^${body}$`); return new RegExp(`^${body}$`);
} }
/**
* 两条模版会不会匹配到同一个具体路径:同段数,且每一位都兼容
* (参数段与任何段兼容;字面段只与相同的字面兼容)。
*/
function canCollide(a: string, b: string): boolean {
const sa = a.split('/');
const sb = b.split('/');
if (sa.length !== sb.length) return false;
return sa.every((seg, i) => {
const other = sb[i] ?? '';
if (seg.startsWith(':') || other.startsWith(':')) return true;
return seg === other;
});
}
export function buildRouteRegistry(routes: readonly RegisteredRoute[]): RouteRegistry { export function buildRouteRegistry(routes: readonly RegisteredRoute[]): RouteRegistry {
const literal = new Map<string, RouteAccess>(); const literal = new Map<string, RouteAccess>();
const patterns: Array<{ method: string; pattern: string; re: RegExp; access: RouteAccess }> = []; const patterns: Array<{ method: string; pattern: string; re: RegExp; access: RouteAccess }> = [];
@@ -60,6 +75,26 @@ export function buildRouteRegistry(routes: readonly RegisteredRoute[]): RouteReg
if (r.path.includes(':')) patterns.push({ method: r.method, pattern: r.path, re: matcherOf(r.path), access: r.access }); if (r.path.includes(':')) patterns.push({ method: r.method, pattern: r.path, re: matcherOf(r.path), access: r.access });
else literal.set(`${r.method} ${r.path}`, r.access); else literal.set(`${r.method} ${r.path}`, r.access);
} }
/*
* 两条模版能匹配到同一个具体路径 = 查它时命中谁全看声明顺序 —— 那是**静默判错权限**,
* 比启动失败严重得多。契约是我们自己写的,撞了就是写错了:开发期就掀桌子。
* 只检模版之间;字面与参数并存是**有意的**优先级(字面赢),不算冲突。
*/
for (let i = 0; i < patterns.length; i += 1) {
for (let j = i + 1; j < patterns.length; j += 1) {
const a = patterns[i];
const b = patterns[j];
if (a === undefined || b === undefined) continue;
if (a.method === b.method && canCollide(a.pattern, b.pattern)) {
throw new Error(
`路由归属表冲突:${a.method} ${a.pattern} 与 ${a.method} ${b.pattern} 会匹配到同一个具体路径 —— ` +
'命中谁只看声明顺序,等于静默判错权限。改掉其中一条的路径。',
);
}
}
}
return { return {
size: routes.length, size: routes.length,
lookup(method, path) { lookup(method, path) {