变更分级与颗粒度政策¶
这份文档回答一个问题:改动多大算大,什么时候必须先出设计再动手。 它来自 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. 四条硬约束(针对已证实的失效模式)¶
- 一个提交一个意图;机械改写与语义改动禁止混在同一提交。机械改写单独提交,并附等价性证据。
- 批量/机械改写必须:AST 级等价性证明(或等价的机械校验)+ 逐点核对。参考
tests/kernel/test_db.py里"结构等价性"的做法。 - 跨切面语义改动(锁/事务/并发/生命周期)必须配齐:能变红的回归测试(变异验证)+ 至少两种交错/并发场景 + 明确写出残留边界(例如"10s busy timeout 之外的超大账本会拿不到锁")。
- 改用户环境行为的改动(默认路径、外部依赖、密钥)必须在 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 我会先给草案。