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,334 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Vitest/Jest AC 提取器(框架知识的合法落点)。
|
|
3
|
+
|
|
4
|
+
轻量词法扫描:识别 `it/test/describe` 调用、标题字符串、紧邻 JSDoc 与
|
|
5
|
+
`.skip/.todo/.fails` 等修饰;不引入 TS/JS parser,保持零第三方依赖。
|
|
6
|
+
|
|
7
|
+
标记约定(与 `docs/guide/backend-test.md` 对齐):
|
|
8
|
+
* AC:测试标题里的 `[AC-...]`;
|
|
9
|
+
* dims:紧邻 JSDoc 的 `@atlas-dims C01,C11`;
|
|
10
|
+
* waiver:标题或 JSDoc 里的 `waiver:` / `@atlas-waiver`。
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from . import ExtractResult
|
|
21
|
+
|
|
22
|
+
_ROOT = Path(__file__).resolve().parents[2]
|
|
23
|
+
if str(_ROOT) not in sys.path:
|
|
24
|
+
sys.path.insert(0, str(_ROOT))
|
|
25
|
+
from _support.siblings import load_module # noqa: E402
|
|
26
|
+
|
|
27
|
+
_ac_coverage = load_module("ac_coverage")
|
|
28
|
+
|
|
29
|
+
AC_ID_RE = re.compile(r"\b(AC-[A-Z0-9]+-\d+)\b")
|
|
30
|
+
TITLE_AC_RE = re.compile(r"\[(AC-[A-Z0-9]+-\d+)\]")
|
|
31
|
+
CALL_RE = re.compile(
|
|
32
|
+
r"\b(it|test|describe|xit|xdescribe|fit|fdescribe)\b"
|
|
33
|
+
r"(\s*\.\s*(skip|todo|only|fails|concurrent|skipIf|each))?\s*\("
|
|
34
|
+
)
|
|
35
|
+
DESCRIBE_NAMES = {"describe", "xdescribe", "fdescribe"}
|
|
36
|
+
TEST_NAMES = {"it", "test", "xit", "fit"}
|
|
37
|
+
JS_SUFFIXES = {".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"}
|
|
38
|
+
SKIP_DIRS = {
|
|
39
|
+
"node_modules", ".git", "dist", "build", ".next", ".nuxt", ".output",
|
|
40
|
+
"coverage", "__pycache__", ".venv", "venv", "vendor", ".cache", "out",
|
|
41
|
+
"target", ".atlas", ".atlas-run", ".scratch", ".trellis", ".turbo",
|
|
42
|
+
}
|
|
43
|
+
E2E_PREFIXES = ("product/e2e/", ".atlas-run/", ".atlas/")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ------------------------------------------------------------------ 轻量词法
|
|
47
|
+
|
|
48
|
+
def _skip_ws_comments(text: str, pos: int) -> int:
|
|
49
|
+
while pos < len(text):
|
|
50
|
+
ch = text[pos]
|
|
51
|
+
if ch.isspace():
|
|
52
|
+
pos += 1
|
|
53
|
+
elif text.startswith("//", pos):
|
|
54
|
+
nl = text.find("\n", pos)
|
|
55
|
+
pos = len(text) if nl < 0 else nl + 1
|
|
56
|
+
elif text.startswith("/*", pos):
|
|
57
|
+
end = text.find("*/", pos + 2)
|
|
58
|
+
pos = len(text) if end < 0 else end + 2
|
|
59
|
+
else:
|
|
60
|
+
break
|
|
61
|
+
return pos
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _parse_string(text: str, pos: int) -> tuple[str, int, bool] | None:
|
|
65
|
+
"""解析字符串/模板字面量;模板含 ${} ⇒ dynamic=True。"""
|
|
66
|
+
if pos >= len(text) or text[pos] not in "'\"`":
|
|
67
|
+
return None
|
|
68
|
+
quote = text[pos]
|
|
69
|
+
i = pos + 1
|
|
70
|
+
out: list[str] = []
|
|
71
|
+
dynamic = False
|
|
72
|
+
while i < len(text):
|
|
73
|
+
ch = text[i]
|
|
74
|
+
if ch == "\\":
|
|
75
|
+
if i + 1 >= len(text):
|
|
76
|
+
return None
|
|
77
|
+
out.append(text[i:i + 2])
|
|
78
|
+
i += 2
|
|
79
|
+
elif ch == quote:
|
|
80
|
+
return ("".join(out), i + 1, dynamic)
|
|
81
|
+
elif quote == "`" and text.startswith("${", i):
|
|
82
|
+
dynamic = True
|
|
83
|
+
depth = 1
|
|
84
|
+
j = i + 2
|
|
85
|
+
while j < len(text) and depth:
|
|
86
|
+
if text[j] == "{":
|
|
87
|
+
depth += 1
|
|
88
|
+
elif text[j] == "}":
|
|
89
|
+
depth -= 1
|
|
90
|
+
j += 1
|
|
91
|
+
if depth:
|
|
92
|
+
return None
|
|
93
|
+
out.append("${...}")
|
|
94
|
+
i = j
|
|
95
|
+
else:
|
|
96
|
+
out.append(ch)
|
|
97
|
+
i += 1
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _match_paren(text: str, open_pos: int) -> int | None:
|
|
102
|
+
depth = 0
|
|
103
|
+
i = open_pos
|
|
104
|
+
quote = ""
|
|
105
|
+
while i < len(text):
|
|
106
|
+
ch = text[i]
|
|
107
|
+
if quote:
|
|
108
|
+
if ch == "\\":
|
|
109
|
+
i += 2
|
|
110
|
+
continue
|
|
111
|
+
if ch == quote:
|
|
112
|
+
quote = ""
|
|
113
|
+
i += 1
|
|
114
|
+
continue
|
|
115
|
+
if text.startswith("//", i):
|
|
116
|
+
nl = text.find("\n", i)
|
|
117
|
+
i = len(text) if nl < 0 else nl + 1
|
|
118
|
+
continue
|
|
119
|
+
if text.startswith("/*", i):
|
|
120
|
+
end = text.find("*/", i + 2)
|
|
121
|
+
i = len(text) if end < 0 else end + 2
|
|
122
|
+
continue
|
|
123
|
+
if ch in "'\"`":
|
|
124
|
+
quote = ch
|
|
125
|
+
i += 1
|
|
126
|
+
continue
|
|
127
|
+
if ch == "(":
|
|
128
|
+
depth += 1
|
|
129
|
+
elif ch == ")":
|
|
130
|
+
depth -= 1
|
|
131
|
+
if depth == 0:
|
|
132
|
+
return i
|
|
133
|
+
i += 1
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _noncode_ranges(text: str) -> list[tuple[int, int]]:
|
|
138
|
+
ranges: list[tuple[int, int]] = []
|
|
139
|
+
i, n = 0, len(text)
|
|
140
|
+
while i < n:
|
|
141
|
+
if text.startswith("//", i):
|
|
142
|
+
j = text.find("\n", i)
|
|
143
|
+
j = n if j < 0 else j
|
|
144
|
+
ranges.append((i, j))
|
|
145
|
+
i = j
|
|
146
|
+
elif text.startswith("/*", i):
|
|
147
|
+
j = text.find("*/", i + 2)
|
|
148
|
+
j = n if j < 0 else j + 2
|
|
149
|
+
ranges.append((i, j))
|
|
150
|
+
i = j
|
|
151
|
+
elif text[i] in "'\"`":
|
|
152
|
+
parsed = _parse_string(text, i)
|
|
153
|
+
if parsed is None:
|
|
154
|
+
i += 1
|
|
155
|
+
continue
|
|
156
|
+
ranges.append((i, parsed[1]))
|
|
157
|
+
i = parsed[1]
|
|
158
|
+
else:
|
|
159
|
+
i += 1
|
|
160
|
+
return ranges
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _is_code(ranges: list[tuple[int, int]], pos: int) -> bool:
|
|
164
|
+
return not any(start <= pos < end for start, end in ranges)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _line_of(text: str, pos: int) -> int:
|
|
168
|
+
return text.count("\n", 0, pos) + 1
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _jsdoc_before(text: str, pos: int) -> str:
|
|
172
|
+
start = text.rfind("/**", 0, pos)
|
|
173
|
+
if start < 0:
|
|
174
|
+
return ""
|
|
175
|
+
end = text.find("*/", start + 3)
|
|
176
|
+
if end < 0 or end + 2 > pos:
|
|
177
|
+
return ""
|
|
178
|
+
if text[end + 2:pos].strip():
|
|
179
|
+
return ""
|
|
180
|
+
return text[start:end + 2]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _jsdoc_tags(block: str) -> dict[str, Any]:
|
|
184
|
+
out: dict[str, Any] = {
|
|
185
|
+
"acs": set(), "dims": set(), "dims_bad": [], "dims_shape_ok": True,
|
|
186
|
+
"dims_present": False, "waiver": "",
|
|
187
|
+
}
|
|
188
|
+
if not block:
|
|
189
|
+
return out
|
|
190
|
+
m = re.search(r"@atlas-ac\b([^\n*]*)", block)
|
|
191
|
+
if m:
|
|
192
|
+
out["acs"].update(AC_ID_RE.findall(m.group(1)))
|
|
193
|
+
m = re.search(r"@atlas-dims\b([^\n*]*)", block)
|
|
194
|
+
if m:
|
|
195
|
+
out["dims_present"] = True
|
|
196
|
+
dims, bad, ok = _ac_coverage.parse_dims_list(m.group(1).strip())
|
|
197
|
+
out["dims"], out["dims_bad"], out["dims_shape_ok"] = dims, bad, ok
|
|
198
|
+
m = re.search(r"@atlas-waiver\b([^\n*]*)", block)
|
|
199
|
+
if m:
|
|
200
|
+
out["waiver"] = m.group(1).strip()
|
|
201
|
+
return out
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _calls(text: str) -> list[dict[str, Any]]:
|
|
205
|
+
ranges = _noncode_ranges(text)
|
|
206
|
+
out: list[dict[str, Any]] = []
|
|
207
|
+
for m in CALL_RE.finditer(text):
|
|
208
|
+
if not _is_code(ranges, m.start()):
|
|
209
|
+
continue
|
|
210
|
+
name = m.group(1)
|
|
211
|
+
if name not in TEST_NAMES | DESCRIBE_NAMES:
|
|
212
|
+
continue
|
|
213
|
+
open_pos = m.end() - 1
|
|
214
|
+
close = _match_paren(text, open_pos)
|
|
215
|
+
if close is None:
|
|
216
|
+
continue
|
|
217
|
+
arg_pos = _skip_ws_comments(text, open_pos + 1)
|
|
218
|
+
parsed = _parse_string(text, arg_pos)
|
|
219
|
+
out.append({
|
|
220
|
+
"name": name,
|
|
221
|
+
"modifier": m.group(3) or "",
|
|
222
|
+
"start": m.start(),
|
|
223
|
+
"end": close,
|
|
224
|
+
"title": parsed[0] if parsed else None,
|
|
225
|
+
"dynamic": parsed[2] if parsed else False,
|
|
226
|
+
"jsdoc": _jsdoc_before(text, m.start()),
|
|
227
|
+
"line": _line_of(text, m.start()),
|
|
228
|
+
})
|
|
229
|
+
return out
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _iter_test_files(test_dir: Path, root: Path) -> list[Path]:
|
|
233
|
+
if not test_dir.is_dir():
|
|
234
|
+
return []
|
|
235
|
+
files: list[Path] = []
|
|
236
|
+
for dirpath, dirnames, filenames in os.walk(test_dir):
|
|
237
|
+
dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS and not d.startswith("."))
|
|
238
|
+
for name in sorted(filenames):
|
|
239
|
+
p = Path(dirpath) / name
|
|
240
|
+
if p.suffix not in JS_SUFFIXES:
|
|
241
|
+
continue
|
|
242
|
+
if ".test." not in name and ".spec." not in name and "__tests__" not in p.parts:
|
|
243
|
+
continue
|
|
244
|
+
try:
|
|
245
|
+
rel = p.relative_to(root).as_posix()
|
|
246
|
+
except ValueError:
|
|
247
|
+
rel = p.as_posix()
|
|
248
|
+
if rel.startswith(E2E_PREFIXES):
|
|
249
|
+
continue
|
|
250
|
+
files.append(p)
|
|
251
|
+
return files
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class Extractor:
|
|
255
|
+
def __init__(self, name: str = "vitest") -> None:
|
|
256
|
+
self.name = name
|
|
257
|
+
|
|
258
|
+
def extract(self, root: Path, test_dir: Path, cfg: dict[str, Any] | None = None) -> ExtractResult:
|
|
259
|
+
files = _iter_test_files(test_dir, root)
|
|
260
|
+
if not files:
|
|
261
|
+
return ExtractResult(
|
|
262
|
+
input_state="absent",
|
|
263
|
+
scan_face=[f"扫描面无 JS/TS 测试文件:{test_dir}"],
|
|
264
|
+
)
|
|
265
|
+
tests: list[dict[str, Any]] = []
|
|
266
|
+
parse_errors: list[str] = []
|
|
267
|
+
scan_face: list[str] = []
|
|
268
|
+
for p in files:
|
|
269
|
+
try:
|
|
270
|
+
text = p.read_text(encoding="utf-8")
|
|
271
|
+
except Exception as exc:
|
|
272
|
+
parse_errors.append(f"{p.relative_to(root)}: {exc}")
|
|
273
|
+
continue
|
|
274
|
+
rel = str(p.relative_to(root))
|
|
275
|
+
calls = _calls(text)
|
|
276
|
+
describes = [c for c in calls if c["name"] in DESCRIBE_NAMES]
|
|
277
|
+
for c in calls:
|
|
278
|
+
if c["name"] in DESCRIBE_NAMES:
|
|
279
|
+
continue
|
|
280
|
+
if c["title"] is None:
|
|
281
|
+
scan_face.append(
|
|
282
|
+
f"{rel}:{c['line']} 动态/非字符串标题(test.each / skipIf / 变量),未静态提取"
|
|
283
|
+
)
|
|
284
|
+
continue
|
|
285
|
+
parents = [d for d in describes if d["start"] < c["start"] < d["end"]]
|
|
286
|
+
parents.sort(key=lambda d: d["start"])
|
|
287
|
+
parent_titles = [d["title"] or "<dynamic>" for d in parents]
|
|
288
|
+
parent_skip = any(
|
|
289
|
+
d["name"] == "xdescribe" or d["modifier"] in ("skip", "todo")
|
|
290
|
+
for d in parents
|
|
291
|
+
)
|
|
292
|
+
parent_scope = False
|
|
293
|
+
for d in parents:
|
|
294
|
+
dt = _jsdoc_tags(d["jsdoc"])
|
|
295
|
+
if TITLE_AC_RE.search(d["title"] or "") or dt["acs"] or dt["dims_present"]:
|
|
296
|
+
parent_scope = True
|
|
297
|
+
tags = _jsdoc_tags(c["jsdoc"])
|
|
298
|
+
title_acs = set(TITLE_AC_RE.findall(c["title"]))
|
|
299
|
+
marker_acs = title_acs | tags["acs"]
|
|
300
|
+
skipped = parent_skip or c["name"] == "xit" or c["modifier"] in ("skip", "todo")
|
|
301
|
+
weak = skipped or c["modifier"] in ("fails", "skipIf") or c["dynamic"]
|
|
302
|
+
waiver = tags["waiver"] if skipped else ""
|
|
303
|
+
if skipped and not waiver and "waiver:" in c["title"].lower():
|
|
304
|
+
waiver = c["title"]
|
|
305
|
+
if c["modifier"] == "each":
|
|
306
|
+
scan_face.append(f"{rel}:{c['line']} test.each 表格未静态展开")
|
|
307
|
+
if c["name"] == "fit" or c["modifier"] == "only":
|
|
308
|
+
scan_face.append(f"{rel}:{c['line']} 聚焦用例(fit/only)")
|
|
309
|
+
if c["dynamic"]:
|
|
310
|
+
scan_face.append(f"{rel}:{c['line']} 动态标题,按静态可读部分计入")
|
|
311
|
+
if parent_scope:
|
|
312
|
+
scan_face.append(f"{rel}:{c['line']} describe 级 AC/dims 不支持继承")
|
|
313
|
+
tests.append({
|
|
314
|
+
"name": " > ".join(parent_titles + [c["title"]]),
|
|
315
|
+
"file": rel,
|
|
316
|
+
"line": c["line"],
|
|
317
|
+
"acs": marker_acs,
|
|
318
|
+
"marker_acs": marker_acs,
|
|
319
|
+
"doc_acs": set(),
|
|
320
|
+
"dims": tags["dims"],
|
|
321
|
+
"dims_bad": tags["dims_bad"],
|
|
322
|
+
"dims_shape_ok": tags["dims_shape_ok"],
|
|
323
|
+
"dims_present": tags["dims_present"],
|
|
324
|
+
"skipped": skipped,
|
|
325
|
+
"waiver": waiver,
|
|
326
|
+
"weak": weak,
|
|
327
|
+
"marker_scope_unsupported": parent_scope,
|
|
328
|
+
})
|
|
329
|
+
return ExtractResult(
|
|
330
|
+
tests=tests,
|
|
331
|
+
parse_errors=parse_errors,
|
|
332
|
+
scan_face=scan_face,
|
|
333
|
+
input_state="present",
|
|
334
|
+
)
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""JUnit 5 AC 提取器(框架知识的合法落点)。
|
|
3
|
+
|
|
4
|
+
轻量行级扫描:识别 `@Test/@ParameterizedTest/@RepeatedTest/@TestFactory` 方法、
|
|
5
|
+
`@DisplayName`、`@Disabled/@Ignore`、`@Tag("atlas-dims-Cxx")`;不引入 Java
|
|
6
|
+
parser/JVM 依赖。JUnit 的复杂动态形态(TestFactory / 参数化占位符)标 weak
|
|
7
|
+
并进扫描面,不假装覆盖。
|
|
8
|
+
|
|
9
|
+
标记约定(与 `docs/guide/backend-test.md` 对齐):
|
|
10
|
+
* AC:`@DisplayName("[AC-...] ...")`(或方法名里的 `[AC-...]`);
|
|
11
|
+
* dims:重复的 `@Tag("atlas-dims-C01")`(JUnit @Tag 不允许逗号);
|
|
12
|
+
* waiver:`@Disabled("waiver: ...")` / `@Ignore("waiver: ...")`。
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from . import ExtractResult
|
|
23
|
+
|
|
24
|
+
_ROOT = Path(__file__).resolve().parents[2]
|
|
25
|
+
if str(_ROOT) not in sys.path:
|
|
26
|
+
sys.path.insert(0, str(_ROOT))
|
|
27
|
+
from _support.siblings import load_module # noqa: E402
|
|
28
|
+
|
|
29
|
+
_ac_coverage = load_module("ac_coverage")
|
|
30
|
+
|
|
31
|
+
TITLE_AC_RE = re.compile(r"\[(AC-[A-Z0-9]+-\d+)\]")
|
|
32
|
+
TEST_ANN_RE = re.compile(r"@(Test|ParameterizedTest|RepeatedTest|TestFactory)\b")
|
|
33
|
+
CLASS_RE = re.compile(r"\b(?:class|interface|record|enum)\s+([A-Za-z_]\w*)")
|
|
34
|
+
METHOD_RE = re.compile(r"([A-Za-z_]\w*)\s*\(")
|
|
35
|
+
JAVA_KEYWORDS = {
|
|
36
|
+
"if", "for", "while", "switch", "catch", "new", "return", "throw",
|
|
37
|
+
"synchronized", "try", "assert", "super", "this",
|
|
38
|
+
}
|
|
39
|
+
SKIP_DIRS = {
|
|
40
|
+
"node_modules", ".git", "target", "build", "out", "dist", "coverage",
|
|
41
|
+
"__pycache__", ".venv", "venv", ".atlas", ".atlas-run", ".scratch", ".trellis", ".gradle",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _strip_comments(text: str) -> str:
|
|
46
|
+
out: list[str] = []
|
|
47
|
+
i, n = 0, len(text)
|
|
48
|
+
quote = ""
|
|
49
|
+
in_line = False
|
|
50
|
+
in_block = False
|
|
51
|
+
while i < n:
|
|
52
|
+
if in_line:
|
|
53
|
+
if text[i] == "\n":
|
|
54
|
+
in_line = False
|
|
55
|
+
out.append("\n")
|
|
56
|
+
else:
|
|
57
|
+
out.append(" ")
|
|
58
|
+
i += 1
|
|
59
|
+
continue
|
|
60
|
+
if in_block:
|
|
61
|
+
if text.startswith("*/", i):
|
|
62
|
+
in_block = False
|
|
63
|
+
out.append(" ")
|
|
64
|
+
i += 2
|
|
65
|
+
else:
|
|
66
|
+
out.append("\n" if text[i] == "\n" else " ")
|
|
67
|
+
i += 1
|
|
68
|
+
continue
|
|
69
|
+
if quote:
|
|
70
|
+
out.append(text[i])
|
|
71
|
+
if text[i] == "\\" and i + 1 < n:
|
|
72
|
+
out.append(text[i + 1])
|
|
73
|
+
i += 2
|
|
74
|
+
continue
|
|
75
|
+
if text[i] == quote:
|
|
76
|
+
quote = ""
|
|
77
|
+
i += 1
|
|
78
|
+
continue
|
|
79
|
+
if text.startswith("//", i):
|
|
80
|
+
in_line = True
|
|
81
|
+
out.append(" ")
|
|
82
|
+
i += 2
|
|
83
|
+
continue
|
|
84
|
+
if text.startswith("/*", i):
|
|
85
|
+
in_block = True
|
|
86
|
+
out.append(" ")
|
|
87
|
+
i += 2
|
|
88
|
+
continue
|
|
89
|
+
if text[i] in "'\"":
|
|
90
|
+
quote = text[i]
|
|
91
|
+
out.append(text[i])
|
|
92
|
+
i += 1
|
|
93
|
+
return "".join(out)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _unescape(s: str) -> str:
|
|
97
|
+
return (s.replace("\\n", "\n").replace("\\t", "\t")
|
|
98
|
+
.replace('\\"', '"').replace("\\\\", "\\"))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _ann_value(annotations: str, name: str) -> str:
|
|
102
|
+
m = re.search(
|
|
103
|
+
r"@%s\s*\(\s*(?:value\s*=\s*)?\"((?:\\.|[^\"\\])*)\"" % re.escape(name),
|
|
104
|
+
annotations,
|
|
105
|
+
)
|
|
106
|
+
return _unescape(m.group(1)) if m else ""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _tag_values(annotations: str) -> list[str]:
|
|
110
|
+
return [_unescape(m.group(1)) for m in re.finditer(
|
|
111
|
+
r"@Tag\s*\(\s*\"((?:\\.|[^\"\\])*)\"\s*\)", annotations
|
|
112
|
+
)]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _iter_java_files(test_dir: Path, root: Path) -> list[Path]:
|
|
116
|
+
if not test_dir.is_dir():
|
|
117
|
+
return []
|
|
118
|
+
files: list[Path] = []
|
|
119
|
+
for dirpath, dirnames, filenames in os.walk(test_dir):
|
|
120
|
+
dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS and not d.startswith("."))
|
|
121
|
+
for name in sorted(filenames):
|
|
122
|
+
if not name.endswith(".java"):
|
|
123
|
+
continue
|
|
124
|
+
p = Path(dirpath) / name
|
|
125
|
+
try:
|
|
126
|
+
rel = p.relative_to(root).as_posix()
|
|
127
|
+
except ValueError:
|
|
128
|
+
rel = p.as_posix()
|
|
129
|
+
if "/src/main/" in f"/{rel}":
|
|
130
|
+
continue
|
|
131
|
+
files.append(p)
|
|
132
|
+
return files
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _scan(text: str, rel: str) -> tuple[list[dict[str, Any]], list[str]]:
|
|
136
|
+
clean = _strip_comments(text)
|
|
137
|
+
tests: list[dict[str, Any]] = []
|
|
138
|
+
scan_face: list[str] = []
|
|
139
|
+
class_name = ""
|
|
140
|
+
class_scope = False
|
|
141
|
+
class_disabled = False
|
|
142
|
+
class_waiver = ""
|
|
143
|
+
pending: list[str] = []
|
|
144
|
+
|
|
145
|
+
def flush_pending(line: str, lineno: int) -> None:
|
|
146
|
+
nonlocal pending
|
|
147
|
+
ann = "\n".join(pending)
|
|
148
|
+
pending = []
|
|
149
|
+
if not TEST_ANN_RE.search(ann):
|
|
150
|
+
return
|
|
151
|
+
m = METHOD_RE.search(line)
|
|
152
|
+
if not m:
|
|
153
|
+
return
|
|
154
|
+
method = m.group(1)
|
|
155
|
+
if method in JAVA_KEYWORDS:
|
|
156
|
+
return
|
|
157
|
+
display = _ann_value(ann, "DisplayName")
|
|
158
|
+
title = display or method
|
|
159
|
+
acs = set(TITLE_AC_RE.findall(display)) | set(TITLE_AC_RE.findall(method))
|
|
160
|
+
tags = _tag_values(ann)
|
|
161
|
+
dims: set[str] = set()
|
|
162
|
+
bad: list[str] = []
|
|
163
|
+
dims_present = False
|
|
164
|
+
shape_ok = True
|
|
165
|
+
for tag in tags:
|
|
166
|
+
if not tag.startswith("atlas-dims-"):
|
|
167
|
+
continue
|
|
168
|
+
dims_present = True
|
|
169
|
+
suffix = tag[len("atlas-dims-"):]
|
|
170
|
+
parsed, bad_tokens, ok = _ac_coverage.parse_dims_list(suffix)
|
|
171
|
+
dims.update(parsed)
|
|
172
|
+
bad.extend(bad_tokens)
|
|
173
|
+
shape_ok = shape_ok and ok
|
|
174
|
+
disabled_reason = _ann_value(ann, "Disabled") or _ann_value(ann, "Ignore")
|
|
175
|
+
skipped = class_disabled or bool(re.search(r"@(Disabled|Ignore)\b", ann))
|
|
176
|
+
waiver = ""
|
|
177
|
+
if skipped:
|
|
178
|
+
candidate = disabled_reason or class_waiver
|
|
179
|
+
waiver = candidate if "waiver:" in candidate.lower() else ""
|
|
180
|
+
parameterized = bool(re.search(r"@ParameterizedTest\b", ann))
|
|
181
|
+
test_factory = bool(re.search(r"@TestFactory\b", ann))
|
|
182
|
+
dynamic_display = bool(display) and ("{" in display or "#" in display)
|
|
183
|
+
weak = skipped or parameterized or test_factory or dynamic_display
|
|
184
|
+
if class_scope:
|
|
185
|
+
scan_face.append(f"{rel}:{lineno} class 级 AC/dims 不支持继承")
|
|
186
|
+
if test_factory:
|
|
187
|
+
scan_face.append(f"{rel}:{lineno} @TestFactory 动态用例未静态展开")
|
|
188
|
+
if dynamic_display:
|
|
189
|
+
scan_face.append(f"{rel}:{lineno} 参数化 DisplayName 占位符,按静态部分计入")
|
|
190
|
+
tests.append({
|
|
191
|
+
"name": f"{class_name}.{method}" if class_name else method,
|
|
192
|
+
"file": rel,
|
|
193
|
+
"line": lineno,
|
|
194
|
+
"acs": acs,
|
|
195
|
+
"marker_acs": acs,
|
|
196
|
+
"doc_acs": set(),
|
|
197
|
+
"dims": dims,
|
|
198
|
+
"dims_bad": bad,
|
|
199
|
+
"dims_shape_ok": shape_ok,
|
|
200
|
+
"dims_present": dims_present,
|
|
201
|
+
"skipped": skipped,
|
|
202
|
+
"waiver": waiver,
|
|
203
|
+
"weak": weak,
|
|
204
|
+
"marker_scope_unsupported": class_scope,
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
for lineno, line in enumerate(clean.splitlines(), 1):
|
|
208
|
+
s = line.strip()
|
|
209
|
+
if not s:
|
|
210
|
+
continue
|
|
211
|
+
if s.startswith("@"):
|
|
212
|
+
pending.append(s)
|
|
213
|
+
continue
|
|
214
|
+
cm = CLASS_RE.search(s)
|
|
215
|
+
if cm:
|
|
216
|
+
class_name = cm.group(1)
|
|
217
|
+
ann = "\n".join(pending)
|
|
218
|
+
pending = []
|
|
219
|
+
tags = _tag_values(ann)
|
|
220
|
+
display = _ann_value(ann, "DisplayName")
|
|
221
|
+
class_scope = bool(TITLE_AC_RE.search(display)) or any(
|
|
222
|
+
t.startswith("atlas-dims-") for t in tags
|
|
223
|
+
)
|
|
224
|
+
class_disabled = bool(re.search(r"@(Disabled|Ignore)\b", ann))
|
|
225
|
+
reason = _ann_value(ann, "Disabled") or _ann_value(ann, "Ignore")
|
|
226
|
+
class_waiver = reason if "waiver:" in reason.lower() else ""
|
|
227
|
+
continue
|
|
228
|
+
if "(" in s and pending:
|
|
229
|
+
flush_pending(s, lineno)
|
|
230
|
+
continue
|
|
231
|
+
if pending and (";" in s or "=" in s or "}" in s):
|
|
232
|
+
pending = []
|
|
233
|
+
|
|
234
|
+
return tests, scan_face
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class Extractor:
|
|
238
|
+
def __init__(self, name: str = "junit5") -> None:
|
|
239
|
+
self.name = name
|
|
240
|
+
|
|
241
|
+
def extract(self, root: Path, test_dir: Path, cfg: dict[str, Any] | None = None) -> ExtractResult:
|
|
242
|
+
files = _iter_java_files(test_dir, root)
|
|
243
|
+
if not files:
|
|
244
|
+
return ExtractResult(
|
|
245
|
+
input_state="absent",
|
|
246
|
+
scan_face=[f"扫描面无 Java 测试文件:{test_dir}"],
|
|
247
|
+
)
|
|
248
|
+
tests: list[dict[str, Any]] = []
|
|
249
|
+
parse_errors: list[str] = []
|
|
250
|
+
scan_face: list[str] = []
|
|
251
|
+
for p in files:
|
|
252
|
+
try:
|
|
253
|
+
text = p.read_text(encoding="utf-8")
|
|
254
|
+
except Exception as exc:
|
|
255
|
+
parse_errors.append(f"{p.relative_to(root)}: {exc}")
|
|
256
|
+
continue
|
|
257
|
+
rel = str(p.relative_to(root))
|
|
258
|
+
found, face = _scan(text, rel)
|
|
259
|
+
tests.extend(found)
|
|
260
|
+
scan_face.extend(face)
|
|
261
|
+
return ExtractResult(
|
|
262
|
+
tests=tests,
|
|
263
|
+
parse_errors=parse_errors,
|
|
264
|
+
scan_face=scan_face,
|
|
265
|
+
input_state="present",
|
|
266
|
+
)
|