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.
- package/CHANGELOG.md +267 -0
- package/DESIGN.md +46 -3
- package/README.md +24 -14
- package/_support/__pycache__/ac_coverage.cpython-312.pyc +0 -0
- package/_support/__pycache__/case_block.cpython-312.pyc +0 -0
- package/_support/__pycache__/layout.cpython-312.pyc +0 -0
- package/_support/__pycache__/manual_block.cpython-312.pyc +0 -0
- package/_support/__pycache__/stack_profile.cpython-312.pyc +0 -0
- package/_support/ac_coverage.py +120 -0
- package/_support/case_block.py +3 -3
- package/_support/layout.py +411 -0
- package/_support/manual_block.py +67 -0
- package/_support/stack_profile.py +3 -1
- package/adapters/__pycache__/_graph.cpython-312.pyc +0 -0
- package/adapters/__pycache__/_jpa.cpython-312.pyc +0 -0
- package/adapters/__pycache__/_lib.cpython-312.pyc +0 -0
- package/adapters/__pycache__/api.cpython-312.pyc +0 -0
- package/adapters/__pycache__/models.cpython-312.pyc +0 -0
- package/adapters/__pycache__/tests.cpython-312.pyc +0 -0
- package/adapters/_graph.py +7 -2
- package/adapters/_jpa.py +291 -0
- package/adapters/_lib.py +8 -3
- package/adapters/ac/__init__.py +80 -0
- package/adapters/ac/__pycache__/__init__.cpython-312.pyc +0 -0
- package/adapters/ac/__pycache__/command.cpython-312.pyc +0 -0
- package/adapters/ac/__pycache__/js.cpython-312.pyc +0 -0
- package/adapters/ac/__pycache__/junit.cpython-312.pyc +0 -0
- package/adapters/ac/__pycache__/pytest.cpython-312.pyc +0 -0
- package/adapters/ac/command.py +88 -0
- package/adapters/ac/js.py +334 -0
- package/adapters/ac/junit.py +266 -0
- package/adapters/ac/pytest.py +380 -0
- package/adapters/api.py +331 -8
- package/adapters/models.py +126 -2
- package/adapters/routes.py +7 -3
- package/adapters/tests.py +43 -5
- package/apply/reference.md +6 -3
- package/bin/atlas.js +66 -28
- package/extensions/pi.ts +39 -8
- package/install.sh +179 -19
- package/package.json +1 -1
- package/patches/project-wiring/README.md +4 -3
- package/patches/project-wiring/__pycache__/apply-wiring.cpython-312.pyc +0 -0
- package/patches/project-wiring/agent-pointer.md +1 -1
- package/patches/project-wiring/apply-wiring.py +146 -153
- package/patches/workflow-plan-apply/1.6-plan-apply.insert.md +1 -1
- package/rings/e2e/reference.md +37 -20
- package/rings/prd/reference.md +26 -8
- package/rings/structure/reference.md +3 -2
- package/scripts/__pycache__/atlas_check.cpython-312.pyc +0 -0
- package/scripts/__pycache__/atlas_report.cpython-312.pyc +0 -0
- package/scripts/__pycache__/atlas_verify.cpython-312.pyc +0 -0
- package/scripts/__pycache__/closeout_derive.cpython-312.pyc +0 -0
- package/scripts/__pycache__/e2e_console.cpython-312.pyc +0 -0
- package/scripts/__pycache__/gen_e2e_scripts.cpython-312.pyc +0 -0
- package/scripts/__pycache__/gen_testid_stub.cpython-312.pyc +0 -0
- package/scripts/__pycache__/make_review_inputs.cpython-312.pyc +0 -0
- package/scripts/__pycache__/pkg_digest.cpython-312.pyc +0 -0
- package/scripts/__pycache__/refresh_structure.cpython-312.pyc +0 -0
- package/scripts/__pycache__/release.cpython-312.pyc +0 -0
- package/scripts/__pycache__/write_claim.cpython-312.pyc +0 -0
- package/scripts/atlas_check.py +329 -86
- package/scripts/atlas_report.py +10 -2
- package/scripts/atlas_verify.py +435 -0
- package/scripts/closeout_derive.py +296 -0
- package/scripts/e2e_console.py +46 -31
- package/scripts/gen_e2e_scripts.py +424 -66
- package/scripts/gen_testid_stub.py +8 -3
- package/scripts/make_review_inputs.py +20 -6
- package/scripts/pkg_digest.py +42 -5
- package/scripts/refresh_structure.py +29 -8
- package/scripts/release.py +238 -7
- package/scripts/write_claim.py +204 -0
- package/shared/closeout.md +7 -0
- package/shared/gates.md +24 -19
- package/shared/global-rules.md +8 -1
- package/shared/independent-review.md +4 -3
- package/shared/layout.md +28 -3
- package/shared/single-source.md +2 -0
- package/shared/stack-profile.md +19 -10
- package/skills/atlas-apply/SKILL.md +1 -0
- package/skills/atlas-design-review/SKILL.md +3 -3
- package/skills/atlas-e2e/SKILL.md +1 -0
- package/skills/atlas-prd/SKILL.md +1 -0
- package/skills/atlas-release/SKILL.md +9 -3
- package/skills/atlas-structure/SKILL.md +1 -0
- package/skills/frontend-design/SKILL.md +2 -2
- package/skills/frontend-design/references/tuning-verbs.md +1 -1
- package/templates/e2e/case-shard.md +1 -1
- package/templates/e2e/console/app.js +25 -4
- package/templates/e2e/console/console.css +2 -0
- package/templates/e2e/console/index.html +2 -2
- package/templates/prd/domain.md +14 -12
- package/templates/prd/global.md +2 -2
- package/templates/stack-profile.yaml +12 -11
- package/tests/__pycache__/test_ac_adapters.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_adapter_api.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_adapter_models.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_apply_wiring.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_atlas_check.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_atlas_verify.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_atlas_verify_playground.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_canonical_consistency.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_closeout_derive.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_duty_bug_families.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_e2e_console.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_gen_e2e_scripts.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_gen_e2e_scripts.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_install_selfcheck.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_layout.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_map_anchors.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_no_framework_names.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_project_wiring.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_refresh_facts.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_shared_kernel.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_tools.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_tools.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_validate_ac_alignment.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_validate_adr.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_validate_walkthrough.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_validators.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/__pycache__/test_write_claim.cpython-312-pytest-9.1.1.pyc +0 -0
- package/tests/fixtures/e2e_coverage_dims_golden.json +12 -0
- package/tests/fixtures/verify_playground/product/e2e/cases/home.md +40 -0
- package/tests/fixtures/verify_playground/product/e2e/e2e-index.md +18 -0
- package/tests/fixtures/verify_playground/product/e2e/reviews/2026-01-01-fixture/README.md +3 -0
- package/tests/fixtures/verify_playground/product/e2e/reviews/2026-01-01-fixture/review.json +17 -0
- package/tests/fixtures/verify_playground/product/e2e/scripts/atlas-prepare.mjs +4 -0
- package/tests/fixtures/verify_playground/product/prd/README.md +5 -0
- package/tests/fixtures/verify_playground/product/prd/home-prd.md +62 -0
- package/tests/fixtures/verify_playground/product/prd/prd.md +52 -0
- package/tests/fixtures/verify_playground/product/stack-profile.yaml.tpl +18 -0
- package/tests/fixtures/verify_playground/site/index.html +6 -0
- package/tests/test_ac_adapters.py +312 -0
- package/tests/test_adapter_api.py +253 -0
- package/tests/test_adapter_models.py +236 -0
- package/tests/test_apply_wiring.py +136 -23
- package/tests/test_atlas_check.py +371 -0
- package/tests/test_atlas_verify.py +335 -0
- package/tests/test_atlas_verify_playground.py +162 -0
- package/tests/test_canonical_consistency.py +5 -2
- package/tests/test_closeout_derive.py +264 -0
- package/tests/test_duty_bug_families.py +355 -3
- package/tests/test_e2e_console.py +49 -0
- package/tests/test_gen_e2e_scripts.py +650 -17
- package/tests/test_install_selfcheck.py +76 -0
- package/tests/test_layout.py +252 -0
- package/tests/test_map_anchors.py +475 -0
- package/tests/test_no_framework_names.py +86 -0
- package/tests/test_project_wiring.py +12 -4
- package/tests/test_refresh_facts.py +18 -3
- package/tests/test_shared_kernel.py +50 -0
- package/tests/test_tools.py +257 -1
- package/tests/test_validate_ac_alignment.py +1081 -0
- package/tests/test_validate_adr.py +24 -0
- package/tests/test_validate_walkthrough.py +313 -0
- package/tests/test_validators.py +499 -1
- package/tests/test_write_claim.py +236 -0
- package/validators/__pycache__/validate_ac_alignment.cpython-312.pyc +0 -0
- package/validators/__pycache__/validate_adr.cpython-312.pyc +0 -0
- package/validators/__pycache__/validate_design_exemptions.cpython-312.pyc +0 -0
- package/validators/__pycache__/validate_e2e_index.cpython-312.pyc +0 -0
- package/validators/__pycache__/validate_e2e_mocks.cpython-312.pyc +0 -0
- package/validators/__pycache__/validate_prd.cpython-312.pyc +0 -0
- package/validators/__pycache__/validate_stack_profile.cpython-312.pyc +0 -0
- package/validators/__pycache__/validate_structure.cpython-312.pyc +0 -0
- package/validators/__pycache__/validate_testids.cpython-312.pyc +0 -0
- package/validators/__pycache__/validate_walkthrough.cpython-312.pyc +0 -0
- package/validators/validate_ac_alignment.py +439 -183
- package/validators/validate_adr.py +15 -5
- package/validators/validate_design_exemptions.py +15 -6
- package/validators/validate_e2e_index.py +109 -38
- package/validators/validate_e2e_mocks.py +12 -5
- package/validators/validate_prd.py +109 -14
- package/validators/validate_stack_profile.py +62 -8
- package/validators/validate_structure.py +9 -2
- package/validators/validate_testids.py +36 -23
- 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))
|