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
@@ -3,16 +3,20 @@
3
3
 
4
4
  依据 B209-1 / 3507 BT 决策(2026-10-12):
5
5
  * **无主 AC 拦截(Dangling ACs)**:测试中挂载的 AC ID 必须在现有 PRD 资产中存在(无主即 FAIL)。
6
- * **无效跳过拦截(Unwaived Skip/Xfail)**:挂载了 AC 的测试若被标记为跳过(如 skip / xfail),
7
- 必须附带显式豁免说明(`reason` 包含 `waiver:` 或 docstring 声明 `waiver:`),否则 FAIL。
6
+ * **无效跳过拦截(Unwaived Skip/Xfail)**:挂载了 AC 的测试若被标记为跳过,必须附带显式豁免说明
7
+ (`reason` 包含 `waiver:` 或 docstring 声明 `waiver:`),否则 FAIL。
8
8
  * **双向对账与覆盖清单(Alignment Coverage)**:
9
9
  提取各需求 PRD 的 AC 集合与测试代码映射。若配置为 enforce 模式,检查对应后端 AC 挂载覆盖度。
10
10
  * **差量防退化(Delta Ratchet)**:
11
11
  与上一次执行留存的基线快照比对,挂载 AC 的测试用例数与覆盖的 AC 数净增不得为负。
12
+ * **AC × 覆盖维度(后端通道,D153)**:
13
+ 域级 PRD 验收表可选列 `后端适用覆盖维度` ⇔ 测试记录的 `dims`;
14
+ 缺维度 / 仅弱证据 / 零测试 / 未知 / 孤儿 ⇒ WARN(不 FAIL)。第 4 列仍归 E2E 门(D130)。
12
15
 
13
- - **语言中立架构**:以 AST 提取器与模式匹配器双轨运行,v1 原生支持 Python 测试提取,预留适配器接口。
14
- - **纯标准库**:无第三方依赖。
15
- - 判定:`PASS`/`WARN` → 退出码 0;`FAIL` → 1。
16
+ **语言中立架构**:本文件不识别任何具体测试框架,也不识别测试文件命名约定;
17
+ 框架知识只在 `adapters/ac/` 的提取器内。本文件只消费归一化记录,并按项目形态决定门是否适用。
18
+
19
+ 判定:`PASS`/`WARN`/`N/A` → 退出码 0;`FAIL` → 1。
16
20
 
17
21
  用法:
18
22
  python3 validate_ac_alignment.py --root <项目根> [--json]
@@ -20,7 +24,6 @@
20
24
  from __future__ import annotations
21
25
 
22
26
  import argparse
23
- import ast
24
27
  import json
25
28
  import re
26
29
  import sys
@@ -28,18 +31,27 @@ from pathlib import Path
28
31
  from typing import Any
29
32
 
30
33
  # 随包内核引导(`_support/`,2026-10-12 单源批):profile 解析走单源实现
31
- # (本文件原持第三份手写解析,B209-1 引入时未收敛,见 `_support/stack_profile.py` 头注)。
32
34
  if str(Path(__file__).resolve().parents[1]) not in sys.path:
33
35
  sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
34
36
  from _support.siblings import load_module # noqa: E402
37
+ from _support.layout import LayoutError, load_layout # noqa: E402
35
38
 
36
39
  _sp = load_module("stack_profile")
40
+ ac_coverage = load_module("ac_coverage")
41
+ parse_dims_cell = ac_coverage.parse_dims_cell
42
+
43
+ try:
44
+ import adapters.ac as ac_adapters
45
+ from adapters.ac import command as ac_command
46
+ except Exception as exc: # 装配缺失 / 加载失败必须响亮,不静默降级
47
+ ac_adapters = None
48
+ ac_command = None
49
+ _ADAPTER_IMPORT_ERROR = exc
37
50
 
38
51
  AC_ID_RE = re.compile(r"\b(AC-[A-Z0-9]+-\d+)\b")
39
- SKIP_DIRS = {
40
- "node_modules", ".git", "dist", "build", ".next", "coverage", "__pycache__",
41
- ".venv", "venv", "vendor", ".cache", "out", "target", ".atlas", ".atlas-run"
42
- }
52
+ AC_ID_STRICT_RE = re.compile(r"^AC-[A-Z0-9]+-\d{3}$")
53
+ BACKEND_DIM_HEADER = "后端适用覆盖维度"
54
+ E2E_DIM_HEADER = "适用覆盖维度"
43
55
  BASELINE_PATH = Path(".atlas/runs/backend-ac-alignment.json")
44
56
 
45
57
 
@@ -57,28 +69,24 @@ class Result:
57
69
  return "FAIL"
