deepseek-harness/.agents/notes/implemented/process/2026-08-27-explicit-workspace-path-aliases.zh.md
Yichen Jiang c4c3c32931 fix(infra): assert alias coverage and harden the generator entry guard
Review found the CLI entry guard compared import.meta.url against a
concatenated file:// URL. That fails whenever import.meta.url encodes
something process.argv[1] does not — a repository path containing a
space, or any Windows drive path — and it fails silently: the script
exits 0 having done nothing, so verify-tsconfig-paths would pass
without checking exactly where it is needed. Running a copy from a
directory whose name contains a space reproduces it. The guard now uses
the comparison the repository's seven other generators already use.

Deleting the group wildcards also removed the fallback that resolved a
package nobody had aliased, so the generator now asserts coverage:
every workspace package with a src directory must be mapped, and
--check names any that is not. That assertion immediately found four
packages named dsh-<group>-<directory> whose siblings carry hand-written
aliases while they did not, so they reached built lib/ output through
the workspace symlink. They now carry aliases too, which takes the
resolution differences in this branch from seven to eleven.

Two comments in tsconfig.base.json still pointed at the deleted
wildcards; they now state why those aliases stay hand-written. The
package-inventory proposal recorded the wildcard collapse as current,
so both notes are cross-linked as partial supersession.
2026-08-27 19:39:21 +08:00

8.6 KiB
Raw Permalink Blame History

Agent Note: Explicit workspace path aliases replace per-group wildcards

Status: implemented

English | 中文

Problem

tsconfig.base.json 是整个仓库的解析门面:每个包的 project 都 extends 它,两个聚合配置都读它,每个 Vitest 配置都把 vite-tsconfig-paths 指向它。其中两条别名按包分组而不是按包各写一条候选——@deepseek-ai/dsh-* 列了 49 个候选 glob,@deepseek-ai/dsh-*/invariant 列了 45 个。

TypeScript 与 tsx 按顺序逐个尝试这些候选、取第一个存在的,因此一个位于列表靠后位置的包,其说明符要为前面每一次未命中买单。在 dsh 源码启动下,每次未命中都是一个 ERR_MODULE_NOT_FOUND,而 Node 会用 decorateErrorWithCommonJSHints 装饰它——每次失败都跑一遍完整的 CommonJS 解析走查。一次源码启动的 profile 把 **934.6 ms(占启动 35%)**单独归给了这条装饰路径,来源是 60,942 次失败解析。

