atlas-project-harness 0.2.0-beta.2 → 0.2.0-beta.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/CHANGELOG.md +267 -0
  2. package/DESIGN.md +46 -3
  3. package/README.md +24 -14
  4. package/_support/__pycache__/ac_coverage.cpython-312.pyc +0 -0
  5. package/_support/__pycache__/case_block.cpython-312.pyc +0 -0
  6. package/_support/__pycache__/layout.cpython-312.pyc +0 -0
  7. package/_support/__pycache__/manual_block.cpython-312.pyc +0 -0
  8. package/_support/__pycache__/stack_profile.cpython-312.pyc +0 -0
  9. package/_support/ac_coverage.py +120 -0
  10. package/_support/case_block.py +3 -3
  11. package/_support/layout.py +411 -0
  12. package/_support/manual_block.py +67 -0
  13. package/_support/stack_profile.py +3 -1
  14. package/adapters/__pycache__/_graph.cpython-312.pyc +0 -0
  15. package/adapters/__pycache__/_jpa.cpython-312.pyc +0 -0
  16. package/adapters/__pycache__/_lib.cpython-312.pyc +0 -0
  17. package/adapters/__pycache__/api.cpython-312.pyc +0 -0
  18. package/adapters/__pycache__/models.cpython-312.pyc +0 -0
  19. package/adapters/__pycache__/tests.cpython-312.pyc +0 -0
  20. package/adapters/_graph.py +7 -2
  21. package/adapters/_jpa.py +291 -0
  22. package/adapters/_lib.py +8 -3
  23. package/adapters/ac/__init__.py +80 -0
  24. package/adapters/ac/__pycache__/__init__.cpython-312.pyc +0 -0
  25. package/adapters/ac/__pycache__/command.cpython-312.pyc +0 -0
  26. package/adapters/ac/__pycache__/js.cpython-312.pyc +0 -0
  27. package/adapters/ac/__pycache__/junit.cpython-312.pyc +0 -0
  28. package/adapters/ac/__pycache__/pytest.cpython-312.pyc +0 -0
  29. package/adapters/ac/command.py +88 -0
  30. package/adapters/ac/js.py +334 -0
  31. package/adapters/ac/junit.py +266 -0
  32. package/adapters/ac/pytest.py +380 -0
  33. package/adapters/api.py +331 -8
  34. package/adapters/models.py +126 -2
  35. package/adapters/routes.py +7 -3
  36. package/adapters/tests.py +43 -5
  37. package/apply/reference.md +6 -3
  38. package/bin/atlas.js +66 -28
  39. package/extensions/pi.ts +39 -8
  40. package/install.sh +179 -19
  41. package/package.json +1 -1
  42. package/patches/project-wiring/README.md +4 -3
  43. package/patches/project-wiring/__pycache__/apply-wiring.cpython-312.pyc +0 -0
  44. package/patches/project-wiring/agent-pointer.md +1 -1
  45. package/patches/project-wiring/apply-wiring.py +146 -153
  46. package/patches/workflow-plan-apply/1.6-plan-apply.insert.md +1 -1
  47. package/rings/e2e/reference.md +37 -20
  48. package/rings/prd/reference.md +26 -8
  49. package/rings/structure/reference.md +3 -2
  50. package/scripts/__pycache__/atlas_check.cpython-312.pyc +0 -0
  51. package/scripts/__pycache__/atlas_report.cpython-312.pyc +0 -0
  52. package/scripts/__pycache__/atlas_verify.cpython-312.pyc +0 -0
  53. package/scripts/__pycache__/closeout_derive.cpython-312.pyc +0 -0
  54. package/scripts/__pycache__/e2e_console.cpython-312.pyc +0 -0
  55. package/scripts/__pycache__/gen_e2e_scripts.cpython-312.pyc +0 -0
  56. package/scripts/__pycache__/gen_testid_stub.cpython-312.pyc +0 -0
  57. package/scripts/__pycache__/make_review_inputs.cpython-312.pyc +0 -0
  58. package/scripts/__pycache__/pkg_digest.cpython-312.pyc +0 -0
  59. package/scripts/__pycache__/refresh_structure.cpython-312.pyc +0 -0
  60. package/scripts/__pycache__/release.cpython-312.pyc +0 -0
  61. package/scripts/__pycache__/write_claim.cpython-312.pyc +0 -0
  62. package/scripts/atlas_check.py +329 -86
  63. package/scripts/atlas_report.py +10 -2
  64. package/scripts/atlas_verify.py +435 -0
  65. package/scripts/closeout_derive.py +296 -0
  66. package/scripts/e2e_console.py +46 -31
  67. package/scripts/gen_e2e_scripts.py +424 -66
  68. package/scripts/gen_testid_stub.py +8 -3
  69. package/scripts/make_review_inputs.py +20 -6
  70. package/scripts/pkg_digest.py +42 -5
  71. package/scripts/refresh_structure.py +29 -8
  72. package/scripts/release.py +238 -7
  73. package/scripts/write_claim.py +204 -0
  74. package/shared/closeout.md +7 -0
  75. package/shared/gates.md +24 -19
  76. package/shared/global-rules.md +8 -1
  77. package/shared/independent-review.md +4 -3
  78. package/shared/layout.md +28 -3
  79. package/shared/single-source.md +2 -0
  80. package/shared/stack-profile.md +19 -10
  81. package/skills/atlas-apply/SKILL.md +1 -0
  82. package/skills/atlas-design-review/SKILL.md +3 -3
  83. package/skills/atlas-e2e/SKILL.md +1 -0
  84. package/skills/atlas-prd/SKILL.md +1 -0
  85. package/skills/atlas-release/SKILL.md +9 -3
  86. package/skills/atlas-structure/SKILL.md +1 -0
  87. package/skills/frontend-design/SKILL.md +2 -2
  88. package/skills/frontend-design/references/tuning-verbs.md +1 -1
  89. package/templates/e2e/case-shard.md +1 -1
  90. package/templates/e2e/console/app.js +25 -4
  91. package/templates/e2e/console/console.css +2 -0
  92. package/templates/e2e/console/index.html +2 -2
  93. package/templates/prd/domain.md +14 -12
  94. package/templates/prd/global.md +2 -2
  95. package/templates/stack-profile.yaml +12 -11
  96. package/tests/__pycache__/test_ac_adapters.cpython-312-pytest-9.1.1.pyc +0 -0
  97. package/tests/__pycache__/test_adapter_api.cpython-312-pytest-9.1.1.pyc +0 -0
  98. package/tests/__pycache__/test_adapter_models.cpython-312-pytest-9.1.1.pyc +0 -0
  99. package/tests/__pycache__/test_apply_wiring.cpython-312-pytest-9.1.1.pyc +0 -0
  100. package/tests/__pycache__/test_atlas_check.cpython-312-pytest-9.1.1.pyc +0 -0
  101. package/tests/__pycache__/test_atlas_verify.cpython-312-pytest-9.1.1.pyc +0 -0
  102. package/tests/__pycache__/test_atlas_verify_playground.cpython-312-pytest-9.1.1.pyc +0 -0
  103. package/tests/__pycache__/test_canonical_consistency.cpython-312-pytest-9.1.1.pyc +0 -0
  104. package/tests/__pycache__/test_closeout_derive.cpython-312-pytest-9.1.1.pyc +0 -0
  105. package/tests/__pycache__/test_duty_bug_families.cpython-312-pytest-9.1.1.pyc +0 -0
  106. package/tests/__pycache__/test_e2e_console.cpython-312-pytest-9.1.1.pyc +0 -0
  107. package/tests/__pycache__/test_gen_e2e_scripts.cpython-312-pytest-9.1.1.pyc +0 -0
  108. package/tests/__pycache__/test_gen_e2e_scripts.cpython-312.pyc +0 -0
  109. package/tests/__pycache__/test_install_selfcheck.cpython-312-pytest-9.1.1.pyc +0 -0
  110. package/tests/__pycache__/test_layout.cpython-312-pytest-9.1.1.pyc +0 -0
  111. package/tests/__pycache__/test_map_anchors.cpython-312-pytest-9.1.1.pyc +0 -0
  112. package/tests/__pycache__/test_no_framework_names.cpython-312-pytest-9.1.1.pyc +0 -0
  113. package/tests/__pycache__/test_project_wiring.cpython-312-pytest-9.1.1.pyc +0 -0
  114. package/tests/__pycache__/test_refresh_facts.cpython-312-pytest-9.1.1.pyc +0 -0
  115. package/tests/__pycache__/test_shared_kernel.cpython-312-pytest-9.1.1.pyc +0 -0
  116. package/tests/__pycache__/test_tools.cpython-312-pytest-9.1.1.pyc +0 -0
  117. package/tests/__pycache__/test_tools.cpython-312.pyc +0 -0
  118. package/tests/__pycache__/test_validate_ac_alignment.cpython-312-pytest-9.1.1.pyc +0 -0
  119. package/tests/__pycache__/test_validate_adr.cpython-312-pytest-9.1.1.pyc +0 -0
  120. package/tests/__pycache__/test_validate_walkthrough.cpython-312-pytest-9.1.1.pyc +0 -0
  121. package/tests/__pycache__/test_validators.cpython-312-pytest-9.1.1.pyc +0 -0
  122. package/tests/__pycache__/test_write_claim.cpython-312-pytest-9.1.1.pyc +0 -0
  123. package/tests/fixtures/e2e_coverage_dims_golden.json +12 -0
  124. package/tests/fixtures/verify_playground/product/e2e/cases/home.md +40 -0
  125. package/tests/fixtures/verify_playground/product/e2e/e2e-index.md +18 -0
  126. package/tests/fixtures/verify_playground/product/e2e/reviews/2026-01-01-fixture/README.md +3 -0
  127. package/tests/fixtures/verify_playground/product/e2e/reviews/2026-01-01-fixture/review.json +17 -0
  128. package/tests/fixtures/verify_playground/product/e2e/scripts/atlas-prepare.mjs +4 -0
  129. package/tests/fixtures/verify_playground/product/prd/README.md +5 -0
  130. package/tests/fixtures/verify_playground/product/prd/home-prd.md +62 -0
  131. package/tests/fixtures/verify_playground/product/prd/prd.md +52 -0
  132. package/tests/fixtures/verify_playground/product/stack-profile.yaml.tpl +18 -0
  133. package/tests/fixtures/verify_playground/site/index.html +6 -0
  134. package/tests/test_ac_adapters.py +312 -0
  135. package/tests/test_adapter_api.py +253 -0
  136. package/tests/test_adapter_models.py +236 -0
  137. package/tests/test_apply_wiring.py +136 -23
  138. package/tests/test_atlas_check.py +371 -0
  139. package/tests/test_atlas_verify.py +335 -0
  140. package/tests/test_atlas_verify_playground.py +162 -0
  141. package/tests/test_canonical_consistency.py +5 -2
  142. package/tests/test_closeout_derive.py +264 -0
  143. package/tests/test_duty_bug_families.py +355 -3
  144. package/tests/test_e2e_console.py +49 -0
  145. package/tests/test_gen_e2e_scripts.py +650 -17
  146. package/tests/test_install_selfcheck.py +76 -0
  147. package/tests/test_layout.py +252 -0
  148. package/tests/test_map_anchors.py +475 -0
  149. package/tests/test_no_framework_names.py +86 -0
  150. package/tests/test_project_wiring.py +12 -4
  151. package/tests/test_refresh_facts.py +18 -3
  152. package/tests/test_shared_kernel.py +50 -0
  153. package/tests/test_tools.py +257 -1
  154. package/tests/test_validate_ac_alignment.py +1081 -0
  155. package/tests/test_validate_adr.py +24 -0
  156. package/tests/test_validate_walkthrough.py +313 -0
  157. package/tests/test_validators.py +499 -1
  158. package/tests/test_write_claim.py +236 -0
  159. package/validators/__pycache__/validate_ac_alignment.cpython-312.pyc +0 -0
  160. package/validators/__pycache__/validate_adr.cpython-312.pyc +0 -0
  161. package/validators/__pycache__/validate_design_exemptions.cpython-312.pyc +0 -0
  162. package/validators/__pycache__/validate_e2e_index.cpython-312.pyc +0 -0
  163. package/validators/__pycache__/validate_e2e_mocks.cpython-312.pyc +0 -0
  164. package/validators/__pycache__/validate_prd.cpython-312.pyc +0 -0
  165. package/validators/__pycache__/validate_stack_profile.cpython-312.pyc +0 -0
  166. package/validators/__pycache__/validate_structure.cpython-312.pyc +0 -0
  167. package/validators/__pycache__/validate_testids.cpython-312.pyc +0 -0
  168. package/validators/__pycache__/validate_walkthrough.cpython-312.pyc +0 -0
  169. package/validators/validate_ac_alignment.py +439 -183
  170. package/validators/validate_adr.py +15 -5
  171. package/validators/validate_design_exemptions.py +15 -6
  172. package/validators/validate_e2e_index.py +109 -38
  173. package/validators/validate_e2e_mocks.py +12 -5
  174. package/validators/validate_prd.py +109 -14
  175. package/validators/validate_stack_profile.py +62 -8
  176. package/validators/validate_structure.py +9 -2
  177. package/validators/validate_testids.py +36 -23
  178. package/validators/validate_walkthrough.py +300 -0
