dsh-plugin-dependency-graph
Đã xác minhdsh-plugin-dependency-graph · v0.3.0 · MIT · Giao diện web
Plugin dependency graph for DeepSeek Harness: which plugin provides the services every other plugin injects, read from the live Cordis runtime.
Cài đặt
dsh plugin add dsh-plugin-dependency-graph Xác nhận layer đã áp bằng dsh --profile default --dump-config — xem hướng dẫn cài plugin.
Mã nguồn
Thẻ
Readme
English | 中文
插件依赖图
哪个插件提供了其他插件所注入的服务——读自运行中的 Cordis 运行时,而不是某份清单。
设置里有一个分节绘制这张图,另有一个独立页面以整页宽度展示同一张图。两处渲染的是同一个面板: 两个宿主只在"能提供什么"上不同(一个 locale 服务、一套主题、一个小窗口 vs 一整个页面)。
截图
分节刚打开的样子:统计胶囊、状态行(图上真有的每个状态一颗色点、它的数量,点一下即筛选到它)、
画面,以及角落里那段四行图例。画面上方的控件依次是 ← →(选中历史)、缩放、重置视图、全屏。

全屏。画布本身就是全屏元素,所以面板里放在它旁边的东西在屏幕上都不存在——这正是 工具栏要移到画布内的第一行、搜索框占第二行的原因。

选中一个节点。包名下方是它自己的描述,监听的事件列出它订阅了哪些事件, 而连线按方向分开——两个颜色各是哪一边,看图例——只在运行时才取的依赖仍然是虚线。

搜索。查询同时匹配包名、Loader 的 entry id 和插件提供的服务名——所以 ctx.locale
能被直接搜出来。命中的插件列成一张卡片,每行带着包名与它的状态;若是按服务命中的,包名下面
还会写出是哪个服务。画面把其余部分变暗。输入框里的 × 清空查询并把光标交回输入框。

新标签页里的独立查看页:同一张图,用整个窗口的宽度。这里选中的是 (harness) 节点——
运行时自己那一行,承载一切不归属任何插件的条目:根 fiber 提供的服务、它注入的服务、
以及它监听的事件。它不依赖任何插件,所以只有指向它的线。

