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,291 @@
1
+ #!/usr/bin/env python3
2
+ """JPA 模型解析 helper(框架知识的合法落点)。
3
+
4
+ 跨文件解析 `@Entity` / `@MappedSuperclass` / `@Embeddable`,合并父类字段与
5
+ `@Embedded` 内嵌字段;不做 JPA 继承策略推断(只合并 mapped superclass),
6
+ 不猜数据库命名策略(无 `@Table` 时表名 = 类名)。纯标准库。
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ CLASS_RE = re.compile(
15
+ r"\b(?:class|record)\s+([A-Za-z_]\w*)"
16
+ r"(?:\s+extends\s+([A-Za-z_]\w*))?[^{]*\{"
17
+ )
18
+ FIELD_RE = re.compile(
19
+ r"^\s*(?:private|protected|public)?\s*"
20
+ r"(?:static\s+|final\s+|volatile\s+|transient\s+)*"
21
+ r"([A-Za-z_][\w.<>, ?\[\]]*)\s+(\w+)\s*(?:=.*)?;\s*$"
22
+ )
23
+ ENUM_RE = re.compile(r"\benum\s+([A-Za-z_]\w*)\s*\{([^}]*)\}", re.S)
24
+
25
+
26
+ def _strip_comments(text: str) -> str:
27
+ out: list[str] = []
28
+ i, n = 0, len(text)
29
+ quote = ""
30
+ in_line = False
31
+ in_block = False
32
+ while i < n:
33
+ if in_line:
34
+ if text[i] == "\n":
35
+ in_line = False
36
+ out.append("\n")
37
+ else:
38
+ out.append(" ")
39
+ i += 1
40
+ continue
41
+ if in_block:
42
+ if text.startswith("*/", i):
43
+ in_block = False
44
+ out.append(" ")
45
+ i += 2
46
+ else:
47
+ out.append("\n" if text[i] == "\n" else " ")
48
+ i += 1
49
+ continue
50
+ if quote:
51
+ out.append(text[i])
52
+ if text[i] == "\\" and i + 1 < n:
53
+ out.append(text[i + 1])
54
+ i += 2
55
+ continue
56
+ if text[i] == quote:
57
+ quote = ""
58
+ i += 1
59
+ continue
60
+ if text.startswith("//", i):
61
+ in_line = True
62
+ out.append(" ")
63
+ i += 2
64
+ continue
65
+ if text.startswith("/*", i):
66
+ in_block = True
67
+ out.append(" ")
68
+ i += 2
69
+ continue
70
+ if text[i] in "'\"":
71
+ quote = text[i]
72
+ out.append(text[i])
73
+ i += 1
74
+ return "".join(out)
75
+
76
+
77
+ def _match_brace(text: str, open_pos: int) -> int | None:
78
+ depth = 0
79
+ i = open_pos
80
+ quote = ""
81
+ while i < len(text):
82
+ ch = text[i]
83
+ if quote:
84
+ if ch == "\\":
85
+ i += 2
86
+ continue
87
+ if ch == quote:
88
+ quote = ""
89
+ i += 1
90
+ continue
91
+ if ch in "'\"":
92
+ quote = ch
93
+ i += 1
94
+ continue
95
+ if ch == "{":
96
+ depth += 1
97
+ elif ch == "}":
98
+ depth -= 1
99
+ if depth == 0:
100
+ return i
101
+ i += 1
102
+ return None
103
+
104
+
105
+ def _ann_value(annotations: str, name: str) -> str:
106
+ m = re.search(
107
+ r"@%s\s*\(\s*(?:[A-Za-z_]+\s*=\s*)?\"((?:\\.|[^\"\\])*)\"" % re.escape(name),
108
+ annotations,
109
+ )
110
+ return m.group(1) if m else ""
111
+
112
+
113
+ def _has(annotations: str, name: str) -> bool:
114
+ return bool(re.search(r"@%s\b" % re.escape(name), annotations))
115
+
116
+
117
+ def _annotations_before(text: str, pos: int) -> str:
118
+ prefix = text[max(0, pos - 1200):pos]
119
+ cut = max(prefix.rfind("}"), prefix.rfind(";"))
120
+ tail = prefix[cut + 1:] if cut >= 0 else prefix
121
+ return "\n".join(ln.strip() for ln in tail.splitlines() if ln.strip().startswith("@"))
122
+
123
+
124
+ def _fields(body: str) -> list[dict[str, Any]]:
125
+ rows: list[dict[str, Any]] = []
126
+ pending: list[str] = []
127
+ skip_depth = 0
128
+ for line in body.splitlines():
129
+ s = line.strip()
130
+ if not s:
131
+ continue
132
+ if skip_depth > 0:
133
+ skip_depth += line.count("{") - line.count("}")
134
+ continue
135
+ if re.search(r"\b(?:class|record|enum)\s+\w+[^{]*\{", s):
136
+ skip_depth = line.count("{") - line.count("}")
137
+ pending = []
138
+ continue
139
+ if s.startswith("@"):
140
+ pending.append(s)
141
+ continue
142
+ m = FIELD_RE.match(line)
143
+ if not m or "(" in line or re.search(r"\bstatic\b", line):
144
+ if s.endswith(";") or "}" in s:
145
+ pending = []
146
+ continue
147
+ ann = "\n".join(pending)
148
+ pending = []
149
+ if _has(ann, "Transient"):
150
+ continue
151
+ typ, field = m.group(1).strip(), m.group(2)
152
+ if typ in {"return", "throw", "new", "else", "case", "break", "continue",
153
+ "assert", "yield"} or re.search(r"\breturn\b", s):
154
+ continue
155
+ field = _ann_value(ann, "Column") or _ann_value(ann, "TableField") or field
156
+ flags: list[str] = []
157
+ if _has(ann, "Id") or _has(ann, "EmbeddedId") or _has(ann, "TableId"):
158
+ flags.append("主键")
159
+ if _has(ann, "GeneratedValue"):
160
+ flags.append("自增")
161
+ if re.search(r"@Column\b[^)]*nullable\s*=\s*false", ann):
162
+ flags.append("非空")
163
+ if re.search(r"@Column\b[^)]*unique\s*=\s*true", ann):
164
+ flags.append("唯一")
165
+ lm = re.search(r"@Column\b[^)]*length\s*=\s*(\d+)", ann)
166
+ if lm:
167
+ flags.append(f"长度={lm.group(1)}")
168
+ relation = None
169
+ if any(_has(ann, a) for a in ("ManyToOne", "OneToMany", "OneToOne", "ManyToMany")):
170
+ relation = typ
171
+ rows.append({
172
+ "field": field,
173
+ "type": typ,
174
+ "constraints": ",".join(flags) if flags else None,
175
+ "default": None,
176
+ "relation": relation,
177
+ "enum": typ if _has(ann, "Enumerated") else None,
178
+ "embedded": _has(ann, "Embedded") or _has(ann, "EmbeddedId"),
179
+ })
180
+ return rows
181
+
182
+
183
+ def parse_classes(text: str, rel: str) -> list[dict[str, Any]]:
184
+ clean = _strip_comments(text)
185
+ defs: list[dict[str, Any]] = []
186
+ for m in CLASS_RE.finditer(clean):
187
+ ann = _annotations_before(clean, m.start())
188
+ kind = None
189
+ if _has(ann, "Entity"):
190
+ kind = "entity"
191
+ elif _has(ann, "MappedSuperclass"):
192
+ kind = "mapped"
193
+ elif _has(ann, "Embeddable"):
194
+ kind = "embeddable"
195
+ elif _has(ann, "TableName"):
196
+ kind = "mybatis"
197
+ else:
198
+ kind = "plain"
199
+ open_pos = m.end() - 1
200
+ close = _match_brace(clean, open_pos)
201
+ if close is None:
202
+ continue
203
+ defs.append({
204
+ "name": m.group(1),
205
+ "extends": m.group(2),
206
+ "kind": kind,
207
+ "table": _ann_value(ann, "Table") or _ann_value(ann, "TableName") or m.group(1),
208
+ "fields": _fields(clean[open_pos + 1:close]),
209
+ "rel": rel,
210
+ })
211
+ return defs
212
+
213
+
214
+ def _enum_map(texts: list[str]) -> dict[str, list[str]]:
215
+ out: dict[str, list[str]] = {}
216
+ for text in texts:
217
+ for m in ENUM_RE.finditer(_strip_comments(text)):
218
+ values = [v.strip().split()[0] for v in m.group(2).split(",")
219
+ if v.strip() and not v.strip().startswith("@")]
220
+ out[m.group(1)] = values
221
+ return out
222
+
223
+
224
+ def parse_project(files: list[Path], root: Path) -> tuple[list[dict], list[dict], list[str]]:
225
+ defs: list[dict[str, Any]] = []
226
+ texts: list[str] = []
227
+ for f in files:
228
+ try:
229
+ text = f.read_text(encoding="utf-8")
230
+ except OSError:
231
+ continue
232
+ rel = f.relative_to(root).as_posix()
233
+ texts.append(text)
234
+ defs.extend(parse_classes(text, rel))
235
+ if not defs:
236
+ return [], [], []
237
+ by_name = {d["name"]: d for d in defs}
238
+ embeddables = {d["name"]: d for d in defs if d["kind"] == "embeddable"}
239
+ enums_map = _enum_map(texts)
240
+
241
+ def resolve(defn: dict[str, Any], stack: set[str], prefix: str = "",
242
+ merge_plain: bool = False) -> list[dict[str, Any]]:
243
+ out: list[dict[str, Any]] = []
244
+ index: dict[str, int] = {}
245
+
246
+ def add(row: dict[str, Any]) -> None:
247
+ name = row["field"]
248
+ if name in index:
249
+ out[index[name]] = row
250
+ else:
251
+ index[name] = len(out)
252
+ out.append(row)
253
+
254
+ base = by_name.get(defn.get("extends") or "")
255
+ merge = base is not None and base["name"] not in stack and (
256
+ base["kind"] == "mapped" or (merge_plain and base["kind"] in ("plain", "mybatis"))
257
+ )
258
+ if merge:
259
+ for row in resolve(base, stack | {defn["name"]}, prefix, merge_plain):
260
+ add(row)
261
+ for row in defn["fields"]:
262
+ name = prefix + row["field"]
263
+ if row["embedded"] and row["type"] in embeddables:
264
+ for nested in resolve(embeddables[row["type"]], stack | {defn["name"]}, name + "."):
265
+ add(nested)
266
+ continue
267
+ copy = dict(row)
268
+ copy["field"] = name
269
+ add(copy)
270
+ return out
271
+
272
+ items: list[dict] = []
273
+ enums: list[dict] = []
274
+ for d in defs:
275
+ if d["kind"] not in ("entity", "mybatis"):
276
+ continue
277
+ table = d["table"]
278
+ for row in resolve(d, set(), "", d["kind"] == "mybatis"):
279
+ row = dict(row)
280
+ row.pop("embedded", None)
281
+ row["table"] = table
282
+ row["source_file"] = d["rel"]
283
+ if row.get("enum") and row["type"] in enums_map:
284
+ enums.append({
285
+ "table": table,
286
+ "field": row["field"],
287
+ "values": [{"value": v, "meaning": ""} for v in enums_map[row["type"]]],
288
+ })
289
+ items.append(row)
290
+ sources = sorted({row["source_file"] for row in items})
291
+ return items, enums, sources
package/adapters/_lib.py CHANGED
@@ -12,10 +12,15 @@ import re
12
12
  import sys
13
13
  from pathlib import Path
14
14
 
15
+ if str(Path(__file__).resolve().parents[1]) not in sys.path:
16
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
17
+ from _support.layout import load_layout # noqa: E402
18
+
15
19
  IGNORE_DIRS = {
16
20
  ".git", "node_modules", "__pycache__", "site-packages", "dist", "build",
17
21
  ".next", ".turbo", ".mypy_cache", ".pytest_cache", ".pyxle-build",
18
- "coverage", ".coverage", ".venv", "venv",
22
+ "coverage", ".coverage", ".venv", "venv", ".scratch", "target",
23
+ ".gradle", ".idea", ".atlas", ".atlas-run", ".trellis", ".adapter-out",
19
24
  }
20
25
 
21
26
 
@@ -40,8 +45,8 @@ def iter_files(root: Path, suffixes: tuple[str, ...], app_path: str | None = Non
40
45
 
41
46
 
42
47
  def load_apps(root: Path) -> list[dict]:
43
- """读 product/stack-profile.yaml 的 apps(name / path / role)。"""
44
- p = root / "product" / "stack-profile.yaml"
48
+ """读资产根/stack-profile.yaml 的 apps(name / path / role)。"""
49
+ p = load_layout(root).stack_profile
45
50
  if not p.is_file():
46
51
  return []
47
52
  apps: list[dict] = []
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env python3
2
+ """atlas AC 提取器注册表(框架知识的合法落点)。
3
+
4
+ `validators/validate_ac_alignment.py` 只依赖本注册表返回的归一化记录;
5
+ 具体框架的扫描/解析逻辑只允许出现在本目录的 adapter 内。
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import importlib
10
+ import sys
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ _ROOT = Path(__file__).resolve().parents[1]
16
+ if str(_ROOT) not in sys.path:
17
+ sys.path.insert(0, str(_ROOT))
18
+ from _support.layout import load_layout # noqa: E402
19
+ from _support.siblings import load_module # noqa: E402
20
+
21
+ _sp = load_module("stack_profile")
22
+
23
+
24
+ @dataclass
25
+ class ExtractResult:
26
+ tests: list[dict[str, Any]] = field(default_factory=list)
27
+ parse_errors: list[str] = field(default_factory=list)
28
+ scan_face: list[str] = field(default_factory=list)
29
+ input_state: str = "absent" # "present" | "absent"
30
+
31
+
32
+ DEFAULT = "pytest"
33
+ # name -> (模块名, Extractor 构造参数);具体框架知识只在本目录。
34
+ _BUILTINS = {
35
+ "pytest": ("pytest", None),
36
+ "vitest": ("js", "vitest"),
37
+ "jest": ("js", "jest"),
38
+ "junit5": ("junit", "junit5"),
39
+ }
40
+
41
+
42
+ def resolve(name: str | None = None):
43
+ """按名字解析内置 adapter;未知返回 None。"""
44
+ key = (name or DEFAULT).strip()
45
+ spec = _BUILTINS.get(key)
46
+ if spec is None:
47
+ return None
48
+ module_name, label = spec
49
+ module = importlib.import_module(f".{module_name}", __name__)
50
+ return module.Extractor(label) if label else module.Extractor()
51
+
52
+
53
+ def available() -> list[str]:
54
+ return list(_BUILTINS)
55
+
56
+
57
+ def wire_project(target: Path) -> dict:
58
+ """按项目声明的 extractor 调其项目级配置 hook;不支持则诚实跳过。"""
59
+ target = Path(target)
60
+ name = None
61
+ try:
62
+ lay = load_layout(target)
63
+ if lay.stack_profile.is_file():
64
+ profile = _sp.parse_profile(lay.stack_profile.read_text(encoding="utf-8"))
65
+ cfg = profile.get("backend_test") or {}
66
+ if isinstance(cfg, dict):
67
+ name = (cfg.get("extractor_command")
68
+ or cfg.get("extractor")
69
+ or cfg.get("framework"))
70
+ except Exception:
71
+ name = None
72
+ adapter = resolve(name)
73
+ if adapter is None:
74
+ return {"file": None, "status": "skipped",
75
+ "detail": f"extractor={name!r} 无项目级配置钩子(不写任何配置)"}
76
+ fn = getattr(adapter, "wire_project", None)
77
+ if fn is None:
78
+ return {"file": None, "status": "skipped",
79
+ "detail": "该 extractor 无项目级配置钩子(不写任何配置)"}
80
+ return fn(target, name)
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env python3
2
+ """外部 AC extractor 运行器(通用协议,不含任何具体框架知识)。
3
+
4
+ 协议:`extractor_command` 是 argv 字符串(`shlex.split`,禁止 shell),Atlas 追加
5
+ `--root <项目根> --test-dir <扫描根> --json`,命令向 stdout 输出:
6
+
7
+ ```json
8
+ {
9
+ "schema_version": 1,
10
+ "input_state": "present",
11
+ "tests": [ { "name": "...", "file": "...", "line": 1, "acs": [], "...": "..." } ],
12
+ "parse_errors": [],
13
+ "scan_face": []
14
+ }
15
+ ```
16
+
17
+ 失败(非 0 / 超时 / 非法 JSON / schema 不合法)一律抛 `ExtractError`,由 AC 门报
18
+ `[EXTRACTOR]` FAIL;不静默降级。
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import shlex
24
+ import subprocess
25
+ from pathlib import Path
26
+
27
+ from . import ExtractResult
28
+
29
+ TIMEOUT_SECONDS = 120
30
+ MAX_OUTPUT_BYTES = 10_000_000
31
+
32
+
33
+ class ExtractError(Exception):
34
+ pass
35
+
36
+
37
+ def _tail(text: str, n: int = 3) -> str:
38
+ lines = [ln for ln in (text or "").strip().splitlines() if ln.strip()]
39
+ return " | ".join(lines[-n:])
40
+
41
+
42
+ def run(command: str, root: Path, test_dir: Path, timeout: int = TIMEOUT_SECONDS) -> ExtractResult:
43
+ argv = shlex.split(command)
44
+ if not argv:
45
+ raise ExtractError("extractor_command 为空")
46
+ argv = argv + ["--root", str(root), "--test-dir", str(test_dir), "--json"]
47
+ try:
48
+ proc = subprocess.run(
49
+ argv, cwd=str(root), capture_output=True, text=True,
50
+ timeout=timeout, shell=False,
51
+ )
52
+ except FileNotFoundError as exc:
53
+ raise ExtractError(f"命令不存在:{argv[0]}") from exc
54
+ except subprocess.TimeoutExpired as exc:
55
+ raise ExtractError(f"命令超时({timeout}s):{argv[0]}") from exc
56
+ except OSError as exc:
57
+ raise ExtractError(f"命令启动失败:{exc}") from exc
58
+
59
+ if len(proc.stdout.encode("utf-8", errors="ignore")) > MAX_OUTPUT_BYTES:
60
+ raise ExtractError("stdout 超过 10MB 上限")
61
+ if proc.returncode != 0:
62
+ raise ExtractError(f"退出码 {proc.returncode}:{_tail(proc.stderr or proc.stdout)}")
63
+ try:
64
+ payload = json.loads(proc.stdout)
65
+ except Exception as exc:
66
+ raise ExtractError(f"stdout 非合法 JSON:{exc}") from exc
67
+ if not isinstance(payload, dict):
68
+ raise ExtractError("JSON 顶层必须是对象")
69
+ if payload.get("schema_version") != 1:
70
+ raise ExtractError("schema_version 必须为 1")
71
+ tests = payload.get("tests")
72
+ if not isinstance(tests, list):
73
+ raise ExtractError("tests 必须是数组")
74
+ parse_errors = payload.get("parse_errors", [])
75
+ scan_face = payload.get("scan_face", [])
76
+ if not isinstance(parse_errors, list) or not all(isinstance(x, str) for x in parse_errors):
77
+ raise ExtractError("parse_errors 必须是字符串数组")
78
+ if not isinstance(scan_face, list) or not all(isinstance(x, str) for x in scan_face):
79
+ raise ExtractError("scan_face 必须是字符串数组")
80
+ input_state = payload.get("input_state", "present" if tests else "absent")
81
+ if input_state not in ("present", "absent"):
82
+ raise ExtractError("input_state 必须是 present 或 absent")
83
+ return ExtractResult(
84
+ tests=tests,
85
+ parse_errors=parse_errors,
86
+ scan_face=scan_face,
87
+ input_state=input_state,
88
+ )