@@ -0,0 +1,264 @@
1
+ #!/usr/bin/env python3
2
+ """`scripts/closeout_derive.py` 回归测试(M2,`RFC-20261022-01` 批 2)。
3
+
4
+ 触发表(主表 + 补表)是唯一真相源;本器只解析不复制。钉住:
5
+ 正控:改契约 ⇒ 1 → 2 → 5 → 7 全出,机器步骤标「自动」、人侧标「人工」;
6
+ 无条件行恒出;补表被解析;步骤跨行去重;
7
+ 告警宇宙:业务面文件只计条数、不出声;atlas 自有面未命中 ⇒ WARN 逐条;
8
+ 响亮失败:触发表引用空号(含已退役步骤 6)⇒ FAIL;表结构解析失败 ⇒ FAIL;
9
+ git ref 不可用 ⇒ FAIL;语义触发行(缺陷修复 / 复盘)不得被路径派生。
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import importlib.util
14
+ import json
15
+ import subprocess
16
+ from pathlib import Path
17
+
18
+ PKG_ROOT = Path(__file__).resolve().parents[1]
19
+ SCRIPT = PKG_ROOT / "scripts" / "closeout_derive.py"
20
+
21
+
22
+ def _load():
23
+ spec = importlib.util.spec_from_file_location("closeout_derive_under_test", SCRIPT)
24
+ mod = importlib.util.module_from_spec(spec)
25
+ spec.loader.exec_module(mod)
26
+ return mod
27
+
28
+
29
+ MOD = _load()
30
+
31
+ CONTRACT = """# atlas 共享契约 — 收口清单
32
+
33
+ ## 1. 触发表(哪些事件要跑哪几步)
34
+
35
+ | 事件 | 必跑步骤 |
36
+ |---|---|
37
+ | 改契约 / 模板 / 校验器 / 脚本 / skill 瘦桩 | 1 → 2 → 5 → 7 |
38
+ | 环退役 / 真相源迁移 / 产物改名 | 8(生命周期三件套) |
39
+ | 改 `DESIGN.md` §12 决策(尤其含「被否:」的行)/ 撤回或收窄某项能力宣称 | **1(含宣称 sweep)**:以该裁决的否决词**全仓 grep** |
40
+ | 缺陷修复类任务(bug fix) | **4**(修复验证 7 检查点)→ 1 → 7 |
41
+ | 影响面报告已产出(`.trellis/tasks/<ID>/impact.json` 存在)的收口 | 1(含 `impact_reconcile --task <ID>`,2026-10-07 起) |
42
+ | 独立审查报告落盘(①②④⑤ 任一实例) | 1(含 `validate_review_report --dir <报告目录>`,2026-10-06 起) |
43
+
44
+ | 任一工作单元收尾 | 1(至少) |
45
+ | 域 / 里程碑收口且出现 ≥2 次同类纠偏(或用户点名复盘) | **11**(harness 复盘,按需) |
46
+ | 结构环产出 / 刷新**域级文件** | 1(含**域清单确认门对账**) |
47
+
48
+ ## 2. 步骤
49
+
50
+ > 编号口径:现行步骤 = 1–5、7–11;步骤 6 已退役,号码不回收。
51
+
52
+ ### 步骤 1 — 门自检
53
+
54
+ ```bash
55
+ python3 .atlas/scripts/atlas_check.py
56
+ ```
57
+
58
+ ### 步骤 2 — 装配面核对
59
+
60
+ ### 步骤 3 — 变更风险定档
61
+
62
+ ### 步骤 4 — 缺陷修复验证
63
+
64
+ ### 步骤 5 — 回灌登记
65
+
66
+ ### 步骤 7 — 提交
67
+
68
+ ### 步骤 8 — 生命周期三件套
69
+
70
+ ### 步骤 9 — 域收口验收走查
71
+
72
+ ### 步骤 10 — 实现代码独立审查
73
+
74
+ ### 步骤 11 — harness 复盘
75
+ """
76
+
77
+
78
+ def _repo(tmp_path: Path, contract: str = CONTRACT) -> Path:
79
+ root = tmp_path / "proj"
80
+ (root / ".atlas" / "shared").mkdir(parents=True)
81
+ (root / ".atlas" / "shared" / "closeout.md").write_text(contract, encoding="utf-8")
82
+ subprocess.run(["git", "init", "-q"], cwd=root, check=True)
83
+ subprocess.run(["git", "add", "-A"], cwd=root, check=True)
84
+ subprocess.run(["git", "-c", "user.email=t@t", "-c", "user.name=t",
85
+ "commit", "-qm", "base"], cwd=root, check=True)
86
+ return root
87
+
88
+
89
+ def _touch(root: Path, rel: str) -> None:
90
+ p = root / rel
91
+ p.parent.mkdir(parents=True, exist_ok=True)
92
+ p.write_text("changed\n", encoding="utf-8")
93
+
94
+
95
+ def _res(root: Path, base: str = "HEAD") -> dict:
96
+ return MOD.run(root, base)
97
+
98
+
99
+ # ---------------------------------------------------------------- 正控 / 语法
100
+
101
+
102
+ def test_contract_row_arrow_chain_and_labels(tmp_path) -> None:
103
+ root = _repo(tmp_path)
104
+ _touch(root, "shared/gates.md")
105
+ res = _res(root)
106
+ assert set(res["steps"]) == {"1", "2", "5", "7"}, res["steps"]
107
+ text = res["text"]
108
+ assert text.splitlines()[0] == "本清单不含语义触发行,正文自查"
109
+ for n in (1, 2):
110
+ assert f"[自动] 步骤 {n} " in text, text
111
+ for n in (5, 7):
112
+ assert f"[人工] 步骤 {n} " in text, text
113
+ assert f"步骤 7 " in text and f"closeout.md §2" in text
114
+
115
+
116
+ def test_unconditional_row_always_present(tmp_path) -> None:
117
+ root = _repo(tmp_path)
118
+ _touch(root, "src/app.ts")
119
+ res = _res(root)
120
+ assert set(res["steps"]) == {"1"}, res["steps"]
121
+ assert "任一工作单元收尾" in res["text"]
122
+
123
+
124
+ def test_supplementary_table_is_parsed(tmp_path) -> None:
125
+ root = _repo(tmp_path)
126
+ _touch(root, ".trellis/spec/structure/current/demo/facts.md")
127
+ res = _res(root)
128
+ assert "结构环产出" in res["text"], res["text"]
129
+
130
+
131
+ def test_steps_are_deduped_across_rows(tmp_path) -> None:
132
+ root = _repo(tmp_path)
133
+ _touch(root, "shared/gates.md")
134
+ _touch(root, "DESIGN.md")
135
+ _touch(root, ".trellis/spec/structure/current/demo/facts.md")
136
+ text = _res(root)["text"]
137
+ assert text.count("步骤 1 ") == 1, text
138
+
139
+
140
+ # ---------------------------------------------------------------- 告警宇宙
141
+
142
+
143
+ def test_business_file_is_counted_without_warning(tmp_path) -> None:
144
+ root = _repo(tmp_path)
145
+ _touch(root, "src/app.ts")
146
+ _touch(root, "src/lib.ts")
147
+ res = _res(root)
148
+ assert res["business"] == 2
149
+ assert "[WARN]" not in res["text"], res["text"]
150
+ assert "业务面 2 个,只计条数" in res["text"]
151
+
152
+
153
+ def test_atlas_owned_unmatched_warns_each_path(tmp_path) -> None:
154
+ root = _repo(tmp_path)
155
+ _touch(root, "patches/unknown.py")
156
+ _touch(root, "_support/unknown.py")
157
+ res = _res(root)
158
+ warns = [l for l in res["text"].splitlines() if l.startswith("[WARN]")]
159
+ assert len(warns) == 2, res["text"]
160
+ assert any("patches/unknown.py" in w for w in warns)
161
+ assert any("_support/unknown.py" in w for w in warns)
162
+
163
+
164
+ def test_tests_dir_is_covered_by_script_row(tmp_path) -> None:
165
+ """`tests/` 属「脚本」族:改回归钉不得落进「未命中」告警(真跑咬出的假信号)。"""
166
+ root = _repo(tmp_path)
167
+ _touch(root, "tests/test_something.py")
168
+ res = _res(root)
169
+ assert "[WARN]" not in res["text"], res["text"]
170
+ assert set(res["steps"]) == {"1", "2", "5", "7"}, res["steps"]
171
+
172
+
173
+ # ---------------------------------------------------------------- 响亮失败
174
+
175
+
176
+ def test_dead_step_reference_fails(tmp_path) -> None:
177
+ bad = CONTRACT.replace("| 环退役 / 真相源迁移 / 产物改名 | 8(生命周期三件套) |",
178
+ "| 环退役 / 真相源迁移 / 产物改名 | 6(生命周期三件套) |")
179
+ root = _repo(tmp_path, bad)
180
+ _touch(root, "rings/e2e/reference.md")
181
+ try:
182
+ _res(root)
183
+ except MOD.DeriveError as e:
184
+ assert "步骤 6" in str(e) and "空号" in str(e)
185
+ else:
186
+ raise AssertionError("引用已退役步骤 6 必须 FAIL")
187
+
188
+
189
+ def test_missing_table_fails_loudly(tmp_path) -> None:
190
+ root = _repo(tmp_path, "# 收口清单\n\n## 2. 步骤\n\n### 步骤 1 — 门自检\n")
191
+ _touch(root, "shared/gates.md")
192
+ try:
193
+ _res(root)
194
+ except MOD.DeriveError as e:
195
+ assert "触发表" in str(e)
196
+ else:
197
+ raise AssertionError("表结构缺失必须 FAIL,不得退化成空清单")
198
+
199
+
200
+ def test_bad_git_ref_fails(tmp_path) -> None:
201
+ root = _repo(tmp_path)
202
+ try:
203
+ _res(root, "no-such-ref")
204
+ except MOD.DeriveError as e:
205
+ assert "git diff" in str(e)
206
+ else:
207
+ raise AssertionError("坏 ref 必须 FAIL")
208
+
209
+
210
+ # ---------------------------------------------------------------- 语义行不派生
211
+
212
+
213
+ def test_semantic_rows_never_derived(tmp_path) -> None:
214
+ root = _repo(tmp_path)
215
+ _touch(root, "src/app.ts")
216
+ res = _res(root)
217
+ assert "4" not in res["steps"] and "11" not in res["steps"], res["steps"]
218
+ assert "缺陷修复类任务" not in res["text"]
219
+ assert "harness 复盘" not in res["text"]
220
+
221
+
222
+ # ---------------------------------------------------------------- 真契约 / CLI
223
+
224
+
225
+ def test_real_canonical_contract_parses() -> None:
226
+ rows = MOD.parse_table((PKG_ROOT / "shared" / "closeout.md").read_text(encoding="utf-8"))
227
+ assert len(rows) >= 10, len(rows)
228
+ existing = MOD.parse_existing_steps((PKG_ROOT / "shared" / "closeout.md").read_text(encoding="utf-8"))
229
+ MOD.validate_step_references(rows, existing) # 不抛 = 无空号
230
+
231
+
232
+ def test_cli_json_and_exit_codes(tmp_path, capsys) -> None:
233
+ root = _repo(tmp_path)
234
+ _touch(root, "shared/gates.md")
235
+ assert MOD.main(["--root", str(root), "--diff-base", "HEAD", "--json"]) == 0
236
+ payload = json.loads(capsys.readouterr().out)
237
+ assert payload["ok"] is True and "1" in payload["steps"]
238
+
239
+ bad = _repo(tmp_path / "bad", CONTRACT.replace(
240
+ "| 环退役 / 真相源迁移 / 产物改名 | 8(生命周期三件套) |",
241
+ "| 环退役 / 真相源迁移 / 产物改名 | 12(生命周期三件套) |"))
242
+ assert MOD.main(["--root", str(bad), "--diff-base", "HEAD"]) == 2
243
+ assert "[FAIL]" in capsys.readouterr().err
244
+
245
+
246
+ def test_non_git_project_fails_with_short_message(tmp_path, capsys) -> None:
247
+ """评审 P2-2:非 git 项目不是坏 ref —— 短句拒绝,不刷整屏 git usage。"""
248
+ root = tmp_path / "plain"
249
+ (root / ".atlas" / "shared").mkdir(parents=True)
250
+ (root / ".atlas" / "shared" / "closeout.md").write_text(CONTRACT, encoding="utf-8")
251
+ assert MOD.main(["--root", str(root), "--diff-base", "HEAD"]) == 2
252
+ err = capsys.readouterr().err
253
+ assert "不是 git 仓库" in err
254
+ assert "usage:" not in err.lower()
255
+
256
+
257
+ def test_event_path_tokens_follow_layout(tmp_path) -> None:
258
+ """RFC-20261026-01:ADR / 审查报告前缀随资产根;布局配置本身归「契约」事件。"""
259
+ (tmp_path / "atlas.config.yaml").write_text(
260
+ "version: 1\nlayout:\n assets_root: docs\n", encoding="utf-8")
261
+ tokens = dict(MOD.event_path_tokens(tmp_path))
262
+ assert tokens["ADR"] == ("docs/adr/",)
263
+ assert tokens["独立审查报告"] == ("docs/reviews/", "docs/prd/reviews/", "docs/e2e/reviews/")
264
+ assert "atlas.config.yaml" in tokens["契约"]
@@ -1,5 +1,14 @@
1
1
  #!/usr/bin/env python3
2
- """DR4 **口径 B** 单源模块(`duty-review/families.py`)的形状钉 + 判定真值表。
2
+ """DR4 **口径 B** 单源模块(`duty-review/families.py`)的形状钉 + 判定真值表 + 条目字段制式钉。
3
+
4
+ **套件计数**:19 条 = 既有形状/真值表 4 + D154 字段制式 7 + **D155 日期越界 7** + **D156 指针行 1**
5
+ (`duty-review/README.md` §2 的钉数与本文件一致;改任一钉同步改这里)。
6
+ D156 的 1 条 = 留出验证指针行形状钉(正向:合规指针不改三值;反向:带日期改 `last_date`、带 `#NN` 改 `count`——反向用例即其反面包,不另配 M)。
7
+ D155 的 7 条 = 越界两钉(日历非法 / 未来日期)+ 合法边界反面包 + 判别性补强 + **轮 2 复核补的三钉**:
8
+ **判据级留出 = 归零路径**(全坏字段 + 零散文日期 ⇒ `last_date=""`,既有钉无一走到;pre 红 = verdict/`last_date`/`exact` 三层翻转);
9
+ **紧边界判别钉 = `T` / `T+1` 对**(红在判据层,但属 G2 同夹具日期串变体,按 RFC-03 §2.1.4 计判别性补强——轮 3 年粒度变异实测:G2 绿、唯它与 CLI 钉红);
10
+ **送达钉 = CLI 巡检轨**(`main()` 的 stdout——既有钉全在进程内,把 `print` 改成 `stderr.write` 时 15 条钉曾全绿而告警消失)。
11
+ **三条在 D154 版上实测全红;留出资格只归归零路径——「pre 红」不自动等于「留出」(轮 3 判词)。**
3
12
 
4
13
  **为什么钉它**:口径 B 的输入是**人工写的** `bugs.md`。解析形状一漂,最坏结果是**静默归零**
5
14
  (每段都数成 0 ⇒ 永不升室)——那恰好让本机制要治的「复发靠撞见」原样回来,而且**没有任何声息**。
@@ -7,8 +16,20 @@
7
16
 
8
17
  **副本态**:`duty-review/` 不随包分发(`.atlas/tests` 里没有它)⇒ 显式 skip,不是通过。
9
18
 
10
- 变异证明 **M91**:把计数正则从段内全文 (`re.findall(r"#(\\d+)", line)`) 退回行首
11
- (`r"^#(\\d+)"`) ⇒ 「案数上界」塌成 ≤1 ⇒ 本测红(真实库里的族再也数不到 3)。
19
+ 变异证明:
20
+ * **M91**:把计数正则从段内全文 (`re.findall(r"#(\\d+)", line)`) 退回行首 (`r"^#(\\d+)"`)
21
+ ⇒ 「案数上界」塌成 ≤1 ⇒ 本测红(真实库里的族再也数不到 3)。
22
+ * **M157a**(D154):摘分层日期来源(回退段内全行散文日期)⇒「陈旧说明不抬窗」正向钉红。
23
+ * **M157b**(D154):摘坏形状 WARN ⇒ 字段形状钉坏形状组红(静默回退从另一门归来)。
24
+ * **M158a**(D155):摘日历关(`date.fromisoformat` 失败仍当合法)⇒ **2 红**(日历非法钉 + 归零路径钉)。
25
+ * **M158b**(D155):摘未来关(晚于当日的字段日期不拦)⇒ **4 红**(未来钉 + 归零钉 + `T+1` 边界钉 + CLI 轨钉)。
26
+ * **M158c**(D155):把 `main()` 的 `print` 换成 `sys.stderr.write` ⇒ **恰 1 红 = 只有 CLI 轨钉**,17 条判据面全绿
27
+ —— 这正说明该钉咬的是「告警有没有送达巡检消费者」,与两关算得对不对无关(轮 2 复实用它证伪了「换通道=换留出」的第一版说法,也据此把 CLI 轨从「留出」降为「送达钉」,判据级留出改由归零路径与 `T+1` 边界承担)。
28
+ * 三枚的红因集合 = **恰好是依赖被摘那一关的钉**,无一条无关钉被牵连 ⇒ 注入面与红因一致(不是「一处崩了冒充全局红」)。
29
+
30
+ 判据零变更对照基线(D154 落刀日 2026-10-26,证据 = `proposals/REVIEW-20261026-07` §3 试跑):
31
+ 五段 verdict/案数/最近日期冻结在
32
+ `test_current_real_file_baseline_frozen`——字段制式对现行全文(零条目带尾缀)**零扰动**。
12
33
  """