两棵树
一共有两个 Cordis 运行时,因此有两张图:
| 宿主 | 浏览器 | |
|---|---|---|
| 在哪运行 | Node 进程 | 页面里 |
| 谁来采集 | 宿主侧的 collectGraph(ctx) |
页面里的 collectGraph(ctx.root) |
| 谁来展示 | 本分节,经 HTTP 取回 | 由页面上报,查看页再读回 |
两者永不合并。 它们是不同的运行时:插件不同、服务名也不同——合并出来的不是更大的图, 而是错的图。浏览器那一栏是第二个来源,不是第二组节点。
浏览器那棵树无法由 Node 半采集,所以只有页面能描述它:分节把它采集到的结果 POST 到
/dsh-plugin-dependency-graph/client,查看页再 GET 回来(一条路径、两个方法)。这份报告带着采集时刻,
查看页会把它显示出来——一张被读者当成"当前"、其实不是的图,比一张坦白自己几点了的图更糟。
浏览器树的描述来自它自己的一条路由(/dsh-plugin-dependency-graph/descriptions):页面读不到 node_modules,
而它要描述的那些包正是同一批,所以由 Node 半读出并给出「包名 → 描述」的表。这是两半能力唯一不同的
地方,也是合并逻辑放在 src/describe.ts 的原因——它是纯的,两半跑同一份实现,而只有 Node 半碰
node:fs。解析顺序是:先试插件自己的目录,再试从宿主 profileContext.dir 学来的基点——实测下来,
那些"显而易见"的基点全都落在仓库里,而第三方插件一个都不在那儿。
依赖是怎么获得的
一个插件拿到服务有两种方式,区别不是修饰性的:
- 声明式(插件上写
export const inject = [...]、inject:选项,或@Inject装饰器)—— fiber 会一直挂起,直到它们全部解析。少了它,插件根本不会加载。 - 运行时,通过
ctx.inject(deps, callback)助手——依赖出现后回调才跑。插件照常加载, 只有那一项贡献在等。
两者都是真实依赖,都会产生边,而第二种就是 optional 标记的含义。缺一个必需服务是组合坏了;
缺一个可选服务是回调形式本来就为之存在的正常情形。
采集会读三处,而且这个区分很要紧:Loader 条目给出 id 与包名;反射存储是权威的提供者表
(按隔离符号索引——fiber 自己的 store 是错的来源,会把消费者当成提供者);注册表给出所有
活着的 fiber,包括运行时才启动的那些。
怎么看这张图
- 布局是数据,不是物理。 越多人依赖的插件越靠中心,角度是沿 id 走的黄金角——所以同一份组合 每次画出来都一样,刷新不会让读者重新找方向。中心是枢纽,外圈是没人依赖的那些。
- 一条边都没有的插件默认折叠,收在一枚写明数量的控件后面(
61 个无依赖)。在开发时对着的这份 组合里它占 31%——一堆没有任何关系可画、却占着外圈(也就是占着缩放)的节点——所以是藏起来而不是删掉: 按一下就回来,因为「这个插件什么都没参与」本身就是一个读者会来这儿找的答案。被搜索或状态筛选点名的 节点即使没有边也会画出来,这样点结果行永远有处可落。 - 刷新重新读取这张图,但保留屏幕上已有的内容而不是先清成转圈:画布可能就是全屏元素, 而一个离开文档的元素会把全屏一起带走。
- 搜索随输入过滤包名、Loader 的 entry id,以及服务名——而命中一个服务会带回这个服务的两端:
谁提供它(标
提供),以及谁注入它(标注入)。于是「谁在用ctx.locale」变成一次查询, 而不是顺着节点一个个走;这也正是画面会把该服务的线留亮的原因:画布只在两端都在结果里时才保留一条边。 没有人提供的必选服务在这里仍然搜不到——它在下面的问题清单里;订阅的事件名也不参与匹配, 那由详情面板的事件区块回答。命中项每行带该插件的状态,这张列表也是够到那些被筛暗、缩到边上的 节点的办法。输入框里的×清空查询并把光标交回;右内边距是一直留着的,所以敲字时框里的内容不会位移。 - 状态行同时是三样东西:图例、计数、筛选。图上真有的每个状态一颗胶囊,点一下就把画面窄到 那个状态。只列图上确实存在的状态——一排大多是 0 的胶囊,看起来像在给看不见的颜色作说明。 图例则始终显示,而且线段用的就是"选中时"的颜色:还没点过的那个读者,才最需要知道颜色的含义。
- 滚轮缩放,拖拽平移,点击选中。但在匹配卡片或详情卡片里滚动时,滚的是那张卡片—— 它们是浮在画面上的面板,把手伸过去是想翻一张列表,不是想缩放它背后的图。
- 后退 / 前进沿选中轨迹走。后退之后再选一个新节点,会丢弃前进历史——与浏览器同一条规则、 同一个理由;"未选中"也是一步,所以一个节点从两个方向都回得去。
- 全屏铺满窗口。工具栏与搜索框会移进画布内部、占前两行,详情作为浮层跟进去: 那个位置的右侧栏在屏幕上并不存在。
- 新标签页打开把这张图交给独立查看页——一个由 Node 半服务的整页。这正是重点: iframe 或应用内的路由都会继承读者正想摆脱的那个宽度。
- 查看配置把这个条目自己的配置画在面板里:所有碰过这一行的层,已经按顺序合成好了,与其他
任何插件无关。不是整份文件、也不是原生编辑器——读者人在浏览器里,而那未必是跑宿主的那台机器;
再说一个盯着某个插件看的人,要的是这个插件的那一行,而不是所有插件共同住着的那份文件。
有两点它不是:没求值——
!!js是照原样显示源码(宿主的--dump-config也是这个选择: 表达式节点是在该行自己的 fiber 上插值的,从不写回条目的 options);也没套 schema 默认值—— 这一行没写的字段就是没有,不会补上默认值。文本上方那条限定路径,就是想去翻文件时的入口; 换选另一个插件时它会自动关闭。
一个节点上报什么
选中一个节点后,详情卡里是运行时知道的全部事实:
- 包自己的描述,读自它的
package.json。包没写、或者读不到时不显示这一行而不是显示空白—— "这个包没有自述"是事实,画一个空框则是我们的话。 - 声明的注入——这一行的 patch 里声明要哪些服务(如果它声明了)。读自条目而不是运行中的
fiber,所以要并排放在「注入」旁边而不是并进去:两者恰好在"声明没被满足"时不同,而一个从未
加载的条目根本没有运行期注入可列。没有就不画:194 个条目里只有 3 个声明了东西,常驻一行
「什么都没声明」等于在说另外 191 个。条目注册的其余字段(挂载路径、所属组、
isolate、intercept、config)照旧采集、也照旧由 JSON 路由提供,只是不再占行。 - 已禁用——状态旁边的一枚标签,只在被关掉的条目上出现,也是状态缺的那一半:被禁用的条目显示 「未加载」,与"还在等人"看起来一模一样,而这份组合里 29 个「未加载」全都是前者。
- 提供——它的那些 fiber 注册的服务。
- 监听的事件——它的 fiber 订阅了哪些事件名,读自派发器自己的表。监听不产生边——监听等的是一个 名字,不是一个提供者,所以它不是依赖,画成边就是假边。反过来的方向是下面的发出的事件, 那条运行时答不了,原委写在那一节里。
- 发出的事件——这个插件自己的代码里发出去的事件名;运行时确实派发过的会带一个「实测」标记。
这是卡片上唯一一项不来自运行中的运行时的数据,因为运行时答不了:派发器的表是「名字 → 监听者」,
从不登记发布者;唯一带发布者的通道(
internal/dispatch的thisArg)只在显式写emit(thisArg, name, …)时才有值——实测宿主 132 处emit调用里是 0 处。所以归属来自文本扫描 (emittedIn读该模块自己的文件),运行时只负责确认。两个后果要知道:运行时拼出来的名字不是字面量, 扫不到;而任何.emit(都会命中,包括 Node 的EventEmitter——close、open、ready就是这么来的。 浏览器树与独立查看页里没有这一项(不是空列表):那边没有文件系统可读,空列表会变成「它什么都不发」的假话。 点一个名字会列出监听它的插件——用的是监听者自己卡片上同一份listens数据、同一次采集, 所以两处不可能对「谁在监听」说法不一——每行点进去就是那个插件。「监听的事件」反过来一样可点: 点一个名字列出代码里发出它的插件,那是扫描的答案,标题里也照此写明。在没法读文件系统的地方采集的图 (浏览器树、独立查看页)没有扫描可答,那里的监听事件保持普通标签——而不是点开一个会读成「没有插件发出它」的答案。 - 注入与运行时注入——依赖的两种获取方式,分开列是因为它们回答不同的问题: 一个在问"它为什么没加载"的读者,要的只是前者。
- 依赖与被依赖——两个方向的每条边,每行给出造成这条边的服务名,以及它是否可选。
(harness) 是唯一不是插件的节点:它是运行时自己那一行。没有 Loader 条目归属的 fiber
(根 fiber,以及条目树之外启动的那些)提供的服务记在这里而不是丢掉,它们的注入与监听也一并记在
这里。loader 与环境那几行因此不会读成"所有注入者的未解析依赖"。
图上报了什么
画面下方有两块内容,两块都关乎"活儿"而不是"画法":
- 未解析的依赖——在本组合里没有任何提供者的必需服务。只列必需的:一个没有提供者的 可选注入只是还没被提供而已,插件正按设计工作。
- 被隔离的服务——同一个服务名有多个活实现,也就是在多个隔离标签下被提供。
独立查看页
一个由 Node 半作为完整文档服务的页面。它自己没有 Cordis,所以只能展示宿主的树,以及应用上报的 浏览器树。它从"被打开时的 URL"里读两样东西,因为除此之外它无从得知:
| 参数 | 为什么 |
|---|---|
?scheme=dark|light |
页面用应用的 --dsw-* 令牌来配色,而切换这些令牌的属性属于应用,不属于这个页面 |
?lang=zh|en|ja|ko|es|fr|de |
让新标签页用应用当前的语言打开,而不是靠浏览器去猜 |
语言在进入文档之前会先过一遍字典白名单——查询串不是可以信任的地方,而这个值最终会写进页面的
lang 属性。直接打开该页(没有参数)时,依次回落到浏览器自身的语言偏好,再回落到英文。
浏览器那份报告只存在于内存里,从不落盘:它描述的是一个只在页面开着时存在的运行时, 而一份熬过了宿主重启的报告,描述的是个并不存在的东西。
语言
src/client/locales.ts 里提供七本字典:宿主自带的 zh 与 en,外加 ja、ko、es、fr、de。
后五种通过单语言重载逐个注册——环境所携带的语言包里那份定义才是让一种语言可被选中的东西,
本插件只往它上面补自己的字符串,且刻意不调用 addLanguage。
七本字典都标注为 Record<MessagesKey, string>:往 MessagesKey 加一个键,七本补齐之前编译不过。
用词与 session-messages 一致(那五本最早由它带上)——两个插件在同一套界面里,
读者应当两处看到同样的术语。
目录
dsh-plugin-dependency-graph-plugin/
package.json dsh.bundle + dsh.client 声明、exports 映射(private,不对外发布)
cordis.patch.yml 层补丁:插入 Loader 那一行
build.mjs 构建脚本:打包两半,并在构建期复制主题
tsconfig.json 仅用于 IDE 解析类型,指向检出源码(只读)
src/
index.ts Node 半:collectGraph,以及下面那几条路由
graph-types.ts 两半共享的 wire 形状(类型 + 路径常量)
collect.ts 采集器,两个运行时共用一个函数
describe.ts 描述合并,纯函数——两半都跑它,只有 Node 半读 package.json
emits.ts 发出事件的扫描:读包自己的代码,并合入运行时的确认(仅 Node 半)
viewer-page.ts 独立查看页的文档,以字符串形式给出
viewer/main.tsx 独立查看页的主体(React 被打包进来——那个页面没有模块表
可以应答一个裸的 `react` 导入)
client/
index.ts 注册设置分节
GraphPanel.tsx 两个宿主都渲染的面板
graph-canvas.tsx 画面本身:布局、命中检测、缩放平移
search.ts 查询 → 命中的节点,以及命中的是哪个字段
locales.ts 七本字典
docs/ 上面那些截图(中文版为 `-zh.png`)
路由都在 /dsh-plugin-dependency-graph 之下:图本体、/view(页面)、/viewer.js、/theme.css,
/client——它接受应用发来的 POST,并应答查看页的 GET——以及 /descriptions,
它给浏览器树提供「包名 → 描述」的表,以及 /config——接受一个条目 id,返回那个条目组合后的配置。
开发
npm run build # 打包 lib/index.js、lib/client.js、lib/viewer.js
npx tsc -p tsconfig.json # 类型检查
在 DSH 检出里作为插件安装:把本目录的 cordis.patch.yml 加进 bundle 即可,它只插入一条 Loader 行。
这里没有任何东西需要改动宿主源码——这是本插件被构建出来时就遵循的约束,不是巧合。