Skip to content

边境线

背景:一次点进空文件夹的死路,牵出散落全仓 11 处、复制粘贴了同一道裂缝的路径判定——propsExtra 被守卫当成 props 的属民。

过程:建 isUnderRoot 边界工厂,收敛判定为单点;修 3 处 propDir 裸前缀真 bug;归一化合并的硬骨头立为 ADR-095,单独排队。


外交官点进了一个文件夹。

里面是空的。

不是"暂时没加载"的空,是"根本不存在"的空——一条死路。他退出来,看着资源库列表里那个凭空冒出来的文件夹名:"Sub"。它不该在这里。库里从来没有一个叫 Sub 的目录。

"又是它。"外交官说。

AI 同行者凑过来:"你不是上周才在对话框记忆那边修过这个?adr-090。"

"修过。"外交官打开 library-core.ts,翻到那段刚缝好的伤口,"dir=".../PMX" 会误命中 m.dir=".../PMXSub/..."——机器把 PMXSub 当成了 PMX 的子目录,切出一段 Sub,生成一个点进去空空如也的伪文件夹。我给它补了边界判定。"

"那现在这个 Sub 是哪来的?"

外交官没回答。他盯着那行刚修好的代码,忽然有一种不祥的预感——像修补一堵墙时,发现裂缝并不止你手里补的这一道。

"我上周修的,"他缓缓地说,"是一个地方。"


一、把石子翻过来

桌上还摆着三颗红石子——XSS、路径匹配、材质启用。是上一季清算留下的账。

外交官伸手,想再添一颗。

AI 同行者却先拿起了那颗刻着"路径匹配"的旧石子,翻了个面。

"你看背面。"

外交官凑近。石子的背面,密密麻麻刻着一行行极小的字。同一句话,抄了很多遍:

filePath.startsWith(propDir)
filePath.startsWith(propDir)
dirLower.startsWith(rootLower)
...

"这不是一颗石子的问题,"AI 同行者说,"这是同一道裂缝,被复制粘贴到了很多地方。你补了最上面那一道,下面还有一排。"

外交官沉默了几秒,然后做了他每次遇到"以为修完了"时都会做的事——他不相信"以为"。他打开 grep,把全仓所有做"这个路径是不是在那个目录下"判断的地方,一个一个揪出来。

结果让房间安静了下来。

十一处。

十一处路径归属判定,散落在 core、menus、scene 三座城区。而且——他数了数它们用的归一化函数——三套normPath 一套,normalizePath 一套,还有一处干脆就地 replace(/\\/g, '/'),自成一套。

"十一处判断,三种归一化,"外交官把笔搁下,"每一处的作者都以为自己在做同一件事。但他们做的不是同一件事。"


二、一个斜杠的边境

外交官把十一处摊在桌上,像摊开一张边防图。

真正带病的有四处。病症一模一样——缺一个斜杠

他指着 motion-popup.ts 第 699 行:

typescript
// 路径在 prop 目录下的视为道具,不参与动作绑定
if (propDir && inst.filePath.toLowerCase().startsWith(propDir)) {
    continue;
}

"看出来了吗?"

AI 同行者盯了半天:"……看起来没问题啊。判断这个文件路径是不是在 propDir 下面。"

"props,"外交官在纸上写下两个词,"和 propsExtra。"

他在两个词之间画了一道竖线。

"propsExtra 的开头,逐字符就是 propsstartsWith('props') 对它返回 true。于是——一个放在 propsExtra 目录里的模型,被当成了 props 里的道具,被跳过了动作绑定。它明明是个该跳舞的角色,却被守卫拦在门外,理由是'你是隔壁城的'。"

"差在哪?"

"差一个斜杠。"外交官写下正确的边境线:

startsWith(propDir)        →  props | propsExtra 一视同仁  ✗
startsWith(propDir + '/')  →  只有 props/ 才算入境        ✓