58
70
  if "WARN" in statuses:
59
71
  return "WARN"
72
+ if self.checks and statuses <= {"N/A"}:
73
+ return "N/A"
60
74
  return "PASS"
61
75
 
62
76
 
63
- def load_stack_profile(root: Path) -> dict[str, Any]:
64
- """经随包内核读取 stack-profile(单源口径,2026-10-12 单源批)。
65
-
66
- 原为本文件自持的轻量扁平解析(第三份副本,B209-1 批引入时未收敛)——已改走
67
- `_support/stack_profile.py` 唯一实现(`_support.siblings.load_module` 引导)。
68
- """
69
- path = root / "product" / "stack-profile.yaml"
77
+ def load_stack_profile(root: Path, lay=None) -> dict[str, Any]:
78
+ """经随包内核读取 stack-profile(单源口径,2026-10-12 单源批)。"""
79
+ lay = lay or load_layout(root)
80
+ path = lay.stack_profile
70
81
  if not path.is_file():
71
82
  return {}
72
83
  return _sp.parse_profile(path.read_text(encoding="utf-8"))
73
84
 
74
85
 
75
- def collect_prd_acs(root: Path) -> dict[str, set[str]]:
76
- """收集 product/prd/ 目录下所有已声明的 AC ID。返回 {文件名: {AC_ID, ...}}。
77
-
78
- 契约(rings/prd/reference.md §2):仅扫平铺规范文件(prd.md 与 *-prd.md),
79
- 显式排除 reviews/ 等子目录及 README.md,避免历史评审或说明文档中残留的旧 AC 污染。
80
- """
81
- prd_dir = root / "product" / "prd"
86
+ def collect_prd_acs(root: Path, lay=None) -> dict[str, set[str]]:
87
+ """收集资产根/prd/ 目录下所有已声明的 AC ID。返回 {文件名: {AC_ID, ...}}。"""
88
+ lay = lay or load_layout(root)
89
+ prd_dir = lay.prd_dir
82
90
  if not prd_dir.is_dir():
83
91
  return {}
84
92
  acs_by_file: dict[str, set[str]] = {}
@@ -95,155 +103,201 @@ def collect_prd_acs(root: Path) -> dict[str, set[str]]:
95
103
  return acs_by_file
96
104
 
97
105
 