13
34
  from __future__ import annotations
14
35
 
@@ -23,6 +44,17 @@ PKG_ROOT = Path(__file__).resolve().parents[1]
23
44
  FAMILIES = PKG_ROOT / "duty-review" / "families.py"
24
45
  BUGS = PKG_ROOT / "duty-review" / "bugs.md"
25
46
 
47
+ TODAY = datetime.date(2026, 10, 26) # D154 落刀日;冻结基线与字段钉都用它,不随时钟漂
48
+ FROZEN_BASELINE = { # (案数上界, 最近日期, verdict) —— 现行 bugs.md 五段
49
+ "structure 逆向(适配器 / 事实区渲染 / 结构环)": (3, "", "族已静默"),
50
+ "E2E(契约用例 / 生成器 / 校验器)": (16, "2026-10-25", "升室候选"),
51
+ # 2026-10-28:发版口径回归条目关闭并并入同根归纳(当日补录 `c73f8a0` 出库)
52
+ # ⇒ 该族最近日期由 2026-10-27 回落到 2026-10-17;案数不变
53
+ "安装与装配(install.sh / adopt 守卫 / 包自检 / 发版口径)": (3, "2026-10-17", "升室候选"),
54
+ "图谱通道(已知边界,非缺陷——防误重报)": (0, "", "无同根归纳"),
55
+ "自感知上报(§15 缺陷族)": (8, "2026-10-17", "升室候选"),
56
+ }
57
+
26
58
  pytestmark = pytest.mark.skipif(
27
59
  not FAMILIES.is_file() or not BUGS.is_file(),
28
60
  reason="duty-review/ 不随包分发(副本态):口径 B 的载体不在场 —— 不是通过")
