结构批:拆 __init__.py + 抽 runtime_state + StoragePort 抽出 + import-linter(结论:不建子包) #97

Closed
opened 2026-07-29 08:26:25 +00:00 by KumaAgent · 2 comments
Member

来源:2026-07-29 src/ 内部结构实测(AST 全量解析 origin/main @ 649673c,56 文件 / 14579 行)+ 三方案对比 + 两路对抗性复核。本票是第三批

先说结论:不建子包。 这是两路复核独立收敛的结果,本票只做「切内容」,一个目录都不建。

为什么不建子包(已拍板,别在实现期重开)

证据 数据
模块间无自然簇 标签传播跑出 53+3;摘掉七个最大模块后,剩 49 个里仍有 33 个粘成一坨
照 design.md 七层切会成环 9 个桶塌成一个 SCC,最小反向边集 26/170 跨层边
迁移代价是真金白银 298 行测试 import 改写,横跨 108/110 个测试文件
没有实证缺口 本仓历史上零条 review 意见提过文件组织问题
目录的约束力有更便宜的替代 红队实测把 import-linter 跑在平铺布局上,复现出分包方案列的全部 4 条违规

「平铺不好找」这个前提本身从没被公平测过——.codegraph/codegraph.db 停在 2026-07-02、落后 27 天,CLI 在本机崩。(该索引由维护者另行处理,不属本票。)

任务一:拆 __init__.py(2274 行 → 用 entry_*.py 前缀,零新目录)

它是本包唯一能在本仓 review 词汇表上直接落地的结构问题(Fowler Divergent Change):

  • 19 个互不相干的装饰入口;
  • 35 个 helper 里 21 个只被恰好一个装饰入口到达——它们是各自路径的私有实现,只是碰巧同住一个文件;
  • 10 个模块级状态里 7 个只被一个函数碰
  • NoneBot 注册面只占 431 行 ≈ 19%,而框架强制度实测为零(已读 nonebot 插件加载机制,matcher/@scheduler.scheduled_job/@event_postprocessor 都可以放子模块,由 __init__.py import 触发注册)。

簇边界(可直接作为 entry_*.py 的切分依据,行数为实测):

含入口本体
Drive Tick + 跨平台拉取 411 行
反应式 + 群聊追问 + 贴纸学习 322 行
管理命令(7 条 + _admin_gate/_argument_of ~388 行
环境信号 + 实时状态 193 行
Callback 投递 87 行
成本熔断(共享,被 6–8 入口用) 99 行
构造(共享) 126 行
门控与快照(共享) 135 行

两个必须先做的前置(否则会踩坑)

  • 先抽一个约 30 行的 runtime_state.py,收 _pending_flushes(跨 3 簇)、_last_group_chat_activity(跨 2 簇)、_debouncer。不先做的话 entry_drive_tick/entry_callback 会去 import entry_reactive
  • 不做包根 re-export_last_persona_fingerprint标量、被 tests/test_admin_commands.py:62 重绑,re-export 会让那次 monkeypatch 静默失效(改的是包根那个名字,被测代码读的是子模块里的)。直接改那 ~25 行测试 import,让任何遗漏以 ImportError 的形式立刻失败,而不是变成一条假绿测试。

任务二:StoragePort 聚合 Protocol 从 sent_log.py 抽出

sent_log.py 809 行里装着:聚合 StoragePort(继承 24 个切片)+ 651 行 SentLog 内存实现。而 SentLogPersistentStorage 的 public 方法各 71 个、逐字相同(同一接口的两个实现),71 个里只有 7 个跟"已发日志"有关——文件名是 issue #3 的历史沉积。

副作用:ports.py → sent_log.py 是全图唯一一条从装配层「向上」进持久化层的边——host 契约文件为了拿一个 Protocol,拖进了 651 行内存实现。

  • 把聚合 StoragePort 抽到自己的模块,解掉那条边。
  • SentLog 本体留在 src/。把它移进 tests/打包决定不是结构简化,-651 行 是记账不是收益;要做请单独立项。

任务三:import-linter,只要 forbidden 契约

只守一条:core 模块不得 import nonebot。例外:entry_*.py(插件入口)、config.pyget_plugin_config)、storage.pynonebot_plugin_orm)。

