那天遇到的问题,表面上不像一个真正的故障。一个原本很大的 Python 脚本被拆成了一个包,业务代码可以继续运行,原来的命令也没有立刻报错。可是,当我再看脚本索引时,发现新拆出来的模块一个都没有出现。
这类问题很容易被低估。程序能跑,通常会让人以为拆分已经完成;索引只是说明文档,晚一点补也没关系。后来我意识到,索引并不只是给人看的目录,它还是自动路由和后续维护者理解代码地图的入口。入口没有跟着代码一起移动,系统就会出现一种很安静的失真:功能存在,但发现不了。
业务能运行,不代表代码地图还是对的
这次拆分之前,索引器只扫描每个技能目录下 scripts/ 的顶层 Python 文件。这个假设在单文件时代成立:脚本放在哪里,索引就能列在哪里;顶层文件名也基本等于可执行入口。
拆分之后,原来的大脚本变成了一个包,下面多了 12 个子模块。它们被放在 scripts/ 的子目录里,目录本身有初始化文件,已经具备了 Python 包的结构,但扫描器仍然只看第一层。于是结果不是“索引内容少了一点”,而是代码结构和代码地图分叉了。
如果不处理,后面会出现两个后果。第一,负责路由的人看不到新模块,可能继续把所有调用都压回旧入口,拆包带来的边界就被重新抹平。第二,后来接手的人会把索引当成事实来源,误以为那些模块不存在,排查问题时只能依赖猜测或全文搜索。
代码一旦从单文件变成包,发现它的规则也必须从“找文件”升级为“识别结构”。
这句话听起来像实现细节,其实关乎维护成本。目录结构已经表达了新的组织方式,索引器如果还坚持旧的平面视角,就等于要求每个使用者自己补一次结构理解。
先分清入口和模块,而不是把所有文件混在一起
我一开始也想过一个很直接的办法:把扫描范围改成递归,然后把找到的所有 .py 文件都追加到原来的列表里。它可能很快让漏掉的文件出现,但也会带来新的混乱。
原来的顶层脚本和包内模块不是同一类东西。顶层脚本通常是可以直接调用的 CLI 入口,包内模块更多是实现组成部分,或者供入口导入的内部边界。如果索引只增加路径,不区分这两种角色,读者看到的仍然是一张没有层次的文件清单。更糟的是,某些包里的初始化文件也会被当成普通脚本,使用者无法判断它到底该不该直接运行。
所以我把问题拆成了两步:先递归建立包清单,再在输出时明确区分顶层 CLI 入口和包内模块。判断包的依据也不能只是“这是一个目录”,而要看它是否具有 Python 包的初始化文件。这样可以避免把普通的临时目录、缓存目录或不完整的文件夹误收入索引。
我选择这个边界,是因为它同时照顾了准确性和可读性。只递归文件,会把目录语义丢掉;只列包目录,又会隐藏真正值得查找的子模块。入口与模块分开后,索引既保留原来的使用方式,也能表达拆包后的内部地图。
先写红灯测试,把旧格式锁在原地
这次最重要的动作,不是马上改扫描逻辑,而是先补测试。因为任务有一个容易被忽略的约束:新功能是增加递归收录,但已有索引的输出格式要向后兼容。换句话说,成功不是“现在能找到更多文件”,而是“能找到更多文件,同时旧条目不被无意改写”。
我先为包扫描、模块分类和输出行为写了测试。第一次运行时,新测试中有 5 项失败,失败原因正好暴露出实现还没有提供包清单能力,也没有让渲染函数接收新的模块数据。与此同时,专门锁定顶层样本旧输出的测试保持通过。这一红一绿很有价值:它告诉我新增能力确实还不存在,也告诉我原有格式已经被固定下来。
红灯测试的作用不是证明代码很严格,而是先把“不能改变什么”说清楚。
如果没有这一步,递归改动很容易顺便重排原有条目、改变标题、调整空行,最后再用“索引已经更新”掩盖兼容性损失。对索引这种长期生成文件来说,旧内容的稳定本身就是一种接口。测试把这个接口从口头要求变成了可执行的边界。
递归不是越深越好,而是只沿着有效结构走
实现时,我没有把所有子目录都当成候选,而是从脚本根目录向下识别含初始化文件的 Python 包,再收集包内模块。这样做的关键,不是递归调用本身,而是给递归设定了结构条件。
这里最容易误判的是“只要后缀是 .py 就应该收录”。一个目录里的 Python 文件可能是实验文件,也可能只是包内部的辅助模块;如果没有包结构作为上下文,扫描器很难判断它应该以哪种身份出现。反过来,如果只依赖目录名,又会把无关目录的文件误认为业务模块。
我还保留了原来顶层扫描的路径和排序方式。新逻辑只负责补充包模块,顶层 CLI 仍按原规则输出。这样处理的理由很简单:这次任务的目标是修复发现能力,不是借机重做整个索引格式。当输出已经被其他流程依赖时,最稳妥的扩展通常是增加新分支,而不是重排旧分支。
改完之后,新测试 6 项全部通过。这个结果只能说明新规则在测试样本中成立,不能直接推出全量系统没有别的问题,所以我还需要继续看生成结果和既有测试。
用差异验证结果,而不是只看测试数字
我重新生成了目标包的索引,看到包内模块已经入册,同时原来的 12 个顶层条目没有变化。对我来说,最关键的不是新增了多少行,而是差异是否只发生在预期位置:新增模块区域出现了变化,原有区域没有被重写或重新排序。
为了确认递归没有污染其他情况,我又检查了没有包结构的样本。它们的内存渲染结果与磁盘上的旧索引逐字一致,说明“递归支持包”没有变成“所有目录都重新生成”。最后的检查命令返回正常,目标包的索引状态也符合预期。
全量测试仍然有 1 项失败,但它是改动前就存在的版本号断言,改动前后的基线相同;新加的 6 项测试则全部通过。这个边界必须单独记录。把既存失败藏在“整体不绿”里不准确,把它说成“全部通过”也不准确。验证报告应该同时告诉别人:这次改动守住了什么,还有什么不是这次改动造成的。
这件事让我重新意识到,索引的质量不能只看有没有新条目。还要看旧条目是否保持稳定、无包样本是否零差异、失败是否能归因。一个索引器真正可靠,不是它某次扫描得很全,而是它每次更新都能解释差异来自哪里。
一套可以复用的索引维护方法
把这次过程抽象出来,我会把它变成一条适用于代码地图、插件清单和资源目录的流程。
第一步,先确认扫描器当前实际认识的结构,不要从需求里的“递归”两个字直接开始改。要知道它原来扫描什么、输出什么、哪些结果已经被使用。
第二步,把新旧对象分型。顶层入口、包目录、包内模块和普通文件夹承担的职责不同,索引应该显式表达差异,而不是把所有路径压成一列。
第三步,先写旧格式的兼容性测试,再写新结构的红灯测试。前者锁住不能变化的部分,后者证明新需求确实还没有实现。两类测试缺一不可:只有新测试,可能悄悄破坏旧用户;只有旧测试,则可能在功能缺失时仍然通过。
第四步,递归只沿着可确认的结构走。对 Python 包来说,初始化文件是一个可检查的边界;换成其他生态,也应该寻找对应的结构信号,而不是依赖目录深度或文件后缀猜测。
第五步,生成后做目标差异和无目标差异两次验证。目标样本要确认新模块出现,无包样本要确认旧输出不变。最后再看全量测试,把新失败、既存失败和环境问题分开记录。
把索引器当成代码地图的单一来源,修复就不该停在手工补一份索引文档,而要修正地图的生成规则。
这也是这次错误捷径最值得记住的地方。只改生成结果,短期看起来最快,下一次重跑就会再次丢失;只让扫描器递归、不区分入口和模块,则会把一个漏项修成一张更大的混乱清单。真正可复用的修复,必须同时处理发现规则、输出分类和兼容性验证。
下次我会怎么做
下次再遇到代码从单文件拆成包,我会在拆分完成的同一轮检查发现链路,而不是等有人从索引里找不到模块。先问清楚:原来的入口有没有保留,新的包边界由什么文件定义,索引是否只认识顶层,旧输出又被哪些流程依赖。
然后我会先加一个无包样本的字节兼容测试,再加一个含初始化文件的包样本,明确期待顶层入口和包内模块分别出现。测试先红,再改扫描器。改完后不只看测试通过,还会对目标索引做差异检查,并用一个普通目录确认递归没有扩大收录范围。
如果全量测试里仍有既存失败,我会保留基线证据,说明它出现在哪里、为什么与本次改动无关。这样下一位接手的人不需要重新猜测“红灯是新问题还是旧问题”。
表面上,我是在给一个索引器增加递归能力;其实,我是在维护代码结构变化之后的可发现性。一个流程成熟的标志,不是它能在拆包后勉强跑通一次,而是下一次结构变化时,索引、测试和差异报告会一起提醒我们哪里需要更新。