98
- class PyTestACVisitor(ast.NodeVisitor):
99
- """AST 遍历提取 pytest 函数及方法中的 AC 挂载与 skip 状态。"""
100
-
101
- def __init__(self, rel_path: str) -> None:
102
- self.rel_path = rel_path
103
- # list of {name, file, line, acs, skipped, waiver}
104
- self.tests: list[dict[str, Any]] = []
105
-
106
- def _extract_acs_from_decorator(self, dec: ast.AST) -> tuple[set[str], bool, str]:
107
- """返回 (acs, is_skipped, waiver_reason)"""
108
- acs: set[str] = set()
109
- is_skipped = False
110
- waiver = ""
111
-
112
- # 形如 @pytest.mark.ac("AC-xxx") or @pytest.mark.skip(reason="waiver: ...")
113
- if isinstance(dec, ast.Call):
114
- func = dec.func
115
- # 装饰器名称提取
116
- names = []
117
- cur = func
118
- while isinstance(cur, ast.Attribute):
119
- names.append(cur.attr)
120
- cur = cur.value
121
- if isinstance(cur, ast.Name):
122
- names.append(cur.id)
123
- names.reverse()
124
- call_path = ".".join(names)
125
-
126
- if call_path in ("pytest.mark.ac", "mark.ac", "ac"):
127
- for arg in dec.args:
128
- if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
129
- for m in AC_ID_RE.findall(arg.value):
130
- acs.add(m)
131
- elif isinstance(arg, (ast.List, ast.Tuple)):
132
- for elt in arg.elts:
133
- if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
134
- for m in AC_ID_RE.findall(elt.value):
135
- acs.add(m)
136
- elif call_path in ("pytest.mark.skip", "pytest.mark.xfail", "mark.skip", "mark.xfail", "skip", "xfail"):
137
- is_skipped = True
138
- for kw in dec.keywords:
139
- if kw.arg == "reason" and isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
140
- waiver = kw.value.value
141
- for arg in dec.args:
142
- if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
143
- waiver = arg.value
144
- elif isinstance(dec, ast.Attribute):
145
- # 无参 @pytest.mark.skip
146
- if dec.attr in ("skip", "xfail"):
147
- is_skipped = True
148
-
149
- return acs, is_skipped, waiver
150
-
151
- def _visit_test_func(self, node: ast.FunctionDef | ast.AsyncFunctionDef, prefix: str = "") -> None:
152
- if not node.name.startswith("test_"):
153
- return
154
- func_name = f"{prefix}{node.name}" if prefix else node.name
155
- acs: set[str] = set()
156
- is_skipped = False
157
- waiver_reason = ""
158
-
159
- for dec in node.decorator_list:
160
- d_acs, d_skip, d_waiver = self._extract_acs_from_decorator(dec)
161
- acs.update(d_acs)
162
- if d_skip:
163
- is_skipped = True
164
- if d_waiver:
165
- waiver_reason = d_waiver
166
-
167
- # 检查 docstring 兜底
168
- doc = ast.get_docstring(node) or ""
169
- if doc:
170
- for m in AC_ID_RE.findall(doc):
171
- acs.add(m)
172
- if "waiver:" in doc.lower():
173
- waiver_reason = waiver_reason or doc
174
-
175
- self.tests.append({
176
- "name": func_name,
177
- "file": self.rel_path,
178
- "line": node.lineno,
179
- "acs": acs,
180
- "skipped": is_skipped,
181
- "waiver": waiver_reason,
182
- })
183
-
184
- def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
185
- self._visit_test_func(node)
186
- self.generic_visit(node)
187
-
188
- def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
189
- self._visit_test_func(node)
190
- self.generic_visit(node)
191
-
192
- def visit_ClassDef(self, node: ast.ClassDef) -> None:
193
- if node.name.startswith("Test"):
194
- for item in node.body:
195
- if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
196
- self._visit_test_func(item, prefix=f"{node.name}.")
197
- self.generic_visit(node)
198
-
199
-
200
- def scan_backend_tests(test_dir: Path, root: Path) -> list[dict[str, Any]]:
201
- """扫描 Python 测试目录提取所有挂载了 AC 的测试用例。"""
202
- collected: list[dict[str, Any]] = []
203
- if not test_dir.is_dir():
204
- return collected
205
-
206
- for p in test_dir.rglob("test_*.py"):
207
- if any(part in SKIP_DIRS for part in p.parts):
208
- continue
209
- try:
210
- rel = str(p.relative_to(root))
211
- source = p.read_text(encoding="utf-8")
212
- tree = ast.parse(source, filename=str(p))
213
- visitor = PyTestACVisitor(rel)
214
- visitor.visit(tree)
215
- collected.extend(visitor.tests)
216
- except Exception:
217
- # 语法解析失败等,暂跳过或记录
218
- continue
219
- return collected
106
+ def _strip_cell(raw) -> str:
107
+ return ac_coverage.strip_md_cell(raw)
220
108
 
221
109
 
222
- def validate_ac_alignment(root: Path) -> Result:
223
- res = Result()
224
- profile = load_stack_profile(root)
225
- backend_test_cfg = profile.get("backend_test") or {}
226
- if not isinstance(backend_test_cfg, dict):
227
- backend_test_cfg = {}
110
+ def _empty_dim_cell(raw) -> bool:
111
+ return _strip_cell(raw) in ("", "N/A", "—", "-")
228
112
 
229
- test_dir_str = backend_test_cfg.get("test_dir") or "backend/tests"
230
- test_dir = root / test_dir_str
231
113
 
