dsh-semantic-drift
Verifieddsh-semantic-drift · v0.1.6 · MIT · Web UI
One versioned vocabulary of a project's business concepts, a read-only drift scan, and the same naming rules in every agent prompt. A DSH plugin.
Install
dsh plugin add dsh-semantic-drift Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Tags
Creators
Readme
dsh-semantic-drift · 语义漂移词表
English · 简体中文
一个 DSH 插件:用一份可版本化的词表记录项目的业务概念,只读扫描出代码里把它们写得不一样的地方,并给人和 Agent 一个地方去决定"哪种写法才算数"。
DSH 中显示的名字是 语义漂移词表(英文 Semantic Drift Vocabulary)。
目录
这个想法从哪来
思路来自 kurapica/schema-node-csharp,它主张的三件事是本插件的前提:
- 语义应当是可版本化的独立实体,而不是每次从代码里重新推导;
- 有限可枚举的词汇表比无限类型系统更容易保持正确;
- 漂移必须可观测、可裁决,而不是指望团队靠记忆维持。
但不借实现:没有 Meta/Property/Relation 建模,没有语义编译,没有代码生成。只有一份 YAML、一个只读扫描器、一个面板、五个 Agent 工具。
它做什么
它记住"东西叫什么"。 每个概念一条记录:点分 id、人怎么称呼它(术语)、种类、规范写法(必要时还有类型与单位)、一句说明。这份文件跟着项目走版本,所以一次改名就是一次可 review 的 diff。
它找出代码里不一致的地方。 只读扫描把代码树与词表对照,报告:
- 同一个类型被定义了两遍——比较成员名,也比较成员取值;
- 两份拷贝已经分叉:少了成员、多了成员、枚举值被互换;
- 看起来是拷贝、其实是参数化派生,不能合并;
- 规范名的拼写变体,包括"词表里的写法在代码里根本不存在";
- 术语出现在注释或资源里,但代码里没有对应符号。
词表为空时也能用:结构性检测器拿代码和代码比较,所以新项目在登记第一个概念之前就有结果。
它给"决定"一个落点。 接受一条例外要写理由、选最小范围;之后可以改范围、改理由、移除、撤销,每条都显示它当前真正静音了多少条发现。这个决定是词表里的一行,而不是某个人脑子里的记忆。
它把词表放到 Agent 面前。 每个会话收到一节系统提示:所在工作区的概念,以及配套规则——改名前先查、名字与规范符号逐字一致、缺概念先登记、刻意偏离要写理由记录。五个工具让 Agent 读取、登记、扫描、裁决。
它会消耗 token,而且可以关掉。 提示小节是持续性的成本;面板设置里的开关可按 profile 关闭它,扫描本身在没有请求时不产生任何开销。实测数字见 Token 成本。
它不做什么
- 它是词法级工具,不懂语义。 比较的是标识符、成员名与注释文本;两个同名但无关的类型仍可能被报出来。
- 它不强制任何事。 提示里的规则是建议;Agent 可以偏离,唯一的防线是偏离必须记录。
- 它不改代码。 没有自动重命名、自动合并或补丁;扫描之所以能被信任,正因为它从不写源码。
- 结构性检测只覆盖三种语言的声明:C#、TypeScript、Python。其它文件只贡献注释/字符串证据。
- 单位只存不查。
canonical.unit会被展示并做字符串校验,但没有量纲漂移检测器。 - 一个工作区一份词表。 没有跨仓库视图。
- 它不替你决定词表是否提交。 那是项目自己的选择:提交则可以团队共享与 review,不提交则只在本机生效。
安装
以下任一方式都可安装到某个 profile:
# 从 npm
dsh plugin --profile desktop add dsh-semantic-drift
# 从 GitHub
dsh plugin --profile desktop add github:Yubickari/dsh-semantic-drift
# 从本地目录
dsh plugin --profile desktop add link:/path/to/dsh-semantic-drift
Web GUI 里对应「设置 → 插件 → 从源安装」。安装后刷新一次页面让浏览器半边加载,侧边栏会出现「语义漂移词表」。
lib/ 已随仓库提交,从 git 安装不需要构建。插件要求 DSH >=0.2.1-rc.3。
更新
插件里没有自动更新器:DSH 在重新安装时替换已安装的 bundle。
# npm,指定版本
dsh plugin --profile desktop add [email protected]
# GitHub,锁定到某个 tag
dsh plugin --profile desktop add github:Yubickari/dsh-semantic-drift#v0.2.0
之后重启 DSH,版本号看面板右下角的底栏(vX.Y.Z)或 GET /semantic-drift/health;设置弹窗里不再显示版本。更新不会改动你的词表文件与已接受例外。
开始使用
- 在某个工作区打开「语义漂移词表」。
- 点「创建词表」——写入
.dsh/semantics.yaml(含默认排除项,暂无概念)。 - 点「重新扫描」。还没有概念时就有发现:结构性检测器会指出"同名类型被定义了两遍",这是冷启动的价值。
- 用「新建概念」(或
semantic_upsert)登记概念,再扫一次。依赖词表的检测器这才有比较基准。 - 对打算保留的写法点「接受」:选范围(仅此文件 / 同文件名的所有目录 / 整个符号 / 自定义 glob),看清会静音多少条,再写理由。
各模块信息
词表文件
version: 1
meta:
project: demo
updated: 2026-10-25
exclude: ["**/node_modules/**", "**/bin/**", "**/obj/**"]
concepts:
- id: device.target_temperature # group.name,全局唯一
terms: ["目标温度", "设定温度"] # 人怎么称呼它
kind: quantity # entity | quantity | enum | status | reference
canonical:
symbol: TargetTemperature # 代码里必须这么写
type: double
unit: "°C"
note: 设备被要求达到的温度。
accepted: # 已裁决的例外,必须带理由
- symbol: TargetTemp
path: src/Legacy/** # glob;省略表示该符号全局接受
reason: 公开字段保留一个版本
terms 可以写任何语言:扫描器把它们当纯文本在注释、字符串与资源文件里匹配。
写入前一定先校验;不通过就不会写入,并给出行号或文档路径:
id唯一且形如group.name;canonical.symbol是合法标识符且全局唯一;terms非空,且不能被两个概念共用;- 每条
accepted必须有reason; - 每个 glob 必须能解析;
- YAML 语法错误给出行列号,文件保持原样。
保存是规范化、幂等的;用原文编辑器手改时保留你自己的格式。
检测器
| 检测器 | 置信度 | 信号 |
|---|---|---|
duplicate-type |
高 | 成员名与取值都相同:真正的复制,建议合并是站得住的 |
| 高 | 派生副本:成员名相同、取值不同、共享同一基类——参数化派生,不作为合并候选 | |
| 高 | 同名不同值:没有共同基类,两侧对这些名字的含义已不一致 | |
contract-drift |
高 | 同名类型的成员不同,或同名成员的取值不同。若一方是另一方的同名子类,或两方都是私有嵌套助手类,则不报 |
symbol-variant |
中 | canonical.symbol 的拼写变体(大小写/分隔符、已知错拼、编辑距离 ≤ 2),或同一契约另一种命名:同基类、同成员、同稳定常量 |
| 中 | 词表拼写不在代码里:canonical.symbol 作为代码 token 全仓不出现,而另一种大小写写法有声明或作为声明词头 |
|
term-unmapped |
低 | 术语出现在注释或资源里,但规范符号从未在代码中出现 |
每条发现还带一个稳定的规则 id(detail.rule),面板就是按它本地化的:
| 规则 | 检测器 | 含义 |
|---|---|---|
identical-members |
duplicate-type | 成员名与取值都相同 |
derived-copy |
duplicate-type | 成员相同、取值不同、共享同一基类 |
literal-variant |
duplicate-type | 成员相同、取值不同、无共享基类 |
diverged-members |
contract-drift | 成员或成员取值不同 |
normalized |
symbol-variant | 去掉大小写与分隔符后拼写相同 |
confusable |
symbol-variant | 符号中某个词的已知错拼 |
edit-distance |
symbol-variant | 与规范写法相差 ≤ 2 次编辑 |
contract-alias |
symbol-variant | 同一契约、另一个名字 |
canonical-case |
symbol-variant | 规范写法不存在,只有另一种大小写 |
canonical-missing |
term-unmapped | 术语被使用,规范符号未出现 |
成员的取值也参与比较(仅限单行简单字面量):两个枚举成员值互换,过去因为只比名字而看起来"完全一致"。一侧是 struct、另一侧是 class 的拷贝会在消息里点明。
两条过滤避免刷屏:变体必须同文件还提到该概念的术语,或与规范符号共享首个词元;"契约漂移"的两份声明必须至少共享一个成员。
刻意排除:入口类(带 Main 的 Program/Startup)、partial 声明、生成文件(*.Designer.cs、*.g.cs、auto-generated 头)、成员少于两个的声明、同名子类遮蔽基类、私有嵌套助手类。
Agent 工具与提示注入
| 工具 | 用途 |
|---|---|
semantic_list |
列出概念;hasDrift 跑一次扫描并只保留有发现的概念 |
semantic_get |
按 id 或符号读一个概念与它的已接受例外;includeDrift 追加扫描 |
semantic_upsert |
新建或更新概念(merge 保留未提供字段,replace 重建) |
drift_scan |
结构化发现,带检测器名、置信度与引用 |
semantic_accept |
add(默认)、update 改范围或理由、remove 移除一条、clear 清空一个概念;新增与修改必须带 reason;定位已有条目用 previousPath |
announceToAgent 开启时(默认),每个会话收到一节提示:所在工作区的概念与配套规则。整节最多 200 行,超过 120 个概念折叠为计数。面板顶部、工作区开关右边的「注入提示」按工作区记忆,关掉即停止该工作区的持续成本,工具不受影响。
拒绝一律是返回值({ ok: false, code, message, issues }),不抛异常。词表无法解析时返回 valid: false 与前几条问题。
它设计的循环:
- Agent 接到任务,先
semantic_get查要动的概念; - 缺失的概念先
semantic_upsert登记,再写代码; - 改完
drift_scan,看有没有引入变体或分叉; - 确属刻意的发现,用
semantic_accept带理由、带最小范围接受; - 词表 diff 和其它改动一样走 review。
面板
- 左侧树:概念按 id 前缀分组,组内有发现计数、组头有加号,顶部有搜索与快速新建。
- 概念详情:字段、归属的发现、已接受例外——每行有 编辑 与 移除,超过一条时还有 清空。行上写"当前静音 N 条";0 通常意味着范围什么都没匹配到。
- 工具条:纯图标的扫描、新建、YAML 原文、设置;悬停立即显示说明,也带无障碍标签。工具条是唯一的扫描入口,底栏只报告。
- 工作区模式:跟随时是准星,固定时是图钉,点一下切换。
- 注入提示:工作区模式右边的开关,按工作区记忆;控制这个工作区的词表是否写进 Agent 提示。
- 扫描记忆:重新进入面板时展示本标签页已有结果(默认 10 分钟,可配置);超过保留期亮起琥珀徽标,词表改过也有同款徽标,两个徽标本身就是重扫按钮。
- 记忆页面:回到上次停留的概念(或未归类),且只在该概念仍存在时恢复。
- 未归类:没有被任何概念认领的发现;词表还空着时就有内容。
- 设置:三组——工作区跟随(是否跟随、切换时是否重扫)、扫描与缓存(结果保留时长)、显示与通知(低置信度显示、通知停留秒数)。所有改动改即生效:偏好写入
localStorage。没有保存按钮,标题栏右上角的 × 即关闭。注入开关不在这里,它已经移到面板顶部;项目级设置见弹窗底部的指路提示(见 配置)。
提示通道
面板有四种提示通道,生命周期各不相同:
| 通道 | 出现位置 | 生命周期 |
|---|---|---|
| 校验问题 | 出问题的表单内部 | 留在表单里,直到改正并重新提交;不会再重复出现在横幅里 |
| 横幅 | 页面顶部(尚未扫描)、详情上方(词表无效),或失败操作时在 header 下方 | 状态不变就一直存在;失败操作的横幅带关闭按钮,并列出定位问题 |
| 底栏徽标 | 底栏 | 成功确认(已保存、已复制、已接受、已恢复)与状态标记共用同一种填充徽标;点击关闭,或按「通知自动关闭」的秒数消失(0 表示不关)。状态标记(扫描过期、词表已改、结果截断)也走这个通道 |
没有 Toast:成功走底栏徽标,需要细节的失败走横幅。
配置
面板配置的是自己怎么显示、怎么行为;项目配置的是扫什么、词表文件在哪。
| 字段 | 默认 | 含义 |
|---|---|---|
vocabularyPath |
.dsh/semantics.yaml |
词表路径,相对工作区根 |
announceToAgent |
true |
没有单独设置的工作区的默认注入值 |
announceByWorkspace |
{} |
按工作区覆盖注入开关,键是规范化路径;顶部开关写入,表单不显示 |
citationStyle |
file+symbol |
path/File.cs::Symbol 或 path/File.cs:123 |
minConfidence |
medium |
工具报告的最低置信度;面板始终拿到全部 |
timeoutMs |
30000 |
单次扫描时间预算 |
maxFindings |
5000 |
单次扫描上限 |
maxFileBytes |
1048576 |
超过则跳过并计数 |
maxFiles |
50000 |
单次扫描访问文件上限 |
detectors |
全部四项 | 本次扫描启用哪些检测器 |
上表每一项都是插件行配置,其中除两个注入字段之外都在 DSH 里改:「插件 → 已安装插件 → dsh-semantic-drift(组合包)→ Configuration」。插件把这一页注册到 plugins.bundle.config 座位,并带来字段表(来自 schemastery schema)、字段标签与控件;表单本体——值以及写入——是 DSH 自己的 live config form,由它的 configForms 服务按本插件的 profile 条目 id semantic-drift 提供。插件不再有自建保存通道:这一页不发任何 HTTP 写入,GET /semantic-drift/config 只回字段表,值不走 HTTP。
这里的每个字段都是 volatile:那正是 DSH 会渲染成可编辑字段的一类,也是宿主就地提交、不卸载插件行的一类。所以改动在下一次读取时就生效——没有保存按钮,不需要刷新或重新加载。
announceToAgent 是默认值,announceByWorkspace 是顶部开关写入的按工作区分组。两个字段都不出现在 Configuration 表单里。面板把工作区和新值交给宿主的 config editor——POST /semantic-drift/settings 只承载这两个成员,传 patch 会被拒绝;提示小节实时读同一张表,所以一次点击就够,下一次组装提示时已经生效;没有条目的工作区继续用默认值。
设置弹窗里的每一项都是浏览器偏好,存在 localStorage——既不进词表文件,也不进插件行配置。
工作详细信息
Token 成本
在一个 18 个概念的真实词表上实测:
| 组成 | 大小 | 估算 tokens | 是否随项目变化 |
|---|---|---|---|
| 5 个工具 schema | 5,474 字符 | 约 1,400–1,800 | 不变 |
| 提示小节 | 1,749 字符 | 约 500–700 | 随概念数增长 |
| 提示小节(空词表) | 约 250 字符 | 约 80–120 | — |
| 提示小节(120 概念上限) | 约 8,000 字符 | 约 2,500–3,500 | 封顶 |
也就是说:小词表项目每个请求大约多 2,000–2,500 tokens,满载时可达 4,000–5,000。系统提示通常能命中缓存(稳定部分按缓存价计),但切换工作区或修改词表会让缓存失效。
可控手段:
- 在面板设置里关闭注入:一次点击、无需重载,去掉提示小节(约 500–3,500 tokens),工具仍然可用;
- 控制词表规模:它是概念词表,不是字段大全;
- 停用插件行:连工具 schema 一起去掉。
保证
- 扫描永不写盘。 词表是插件唯一会写的文件。
- 单一写路径。 面板与工具共用 store:按文件串行、先校验后写、原子替换。
- 无 daemon、无监听、无索引。 扫描是你要的时候现算。
- 有界。 超时与结果上限总会结束一次扫描,结果里写明是哪一个。
- 重复扫描很便宜。 未改动文件的解析结果按路径、大小、mtime 复用(1 秒内动过的文件强制重解析),并发的相同扫描共享一次计算;发现本身从不缓存。
- 限定工作区。 读写只在已注册工作区(或 Host 默认目录)内;写请求必须指出工作区,否则拒绝而不是回落到默认目录。
- 对"按需现算"的一处有意例外。 浏览器按工作区把最近一次扫描记在
sessionStorage里一段有界时间:不进入仓库、关闭标签页即失效、工具永不读取、底栏标注时间。 - 可诊断。
GET /semantic-drift/health返回本次挂载了什么(五个工具、九条路由、一节提示)以及生效配置;GET /semantic-drift/config返回设置表单所依据的字段表——类型、默认值、上下限、可选项——不含取值:值在 DSH 的表单里。
限制
这是词法级工具:它懂标识符、成员名与注释文本,不懂语义。
- 名字不是契约;共享成员过滤能去掉大部分误报,但不是全部。
- 成员表是正则解析的;非常规声明会被跳过并计数,而不是猜。
- 编辑距离很粗:只对长度 ≥ 6 的声明类型名,且需要共享首词元或同文件出现术语。
term-unmapped是启发式(低置信度,默认折叠)。- 跨词元的改名(
SetTempvsTargetTemperature)不是拼写变体,应作为概念连同术语登记。 - 面板不能打开文件,只按配置格式复制引用。
- 提示小节按"会话所属的已注册工作区"解析;若会话目录不是已注册工作区,会回落到第一个已注册工作区——工具本身仍作用于会话自己的目录。
开发
pnpm install
pnpm test # 类型检查 + node --test
pnpm build # esbuild → lib/index.js(host)+ lib/client.js(浏览器)
三层测试,各负责前一层看不见的故障:
test/*.test.ts:store、扫描器、检测器、服务与语言字典,用手工构造的夹具;test/fiber.test.ts:把构建产物的 host 挂到假宿主上,驱动工具、路由与提示小节;test/*.test.mjs:渲染组件,并在 jsdom 里驱动构建产物的浏览器包,用真实响应体桩住fetch。
tsc 只覆盖浏览器半边:它能抓住"函数在一处被删掉、另一处还在调用"这类打包器会照单全收的 bug。
贡献规则见 CONTRIBUTING.md,其中包含"任何真实项目都不得在本仓库任何地方被识别出来"这一条。
许可
MIT