拍板理由:

  • 守的是 ADR-0001 明文背书的「core 平台无关」,是真不变量,不是任意排法;
  • 例外表只有 3 条且稳定——实测 55 个模块里 52 个不 import nonebot;
  • 无层序之争(层级契约那套的层序是可以反复争论的排法,本票不采纳);
  • 它正好守住本票任务一引入的新风险:平台耦合代码从 1 个文件散进若干 entry_*.py,没有机械守卫就可能漏进非 entry 模块。

明确不采纳全套层级契约layers + exhaustive):它开张第一天就要给 recency_decay/judge_persona_drift 两个横切原语写 ignore_imports 例外,而 issue #88 刚以「一上线就需要长期维护的例外表才能产出零信号」否掉过同一形状的 CI 依赖审计脚本。forbidden 契约没有这个问题。

  • .importlinter 配置 + dev 依赖 + 一步 CI。
  • 顺带修 visible_host_tools:它定义在 admin.py:78,却被 runtime_loop.py:10subagent.py:32 import——与 admin 无关,且它自己的 docstring 就写着该在工具港。移进 tool_port.py。(这条是层级契约扫出来的 4 条违规里唯一一条即使不上层级契约也该修的。)

明确不动

  • runtime_loop.py 一行不动三份方案罕见地一致:5 个公开方法 / 44 个方法,16 个 handler 共享同一个 _TurnState(ADR-0012 pre-send regime 的真不变量——send_message 本轮只入队不发,同轮 self_recall/edit 要在真发出前拦下改写)。拆它就是把深模块换成浅模块。
    • 它确实有另一个真味道:44 个构造参数里 29 个只被恰好一个方法读(是被提升到构造函数的 config 常量,不是对象状态)。那该用 config dataclass 收拢,与拆不拆类是两个问题,不在本票。
  • storage.py 不拆。Ousterhout 意义上最重要的事(把宽接口切成 26 个窄 Protocol、每片贴着自己的领域)这个仓已经做完了,1274 行按已声明切片归类分得干干净净——所以拆它不会让接口更深一分,只买可导航性,是上述三件事之后才轮得到的第四件。
    • 若将来真要拆,注意陷阱:own_tables()__module__.startswith(f"{__package__}.") 筛表,改成子包后 __package__静默变义导致漏建表;所幸 tests/test_schema_coverage.py 用的是硬编码前缀(独立第二条推导路径)会红。

什么时候才回头考虑目录

只有当 forbidden 契约在实践中被反复用 ignore_imports 打补丁,或者出现「最近 N 个 PR 触及的簇数中位数 ≥ 3」这类实测信号(说明改动天然横切、目录也集中不了 diff),才值得重开。

单独的「55 个文件看着乱」不算——Fowler 那张表上没有对应条目,Speculative Generality 倒是有。