@@ -94,3 +126,323 @@ def test_read_only_and_empty_input_is_not_a_crash() -> None:
94
126
  fam = _load()
95
127
  assert fam.families("") == []
96
128
  assert fam.render([]) == ["(无 duty-review/bugs.md,跳过)"]
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # D154 条目字段制式(2026-10-26)——七钉(含末尾的现行库基线冻结对照钉)
133
+ # ---------------------------------------------------------------------------
134
+
135
+ def test_fielded_entry_date_drives_window_and_excludes_own_prose() -> None:
136
+ """带字段条目:字段日期是唯一活跃窗来源;本行散文旧日期与段尾收尾说明**不再抬窗**。"""
137
+ fam = _load()
138
+ text = (
139
+ "## 字段激活段\n"
140
+ "- 同根归纳:同属一族\n"
141
+ f"- #51 案甲 2026-01-01〔日期 {TODAY.isoformat()}〕\n"
142
+ "- #52 案乙\n"
143
+ "- #53 案丙\n"
144
+ "(段尾收尾说明 2026-01-01 全量绿——非条目行,不是日期来源)\n"
145
+ )
146
+ rows = fam.families(text, today=TODAY)
147
+ row = rows[0]
148
+ assert row["last_date"] == TODAY.isoformat(), (
149
+ "带字段条目的本行散文旧日期 / 段尾说明不应参与活跃窗取数")
150
+ assert row["active"] is True
151
+ assert row["verdict"] == "升室候选"
152
+ assert row["exact"] == 1, row # 精确病案数 = 带字段条目的 #51
153
+
154
+
155
+ def test_stale_tail_note_and_fieldless_entries_do_not_lift_window() -> None:
156
+ """仅陈旧收尾说明日期、全段无字段条目 ⇒ 判「族已静默」(M157a 的正向钉 ②)。"""
157
+ fam = _load()
158
+ text = (
159
+ "## 静默段\n"
160
+ "- 同根归纳:同属一族\n"
161
+ "- #61 案甲\n"
162
+ "- #62 案乙\n"
163
+ "- #63 案丙\n"
164
+ "(收尾说明:2026-01-01 已修全量绿——非条目行)\n"
165
+ )
166
+ row = fam.families(text, today=TODAY)[0]
167
+ assert row["last_date"] == "", "非条目行的日期不是取数源,不得抬窗"
168
+ assert row["active"] is False
169
+ assert row["verdict"] == "族已静默"
170
+
171
+
172
+ def test_bad_shape_warns_and_falls_back() -> None:
173
+ """坏形状(重复键 / 日期形态非法 / 未知键 / 值域外 / 缺必填)⇒ WARN + 按无字段回退,不静默。"""
174
+ fam = _load()
175
+ text = (
176
+ "## 坏形状段\n"
177
+ "- 同根归纳:同属一族\n"
178
+ "- #71 案甲〔日期 2026-10-26|日期 2026-10-25〕\n"
179
+ "- #72 案乙〔日期 2026/10/26〕\n"
180
+ "- #73 案丙〔时间 2026-10-26〕\n"
181
+ "- #74 案丁〔状态 已废除〕\n"
182
+ "- #75 案戊〔状态 开放〕\n"
183
+ "- #76 案己 2026-10-20\n"
184
+ )
185
+ rows = fam.families(text, today=TODAY)
186
+ row = rows[0]
187
+ assert len(row["warns"]) == 5, row["warns"] # #71–#75 各一条,缺「日期」同算坏
188
+ for w in row["warns"]:
189
+ assert "已按无字段回退" in w
190
+ assert row["count"] == 6 # 上界计数不受字段状态影响
191
+ assert row["last_date"] == "2026-10-20", "坏形状条目回退后,日期来源只剩 #76 的行内散文日期"
192
+ assert row["active"] is True and row["verdict"] == "升室候选"
193
+ assert row["exact"] is None, "段内零个合法带字段条目 ⇒ 无精确病案数列"
194
+ assert any("WARN 条目字段形状" in ln for ln in fam.render(rows))
195
+
196
+
197
+ def test_half_field_is_legal_shape() -> None:
198
+ """半字段(只带「日期」)合法:字段直接进活跃窗,不 WARN。"""
199
+ fam = _load()
200
+ text = (
201
+ "## 半字段段\n"
202
+ "- #81 案甲〔日期 2026-10-25〕\n"
203
+ "- #82 案乙\n- #83 案丙\n"
204
+ )
205
+ row = fam.families(text, today=TODAY)[0]
206
+ assert row["warns"] == []
207
+ assert row["last_date"] == "2026-10-25"
208
+ assert row["exact"] == 1
209
+
210
+
211
+ def test_mixed_segment_aggregates_by_max_date() -> None:
212
+ """混合段:带字段新条目 ∪ 无字段旧条目行内日期,段活跃窗 = 并集最大(按条目级回退)。"""
213
+ fam = _load()
214
+ text = (
215
+ "## 混合段\n"
216
+ "- 同根归纳:同属一族\n"
217
+ "- #91 新案〔日期 2026-10-25|状态 开放〕\n"
218
+ "- #92 旧案 自 2026-10-20 起(无字段,散文日期)\n"
219
+ "- #93 旧案无日期\n"
220
+ )
221
+ row = fam.families(text, today=TODAY)[0]
222
+ assert row["last_date"] == "2026-10-25"
223
+ assert row["active"] is True and row["verdict"] == "升室候选"
224
+ assert row["exact"] == 1
225
+
226
+
227
+ def test_render_exact_column_only_when_present() -> None:
228
+ """显示列钉:带字段段出「精确病案数=M」;无字段段不出该列(RFC v2 §2.1)。"""
229
+ fam = _load()
230
+ mixed = "## 混合段\n- 同根归纳:同属一族\n- #91 新案〔日期 2026-10-25〕\n- #92 旧案\n- #93 旧案\n"
231
+ plain = "## 无字段段\n- #94 案甲\n- #95 案乙\n- #96 案丙\n"
232
+ out = fam.render(fam.families(mixed, today=TODAY) + fam.families(plain, today=TODAY))
233
+ mixed_line = [ln for ln in out if "混合段" in ln][0]
234
+ plain_line = [ln for ln in out if "无字段段" in ln][0]
235
+ assert "精确病案数=1" in mixed_line
236
+ assert "精确病案数" not in plain_line
237
+
238
+
239
+ # ---------------------------------------------------------------------------
240
+ # D155 字段日期越界校验(2026-10-26,RFC-20261026-05)——四钉
241
+ # ---------------------------------------------------------------------------
242
+
243
+ def test_calendar_invalid_field_date_warns_and_falls_back() -> None:
244
+ """越界之一:形态过正则但**日历非法**(13 月 40 日)⇒ WARN + 按无字段回退。
245
+
246
+ 「判据可被形态满足」族的新实例:`^20\\d\\d-\\d\\d-\\d\\d$` 满足 ≠ 是一个可用日期。
247
+ """
248
+ fam = _load()
249
+ text = (
250
+ "## 日历非法段\n"
251
+ "- 同根归纳:同属一族\n"
252
+ "- #301 案甲 2026-01-01〔日期 2026-13-40〕\n"
253
+ "- #302 案乙\n"
254
+ "- #303 案丙\n"
255
+ )
256
+ row = fam.families(text, today=TODAY)[0]
257
+ assert len(row["warns"]) == 1, row["warns"]
258
+ assert "日历非法" in row["warns"][0], row["warns"]
259
+ assert "已按无字段回退" in row["warns"][0], row["warns"]
260
+ assert row["last_date"] == "2026-01-01", "越界字段日期不得抬活跃窗——须回退行内散文日期"
261
+ assert row["verdict"] == "族已静默"
262
+
263
+
264
+ def test_future_field_date_warns_and_falls_back() -> None:
265
+ """越界之二:**晚于当日**的字段日期 ⇒ WARN + 回退,否则该段被永久判「升室候选」。"""
266
+ fam = _load()
267
+ text = (
268
+ "## 未来日期段\n"
269
+ "- 同根归纳:同属一族\n"
270
+ "- #311 案甲 2026-01-01〔日期 2027-01-01〕\n"
271
+ "- #312 案乙\n"
272
+ "- #313 案丙\n"
273
+ )
274
+ row = fam.families(text, today=TODAY)[0]
275
+ assert len(row["warns"]) == 1, row["warns"]
276
+ assert "晚于当日" in row["warns"][0], row["warns"]
277
+ assert row["last_date"] == "2026-01-01", "未来日期不是活跃窗来源——须回退行内散文日期"
278
+ assert row["verdict"] == "族已静默"
279
+
280
+
281
+ def test_legal_field_date_still_accepted_after_tightening() -> None:
282
+ """反面包:收紧只挡越界,**合法边界值不得被误伤**。
283
+
284
+ 正好 = cutoff(`TODAY - WINDOW_DAYS` = 2026-09-26)且行内散文日期更旧 ⇒ 字段日期生效、无 WARN。
285
+ """
286
+ fam = _load()
287
+ cutoff = (TODAY - datetime.timedelta(days=fam.WINDOW_DAYS)).isoformat()
288
+ text = (
289
+ "## 合法边界段\n"
290
+ "- 同根归纳:同属一族\n"
291
+ f"- #331 案甲 2026-01-01〔日期 {cutoff}〕\n"
292
+ "- #332 案乙\n"
293
+ "- #333 案丙\n"
294
+ )
295
+ row = fam.families(text, today=TODAY)[0]
296
+ assert row["warns"] == [], f"合法日期被误拦:{row['warns']}"
297
+ assert row["last_date"] == cutoff
298
+ assert row["verdict"] == "升室候选"
299
+
300
+
301
+ def test_tail_note_newer_than_field_date_does_not_lift_window() -> None:
302
+ """判别性补强:收尾说明日期**晚于**字段日期时,活跃窗仍只认字段日期。
303
+
304
+ 既有钉 #1 的尾注日期(2026-01-01)比字段日期更早,pre/post 同结论 ⇒ 咬不住「其余行不抬窗」;
305
+ 本条把尾注改成**更新**,M157a 类退化才会被抓住。**不替换既有钉**——新增。
306
+ """
307
+ fam = _load()
308
+ text = (
309
+ "## 尾注更新段\n"
310
+ "- 同根归纳:同属一族\n"
311
+ "- #321 案甲〔日期 2026-01-01〕\n"
312
+ "- #322 案乙\n"
313
+ "- #323 案丙\n"
314
+ "(收尾说明 2026-10-26 已修全量绿——非条目行,不是日期来源)\n"
315
+ )
316
+ row = fam.families(text, today=TODAY)[0]
317
+ assert row["last_date"] == "2026-01-01", "收尾说明即使更新也不得抬窗"
318
+ assert row["verdict"] == "族已静默"
319
+
320
+
321
+ def test_all_bad_field_dates_zero_out_window_and_exact() -> None:
322
+ """归零路径(轮 2 复核指出:现钉无一覆盖):段内字段日期**全部越界**且无散文日期。
323
+
324
+ 这是 `last_date = ""` 那条「无日期 ⇒ 已静默」分支在**坏字段**下的唯一来路——既有坏形状钉
325
+ (`test_bad_shape_warns_and_falls_back`)总留一条带散文日期的 #76,所以从没走到这里。
326
+ pre(D154)实测:`last=2027-06-01 / 升室候选 / warns=0 / exact=2`(越界值被当合法,
327
+ 且抬窗 + 虚计精确病案数);post(D155):全拒 ⇒ 归零静默。
328
+ """
329
+ fam = _load()
330
+ text = (
331
+ "## 全坏字段段\n"
332
+ "- 同根归纳:同属一族\n"
333
+ "- #501 案甲〔日期 2026-13-40〕\n"
334
+ "- #502 案乙〔日期 2027-06-01〕\n"
335
+ "- #503 案丙\n"
336
+ )
337
+ row = fam.families(text, today=TODAY)[0]
338
+ assert len(row["warns"]) == 2, row["warns"]
339
+ assert row["last_date"] == "", "两个越界字段都不许充当日期源"
340
+ assert row["verdict"] == "族已静默", "越界字段不得把段抬进活跃窗"
341
+ assert row["exact"] is None, "坏字段条目不得计入精确病案数"
342
+
343
+
344
+ def test_tomorrow_field_date_rejected_but_today_accepted() -> None:
345
+ """紧边界对(轮 2 复核指出:G2 用 2027 远日期,测不出「只按年比较」的偷懒实现)。
346
+
347
+ `当日` ⇒ 合法、无 WARN、字段日期生效;`当日 + 1 天` ⇒ 越界 WARN + 回退行内散文日期。
348
+ """
349
+ fam = _load()
350
+ tomorrow = (TODAY + datetime.timedelta(days=1)).isoformat()
351
+
352
+ def seg(date_str: str):
353
+ text = (
354
+ f"## 边界 {date_str}\n"
355
+ "- 同根归纳:同属一族\n"
356
+ f"- #701 案甲 2026-01-01〔日期 {date_str}〕\n"
357
+ "- #702 案乙\n"
358
+ "- #703 案丙\n"
359
+ )
360
+ return fam.families(text, today=TODAY)[0]
361
+
362
+ legal = seg(TODAY.isoformat())
363
+ assert legal["warns"] == [], f"当日不许被误拦:{legal['warns']}"
364
+ assert legal["last_date"] == TODAY.isoformat() and legal["verdict"] == "升室候选"
365
+
366
+ boundary = seg(tomorrow)
367
+ assert len(boundary["warns"]) == 1, boundary["warns"]
368
+ assert "晚于当日" in boundary["warns"][0], boundary["warns"]
369
+ assert boundary["last_date"] == "2026-01-01", "T+1 必须回退行内散文日期,而不是继续抬窗"
370
+ assert boundary["verdict"] == "族已静默"
371
+
372
+
373
+ def test_cli_channel_delivers_shape_warn_on_stdout(tmp_path, capsys) -> None:
374
+ """CLI 巡检轨(`duty_signals.sh:30` 的真实消费面):WARN 必须**到达 stdout**。
375
+
376
+ 既有钉全部在进程内调 `families()` / `render()`,无一触达 `main()` 的 argv / 读文件 /
377
+ `print` 分支——把 `print` 改成 `sys.stderr.write` 时 15 条钉全绿而巡检输出里的 WARN 消失
378
+ (轮 2 复核实测)。本钉把「告警送达消费者」钉住。日期用**真实当日**推导 ⇒ 不绑死某天,可长期复算。
379
+ """
380
+ fam = _load()
381
+ today = datetime.date.today()
382
+ bad = (today + datetime.timedelta(days=1)).isoformat()
383
+ legal = today.isoformat()
384
+
385
+ def run_cli(body: str) -> str:
386
+ path = tmp_path / "ledger.md"
387
+ path.write_text(body, encoding="utf-8")
388
+ capsys.readouterr() # 清屏,避免上一条残留
389
+ rc = fam.main([str(path)])
390
+ out = capsys.readouterr().out
391
+ assert rc == 0, f"CLI 退出码非 0:{rc}"
392
+ return out
393
+
394
+ warn_out = run_cli(
395
+ f"## 巡检轨越界\n- 同根归纳:同属一族\n- #801 案甲 2026-01-01〔日期 {bad}〕\n"
396
+ "- #802 案乙\n- #803 案丙\n"
397
+ )
398
+ assert "WARN 条目字段形状" in warn_out, "越字段日期的告警没送达 stdout(巡检看不见 = 等于没报)"
399
+ assert "族已静默" in warn_out and "升室候选" not in warn_out
400
+
401
+ quiet_out = run_cli(
402
+ f"## 巡检轨合法\n- 同根归纳:同属一族\n- #811 案甲〔日期 {legal}〕\n"
403
+ "- #812 案乙\n- #813 案丙\n"
404
+ )
405
+ assert "WARN 条目字段形状" not in quiet_out, "合法日期在 CLI 轨上被误报"
406
+ assert "升室候选" in quiet_out
407
+
408
+
409
+ def test_current_real_file_baseline_frozen() -> None:
410
+ """判据零变更对照钉(D154):对现行 `bugs.md` 全文(零条目带尾缀),判据三输出逐段冻结
411
+ = D154 落刀日基线(REVIEW-20261026-07 §3 试跑)——字段制式对旧库**零扰动**且无 WARN。"""
412
+ fam = _load()
413
+ rows = fam.families(BUGS.read_text(encoding="utf-8"), today=TODAY)
414
+ got = {r["name"]: (r["count"], r["last_date"], r["verdict"]) for r in rows}
415
+ assert got == FROZEN_BASELINE, got
416
+ assert all(r["warns"] == [] for r in rows), "现行全文零尾缀 ⇒ 不应有任何形状 WARN"
417
+ assert all(r["exact"] is None for r in rows), "现行全文零尾缀 ⇒ 不应出精确病案数列"
418
+
419
+
420
+ # ---------------------------------------------------------------------------
421
+ # D156 留出验证指针行形状钉(2026-10-26,RFC-20261026-03 Phase 1)——一钉
422
+ # ---------------------------------------------------------------------------
423
+
424
+ def test_holdout_pointer_line_is_judgment_neutral() -> None:
425
+ """指针行 `- 留出验证:RFC-… / REVIEW-…` 的格式禁令的机器面(D156 / `RFC-03` §4.4)。
426
+
427
+ 正向:合规指针行(无日期 / 无 `#NN`)⇒ `count` / `last_date` / `verdict` 三值不变;
428
+ 反向:带日期 ⇒ `last_date` 被抬到指针行日期;带 `#NN` ⇒ `count` +1——这就是为什么
429
+ 格式禁日期与病案号(指针必须中性,不得污染段级信号;`duty-review/README.md` §2/§3)。
430
+ """
431
+ fam = _load()
432
+ base = (
433
+ "## 指针段\n"
434
+ "- 同根归纳:同属一族\n"
435
+ "- #601 案甲 2026-10-01\n"
436
+ "- #602 案乙\n"
437
+ "- #603 案丙\n"
438
+ )
439
+ legal = base + "- 留出验证:RFC-20261026-03 / REVIEW-20261026-06\n"
440
+ dirty_date = base + "- 留出验证:RFC-20261026-03(2026-10-26)\n"
441
+ dirty_id = base + "- 留出验证:RFC-20261026-03 / REVIEW-20261026-06 / #99\n"
442
+ b, l, d, i = (fam.families(t, today=TODAY)[0] for t in (base, legal, dirty_date, dirty_id))
443
+ assert (b["count"], b["last_date"], b["verdict"]) == (3, "2026-10-01", "升室候选"), b
444
+ assert (l["count"], l["last_date"], l["verdict"]) == (b["count"], b["last_date"], b["verdict"]), \
445
+ "合规指针行不得改变三值"
446
+ assert d["last_date"] == "2026-10-26" and d["last_date"] != b["last_date"], \
447
+ "反向:带日期的指针行会把最近日期抬到指针行——故格式禁日期"
448
+ assert i["count"] == b["count"] + 1, "反向:带 #NN 的指针行会虚增案数——故格式禁 #NN"