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,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
+ )