> 来源:2026-07-29 `src/` 内部结构实测(AST 全量解析 `origin/main` @ 649673c,56 文件 / 14579 行)+ 三方案对比 + 两路对抗性复核。本票是**第三批**。 > > **先说结论:不建子包。** 这是两路复核**独立收敛**的结果,本票只做「切内容」,一个目录都不建。 ## 为什么不建子包(已拍板,别在实现期重开) | 证据 | 数据 | |---|---| | 模块间**无自然簇** | 标签传播跑出 53+3;摘掉七个最大模块后,剩 49 个里仍有 **33 个粘成一坨** | | 照 design.md 七层切**会成环** | 9 个桶塌成一个 SCC,最小反向边集 **26/170** 跨层边 | | 迁移代价是真金白银 | **298 行测试 import 改写,横跨 108/110 个测试文件** | | 没有实证缺口 | 本仓历史上**零条 review 意见**提过文件组织问题 | | 目录的约束力有更便宜的替代 | 红队**实测**把 import-linter 跑在平铺布局上,复现出分包方案列的全部 4 条违规 | **「平铺不好找」这个前提本身从没被公平测过**——`.codegraph/codegraph.db` 停在 2026-07-02、落后 27 天,CLI 在本机崩。(该索引由维护者另行处理,不属本票。) ## 任务一:拆 `__init__.py`(2274 行 → 用 `entry_*.py` 前缀,零新目录) **它是本包唯一能在本仓 review 词汇表上直接落地的结构问题**(Fowler `Divergent Change`): - 19 个**互不相干**的装饰入口; - **35 个 helper 里 21 个只被恰好一个装饰入口到达**——它们是各自路径的私有实现,只是碰巧同住一个文件; - 10 个模块级状态里 **7 个只被一个函数碰**; - NoneBot 注册面只占 **431 行 ≈ 19%**,而**框架强制度实测为零**(已读 nonebot 插件加载机制,matcher/`@scheduler.scheduled_job`/`@event_postprocessor` 都可以放子模块,由 `__init__.py` import 触发注册)。 簇边界(可直接作为 `entry_*.py` 的切分依据,行数为实测): | 簇 | 含入口本体 | |---|---| | Drive Tick + 跨平台拉取 | 411 行 | | 反应式 + 群聊追问 + 贴纸学习 | 322 行 | | 管理命令(7 条 + `_admin_gate`/`_argument_of`) | ~388 行 | | 环境信号 + 实时状态 | 193 行 | | Callback 投递 | 87 行 | | 成本熔断(共享,被 6–8 入口用) | 99 行 | | 构造(共享) | 126 行 | | 门控与快照(共享) | 135 行 | ### 两个必须先做的前置(否则会踩坑) - [ ] **先抽一个约 30 行的 `runtime_state.py`**,收 `_pending_flushes`(跨 3 簇)、`_last_group_chat_activity`(跨 2 簇)、`_debouncer`。不先做的话 `entry_drive_tick`/`entry_callback` 会去 import `entry_reactive`。 - [ ] **不做包根 re-export**。`_last_persona_fingerprint` 是**标量**、被 `tests/test_admin_commands.py:62` 重绑,re-export 会让那次 monkeypatch **静默失效**(改的是包根那个名字,被测代码读的是子模块里的)。直接改那 ~25 行测试 import,让任何遗漏以 `ImportError` 的形式**立刻失败**,而不是变成一条假绿测试。 ## 任务二:`StoragePort` 聚合 Protocol 从 `sent_log.py` 抽出 `sent_log.py` 809 行里装着:聚合 `StoragePort`(继承 24 个切片)**+ 651 行 `SentLog` 内存实现**。而 `SentLog` 与 `PersistentStorage` 的 public 方法**各 71 个、逐字相同**(同一接口的两个实现),**71 个里只有 7 个**跟"已发日志"有关——文件名是 issue #3 的历史沉积。 副作用:`ports.py → sent_log.py` 是全图**唯一**一条从装配层「向上」进持久化层的边——host 契约文件为了拿一个 Protocol,拖进了 651 行内存实现。 - [ ] 把聚合 `StoragePort` 抽到自己的模块,解掉那条边。 - [ ] **`SentLog` 本体留在 `src/`**。把它移进 `tests/` 是**打包决定**不是结构简化,`-651 行` 是记账不是收益;要做请单独立项。 ## 任务三:import-linter,只要 forbidden 契约 **只守一条**:core 模块不得 import nonebot。例外:`entry_*.py`(插件入口)、`config.py`(`get_plugin_config`)、`storage.py`(`nonebot_plugin_orm`)。 拍板理由: - 守的是 **ADR-0001 明文背书的「core 平台无关」**,是真不变量,不是任意排法; - **例外表只有 3 条且稳定**——实测 55 个模块里 52 个不 import nonebot; - **无层序之争**(层级契约那套的层序是可以反复争论的排法,本票不采纳); - **它正好守住本票任务一引入的新风险**:平台耦合代码从 1 个文件散进若干 `entry_*.py`,没有机械守卫就可能漏进非 entry 模块。 **明确不采纳全套层级契约**(`layers` + `exhaustive`):它开张第一天就要给 `recency_decay`/`judge_persona_drift` 两个横切原语写 `ignore_imports` 例外,而 issue #88 刚以「一上线就需要长期维护的例外表才能产出零信号」否掉过同一形状的 CI 依赖审计脚本。forbidden 契约没有这个问题。 - [ ] `.importlinter` 配置 + dev 依赖 + 一步 CI。 - [ ] 顺带修 `visible_host_tools`:它定义在 `admin.py:78`,却被 `runtime_loop.py:10` 与 `subagent.py:32` import——**与 admin 无关**,且它自己的 docstring 就写着该在工具港。移进 `tool_port.py`。(这条是层级契约扫出来的 4 条违规里唯一一条即使不上层级契约也该修的。) ## 明确不动 - **`runtime_loop.py` 一行不动**。**三份方案罕见地一致**:5 个公开方法 / 44 个方法,16 个 handler 共享同一个 `_TurnState`(ADR-0012 pre-send regime 的真不变量——`send_message` 本轮只入队不发,同轮 `self_recall`/`edit` 要在真发出前拦下改写)。拆它就是把深模块换成浅模块。 - 它确实有另一个真味道:**44 个构造参数里 29 个只被恰好一个方法读**(是被提升到构造函数的 config 常量,不是对象状态)。那该用 config dataclass 收拢,**与拆不拆类是两个问题**,不在本票。 - **`storage.py` 不拆**。Ousterhout 意义上最重要的事(把宽接口切成 26 个窄 Protocol、每片贴着自己的领域)**这个仓已经做完了**,1274 行按已声明切片归类分得干干净净——所以拆它**不会让接口更深一分**,只买可导航性,是上述三件事之后才轮得到的第四件。 - 若将来真要拆,注意陷阱:`own_tables()` 用 `__module__.startswith(f"{__package__}.")` 筛表,改成子包后 `__package__` 会**静默变义**导致漏建表;所幸 `tests/test_schema_coverage.py` 用的是硬编码前缀(独立第二条推导路径)会红。 ## 什么时候才回头考虑目录 只有当 forbidden 契约在实践中被反复用 `ignore_imports` 打补丁,或者出现「最近 N 个 PR 触及的簇数中位数 ≥ 3」这类实测信号(说明改动天然横切、目录也集中不了 diff),才值得重开。 **单独的「55 个文件看着乱」不算**——Fowler 那张表上没有对应条目,`Speculative Generality` 倒是有。
Author
Member