232
- # 1. 收集已声明的合法 AC 集合
233
- prd_acs_map = collect_prd_acs(root)
234
- all_declared_acs: set[str] = set()
235
- for acs in prd_acs_map.values():
236
- all_declared_acs.update(acs)
114
+ def collect_backend_dims(prd_dir: Path) -> tuple[dict[str, set[str]], list[str], bool]:
115
+ """解析 `*-prd.md` 的 `后端适用覆盖维度` 列。
237
116
 
238
- # 2. 扫描测试用例
239
- tests = scan_backend_tests(test_dir, root) if test_dir.is_dir() else []
240
-
241
- # 门控态:若没有任何 PRD 文件,且没有测试目录,给 N/A
242
- if not all_declared_acs and not tests:
243
- res.add("AC对账门禁运行态", "PASS", "无 PRD 验收标准资产且无测试目录,门禁降级通过")
244
- return res
117
+ 返回 (declared, issues, had_legal)。had_legal = 至少一张 AC 表出现该表头。
118
+ 逐表处理:一个 4 列旧表 / 错表头文件只跳过自身,不擦掉其他域。
119
+ """
120
+ declared: dict[str, set[str]] = {}
121
+ issues: list[str] = []
122
+ had_legal = False
123
+ if not prd_dir.is_dir():
124
+ return declared, issues, had_legal
125
+
126
+ for p in sorted(prd_dir.glob("*-prd.md")):
127
+ text = p.read_text(encoding="utf-8")
128
+ for header, rows in ac_coverage.iter_md_tables(text):
129
+ hdr = [_strip_cell(c) for c in header]
130
+ if "AC ID" not in hdr or "回指规则" not in hdr:
131
+ continue
132
+ ac_idx = hdr.index("AC ID")
133
+ if BACKEND_DIM_HEADER in hdr:
134
+ had_legal = True
135
+ dim_idx = hdr.index(BACKEND_DIM_HEADER)
136
+ if dim_idx == 3:
137
+ issues.append(
138
+ f"{p.name}: 后端维度列表头位于第 4 列(疑似与 E2E 列位混淆;建议保留第 4 列 E2E、把后端列放第 5 列)"
139
+ )
140
+ for row in rows:
141
+ if ac_idx >= len(row):
142
+ continue
143
+ ac = _strip_cell(row[ac_idx])
144
+ if not AC_ID_RE.fullmatch(ac):
145
+ continue
146
+ if not AC_ID_STRICT_RE.fullmatch(ac):
147
+ issues.append(f"{ac} 不符合 NNN 契约")
148
+ cell = row[dim_idx] if dim_idx < len(row) else ""
149
+ if _empty_dim_cell(cell):
150
+ continue
151
+ dims, _looks = parse_dims_cell(cell)
152
+ unknown = ac_coverage.unknown_dim_tokens(cell)
153
+ if unknown:
154
+ issues.append(f"{p.name}:{ac} 后端维度含未知 token:{','.join(unknown)}")
155
+ if not dims:
156
+ issues.append(f"{p.name}:{ac} 后端维度无法解析:{cell!r}")
157
+ continue
158
+ if ac in declared:
159
+ suffix = "(后者覆盖前者)" if declared[ac] != dims else ""
160
+ issues.append(f"{p.name}:{ac} 后端维度重复声明{suffix}")
161
+ declared[ac] = dims
162
+ continue
163
+
164
+ # 没有合法后端表头:只对「表头含 维度/备注/后端 或列位 ≥5」的列做疑似扫描;
165
+ # 永远排除第 4 列 `适用覆盖维度`(标准 E2E 表不得误判)。
166
+ suspect = [
167
+ i for i, h in enumerate(hdr)
168
+ if h != E2E_DIM_HEADER
169
+ and (("维度" in h) or ("备注" in h) or ("后端" in h) or i >= 4)
170
+ ]
171
+ hit = False
172
+ for row in rows:
173
+ if ac_idx >= len(row):
174
+ continue
175
+ ac = _strip_cell(row[ac_idx])
176
+ if not AC_ID_RE.fullmatch(ac):
177
+ continue
178
+ for i in suspect:
179
+ if i < len(row) and ac_coverage.has_dim_claim(row[i]):
180
+ hit = True
181
+ break
182
+ if hit:
183
+ break
184
+ if hit:
185
+ issues.append(f"{p.name}: 疑似后端维度列但表头/列位不符")
186
+
187
+ seen: set[str] = set()
188
+ uniq: list[str] = []
189
+ for issue in issues:
190
+ if issue not in seen:
191
+ seen.add(issue)
192
+ uniq.append(issue)
193
+ return declared, uniq, had_legal
194
+
195
+
196
+ def backend_channel_reason(root: Path, profile: dict[str, Any], backend_test_cfg: dict[str, Any]) -> str:
197
+ """维度子项的通道适用/可用判据;返回空串 = 可用。
198
+
199
+ 具体框架支持性不在这里判:extractor 解析已在主流程完成;本函数只判
200
+ 「形态 / 多后端 / 是否声明 backend_test / test_dir 是否存在」。
201
+ """
202
+ apps = profile.get("apps")
203
+ if not isinstance(apps, list) or not apps:
204
+ return "未声明 apps(无法判定后端通道)"
205
+ kinds = [a.get("kind") for a in apps if isinstance(a, dict)]
206
+ backend_kinds = [k for k in kinds if k in ("backend", "fullstack")]
207
+ if not backend_kinds:
208
+ return "无 backend/fullstack 应用(当前栈不适用)"
209
+ if not backend_test_cfg:
210
+ return "未声明 backend_test(合法降级)"
211
+ test_dir = backend_test_cfg.get("test_dir") or "backend/tests"
212
+ if not (root / test_dir).is_dir():
213
+ return f"test_dir 不存在:{test_dir}"
214
+ return ""
215
+
216
+
217
+ def _cap(items: list[str], n: int = 8) -> str:
218
+ if len(items) <= n:
219
+ return "、".join(items)
220
+ return "、".join(items[:n]) + f"…(共 {len(items)})"
221
+
222
+
223
+ def _as_str_set(value: Any) -> set[str] | None:
224
+ if not isinstance(value, (list, tuple, set)):
225
+ return None
226
+ if not all(isinstance(x, str) for x in value):
227
+ return None
228
+ return set(value)
229
+
230
+
231
+ def _record_schema_errors(tests: list[dict[str, Any]], root: Path) -> list[str]:
232
+ """校验 adapter 归一化记录的不变量;返回空列表 = 合法。"""
233
+ errors: list[str] = []
234
+ seen: set[tuple[str, int]] = set()
235
+ required = (
236
+ "name", "file", "line", "acs", "marker_acs", "doc_acs",
237
+ "dims", "dims_bad", "dims_shape_ok", "dims_present",
238
+ "skipped", "waiver", "weak", "marker_scope_unsupported",
239
+ )
240
+ for idx, t in enumerate(tests):
241
+ if not isinstance(t, dict):
242
+ errors.append(f"#{idx} 记录不是对象")
243
+ continue
244
+ missing = [k for k in required if k not in t]
245
+ if missing:
246
+ errors.append(f"#{idx} 缺字段 {missing}")
247
+ continue
248
+ name, file, line = t["name"], t["file"], t["line"]
249
+ if not isinstance(name, str) or not name.strip():
250
+ errors.append(f"#{idx} name 非空字符串")
251
+ if not isinstance(file, str) or not file.strip():
252
+ errors.append(f"#{idx} file 非空字符串")
253
+ else:
254
+ p = Path(file)
255
+ if p.is_absolute() or ".." in p.parts:
256
+ errors.append(f"#{idx} file 必须是项目根相对路径:{file!r}")
257
+ if isinstance(line, bool) or not isinstance(line, int) or line <= 0:
258
+ errors.append(f"#{idx} line 必须为正整数:{line!r}")
259
+ acs = _as_str_set(t["acs"])
260
+ marker_acs = _as_str_set(t["marker_acs"])
261
+ doc_acs = _as_str_set(t["doc_acs"])
262
+ dims = _as_str_set(t["dims"])
263
+ dims_bad = _as_str_set(t["dims_bad"])
264
+ if acs is None or marker_acs is None or doc_acs is None or dims is None or dims_bad is None:
265
+ errors.append(f"#{idx} acs/marker_acs/doc_acs/dims/dims_bad 必须是字符串集合")
266
+ continue
267
+ if marker_acs - acs or doc_acs - acs:
268
+ errors.append(f"#{idx} marker_acs/doc_acs 必须是 acs 的子集")
269
+ if acs != (marker_acs | doc_acs):
270
+ errors.append(f"#{idx} acs 必须等于 marker_acs ∪ doc_acs")
271
+ if not isinstance(t["dims_shape_ok"], bool) or not isinstance(t["dims_present"], bool):
272
+ errors.append(f"#{idx} dims_shape_ok/dims_present 必须是布尔")
273
+ if not isinstance(t["skipped"], bool) or not isinstance(t["weak"], bool):
274
+ errors.append(f"#{idx} skipped/weak 必须是布尔")
275
+ if not isinstance(t["waiver"], str):
276
+ errors.append(f"#{idx} waiver 必须是字符串")
277
+ if not isinstance(t["marker_scope_unsupported"], bool):
278
+ errors.append(f"#{idx} marker_scope_unsupported 必须是布尔")
279
+ if isinstance(file, str) and isinstance(line, int) and not isinstance(line, bool) and line > 0:
280
+ key = (file, line)
281
+ if key in seen:
282
+ errors.append(f"#{idx} (file,line) 重复:{file}:{line}")
283
+ seen.add(key)
284
+ return errors
285
+
286
+
287
+ def _run_checks(
288
+ res: Result,
289
+ result,
290
+ root: Path,
291
+ lay,
292
+ profile: dict[str, Any],
293
+ backend_test_cfg: dict[str, Any],
294
+ all_declared_acs: set[str],
295
+ mode: str,
296
+ ) -> Result:
297
+ tests = result.tests
298
+ parse_errors = result.parse_errors
299
+ scan_face = result.scan_face
245
300
 
