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,380 @@
1
+ #!/usr/bin/env python3
2
+ """pytest AC 提取器(框架知识的合法落点)。
3
+
4
+ 本文件是 `validators/validate_ac_alignment.py` 的 pytest 扫描实现:
5
+ 只做「测试文件 → 归一化记录」,不判门、不读 PRD、不生成 baseline。
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ import re
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from . import ExtractResult
16
+
17
+ _ROOT = Path(__file__).resolve().parents[2]
18
+ if str(_ROOT) not in sys.path:
19
+ sys.path.insert(0, str(_ROOT))
20
+ from _support.siblings import load_module # noqa: E402
21
+ from _support.layout import load_layout # noqa: E402
22
+
23
+ _ac_coverage = load_module("ac_coverage")
24
+
25
+ # ---------------------------------------------------------------- 项目级配置兜底
26
+ # 注释剥离(2026-10-15 事故):出厂模板的 `backend_test:` 段是**注释示例**,逐字匹配 raw
27
+ # 文本会把**每个**按模板开户的项目判成 Python 工程 ⇒ 纯前端仓库里凭空多出一个 pytest.ini。
28
+ # ⇒ 只认**声明项**。
29
+ RE_COMMENT = re.compile(r"(?:^|\s+)#.*$")
30
+
31
+ MARKERS_INI = (
32
+ "[pytest]\n"
33
+ "markers =\n"
34
+ " ac: mark test with AC ID for traceability\n"
35
+ )
36
+
37
+
38
+ def is_python_project(target: Path) -> bool:
39
+ """探测是否为 Python 工程(stack-profile **声明**或根目录 Python 特征文件)。
40
+
41
+ 只看**声明**:注释里出现的关键字不算。声明面三种任一生效:
42
+ `python` 字样 / `framework: pytest` / `extractor: pytest`。
43
+ `backend_test:` 是框架中立的调度段,不能单独作为 Python 证据。
44
+ 显式声明了其它 extractor ⇒ 不是本 adapter 的项目。
45
+ """
46
+ prof_path = load_layout(target).stack_profile
47
+ if prof_path.is_file():
48
+ text = prof_path.read_text(encoding="utf-8", errors="ignore")
49
+ declared = "\n".join(RE_COMMENT.sub("", ln) for ln in text.splitlines()).lower()
50
+ m = re.search(r"^\s+(?:extractor|framework):\s*([a-z0-9][a-z0-9_-]*)\s*$",
51
+ declared, re.M)
52
+ if m and m.group(1) != "pytest":
53
+ return False
54
+ if ("python" in declared
55
+ or "framework: pytest" in declared
56
+ or "extractor: pytest" in declared):
57
+ return True
58
+ for marker in ("requirements.txt", "pyproject.toml", "setup.py", "Pipfile", ".venv"):
59
+ if (target / marker).exists():
60
+ return True
61
+ return False
62
+
63
+
64
+ def _has_pytest_section(pyproject: Path) -> bool:
65
+ try:
66
+ return "[tool.pytest" in pyproject.read_text(encoding="utf-8", errors="ignore")
67
+ except OSError:
68
+ return False
69
+
70
+
71
+ def wire_project(target: Path, force: bool = False) -> dict:
72
+ """AC 标记注册兜底——**载体优先级:已有配置 > 既有 ini > 新建 ini**。
73
+
74
+ R6.2 对账定稿(2026-10-15):本兜底只承担 **`ac` marker 注册**这一项正当元数据;
75
+ `pythonpath = .` 环境补丁已随 F23 退役。
76
+
77
+ **退役对称**:非本 adapter 工程里若躺着一份**逐字节等于兜底内容**的 ini,那是本
78
+ 脚本早先误建的 ⇒ 清掉;用户改过 ⇒ 一字不动。
79
+ """
80
+ ini_path = target / "pytest.ini"
81
+ if not force and not is_python_project(target):
82
+ if ini_path.is_file() and ini_path.read_text(encoding="utf-8") == MARKERS_INI:
83
+ return {"file": "pytest.ini", "status": "removed",
84
+ "detail": "非本 extractor 工程:清掉早先误建的配置"
85
+ "(仅当内容逐字节等于兜底内容;用户改过则保留)",
86
+ "_remove": ini_path}
87
+ return {"file": "pytest.ini", "status": "skipped", "detail": "非本 extractor 工程,跳过"}
88
+
89
+ pyproject = target / "pyproject.toml"
90
+ setup_cfg = target / "setup.cfg"
91
+
92
+ if pyproject.is_file() and _has_pytest_section(pyproject):
93
+ return {"file": "pytest.ini", "status": "skipped", "detail": "已由 pyproject.toml 承载配置"}
94
+ if setup_cfg.is_file():
95
+ text_cfg = setup_cfg.read_text(encoding="utf-8", errors="ignore")
96
+ if "[tool:pytest]" in text_cfg:
97
+ return {"file": "pytest.ini", "status": "skipped", "detail": "已由 setup.cfg 承载配置"}
98
+
99
+ if not ini_path.is_file():
100
+ return {"file": "pytest.ini", "status": "applied",
101
+ "detail": "created pytest.ini with ac marker registration",
102
+ "_write": (ini_path, MARKERS_INI)}
103
+
104
+ content = ini_path.read_text(encoding="utf-8")
105
+ if "markers" in content:
106
+ return {"file": "pytest.ini", "status": "skipped", "detail": "pytest.ini 已包含 marker 注册"}
107
+
108
+ new_content = content.rstrip("\n") + "\nmarkers =\n ac: mark test with AC ID for traceability\n"
109
+ return {"file": "pytest.ini", "status": "applied",
110
+ "detail": "appended ac marker registration to existing pytest.ini",
111
+ "_write": (ini_path, new_content)}
112
+
113
+ AC_ID_RE = re.compile(r"\b(AC-[A-Z0-9]+-\d+)\b")
114
+ SKIP_DIRS = {
115
+ "node_modules", ".git", "dist", "build", ".next", "coverage", "__pycache__",
116
+ ".venv", "venv", "vendor", ".cache", "out", "target", ".atlas", ".atlas-run", ".scratch", ".trellis",
117
+ }
118
+ _UNSUPPORTED = object()
119
+
120
+
121
+ class PyTestACVisitor(ast.NodeVisitor):
122
+ """AST 遍历提取 pytest 函数/方法中的 AC、dims、skip/weak 与 docstring 归属。"""
123
+
124
+ def __init__(self, rel_path: str) -> None:
125
+ self.rel_path = rel_path
126
+ self.tests: list[dict[str, Any]] = []
127
+ self.module_weak = False
128
+ self.module_pytestmark_ac = False
129
+ self.seen: set[tuple[str, int]] = set()
130
+
131
+ @staticmethod
132
+ def _call_path(node: ast.AST) -> str:
133
+ if not isinstance(node, ast.Call):
134
+ return ""
135
+ names: list[str] = []
136
+ cur = node.func
137
+ while isinstance(cur, ast.Attribute):
138
+ names.append(cur.attr)
139
+ cur = cur.value
140
+ if isinstance(cur, ast.Name):
141
+ names.append(cur.id)
142
+ names.reverse()
143
+ return ".".join(names)
144
+
145
+ @staticmethod
146
+ def _literal(node: ast.AST):
147
+ try:
148
+ return ast.literal_eval(node)
149
+ except Exception:
150
+ return _UNSUPPORTED
151
+
152
+ @staticmethod
153
+ def _has_mark(node: ast.AST, names: set[str]) -> bool:
154
+ for sub in ast.walk(node):
155
+ if isinstance(sub, ast.Attribute) and sub.attr in names:
156
+ return True
157
+ if isinstance(sub, ast.Name) and sub.id in names:
158
+ return True
159
+ return False
160
+
161
+ def _decorator_info(self, dec: ast.AST) -> dict[str, Any]:
162
+ info: dict[str, Any] = {
163
+ "acs": set(), "dims": set(), "dims_bad": [], "dims_shape_ok": True,
164
+ "dims_present": False, "skipped": False, "waiver": "",
165
+ "weak": False,
166
+ }
167
+ if isinstance(dec, ast.Call):
168
+ path = self._call_path(dec)
169
+ if path in ("pytest.mark.ac", "mark.ac", "ac"):
170
+ for arg in dec.args:
171
+ if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
172
+ info["acs"].update(AC_ID_RE.findall(arg.value))
173
+ elif isinstance(arg, (ast.List, ast.Tuple)):
174
+ for elt in arg.elts:
175
+ if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
176
+ info["acs"].update(AC_ID_RE.findall(elt.value))
177
+ for kw in dec.keywords:
178
+ if kw.arg != "dims":
179
+ continue
180
+ info["dims_present"] = True
181
+ val = self._literal(kw.value)
182
+ if val is _UNSUPPORTED:
183
+ info["dims_shape_ok"] = False
184
+ continue
185
+ dims, bad, ok = _ac_coverage.parse_dims_list(val)
186
+ info["dims"] = dims
187
+ info["dims_bad"] = bad
188
+ info["dims_shape_ok"] = ok
189
+ elif path in ("pytest.mark.skip", "pytest.mark.xfail", "mark.skip", "mark.xfail", "skip", "xfail"):
190
+ info["skipped"] = True
191
+ info["weak"] = True
192
+ for kw in dec.keywords:
193
+ if kw.arg == "reason" and isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
194
+ info["waiver"] = kw.value.value
195
+ for arg in dec.args:
196
+ if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
197
+ info["waiver"] = arg.value
198
+ elif path in ("pytest.mark.skipif", "mark.skipif", "skipif"):
199
+ info["weak"] = True
200
+ elif path in ("pytest.mark.parametrize", "mark.parametrize", "parametrize"):
201
+ if self._has_mark(dec, {"skip", "xfail", "skipif"}):
202
+ info["weak"] = True
203
+ elif isinstance(dec, ast.Attribute):
204
+ if dec.attr in ("skip", "xfail"):
205
+ info["skipped"] = True
206
+ info["weak"] = True
207
+ elif isinstance(dec, ast.Name) and dec.id in ("skip", "xfail"):
208
+ info["skipped"] = True
209
+ info["weak"] = True
210
+ return info
211
+
212
+ def _scan_pytestmark(self, node: ast.AST) -> tuple[bool, bool]:
213
+ if isinstance(node, ast.Assign):
214
+ targets = node.targets
215
+ elif isinstance(node, ast.AnnAssign):
216
+ targets = [node.target]
217
+ else:
218
+ return False, False
219
+ if not any(isinstance(t, ast.Name) and t.id == "pytestmark" for t in targets):
220
+ return False, False
221
+ value = node.value
222
+ if value is None:
223
+ return False, False
224
+ return (
225
+ self._has_mark(value, {"skip", "xfail", "skipif"}),
226
+ self._has_mark(value, {"ac"}),
227
+ )
228
+
229
+ def visit_Module(self, node: ast.Module) -> None:
230
+ for item in node.body:
231
+ weak, ac = self._scan_pytestmark(item)
232
+ self.module_weak = self.module_weak or weak
233
+ self.module_pytestmark_ac = self.module_pytestmark_ac or ac
234
+ self.generic_visit(node)
235
+
236
+ def _visit_test_func(
237
+ self,
238
+ node: ast.FunctionDef | ast.AsyncFunctionDef,
239
+ prefix: str = "",
240
+ class_weak: bool = False,
241
+ class_pytestmark_ac: bool = False,
242
+ ) -> None:
243
+ if not node.name.startswith("test_"):
244
+ return
245
+ key = (self.rel_path, node.lineno)
246
+ if key in self.seen:
247
+ return
248
+ self.seen.add(key)
249
+ func_name = f"{prefix}{node.name}" if prefix else node.name
250
+ marker_acs: set[str] = set()
251
+ dims: set[str] = set()
252
+ dims_bad: list[str] = []
253
+ dims_present = False
254
+ dims_shape_ok = True
255
+ is_skipped = False
256
+ waiver_reason = ""
257
+ weak = class_weak or self.module_weak
258
+
259
+ for dec in node.decorator_list:
260
+ info = self._decorator_info(dec)
261
+ marker_acs.update(info["acs"])
262
+ if info["dims_present"]:
263
+ dims_present = True
264
+ dims.update(info["dims"])
265
+ dims_bad.extend(info["dims_bad"])
266
+ dims_shape_ok = dims_shape_ok and info["dims_shape_ok"]
267
+ if info["skipped"]:
268
+ is_skipped = True
269
+ if info["waiver"]:
270
+ waiver_reason = info["waiver"]
271
+ weak = weak or info["weak"]
272
+
273
+ doc = ast.get_docstring(node) or ""
274
+ doc_acs: set[str] = set()
275
+ if doc:
276
+ doc_acs.update(AC_ID_RE.findall(doc))
277
+ if "waiver:" in doc.lower():
278
+ waiver_reason = waiver_reason or doc
279
+
280
+ self.tests.append({
281
+ "name": func_name,
282
+ "file": self.rel_path,
283
+ "line": node.lineno,
284
+ "acs": marker_acs | doc_acs,
285
+ "marker_acs": marker_acs,
286
+ "doc_acs": doc_acs,
287
+ "dims": dims,
288
+ "dims_bad": dims_bad,
289
+ "dims_shape_ok": dims_shape_ok,
290
+ "dims_present": dims_present,
291
+ "skipped": is_skipped,
292
+ "waiver": waiver_reason,
293
+ "weak": weak,
294
+ "marker_scope_unsupported": class_pytestmark_ac or self.module_pytestmark_ac,
295
+ })
296
+
297
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
298
+ self._visit_test_func(node)
299
+ self.generic_visit(node)
300
+
301
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
302
+ self._visit_test_func(node)
303
+ self.generic_visit(node)
304
+
305
+ def visit_ClassDef(self, node: ast.ClassDef) -> None:
306
+ class_weak = self.module_weak
307
+ class_ac = self.module_pytestmark_ac
308
+ for item in node.body:
309
+ weak, ac = self._scan_pytestmark(item)
310
+ class_weak = class_weak or weak
311
+ class_ac = class_ac or ac
312
+ if node.name.startswith("Test"):
313
+ for item in node.body:
314
+ if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
315
+ self._visit_test_func(
316
+ item, prefix=f"{node.name}.", class_weak=class_weak,
317
+ class_pytestmark_ac=class_ac,
318
+ )
319
+ self.generic_visit(node)
320
+
321
+
322
+ def _files(test_dir: Path, pattern: str) -> list[Path]:
323
+ return sorted(
324
+ p for p in test_dir.rglob(pattern)
325
+ if not any(part in SKIP_DIRS for part in p.parts)
326
+ )
327
+
328
+
329
+ def scan_backend_tests_detailed(
330
+ test_dir: Path, root: Path
331
+ ) -> tuple[list[dict[str, Any]], list[str], list[str], str]:
332
+ """扫描 Python 测试目录,返回 (tests, parse_errors, scan_face, input_state)。"""
333
+ collected: list[dict[str, Any]] = []
334
+ parse_errors: list[str] = []
335
+ scan_face: list[str] = []
336
+ if not test_dir.is_dir():
337
+ return collected, parse_errors, scan_face, "absent"
338
+
339
+ test_files = _files(test_dir, "test_*.py")
340
+ star_files = _files(test_dir, "*_test.py")
341
+ input_state = "present" if test_files or star_files else "absent"
342
+ if star_files:
343
+ names = ", ".join(str(p.relative_to(root)) for p in star_files[:3])
344
+ scan_face.append(f"存在未扫描的 *_test.py:{len(star_files)} 个({names})")
345
+ if not test_files:
346
+ scan_face.append(f"扫描面无 test_*.py:{test_dir}")
347
+ return collected, parse_errors, scan_face, input_state
348
+
349
+ for p in test_files:
350
+ try:
351
+ rel = str(p.relative_to(root))
352
+ source = p.read_text(encoding="utf-8")
353
+ tree = ast.parse(source, filename=str(p))
354
+ visitor = PyTestACVisitor(rel)
355
+ visitor.visit(tree)
356
+ collected.extend(visitor.tests)
357
+ except Exception as exc:
358
+ parse_errors.append(f"{p.relative_to(root)}: {exc}")
359
+ return collected, parse_errors, scan_face, input_state
360
+
361
+
362
+ def scan_backend_tests(test_dir: Path, root: Path) -> list[dict[str, Any]]:
363
+ """兼容旧调用:只返回 tests。"""
364
+ return scan_backend_tests_detailed(test_dir, root)[0]
365
+
366
+
367
+ class Extractor:
368
+ name = "pytest"
369
+
370
+ def extract(self, root: Path, test_dir: Path, cfg: dict[str, Any] | None = None) -> ExtractResult:
371
+ tests, parse_errors, scan_face, input_state = scan_backend_tests_detailed(test_dir, root)
372
+ return ExtractResult(
373
+ tests=tests,
374
+ parse_errors=parse_errors,
375
+ scan_face=scan_face,
376
+ input_state=input_state,
377
+ )
378
+
379
+ def wire_project(self, target: Path, name: str | None = None) -> dict:
380
+ return wire_project(target, force=bool(name))