Skip to content

dsh-semantic-drift

Verified

dsh-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,它主张的三件事是本插件的前提:

  1. 语义应当是可版本化的独立实体,而不是每次从代码里重新推导;
  2. 有限可枚举的词汇表比无限类型系统更容易保持正确;
  3. 漂移必须可观测、可裁决,而不是指望团队靠记忆维持。

但不借实现:没有 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;设置弹窗里不再显示版本。更新不会改动你的词表文件与已接受例外。

开始使用

  1. 在某个工作区打开「语义漂移词表」。
  2. 点「创建词表」——写入 .dsh/semantics.yaml(含默认排除项,暂无概念)。
  3. 点「重新扫描」。还没有概念时就有发现:结构性检测器会指出"同名类型被定义了两遍",这是冷启动的价值。
  4. 用「新建概念」(或 semantic_upsert)登记概念,再扫一次。依赖词表的检测器这才有比较基准。
  5. 对打算保留的写法点「接受」:选范围(仅此文件 / 同文件名的所有目录 / 整个符号 / 自定义 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 与前几条问题。

它设计的循环:

  1. Agent 接到任务,先 semantic_get 查要动的概念;
  2. 缺失的概念先 semantic_upsert 登记,再写代码;
  3. 改完 drift_scan,看有没有引入变体或分叉;
  4. 确属刻意的发现,用 semantic_accept 带理由、带最小范围接受;
  5. 词表 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 是启发式(低置信度,默认折叠)。
  • 跨词元的改名(SetTemp vs TargetTemperature)不是拼写变体,应作为概念连同术语登记。
  • 面板不能打开文件,只按配置格式复制引用。
  • 提示小节按"会话所属的已注册工作区"解析;若会话目录不是已注册工作区,会回落到第一个已注册工作区——工具本身仍作用于会话自己的目录。

开发

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