"边境线,"他说,"就是那个斜杠。props 是一座城,propsExtra 是另一座城。它们的名字前缀相同,但它们是两座城。没有那个斜杠,守卫就分不清城墙在哪——他会把 propsExtra 的居民,全部登记成 props 的属民。"

同样的裂缝,AI 同行者在另外两处也找到了:scene-stage-levels.ts 第 39 行,model-loader.ts 第 331 行。三处一模一样的 startsWith(propDir),三处都少了那个斜杠。加上 library-core.ts 第 351 行那处 outfit 误命中 outfitX 的残留——四道无栏的悬崖边

"三处 propDir 是同一个作者复制的?"AI 同行者问。

"或者是三个作者各自复制的同一个坏样板,"外交官说,"结果一样。裂缝不会因为你抄得整齐就变成墙。"


三、只砌一道墙

"那就一处一处补斜杠?"AI 同行者问。

"不。"外交官摇头,"补斜杠是把同一道墙砌十一遍。今天补齐了,明天有人写第十二处,又会忘。"

他翻到 core/utils.ts——地基层,所有城区都会路过的地方。

"砌一道墙,让所有人都从这道墙进出。"

他写下那个工厂:

typescript
/** 判定 child 是否位于 base 之下(路径边界 + 大小写忽略)。禁止裸前缀。 */
export function isUnderRoot(base: string, child: string): boolean {
    const b = normPath(base).toLowerCase();
    const c = normPath(child).toLowerCase();
    return c === b || c.startsWith(b + '/');
}

"归一化——统一走 normPath。大小写——两边都 toLowerCase,Windows 不区分大小写这件事在这里一次性了结。边界——b + '/',那个斜杠被焊死在墙里,谁都绕不过去。"

四处病灶,全部换成一句话:

typescript
if (isUnderRoot(propDir, inst.filePath)) {
    continue;
}

propsExtra 不再入境。outfitX 不再冒充 outfit 的子目录。而那个开场时点进去空无一物的 Sub 文件夹——它甚至没机会被生成了,因为 getRelativePathUnderDir 内部也改成了调用同一道墙。

"十一处判断,"AI 同行者数着,"现在有几处真正在做判断?"

"一处,"外交官说,"其余的都是来敲这道门的。"


四、差点冤枉的邻居

外交官的目光越过前端的城墙,投向 Go 后端。

"如果前端手写了十一遍还错了四遍,"他说,"后端呢?后端也要判断路径归属——解压 zip 的时候要防路径穿越,监听文件的时候要判断在不在监听目录下。它会不会更惨?"

他 grep 了 internal/,一眼就锁定了 zipextract.go 第 134 行:

go
strings.HasPrefix(targetAbs, destPrefix)

"HasPrefix。"外交官皱眉,"又是裸前缀。而且这是安全判断——防止 zip 里的 ../../etc/passwd 逃出解压目录。如果这里也缺边境线……"

他几乎要把这一行也刻成一颗新的红石子——一颗最锋利的、安全级别的红。

但他停住了,往上翻了几行,去看 destPrefix 到底是什么:

go
destPrefix := destAbs + string(filepath.Separator)

外交官愣了一下。

destAbs 后面,拼了一个路径分隔符。那个斜杠,Go 早就焊在里面了。HasPrefix(targetAbs, destAbs + '/')——这正是前端折腾半天才砌出来的那道墙。后端从一开始就砌好了。

他继续往下看。app.go 第 677 行、thumbnail.go 第 25 行——求相对路径用的是 filepath.Rel,标准库函数,边界、分隔符、上溯全给你算好。integration.go 第 624 行的归属判断,带着完整的斜杠边界。

外交官把手里那颗差点刻下的红石子,轻轻放回了口袋。

"我差点冤枉了它。"

"为什么后端没事?"AI 同行者问。

"因为它没有手写,"外交官说,"它用 filepath.Rel、用 filepath.Separator 拼边界——标准库替它把斜杠、大小写、上溯全考虑过了。前端为什么错了十一遍?因为前端每个人都觉得'不就是个 startsWith 嘛',然后自己搓了一个。"