正文补充四处(2026-08-03 grill 结论,均为本票范围内)

一、新增任务:修 ports.py 的错误文案 + 把 host 公开面 re-export 到包根

这是本票的第四个任务,与任务一(拆 __init__.py)碰同一个文件,必须同批做——否则 __init__.py 会被连着重写两次。

现状是错的ports.py:172 的错误信息告诉 host 调 nonebot_plugin_arise.configure(ArisePorts(...)),但 configure 从未被 re-export 到包根__init__.py 里只在一处注释文本中出现过这个词),真实路径是 nonebot_plugin_arise.ports.configuretests/conftest.py 走的就是它)。issue #93 修过这条消息的"字段列表"那半,调用路径那半原样留着——而这是 host 第一次接入时看到的第一条错误信息。

已拍板:re-export 到包根,那条文案随之变成真的。

  • __init__.py 里 re-export configure / get_ports / ArisePorts 三个名字。
  • ports.py:172 的文案随之成立,不需要再改路径(只需确认它与 re-export 后的真实路径一致)。
  • 写进 docs/adr/0001-standalone-core-and-ports.md(追加更新节):host 入口的公开面 = 包根。
    • 依据:ADR-0001 与 CONTEXT.md 从未提到过 configure(已核实,两份文档零命中)——host 入口的公开面从没被设计文档规定过,目前唯一"规定"它的东西就是那条写错了的错误信息。所以这不是推翻既有决策,是填一个从没被决定过的空白;且零 host 已接入,现在定公开面免费。

⚠️ 与本票既有的「不做包根 re-export」不矛盾,但必须分清:那条针对的是私有名_last_persona_fingerprint 是标量、被 tests/test_admin_commands.py:62 重绑,re-export 会让那次 monkeypatch 静默失效)。本条针对的是 host 公开契约的三个名字——包根本来就是干这个的。

拆完之后 __init__.py 的新职责因此是完整定义的:注册触发 + host 公开面 re-export,别的都搬走

二、storage.py 不拆——补上可观测的重评触发条件

正文「明确不动」那节现在写的是「是三件事之后才轮得到的第四件」,这是个没有触发条件的悬空留痕。补一条可观测的:

重评触发:再有一个 bug 被证明是藏在 storage.py 的行数里(即:一个在更小的文件里本会被看见的错误)。storage.py:1763self._qdrant()@property 被当函数调,/diagnose 恒误报向量库 down,11 处用法里唯一一处多了括号,且唯一相关测试用假 storage、这条路从未被执行)已经是第一个。第二个出现时,"只买可导航性"这个理由就不再成立。

三、AC 补一条:conftest.py 的插件加载顺序约束

tests/conftest.py::pytest_configure 有一条载荷约束:NoneBot 必须是第一个 import 本插件的人,否则 load_from_tomlModule ... is not loaded as a plugin

对本票的 entry_*.py 拆分是安全的(collection 发生在 pytest_configure 之后),但三份评估方案都没把它写进 AC,我也漏了。写下来,免得实现时踩了才回头查:

  • 拆分后确认 pytest_configure 的加载顺序约束仍成立;若新增模块在 NoneBot 加载前被 import,会以 Module ... is not loaded as a plugin 的形式失败。

四、TriggerPath 明示不处理(不是遗漏)

红队实测 import-linter 的层级契约时报出 4 条违规,其中两条被点名:visible_host_tools(本票已收,移进 tool_port.py)和 TriggerPath(定义在 decision_snapshot.py,被 cost / explain / snapshot 三个模块消费)。

TriggerPath 本票不处理,理由:我们只上 forbidden 契约、不上层级契约(见正文任务三),而它只在层级契约下才算违规。visible_host_tools 之所以仍要修,是因为它即使不上层级契约也是错位的(自己的 docstring 就写着该在工具港)。

写出来是为了不留白——不是漏了,是判过不动。若将来真上层级契约,这条会重新变成待处理项。

## 正文补充四处(2026-08-03 grill 结论,均为本票范围内) ### 一、新增任务:修 `ports.py` 的错误文案 + 把 host 公开面 re-export 到包根 **这是本票的第四个任务**,与任务一(拆 `__init__.py`)碰同一个文件,必须同批做——否则 `__init__.py` 会被连着重写两次。 **现状是错的**:`ports.py:172` 的错误信息告诉 host 调 `nonebot_plugin_arise.configure(ArisePorts(...))`,但 **`configure` 从未被 re-export 到包根**(`__init__.py` 里只在一处注释文本中出现过这个词),真实路径是 `nonebot_plugin_arise.ports.configure`(`tests/conftest.py` 走的就是它)。issue #93 修过这条消息的"字段列表"那半,**调用路径那半原样留着**——而这是 host 第一次接入时看到的第一条错误信息。 **已拍板:re-export 到包根**,那条文案随之变成真的。 - [ ] `__init__.py` 里 re-export `configure` / `get_ports` / `ArisePorts` 三个名字。 - [ ] `ports.py:172` 的文案随之成立,不需要再改路径(只需确认它与 re-export 后的真实路径一致)。 - [ ] **写进 `docs/adr/0001-standalone-core-and-ports.md`**(追加更新节):host 入口的公开面 = 包根。 - 依据:**ADR-0001 与 CONTEXT.md 从未提到过 `configure`**(已核实,两份文档零命中)——host 入口的公开面**从没被设计文档规定过**,目前唯一"规定"它的东西就是那条写错了的错误信息。所以这不是推翻既有决策,是填一个从没被决定过的空白;且零 host 已接入,现在定公开面免费。 > ⚠️ **与本票既有的「不做包根 re-export」不矛盾,但必须分清**:那条针对的是**私有名**(`_last_persona_fingerprint` 是标量、被 `tests/test_admin_commands.py:62` 重绑,re-export 会让那次 monkeypatch **静默失效**)。本条针对的是 **host 公开契约的三个名字**——包根本来就是干这个的。 > > 拆完之后 `__init__.py` 的新职责因此是完整定义的:**注册触发 + host 公开面 re-export,别的都搬走**。 ### 二、`storage.py` 不拆——补上可观测的重评触发条件 正文「明确不动」那节现在写的是「是三件事之后才轮得到的第四件」,**这是个没有触发条件的悬空留痕**。补一条可观测的: > **重评触发**:再有**一个 bug 被证明是藏在 `storage.py` 的行数里**(即:一个在更小的文件里本会被看见的错误)。`storage.py:1763` 的 `self._qdrant()`(`@property` 被当函数调,`/diagnose` 恒误报向量库 down,11 处用法里唯一一处多了括号,且唯一相关测试用假 storage、这条路从未被执行)**已经是第一个**。第二个出现时,"只买可导航性"这个理由就不再成立。 ### 三、AC 补一条:`conftest.py` 的插件加载顺序约束 `tests/conftest.py::pytest_configure` 有一条载荷约束:**NoneBot 必须是第一个 import 本插件的人**,否则 `load_from_toml` 报 `Module ... is not loaded as a plugin`。 对本票的 `entry_*.py` 拆分**是安全的**(collection 发生在 `pytest_configure` 之后),但**三份评估方案都没把它写进 AC,我也漏了**。写下来,免得实现时踩了才回头查: - [ ] 拆分后确认 `pytest_configure` 的加载顺序约束仍成立;若新增模块在 NoneBot 加载前被 import,会以 `Module ... is not loaded as a plugin` 的形式失败。 ### 四、`TriggerPath` 明示不处理(不是遗漏) 红队实测 import-linter 的**层级契约**时报出 4 条违规,其中两条被点名:`visible_host_tools`(本票已收,移进 `tool_port.py`)和 **`TriggerPath`**(定义在 `decision_snapshot.py`,被 cost / explain / snapshot 三个模块消费)。 **`TriggerPath` 本票不处理**,理由:我们**只上 forbidden 契约、不上层级契约**(见正文任务三),而它只在层级契约下才算违规。`visible_host_tools` 之所以仍要修,是因为它**即使不上层级契约也是错位的**(自己的 docstring 就写着该在工具港)。 写出来是为了不留白——不是漏了,是判过不动。若将来真上层级契约,这条会重新变成待处理项。
Author
Member

