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
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.2.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
- SCHEMA_MARKERS = (re.compile(r"\bAPIRouter\s*\("), re.compile(r"\bFastAPI\s*\("))
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 any(mk.search(text) for mk in SCHEMA_MARKERS):
207
- schema_sources.append(rel)
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 补:任何 `.py` 文本含 `APIRouter(`/`FastAPI(` 都会走到这里,
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 类指针形态只记"
@@ -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.2.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 / "product" / "stack-profile.yaml"
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,
@@ -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
- """从 product/stack-profile.yaml 读 apps(path / role),供状态标注与 --app 过滤。"""
73
- p = root / "product" / "stack-profile.yaml"
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 段(先在 product/stack-profile.yaml 声明)")
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.2.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