# dsh-semantic-drift · 语义漂移词表

[English](README.md) · **简体中文**

一个 DSH 插件：用**一份可版本化的词表**记录项目的业务概念，只读扫描出代码里把它们写得不一样的地方，并给人和 Agent 一个地方去决定"哪种写法才算数"。

DSH 中显示的名字是 **语义漂移词表**（英文 Semantic Drift Vocabulary）。

## 目录

- [这个想法从哪来](#这个想法从哪来)
- [它做什么](#它做什么)
- [它不做什么](#它不做什么)
- [安装](#安装)
- [开始使用](#开始使用)
- [各模块信息](#各模块信息)
  - [词表文件](#词表文件)
  - [检测器](#检测器)
  - [Agent 工具与提示注入](#agent-工具与提示注入)
  - [面板](#面板)
  - [配置](#配置)
- [工作详细信息](#工作详细信息)
  - [Token 成本](#token-成本)
  - [保证](#保证)
  - [限制](#限制)
  - [开发](#开发)
- [许可](#许可)

## 这个想法从哪来

思路来自 [kurapica/schema-node-csharp](https://github.com/kurapica/schema-node-csharp)，它主张的三件事是本插件的前提：

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

但**不借实现**：没有 Meta/Property/Relation 建模，没有语义编译，没有代码生成。只有一份 YAML、一个只读扫描器、一个面板、五个 Agent 工具。

## 它做什么

**它记住"东西叫什么"。** 每个概念一条记录：点分 id、人怎么称呼它（术语）、种类、规范写法（必要时还有类型与单位）、一句说明。这份文件跟着项目走版本，所以一次改名就是一次可 review 的 diff。

**它找出代码里不一致的地方。** 只读扫描把代码树与词表对照，报告：

- 同一个类型被定义了两遍——比较成员名，也比较成员**取值**；
- 两份拷贝已经分叉：少了成员、多了成员、枚举值被互换；
- 看起来是拷贝、其实是**参数化派生**，不能合并；
- 规范名的拼写变体，包括"词表里的写法在代码里根本不存在"；
- 术语出现在注释或资源里，但代码里没有对应符号。

**词表为空时也能用**：结构性检测器拿代码和代码比较，所以新项目在登记第一个概念之前就有结果。

**它给"决定"一个落点。** 接受一条例外要写理由、选最小范围；之后可以改范围、改理由、移除、撤销，每条都显示它当前真正静音了多少条发现。这个决定是词表里的一行，而不是某个人脑子里的记忆。

**它把词表放到 Agent 面前。** 每个会话收到一节系统提示：所在工作区的概念，以及配套规则——改名前先查、名字与规范符号逐字一致、缺概念先登记、刻意偏离要写理由记录。五个工具让 Agent 读取、登记、扫描、裁决。

**它会消耗 token，而且可以关掉。** 提示小节是持续性的成本；面板设置里的开关可按 profile 关闭它，扫描本身在没有请求时不产生任何开销。实测数字见 [Token 成本](#token-成本)。

## 它不做什么

- **它是词法级工具，不懂语义。** 比较的是标识符、成员名与注释文本；两个同名但无关的类型仍可能被报出来。
- **它不强制任何事。** 提示里的规则是建议；Agent 可以偏离，唯一的防线是偏离必须记录。
- **它不改代码。** 没有自动重命名、自动合并或补丁；扫描之所以能被信任，正因为它从不写源码。
- **结构性检测只覆盖三种语言的声明**：C#、TypeScript、Python。其它文件只贡献注释/字符串证据。
- **单位只存不查。** `canonical.unit` 会被展示并做字符串校验，但没有量纲漂移检测器。
- **一个工作区一份词表。** 没有跨仓库视图。
- **它不替你决定词表是否提交。** 那是项目自己的选择：提交则可以团队共享与 review，不提交则只在本机生效。

## 安装

以下任一方式都可安装到某个 profile：

```bash
# 从 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。

```bash
# npm，指定版本
dsh plugin --profile desktop add dsh-semantic-drift@0.2.0

# 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），看清会静音多少条，再写**理由**。

## 各模块信息

### 词表文件

```yaml
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`）不是拼写变体，应作为概念连同术语登记。
* 面板不能打开文件，只按配置格式复制引用。
* 提示小节按"会话所属的已注册工作区"解析；若会话目录不是已注册工作区，会回落到第一个已注册工作区——工具本身仍作用于会话自己的目录。

### 开发

```bash
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](CONTRIBUTING.md)，其中包含"任何真实项目都不得在本仓库任何地方被识别出来"这一条。

## 许可

MIT