措辞更正:正文「明确不动」里的「runtime_loop.py 一行不动」说过头了

原意是不重构 RuntimeLoop 这个类——5 public / 44 methods 的深模块,16 个 handler 共享 _TurnState 的 pre-send 不变量(ADR-0012:send_message 本轮只入队不发,同轮 self_recall/edit 要在真发出前拦下改写),拆它就是把深模块换成浅模块。这一点不变。

但字面「一行不动」与本票另外两项任务直接矛盾

  • visible_host_toolsadmin.py 移进 tool_port.py → 强制改 runtime_loop.py:10 的 import
  • StoragePortsent_log.py 抽出 → 同样强制改 runtime_loop.py 的 import

准确表述runtime_loop.py类结构与方法体一行不动;因其它模块搬家而必须跟随的 import 行照改

判断标准很简单——如果一处改动不是「跟着别人搬家改 import」,那它就超出本票范围了(构造函数的 44 个参数收 config dataclass 属于 #105,排在本票之后)。

## 措辞更正:正文「明确不动」里的「`runtime_loop.py` 一行不动」说过头了 原意是**不重构 `RuntimeLoop` 这个类**——5 public / 44 methods 的深模块,16 个 handler 共享 `_TurnState` 的 pre-send 不变量(ADR-0012:`send_message` 本轮只入队不发,同轮 `self_recall`/`edit` 要在真发出前拦下改写),拆它就是把深模块换成浅模块。**这一点不变。** 但字面「一行不动」与本票另外两项任务**直接矛盾**: - 把 `visible_host_tools` 从 `admin.py` 移进 `tool_port.py` → 强制改 `runtime_loop.py:10` 的 import - 把 `StoragePort` 从 `sent_log.py` 抽出 → 同样强制改 `runtime_loop.py` 的 import **准确表述**:`runtime_loop.py` 的**类结构与方法体一行不动**;因其它模块搬家而必须跟随的 **import 行照改**。 判断标准很简单——**如果一处改动不是「跟着别人搬家改 import」,那它就超出本票范围了**(构造函数的 44 个参数收 config dataclass 属于 #105,排在本票之后)。
Yushu closed this issue 2026-08-11 01:02:34 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
ProjectKuma/arise#97
No description provided.