"手搓的绳子,"AI 同行者接道,"总有一股会先断。"

"标准库的谦逊,"外交官说,"在于它假设你会犯错,所以提前替你把边境线画好了。手写的自信,在于你以为自己不会犯错——然后你犯了十一次。"


五、留在明天的硬骨头

四处真 bug 补了,十一处判断收敛成了一道墙。但外交官没有把账一次算完。

utils.ts 里还有两处判断,走的是 normalizePath——那套会处理 .. 上溯、会合并多重斜杠的归一化。scene-bundle.ts 里还有一处,走的是就地 replace。它们也想投奔 isUnderRoot

外交官却按住了它们。

"你俩的归一化,和这道墙的归一化,不是一套。"他说,"normPath 不处理 ..normalizePath 处理。我要是现在硬把你们接过来,含 .. 的路径行为就会变——我修的不是 bug,是新的 bug。"

他新起了一份卷宗——ADR-095,把这三套并存的归一化登记在案:

根因:normPath(不处理 ../.)与 normalizePath(处理)语义不等价,加上一处就地 replace,三套并存。 决议:normPath 定为唯一归一化入口;归属判定收敛至 isUnderRoot。归一化合并列为批次 5——高风险跨模块改动,独立 PR + 全量回归,不混进本次。

"能一起修的,为什么要留到明天?"AI 同行者问。

"因为'一起修'和'一起错',中间只隔着一个没做的回归测试,"外交官合上卷宗,"今天这道墙砌得干净,是因为我只砌了地基稳的那一段。剩下三段地基还在晃——把墙砌在晃动的地基上,墙也会晃。"

他把 ADR-095 钉在边防图的角落。不是遗忘,是排队。


六、边境线的教训

外交官在新地图上画了两座城。

一座叫 props,一座叫 propsExtra。它们紧挨着,名字的前半截一模一样。两城之间,他画了一道细细的线——一个斜杠那么细。

线的这边写着:入境。线的那边写着:止步

城下钉着一行批注:

前缀相同,不等于同属一城。

propspropsExtra 只差五个字符,但它们是两座城。区分它们的,不是名字有多像,而是名字后面那道边境线在不在。

少一个斜杠,守卫就会把邻城的居民登记成自己的属民。而这个错误,会被复制到它出现过的每一个地方——除非你只在一处画边境线,让所有守卫都来同一个关口验证。

他在旁边补了两条,是这一季新添的坑:

第七个坑: 同一道裂缝散落十一处。修好一处不等于修完——真 bug 往往是复制粘贴的族群,止血一个之后,必须往前核对它的所有兄弟。

第八个坑: 三套归一化并存。判定的正确性,取决于归一化的一致性。归一化不统一,边境线画得再准也是画在错的地图上。归一化合并是硬骨头,值得单独排一个批次去啃。

AI 同行者看着地图上那道细线:"就为了一个斜杠。"

"最贵的 bug 往往最小,"外交官说,"因为它小到你会觉得'这怎么可能出错'——然后你就不检查它了。"


窗外的灯又暗了一格。

桌上的四颗红石子旁边,外交官没有再添新的。他只是把那颗刻着"路径匹配"的旧石子,重新正面朝上摆好——背面那一排复制的裂缝,已经被一道墙填平了。

他拿起笔,在边防图最上方,写下这道墙的名字。

只有一个词:isUnderRoot

一道墙,一个关口,十一座城的守卫从此都到这里验证入境。

而那道曾经缺失的边境线——那个小小的斜杠——现在被焊在了墙里,谁也拆不下来。

联邦的地图上,从此多了一条画得清清楚楚的边境线。


教训:前缀相同不代表同属一城。区分 propspropsExtra 的,是名字后面那道边境线——一个斜杠。而当同一道裂缝散落十一处时,真正的修复不是补十一遍,是只砌一道墙,让所有人都从那里入境。