tp/docs/REPOSITORY-AUDIT.md
Kinneyzhang 972b6d4e4c Complete text-property facade and managed lifecycle
Add canonical query semantics, managed metadata and transactions, overlay-aware lookup, reproducible benchmarks, and synchronized API documentation.
2026-07-28 22:42:55 +08:00

1898 lines
69 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# tp 仓库系统审计与“文本属性操作替代层”能力评估
> 审计日期2026-07-28
> 审计快照:`a65d799`tp 0.3.0
> 结论置信度:高
> 范围:公开 API 语义、核心实现、层栈与响应式设计、Emacs 原生语义覆盖、
> 测试与 CI、性能风险、文档一致性以及后续扩展路线。
> 实施状态2026-07-28阶段 0 语义已冻结在
> [API-SEMANTICS.md](API-SEMANTICS.md);阶段 1 的 TP-A01A06、TP-A10 与
> TP-A11 已按本文路线实现回归测试和修复;阶段 2 canonical façade 已完成为
> 内部记录/数据流模型,公开返回保持兼容;阶段 3 text-only 原生语义 façade
> 已完成;阶段 4 managed lifecycle 已完成;阶段 5 overlay-aware 查询已完成,
> overlay lifecycle 明确排除。下文的问题描述保留为审计快照证据,不代表
> 修复后工作树的现状。
> 当前验收结论:按路线编号共有阶段 056 个阶段;若只统计开发阶段则为
> 15 共 5 个现已全部完成。tp 已能作为日常文本属性操作的统一高层
> façade并对 direct/effective/source、显式 nil、change/predicate、
> mutation policy、managed lifecycle 和 overlay-aware lookup 给出公开契约。
> “替代”不表示重写 Emacs 引擎overlay lifecycle、yank/stickiness/undo
> 等底层机制继续显式委托原生 API。
## 0. 结论先行
### 0.1 直接回答
如果“替代所有文本属性操作”是指:
1. 用更统一、易记的接口完成日常的属性设置、查询、删除、批量匹配;
2. 同时提供原生 API 没有的命名层、层栈、响应式属性、调色板等高层能力;
那么 tp **已经基本具备成为首选高层工具箱的条件**
如果“替代”是指:
1. tp 的每个 API 在字符串与缓冲区上都具有稳定、可预测、统一的语义;
2. 可以覆盖或等价映射 GNU Emacs 的全部文本属性读取、写入、边界搜索、
插入继承、分类默认值、撤销、复制/yank、特殊属性行为
3. 进一步覆盖 `get-char-property` 所代表的文本属性与 overlay 联合视图;
4. 现有用户可以不再理解 Emacs 原生语义而只依赖 tp
那么答案是 **目前不能**
当前最准确的定位应当是:
> **tp 是建立在 Emacs 原生文本属性之上的高层操作工具箱,并额外提供一套
> 受管理的命名层、层栈与响应式渲染模型。**
不宜在当前版本中把它描述为:
> Emacs 全部文本属性/字符属性操作的语义等价替代品。
### 0.2 为什么还不能称为“完整替代”
主要原因不是函数数量不足,而是以下四个语义问题:
1. **公共 API 契约尚未完全收敛。** 同一函数在字符串和缓冲区上可能采用不同
的修改方式、坐标、返回类型与搜索结果结构。
2. **“属性不存在”和“属性存在但值为 nil”没有被全程区分。** 这会直接破坏
精确查询、搜索、覆盖和删除语义。
3. **原生文本属性模型与 tp 的受管理层模型有两个不同的所有权系统。**
`tp-set` 展开命名层后通常不保留身份,而 `tp-push-layer`/`tp-put-layer`
会写入 `tp-name`/`tp-layers`;二者不能互换。
4. **Emacs 的完整语义远大于“给区间写 plist”。** 分类默认值、属性别名、
stickiness、插入继承、撤销、yank 过滤、special properties、overlay-aware
查询等仍没有成为 tp 的明确 API 契约。
此外,本次审计确认了数个当前测试未覆盖的实现问题,其中至少两个会造成用户
可见的属性错误,见[第 6 节](#6-已确认的问题与风险排序)。
### 0.3 三个替代层级
建议把“替代”拆成三个可验证层级,而不是使用一个无法验收的口号:
| 层级 | 定义 | 当前状态 |
| --- | --- | --- |
| A. 日常操作替代 | 常见设置、读取、删除、匹配、遍历可优先使用 tp | **较强,接近可用** |
| B. 原生文本属性 API 语义替代 | 对原生读取、写入、搜索、复制、nil 值、坐标和返回值有明确等价契约 | **text-only 已达到,公开返回仍兼容历史** |
| C. 完整字符属性生态替代 | 明确覆盖 category/default/alias、stickiness、特殊属性、yank、undo、overlay-aware 查询等 | **查询边界已达到overlay lifecycle 明确排除** |
合理的近期目标是先完整达到 BC 应当被设计成显式兼容层,而不是重新实现
Emacs 的底层属性引擎。
---
## 1. 审计方法与证据边界
本报告使用了四类证据:
1. **源码审计**:逐模块检查公开入口、解析器、属性合并、层栈编解码、响应式
注册与刷新路径。
2. **文档交叉检查**:比较 README、函数 docstring、架构文档和 CHANGELOG。
3. **动态验证**运行完整编译、ERT、doctest、随机顺序测试并对关键语义
编写最小批处理复现。
4. **官方语义基线**:以 GNU Emacs Lisp Reference Manual 的
[Text Properties](https://www.gnu.org/software/emacs/manual/html_node/elisp/Text-Properties.html)、
[Examining Properties](https://www.gnu.org/software/emacs/manual/html_node/elisp/Examining-Properties.html)、
[Changing Properties](https://www.gnu.org/software/emacs/manual/html_node/elisp/Changing-Properties.html)、
[Property Search](https://www.gnu.org/software/emacs/manual/html_node/elisp/Property-Search.html)、
[Sticky Properties](https://www.gnu.org/software/emacs/manual/html_node/elisp/Sticky-Properties.html)
和 [Special Properties](https://www.gnu.org/software/emacs/manual/html_node/elisp/Special-Properties.html)
为比较基线。
报告使用以下证据标记:
| 标记 | 含义 |
| --- | --- |
| 已确认 | 有源码路径和动态复现,或有明确测试证明 |
| 高置信推断 | 源码控制流明确,但尚未加入永久回归测试 |
| 需阈值 | 已有 benchmark 基线,但尚未固化为发布阈值 |
本报告没有把“测试全绿”等同于“没有问题”。现有测试主要证明已经写入测试的
行为;本次发现的问题恰好说明,缺少跨 API 等价性和状态空间测试时,固定回归
测试无法证明语义完备性。
---
## 2. 仓库现状
### 2.1 模块结构
当前代码采用线性模块边界:
```text
tp-core
→ tp-reactive
→ tp-layer
→ tp-ops
→ tp-search
→ tp-render
→ tp-stack
→ tp-palette
→ tp-builtins
→ tp.el
```
职责总体清晰:
| 模块 | 当前职责 | 审计评价 |
| --- | --- | --- |
| `tp-core.el` | 区间、plist/face 合并、底层工具 | 边界清晰,但部分“便利查询”会丢失位置信息 |
| `tp-reactive.el` | 依赖、watcher、批量队列、缓冲区注册表 | 机制完整,但生命周期依赖所有写入都经过登记入口 |
| `tp-layer.el` | 层定义、解析、展开、栈存储编码 | 是语义复杂度最高的所有者,直接应用与受管理应用混在同一解析体系 |
| `tp-ops.el` | 核心增删改查、`tp-text` | 重载较多;字符串/缓冲区契约未完全统一 |
| `tp-search.el` | 模式、搜索、导航、替换 | 功能丰富,但返回模型与 nil 查询语义分裂 |
| `tp-render.el` | 响应式重渲染、最小文本更新 | 已有差异更新意识,但层重新定义的 old/new 所有权不足 |
| `tp-stack.el` | 命名层栈操作 | 特性强;本质上是独立于原生 plist 的受管理状态机 |
| `tp-query.el` | 原生文本 lookup/change 封装、修改策略 | text-only Stage 3 与 overlay-aware Stage 5 查询已完成 |
| `tp-palette.el` | 主题色数据 | 独立性好;主题变化后的已应用结果没有完整刷新协议 |
| `tp-builtins.el` | 内置层和显示辅助 | 适合作为上层可选能力,不应决定核心属性语义 |
模块化本身是成功的。当前最大的架构问题已经不再是“文件太大”,而是:
> **原生属性操作、层定义展开、受管理层栈、响应式重渲染在 API 层共享了过多
> 隐式规则,却没有共享一个完整的语义规范。**
继续拆文件不会自动解决这个问题;首先应收敛状态所有权和公开契约。
### 2.2 当前质量基线
| 项目 | 当前结果 |
| --- | --- |
| 包版本 | 0.3.0 |
| Emacs 基线 | 28.1+ |
| CI 矩阵 | 28.1 / 29.4 / 30.1 |
| ERT | 636 个,全部通过 |
| doctest | 92 个断言,全部通过 |
| 随机顺序 ERT | 636 个,全部通过;本次种子 `747555` |
| 字节编译 | warning-as-error 下通过 |
这说明仓库已经有良好的维护基础,尤其是模块加载、固定回归、文档示例和多版本
兼容性方面。问题主要集中在**未被现有用例枚举到的语义组合**,不是基础工程
完全缺失。
---
## 3. 设计上做得好的部分
### 3.1 `set` / `reset` / `add` 给出了有价值的高层词汇
README 把三者区分为:
- `tp-set`:只覆盖指定键,保留其他键;
- `tp-reset`:完整替换区间属性;
- `tp-add`:对 face 和嵌套 plist 做合并。
这个词汇比直接记忆多个原生函数更容易使用,也确实表达了三个常见意图。
尤其 `tp-add` 对 face 合并的封装,是原生 API 之上的真实增值,不是简单改名。
### 3.2 字符串和缓冲区共用同一组概念
核心 API 能处理字符串、当前缓冲区和显式缓冲区,模式/正则/搜索/层栈也尽量
保持两类对象都可用。这是合理的产品方向。问题不是“应否统一”,而是当前统一
只发生在函数名层面,坐标、修改性和返回值尚未完全统一。
### 3.3 层栈解决了原生文本属性缺少来源身份的问题
原生文本属性只保存当前每个字符的 plist不记录“这个 face 是哪个业务层写入
的”。tp 用 `tp-name``tp-layers` 保存来源与顺序,使以下操作成为可能:
- 按名称删除某一层,而不是猜测具体属性值;
- 上移、下移、旋转、隐藏、显示;
- 保留下层并只渲染当前顶层;
- 更新被覆盖或隐藏的响应式层;
- 将多个业务状态组合在一个文本范围上。
这是仓库最有差异化的能力,应继续作为核心卖点。
### 3.4 响应式系统已经考虑了增量更新和生命周期
当前实现有:
- 变量 watcher
- 依赖注册;
- 层到缓冲区的索引;
- buffer-local 值;
- 批量更新;
- 已隐藏/埋藏层的写穿;
- `tp-text` 的最小编辑更新;
- 匿名层回收;
- 对“插入已带属性字符串绕过登记”的已知缺口给出手工跟踪入口。
这不是一个简单 demo。不过这也意味着它已经是一套小型渲染系统需要以
状态机和生命周期的标准来测试,而不能只按几个属性函数来测试。
### 3.5 CI 和文档覆盖明显高于一般小型 Elisp 库
多版本编译、warning-as-error、完整 ERT、随机顺序和 doctest 都是有效资产。
`docs/ARCHITECTURE.md` 也对模块职责和反向钩子边界做了明确说明。后续不应推翻
这些基础,而应补充“语义规范”和“等价性测试”两块缺失拼图。
---
## 4. “完整替代”所需的 Emacs 语义基线
### 4.1 原生模型不是稳定的 interval 对象模型
Emacs 的基本模型是:
- 字符串或缓冲区中的每个字符携带一个属性 plist
- `[START, END)` 是操作范围;
- property change functions 从值变化推导边界;
- interval 是实现和检查视图,不是用户持有的稳定业务实体。
GNU 手册专门说明了
[Why Text Properties are not Intervals](https://www.gnu.org/software/emacs/manual/html_node/elisp/Not-Intervals.html)。
这意味着 tp 可以提供 `tp-intervals` 作为便利视图,但不应把 interval 的分割
方式当成稳定身份,也不应让公开返回值依赖内部 interval 碎片数量。
当前多个层栈修改函数返回“改写的 property run 数量”。这个数会随无关属性
边界变化而变化,因此更像调试指标,不是稳定的业务结果。
### 4.2 读取不是简单的 `plist-get`
原生读取可能涉及:
1. 字符上的直接文本属性;
2. `category` 符号 plist 提供的默认值;
3. `default-text-properties`
4. `char-property-alias-alist`
5.`get-char-property` 而言,还包括 overlay 与优先级。
因此以下三个问题必须明确区分:
- 该字符是否**直接携带**属性键;
- Emacs 解析后该字符的**有效属性值**是什么;
- 这个值来自文本、category/default/alias还是 overlay。
当前 tp 的特定属性读取会调用 `get-text-property`,因此会**间接继承**
category/default/alias 的有效值解析;而“全部属性”和 presence 查询使用
`text-properties-at`,只看到直接 plist。问题不是底层能力完全缺失而是公开
API 没有明确命名 direct/effective/source 三种视图,也没有相应兼容性测试。
### 4.3 nil 是合法值,不等于缺失
在 plist 模型中:
```elisp
(property nil)
```
与完全没有 `property` 键不是同一状态。前者可以显式遮蔽较低优先级来源。
因此任何完整替代 API 都必须为下列查询提供不同结果:
1. 缺少键;
2. 存在键且值为 nil
3. 存在键且值为非 nil
4. 调用者没有传 VALUE表示匹配任意值。
当前 tp 在若干路径中使用 `plist-get` 的真值或参数 `nil` 同时表达多个状态,
这是最需要优先消除的语义根因之一。
### 4.4 写属性会参与修改、撤销和 hook
GNU Emacs 中,缓冲区文本属性变化通常会:
- 设置 buffer modified
- 进入 undo
- 触发相关 modification hooks
-`read-only``inhibit-read-only` 影响。
`with-silent-modifications` 可以用于不应污染 modified/undo 的纯属性更新,但它
有明确边界,不能粗暴包住真实文本修改。
tp 当前一些路径尊重原生行为,另一些路径通过
[`tp-with-current-buffer`](../tp-core.el#L110-L115) 无条件绑定
`inhibit-read-only`。如果目标是原生语义替代,这种“默认强制写入”必须成为
显式策略,而不是辅助宏的隐藏副作用。
### 4.5 属性会影响未来编辑和交互
`front-sticky`、`rear-nonsticky` 与 `text-property-default-nonsticky` 决定新文本
怎样继承属性;`insert-and-inherit` 与普通 `insert` 又不同。
此外,以下属性不是被动数据:
- `read-only`
- `modification-hooks`、`insert-in-front-hooks`、`insert-behind-hooks`
- `invisible`、`display`、`composition`
- `keymap`、`local-map`、`help-echo`
- `field`
- `cursor-intangible`、`cursor-sensor-functions`
- `yank-handler`
tp 可以通过通用 setter 写入这些属性,实际行为仍由 Emacs 执行;但“可以写入
一个键”不等于“tp 已经完整建模它的读取、继承、复制、冲突和生命周期语义”。
### 4.6 overlay 不是文本属性,但字符属性查询会看到它
overlay
- 不随复制文本一起复制;
- 属性变化不进入 buffer modified/undo
- 有独立的优先级、边界和 hook
- 会被 `get-char-property`、字段和显示相关语义看到。
因此建议把目标写清楚:
- “完整文本属性 façade”可以不提供 overlay 创建/移动 API
- 但如果承诺替代 `get-char-property` 或“所有字符属性操作”,就必须提供
overlay-aware 查询模式和明确的来源信息。
---
## 5. 当前能力矩阵
状态说明:
- **较完整**:当前 API 足以承担主要工作,契约也基本清楚;
- **部分**:有入口,但存在语义缺口或对象间不一致;
- **间接**:可以写入原生键,行为由 Emacs 执行tp 没有独立建模;
- **缺失**:没有与目标相匹配的公开能力;
- **超出当前边界**:不一定应该实现,但必须在定位中排除。
| 语义面 | 当前能力 | 状态 | 主要缺口 |
| --- | --- | --- | --- |
| 任意属性键写入 | `tp-set/reset/add` | 较完整 | 重载、返回值、read-only 策略仍不统一 |
| 单键增改 | `tp-set` | 较完整 | 名字与原生 `set-text-properties` 易混淆 |
| 全 plist 替换 | `tp-reset` | 较完整 | 与 `tp-text` 的属性保留说明需更精确 |
| face/嵌套 plist 合并 | `tp-add` | 较完整 | 自定义合并规则需正式规范化 |
| 单键/多键删除 | `tp-remove`、`tp-clear` | 部分 | 字符串子属性删除可留下“存在但为 nil”的键 |
| 位置读取 | `tp-at`、`tp-member`、`tp-lookup` | 已达到 Stage 3/5 | overlay lifecycle 不属于 lookup |
| 区间读取 | `tp-get`、`tp-intervals` | 部分 | `tp-get` 丢弃 nil 值;坐标模式不统一 |
| 属性汇总 | `tp-plist` | 部分 | last-value-wins丢失属性对应的区间信息 |
| 属性边界 | `tp-intervals`、内部 map、`tp-property-change` | 已达到 text-only Stage 3 | public interval relative 默认作为兼容例外保留 |
| 搜索相等值 | `tp-forward/backward/search`、`tp-property-any/not-all` | 已达到 text-only Stage 3 | 字符串/缓冲区 public 返回仍保持历史兼容差异 |
| predicate 搜索 | `tp-forward/backward/search` | 已达到 text-only Stage 3 | public 返回仍兼容历史 |
| 模式/正则批量写入 | `tp-match-*`、`tp-regexp-*` | 较完整 | 默认绕过 read-only 的策略需显式化 |
| 字符串复制式操作 | 整串 `tp-set/reset/add` | 较完整 | 与字符串区间原地修改形成两套模型 |
| 字符串长度变化替换 | `tp-text` 可返回新字符串 | 部分 | 初次多 interval 属性合并存在错误 |
| category 默认值 | `tp-lookup :mode :text-source/:text-effective` | 已达到 text-only Stage 3 | overlay lifecycle 不参与 |
| `default-text-properties` | `tp-lookup :mode :text-source/:text-effective` | 已达到 text-only Stage 3 | overlay lifecycle 不参与 |
| property alias | `tp-lookup :mode :text-source/:text-effective` | 已达到 text-only Stage 3 | overlay lifecycle 不参与 |
| text-only / char-property 查询 | `tp-lookup` text/char modes | 已达到 Stage 3/5 | overlay 创建/移动/删除/priority 管理排除 |
| stickiness/插入继承 | 直接委托 Emacs | 已文档化并测试 | 无 tp wrapper |
| buffer modified/undo | `tp-with-mutation-policy` + Emacs 副作用 | 三种组合已文档化并测试 | 三种组合之外不承诺统一策略 |
| silent property update | `tp-with-mutation-policy` | 已达到 Stage 3 | `:silent` + `:respect` 明确拒绝 |
| kill/yank 属性处理 | 直接委托 Emacs | 已文档化并测试 | 无 tp wrapper |
| 特殊显示/交互属性 | 可通用写入,行为委托 Emacs | 已文档化 | 无 source/priority wrapper |
| narrowing | 依赖原生缓冲区 | 已文档化并测试 | - |
| indirect buffer | 依赖原生共享文本 | 已文档化并测试 | - |
| 命名层/层栈 | `define-tp`、stack API、managed lifecycle API | Stage 4 已达到 | compositional layer 仍是可选扩展 |
| 响应式重渲染 | watcher + registry + render + theme generation diagnostics | Stage 4 已达到 | benchmark 基线已记录,非发布阈值 |
| overlay 创建/移动/删除 | 无 | 明确排除 | 使用 Emacs 原生 overlay lifecycle |
### 5.1 能否设置所有原生属性
多数情况下可以通过通用 plist 写入任意属性键,因此 `tp-set` 的表达力不受一个
固定 allowlist 限制。
但 [`tp--builtin-text-properties`](../tp-core.el#L45-L72) 被用于禁止层名与
原生属性名冲突。这个手工列表:
- 不可能天然随 Emacs 新版本保持完整;
- 已缺少如 `cursor-sensor-functions` 等官方属性;
- 同时包含 `evaporate` 这类 overlay-only 概念。
所以它不应被视为原生属性语义的权威目录。更稳妥的做法是给 tp 层使用独立命名
空间,或只禁止 tp 自己的保留元数据键;不要试图手工穷举 Emacs 属性名。
---
## 6. 已确认的问题与风险排序
### 6.1 汇总
| ID | 优先级 | 问题 | 证据 | 影响 |
| --- | --- | --- | --- | --- |
| TP-A01 | P0 | 初次 `tp-text` 应用会把位置 0 的内嵌属性扩散到后续 interval | 动态复现 + 源码 | 输出属性错误 |
| TP-A02 | P0/P1 | 静态层重定义不刷新;已触发刷新时旧键仍可残留 | 两组动态复现 + 源码 | 已显示内容与定义不一致 |
| TP-A03 | P1 | 显式 nil 无法覆盖 `tp-text` 内嵌属性 | 动态复现 + 源码 | nil 语义错误 |
| TP-A04 | P1 | 搜索 API 把省略 VALUE 与 VALUE=nil 混为一体,且字符串/缓冲区结果不一致 | 动态复现 + 源码 | 无法精确搜索 nil |
| TP-A05 | P1 | 字符串子属性删除可留下 `(PROPERTY nil)`,缓冲区路径则移除键 | 动态复现 + 源码 | 同一 API 两种状态 |
| TP-A06 | P1 | `NOERROR` 捕获所有内部错误 | 动态复现 + 源码 | 真实 bug 被静默吞掉 |
| TP-A07 | P1 | 直接层应用与层栈应用的身份语义分裂 | 源码 + 文档/测试 | 用户难以预测后续层操作 |
| TP-A08 | P1 | 修改性、坐标和返回值在对象/函数族之间不一致 | 源码 + 文档 | 很难成为稳定 façade |
| TP-A09 | P1 | 多处文档与实际实现矛盾 | 源码/docstring | 用户按文档编程会出错 |
| TP-A10 | P1 | 隐藏层存在时,外部直接属性编辑会被后续栈操作静默丢弃 | 动态复现 + 源码明确说明 | 用户属性数据丢失 |
| TP-A11 | P1/P2 | transform/compute 等业务计算失败后 fallback/跳过observer 错误又无结构化报告 | 源码 | 产生貌似有效但错误或过期的输出 |
| TP-A12 | P2 | 响应式注册依赖入口完整性,插入带属性字符串需手工登记 | 源码明确承认 | 更新可能延迟或漏掉 |
| TP-A13 | P2 | 缺少全矩阵原生等价性和 CI 性能阈值 | 测试审计 | 无法证明“完整替代”与性能 SLA |
### 6.2 TP-A01初次 `tp-text` 多 interval 属性扩散
**状态:已确认。**
[`tp--merge-string-props-into-plist`](../tp-core.el#L326-L352) 明确只读取字符串
位置 0 的属性。初次 `tp-text` 处理路径随后把这一结果作为整个替换区域的基础
属性;虽然部分路径又按 interval 应用属性,但位置 0 的值已经被提前合入,造成
后续 interval 污染。
最小语义场景:
```text
FINAL-TEXT:
[0,2) face=bold
[2,4) face=italic
```
本次批处理复现中:
- 字符串初次应用的后半段同时得到 `bold``italic`
- 缓冲区初次应用把 `bold` 扩散到整个替换范围。
而后续响应式更新已有按 interval 保留属性的专门路径
([`tp-render.el`](../tp-render.el#L339-L427))。这说明当前同一功能的“初次应用”
和“更新应用”使用了两套不完全一致的合并引擎。
现有 [`tp-render-tests.el`](../tp-render-tests.el#L318-L334) 覆盖了后续响应式更新,
没有覆盖初次应用的这个状态。
**根因:**
不是简单漏了一个条件,而是“字符串内嵌属性的所有者”在两个阶段不一致:
- 一条路径把位置 0 属性提升为全区域属性;
- 另一条路径把内嵌属性视为 per-interval 数据。
**修复方向:**
只保留一个 per-interval 合并入口。禁止任何初次应用路径把 position 0 的属性
当成整串代表值。修复前先加入字符串与缓冲区两组红色回归测试。
### 6.3 TP-A02层重定义存在两条独立的刷新缺陷
**状态:两条均已确认。**
#### A. 静态层重定义不触发已应用区域刷新
静态 simple 层的定义路径只更新 `tp-layer-alist`,没有调用
`tp--layer-refresh`[`tp-layer.el`](../tp-layer.el#L427-L439))。
复现流程:
```elisp
(tp-layer-reset)
(define-tp audit-static () '(face bold help-echo "old"))
;; 在临时缓冲区插入 "abc" 并 push audit-static 后:
(define-tp audit-static () '(face italic help-echo "new"))
```
实际:
```text
face=bold
help-echo="old"
stack=((audit-static face bold help-echo "old"))
```
预期:
```text
face=italic
help-echo="new"
stack=((audit-static face italic help-echo "new"))
```
因此 `face` 也完全不刷新,这与“旧键残留”不是同一个问题。
#### B. 已触发 refresh 时,只更新新键,不删除旧键
响应式层重定义会触发 refresh
[`tp--merge-props-into-stack-entry`](../tp-render.el#L142-L152)
只复制旧 entry 后写入新 plist 出现的键;
[`tp--update-layer-regions`](../tp-render.el#L190-L233)
也只遍历新 props 做 `put-text-property`
复现把响应式层从:
```elisp
(face (:foreground $audit-fg) help-echo "old")
```
重定义为:
```elisp
(face (:background $audit-bg))
```
实际:
```text
face=(:background "blue")
help-echo="old"
stack=((audit-reactive face (:background "blue") help-echo "old"))
```
预期 `help-echo` 被删除。
**共同根因:**
层定义更新没有统一的生命周期协议:
- 静态定义更新没有进入刷新路径;
- 刷新路径又只持有“新层定义”,没有完整替换“旧层 entry”。
**修复方向:**
所有可刷新 managed layer 都应经过同一 old/new entry 替换路径,再从完整 stack
重建可见 plist。对于直接应用但不保留 `tp-name` 的层,应明确它是一次性模板
展开,不承诺随定义更新。
### 6.4 TP-A03显式 nil 不能覆盖内嵌属性
**状态:已确认。**
[`tp--merge-embedded-props`](../tp-ops.el#L66-L84) 使用:
```elisp
(let ((existing (plist-get result key)))
(if existing ... embedded-value))
```
当调用者明确传入 `(custom nil)` 时,`plist-get` 返回 nil代码把它误判为
“调用者没有这个键”,最终让内嵌值覆盖显式 nil。
本次复现:
```text
内嵌字符串custom=embedded
调用属性custom=nil
期望custom 键存在,值为 nil
实际custom=embedded
```
**修复方向:**
凡是判断“键是否由调用者提供”必须使用 `plist-member``plist-get` 只用于取值。
随后应全库搜索相同模式,而不是只修这一处。
### 6.5 TP-A04VALUE=nil 无法表达精确搜索
**状态:已确认。**
[`tp-search`](../tp-search.el#L904-L992) 的条件是:
```elisp
(or (null value)
(equal prop-val value))
```
因此 nil 同时表示:
- 调用者省略 VALUE匹配“属性存在且任意值”
- 调用者明确传入 nil匹配“属性存在且值为 nil”。
两者无法区分。
更严重的是 [`tp-forward`](../tp-search.el#L521-L566)
- 字符串路径委托给 `tp-search`nil 是 wildcard
- 缓冲区路径把 nil 传给 `text-property-search-forward` 并固定 predicate 为
`equal`,语义不同;
- 字符串返回前 N 个 `(START END VALUE)` 列表;
- 缓冲区返回第 N 次搜索的 `prop-match`
本次动态复现确认,同一份属性布局、同一个 nil 参数,在字符串和缓冲区路径上
匹配到了不同区间。
**修复方向:**
使用不可与合法属性值冲突的 sentinel 表示“VALUE 未提供/任意值”,例如内部
`tp--any-value`;公共 API 可以通过显式 keyword 或拆分入口表达。所有对象路径
应先产生同一种规范化 match 结构,再决定是否移动 point。
### 6.6 TP-A05字符串子属性删除留下 nil 键
**状态:已确认。**
字符串子属性删除路径会用 `plist-put` 把父属性重建为 nil再以
`set-text-properties` 写回,因此保留属性键、只把值设为 nil缓冲区路径则会
真正删除属性键。
本次复现中,同样的子属性删除后:
- 字符串 `tp-member` 返回 `(face nil)`
- 缓冲区 `tp-member` 返回 nil。
相关实现位于 [`tp-ops.el`](../tp-ops.el#L1050-L1072)。
**修复方向:**
先定义子属性删除后父属性为空时的唯一契约:
- 若父属性没有剩余有效内容,应移除父键;
- 若业务确实需要显式 nil应由调用者明确请求而不是删除操作偶然产生。
字符串和缓冲区必须使用同一个纯函数计算“旧值 → 新值/删除标记”,然后各自
只负责写回。
### 6.7 TP-A06`NOERROR` 吞掉层内部执行错误
**状态:已确认。**
[`tp-put-layer`](../tp-stack.el#L279-L342) 的 `NOERROR` 路径使用宽泛的
`condition-case nil ... (error ...)`。本次定义一个解析成功但求值时主动报错的
参数化层后,`NOERROR=t` 返回 nil内部的真实错误也被吞掉。
`NOERROR` 合理的语义应当仅是:
> 找不到指定层/无法解析用户给出的层名时不报错。
它不应当吞掉:
- 参数化层 body 的 bug
- 响应式计算错误;
- 非法属性结构;
- 栈编解码不变量破坏;
- 任意其他内部异常。
**修复方向:**
不要围住整个执行路径捕获 `error`。先进行可返回“not found”的窄解析再让后续
错误自然传播。
### 6.8 TP-A07命名层有两种不兼容的应用语义
**状态:已确认。**
直接属性操作中的层名解析:
```elisp
(tp-set object 'my-layer ...)
```
通常展开为该层的属性,但不保存 `tp-name`
([`tp-layer.el`](../tp-layer.el#L1353-L1366))。因此它是“一次性模板展开”。
层栈操作:
```elisp
(tp-push-layer object 'my-layer ...)
(tp-put-layer object ... 'my-layer ...)
```
会保存 `tp-name``tp-layers`
([`tp-layer.el`](../tp-layer.el#L1419-L1477)),因此是“受管理实例”。
这两种行为各自都可以合理,但使用同一个“应用层”词汇会让用户自然期待:
- 后续可按层名查询或删除;
- 层重定义会更新已应用区域;
- 响应式系统能找到该层;
- 直接设置与 push 只差栈位置。
实际并非如此。
**修复方向:**
正式命名两个概念:
1. **展开模板**:把层定义解析成普通 plist不保留身份不参与生命周期
2. **挂载层实例**:保存身份、进入层栈、可刷新、可按名操作。
关键不是增加包装函数,而是让文档、函数名、返回值和测试始终使用同一词汇。
### 6.9 TP-A08修改性、坐标和返回值没有统一模型
**状态:已确认。**
#### 修改性
`tp-set/reset/add`
- 整串字符串形式返回新字符串;
- 字符串 region 形式原地修改;
- 缓冲区原地修改。
层栈的大多数字符串形式又是原地修改
([`tp-stack.el`](../tp-stack.el#L494-L524))。
这意味着仅从函数名或 OBJECT 类型无法判断是否修改原对象,还必须记住调用形式。
#### 返回值
当前常见返回值包括:
- 新字符串;
- 原字符串;
- `(START . END)`
- 被修改的 property run 数量;
- `prop-match`
- `(START END VALUE)` 列表;
- nil
- 层栈查询中的 union、最大深度、首个 top。
其中“property run 数量”不是稳定业务语义,因为无关属性边界也能改变 run 数量。
#### 坐标
字符串通常为 0-based、缓冲区为 1-based这是原生约定本身合理
[`tp-intervals`](../tp-core.el#L117-L148) 的缓冲区结果默认又改为相对 START 的
0-based offset只有 `ABSOLUTE` 才返回原生坐标。一个用户从查询结果继续调用
`tp-set` 时容易发生 off-by-one。
**修复方向:**
不必强行让字符串和缓冲区使用同一坐标基数,但必须统一:
- 默认返回 OBJECT 的原生坐标;
- 相对坐标必须通过显式选项请求;
- 查询返回值使用同一种结构;
- 原地修改与复制式转换必须能从入口名称或显式参数判断;
- 修改函数返回稳定的“是否变化/结果对象”,诊断统计另设调试入口。
### 6.10 TP-A09文档和实现存在明确矛盾
**状态:已确认。**
代表性例子:
1. [`tp--resolve-props`](../tp-layer.el#L1160-L1205) docstring 说符号层名会包含
`tp-name`,实现却明确传入 nil不包含 `tp-name`
([`tp-layer.el`](../tp-layer.el#L1359-L1366))。
2. [`define-tp`](../tp-layer.el#L316-L367) 的当前使用示例仍声称直接 `tp-set`
会得到 `tp-name`,与 README 和当前实现相反。
3. [`tp-search-map`](../tp-search.el#L1053-L1101) docstring 说字符串长度不同时
会截断/部分替换,但实际实现
[`tp--replace-match-text`](../tp-search.el#L670-L736) 会明确报错。
4. README 宣称“统一 API 参数规范”,但同一文档同时记录了字符串复制/原地
修改和搜索结果结构的差异。
**修复方向:**
先写机器可验证的语义表,再从该表更新 README 与 docstring。避免继续分别修补
中英文文档和函数说明而没有共同规范。
### 6.11 TP-A10隐藏层状态会静默覆盖外部直接属性编辑
**状态:已确认,且内部 docstring 已明确说明。**
当任意层处于 hidden 状态时,`tp-layers` 保存完整层栈,字符的其他直接属性只是
临时渲染表面。栈编解码文档明确说明,期间发生的原生或 tp 直接属性编辑会在
下一次 stack operation 时被丢弃
([`tp-layer.el`](../tp-layer.el#L1519-L1565))。
本次最小复现:
1. push 一个 `face=bold` 的 managed layer
2. hide 该层;
3. 直接写入 `help-echo="external"`
4. show 该层。
show 前:
```text
(help-echo "external"
tp-layers ((tp-hidden t face bold tp-name audit-hidden)))
```
show 后:
```text
(face bold tp-name audit-hidden)
```
`help-echo` 无提示地消失。这不是单纯的内部实现细节,而是用户数据所有权冲突,
应当与 correctness 缺陷一起处理,不能推迟到新增高级层功能之后。
**修复方向:**
阶段 0 就必须选定默认规则。最小且安全的默认是managed hidden range 检测到
无法归属的直接属性变化时,在下一次栈写入前明确报出冲突;若决定采用
“收编为匿名层”或“明确覆盖”,也必须由公开契约和回归测试证明,不能静默决定。
### 6.12 TP-A11业务计算与 observer 的错误边界混在一起
**状态:源码确认,具体产品策略待定。**
当前:
- transform 出错后 message 并返回原文本
([`tp-ops.el`](../tp-ops.el#L50-L64))
- initial compute 出错后 message 并跳过赋值
([`tp-reactive.el`](../tp-reactive.el#L352-L368))
- watcher 出错后 message 并继续
([`tp-reactive.el`](../tp-reactive.el#L306-L318))。
三者不应使用同一策略:
- transform/compute 决定业务输出,失败后 fallback 或跳过会留下貌似有效、实际
错误或过期的文本,应默认传播到外层错误边界;
- watcher 如果只是 observer隔离单个 callback 可以合理,但必须留下可查询的
结构化失败,而不只是易被忽略的 minibuffer message。
**修复方向:**
先按“计算结果所有者”和“副作用观察者”分类,再收敛错误边界。不要为每个函数
新增一个静默选项;默认让业务计算失败,顶层批量渲染可以汇总多个错误。
### 6.13 TP-A12响应式注册表依赖入口完整性
**状态:源码明确承认。**
[`tp-reactive-layer-buffers`](../tp-reactive.el#L86-L103) 已记录:
> 插入一个已经带 `tp-name` 的 propertized string 会绕过 buffer operation 的
> 登记路径。
当前补救是:
- unknown 时进行学习性全 buffer scan
- 用户调用 `tp-reactive-track-buffer` 手工登记。
这在“高层工具箱”定位下可以接受,但在“所有文本属性操作替代层”定位下不够:
用户可以通过原生 `insert`、substring、kill/yank、间接缓冲区等多种路径让属性
进入缓冲区,注册表无法假设所有变化都经过 tp。
**扩展方向:**
提供一个明确的 managed mount/attach 生命周期;不要尝试监听所有原生属性变化。
对于外部插入的属性,提供显式 `track/attach` 和可选的 after-change 集成,并在
文档中说明成本。
### 6.14 TP-A13测试数量多但不能证明语义完备
**状态:已确认。**
现有 604 个 ERT 和 92 个 doctest 对固定回归很有价值,但缺少:
- 与原生函数的等价性测试;
- 随机生成属性 interval 的 property-based 测试;
- `nil`、缺失、默认值、category、alias 的组合;
- 字符串/缓冲区同构场景;
- undo、modified、read-only、hook、stickiness
- indirect buffer 和 narrowing
- 将大缓冲区、碎片 interval、深层栈、响应式 fan-out 的性能基准固化为 CI 阈值;
- 主要交互命令的 `call-interactively` 路径;
- 调色板/主题切换后的动态视觉验证。
`tp-run-shuffled.el` 只是改变测试顺序,不会生成新的输入状态。它能发现全局状态
泄漏,但不能替代属性状态空间测试。
---
## 7. API 语义专项分析
### 7.1 `tp-set` 的名称容易与原生 `set-text-properties` 发生反向联想
原生:
- `put-text-property`:单键覆盖;
- `add-text-properties`:添加/覆盖给定键;
- `set-text-properties`:完整替换 plist。
tp
- `tp-set`:部分覆盖;
- `tp-reset`:完整替换;
- `tp-add`:深度/face 合并。
tp 内部体系是自洽的,但熟悉 Emacs 的用户看到 `set` 往往会联想到“完整替换”。
无需为了原生名字机械改 API但应在语义规范第一屏给出映射表并让返回值也
尽量接近相应原生操作:
| 用户意图 | tp 当前入口 | 最接近的原生概念 |
| --- | --- | --- |
| 覆盖指定键 | `tp-set` | `add-text-properties` / 多次 `put-text-property` |
| 替换全部属性 | `tp-reset` | `set-text-properties` |
| 按自定义规则合并 | `tp-add` | `add-face-text-property` + 自定义 merge |
### 7.2 过载解析器降低了可预测性
[`tp--parse-args`](../tp-ops.el#L248-L317) 支持多种调用形式,并根据:
- 第一个参数是 string 还是 number
- 第二个 symbol 当前是否已注册为层;
- 第三个参数是否非 nil
- 余下参数是否看似 object
推断用户意图。
这使调用含义依赖全局层注册状态。例如同一个 symbol 在定义为层前后可能进入
不同解析分支。参数化层还允许 flat、wrapped args 与 extra plist 组合。
这种便利适合 REPL但不适合作为“完整替代层”的唯一规范入口。
建议:
- 保留便利调用作为外观层;
- 内部和正式规范使用单一的 canonical request
- 在解析完成后立刻得到明确的 `object/start/end/operation/properties/mutation`
语义,后续模块不再重新猜测;
- 错误应在解析边界一次性报告,不在深层以 nil 继续。
这不要求新增多个文件,也不要求引入新依赖;一个小型、明确的数据结构或参数
规范即可。
### 7.3 查询 API 应区分四种问题
当前 `tp-at`、`tp-member`、`tp-get`、`tp-plist`、`tp-search` 混合回答了不同问题:
1. 某位置的全部直接属性是什么?
2. 某直接属性键是否存在?
3. 某属性的有效值是什么?
4. 哪些连续区域满足某个 predicate
建议把这四个问题写成正式语义,再决定是否保留现有函数名。尤其:
- `tp-get` 不应过滤合法 nil
- `tp-member` 应只回答 presence不顺带承担范围读取
- `tp-plist` 的“跨区间 last-value-wins 汇总”应明确标成 summary而不是区域的
属性真相;
- 搜索结果应包含 range、value并可选包含 source不依赖对象类型改变结构。
### 7.4 层栈不是原生属性的“自然叠加”
当前层栈只把顶层可见属性渲染到字符的直接 plist下层放入 `tp-layers` 存储。
这意味着它更接近图像编辑器的图层栈,而不是 CSS cascade 或多个独立属性来源
的逐键组合。
例如:
- 顶层只含 `face`
- 下层含 `help-echo`
当前“只显示顶层”模型下,下层 `help-echo` 不一定继续生效。用户若把“属性层”
理解为“每个键按层优先级组合”,会得到不同预期。
建议正式命名两种可能模型:
1. **exclusive layer**:只有最高可见层的整个 plist 生效;当前模型;
2. **compositional layer**:每个属性键独立按优先级求值。
无需立即实现第二种,但必须在文档中把第一种说清。若未来扩展第二种,也应作为
显式模式,不能悄悄改变现有栈语义。
### 7.5 区域查询的标量返回值存在信息损失
[`tp-layer-list`](../tp-stack.el#L117-L126) 返回区域中出现过的层名 union
[`tp-layer-count`](../tp-stack.el#L128-L136) 返回所有 run 的最大深度;
[`tp-layer-top`](../tp-stack.el#L143-L157) 返回第一个带名字的 top。
这些定义不是错误,但函数名看起来像在描述“整个区域的统一状态”。当区域内部
异质时,调用者无法从标量结果判断:
- 每个 run 是否相同;
- 某层覆盖全部区域还是只出现一次;
- top 是否统一;
- count 的分布是什么。
建议保留便利标量,同时提供或强化 run-aware 查询作为权威入口。标量 docstring
中应直接出现 union/max/first而不是只写“in region”。
### 7.6 错误策略应只有两个边界
合理的错误边界:
1. 参数解析/公共命令边界:把非法用户输入转成清晰错误;
2. 可选的用户 callback 边界:说明 callback 失败是否终止渲染。
当前策略混合了:
- transform 出错后 message 并退回原文本
([`tp-ops.el`](../tp-ops.el#L50-L64))
- watcher 出错后 message 并继续
([`tp-reactive.el`](../tp-reactive.el#L306-L318))
- initial compute 出错后跳过
([`tp-reactive.el`](../tp-reactive.el#L352-L368))
- `NOERROR` 可能吞任意内部错误;
- 普通参数错误则向上传播。
建议分别规定:
- pure computation/transform 失败:默认传播,或由顶层统一汇总;
- watcher 作为副作用 observer可以隔离但必须记录结构化失败
- “not found” 使用普通返回值,不用异常;
- 内部不变量错误永不被 `NOERROR` 吞掉。
### 7.7 `tp-text` 跨越了属性、文本替换与响应式状态三个领域
`tp-text` 看似一个属性键,实际上可能:
- 替换真实字符串/缓冲区文本;
- 合并新字符串携带的属性;
- 保留旧范围属性;
- 执行 transform
- 识别 layer identity
- 更新响应式变量;
- 触发最小差异编辑。
它不是普通 text property而是一条命令协议。把它与任意 plist 键放在同一个
解析和合并通道中,容易造成 position 0 采样、属性所有权和重复刷新等问题。
建议将 `tp-text` 明确定义为“文本 replacement directive”
- 先计算 replacement text
- 再按 interval 计算其内嵌属性;
- 再执行文本修改;
- 最后应用 layer/props
- 不把 directive 自身持久化为普通文本属性。
公开便利语法可以不变,但内部状态机必须与普通 property merge 分离。
---
## 8. 架构与状态所有权建议
### 8.1 保留现有模块,先确立三个语义域
不建议为了本次问题再拆一批 `utils`/adapter 文件。现有模块足以承载下面三个
清晰语义域:
#### 域 1原生属性 façade
负责:
- 字符串/缓冲区直接属性读取;
- 区间增删改;
- 搜索与 change boundary
- 原生坐标、修改、undo/read-only 语义;
- category/default/alias/overlay-aware 的显式查询模式。
它不拥有层身份。
#### 域 2受管理层实例
负责:
- layer identity
- stack entry
- hide/show/move/merge
- old/new layer contribution
- mount/attach/detach
- 响应式依赖和重渲染。
它不应把直接外部属性偷偷吸收到某个层,也不应在下次 stack operation 时静默
丢弃用户不知道属于谁的属性。
#### 域 3上层表现组件
负责:
- palette
- builtin layers
- 主题感知;
- UI 辅助。
它可以使用前两个域,但不应让核心包无条件承担全部上层状态和加载成本。
### 8.2 明确直接属性与 managed layer 的冲突规则
当前 stack codec 文档已经说明:存在隐藏层时,之后的直接原生/tp 属性编辑可能
在下次栈操作中被丢弃
([`tp-layer.el`](../tp-layer.el#L1519-L1565))。
这是重要的用户数据所有权问题,不能只放在内部 docstring。
应选择并文档化一种策略:
1. **严格模式(推荐默认)**managed range 上发现无法归属的外部直接修改时,
栈操作报出冲突;
2. **adopt 模式**:把外部直接属性采集成一个显式匿名层;
3. **overwrite 模式**:明确允许栈状态覆盖外部直接修改。
关键是不能静默决定。
### 8.3 层刷新必须做完整 entry 替换
层实例应保存:
- identity
- 当前定义或参数;
- 当前解析后的完整 props
- hidden 状态;
- 必要时的实例参数/版本。
重定义/响应式更新时,先生成新 entry再用新 entry 替换旧 entry最后从完整
stack 重新计算可见属性。这样自然解决旧键残留,无需猜测哪些直接属性属于旧层。
### 8.4 不要重新实现 Emacs 属性引擎
完整替代的正确方向不是自己模拟:
- undo
- stickiness
- special property behavior
- overlay priority
- font-lock
- field motion。
这些应继续交给 Emacs。tp 的职责是:
- 提供完整、显式、可组合的 façade
- 不破坏原生副作用;
- 对不支持或不建模的部分给出逃生口;
- 用等价性测试证明委托正确。
---
## 9. 性能与可扩展性分析
### 9.1 当前值得保留的优化
- layer → buffer 注册表避免每次都扫描 `buffer-list`
- unknown 状态与 known-empty 分开;
- kill-buffer 清理;
- property interval 遍历;
- `tp-text` 更新尝试做最小文本差异;
- 批量响应式刷新;
- 避免相同值重复 `put-text-property`
### 9.2 主要性能风险
以下风险已有可复现 benchmark 基线,但仍不应把单机耗时直接当成发布阈值:
| 场景 | 风险来源 | 已记录指标 |
| --- | --- | --- |
| 大缓冲区、高 interval 碎片 | 多次 `next-property-change` 与 plist 复制 | `large-text``fragmented` rows |
| 层多且频繁 move/hide/show | 每 run 解码、重建完整 stack | `stack-depth` rows |
| 响应式 fan-out | watcher × layer × buffer | `reactive-fanout` rows |
| unknown layer | `buffer-list` 学习性扫描 | 尚未单独拆出 |
| `tp-text` 长文本变化 | 差异计算、删除插入、marker/undo | `large-text` rows |
| 匿名层 | 注册表增长和 GC 扫描 | 尚未单独拆出 |
| palette/theme | 重新解析颜色和重渲染 | `theme-managed-refresh` rows |
`make benchmark` 在 Emacs 30.2 上完成固定 seeds `1`、`7`、`42`、`747555`
以及可复现 generated seed `8675309`。完整记录见 `docs/BENCHMARKS.md`
代表性 seed 42 结果:
| Scenario | Requested / actual | Operations | Scanned | Changed | Refreshed | Elapsed (s) |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| large text | 100,000 | 2 | 100,000 | 100,000 | 0 | 0.000302 |
| large text | 1,000,000 | 2 | 1,000,000 | 1,000,000 | 0 | 0.000318 |
| fragmented intervals | 1,000 | 1 | 1,000 | 500 | 0 | 0.047154 |
| fragmented intervals | 10,000 | 1 | 10,000 | 5,000 | 0 | 0.514766 |
| fragmented intervals | 50,000 | 1 | 50,000 | 25,000 | 0 | 2.769766 |
| stack depth | 1 | 2 | 2,000 | 2,000 | 0 | 0.000892 |
| stack depth | 5 | 6 | 2,000 | 2,000 | 0 | 0.003121 |
| stack depth | 20 | 21 | 2,000 | 2,000 | 0 | 0.012660 |
| stack depth | 50 | 51 | 2,000 | 2,000 | 0 | 0.039089 |
| reactive fan-out | 1 / 1 | 1 | 1 | 1 | 1 | 0.001139 |
| reactive fan-out | 10 / 10 | 1 | 10 | 10 | 10 | 0.008153 |
| reactive fan-out | 100 / 100 | 1 | 100 | 100 | 100 | 0.064352 |
| reactive fan-out | 500 / 200 | 1 | 200 | 200 | 200 | 0.127140 |
| theme refresh | 1 / 1 | 1 | 1,000 | 0 | 1 | 0.001691 |
### 9.3 返回“修改 run 数”会诱导错误优化
run 数受无关属性边界影响,既不是修改字符数,也不是修改业务对象数。如果公开
返回这个值,用户可能把它当成稳定统计或用于控制流。
建议:
- 公开返回 `changed-p` 或结果对象;
- debug/benchmark API 可返回 `runs-visited/runs-written/chars-covered`
- 不把实现碎片结构暴露成业务保证。
### 9.4 `dash` 依赖可以作为低优先级简化项
当前 `dash` 主要用于少量 list 操作,很多可以由 `seq`/`cl-lib` 表达。移除依赖
可能降低安装摩擦,但不是当前语义问题的根因。
只有在:
- 能减少公开安装复杂度;
- 不引入自制 helper 堆栈;
- 完整测试仍通过;
时才值得处理。优先级应低于 correctness 和 API contract。
---
## 10. 测试策略升级
### 10.1 第一层:为已确认问题补红色回归
必须先加入:
1. `tp-text` 初次应用保留多个内嵌 property interval
2. 字符串与缓冲区各一组;
3. 显式 nil 覆盖内嵌非 nil
4. 静态层重定义触发 managed region 刷新;
5. 已触发刷新时删除旧层属性键;
6. `NOERROR` 只吞 unresolved layer不吞 layer body error
7. 字符串和缓冲区子属性删除得到相同 presence
8. 精确搜索 nil 与 wildcard 是两种调用;
9. hidden managed range 上的外部直接编辑不会静默丢失;
10. transform/compute 与 watcher 分别遵守其错误边界;
11. search-map docstring 与实际长度规则一致。
### 10.2 第二层:原生等价性测试
对 façade 中承诺等价的操作,使用同一随机输入分别执行:
```text
native primitive
vs.
tp canonical API
```
比较:
- 文本内容;
- `equal-including-properties`
- 每个 change boundary
- property presence 与 value
- buffer modified
- undo 后内容和属性;
- point/marker
- 错误类型。
建议覆盖:
- 空区间、单字符、对象末尾;
- 相邻同值/不同值 interval
- nil 值与缺失;
- 多种 Lisp 值;
- narrowing
- read-only
- sticky boundaries
- category/default/alias
- indirect buffer。
### 10.3 第三层:状态机测试 managed layers
随机生成操作序列:
```text
push → put → hide → redefine → show → move → external edit
→ reactive update → delete → flatten
```
每一步验证不变量:
- 每个 stack entry 身份唯一/顺序正确;
- hidden entry 不泄漏到 rendered plist
- show 后得到最新定义;
- 删除层不会删除其他来源属性;
- encode/decode round-trip
- 同一操作重复执行幂等;
- 不相关区域不变化。
### 10.4 第四层:性能回归
性能基准不应只给一次绝对耗时。建议固定:
- Emacs 主版本;
- 文本长度;
- property run 数;
- 层数;
- buffer 数;
- 响应式依赖 fan-out
- 重复次数和 GC 策略。
记录斜率和阈值,例如:
- 10 倍 run 数不应出现 100 倍增长;
- 更新已知单 buffer 层时不得扫描全部 buffer
- 无变化刷新不得写入属性或改变 modified flag。
### 10.5 交互与视觉验证
为交互命令增加少量 `call-interactively` 路径,验证 prefix、region、current buffer
和错误消息。palette/display/theme 相关能力应增加 GUI Emacs 的可重复截图或状态
检查,尤其是主题切换后已应用层是否刷新。
---
## 11. 分阶段实施路线
### 阶段 0冻结目标语义不新增大功能
产出一份短小、可测试的 `API-SEMANTICS.md`,至少规定:
- 对象和坐标;
- 字符串复制/原地修改;
- buffer modified/undo/read-only
- presence 与 nil
- wildcard sentinel
- 返回结构;
- 直接模板展开 vs managed mount
- 错误传播;
- tp-text replacement
- 外部属性与层栈冲突。
停止条件:每个公开核心函数都能映射到该规范中的一条操作语义。
### 阶段 1修复 P0/P1 正确性问题
顺序:
1. 为 TP-A01A06 和 TP-A10 写失败测试;
2. 合并 `tp-text` 初次/更新的 per-interval 属性算法;
3. 全库用 presence-aware 判断替换真值判断;
4. 引入 internal wildcard sentinel
5. 层 entry 使用完整替换;
6. 收窄 `NOERROR`
7. 落实 managed range 外部编辑的唯一默认冲突策略;
8. 区分业务计算失败与 observer 失败;
9. 同步 docstring、README 和 doctest。
停止条件:回归测试全绿,现有 604 ERT/92 doctest 不退化。
### 阶段 2建立 canonical façade
状态:已完成(内部模型)。公开入口和历史返回值保持兼容。
不要先追求更多便利重载。先保证内部存在唯一规范路径:
```text
request
= object
+ native range
+ operation
+ properties/value/predicate
+ mutation policy
+ error/read-only policy
```
现有 `tp-set/reset/add/...` 可以继续作为外观入口,但都解析到同一规范路径。
停止条件:
- [x] 字符串与缓冲区的等价场景只在 I/O 边界分支;
- [x] 查询内部结果结构不再随对象类型改变;公开返回保持历史兼容;
- [x] 内部 canonical range 使用原生坐标;`tp-intervals` / `tp-intervals-map` 的缓冲区 relative 默认是保留的公开兼容例外;
- [x] “是否原地修改”可从公开调用清楚判断。
### 阶段 3补齐原生文本属性语义
状态已完成text-only。overlay-aware 字符属性查询见阶段 5。
按价值从高到低:
1. [x] presence-aware exact nil
2. [x] next/previous single/all property change
3. [x] `text-property-any` / `not-all` / predicate search 对齐;
4. [x] direct vs effective lookup
5. [x] category/default/alias 有效值委托成为明确且经过测试的 lookup 契约;
6. [x] read-only、modified、undo 和 silent modification 的三种有效策略;
7. [x] stickiness 与 insert 行为直接委托 Emacs无 tp wrapper
8. [x] copy/insert/kill/yank 属性保留与过滤直接委托 Emacs无 tp wrapper
9. [x] narrowing 与 indirect buffer 坐标/共享文本行为通过原生等价性测试。
停止条件:与选定 GNU Emacs 基线的原生等价性测试通过。
### 阶段 4完善 managed layer 生命周期
状态:已完成。
建议扩展:
- [x] 显式 managed layer attach/detach
- [x] 层实例参数和版本;
- [x] old/new entry 全量替换与参数化 args refresh
- [x] 已选外部编辑冲突策略在 push/hide/show/merge 全路径保持一致;
- [x] 单 managed layer 使用 `tp-layers` 保存 authoritative `tp-meta`direct rendered/public stack query 不暴露 `tp-meta`
- [x] 插入带层字符串后的显式 attach
- [x] theme generation 与 enable/disable conservative refresh diagnostics
- [x] registry 诊断与泄漏检查入口;
- [x] layer transaction 的结构化成功/失败和 rollback 报告;
- [ ] compositional layer 作为可选扩展,未纳入当前完成条件。
停止条件:随机操作序列下 stack/render/registry 不变量持续成立。
### 阶段 5可选的字符属性兼容层
状态已完成lookup。overlay lifecycle 明确排除。
若产品确实要使用“所有字符属性操作”定位,再增加:
- [x] text-only 与 overlay-aware lookup 模式;
- [x] source-aware resultoverlay 获胜时报告 `:overlay` 与 overlay identity
- [x] overlay priority/change boundary 委托 Emacs `get-char-property-and-overlay`
- [x] fields、buttons、display 等特殊属性行为委托 Emacs不提供高层 wrapper。
不建议把 overlay 的创建/移动/删除硬塞进现有文本属性函数;应保持清晰边界。
停止条件:文档明确哪些语义由 tp 提供,哪些委托 Emacs哪些刻意排除。
---
## 12. 可拓展方向
以下扩展建立在前述 correctness 与 contract 完成之后。
### 12.1 语义查询层
可提供:
- direct/effective/source-aware property query
- overlay-aware 可选模式;
- 区域一致性判断,例如“整个区域是否同值”;
- run-aware layer snapshot
- 属性来源解释器:某个最终 face 来自哪个层/category/overlay。
这会比再增加几个重载 setter 更能支持调试和复杂 UI。
### 12.2 compositional layer
在现有 exclusive stack 之外,可增加按属性键独立合成的模式:
```text
top layer: face
middle layer: help-echo
bottom layer: keymap
```
最终三个键都可生效,同键才按优先级覆盖。它适合:
- 语法 + 诊断 + 交互提示并存;
- hover/click/face 分属不同业务来源;
- 响应式状态只更新自己拥有的键。
必须通过显式 layer mode 启用,以免改变现有语义。
### 12.3 transaction 与冲突检测
对复杂 UI允许在一次事务中
- 读取当前 stack version
- 修改多个层;
- 一次重建;
- 收集 changed ranges
- 失败时不留下半更新状态。
冲突检测可发现原生外部修改,避免下次 stack operation 静默覆盖。
### 12.4 增量索引和诊断
暴露只读诊断:
- 某层登记了哪些 buffer
- 某 buffer 有哪些 managed layer
- 某层依赖哪些变量;
- 最近一次更新访问/写入多少 run
- 未追踪但扫描发现了哪些层;
- 匿名层为何仍存活。
这能把响应式问题从“猜”变为可观察。
### 12.5 theme-aware 生命周期
调色板当前在解析颜色时读取 frame 的主题类型
([`tp-parse-color`](../tp-palette.el#L256-L302)),但静态已应用结果不会自然随
主题变化。
可以引入:
- theme generation/version
- enable/disable-theme hook 后批量失效;
- 只重渲染真正依赖 palette 的层;
- theme change 的视觉回归。
### 12.6 与 font-lock/jit-lock 的协作
对于大缓冲区,主动给整段文本写 face 不一定是最佳方案。可探索:
- 层定义生成 font-lock rule
- jit-lock 按可见范围物化;
- tp 管理来源身份和规则Emacs 管理惰性字体化;
- 明确 `face``font-lock-face` 的优先级和清理策略。
这适合语法、诊断等长文档场景,但需要单独的小型验证,不应直接扩展全库。
### 12.7 可选核心加载
`require 'tp` 当前加载 palette/builtins。未来若安装时间或最小依赖成为真实问题
可以提供:
- 核心 façade
- managed layers/reactive
- palette/builtins
三个明确 feature 入口。只有在有启动性能或依赖数据支持时再做,不为拆分而拆分。
---
## 13. 推荐的产品定位与文案
### 当前版本推荐
> tp 为 GNU Emacs 字符串和缓冲区提供高层文本属性操作 API并在原生属性之上
> 增加命名层栈、响应式属性、批量匹配和调色板能力。它覆盖常见属性工作流,但
> category/default/alias、stickiness、undo、yank、special properties 和
> overlays 的底层行为仍由 Emacs 执行,其中部分尚未成为 tp 的显式 API 契约。
### 达到阶段 3 后可使用
> tp 是 GNU Emacs 文本属性操作的完整高层 façade常见原生读取、写入、搜索、
> 复制和编辑语义均有明确映射,同时可选择受管理层栈和响应式扩展。
### 不建议使用
> tp 完全替代 Emacs 的所有文本属性和字符属性系统。
除非 overlay-aware、category/default/alias、stickiness、undo/yank 等都有明确
覆盖矩阵和等价性测试,否则这句话无法被验证。
---
## 14. “可以宣称完整替代”的验收清单
只有同时满足以下条件,才建议宣称“完整文本属性操作替代层”:
### 核心契约
- [x] 字符串/缓冲区的修改性在公开入口上可预测;
- [x] 默认坐标是对象原生坐标(`tp-intervals` / `tp-intervals-map` 的 relative 默认为公开兼容例外);
- [x] 所有 text-only 查询区分缺失与显式 nil
- [x] wildcard 与 VALUE=nil 不复用同一个参数状态;
- [x] 内部查询结果结构不随对象类型改变;部分 public returns 保持历史兼容;
- [x] Stage 1 错误传播与 NOERROR 范围有规范;
- [x] read-only/modified/undo 的三种 mutation policy 组合有测试保证。
### 原生覆盖
- [x] put/add/set/remove/remove-list/clear 有明确映射;
- [x] text-properties-at/get-text-property 有明确映射;
- [x] property change/search 家族有明确映射;
- [x] category/default/alias 有 direct/effective 区分;
- [x] get-char-property / get-char-property-and-overlay 查询有明确映射;
- [x] stickiness 与插入继承有文档和测试;
- [x] copy/substring/insert/kill/yank 有文档和测试;
- [x] special properties 明确委托给 Emacs不被 tp 破坏;
- [x] narrowing/indirect buffer 行为通过测试。
### managed layers
- [x] 直接模板展开和 managed layer 实例名称不同、行为不同;
- [x] 层重定义能删除旧贡献;
- [x] hidden/buried layer 始终保存最新 entry
- [x] 外部直接属性修改不会被静默丢失;
- [x] 插入 propertized string 后的 attach/track 行为明确;
- [x] 参数化 mounted layer args/version refresh 通过测试;
- [x] transaction success/failure rollback 与 diagnostics 通过测试;
- [x] theme generation diagnostics 通过测试。
### 工程证据
- [x] 原生等价性测试覆盖选定 Emacs 版本;
- [x] 性能基准覆盖大文本、碎片 interval、深层栈和 fan-out结果为 advisory baseline不是发布阈值
- [x] 中英文 README、docstring、doctest 来自同一语义规范;
- [ ] CI 全绿且 warning 为零。
---
## 15. 最终优先级
### 立即处理
1. TP-A01`tp-text` 初次多 interval 属性扩散;
2. TP-A10阻止 managed hidden range 静默丢弃外部直接属性;
3. TP-A02静态层重定义与层刷新 old/new 所有权;
4. TP-A03/A04/A05presence、nil、wildcard、删除状态统一
5. TP-A06收窄 `NOERROR`
6. TP-A11收敛业务计算与 observer 错误边界;
7. 修复已确认 docstring/实现矛盾。
### 下一版本的设计重点
1. CI/WERROR/full-suite release evidence 汇总;
2. 可选 compositional layer 的小型验证;
3. theme refresh 的真实依赖定向优化;
4. 将 benchmark advisory baseline 转为 CI 阈值;
5. overlay lifecycle 是否继续排除的长期产品边界复审。
### 在语义收敛后再扩展
1. compositional layers
2. transaction/冲突检测;
3. overlay-aware/source-aware 查询;
4. theme-aware 刷新;
5. font-lock/jit-lock 集成;
6. 性能与诊断工具;
7. 可选核心加载。
---
## 16. 验证记录
本次审计在 Emacs 30.2 上完成以下验证:
```text
compile-allWERROR=t 通过
ERT 604/604 通过
doctest 92/92 通过
benchmark 通过fixed seeds=1/7/42/747555generated seed=8675309
shuffled ERT 604/604 通过seed=747555
```
测试使用仓库 Makefile 和单独加载的 dash 2.20.0。所有动态问题复现都在同一代码
快照 `a65d799` 上执行。
本报告的结论边界是:
- “已有测试全绿”是已确认事实;
- TP-A01A06、TP-A10 和 TP-A11 已有回归测试覆盖;
- 性能部分记录 advisory baseline没有把单机耗时声明为发布阈值
- overlay 是否进入产品范围是定位决策,但 overlay-aware 查询是任何“字符属性
完整替代”声明不可回避的边界。
## 17. 官方参考
- [GNU Emacs Lisp Reference Manual: Text Properties](https://www.gnu.org/software/emacs/manual/html_node/elisp/Text-Properties.html)
- [Examining Text Properties](https://www.gnu.org/software/emacs/manual/html_node/elisp/Examining-Properties.html)
- [Changing Text Properties](https://www.gnu.org/software/emacs/manual/html_node/elisp/Changing-Properties.html)
- [Text Property Search Functions](https://www.gnu.org/software/emacs/manual/html_node/elisp/Property-Search.html)
- [Why Text Properties are not Intervals](https://www.gnu.org/software/emacs/manual/html_node/elisp/Not-Intervals.html)
- [Stickiness of Text Properties](https://www.gnu.org/software/emacs/manual/html_node/elisp/Sticky-Properties.html)
- [Properties with Special Meanings](https://www.gnu.org/software/emacs/manual/html_node/elisp/Special-Properties.html)
- [Fields](https://www.gnu.org/software/emacs/manual/html_node/elisp/Fields.html)
- [Yanking](https://www.gnu.org/software/emacs/manual/html_node/elisp/Yanking.html)
- [Insertion](https://www.gnu.org/software/emacs/manual/html_node/elisp/Insertion.html)
- [Narrowing](https://www.gnu.org/software/emacs/manual/html_node/elisp/Narrowing.html)
- [Indirect Buffers](https://www.gnu.org/software/emacs/manual/html_node/elisp/Indirect-Buffers.html)
- [Overlay Properties](https://www.gnu.org/software/emacs/manual/html_node/elisp/Overlay-Properties.html)
## 18. 最小动态复现
以下表达式均在仓库根目录、Emacs 30.2、dash 2.20.0、快照 `a65d799` 上复核。
通用命令为:
```sh
EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
DASH=/path/to/dash-2.20.0
"$EMACS" -Q --batch -L . -L "$DASH" -l tp.el --eval '<EXPRESSION>'
```
将下面每个完整表达式作为 `--eval` 的参数即可。使用其他受支持 Emacs 版本时,
应同时记录版本和输出。
### 18.1 TP-A01初次 `tp-text` 多 interval 污染
```elisp
(let* ((payload
(concat
(propertize "AB" (quote face) (quote bold))
(propertize "CD" (quote face) (quote italic))))
(result (tp-set "xxxx" (quote tp-text) payload)))
(princ
(format "string: p0=%S p2=%S\n"
(get-text-property 0 (quote face) result)
(get-text-property 2 (quote face) result)))
(with-temp-buffer
(insert "xxxx")
(tp-set 1 5 (list (quote tp-text) payload))
(princ
(format "buffer: p1=%S p3=%S\n"
(get-text-property 1 (quote face))
(get-text-property 3 (quote face))))))
```
实际:
```text
string: p0=bold p2=(bold italic)
buffer: p1=bold p3=bold
```
预期后半段只为 `italic`
### 18.2 TP-A02-A静态层重定义不刷新
```elisp
(progn
(tp-layer-reset)
(define-tp audit-static ()
(quote (face bold help-echo "old")))
(with-temp-buffer
(insert "abc")
(tp-push-layer 1 4 (quote audit-static))
(define-tp audit-static ()
(quote (face italic help-echo "new")))
(princ
(format "face=%S help=%S stack=%S\n"
(get-text-property 1 (quote face))
(get-text-property 1 (quote help-echo))
(tp-layer-stack-at 1)))))
```
实际:
```text
face=bold help="old"
stack=((audit-static face bold help-echo "old"))
```
### 18.3 TP-A02-B已触发 refresh 仍残留旧键
```elisp
(progn
(tp-layer-reset)
(defvar audit-fg "red")
(defvar audit-bg "blue")
(define-tp audit-reactive ()
:props (quote
(face (:foreground $audit-fg)
help-echo "old")))
(with-temp-buffer
(insert "abc")
(tp-push-layer 1 4 (quote audit-reactive))
(define-tp audit-reactive ()
:props (quote (face (:background $audit-bg))))
(princ
(format "face=%S help=%S stack=%S\n"
(get-text-property 1 (quote face))
(get-text-property 1 (quote help-echo))
(tp-layer-stack-at 1)))))
```
实际:
```text
face=(:background "blue") help="old"
stack=((audit-reactive face (:background "blue") help-echo "old"))
```
预期 stack 和直接属性中都不再存在 `help-echo`
### 18.4 TP-A03显式 nil 被内嵌值覆盖
```elisp
(let* ((payload
(propertize "X" (quote custom) (quote embedded)))
(result
(tp-set "x"
(quote tp-text) payload
(quote custom) nil)))
(princ
(format "member=%S props=%S\n"
(tp-member 0 (quote custom) result)
(text-properties-at 0 result))))
```
实际:
```text
member=(custom embedded)
props=(custom embedded tp-text #("X" 0 1 (custom embedded)))
```
预期 `member=(custom nil)`
### 18.5 TP-A04nil 搜索在字符串与缓冲区中不同
```elisp
(let ((s (copy-sequence "abc")))
(put-text-property 0 1 (quote p) (quote x) s)
(put-text-property 2 3 (quote p) nil s)
(princ
(format "string=%S\n"
(tp-forward (quote p) nil s 1)))
(with-temp-buffer
(insert "abc")
(put-text-property 1 2 (quote p) (quote x))
(put-text-property 3 4 (quote p) nil)
(goto-char 1)
(let ((match
(tp-forward (quote p) nil (current-buffer) 1)))
(princ
(format
"buffer=%S\n"
(and match
(list (prop-match-beginning match)
(prop-match-end match)
(prop-match-value match))))))))
```
实际:
```text
string=((0 1 x))
buffer=(2 4 nil)
```
字符串路径把 nil 当作 wildcard缓冲区路径按 nil 值搜索,并把“缺失”和
“显式 nil”的相邻范围合并为一个有效值为 nil 的 match。
### 18.6 TP-A05子属性删除后的 presence 不同
```elisp
(let* ((s
(propertize "x"
(quote face)
(quote (:underline t))))
(result
(tp-remove s (quote face) :underline)))
(princ
(format "string=%S props=%S\n"
(tp-member 0 (quote face) result)
(text-properties-at 0 result)))
(with-temp-buffer
(insert
(propertize "x"
(quote face)
(quote (:underline t))))
(tp-remove 1 2 (quote (face :underline)))
(princ
(format "buffer=%S props=%S\n"
(tp-member 1 (quote face))
(text-properties-at 1)))))
```
实际:
```text
string=(face nil) props=(face nil)
buffer=nil props=nil
```
### 18.7 TP-A06`NOERROR` 吞掉层 body 错误
```elisp
(progn
(define-tp audit-boom (x)
(error "audit boom %S" x))
(let ((s (copy-sequence "x")))
(princ
(format "result=%S props=%S\n"
(tp-push-layer
s (quote (audit-boom 1)) t)
(text-properties-at 0 s)))))
```
实际:
```text
result=nil props=nil
```
关闭 `NOERROR` 时同一层 body 会正常抛出 `audit boom 1`
### 18.8 TP-A10外部直接属性被后续栈操作丢弃
```elisp
(progn
(tp-layer-reset)
(define-tp audit-hidden ()
(quote (face bold)))
(let ((s (copy-sequence "x")))
(tp-push-layer s (quote audit-hidden))
(tp-hide-layer s (quote audit-hidden))
(put-text-property
0 1 (quote help-echo) "external" s)
(princ
(format "before=%S\n"
(text-properties-at 0 s)))
(tp-show-layer s (quote audit-hidden))
(princ
(format "after=%S\n"
(text-properties-at 0 s)))))
```
实际:
```text
before=(help-echo "external"
tp-layers ((tp-hidden t face bold tp-name audit-hidden)))
after=(face bold tp-name audit-hidden)
```
`help-echo``tp-show-layer` 后无提示消失。