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
package/adapters/api.py
CHANGED
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
`spec_entry`(真相源入口)+ `source_files`,**不产 items**;
|
|
10
10
|
- **无描述** → 从源码抽取端点(`@action` 类 RPC、装饰器路由、**TS 的点分方法名调用**)。
|
|
11
11
|
|
|
12
|
+
**Java/Spring 形态**(Batch D1):`@RestController` / `@Controller` 类上的
|
|
13
|
+
`@RequestMapping` 作 base path,方法级 `@GetMapping` / `@PostMapping` / … 组合;
|
|
14
|
+
`@RequestMapping` 未声明动词时记 `ANY`(不猜)。
|
|
15
|
+
|
|
12
16
|
**TS 的 RPC / action 形态**(2026-10-15 补):首参是**点分方法名**字符串字面量,形如
|
|
13
17
|
`request('zeldaEasy.platform-console.provider.addProvider', {...})` 或
|
|
14
18
|
`mcpFetch<In, Out>('zeldaEasy.platform-infrastructure.mcp.create', params)`——正是 §3.5
|
|
@@ -25,6 +29,7 @@
|
|
|
25
29
|
from __future__ import annotations
|
|
26
30
|
|
|
27
31
|
import argparse
|
|
32
|
+
import ast
|
|
28
33
|
import os
|
|
29
34
|
import re
|
|
30
35
|
import sys
|
|
@@ -36,11 +41,18 @@ from _lib import ( # noqa: E402
|
|
|
36
41
|
)
|
|
37
42
|
|
|
38
43
|
ADAPTER = "api"
|
|
39
|
-
VERSION = "0.
|
|
44
|
+
VERSION = "0.5.1"
|
|
40
45
|
|
|
41
46
|
DESCRIPTOR_FILES = {"openapi.json", "openapi.yaml", "openapi.yml", "swagger.json", "swagger.yaml"}
|
|
42
|
-
# 运行时自动生成 schema
|
|
43
|
-
|
|
47
|
+
# 运行时自动生成 schema 的框架信号(框架知识仅允许在适配器出现)。**只认代码里的
|
|
48
|
+
# 构造调用**(AST `Call`):注释 / 文档字符串 / 正则源码 / 测试夹具里的同名字面量
|
|
49
|
+
# 都不是框架事实(2026-10-29 issue #46:适配器把自己的注释与测试夹具认成了描述源)。
|
|
50
|
+
RUNTIME_SCHEMA_CALLS = ("FastAPI", "APIRouter")
|
|
51
|
+
RUNTIME_SCHEMA_MODULE = "fastapi"
|
|
52
|
+
# 测试文件不是产品接口面:夹具里真起一个 app 也不构成「有描述」
|
|
53
|
+
TEST_PATH_RE = re.compile(
|
|
54
|
+
r"(?:^|/)(?:tests?|__tests__)/|(?:^|/)test_[^/]*\.py$|(?:^|/)[^/]*_test\.py$|(?:^|/)conftest\.py$"
|
|
55
|
+
)
|
|
44
56
|
DECO_RE = re.compile(r"@(\w+)\.(get|post|put|delete|patch|options|head)\(\s*['\"]([^'\"]*)['\"]")
|
|
45
57
|
PREFIX_RE = re.compile(r"(\w+)\s*=\s*\w+\([^)]*prefix\s*=\s*['\"]([^'\"]+)['\"]")
|
|
46
58
|
JS_RE = re.compile(r"\b(?:app|router)\.(get|post|put|delete|patch)\(\s*['\"]([^'\"]+)['\"]")
|
|
@@ -155,6 +167,306 @@ def extract_ts_rpc(root: Path, f: Path) -> list[dict]:
|
|
|
155
167
|
return items
|
|
156
168
|
|
|
157
169
|
|
|
170
|
+
SPRING_VERB_ANN = {
|
|
171
|
+
"GetMapping": "GET",
|
|
172
|
+
"PostMapping": "POST",
|
|
173
|
+
"PutMapping": "PUT",
|
|
174
|
+
"DeleteMapping": "DELETE",
|
|
175
|
+
"PatchMapping": "PATCH",
|
|
176
|
+
}
|
|
177
|
+
SPRING_MAPPING_RE = re.compile(
|
|
178
|
+
r"@(GetMapping|PostMapping|PutMapping|DeleteMapping|PatchMapping|RequestMapping)\b"
|
|
179
|
+
)
|
|
180
|
+
SPRING_CLASS_RE = re.compile(r"\b(?:class|interface|record|enum)\s+([A-Za-z_]\w*)")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _strip_java_comments(text: str) -> str:
|
|
184
|
+
out: list[str] = []
|
|
185
|
+
i, n = 0, len(text)
|
|
186
|
+
quote = ""
|
|
187
|
+
in_line = False
|
|
188
|
+
in_block = False
|
|
189
|
+
while i < n:
|
|
190
|
+
if in_line:
|
|
191
|
+
if text[i] == "\n":
|
|
192
|
+
in_line = False
|
|
193
|
+
out.append("\n")
|
|
194
|
+
else:
|
|
195
|
+
out.append(" ")
|
|
196
|
+
i += 1
|
|
197
|
+
continue
|
|
198
|
+
if in_block:
|
|
199
|
+
if text.startswith("*/", i):
|
|
200
|
+
in_block = False
|
|
201
|
+
out.append(" ")
|
|
202
|
+
i += 2
|
|
203
|
+
else:
|
|
204
|
+
out.append("\n" if text[i] == "\n" else " ")
|
|
205
|
+
i += 1
|
|
206
|
+
continue
|
|
207
|
+
if quote:
|
|
208
|
+
out.append(text[i])
|
|
209
|
+
if text[i] == "\\" and i + 1 < n:
|
|
210
|
+
out.append(text[i + 1])
|
|
211
|
+
i += 2
|
|
212
|
+
continue
|
|
213
|
+
if text[i] == quote:
|
|
214
|
+
quote = ""
|
|
215
|
+
i += 1
|
|
216
|
+
continue
|
|
217
|
+
if text.startswith("//", i):
|
|
218
|
+
in_line = True
|
|
219
|
+
out.append(" ")
|
|
220
|
+
i += 2
|
|
221
|
+
continue
|
|
222
|
+
if text.startswith("/*", i):
|
|
223
|
+
in_block = True
|
|
224
|
+
out.append(" ")
|
|
225
|
+
i += 2
|
|
226
|
+
continue
|
|
227
|
+
if text[i] in "'\"":
|
|
228
|
+
quote = text[i]
|
|
229
|
+
out.append(text[i])
|
|
230
|
+
i += 1
|
|
231
|
+
return "".join(out)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _quoted(raw: str) -> list[str]:
|
|
235
|
+
return [m.group(1) for m in re.finditer(r"[\"']([^\"']*)[\"']", raw)]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _spring_paths(args: str) -> list[str]:
|
|
239
|
+
m = re.search(r"(?:value|path)\s*=\s*(\{[^}]*\}|[\"'][^\"']*[\"'])", args)
|
|
240
|
+
if m:
|
|
241
|
+
return _quoted(m.group(1)) or [""]
|
|
242
|
+
s = args.lstrip()
|
|
243
|
+
if s.startswith(("\"", "'", "{")):
|
|
244
|
+
return _quoted(s) or [""]
|
|
245
|
+
return [""]
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _spring_verbs(name: str, args: str) -> list[str]:
|
|
249
|
+
if name in SPRING_VERB_ANN:
|
|
250
|
+
return [SPRING_VERB_ANN[name]]
|
|
251
|
+
verbs = re.findall(r"RequestMethod\.([A-Z]+)", args)
|
|
252
|
+
return verbs or ["ANY"]
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _spring_join(base: str, path: str) -> str:
|
|
256
|
+
b, p = base.strip(), path.strip()
|
|
257
|
+
if not b:
|
|
258
|
+
out = p or "/"
|
|
259
|
+
elif not p:
|
|
260
|
+
out = b
|
|
261
|
+
else:
|
|
262
|
+
out = b.rstrip("/") + "/" + p.lstrip("/")
|
|
263
|
+
return out if out.startswith("/") else "/" + out
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _spring_mappings(anno: str) -> list[tuple[str, str]]:
|
|
267
|
+
"""annotation 文本 → [(path, verb)];支持 @@GetMapping 与 @RequestMapping。"""
|
|
268
|
+
out: list[tuple[str, str]] = []
|
|
269
|
+
for m in SPRING_MAPPING_RE.finditer(anno):
|
|
270
|
+
name = m.group(1)
|
|
271
|
+
start = m.end()
|
|
272
|
+
# 取该注解的括号参数(不处理嵌套,Spring 映射参数没有嵌套括号)
|
|
273
|
+
p = anno.find("(", start)
|
|
274
|
+
if p < 0:
|
|
275
|
+
args = ""
|
|
276
|
+
else:
|
|
277
|
+
q = anno.find(")", p + 1)
|
|
278
|
+
args = anno[p + 1:q] if q >= 0 else anno[p + 1:]
|
|
279
|
+
for path in _spring_paths(args):
|
|
280
|
+
for verb in _spring_verbs(name, args):
|
|
281
|
+
out.append((path, verb))
|
|
282
|
+
return out
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def extract_java_routes(root: Path, f: Path) -> list[dict]:
|
|
286
|
+
"""Spring `@RestController` + `@*Mapping` → api items(框架知识合法落点)。
|
|
287
|
+
|
|
288
|
+
类级 `@RequestMapping` 作为 base path;方法级注解组合。`@RequestMapping` 未声明
|
|
289
|
+
动词时记 `ANY`(不猜成 GET/POST)。只有 `@RestController` / `@Controller` 类才收。
|
|
290
|
+
"""
|
|
291
|
+
rel = f.relative_to(root).as_posix()
|
|
292
|
+
text = _strip_java_comments(f.read_text(encoding="utf-8"))
|
|
293
|
+
lines = text.splitlines()
|
|
294
|
+
pending: list[tuple[str, int]] = []
|
|
295
|
+
class_base = [""]
|
|
296
|
+
is_controller = False
|
|
297
|
+
items: list[dict] = []
|
|
298
|
+
|
|
299
|
+
for lineno, line in enumerate(lines, 1):
|
|
300
|
+
s = line.strip()
|
|
301
|
+
if not s:
|
|
302
|
+
continue
|
|
303
|
+
if s.startswith("@"):
|
|
304
|
+
pending.append((s, lineno))
|
|
305
|
+
continue
|
|
306
|
+
if SPRING_CLASS_RE.search(s):
|
|
307
|
+
ann = "\n".join(a for a, _ in pending)
|
|
308
|
+
is_controller = bool(re.search(r"@(RestController|Controller)\b", ann))
|
|
309
|
+
# 类级只取 @RequestMapping 的路径;@GetMapping 出现在类上不合法,忽略
|
|
310
|
+
class_base = [p for p, _ in _spring_mappings(ann)] or [""]
|
|
311
|
+
pending = []
|
|
312
|
+
continue
|
|
313
|
+
if pending and "(" in s:
|
|
314
|
+
ann = "\n".join(a for a, _ in pending)
|
|
315
|
+
if is_controller:
|
|
316
|
+
for path, verb in _spring_mappings(ann):
|
|
317
|
+
for base in class_base:
|
|
318
|
+
items.append({
|
|
319
|
+
"method": verb,
|
|
320
|
+
"path": _spring_join(base, path),
|
|
321
|
+
"summary": None,
|
|
322
|
+
"source_module": rel,
|
|
323
|
+
"source_file": f"{rel}:{pending[0][1]}",
|
|
324
|
+
})
|
|
325
|
+
pending = []
|
|
326
|
+
continue
|
|
327
|
+
if pending and (";" in s or "}" in s):
|
|
328
|
+
pending = []
|
|
329
|
+
return items
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
GO_METHOD_RE = re.compile(
|
|
334
|
+
r"\b\w+\.(GET|POST|PUT|DELETE|PATCH|OPTIONS|HEAD|Any|Get|Post|Put|Delete|Patch|Options|Head)"
|
|
335
|
+
r"\s*\(\s*\"([^\"]+)\""
|
|
336
|
+
)
|
|
337
|
+
GO_HANDLE_RE = re.compile(
|
|
338
|
+
r"\b(?:http|mux|router|r)\.(?:HandleFunc|Handle)\s*\(\s*\""
|
|
339
|
+
r"((?:(?:GET|POST|PUT|DELETE|PATCH|OPTIONS|HEAD)\s+)?[^\"]+)\""
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def extract_go_routes(root: Path, f: Path) -> list[dict]:
|
|
344
|
+
"""Go 常见路由注册(net/http、gin/echo/chi/fiber 风格)→ api items。
|
|
345
|
+
|
|
346
|
+
`r.GET("/x")` 等记动词;`http.HandleFunc("GET /x")` 取前缀动词;无动词记 `ANY`
|
|
347
|
+
(ServeMux 的旧写法不承载动词,不猜)。`_test.go` 不扫。
|
|
348
|
+
"""
|
|
349
|
+
rel = f.relative_to(root).as_posix()
|
|
350
|
+
if f.name.endswith("_test.go"):
|
|
351
|
+
return []
|
|
352
|
+
text = strip_js_comments(f.read_text(encoding="utf-8"))
|
|
353
|
+
items: list[dict] = []
|
|
354
|
+
for i, ln in enumerate(text.splitlines(), 1):
|
|
355
|
+
for m in GO_METHOD_RE.finditer(ln):
|
|
356
|
+
path = m.group(2)
|
|
357
|
+
# 路由路径必须以 / 开头;q.Get("delay_ms") 这类 query 读取不是路由
|
|
358
|
+
if not path.startswith("/"):
|
|
359
|
+
continue
|
|
360
|
+
method = m.group(1).upper()
|
|
361
|
+
items.append({
|
|
362
|
+
"method": method,
|
|
363
|
+
"path": path,
|
|
364
|
+
"summary": None,
|
|
365
|
+
"source_module": rel,
|
|
366
|
+
"source_file": f"{rel}:{i}",
|
|
367
|
+
})
|
|
368
|
+
for m in GO_HANDLE_RE.finditer(ln):
|
|
369
|
+
raw = m.group(1).strip()
|
|
370
|
+
parts = raw.split(None, 1)
|
|
371
|
+
if len(parts) == 2 and parts[0] in (
|
|
372
|
+
"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"
|
|
373
|
+
):
|
|
374
|
+
method, path = parts[0], parts[1]
|
|
375
|
+
else:
|
|
376
|
+
method, path = "ANY", raw
|
|
377
|
+
if not path.startswith("/"):
|
|
378
|
+
continue
|
|
379
|
+
items.append({
|
|
380
|
+
"method": method,
|
|
381
|
+
"path": path,
|
|
382
|
+
"summary": None,
|
|
383
|
+
"source_module": rel,
|
|
384
|
+
"source_file": f"{rel}:{i}",
|
|
385
|
+
})
|
|
386
|
+
return items
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
NEST_CONTROLLER_RE = re.compile(r"@Controller\s*\(\s*(?:['\"]([^'\"]*)['\"])?\s*\)")
|
|
391
|
+
NEST_METHOD_RE = re.compile(
|
|
392
|
+
r"@(Get|Post|Put|Delete|Patch|Options|Head|All)\s*"
|
|
393
|
+
r"\(\s*(?:['\"]([^'\"]*)['\"])?\s*\)"
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def join_nest(prefix: str, path: str) -> str:
|
|
398
|
+
p, q = prefix.strip(), path.strip()
|
|
399
|
+
if not p and not q:
|
|
400
|
+
return "/"
|
|
401
|
+
if not p:
|
|
402
|
+
return q if q.startswith("/") else "/" + q
|
|
403
|
+
if not q:
|
|
404
|
+
return p if p.startswith("/") else "/" + p
|
|
405
|
+
out = p.rstrip("/") + "/" + q.lstrip("/")
|
|
406
|
+
return out if out.startswith("/") else "/" + out
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def extract_nest_routes(root: Path, f: Path) -> list[dict]:
|
|
410
|
+
"""NestJS / Midway `@Controller` + `@Get/@Post/...` → api items。
|
|
411
|
+
|
|
412
|
+
类级 `@Controller('prefix')` 作 base path;方法级 `@All` 记 ANY;无路径参数记空串。
|
|
413
|
+
只处理含 `@Controller` 的 TS/JS 文件,避免把任意装饰器当路由。
|
|
414
|
+
"""
|
|
415
|
+
rel = f.relative_to(root).as_posix()
|
|
416
|
+
text = strip_js_comments(f.read_text(encoding="utf-8"))
|
|
417
|
+
if "@Controller" not in text:
|
|
418
|
+
return []
|
|
419
|
+
items: list[dict] = []
|
|
420
|
+
prefix: str | None = None
|
|
421
|
+
for i, line in enumerate(text.splitlines(), 1):
|
|
422
|
+
cm = NEST_CONTROLLER_RE.search(line)
|
|
423
|
+
if cm:
|
|
424
|
+
prefix = cm.group(1) or ""
|
|
425
|
+
continue
|
|
426
|
+
if prefix is None:
|
|
427
|
+
continue
|
|
428
|
+
m = NEST_METHOD_RE.search(line)
|
|
429
|
+
if not m:
|
|
430
|
+
continue
|
|
431
|
+
method = m.group(1).upper()
|
|
432
|
+
if method == "ALL":
|
|
433
|
+
method = "ANY"
|
|
434
|
+
items.append({
|
|
435
|
+
"method": method,
|
|
436
|
+
"path": join_nest(prefix, m.group(2) or ""),
|
|
437
|
+
"summary": None,
|
|
438
|
+
"source_module": rel,
|
|
439
|
+
"source_file": f"{rel}:{i}",
|
|
440
|
+
})
|
|
441
|
+
return items
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def uses_runtime_schema(text: str) -> bool:
|
|
445
|
+
"""文件里是否有**真实的**运行时 schema 构造调用(AST `Call` 节点)。
|
|
446
|
+
|
|
447
|
+
字符串 / 注释 / 正则源码不是证据;`from fastapi import FastAPI as App` 的别名也算。
|
|
448
|
+
解析失败由调用方 WARN 跳过——注释里的字面量曾把适配器自己认成描述源
|
|
449
|
+
(2026-10-29 issue #46),退回文本匹配会把该缺陷原样带回。
|
|
450
|
+
"""
|
|
451
|
+
tree = ast.parse(text)
|
|
452
|
+
names = set(RUNTIME_SCHEMA_CALLS)
|
|
453
|
+
for node in ast.walk(tree):
|
|
454
|
+
if (isinstance(node, ast.ImportFrom)
|
|
455
|
+
and (node.module or "").split(".")[0] == RUNTIME_SCHEMA_MODULE):
|
|
456
|
+
for a in node.names:
|
|
457
|
+
if a.name in RUNTIME_SCHEMA_CALLS and a.asname:
|
|
458
|
+
names.add(a.asname)
|
|
459
|
+
for node in ast.walk(tree):
|
|
460
|
+
if not isinstance(node, ast.Call):
|
|
461
|
+
continue
|
|
462
|
+
fn = node.func
|
|
463
|
+
if isinstance(fn, ast.Name) and fn.id in names:
|
|
464
|
+
return True
|
|
465
|
+
if isinstance(fn, ast.Attribute) and fn.attr in names:
|
|
466
|
+
return True
|
|
467
|
+
return False
|
|
468
|
+
|
|
469
|
+
|
|
158
470
|
def find_descriptors(root: Path) -> list[str]:
|
|
159
471
|
"""按**文件名**找机器可读描述文件(§3.5 的「有描述」探测)。
|
|
160
472
|
|
|
@@ -196,26 +508,37 @@ def main() -> int:
|
|
|
196
508
|
desc_files: list[str] = find_descriptors(root)
|
|
197
509
|
schema_sources: list[str] = []
|
|
198
510
|
items: list[dict] = []
|
|
199
|
-
for f in iter_files(root, (".py", ".pyxl", ".js", ".jsx", ".ts", ".tsx"), app_path):
|
|
511
|
+
for f in iter_files(root, (".py", ".pyxl", ".js", ".jsx", ".ts", ".tsx", ".java", ".go"), app_path):
|
|
200
512
|
rel = f.relative_to(root).as_posix()
|
|
201
513
|
if f.suffix == ".py":
|
|
202
514
|
try:
|
|
203
515
|
text = f.read_text(encoding="utf-8")
|
|
204
516
|
except OSError:
|
|
205
517
|
continue
|
|
206
|
-
if
|
|
207
|
-
|
|
518
|
+
if not TEST_PATH_RE.search(rel) and any(n in text for n in RUNTIME_SCHEMA_CALLS):
|
|
519
|
+
try:
|
|
520
|
+
if uses_runtime_schema(text):
|
|
521
|
+
schema_sources.append(rel)
|
|
522
|
+
except (SyntaxError, ValueError) as e:
|
|
523
|
+
print(f"WARN[{ADAPTER}]: 跳过无法解析的文件 {rel}(运行时 schema 探测): {e}",
|
|
524
|
+
file=sys.stderr)
|
|
208
525
|
items.extend(extract_py_routes(root, f))
|
|
209
526
|
elif f.suffix == ".pyxl":
|
|
210
527
|
items.extend(extract_actions(root, f))
|
|
528
|
+
elif f.suffix == ".java":
|
|
529
|
+
items.extend(extract_java_routes(root, f))
|
|
530
|
+
elif f.suffix == ".go":
|
|
531
|
+
items.extend(extract_go_routes(root, f))
|
|
211
532
|
else:
|
|
212
533
|
items.extend(extract_js_routes(root, f))
|
|
213
534
|
items.extend(extract_ts_rpc(root, f))
|
|
535
|
+
items.extend(extract_nest_routes(root, f))
|
|
214
536
|
|
|
215
537
|
if desc_files or schema_sources:
|
|
216
538
|
# 转指针形态 = 整体丢弃已抽到的 items ⇒ 必须出声,绝不静默清空
|
|
217
|
-
# (2026-10-15
|
|
218
|
-
#
|
|
539
|
+
# (2026-10-15 补:探测到运行时 schema 信号也会走到这里;2026-10-29 起信号只认
|
|
540
|
+
# AST 构造调用,注释/字符串/正则源码不再触发——issue #46。不出声的话用户看到的是
|
|
541
|
+
# 「接口清单是空的」而无从查因)。
|
|
219
542
|
entry = desc_files[0] if desc_files else "/openapi.json"
|
|
220
543
|
if items:
|
|
221
544
|
print(f"WARN[{ADAPTER}]: 探测到机器可读描述({entry})⇒ 按 D 类指针形态只记"
|
package/adapters/models.py
CHANGED
|
@@ -29,15 +29,21 @@ import re
|
|
|
29
29
|
import sys
|
|
30
30
|
from pathlib import Path
|
|
31
31
|
|
|
32
|
+
if str(Path(__file__).resolve().parents[1]) not in sys.path:
|
|
33
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
34
|
+
from _support.layout import load_layout # noqa: E402
|
|
35
|
+
|
|
32
36
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
33
37
|
from _lib import strip_js_comments # noqa: E402 # 同包共用工具(引号感知的注释剥离)
|
|
38
|
+
from _jpa import parse_project as parse_jpa_project # noqa: E402
|
|
34
39
|
|
|
35
40
|
ADAPTER = "models"
|
|
36
|
-
VERSION = "0.
|
|
41
|
+
VERSION = "0.5.0"
|
|
37
42
|
|
|
38
43
|
IGNORE_DIRS = {
|
|
39
44
|
".git", "node_modules", "__pycache__", "site-packages", "dist", "build",
|
|
40
45
|
".pyxle-build", ".next", ".turbo", ".mypy_cache", ".pytest_cache", "tests",
|
|
46
|
+
"target", ".gradle", ".idea", ".scratch", ".atlas", ".atlas-run", ".trellis",
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
|
|
@@ -61,7 +67,7 @@ def iter_py(root: Path, app_path: str | None):
|
|
|
61
67
|
|
|
62
68
|
|
|
63
69
|
def load_apps(root: Path) -> list[dict]:
|
|
64
|
-
p = root
|
|
70
|
+
p = load_layout(root).stack_profile
|
|
65
71
|
if not p.is_file():
|
|
66
72
|
return []
|
|
67
73
|
apps: list[dict] = []
|
|
@@ -521,6 +527,105 @@ def parse_ts_models(src: str) -> tuple[list[dict], list[dict], dict]:
|
|
|
521
527
|
return items, enums, stats
|
|
522
528
|
|
|
523
529
|
|
|
530
|
+
|
|
531
|
+
def iter_java(root: Path, app_path: str | None):
|
|
532
|
+
base = root / app_path if app_path else root
|
|
533
|
+
if not base.exists():
|
|
534
|
+
return
|
|
535
|
+
for dirpath, dirnames, filenames in os.walk(base):
|
|
536
|
+
dirnames[:] = sorted(d for d in dirnames if d not in IGNORE_DIRS and not d.startswith("."))
|
|
537
|
+
for name in sorted(filenames):
|
|
538
|
+
if name.endswith(".java"):
|
|
539
|
+
yield Path(dirpath) / name
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
# ------------------------------------------------------------------ Go structs
|
|
544
|
+
|
|
545
|
+
GO_STRUCT_RE = re.compile(r"\btype\s+(\w+)\s+struct\s*\{([^}]*)\}", re.S)
|
|
546
|
+
GO_FIELD_RE = re.compile(r"^\s*(\w+)\s+([\w\.\*\[\]]+)(?:\s+`([^`]*)`)?\s*$")
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _go_tag(tags: str, key: str) -> str:
|
|
550
|
+
m = re.search(r"(?:^|\s)%s:\"([^\"]*)\"" % re.escape(key), tags or "")
|
|
551
|
+
return m.group(1) if m else ""
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _go_table_name(clean: str, struct: str, tags: str) -> str:
|
|
555
|
+
m = re.search(
|
|
556
|
+
r"func\s*\(\s*\w*\s*" + re.escape(struct) +
|
|
557
|
+
r"\s*\)\s*TableName\(\)\s*string\s*\{\s*return\s*\"([^\"]+)\"",
|
|
558
|
+
clean,
|
|
559
|
+
)
|
|
560
|
+
if m:
|
|
561
|
+
return m.group(1)
|
|
562
|
+
tm = _go_tag(tags, "gorm")
|
|
563
|
+
cm = re.search(r"(?:^|;)column:([^;]+)", tm)
|
|
564
|
+
return struct
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def parse_go_models(text: str, rel: str) -> list[dict]:
|
|
568
|
+
clean = strip_js_comments(text)
|
|
569
|
+
items: list[dict] = []
|
|
570
|
+
for m in GO_STRUCT_RE.finditer(clean):
|
|
571
|
+
struct, body = m.group(1), m.group(2)
|
|
572
|
+
table = _go_table_name(clean, struct, "")
|
|
573
|
+
rows: list[dict] = []
|
|
574
|
+
has_tag = False
|
|
575
|
+
for line in body.splitlines():
|
|
576
|
+
fm = GO_FIELD_RE.match(line)
|
|
577
|
+
if not fm:
|
|
578
|
+
continue
|
|
579
|
+
field, typ, tags = fm.group(1), fm.group(2), fm.group(3) or ""
|
|
580
|
+
has_tag = has_tag or bool(tags.strip())
|
|
581
|
+
gorm = _go_tag(tags, "gorm")
|
|
582
|
+
db = _go_tag(tags, "db")
|
|
583
|
+
js = _go_tag(tags, "json")
|
|
584
|
+
col = db or (re.search(r"(?:^|;)column:([^;]+)", gorm).group(1)
|
|
585
|
+
if re.search(r"(?:^|;)column:([^;]+)", gorm) else "")
|
|
586
|
+
name = col or (js.split(",")[0] if js else "") or field
|
|
587
|
+
flags: list[str] = []
|
|
588
|
+
if "primaryKey" in gorm:
|
|
589
|
+
flags.append("主键")
|
|
590
|
+
if "autoIncrement" in gorm:
|
|
591
|
+
flags.append("自增")
|
|
592
|
+
if "not null" in gorm:
|
|
593
|
+
flags.append("非空")
|
|
594
|
+
if "unique" in gorm:
|
|
595
|
+
flags.append("唯一")
|
|
596
|
+
sm = re.search(r"(?:^|;)size:(\d+)", gorm)
|
|
597
|
+
if sm:
|
|
598
|
+
flags.append(f"长度={sm.group(1)}")
|
|
599
|
+
relation = typ if typ.startswith(("*", "[]")) else None
|
|
600
|
+
rows.append({
|
|
601
|
+
"table": table,
|
|
602
|
+
"field": name,
|
|
603
|
+
"type": typ,
|
|
604
|
+
"constraints": ",".join(flags) if flags else None,
|
|
605
|
+
"default": None,
|
|
606
|
+
"relation": relation,
|
|
607
|
+
"enum": None,
|
|
608
|
+
"source_file": rel,
|
|
609
|
+
})
|
|
610
|
+
# 无 tag 且无 TableName 的 struct 多为 service/infra,不是数据模型;
|
|
611
|
+
# 宁可少收,不把 `server{mu,counters}` 这种当表(真实仓烟测暴露)。
|
|
612
|
+
if not rows or (not has_tag and table == struct):
|
|
613
|
+
continue
|
|
614
|
+
items.extend(rows)
|
|
615
|
+
return items
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def iter_go(root: Path, app_path: str | None):
|
|
619
|
+
base = root / app_path if app_path else root
|
|
620
|
+
if not base.exists():
|
|
621
|
+
return
|
|
622
|
+
for dirpath, dirnames, filenames in os.walk(base):
|
|
623
|
+
dirnames[:] = sorted(d for d in dirnames if d not in IGNORE_DIRS and not d.startswith("."))
|
|
624
|
+
for name in sorted(filenames):
|
|
625
|
+
if name.endswith(".go") and not name.endswith("_test.go"):
|
|
626
|
+
yield Path(dirpath) / name
|
|
627
|
+
|
|
628
|
+
|
|
524
629
|
def main() -> int:
|
|
525
630
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
526
631
|
ap.add_argument("--root", required=True)
|
|
@@ -598,6 +703,25 @@ def main() -> int:
|
|
|
598
703
|
print(f"[{ADAPTER}] 统计:顶层枚举/字面量别名声明 {n_enum_decls} 个(自身不成行);"
|
|
599
704
|
f"字段引用旁表补回取值 {n_alias_refs} 处", file=sys.stderr)
|
|
600
705
|
|
|
706
|
+
java_items, java_enums, java_sources = parse_jpa_project(list(iter_java(root, app_path)), root)
|
|
707
|
+
if java_items:
|
|
708
|
+
items.extend(java_items)
|
|
709
|
+
enums.extend(java_enums)
|
|
710
|
+
source_files.extend(java_sources)
|
|
711
|
+
for f in iter_go(root, app_path):
|
|
712
|
+
try:
|
|
713
|
+
text = f.read_text(encoding="utf-8")
|
|
714
|
+
except OSError:
|
|
715
|
+
continue
|
|
716
|
+
if "type " not in text or "struct" not in text:
|
|
717
|
+
continue
|
|
718
|
+
rel = f.relative_to(root).as_posix()
|
|
719
|
+
file_items = parse_go_models(text, rel)
|
|
720
|
+
if not file_items:
|
|
721
|
+
continue
|
|
722
|
+
source_files.append(rel)
|
|
723
|
+
items.extend(file_items)
|
|
724
|
+
|
|
601
725
|
out = {
|
|
602
726
|
"category": "models",
|
|
603
727
|
"items": items,
|
package/adapters/routes.py
CHANGED
|
@@ -32,6 +32,10 @@ import re
|
|
|
32
32
|
import sys
|
|
33
33
|
from pathlib import Path
|
|
34
34
|
|
|
35
|
+
if str(Path(__file__).resolve().parents[1]) not in sys.path:
|
|
36
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
37
|
+
from _support.layout import load_layout # noqa: E402
|
|
38
|
+
|
|
35
39
|
ADAPTER = "routes"
|
|
36
40
|
VERSION = "0.3.0"
|
|
37
41
|
|
|
@@ -69,8 +73,8 @@ def iter_files(root: Path, suffixes: tuple[str, ...], app_path: str | None = Non
|
|
|
69
73
|
|
|
70
74
|
|
|
71
75
|
def load_apps(root: Path) -> list[dict]:
|
|
72
|
-
"""
|
|
73
|
-
p = root
|
|
76
|
+
"""从资产根/stack-profile.yaml 读 apps(path / role),供状态标注与 --app 过滤。"""
|
|
77
|
+
p = load_layout(root).stack_profile
|
|
74
78
|
if not p.is_file():
|
|
75
79
|
return []
|
|
76
80
|
apps: list[dict] = []
|
|
@@ -731,7 +735,7 @@ def main() -> int:
|
|
|
731
735
|
if backend == "auto":
|
|
732
736
|
backend = "graph" if cfg else "regex"
|
|
733
737
|
elif backend == "graph" and cfg is None:
|
|
734
|
-
die("--backend graph 但 profile 未声明 graph
|
|
738
|
+
die("--backend graph 但 profile 未声明 graph 段(先在资产根/stack-profile.yaml 声明)")
|
|
735
739
|
relpaths, backend_used, fallback = resolve_relpaths(root, app_path, backend, cfg,
|
|
736
740
|
explicit=args.backend != "auto")
|
|
737
741
|
|
package/adapters/tests.py
CHANGED
|
@@ -33,23 +33,32 @@ from _lib import ( # noqa: E402
|
|
|
33
33
|
)
|
|
34
34
|
|
|
35
35
|
ADAPTER = "tests"
|
|
36
|
-
VERSION = "0.
|
|
36
|
+
VERSION = "0.4.0"
|
|
37
37
|
|
|
38
38
|
PY_TEST_RE = re.compile(r"^test_.+\.py$|^.+_test\.py$")
|
|
39
39
|
JS_TEST_RE = re.compile(r"\.(spec|test)\.(js|jsx|ts|tsx)$")
|
|
40
|
+
JAVA_TEST_RE = re.compile(r".*(?:Test|Tests|IT)\.java$")
|
|
41
|
+
GO_TEST_RE = re.compile(r".*_test\.go$")
|
|
40
42
|
PYTEST_CONFIG = {"pytest.ini", "tox.ini", "setup.cfg", "pyproject.toml", "conftest.py"}
|
|
41
43
|
PLAYWRIGHT_CONFIG = {"playwright.config.js", "playwright.config.ts"}
|
|
42
44
|
JS_CONFIG = {"vitest.config.js", "vitest.config.ts", "jest.config.js", "jest.config.ts", "package.json"}
|
|
45
|
+
MAVEN_CONFIG = {"pom.xml"}
|
|
46
|
+
GRADLE_CONFIG = {"build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"}
|
|
47
|
+
GO_CONFIG = {"go.mod"}
|
|
43
48
|
|
|
44
49
|
# 配置探测扫**整项目**(配置是项目级的,不随 app 变);测试文件盘点按 app 作用域。
|
|
45
|
-
CONFIG_SUFFIXES = (".py", ".ini", ".toml", ".cfg", ".js", ".ts", ".jsx", ".tsx", ".json")
|
|
46
|
-
TEST_SUFFIXES = (".py", ".js", ".jsx", ".ts", ".tsx")
|
|
50
|
+
CONFIG_SUFFIXES = (".py", ".ini", ".toml", ".cfg", ".js", ".ts", ".jsx", ".tsx", ".json", ".xml", ".gradle", ".kts", ".mod")
|
|
51
|
+
TEST_SUFFIXES = (".py", ".js", ".jsx", ".ts", ".tsx", ".java", ".go")
|
|
47
52
|
|
|
48
53
|
RUN_PYTEST = "python3 -m pytest"
|
|
49
54
|
RUN_PLAYWRIGHT = "npx playwright test"
|
|
50
55
|
RUN_VITEST = "npx vitest run"
|
|
51
56
|
RUN_JEST = "npx jest"
|
|
52
57
|
RUN_NPM = "npm test"
|
|
58
|
+
RUN_MAVEN = "mvn test"
|
|
59
|
+
RUN_GRADLEW = "./gradlew test"
|
|
60
|
+
RUN_GRADLE = "gradle test"
|
|
61
|
+
RUN_GO = "go test ./..."
|
|
53
62
|
|
|
54
63
|
|
|
55
64
|
def classify(rel: str, playwright: bool) -> str:
|
|
@@ -58,7 +67,7 @@ def classify(rel: str, playwright: bool) -> str:
|
|
|
58
67
|
return "e2e"
|
|
59
68
|
if playwright and JS_TEST_RE.search(low):
|
|
60
69
|
return "e2e"
|
|
61
|
-
if "integration" in low:
|
|
70
|
+
if "integration" in low or low.endswith("it.java"):
|
|
62
71
|
return "integration"
|
|
63
72
|
return "unit"
|
|
64
73
|
|
|
@@ -90,6 +99,9 @@ def main() -> int:
|
|
|
90
99
|
npm_cfg = False
|
|
91
100
|
vitest_cfg = False
|
|
92
101
|
jest_cfg = False
|
|
102
|
+
maven_cfg = False
|
|
103
|
+
gradle_cfg = False
|
|
104
|
+
go_cfg = False
|
|
93
105
|
|
|
94
106
|
# ① 配置探测:**整项目范围**。`--app` 下若只扫 app 目录,落在项目根的
|
|
95
107
|
# `pytest.ini` / `playwright.config.ts` 会被漏掉 ⇒ 逐 app 调用(域级类别的主路径)
|
|
@@ -112,14 +124,24 @@ def main() -> int:
|
|
|
112
124
|
configs.add(rel)
|
|
113
125
|
vitest_cfg = vitest_cfg or name.startswith("vitest")
|
|
114
126
|
jest_cfg = jest_cfg or name.startswith("jest")
|
|
127
|
+
if name in MAVEN_CONFIG:
|
|
128
|
+
maven_cfg = True
|
|
129
|
+
configs.add(rel)
|
|
130
|
+
if name in GRADLE_CONFIG:
|
|
131
|
+
gradle_cfg = True
|
|
132
|
+
configs.add(rel)
|
|
133
|
+
if name in GO_CONFIG:
|
|
134
|
+
go_cfg = True
|
|
135
|
+
configs.add(rel)
|
|
115
136
|
|
|
116
137
|
# ② 测试文件盘点:app 作用域。
|
|
117
138
|
test_files: list[str] = []
|
|
118
139
|
for f in iter_files(root, TEST_SUFFIXES, app_path):
|
|
119
|
-
if PY_TEST_RE.match(f.name) or JS_TEST_RE.search(f.name):
|
|
140
|
+
if PY_TEST_RE.match(f.name) or JS_TEST_RE.search(f.name) or JAVA_TEST_RE.match(f.name) or GO_TEST_RE.match(f.name):
|
|
120
141
|
test_files.append(f.relative_to(root).as_posix())
|
|
121
142
|
|
|
122
143
|
node_cfg = playwright_cfg or npm_cfg or vitest_cfg or jest_cfg
|
|
144
|
+
gradle_cmd = RUN_GRADLEW if (root / "gradlew").is_file() else RUN_GRADLE
|
|
123
145
|
|
|
124
146
|
def runner_for(rel: str) -> tuple[str | None, str | None]:
|
|
125
147
|
"""(run_command, 冲突说明)。说明非空 ⇒ run_command 置 null 并打 WARN。
|
|
@@ -128,6 +150,22 @@ def main() -> int:
|
|
|
128
150
|
item 仍出(测试文件存在是事实),命令留空(不作主张),并出声点明。
|
|
129
151
|
完全没配置 ⇒ 命令也留空但**不打 WARN**——那是既有正常形态(契约里 `run_command` 本就可空)。
|
|
130
152
|
"""
|
|
153
|
+
if GO_TEST_RE.match(Path(rel).name):
|
|
154
|
+
if go_cfg:
|
|
155
|
+
return RUN_GO, None
|
|
156
|
+
if pytest_cfg or node_cfg or maven_cfg or gradle_cfg:
|
|
157
|
+
return None, (f"{rel}:Go 测试文件,但仓里只有其它测试栈配置"
|
|
158
|
+
f"(后缀族与配置族冲突,不合成命令)")
|
|
159
|
+
return None, None
|
|
160
|
+
if JAVA_TEST_RE.match(Path(rel).name):
|
|
161
|
+
if maven_cfg:
|
|
162
|
+
return RUN_MAVEN, None
|
|
163
|
+
if gradle_cfg:
|
|
164
|
+
return gradle_cmd, None
|
|
165
|
+
if pytest_cfg or node_cfg:
|
|
166
|
+
return None, (f"{rel}:Java 测试文件,但仓里只有 Python/JS 侧测试配置"
|
|
167
|
+
f"(后缀族与配置族冲突,不合成命令)")
|
|
168
|
+
return None, None
|
|
131
169
|
if PY_TEST_RE.match(Path(rel).name):
|
|
132
170
|
if pytest_cfg:
|
|
133
171
|
return RUN_PYTEST, None
|