246
- # 3. 统计挂载情况
247
301
  test_with_ac = [t for t in tests if t["acs"]]
248
302
  all_cited_acs: set[str] = set()
249
303
  dangling_acs: set[str] = set()
@@ -259,39 +313,145 @@ def validate_ac_alignment(root: Path) -> Result:
259
313
  if not waiver or "waiver:" not in waiver.lower():
260
314
  unwaived_skips.append(f"{t['file']}:{t['name']} (挂载 {','.join(t['acs'])})")
261
315
 
262
- # ---- 判据 1:无主 AC 拦截 (Dangling ACs)
263
316
  if dangling_acs:
264
317
  res.add(
265
- "无主AC拦截",
266
- "FAIL",
267
- f"测试中引用的 AC ID 未在任何 product/prd/*.md 中声明:{sorted(dangling_acs)}"
318
+ "无主AC拦截", "FAIL",
319
+ f"测试中引用的 AC ID 未在任何 {lay.print_value('prd')}/*.md 中声明:{sorted(dangling_acs)}",
268
320
  )
269
321
  else:
270
322
  res.add("无主AC拦截", "PASS", f"测试中引用的 {len(all_cited_acs)} 个 AC 均有合法 PRD 声明")
271
323
 
272
- # ---- 判据 2:无效跳过测试拦截 (Unwaived Skip)
273
324
  if unwaived_skips:
