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,300 @@
1
+ #!/usr/bin/env python3
2
+ """atlas 校验器 —— 走查单确认门的机器面(`RFC-20261022-01` M1,2026-10-23)。
3
+
4
+ 守一件事(对账平面 = **声明 ⇔ 凭据**):
5
+
6
+ `apply` 的三问门判定第 2 问写下「是」(= 本需求改变应然 UI / 流程)⇒ 该需求
7
+ **受影响域级 PRD** 的「用户旅程走查」节必须已有用户确认行 `> 已确认:<日期>`。
8
+ 契约正文 = `shared/gates.md` §2.5「走查单确认门」+ `rings/prd/reference.md` §4·八;
9
+ 确认门本体是人侧门(内容对不对归人),本器只判**声明与凭据对得上**。
10
+
11
+ 时空域(评审 P1-2):
12
+ - 只扫 `.trellis/tasks/*/` 中 `task.json.status ∈ {planning, in_progress}` 的任务;
13
+ - 已归档(completed 等)不扫——「重派生删行」的合法失效与老记录不挂永久红账;
14
+ - task 记录缺失 / 不可解析 ⇒ 该任务 **SKIP + 原因**(响亮,不假红)。
15
+
16
+ 判据(四态出口,评审 P1-1):
17
+ - 第 2 问 =「是」⇒ 受影响域级 PRD(`资产根/prd/<domain>-prd.md`,资产根默认
18
+ `product/`;域由 `atlas-apply.md` 内的路径引用解析)的走查节须有确认行;缺失 / 形态非法 ⇒ **FAIL**
19
+ 指名域与文件。
20
+ - 三问小节在、但第 2 问**不可解析** ⇒ **WARN** 指名 + 要求人工核对
21
+ (不可解析 ≠ 未走到:防「写歪措辞静默旁路」)。
22
+ - 无 apply 记录 / 第 2 问 =「否」/ 三问小节缺席 ⇒ **SKIP** 带原因
23
+ (存量 adopt 不误伤)。
24
+ - 第 2 问 =「是」但报告里解析不到受影响域级 PRD ⇒ **WARN**(要求人工核对)。
25
+
26
+ 显式盲区(不查什么):
27
+ - 三问判定本身的语义越轨(该答「是」的答成「否」)不设防——本器锁的是
28
+ 「写下的『是』与确认行的对账」,不是「是」判得对不对;
29
+ - §3.2 第四形态(无需求卡修订)不在本门(由 closeout 宣称 sweep 承担);
30
+ - 走查内容对不对(语义,用户确认时判)。
31
+
32
+ 框架无关;纯标准库。CLI:`python3 validate_walkthrough.py --root <项目根> [--json]`
33
+ 输出 `{ok, status, checks[]}`;判定 PASS / WARN / SKIP → 退出码 0,FAIL → 1。
34
+ """
35
+ from __future__ import annotations
36
+
37
+ import argparse
38
+ import json
39
+ import re
40
+ import sys
41
+ from pathlib import Path
42
+
43
+ if str(Path(__file__).resolve().parents[1]) not in sys.path:
44
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
45
+ from _support.layout import LayoutError, load_layout # noqa: E402
46
+
47
+ TASKS_RELPATH = Path(".trellis") / "tasks"
48
+ APPLY_RECORD = "atlas-apply.md"
49
+ ACTIVE_STATUSES = {"planning", "in_progress"}
50
+ # Trellis 三态里的归档态;其余未知值(`in-progress` 手滑 / `blocked` 一类)
51
+ # 必须**显式 SKIP 指名**,不得静默出域(评审 P1-1:静默继续 = 门失效)
52
+ ARCHIVED_STATUSES = {"completed"}
53
+
54
+ # 三问小节标题(`## 2. 三问门判定` / `### 三问门判定`;不绑定编号)
55
+ Q_SECTION_RE = re.compile(r"^#{2,6}\s+.*三问.*$", re.M)
56
+ # 第 2 问的行首形态:`2.` / `2、` / `第 2 问` / `第二问` / 列表项包裹
57
+ Q2_LINE_RE = re.compile(
58
+ r"^\s*(?:[-*]\s*)?(?:\*\*)?(?:第\s*2\s*问|第二问|2\s*[.、))])(?:\*\*)?")
59
+ # 答句必须以「是 / 否」开头(契约 §3.1 的机器可读形态;`是否` 不在此列——
60
+ # 它只出现在问题里,问题在 `?` 之前已被切走)
61
+ ANSWER_RE = re.compile(r"^[\s*`((【\[]*(是|否)")
62
+
63
+ # 走查节标题(`## 八、用户旅程走查`;按关键词,不绑定序号)
64
+ WALKTHROUGH_HEADING_RE = re.compile(r"^#{2,6}\s+.*用户旅程走查.*$", re.M)
65
+ # 确认行:必须是引用行首的 `> 已确认:<日期>`;模板里的说明行(`> [初稿无「已确认」行…]`)
66
+ # 不匹配本前缀,因此「未确认」不会被误判成「形态非法」
67
+ CONFIRM_LINE_RE = re.compile(r"^\s*>\s*已确认\s*[::]")
68
+ CONFIRM_VALUE_RE = re.compile(r"^\s*>\s*已确认\s*[::]\s*(\S+)\s*$")
69
+ DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
70
+
71
+ # 受影响域:报告里对域级 PRD 的路径引用(`<资产根>/prd/<domain>-prd.md`;默认 product/)
72
+ DOMAIN_PRD_RE = re.compile(
73
+ r"(?:^|[^\w./-])(?:[A-Za-z0-9_.-]+/)*prd/([A-Za-z0-9][A-Za-z0-9_-]*)-prd\.md")
74
+
75
+
76
+ class Result:
77
+ def __init__(self) -> None:
78
+ self.checks: list[dict] = []
79
+
80
+ def add(self, check: str, status: str, detail: str = "") -> None:
81
+ self.checks.append({"check": check, "status": status, "detail": detail})
82
+
83
+ @property
84
+ def status(self) -> str:
85
+ statuses = [c["status"] for c in self.checks]
86
+ if "FAIL" in statuses:
87
+ return "FAIL"
88
+ if "WARN" in statuses:
89
+ return "WARN"
90
+ if statuses and all(s == "SKIP" for s in statuses):
91
+ return "SKIP"
92
+ return "PASS"
93
+
94
+ @property
95
+ def ok(self) -> bool:
96
+ return self.status != "FAIL"
97
+
98
+
99
+ def _section(text: str, heading_re: re.Pattern[str]) -> str | None:
100
+ """取标题命中的小节正文(到下一个同级或更高级标题为止)。"""
101
+ m = heading_re.search(text)
102
+ if not m:
103
+ return None
104
+ start = m.end()
105
+ rest = text[start:]
106
+ nxt = re.search(r"^#{2,6}\s+", rest, re.M)
107
+ return rest[: nxt.start()] if nxt else rest
108
+
109
+
110
+ def parse_task_status(task_dir: Path) -> tuple[str | None, str]:
111
+ """→ (status, 原因)。记录缺失 / 不可解析 ⇒ (None, 原因)。"""
112
+ record = task_dir / "task.json"
113
+ if not record.is_file():
114
+ return None, "无 task.json(任务记录缺失)"
115
+ try:
116
+ data = json.loads(record.read_text(encoding="utf-8"))
117
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as e:
118
+ return None, f"task.json 不可解析({type(e).__name__})"
119
+ if not isinstance(data, dict):
120
+ return None, "task.json 顶层不是 object"
121
+ status = data.get("status")
122
+ if not isinstance(status, str) or not status.strip():
123
+ return None, "task.json 缺 status 字段"
124
+ return status.strip(), ""
125
+
126
+
127
+ def parse_question_two(apply_text: str) -> tuple[str, str]:
128
+ """→ (state, detail)。state ∈ {yes, no, unparseable, no_section, no_line}。
129
+
130
+ 只读**已写下**的判定(不替模型判语义,备选 D 已否)。答句必须「是 / 否」开头;
131
+ 其余形态一律 unparseable(WARN),不得静默旁路。
132
+ """
133
+ sec = _section(apply_text, Q_SECTION_RE)
134
+ if sec is None:
135
+ return "no_section", "无三问门判定小节"
136
+ found: list[tuple[str, str]] = []
137
+ for raw in sec.splitlines():
138
+ if not Q2_LINE_RE.match(raw):
139
+ continue
140
+ line = Q2_LINE_RE.sub("", raw, count=1)
141
+ # 切走问题部分:`是否…?` / `需要…?`;答句在问号之后
142
+ parts = re.split(r"[??]", line, maxsplit=1)
143
+ answer = parts[1] if len(parts) > 1 else line
144
+ m = ANSWER_RE.match(answer.strip().lstrip("::—- "))
145
+ found.append((("yes" if m.group(1) == "是" else "no") if m else "unparseable",
146
+ raw.strip()))
147
+ if not found:
148
+ return "no_line", "三问小节存在但无第 2 问行"
149
+ answers = {a for a, _ in found}
150
+ if "unparseable" in answers:
151
+ return "unparseable", found[0][1]
152
+ if len(answers) > 1:
153
+ # 小节里出现多条第 2 问行且判定相反(典型:引用模板行 + 本轮判定行)——
154
+ # 不得按 first-match-wins 取一条(评审 P1-2:模板行可遮蔽真实判定)
155
+ return "unparseable", "多条第 2 问行判定不一致:" + " / ".join(s for _, s in found[:3])
156
+ return answers.pop(), found[0][1]
157
+
158
+
159
+ def walkthrough_confirmation(prd_text: str) -> tuple[str, str]:
160
+ """→ (state, detail)。state ∈ {confirmed, missing_section, missing, malformed}。"""
161
+ sec = _section(prd_text, WALKTHROUGH_HEADING_RE)
162
+ if sec is None:
163
+ return "missing_section", "缺「用户旅程走查」节"
164
+ for raw in sec.splitlines():
165
+ if not CONFIRM_LINE_RE.match(raw):
166
+ continue
167
+ m = CONFIRM_VALUE_RE.match(raw)
168
+ if m and DATE_RE.match(m.group(1)):
169
+ return "confirmed", m.group(1)
170
+ return "malformed", raw.strip()
171
+ return "missing", "节首无 `> 已确认:<日期>` 行(未确认,或重派生后未重确认)"
172
+
173
+
174
+ def affected_domains(apply_text: str) -> list[str]:
175
+ return sorted(set(DOMAIN_PRD_RE.findall(apply_text)))
176
+
177
+
178
+ def check_task(root: Path, res: Result, task_dir: Path, lay) -> None:
179
+ name = task_dir.name
180
+ status, why = parse_task_status(task_dir)
181
+ if status is None:
182
+ res.add(f"task:{name}", "SKIP", f"任务记录不可判定({why})——不假红,人工核对")
183
+ return
184
+ if status not in ACTIVE_STATUSES:
185
+ if status in ARCHIVED_STATUSES:
186
+ return # 时空域:已归档不扫
187
+ # 未知 / 变体状态(`in-progress`、`Planning`、`blocked`…)必须可见跳过:
188
+ # 静默出域 = 该任务整体绕过走查锁,正好是本 RFC 要防的「接线断」
189
+ res.add(f"task:{name}", "SKIP",
190
+ f"status={status!r} 不在已知集 {sorted(ACTIVE_STATUSES | ARCHIVED_STATUSES)}"
191
+ "——不扫但不静默,请核对任务记录")
192
+ return
193
+
194
+ apply_path = task_dir / APPLY_RECORD
195
+ if not apply_path.is_file():
196
+ res.add(f"task:{name}", "SKIP", f"无 {APPLY_RECORD}(未走到 apply / 存量记录)")
197
+ return
198
+ try:
199
+ apply_text = apply_path.read_text(encoding="utf-8", errors="replace")
200
+ except OSError as e:
201
+ res.add(f"task:{name}", "SKIP", f"{APPLY_RECORD} 不可读({e})——不假红")
202
+ return
203
+
204
+ state, detail = parse_question_two(apply_text)
205
+ if state == "no_section":
206
+ res.add(f"task:{name}", "SKIP", "无三问门判定小节(存量 apply 记录形态)")
207
+ return
208
+ if state == "no_line":
209
+ res.add(f"task:{name}", "WARN",
210
+ f"三问小节存在但第 2 问不可解析({detail})——不可解析 ≠ 未走到,请人工核对")
211
+ return
212
+ if state == "unparseable":
213
+ res.add(f"task:{name}", "WARN",
214
+ f"第 2 问答句形态不可解析:{detail!r}(应写成「是 / 否」开头)"
215
+ "——不可解析 ≠ 未走到,请人工核对")
216
+ return
217
+ if state == "no":
218
+ res.add(f"task:{name}", "SKIP", "第 2 问=否(不触发行走查确认门)")
219
+ return
220
+
221
+ # state == "yes"
222
+ domains = affected_domains(apply_text)
223
+ if not domains:
224
+ res.add(f"task:{name}", "WARN",
225
+ f"第 2 问=是,但报告里未引用任何 `{lay.print_value('prd')}/<domain>-prd.md`"
226
+ "(受影响域不可解析)——请人工核对")
227
+ return
228
+ for domain in domains:
229
+ rel = lay.prd_dir.relative_to(root) / f"{domain}-prd.md"
230
+ prd = lay.prd_dir / f"{domain}-prd.md"
231
+ label = f"task:{name} / {domain}"
232
+ if not prd.is_file():
233
+ res.add(label, "FAIL", f"第 2 问=是,但受影响域级 PRD 不存在:{rel.as_posix()}")
234
+ continue
235
+ cstate, cdetail = walkthrough_confirmation(
236
+ prd.read_text(encoding="utf-8", errors="replace"))
237
+ if cstate == "confirmed":
238
+ res.add(label, "PASS", f"{rel.as_posix()} 走查节已确认({cdetail})")
239
+ elif cstate == "missing_section":
240
+ res.add(label, "FAIL", f"第 2 问=是,但 {rel.as_posix()} {cdetail}")
241
+ elif cstate == "malformed":
242
+ res.add(label, "FAIL",
243
+ f"{rel.as_posix()} 确认行形态非法:{cdetail!r}"
244
+ "(应为 `> 已确认:YYYY-MM-DD`)")
245
+ else:
246
+ res.add(label, "FAIL", f"第 2 问=是,但 {rel.as_posix()} {cdetail}")
247
+
248
+
249
+ def run(root: Path) -> Result:
250
+ res = Result()
251
+ try:
252
+ lay = load_layout(root)
253
+ except LayoutError as exc:
254
+ res.add("项目布局配置", "FAIL", str(exc))
255
+ return res
256
+ # 布局解析后 root 为绝对路径,lay.* 同基;混用相对 root 会在 relative_to 崩(issue #45)
257
+ root = lay.root
258
+ tasks_root = root / TASKS_RELPATH
259
+ if not tasks_root.is_dir():
260
+ res.add("时空域", "SKIP",
261
+ f"无 {TASKS_RELPATH.as_posix()}/(项目未接 Trellis 任务,未就绪非失败)")
262
+ return res
263
+
264
+ task_dirs = sorted(p for p in tasks_root.iterdir() if p.is_dir())
265
+ if not task_dirs:
266
+ res.add("时空域", "SKIP", f"{TASKS_RELPATH.as_posix()}/ 下无任务目录")
267
+ return res
268
+
269
+ for task_dir in task_dirs:
270
+ check_task(root, res, task_dir, lay)
271
+
272
+ if not res.checks:
273
+ # 零行 ≠ PASS:全部任务都在时空域外(已归档)必须显式可见跳过
274
+ res.add("时空域", "SKIP",
275
+ "无在办任务(已归档或未识别状态不扫)")
276
+ return res
277
+
278
+
279
+ def main(argv: list[str] | None = None) -> int:
280
+ ap = argparse.ArgumentParser(description="走查单确认门机器面(声明 ⇔ 凭据)")
281
+ ap.add_argument("--root", required=True, help="项目根")
282
+ ap.add_argument("--json", action="store_true", help="JSON 输出(stdout)")
283
+ args = ap.parse_args(argv)
284
+
285
+ res = run(Path(args.root))
286
+ payload = {"ok": res.ok, "status": res.status, "checks": res.checks}
287
+ if args.json:
288
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
289
+ else:
290
+ print(f"{res.status} validate_walkthrough")
291
+ for c in res.checks:
292
+ line = f" [{c['status']}] {c['check']}"
293
+ if c["detail"]:
294
+ line += f" — {c['detail']}"
295
+ print(line)
296
+ return 0 if res.ok else 1
297
+
298
+
299
+ if __name__ == "__main__":
300
+ sys.exit(main())