DeepSeek Harness 源码解析(六):类型声明如何接上服务注册
沿 ctx.fs 追踪 DeepSeek Harness 的 Context 声明合并、类型导入、运行时注册与声明产物,通过 TypeScript 实验、真实 Cordis 生命周期与独立包消费,区分编译器看见字段和进程取得服务。
Hex4C59 19 分钟阅读 更新于 2026-10-17
AI 与 Agent·原创·概念与设计
第五篇解释了 ctx.fs 在运行时怎样沿依赖和服务域找到实现。现在换到编译器这一侧:Cordis 的 Context 类没有亲自声明每个 dsh 服务,编辑器为什么仍能补全 ctx.fs?文件工具里一行没有导入任何名字的 import type {},又在做什么?
声明合并让编译器认识字段,服务注册让具体运行环境取得实现。 声明合并是在已有类型上补充成员;服务注册则把实际对象交给运行中的 Cordis。两条路径通过 fs 这个约定的名称接在一起,但并不互相代替。编译器接受 ctx.fs,不代表这次挂载已经取得文件系统;运行时有可用服务,也不代表某个独立 TypeScript 工程已经包含它的声明。
本文面向能阅读 TypeScript、已了解 Cordis 服务和 inject 的开发者。继续沿文件系统接口、工具消费方和本地提供方追踪,再看 Host(后端)与 Client(浏览器端)的类型检查边界和包发布入口。这里的 Program 指 TypeScript 为一次检查加载的源文件、声明文件及其依赖集合,不是 dsh 的插件树。声明文件只描述类型,供编译器检查接口;它与运行时执行的 JavaScript 文件承担不同职责。
本文分析 DeepSeek Harness 0.2.1-alpha.2,对应固定源码快照,核对与补充实验日期为 2026-10-17。该版本内的 Cordis为 4.0.5-alpha.1,Cosmokit为 1.8.6-alpha.1。锁文件将 TypeScript 固定到 6.0.3,两套实验均使用这个版本。
正文沿 dsh 的实际文件系统源码解释连接关系。实验分两层:8 组独立语言检查使用简化 Context;另有 13 组检查构建并加载真实 Cordis,验证服务生命周期、类型约束与本地包消费。后者使用本文定义的内存示意服务,不加载 dsh 的本地文件系统后端;两者都不构成 dsh 全量构建或模型请求的证据。
fs 字段来自接口包的声明合并
#
Cordis 在 context.ts同时导出了 interface Context 和同名的 class Context。前者描述实例有哪些属性和方法,后者提供构造函数与运行实现;TypeScript 允许同名接口为类的实例类型补充成员。Cordis 的包入口再将它们导出。
文件系统接口包没有修改 Cordis 源文件,而是在自己的 fs/src/index.ts中扩展模块。下面只保留 Context 成员,省略同一块中的 Events 增强:
declare module '@deepseek-ai/cordis' {
interface Context {
fs: FileSystem
}
}它表达的是:当这份声明进入当前 Program 时,Cordis 导出的 Context 类型具有一个 FileSystem 类型的 fs 字段。
这份文件顶部已经有 import 和 export,本身就是模块。这里的 declare module 因而属于模块增强:编译器先找到指定模块,再给它已有的导出类型补充成员。若把这几行单独复制到一个没有顶层 import/export 的脚本文件,就不能假定编译器仍把它理解为对已有模块的补充。
这个动作不会给 Context.prototype 写入属性,不会创建 FileSystem 实例,也不会执行 provide()。它为类型检查提供信息;运行对象仍由第五篇追踪的代理、服务存储和 Fiber 管理。
同一处声明还扩展了 Events,登记 fs/write-intent、fs/edit-intent 和 fs/observed 的参数及返回类型。这些声明告诉编译器允许使用哪些事件名称、应传入什么参数,以及返回什么类型。事件究竟按什么顺序执行,仍要看调度实现;下一篇再展开。
字段写成必需,不表示所有 Context 实例都已经有服务 #
这里声明的是 fs: FileSystem,没有问号。类型检查因此允许消费方直接写 ctx.fs.readText(...),但这不是一份描述每个 Fiber 当前状态的证明。
在同一个 Program 中,多个 Context 实例可以采用同一份静态类型,其中一些运行环境有文件系统,另一些还没有。Context 的服务域标签、提供者是否活跃、当前插件有没有合法依赖读取路径,都不是这个字段声明编码进去的状态。
这份类型更适合描述插件在约定环境下如何使用能力。让插件主体等到必需服务可用,是运行时 inject 的职责。不能因为声明省略了问号,就跳过装配和激活检查。
没有导入名字的 import type {} 为什么有用
#
文件工具的 read.ts包含下面两个类型入口。这里省略了位于它们之间的其他导入:
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-fs'第一行取得 Context 类型。第二行没有给本文件增加一个可引用的名字,却让编译器解析文件系统包的类型入口,把其中的模块增强纳入当前 Program。这样,本文件便可以按文件系统接口使用 ctx.fs。
它不是 JavaScript 的副作用导入。import type 在 JavaScript 输出中会被擦除,因此运行进程不会因为这行语句去执行文件系统模块的顶层代码,更不会安装本地文件系统提供者。
还有一个阅读源码时很容易误判的情况:删除第二行后,本地检查不一定立即报错。Program 中的其他文件、另一个类型导入或聚合入口,可能已经把同一份增强加载进来。声明的可见范围是当前 Program,并非“只对写了这行 import 的文件生效”。
显式类型入口让消费方表达自己依赖哪份类型信息。判断它能否独立编译,仍应检查实际文件集合与解析结果,不能依赖大型工作区恰好加载了许多其他声明。
看到包名,要继续追到实际类型入口 #
类型解析首先要找到目标模块。工作区内的 tsconfig.base.json为 Cordis 和 dsh 包设置了指向源码的 paths 映射;包的 manifest 则另外声明发布入口。
因此,“编辑器里能跳转到源码”和“外部工程能从安装后的声明文件取得同样信息”,需要分别检查。模块增强作用于编译器实际解析到的模块与导出;不能只比较两段 import 的包名文本,就断定两个工程加载了同一份定义。
运行时从具体提供方进入 provide()
#
文件系统接口包还导出了 FileSystem 抽象类。它在构造时调用:
constructor(ctx: Context) {
super(ctx, 'fs')
}这是运行代码,和上面的模块增强承担不同工作。具体的 LocalFileSystem继承 FileSystem,其构造函数通过 super(ctx) 进入这个注册入口。
真实装配位置可以从 sdk-minimal 的 patch看起。相关配置节选为:
- id: filesystem
name: '@deepseek-ai/dsh-fs-local'Loader 加载相应插件后,Cordis 在执行这次 Fiber 时,对类入口进行构造调用。随后,FileSystem 构造函数转入 Service,后者调用 ctx.reflect.provide(name, self, check),由反射服务登记实现。
这条路径要区分三个阶段:取得模块导出、挂载插件、实际执行提供方的构造与初始化。导入一个类,不等于已经用某个 Context 构造它;配置行存在,也不等于它的 Fiber 已成功激活。
接口包与实现包也不是同一个包。@deepseek-ai/dsh-fs 给出能力接口和注册基类,@deepseek-ai/dsh-fs-local 提供本地实现。消费工具依赖接口包,不必在自己的文件里直接导入这一份后端。
| 位置 | 对应的约定 | 实际作用 |
|---|---|---|
| Context 模块增强 | fs: FileSystem | 描述字段的静态类型 |
| FileSystem 构造函数 | super(ctx, 'fs') | 以运行时名称注册当前实例 |
| tool-fs 的 inject | 'fs' | 声明消费者激活所需的能力 |
| 具体提供方与配置 | LocalFileSystem、filesystem 条目 | 决定装配哪份实现 |
这几处名称要保持对应,但编译器不会从某一处声明自动生成另外几处。源码审查需要核对三条 fs 约定是否一致,再确认应用确实挂载了相应提供方。
把声明可见与实现可用分开观察 #
下面的图把两条路径并列。切换四种条件,观察静态字段检查和运行时消费者状态怎样分别变化。
图中的运行时假设消费者声明了 inject: ['fs'],其他初始化均成功;“实现可用”还包括同一服务域、提供者 ACTIVE 和可选检查通过。缺少声明时,图只讨论已有 JavaScript 进入运行时后的条件,不表示带类型错误的工程已经成功构建。它是源码关系推演,不是 dsh 运行采样。
只有声明时,编辑器可以接受字段访问,消费者仍可能等待。只有实现时,运行环境可以满足服务需求,而缺少增强的独立 TypeScript Program 仍可能报告没有这个字段。
这四种组合可以帮助我们按失败发生的层次查问题:字段类型缺失时查类型入口,实现缺失时查装配、依赖和服务域。给类型问题补一行 import,不能当作运行故障已经修复。
get() 的返回类型承认缺失,重载也保留宽松入口
#
第五篇解释过 ctx.get('fs') 可以绕过 inject 声明检查进行探测。它的类型重载也与直接属性不同:
get<K extends string & keyof this>(name: K, strict?: boolean): undefined | this[K]
get(name: string, strict?: boolean): any在 FileSystem 增强已可见、参数保留为已知的 'fs' 字面量时,也就是编译器确定参数恰好是这个字符串时,第一个重载给出 FileSystem | undefined。返回类型明确包含“没有找到服务”的可能,提醒消费方处理缺失。直接属性的 FileSystem 类型则没有表达这层可能性,不能当作同样的探测 API。
第二个重载允许传入 Context 类型尚未声明的字符串名称,并返回 any。它为扩展服务留下入口,也削弱了静态检查:即使服务名拼错,调用也可能匹配这个重载;返回值可以被当作任意类型使用,编译器不再按具体服务接口检查后续操作。
provide()也有类似的宽松重载。因此,不能把“有一个按 Context 字段关联值类型的重载”理解为“所有错误注册都会被编译器拒绝”。文末实验保留了这组重载的简化形状,验证错误值仍可匹配宽松入口。
这类类型提示有用,但不是封闭的注册证明。需要核对的运行条件仍包括:服务名是否正确、谁拥有实现、提供者是否就绪、消费者是否在同域,以及当前挂载是否具有合法读取路径。
普通 inject 与 @Inject() 的类型约束并不相同
#
既然 Context 已经知道有哪些服务,写错 inject 中的名字是不是也会被拒绝?要继续看依赖声明的实际签名。
在 registry.ts 中,Inject<M = Dict> 允许键数组或键值对象。这里的 M 决定用哪份对象类型限制键名,默认采用通用字典;插件元数据的 inject?: Inject并没有指定只能使用 Context 已声明的字段。因此,依赖名称拼错也可能通过编译。下面节选自真实 Cordis 实验,两段代码都能通过检查:
export const wrongName: Plugin.Object<void> = {
inject: ['blogFiels'], apply() {},
}
export const wrongConfig: Plugin.Object<void> = {
inject: { blogFiles: { prefix: 123 } }, apply() {},
}实验真正注册的名称是 blogFiles,配置中的 prefix 应为字符串。普通元数据的开放类型仍接受上述输入。编译成功并不能确认名称或配置正确;配置放进声明后,还要看运行时谁读取和校验它。
同文件的装饰器函数 Inject()采用另一条类型路径。InjectKey 从 Context 中筛选具有服务配置类型标记的字段,再通过该字段推导 config 的类型。Service<T>中的 declare [symbols.config]: T 就是这个标记,它只提供类型信息,不会创建运行时配置属性。
实验里的 BlogFiles extends Service<{ prefix: string }> 因而支持:
@Inject('blogFiles', { prefix: 'ok' })
export class TypedConsumer {}另外三个文件分别调用 Inject('blogFiels')、Inject('blogFiles', { prefix: 123 }) 和 Inject('plainFiles')。前两项是拼错名称与配置类型错误;第三项的 plainFiles 虽已增强到 Context,却只是普通接口,没有上述配置类型标记。实际诊断依次是 TS2345、TS2322、TS2345。
这里测试的是装饰器入口的静态约束;方法装饰器怎样通过 ctx.inject() 等待依赖,仍要沿运行实现分析。也不能据此认为所有字符串形式的 inject 都获得了相同的类型检查。
Program 边界为什么会影响 Host 与 Client #
声明合并在当前 Program 中起作用。若把两个运行世界的声明无差别放进同一次检查,它们对同名字段的不同定义就可能相遇。
dsh 的根 tsconfig是 solution 文件:files 为空,通过 references 指向 Host 和 Client 两个检查单元。这里的 solution 相当于项目索引,告诉编译器要检查哪几个项目;它不会把所有源文件放进同一个 Program,也不会自动合并两边对 Context 的全部声明。
这项拆分在源码中有具体对应:
| 字段 | Host 声明 | Client 声明 |
|---|---|---|
Context.sessions | SessionStore | ISessions |
两边服务处在不同的运行边界,对同一字段给出了各自的接口。Host 配置和 Client 配置说明了避免同名 Context 增强在一个 Program 中相遇的原因。
这也区分了两种常被统称为“隔离”的事情:TypeScript 项目边界决定哪些声明一起检查,Cordis 服务域决定运行时哪个名称找到哪份实现。调整 tsconfig 不会给运行进程创建私有服务域,调用 ctx.isolate() 也不会替编译器拆开声明集合。
本篇没有运行 dsh 的 Host/Client 全量检查。文末的冲突用例只验证最小接口增强的语言行为;真实检查单元的范围,以本基线配置和各包 references 为准。
源码可见与发布声明可见,需要分别检查 #
工作区先通过源码与项目引用建立类型关系 #
基础配置使用 moduleResolution: bundler、严格检查、声明输出、相对导入扩展名重写等选项。文件系统包自己的 tsconfig.json将源码目录设为 src,输出目录设为 lib/types,并列出 Cordis 等项目引用。
项目引用负责组织编译依赖,paths 负责告诉编译器到哪里找源码模块。它们和 pnpm 的包依赖声明、运行时插件装配分别工作,不能互相当作替代证据。比如 manifest 中的 peer dependency 要求使用方提供兼容包,却不等于消费者的 Program 已经加载那份模块增强;列出文件系统接口依赖,也不等于安装并挂载了本地后端。
发布入口指向生成的声明与 JavaScript #
@deepseek-ai/dsh-fs manifest的根导出包含以下条件对象节选:
{
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
}类型消费方和 JavaScript 加载方因而有各自的入口。配置中的源码路径能工作,还不足以证明这些发布文件已经生成、被收录,且在外部模块解析方式下可消费。
根 tsdown.config.ts的普通 Host 构建读取已由 TypeScript 发出的 JavaScript,再生成包的 lib 输出;它关闭了自己的 dts 生成。这条构建路线不能概括成“打包器看到源文件,就顺便把所有声明正确发布了”。
这里对 dsh-fs 的判断来自源码、配置和 manifest,尚未验证它的真实发布产物。后面的包消费实验实际构建 Cordis/Cosmokit,再打包安装到独立工程;它能验证这两个包和本文示例包的连接,不能替代 dsh-fs 或完整 Host/Client 的构建检查。
类型导入甚至可能不保留在消费方的 .d.ts 中
#
还有一个需要直接观察产物的细节。最小实验的消费文件写了:
import type { Context } from './runtime.js'
import type {} from './fs-contract.js'
export function readThroughContext(ctx: Context): string {
return ctx.fs.readText('hello.txt')
}编译器在检查函数体时能看到增强。实际 JavaScript 输出中,两条类型导入都已经消失:
export function readThroughContext(ctx) {
return ctx.fs.readText('hello.txt');
}本次 TypeScript 6.0.3 的声明输出则为:
import type { Context } from './runtime.js';
export declare function readThroughContext(ctx: Context): string;声明输出省略了函数体,只留下参数和返回值类型;这份公开签名只需要 Context 与 string,因此输出没有保留 fs-contract 这条类型入口。模块增强仍存在于 fs-contract.d.ts 自己的文件中,但一个外部 Program 只加载上述消费函数声明和 Context 基础声明时,不会因此自动取得这份增强。
实验进一步编译了两个外部消费方:未引入 contract 的版本访问 ctx.fs 得到 TS2339,显式引入后通过。这里的结论是需要核对最终声明依赖路径,不是“所有类型导入都会从 .d.ts 消失”,也不是断言真实 dsh 包必然输出完全相同的内容。
dsh 还提供 verify-node-next-types.ts:扫描声明中的相对扩展名,检查预期输出存在,再建立外部 NodeNext 消费工程检查公开入口。脚本启用了 skipLibCheck,其覆盖条件不能扩大成所有声明内部及 Host/Client 组合均已验证。本篇没有运行这个脚本;它要求先完成对应构建。
亲自验证:一个不加载 Cordis 的语言实验 #
下载完整实验,解压后进入 types-registration-lab 目录。需要 Node.js 22 或以上;实验的开发依赖精确固定为 TypeScript 6.0.3,不依赖 dsh 工作区。
npm install --ignore-scripts
npm run verify实验中的 Context 是一个空类,FileSystem 是仅含 readText() 的示意接口。mountFs() 用普通对象赋值给一个实例增加能力;它不是 Cordis 的 provide() 实现,不负责注入、隔离或资源清理。
脚本分别检查以下条件。对预期会编译失败的反例,它会核对编译器是否给出对应诊断;只有报错符合预期,这项检查才算通过:
| 检查 | 实际观察 |
|---|---|
| 当前 Program 包含模块增强 | 消费函数编译通过 |
| 当前 Program 没有该增强 | ctx.fs 报 TS2339 |
| 同一字段被增强为不兼容类型 | 报 TS2717 |
| 简化的 get/provide 重载 | 已知 get 可选;宽松入口接受 any 及不匹配的提供值 |
| 检查 JavaScript 与声明输出 | 类型导入从 JS 擦除;增强保留在自己的声明文件 |
| 另建外部声明消费 Program | 缺少 contract 时字段不可见,引入后通过 |
| 只导入提供方模块 | 挂载计数仍为 0,新实例的 fs 仍为 undefined |
| 只给实例 A 执行挂载函数 | A 可读取,B 仍缺少能力,直接读取抛 TypeError |
本次运行使用 Node v25.9.0、TypeScript 6.0.3,8 组检查通过。编译输出创建在临时目录中,检查后清理;末行会报告检查数量和实际版本。完整入口是下载包中的 verify.mjs,各个 tsconfig 用显式文件列表控制 Program,避免反例意外加载旁边的增强文件。
这组实验回答的是语言行为。Cordis 会在依赖缺失时阻止消费者主体激活,还会检查服务域、所有权和生命周期;不能把实验里普通对象的 TypeError 直接写成真实 dsh 相同阶段的报错。
在真实 Cordis 中观察等待、清理与恢复 #
普通对象赋值能解释“类型声明不会创建属性”,但没有 Cordis 的依赖管理。要观察消费者什么时候执行,需要让真实 Context、Service 和 Fiber 一起参与。
下载真实 Cordis 实验工程,解压后进入 types-cordis-lab。需要 Git、npm,以及符合 dsh 要求的 Node ^22.19.0 || >=24.0.0。先准备 dsh 0.2.1-alpha.2 对应的源码:
git clone https://github.com/deepseek-ai/deepseek-harness.git
git -C deepseek-harness switch --detach d743267388641bc76f17c45ce8b4c231aed1d32c然后在实验目录安装固定依赖并运行。DSH_SOURCE 替换为刚才的源码目录:
npm ci --ignore-scripts
DSH_SOURCE=/path/to/deepseek-harness npm run verify脚本核对源码快照、相关文件状态与工具版本,在新建的系统临时目录构建,完成后清理;不会向 dsh 原工作区写入产物。依赖为 TypeScript 6.0.3、tsdown 0.22.2、@types/node 22.20.1 和 @standard-schema/spec 1.1.0,随包提供锁文件。实际执行环境为 macOS arm64、Node v25.9.0、npm 11.12.1。
为了不把真实文件 IO 混进这个实验,接口包定义一个名为 blogFiles 的示意能力。下面是其中的能力基类与提供方,Context 模块增强见下载包的 packages/files/src/index.ts:
export abstract class BlogFiles extends Service<{ prefix: string }> {
constructor(ctx: Context) { super(ctx, 'blogFiles') }
abstract readText(path: string): string
}
export class MemoryFiles extends BlogFiles {
constructor(ctx: Context, private options: { prefix: string }) { super(ctx) }
readText(path: string) { return `${this.options.prefix}:${path}` }
}MemoryFiles 只是把前缀和路径拼成字符串,不是 dsh 的 FileSystem 或 LocalFileSystem。注册则确实经过这份 Cordis 的 Service 构造函数和 reflect.provide()。消费方采用 inject: ['blogFiles'],执行时读取 hello.txt,并用 ctx.effect() 登记清理函数。
脚本先挂消费方,等待其当前生命周期工作稳定;再依次挂载、移除和重新挂载提供方。每次都等待相应 Fiber 稳定后检查状态,不靠固定 sleep 猜测完成时间。实际观察为:
| 操作完成后 | 消费者状态 | 新增记录 |
|---|---|---|
| 只挂载消费者 | PENDING | 没有执行读取 |
| 挂载前缀为 v1 的提供方 | ACTIVE | read:v1:hello.txt |
| 移除 v1 提供方 | PENDING | cleanup:v1:hello.txt |
| 挂载前缀为 v2 的提供方 | ACTIVE | read:v2:hello.txt |
| 在另一 blogFiles 服务域挂载消费者 | PENDING | 该消费者没有执行读取 |
| 卸载根 Fiber | DISPOSED | 原消费者增加 cleanup:v2:hello.txt |
前四行使用同一个消费者 Fiber。服务重新出现后,它读取了 v2 提供的内容;清理记录也分别对应两次激活。第五行另建隔离域,用来确认共享域已有实现并不会自动满足另一个域的依赖。
状态值来自实际 Cordis 类型。该版本的 FiberState是 const enum,实验先用 tsc 编译 runtime.ts,再执行生成的 JavaScript;不能假定打包后的运行入口也导出了一个同名 JavaScript 对象。
把构建产物交给独立消费工程 #
这套实验还把“源码里类型可见”推进到一次实际的包消费。构建对象是快照中的真实 Cordis、Cosmokit,以及两个教学包:@dsh-blog/files 提供示意能力,@dsh-blog/reader 导出读取函数。
Cordis/Cosmokit 使用原始 tsconfig 和项目引用完成 tsc -b,先由 TypeScript 编译器生成 JavaScript 和类型声明。随后,打包器 tsdown 针对这两个包读取已生成的 JavaScript,生成 lib/index.js,保留 tsc 输出的 lib/types/index.d.ts。这沿用了前面解释的构建分工,但没有运行根构建中的 Typert 插件或完整 Host/Client 工程。
打包前,脚本只在临时副本中把 manifest 的 workspace: 依赖替换成快照中的对应版本,保留原有 main、types、exports 和 files。两个真实包、两个教学包以及 standard-schema 共生成五个本地包归档(tarball),再逐个安装进一个独立消费工程。该工程没有指向 dsh 源码的 paths,类型检查使用 skipLibCheck: false。
教学 reader 的源码仍然有空类型导入:
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@dsh-blog/files'
export function readThroughContext(ctx: Context): string {
return ctx.blogFiles.readText('hello.txt')
}它实际生成的 .d.ts 为:
import type { Context } from '@deepseek-ai/cordis';
export declare function readThroughContext(ctx: Context): string;独立消费工程中的反例同时导入 Context 和 reader,调用读取函数后再访问 ctx.blogFiles;正例只多一行 import type {} from '@dsh-blog/files'。两种模块解析配置分别得到:
| 消费工程配置 | 只导入 Context 与 reader | 再显式引入 files 类型入口 |
|---|---|---|
module/moduleResolution: NodeNext | 字段访问报 TS2339 | 通过 |
module: ESNext、moduleResolution: Bundler | 字段访问报 TS2339 | 通过 |
这次增强作用于真实 Cordis 的 Context,包也确实经历了构建、打包与安装。即使 files 已经作为 peer 依赖安装在 node_modules,它的增强仍需要被当前 Program 加载;“已安装”与“类型文件已进入检查集合”是两个条件。
实验生成的是按源码快照局部构建的本地 tarball,并未下载验证 npm 上的官方发布包,也未验证真实 dsh-fs/dsh-fs-local 产物。消费工程使用 --offline --ignore-scripts --legacy-peer-deps 安装;最后一个选项用于避免拉取未参与实验的可选 Loader/include peer,实际参与实验的五个包都显式提供。它不证明省略其他应用依赖后完整 dsh 仍能运行。
新增工程共通过 13 组检查,包含真实项目引用构建、运行入口打包、声明输出、tarball 消费、两种解析配置、四组 inject 类型检查、真实生命周期及编译器诊断。完整断言在 run.mjs,结果与脱敏后的诊断保存在 reports/。
用编译器输出定位缺失的声明入口 #
如果只看 ctx.blogFiles 报错,很容易把问题归因于接口没写对。上面的反例里,接口文件和安装后的声明都存在;缺的是从当前 Program 到增强文件的引入路径。
实验自动保存了正反两份 --explainFiles 输出和一份 --traceResolution 输出。前者解释每个文件为什么参与检查,后者展示模块名怎样解析到具体文件,分别对应 TypeScript 的 explainFiles 和 traceResolution 选项。
想手动查看,可在实验目录重新运行并保留本次临时工程:
DSH_SOURCE=/path/to/deepseek-harness npm run verify -- --keep-workspace脚本会输出消费工程路径。保持当前终端位于实验目录,将下面的 /path/to/consumer 换成该路径:
node node_modules/typescript/bin/tsc -p /path/to/consumer/without-contract-Bundler.json --explainFiles --pretty false
node node_modules/typescript/bin/tsc -p /path/to/consumer/with-contract-Bundler.json --traceResolution --pretty false第一条命令预期以非零状态退出并报告 TS2339。对照正例的 explain 日志,能找到下面两行节选,临时路径已简化:
node_modules/@dsh-blog/files/lib/index.d.ts
Imported via '@dsh-blog/files' from file 'fixtures/with-contract.ts' ...反例的文件集合里没有这一项。不是 Context 不允许扩展,而是本次检查根本没有加载包含扩展的文件。
第二条命令的解析日志包含以下节选:
Matched 'exports' condition 'types'.
Using 'exports' subpath '.' with target './lib/index.d.ts'.继续看同一段中的目标路径,就能确认它选中的是安装后的 @dsh-blog/files/lib/index.d.ts。真实项目里若跳转到了另一份包、另一版本或意外的源码路径,也应从这里追查;只比较 import 中的包名文字无法发现这种差异。
排查时先确认增强文件是否进入 Program,再确认它的模块解析目标。字段已经可见后,若消费者仍然等待,再回到运行时核对提供方。
沿着失败所在的层次排查 #
遇到字段报错或服务缺失时,可以按下面顺序区分问题:
- 编译器说没有
fs:先查当前 Program 是否包含对应增强、import 解析到哪里,以及使用的是源码还是发布声明。 - 本地工作区通过、外部工程失败:查 manifest 的 types/exports、实际生成文件、声明里的导入路径及消费方解析模式。
- 同名增强冲突:查是否把 Host 与 Client 或其他不同类型世界混进了一次检查,不先把冲突字段改成 any。
- 类型检查通过、插件仍等待:回到运行时查提供方挂载、名称、活跃状态、全部 inject 依赖和服务域。
- 某个 Context 能读、另一个不能读:分别查实例所属的 Fiber 和域;同一静态 Context 类型不代表同一运行环境。
- 名称或提供值明显不对却未报类型错误:检查是否匹配了宽松重载,以及变量类型是否已经变成 any。
在 dsh 源码根目录,以下只读命令可以定位本篇主线:
git rev-parse HEAD
rg -n 'declare module|interface Context|fs:' packages/fs/fs/src/index.ts vendor/cordis/src/context.ts
rg -n 'import type|ctx.fs' packages/fs/tool-fs/src/read.ts
rg -n 'super\(ctx|reflect.provide' packages/fs/fs/src/index.ts packages/fs/fs-local/src/index.ts vendor/cordis/src/service.ts
rg -n 'lib/types|"types"|"exports"' packages/fs/fs/package.json packages/fs/fs/tsconfig.json
rg -n 'get<K|provide<K|get\(name: string|provide\(name: string' vendor/cordis/src/reflect.ts这些命令已在本文固定版本执行,确认的是定义与连接位置。语言实验与 dsh 静态研究使用不同的验证范围,后续复查时也应保留这个区别。
下一篇继续追踪事件怎样执行 #
服务类型说明“可以调用什么”,服务注册说明“从哪里取得实现”。取得事件服务后,emit、serial、parallel、bail 和 waterfall 又会给同一组监听器带来不同的执行、返回值与异常处理方式。下一篇沿实际事件调用继续阅读这些调度差异。
一起讨论
选中正文中的文字,可以针对这段内容发起讨论。
正在读取讨论…
在 GitHub 查看与管理讨论 ↗