跳转至

变更分级与颗粒度政策

这份文档回答一个问题:改动多大算大,什么时候必须先出设计再动手。 它来自 2026-10-11 的一次复盘(数据见 §1),目的是把"请你小心"变成可执行的流程。

1. 复盘数据(可复现,命令在 §6)

指标 实测
提交总数 163
触及结构核心的提交 67(41%):其中"大改"(≥3 个结构文件)15 个,仅碰 1 个结构文件的局部改动 36 个
按天的"结构提交 / 其中修复" 10-08:15 / 2;10-09:30 / 10;10-10:15 / 7;10-11:7 / 6
账本 schema 破坏性迁移(DROP/改列/改类型) 0 次(8 个版本全是加法演进)
对外契约漂移(自 0.2.0 基线) 见下表——全部为零
门面 __all__ 0.2.0 起一直 33 项(零变化)
被推翻重做的设计 0 个(返工都发生在落地细节)

1.1 对外契约自 0.2.0 起零漂移(AST 实测,方法见 §6)

契约面 v0.2.0 HEAD(0.3.5) 漂移
门面 __all__(src/myastro/__init__.py) 33 项 33 项 0
facade 函数(src/myastro/facade.py) 27 个 27 个 0 增 / 0 删 / 0 签名变更
CLI 子命令(src/myastro/cli.py) 24 个 24 个 0
EXIT_CODES 映射 4 项 4 项 0
账本 schema 迁移 v1–v8 v1–v8 0(v1–v8 全部在 0.2.0 之前定型)

也就是说:那 67 个"结构提交"全部发生在内部实现、守卫、测试、CI 与文档, 公开发布的契约面一次都没动过。回归风险集中在内部实现,而不在契约设计—— 这正好是本政策要限制的地方。

订正记录:本文档初版写的"门面 16 → 37 项(+21)"是用正则数 facade.py 里的引号字符串 得出的错数——__all__ 其实定义在 src/myastro/__init__.py,字符串计数与真实 API 无关。 这正是第 2 步要做契约快照守卫的现实理由:靠人(靠我)手工统计会错。

结论:框架没有被破坏,但结构改动的缺陷率偏高且集中在最近——10-11 的结构提交里 6/7 是在修补。问题不在设计,在落地方式。

2. 已知失效模式(9 起事故归类,不回避)

类别 事故 次数
机械批量改写 jobs.py 拆分(丢 @dataclass、相对导入深度错、吞 ImportError);DB-close 机械替换一次破坏 6 个文件;atomic_write(...to_parquet...) → write_parquet 替换误改 src/myastro/io/index.py 的 import 4
跨切面并发语义 迁移并发把账本弄成永久打不开;PRAGMA journal_mode=WAL 不走 busy handler;非主线程里 pyarrow 段错误 3
配置/文案细节 CI 步骤名里的冒号让整个工作流解析失败;doctor 提示的 extra 名错两次 2

第一失效模式是批量改写,第二是锁/事务/生命周期这类跨切面语义。 这两类正是本政策要限制的对象。

3. 三级分类(决定流程,不决定心情)

级别 判定标准 颗粒度上限 流程
L1 局部 不改任何对外契约:新增数据源、单文件 bug 修复、文档、提示文案、测试 ≤3 文件、≤150 行 diff 直接做,跑门(pytest + ruff + pyflakes + 生成物)
L2 契约 改对外可观察的东西:门面 __all__/签名、FieldSpec 与描述符、CLI 命令与退出码、账本 schema、任务/URL 注册表、错误分类 ≤6 文件,且只改一个契约点 先出草案(影响面 + 兼容/迁移说明)→ 批准 → 做;必须更新 docs/zh/dev/contract-changelog.md
L3 结构 改层次/生命周期/并发/存储:作业生命周期、锁与原子性、层边界、TUI 渲染核心、文件布局 1 个子系统 + 1 条不变量 先出设计(含不变量清单与反例测试计划)→ 批准 → 拆成三个提交:守卫/测试 → 实现 → 迁移;每版最多 1 项 L3,之后冻结一版专注验证