274
325
  res.add(
275
- "无效跳过拦截",
276
- "FAIL",
277
- f"挂载 AC 的测试被 skip/xfail 但未附带合法的 waiver 豁免理由:{unwaived_skips}"
326
+ "无效跳过拦截", "FAIL",
327
+ f"挂载 AC 的测试被 skip/xfail 但未附带合法的 waiver 豁免理由:{unwaived_skips}",
278
328
  )
279
329
  else:
280
330
  res.add("无效跳过拦截", "PASS", "所有挂载 AC 的跳过测试均具备有效豁免声明或无跳过")
281
331
 
282
- # ---- 判据 3:AC 双向覆盖状态统计
283
- mode = backend_test_cfg.get("mode", "advisory")
332
+ if parse_errors:
333
+ status = "FAIL" if mode == "enforce" else "WARN"
334
+ res.add("测试提取完整性", status,
335
+ f"[PARSE] 解析失败={len(parse_errors)}个:{'; '.join(parse_errors[:3])}")
336
+ else:
337
+ res.add("测试提取完整性", "PASS", "所有测试文件解析成功")
338
+
339
+ if scan_face:
340
+ res.add("测试扫描面", "WARN", f"[SCAN] {';'.join(scan_face[:4])}")
341
+
284
342
  total_declared = len(all_declared_acs)
285
343
  total_cited = len(all_cited_acs)
286
344
  coverage_ratio = (total_cited / total_declared) if total_declared > 0 else 1.0
287
-
288
345
  detail_msg = f"已声明 AC: {total_declared},后端测试挂载覆盖: {total_cited} ({coverage_ratio:.1%})"
289
346
  if mode == "enforce" and total_declared > 0 and total_cited == 0:
290
347
  res.add("AC覆盖对账", "FAIL", f"{detail_msg}(enforce 模式下不允许零测试挂载)")
348
+ elif total_declared > 0 and total_cited == 0:
349
+ res.add("AC覆盖对账", "WARN", f"[ZERO-COVERAGE] {detail_msg}")
291
350
  else:
292
- # advisory 模式或有覆盖
293
351
  res.add("AC覆盖对账", "PASS", detail_msg)
294
352
 