代价最重的恰好落在被引用最多的包上:packages/util/* 排在 49 个候选里的第 44 位,却装着几乎每个插件都要引用的叶子工具,因此 dsh-timeout 每次解析约付 9 ms,而一个有显式别名的说明符只要 0.05 ms。

Decision

scripts/gen-tsconfig-paths.ts 在 paths 末尾一个带标记的区域里,为每个 workspace 包写入一条显式别名,两条分组通配符随之删除。pnpm run gen-tsconfig-paths 重写该区域;pnpm run verify-tsconfig-paths 只报告漂移,并与其他生成物检查一起跑在 ci-static 车道上。

生成器只为声明名恰好等于 @deepseek-ai/dsh-<目录名> 的包发别名,因为那是通配符唯一可能解析出的形态:它把说明符的后缀代入 packages/<group>/<后缀>/src。名字与目录不一致的包——packages/typert/protocol 上的 @deepseek-ai/dsh-typert-protocol、以及 dsh-client-* 与 dsh-host-* 两族——本来就有手写别名,保持不动。若某个说明符被两个包目录同时认领,生成器抛错而不是任选其一,因为显式映射无法表达通配符依赖的分组顺序裁决;当前不存在这种冲突。

删除通配符也删掉了「没人写别名的包仍能解析」的兜底,因此生成器同时断言覆盖完备:每个含 src 的 workspace 包都必须被生成别名或手写别名映射,--check 会点名任何未被覆盖者。没有这条断言,一个名字与目录不一致的新包会被生成器跳过,继续经 workspace 软链解析到构建产物 lib/——正是显式别名要消除的产物层泄漏。

该区域用标记注释之间的定点文本改写生成,而不是重新序列化整个文件。tsconfig.base.json 是 JSONC,其手写别名带有解释非显然映射的注释,重新序列化会把它们丢掉。

本决策部分取代了包清单自动发现提案——该提案把「合并为一个通配符」记为当前实现,而通配符已被删除;该提案剩余的主题(聚合配置里显式的 references 数组)不受本次改动影响。

保留四条通配符,每条只有一个候选:dsh-host-*/invariant、dsh-client-*/invariant、dsh-client-*/client,以及五条 dsh-host-<name>/* 子路径映射。一个候选只花一次探测,展开它们只会换来文件变大而无收益。

Resolution differences this change makes

仓库源码中出现的每个 @deepseek-ai/dsh-* 说明符——共 1,023 个互不相同——解析目标与改动前完全一致,只有十一个例外:它们现在能解析,而此前不能。这十一个此前都是经 workspace 软链解析到构建产物 lib/,而不是源码。

其中七个是 /invariant 子路径:dsh-invariants/invariant、dsh-lsp/invariant、dsh-lsp-stdio/invariant、dsh-tool-lsp/invariant、dsh-terminal/invariant、dsh-terminal-bash/invariant、dsh-tool-terminal/invariant。

被删除的 dsh-*/invariant 通配符遗漏了 lsp、terminal、client、host 四个分组。其中 client 与 host 的遗漏是刻意且有文档的——这两族有专用通配符,因为它们的包名以分组目录名为前缀。而 lsp 与 terminal 的遗漏没有任何这类理由,packages/runtime-diagnostics/invariants 则两条列表都不在。于是这七个说明符此前是通过 workspace 软链与包的 ./invariant 导出解析到构建产物 lib/types/*.d.ts,而不是解析到源码——这与「静态门禁通过 paths 把 workspace 导入解析到 src、并在干净树上通过」的规则相抵触。把别名统一之后,它们与所有同类一样解析到源码。

另外四个是被覆盖断言揪出来的整包:dsh-client-ui-directory-picker-browse、dsh-client-ui-directory-picker-native、dsh-experimental-agent-team-profile、dsh-experimental-agent-team-web-profile。它们都叫 dsh-<分组>-<目录>,任何通配符都代不出这种形态;而它们身旁的同族包都有手写别名——这四个只是漏了。现在补上。

Testing

scripts/gen-tsconfig-paths.spec.ts 钉住:收集器把包映射到它自己的目录、跳过带手写别名的包、返回有序列表;渲染器让位于手写说明符,且区域收尾不带多余逗号;区域写入器只替换标记范围,并在缺少标记时拒绝;以及提交后的配置里两条分组通配符都不复存在。另有两条用例钉住覆盖断言:它会点名未被映射的包,且对提交后的配置报告为空。

门禁的拒绝路径被直接验证过:删掉一条生成的别名会让 verify-tsconfig-paths 以非零码退出,恢复后检查通过。

CLI 入口守卫采用仓库既有的比较方式 import.meta.filename === resolve(process.argv[1]),而不是拼接 file:// URL。拼接形式在 import.meta.url 做了百分号编码而 process.argv[1] 没做时失效——仓库路径含空格、或任何 Windows 盘符路径——且失效是静默的:脚本什么也不做就以 0 退出,恰恰会让这道门在最需要它的环境里形同虚设。把脚本复制到一个名字含空格的目录下运行即可复现:拼接式守卫求值为 false,既有写法为 true。

Alternatives considered

给通配符的候选 glob 重新排序,把最热的分组放前面。 这不需要生成器也不需要新门禁,把 util、core、llm、session 挪到前面大约能拿回一半收益。否决理由:收益随着包的增加而衰减,这个顺序没有任何读者可核验的不变量,而且第一个分组之后的每一组仍然要付钱。它还保留了最糟的性质——新增一个包分组会悄悄拖慢所有人的启动。

把 paths 挪进一个生成的 tsconfig.paths.json,由 base 配置 extends。 这能把生成内容彻底移出手写文件,diff 也更干净。本次否决的理由是:有若干消费者直接读 tsconfig.base.json,而不是通过会跟随 extends 的解析器——六个 Vitest 配置,外加 project-reference-faces.ts、verify-export-jsdoc.ts、doc-typecheck.ts、rescope-vendor.ts——逐个审计它们比这次别名改造本身还大。标记区域用零消费者风险达成了同样的隔离。

在显式别名下方保留通配符作为兜底。 显式别名本来就优先于通配符,因此正确性不变,而且新增包忘了重新生成也仍能解析。否决理由:兜底恰恰是让陈旧配置隐形的原因——仓库的立场是「misconfiguration fails loud」,而 --check 门禁把缺失的别名变成一次具名失败,而不是一次没人归因的慢启动。

Consequences

headless profile 的源码启动从 2,157 / 2,182 / 2,153 ms 的基线降到 1,069 / 1,052 / 1,055 ms——约 1.1 秒,51%,两个区间相距极远,且 --help 输出逐字节一致。

收益仅限于 tsx 源码启动这一条路径。 Vitest 走 vite-tsconfig-paths,它在进程内做匹配与文件存在性检查,从不构造 Node 模块错误,因此从未付过那笔装饰代价:对某个包的整套用例做 A/B,实测 5,934 / 5,829 / 5,908 ms 对 5,878 / 5,851 / 5,905 ms,属于噪声。仓库的门禁脚本只 import 少数几个 @deepseek-ai/dsh-* 包,同样测不出可分离的差异。发布用户在裸 Node 下跑构建好的 lib/,本来就不受影响。

paths 从 188 个 key 增长到 523 个,新增包时必须运行生成器。--check 门禁把这件事变成一次具名失败而非静默失败,而生成区域让这类改动的 diff 只有一行。