文件预算怎么算(2026-10-11 细化):生成物与簿记不计入上限。具体指 - 生成物:docs/contract-snapshot.json、docs/zh/dev/test-map.md、docs/zh/dev/design-io.md(含快照与矩阵); - 簿记:CHANGELOG.md、docs/zh/dev/contract-changelog.md; - 按新契约被动更新的既有守卫/既有测试(不是新增决策面)。

理由与依据:0.4.0 批次 1a 实际动了 9 个文件,但真正的"决策面"只有 descriptors.py / tasks.py / facade.py / __init__.py(公开 API 本身跨两个文件) + 新增测试;其余 3 个是生成物/簿记,1 个是被动更新的守卫。用文件数卡这类改动, 只会逼人把一次契约变更拆成"改了但没刷新生成物"的中间态(那更危险)。 决策面本身仍受上限约束:L2 ≤6 个决策面文件、只改一个契约点。

判定有歧义时就高不就低(拿不准算 L2)。

4. 四条硬约束(针对已证实的失效模式)

  1. 一个提交一个意图;机械改写与语义改动禁止混在同一提交。机械改写单独提交,并附等价性证据。
  2. 批量/机械改写必须:AST 级等价性证明(或等价的机械校验)+ 逐点核对。参考 tests/kernel/test_db.py 里"结构等价性"的做法。
  3. 跨切面语义改动(锁/事务/并发/生命周期)必须配齐:能变红的回归测试(变异验证)+ 至少两种交错/并发场景 + 明确写出残留边界(例如"10s busy timeout 之外的超大账本会拿不到锁")。
  4. 改用户环境行为的改动(默认路径、外部依赖、密钥)必须在 CHANGELOG 顶部给一行"你需要做什么"。

5. 契约变更的记录方式

  • 任何 L2 改动,都要在 docs/zh/dev/contract-changelog.md 追加一行:日期 / 版本 / 契约点 / 变更 / 理由 / 兼容性 / 受影响的守卫。
  • 第 2 步会加入契约快照守卫:把门面 __all__+签名、描述符注册表、CLI 命令+退出码、账本 schema 落成生成物快照,代码与快照不一致即守卫红——强制每次契约变化都被显式记录,而不是靠自觉。
  • 快照守卫已落地(2026-10-11):src/myastro/contract.py 生成 docs/contract-snapshot.json,tests/guards/test_contract_snapshot.py 逐字节比对; 差异定位到叶子路径(例如 cli.commands.test-map: 变化、 ledger_schema.user_version: 变化 旧 8 新 9),并断言快照是生成器的逐字节结果 (手工编辑即红)。三种真实变异已验证守卫能红(facade 加参数 / CLI 选项改名 / 加迁移版本)。
  • 所以 §5 的"人工维护"已升级为由守卫强制:忘记重新生成快照、或改了契约不登记, pytest 直接红。

6. 怎么复现 §1 的数据

git log --oneline | wc -l
# 结构相关提交(结构文件清单见脚本里的 STRUCT 正则)
git log --pretty=%h --name-only | grep -E 'src/myastro/(io/db\.py|io/jobs/|facade\.py|descriptors\.py|cli\.py|io/core\.py|io/top/|io/testmap\.py|io/config\.py|tasks\.py)'
# schema 是否做过破坏性迁移
git log -S "DROP COLUMN" -- src/myastro/io/db.py
# 契约面漂移(AST 解析,别用正则数引号字符串——踩过)
python - <<'PY'
import ast, subprocess
def exports(rev):
    src = subprocess.run(["git","show",f"{rev}:src/myastro/__init__.py"],
                         capture_output=True, text=True).stdout
    for n in ast.walk(ast.parse(src)):
        if isinstance(n, ast.Assign) and any(getattr(t,"id","")=="__all__" for t in n.targets):
            return [e.value for e in n.value.elts]
print(len(exports("v0.2.0") or []), len(exports("HEAD") or []))
PY

7. 对协作的明确请求

如果你看到我在没有先出设计的情况下改动 src/myastro/io/db.py、src/myastro/io/jobs/、 src/myastro/facade.py、src/myastro/descriptors.py(即 L3 范围),直接叫停。 L1 我照常直接做;L2 我会先给草案。