一次没人认领的线上问题
某客户端的发布流程里有一条约定:每个合并请求至少要有两位评审人,其中一位必须是相关模块的 owner。约定执行了半年,直到一次线上问题暴露了漏洞。
问题出在一个共享的桥接层:它既不属于平台组,也不属于业务组,是两位工程师在赶版本时临时加的。代码合并时,评审人是从整个客户端团队里随机抽的,没人真正看过这段逻辑。三周后桥接层超时,值班同学在群里问「这块谁负责」,沉默了两个小时,最后由一位已经转岗的工程师凭记忆修好。
复盘时团队发现两件事。第一,仓库里根本没有一份可执行的 owner 映射,所谓「相关模块 owner」全靠自觉。第二,即使有映射,也覆盖不到这个新建目录,因为没有人规定「新增目录必须同时登记 owner」。
代码所有权要解决的不是「谁写得多」,而是三个更具体的问题:每条路径有没有明确负责人、一次变更应该找谁评审、哪些路径处于无人负责状态。
owner 映射是一组带优先级的规则
最朴素的做法是给每个目录写一个 owner。但真实仓库里,规则会重叠:src/platform/** 归平台组,src/platform/legacy/** 又归遗留系统维护组。没有优先级,结果取决于规则的遍历顺序,同一个路径可能在不同工具里得到不同 owner。
| 字段 | 含义 | 约束 |
|---|---|---|
pattern | 路径匹配规则 | 支持精确路径、*、** 与目录后缀 |
owner | 最终负责团队 | 稳定标识,不写个人昵称 |
reviewers | 需要参与评审的角色 | 可空,会自动去重排序 |
priority | 冲突时的覆盖顺序 | 越大越优先,缺省为 0 |
优先级相同时,先声明的规则胜出。这条规则看起来随意,但它保证了确定性:同样的规则列表,无论运行多少次、在哪台机器上,都会得到同一个 owner。确定性比「更聪明」的冲突消解更重要。
匹配规则要克制。支持精确路径、单星号、双星号与 /** 目录后缀就够了。引入正则或复杂 glob 方言,会让规则本身变成需要维护的代码,而且不同语言的实现细节不一致,最终没人敢改。
/** 的语义要明确:它匹配目录本身,也匹配目录下的所有子孙路径,但不匹配前缀相同的兄弟目录。src/platform/** 不应该匹配 src/platformx/a.ts。这个边界在实现里必须写清楚,否则一次重构就会把 owner 分错。
覆盖度不是百分比,而是缺口清单
很多团队把所有权覆盖率做成一个百分比,挂在看板上。百分比适合汇报,不适合行动。真正有用的是缺口清单:哪些路径没有任何规则命中。
| 指标 | 定义 | 用途 |
|---|---|---|
total | 参与统计的路径总数 | 确认统计范围 |
owned | 至少命中一条规则的路径数 | 计算覆盖比例 |
unowned | 没有命中任何规则的路径清单 | 直接生成待办 |
ratio | owned / total | 趋势观察,不单独作为目标 |
空集合的覆盖率定义为 1,而不是 0。这不是数学上的严密,而是工程上的选择:没有路径时不存在缺口,报 0 会让看板出现虚假告警。
覆盖度统计必须使用真实的变更路径,而不是整个仓库的文件列表。一个从未被修改的第三方目录没有 owner,并不影响评审;一条本周要合并的路径没有 owner,才是必须处理的问题。把统计范围收窄到「最近一个发布周期内变更过的路径」,缺口清单会短得多,也更容易推动。
缺口清单还要能区分「新增路径」和「历史遗留路径」。新增路径没有 owner,说明登记流程有问题,应该在合并前阻断;历史路径没有 owner,说明存量治理还没做完,应该进入技术债台账。两者混在一起,团队只会看到一条永远清不完的清单。
评审路由要收敛,而不是扩散
评审路由的目标是「找对人」,不是「找所有人」。一条变更路径可能同时命中多条规则,但最终只需要一组 owner 和一组 reviewer。
路由规则可以归纳为四步:
- 对每条变更路径求出优先级最高的 owner。
- 汇总所有路径的 owner,去重。
- 汇总这些 owner 规则上的 reviewer,去重。
- 对无人负责的路径单独标记,触发升级。
去重之后要排序。排序不是为了好看,而是为了让评审请求的内容稳定:同一组路径无论顺序如何,生成的评审人列表都一样,机器人才不会每次 @ 不同的人。
兜底 owner 是一个有争议的设计。它能让评审请求总是有人接,但也会掩盖「无人负责」这个事实。折中做法是:兜底 owner 只负责把请求转给正确的人,不负责实际评审;同时 escalate 标记必须保留,让流程知道这次路由是降级结果。
没有兜底 owner 时,无人负责路径应该直接失败,而不是静默跳过。评审请求发不出去,比发给一个不相关的人更容易被发现。
无人负责路径与陈旧规则
所有权映射有两种失效方式,方向相反。
第一种是路径没有规则。新增目录、临时脚本、跨团队共建模块最容易出现。检测方法很直接:拿变更路径逐条匹配,收集没有命中的路径。
第二种是规则没有路径。团队重组、目录迁移、模块下线之后,旧规则还留在配置里。它不会造成错误归属,但会让人误以为某个目录仍有人负责。检测方法同样直接:拿规则逐条匹配现有路径,收集从未命中的规则。
两种失效要一起看。只查缺口,会积累一堆指向空气的规则;只查陈旧规则,会漏掉新增目录。把两份清单放在同一张治理看板上,才能看出所有权结构是在收敛还是在膨胀。
陈旧规则不能自动删除。有些规则是为即将合并的分支准备的,有些规则覆盖的是低频但重要的路径。正确做法是标记出来,由 owner 确认后删除,并在删除时留下记录。
把所有权写成代码
下面的模块实现路径规范化、通配匹配、优先级解析、覆盖度统计、评审路由与陈旧规则识别。所有函数都是纯函数,不读文件系统,规则与路径由调用方传入,方便在持续集成里对真实变更集运行。
// file: src/governance/code-ownership.ts
// tier: L1
// verify: node --test test/governance/code-ownership.test.ts
export type OwnerRule = {
pattern: string;
owner: string;
reviewers?: string[];
priority?: number;
};
export type OwnerMatch = {
path: string;
owner: string;
pattern: string;
priority: number;
reviewers: string[];
};
export type CoverageReport = {
total: number;
owned: number;
unowned: string[];
ratio: number;
};
export type ReviewRoute = {
owners: string[];
reviewers: string[];
unowned: string[];
escalate: boolean;
};
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function normalizePath(input: string): string {
let path = input.trim().replace(/\\/g, "/");
while (path.startsWith("./")) {
path = path.slice(2);
}
return path.replace(/\/+$/, "");
}
export function matchPattern(pattern: string, target: string): boolean {
const p = normalizePath(pattern);
const path = normalizePath(target);
if (p.endsWith("/**")) {
const base = p.slice(0, -3);
return path === base || path.startsWith(`${base}/`);
}
if (!p.includes("*")) {
return p === path;
}
const source = p
.split("**")
.map((part) => part.split("*").map(escapeRegExp).join("[^/]*"))
.join(".*");
return new RegExp(`^${source}$`).test(path);
}
export function resolveOwner(rules: OwnerRule[], target: string): OwnerMatch | undefined {
let best: OwnerMatch | undefined;
for (const rule of rules) {
if (!matchPattern(rule.pattern, target)) {
continue;
}
const priority = rule.priority ?? 0;
if (best === undefined || priority > best.priority) {
best = {
path: normalizePath(target),
owner: rule.owner,
pattern: rule.pattern,
priority,
reviewers: [...(rule.reviewers ?? [])],
};
}
}
return best;
}
export function resolveAll(rules: OwnerRule[], paths: string[]): OwnerMatch[] {
const matches: OwnerMatch[] = [];
for (const path of paths) {
const match = resolveOwner(rules, path);
if (match !== undefined) {
matches.push(match);
}
}
return matches;
}
export function coverageReport(rules: OwnerRule[], paths: string[]): CoverageReport {
const unowned = paths
.filter((path) => resolveOwner(rules, path) === undefined)
.map((path) => normalizePath(path));
const total = paths.length;
const owned = total - unowned.length;
return {
total,
owned,
unowned,
ratio: total === 0 ? 1 : owned / total,
};
}
export function unownedPaths(rules: OwnerRule[], paths: string[]): string[] {
return coverageReport(rules, paths).unowned;
}
export function coverageByOwner(rules: OwnerRule[], paths: string[]): Record<string, number> {
const counts: Record<string, number> = {};
for (const path of paths) {
const match = resolveOwner(rules, path);
if (match === undefined) {
continue;
}
counts[match.owner] = (counts[match.owner] ?? 0) + 1;
}
return counts;
}
export function busiestOwner(rules: OwnerRule[], paths: string[]): string | undefined {
const counts = coverageByOwner(rules, paths);
let best: string | undefined;
for (const [owner, count] of Object.entries(counts)) {
if (best === undefined || count > counts[best]) {
best = owner;
}
}
return best;
}
export function reviewRoute(
rules: OwnerRule[],
paths: string[],
fallback?: string,
): ReviewRoute {
const owners = new Set<string>();
const reviewers = new Set<string>();
const unowned: string[] = [];
for (const path of paths) {
const match = resolveOwner(rules, path);
if (match === undefined) {
unowned.push(normalizePath(path));
if (fallback !== undefined) {
owners.add(fallback);
}
continue;
}
owners.add(match.owner);
for (const reviewer of match.reviewers) {
reviewers.add(reviewer);
}
}
return {
owners: [...owners].sort(),
reviewers: [...reviewers].sort(),
unowned: [...new Set(unowned)].sort(),
escalate: unowned.length > 0,
};
}
export function staleRules(rules: OwnerRule[], paths: string[]): string[] {
return rules
.filter((rule) => !paths.some((path) => matchPattern(rule.pattern, path)))
.map((rule) => rule.pattern);
}
export function fullyOwned(rules: OwnerRule[], paths: string[]): boolean {
return unownedPaths(rules, paths).length === 0;
}
实现里有三个细节值得说明。
第一,resolveOwner 只在优先级严格大于当前最优时才替换,因此同优先级下先声明的规则胜出。这个比较符号很容易写反,一旦写成大于等于,规则顺序就会悄悄改变归属。
第二,reviewRoute 用 Set 去重,最后统一排序。如果直接按遍历顺序输出,路径顺序变化会导致评审人顺序变化,机器人可能重复 @ 同一个人。
第三,staleRules 判断的是「规则是否命中过任何路径」,而不是「规则是否是最终 owner」。一条被高优先级规则完全覆盖的低优先级规则,仍然算命中,只是不生效。是否要把它也标记为冗余,是另一个更严格的治理问题,不应该混进基础检测。
判据与反例
判断一套所有权机制是否可信,可以看下面几条。
| 判据 | 合格表现 | 反例 |
|---|---|---|
| 确定性 | 同规则同路径永远同一 owner | 依赖遍历顺序或哈希 |
| 可解释 | 能说出命中哪条规则 | 只返回一个名字 |
| 有缺口 | 无人负责路径能列出来 | 覆盖率 100% 但没有清单 |
| 能升级 | 无人负责时流程可见 | 静默跳过或随机分配 |
| 规则可回收 | 陈旧规则能被识别 | 配置只增不减 |
| 路由稳定 | 评审人列表排序去重 | 每次 @ 不同的人 |
一个常见反例是把 owner 写成个人。个人会转岗、休假、离职,而团队标识相对稳定。即使最终要落到人,也应该先映射到团队,再由团队内部的值班表决定具体评审人。把个人写进仓库配置,等于把组织变动变成构建失败。
另一个反例是「覆盖率 100% 就代表治理完成」。只要兜底规则写成 **,覆盖率永远是 100%,但所有路径都归同一个 owner,评审路由失去意义。覆盖度必须和 owner 分布一起看:如果某个 owner 覆盖了超过一半的路径,说明规则粒度太粗,或者团队结构已经失衡。
还有一种反例是「规则只增不减」。每次组织调整都加一条新规则,旧规则保留「以防万一」。一年后配置里有上百条规则,没人知道哪些还有效。规则数量本身不是问题,无法判断有效性才是问题。陈旧规则检测就是为此存在的。
最后一种反例是「评审路由绕开 owner」。机器人因为找不到 owner,直接把请求发给整个团队群。看起来问题解决了,实际上无人负责的路径被永久掩盖。正确的降级是显式升级,而不是扩大广播范围。
小结
代码所有权是一组可执行的规则,不是一张组织架构图。
- owner 映射要有优先级与稳定的冲突规则,同优先级先声明者胜出。
- 覆盖度要输出缺口清单,而不是只报一个百分比。
- 评审路由汇总 owner 与 reviewer,去重排序,无人负责时显式升级。
- 无人负责路径和陈旧规则要一起检测,一个管增量,一个管存量。
所有权写进配置、接进流水线,才会真正约束合并;停留在文档里的 owner 表,三周后就会和仓库对不上。
阅读导航:上一节:11.1 架构决策记录与契约 · 下一节:11.3 技术债台账与治理节奏 。
常见错误与边界
- owner 写成个人昵称。转岗或离职后规则失效,团队标识更稳定。
- 冲突规则没有优先级。归属取决于遍历顺序,工具之间结果不一致。
/**匹配到前缀相同的兄弟目录。一次重构就会把 owner 分错。- 只报覆盖率百分比。没有缺口清单,团队不知道该处理哪条路径。
- 兜底规则写成
**。覆盖率永远 100%,但路由失去区分度。 - 无人负责时静默跳过。评审请求发不出去,问题被掩盖。
- 规则只增不减。陈旧规则让配置膨胀,没人敢删。
- 评审人列表不排序。路径顺序变化会导致机器人反复 @ 不同的人。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。