353
+ # ---- 判据 5:AC × 覆盖维度(后端通道)
354
+ declared_dims, dim_issues, had_legal = collect_backend_dims(lay.prd_dir)
355
+ reason = backend_channel_reason(root, profile, backend_test_cfg)
356
+ check = "AC × 覆盖维度(后端通道)"
357
+
358
+ if reason:
359
+ detail = reason
360
+ if dim_issues:
361
+ detail += ";" + ";".join(dim_issues[:4])
362
+ res.add(check, "WARN" if dim_issues else "N/A", detail)
363
+ elif not had_legal:
364
+ if dim_issues:
365
+ res.add(check, "WARN", ";".join(dim_issues[:4]))
366
+ else:
367
+ res.add(check, "N/A", "未声明「后端适用覆盖维度」列(按需启用)")
368
+ elif not declared_dims:
369
+ detail = "后端列已存在但未声明任何维度"
370
+ if dim_issues:
371
+ detail += ";" + ";".join(dim_issues[:4])
372
+ res.add(check, "WARN" if dim_issues else "N/A", detail)
373
+ else:
374
+ unique: list[dict[str, Any]] = []
375
+ seen: set[tuple[str, int]] = set()
376
+ for t in tests:
377
+ key = (t["file"], t["line"])
378
+ if key in seen:
379
+ continue
380
+ seen.add(key)
381
+ unique.append(t)
382
+
383
+ zero: list[str] = []
384
+ missing: list[str] = []
385
+ weak_only: list[str] = []
386
+ bad: list[str] = []
387
+ empty: list[str] = []
388
+ multi: list[str] = []
389
+ orphan: list[str] = []
390
+ shape: list[str] = []
391
+ pm_ac: set[str] = set()
392
+
393
+ for t in unique:
394
+ label = f"{t['file']}:{t['name']}"
395
+ if t.get("marker_scope_unsupported"):
396
+ pm_ac.add(t["file"])
397
+ if t.get("dims_bad"):
398
+ bad.append(f"{label}={'/'.join(t['dims_bad'])}")
399
+ if t.get("dims_present") and not t.get("dims_shape_ok"):
400
+ shape.append(label)
401
+ if t.get("dims_present") and not t.get("dims_bad") and t.get("dims_shape_ok") and not t.get("dims"):
402
+ empty.append(label)
403
+ if t.get("dims_present") and not t.get("marker_acs"):
404
+ orphan.append(label)
405
+ if t.get("dims_present") and len(t.get("marker_acs") or ()) > 1:
406
+ multi.append(label)
407
+
408
+ for ac, dims in sorted(declared_dims.items()):
409
+ ac_tests = [t for t in unique if ac in t["marker_acs"]]
410
+ if not ac_tests:
411
+ zero.append(ac)
412
+ continue
413
+ active: set[str] = set()
414
+ weak: set[str] = set()
415
+ for t in ac_tests:
416
+ if t.get("dims_present") and len(t.get("marker_acs") or ()) != 1:
417
+ continue
418
+ if not t.get("dims_shape_ok", True):
419
+ continue
420
+ (weak if t.get("weak") else active).update(t.get("dims") or ())
421
+ covered = active | weak
422
+ miss = dims - covered
423
+ if miss:
424
+ missing.append(f"{ac}缺{','.join(sorted(miss))}")
425
+ weak_only_dims = (dims & weak) - active
426
+ if weak_only_dims:
427
+ weak_only.append(f"{ac}仅弱证据={'/'.join(sorted(weak_only_dims))}")
428
+
429
+ parts: list[str] = []
430
+ if zero:
431
+ parts.append(f"零测试={_cap(zero)}")
432
+ if missing:
433
+ parts.append(f"缺维度={_cap(missing)}")
434
+ if weak_only:
435
+ parts.append(f"弱证据={_cap(weak_only)}")
436
+ if bad:
437
+ parts.append(f"未知维度={_cap(bad, 4)}")
438
+ if empty:
439
+ parts.append(f"空dims={_cap(empty, 4)}")
440
+ if multi:
441
+ parts.append(f"一测多AC带dims={_cap(multi, 4)}")
442
+ if orphan:
443
+ parts.append(f"孤儿dims={_cap(orphan, 4)}")
444
+ if shape:
445
+ parts.append(f"dims形态不支持={_cap(shape, 4)}")
446
+ if pm_ac:
447
+ parts.append(f"模块/类级AC标记不支持={_cap(sorted(pm_ac), 4)}")
448
+ if dim_issues:
449
+ parts.append(";".join(dim_issues[:4]))
450
+
451
+ status = "WARN" if parts else "PASS"
452
+ detail = ";".join(parts) if parts else f"{len(declared_dims)} 条 AC 后端维度全量覆盖"
453
+ res.add(check, status, detail)
454
+
295
455
  # ---- 判据 4:差量防回退 (Delta Ratchet)
296
456
  baseline_file = root / BASELINE_PATH
297
457
  delta_issues = []
@@ -320,17 +480,113 @@ def validate_ac_alignment(root: Path) -> Result:
320
480
  else:
321
481
  res.add("差量防回退", "PASS", f"当前挂载测试数: {len(test_with_ac)},覆盖 AC 数: {len(all_cited_acs)}")
322
482
 
323
- # 自动保存/刷新快照(若运行在工作区内,且本次检查未 FAIL 时才固化新基线,防坏输入污染棘轮)
324
- if res.status != "FAIL":
483
+ # 解析失败时快照不完整:不写 baseline,避免把坏快照冻成新基线
484
+ if res.status != "FAIL" and not parse_errors:
325
485
  try:
326
486
  baseline_file.parent.mkdir(parents=True, exist_ok=True)
327
- baseline_file.write_text(json.dumps(current_snapshot, indent=2, ensure_ascii=False), encoding="utf-8")
487
+ baseline_file.write_text(
488
+ json.dumps(current_snapshot, indent=2, ensure_ascii=False), encoding="utf-8"
489
+ )
328
490
  except Exception:
329
491
  pass
330
492
 
331
493
  return res
