# tp 仓库系统审计与“文本属性操作替代层”能力评估 > 审计日期:2026-07-28 > 审计快照:`a65d799`(tp 0.3.0) > 结论置信度:高 > 范围:公开 API 语义、核心实现、层栈与响应式设计、Emacs 原生语义覆盖、 > 测试与 CI、性能风险、文档一致性,以及后续扩展路线。 > 实施状态(2026-07-28):阶段 0 语义已冻结在 > [API-SEMANTICS.md](API-SEMANTICS.md);阶段 1 的 TP-A01~A06、TP-A10 与 > TP-A11 已按本文路线实现回归测试和修复;阶段 2 canonical façade 已完成为 > 内部记录/数据流模型,公开返回保持兼容;阶段 3 text-only 原生语义 façade > 已完成;阶段 4 managed lifecycle 已完成;阶段 5 overlay-aware 查询已完成, > overlay lifecycle 明确排除。下文的问题描述保留为审计快照证据,不代表 > 修复后工作树的现状。 > 当前验收结论:按路线编号共有阶段 0~5(6 个阶段;若只统计开发阶段则为 > 1~5 共 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 明确排除** | 合理的近期目标是先完整达到 B;C 应当被设计成显式兼容层,而不是重新实现 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`](../tests/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-A04:VALUE=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` 路径; - 调色板/主题切换后的动态视觉验证。 `tests/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-A01~A06 和 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 result,overlay 获胜时报告 `: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/A05:presence、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-all(WERROR=t) 通过 ERT 604/604 通过 doctest 92/92 通过 benchmark 通过,fixed seeds=1/7/42/747555,generated seed=8675309 shuffled ERT 604/604 通过,seed=747555 ``` 测试使用仓库 Makefile 和单独加载的 dash 2.20.0。所有动态问题复现都在同一代码 快照 `a65d799` 上执行。 本报告的结论边界是: - “已有测试全绿”是已确认事实; - TP-A01~A06、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 '' ``` 将下面每个完整表达式作为 `--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-A04:nil 搜索在字符串与缓冲区中不同 ```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` 后无提示消失。