332
494
 
333
495
 
496
+ def validate_ac_alignment(root: Path) -> Result:
497
+ res = Result()
498
+ try:
499
+ lay = load_layout(root)
500
+ except LayoutError as exc:
501
+ res.add("项目布局配置", "FAIL", str(exc))
502
+ return res
503
+ if ac_adapters is None:
504
+ res.add("AC提取器注册表", "FAIL",
505
+ f"adapters/ac 加载失败:{_ADAPTER_IMPORT_ERROR}")
506
+ return res
507
+
508
+ profile = load_stack_profile(root, lay)
509
+ backend_test_cfg = profile.get("backend_test") or {}
510
+ if not isinstance(backend_test_cfg, dict):
511
+ backend_test_cfg = {}
512
+ mode = backend_test_cfg.get("mode", "advisory")
513
+ if mode not in ("advisory", "enforce"):
514
+ mode = "advisory"
515
+
516
+ apps = profile.get("apps")
517
+ apps_declared = isinstance(apps, list) and bool(apps)
518
+ backend_apps = [
519
+ a for a in (apps or [])
520
+ if isinstance(a, dict) and a.get("kind") in ("backend", "fullstack")
521
+ ] if apps_declared else []
522
+
523
+ if apps_declared and not backend_apps:
524
+ res.add("门控态", "N/A", "无 backend/fullstack 应用(当前形态不适用)")
525
+ return res
526
+
527
+ prd_acs_map = collect_prd_acs(root, lay)
528
+ all_declared_acs: set[str] = set()
529
+ for acs in prd_acs_map.values():
530
+ all_declared_acs.update(acs)
531
+
532
+ explicit = backend_test_cfg.get("extractor") or backend_test_cfg.get("framework")
533
+ command = backend_test_cfg.get("extractor_command")
534
+ test_dir_str = backend_test_cfg.get("test_dir") or "backend/tests"
535
+ if command:
536
+ try:
537
+ result = ac_command.run(command, root, root / test_dir_str)
538
+ except ac_command.ExtractError as exc:
539
+ res.add("测试提取通道", "FAIL", f"[EXTRACTOR] {exc}")
540
+ return res
541
+ else:
542
+ adapter = ac_adapters.resolve(explicit) if explicit else ac_adapters.resolve(None)
543
+ if explicit and adapter is None:
544
+ if not all_declared_acs:
545
+ res.add("AC对账门禁运行态", "N/A", "无 PRD AC 且 extractor 未注册(无输入面)")
546
+ return res
547
+ res.add(
548
+ "后端测试提取通道",
549
+ "WARN" if mode == "advisory" else "FAIL",
550
+ f"[UNVERIFIED] extractor={explicit!r} 未注册;后端 AC 覆盖未验证",
551
+ )
552
+ return res
553
+ result = adapter.extract(root, root / test_dir_str, backend_test_cfg)
554
+ input_present = result.input_state == "present"
555
+
556
+ if not all_declared_acs and not input_present:
557
+ res.add("AC对账门禁运行态", "N/A", "无 PRD 验收标准资产且无测试输入(不适用)")
558
+ return res
559
+
560
+ if not apps_declared:
561
+ res.add(
562
+ "后端通道适用性",
563
+ "WARN" if mode == "advisory" else "FAIL",
564
+ "[UNVERIFIED] stack-profile 未声明 apps,后端通道适用性无法判定",
565
+ )
566
+ if not input_present:
567
+ return res
568
+
569
+ # backend_test 已声明但既没有 extractor、默认提取器也没发现输入:
570
+ # 不假设项目是 Python,报未验证而不是 ZERO-COVERAGE。
571
+ if backend_test_cfg and not explicit and not command and not input_present:
572
+ res.add(
573
+ "后端测试提取通道",
574
+ "WARN" if mode == "advisory" else "FAIL",
575
+ "[UNVERIFIED] 未声明 extractor 且默认提取器未发现测试输入;"
576
+ "非 Python 后端请声明 extractor",
577
+ )
578
+ return res
579
+
580
+ schema_errors = _record_schema_errors(result.tests, root)
581
+ if schema_errors:
582
+ res.add("测试提取契约", "FAIL", f"[EXTRACTOR] {_cap(schema_errors, 6)}")
583
+ return res
584
+
585
+ return _run_checks(
586
+ res, result, root, lay, profile, backend_test_cfg, all_declared_acs, mode
587
+ )
588
+
589
+
334
590
  def main() -> int:
335
591
  parser = argparse.ArgumentParser(description=__doc__)
336
592
  parser.add_argument("--root", type=Path, default=Path("."), help="项目根目录")