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
@@ -13,6 +13,7 @@ from __future__ import annotations
13
13
 
14
14
  import importlib.util
15
15
  import json
16
+ import sys
16
17
  from pathlib import Path
17
18
  import pytest
18
19
 
@@ -220,3 +221,1083 @@ def test_valid():
220
221
  assert cov_check["status"] == "PASS"
221
222
  assert "已声明 AC: 1" in cov_check["detail"]
222
223
 
224
+
225
+ # ---------------------------------------------------------------------------
226
+ # D153:后端通道 AC × 覆盖维度
227
+ # ---------------------------------------------------------------------------
228
+
229
+ BACKEND_CHECK = "AC × 覆盖维度(后端通道)"
230
+
231
+
232
+ def _check(res, name=BACKEND_CHECK):
233
+ return next((c for c in res.checks if c["check"] == name), None)
234
+
235
+
236
+ def create_backend_project(
237
+ tmp_path: Path,
238
+ prd_rows: list[tuple],
239
+ test_code: str | None,
240
+ *,
241
+ header: list[str] | None = None,
242
+ with_backend_test: bool = True,
243
+ framework: str | None = None,
244
+ extractor: str | None = None,
245
+ extractor_command: str | None = None,
246
+ mode: str = "advisory",
247
+ apps: list[dict] | None = None,
248
+ test_dir_files: dict[str, str] | None = None,
249
+ ) -> Path:
250
+ prd_dir = tmp_path / "product" / "prd"
251
+ prd_dir.mkdir(parents=True, exist_ok=True)
252
+ header = header or ["AC ID", "场景", "回指规则", "适用覆盖维度", "后端适用覆盖维度"]
253
+ lines = [
254
+ "# 模块 PRD",
255
+ "## 五、验收标准",
256
+ "| " + " | ".join(header) + " |",
257
+ "|" + "---|" * len(header),
258
+ ]
259
+ for row in prd_rows:
260
+ lines.append("| " + " | ".join(row) + " |")
261
+ (prd_dir / "test-prd.md").write_text("\n".join(lines), encoding="utf-8")
262
+
263
+ apps = apps or [
264
+ {"name": "backend", "path": "backend", "kind": "backend", "role": "landing", "stack": "python"}
265
+ ]
266
+ sp = ["product: 测试项目", "origin: greenfield", "apps:"]
267
+ for a in apps:
268
+ sp.extend([
269
+ f" - name: {a['name']}",
270
+ f" path: {a['path']}",
271
+ f" kind: {a['kind']}",
272
+ f" role: {a['role']}",
273
+ f" stack: {a['stack']}",
274
+ ])
275
+ if with_backend_test:
276
+ sp.extend([
277
+ "backend_test:",
278
+ " test_dir: backend/tests",
279
+ f" mode: {mode}",
280
+ " ac_marker: pytest_mark",
281
+ ])
282
+ if extractor_command:
283
+ sp.append(f' extractor_command: "{extractor_command}"')
284
+ elif extractor:
285
+ sp.append(f" extractor: {extractor}")
286
+ elif framework:
287
+ sp.append(f" framework: {framework}")
288
+ (tmp_path / "product" / "stack-profile.yaml").write_text("\n".join(sp) + "\n", encoding="utf-8")
289
+
290
+ test_dir = tmp_path / "backend" / "tests"
291
+ test_dir.mkdir(parents=True, exist_ok=True)
292
+ if test_code is not None:
293
+ (test_dir / "test_sample.py").write_text(test_code, encoding="utf-8")
294
+ for name, code in (test_dir_files or {}).items():
295
+ p = test_dir / name
296
+ p.parent.mkdir(parents=True, exist_ok=True)
297
+ p.write_text(code, encoding="utf-8")
298
+ return tmp_path
299
+
300
+
301
+ def test_backend_dimensions_pass(tmp_path: Path):
302
+ test_code = """
303
+ import pytest
304
+
305
+ @pytest.mark.ac("AC-TEST-001", dims=["C01", "C02"])
306
+ def test_ok():
307
+ assert True
308
+ """
309
+ proj = create_backend_project(
310
+ tmp_path,
311
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "C01,C02")],
312
+ test_code,
313
+ )
314
+ res = validate_ac_alignment(proj)
315
+ check = _check(res)
316
+ assert check is not None and check["status"] == "PASS", check
317
+ assert res.status == "PASS"
318
+
319
+
320
+ def test_backend_dimensions_missing_warn(tmp_path: Path):
321
+ test_code = """
322
+ import pytest
323
+
324
+ @pytest.mark.ac("AC-TEST-001", dims=["C01"])
325
+ def test_ok():
326
+ assert True
327
+ """
328
+ proj = create_backend_project(
329
+ tmp_path,
330
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "C01,C02,C11")],
331
+ test_code,
332
+ )
333
+ res = validate_ac_alignment(proj)
334
+ check = _check(res)
335
+ assert check is not None and check["status"] == "WARN", check
336
+ assert "C02" in check["detail"] and "C11" in check["detail"], check
337
+
338
+
339
+ def test_backend_zero_test_warn(tmp_path: Path):
340
+ proj = create_backend_project(
341
+ tmp_path,
342
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "C01")],
343
+ "def test_unrelated():\n assert True\n",
344
+ )
345
+ res = validate_ac_alignment(proj)
346
+ check = _check(res)
347
+ assert check is not None and check["status"] == "WARN", check
348
+ assert "零测试" in check["detail"] and "AC-TEST-001" in check["detail"], check
349
+
350
+
351
+ def test_backend_weak_only_warn(tmp_path: Path):
352
+ test_code = """
353
+ import pytest
354
+
355
+ @pytest.mark.ac("AC-TEST-001", dims=["C01"])
356
+ @pytest.mark.skip(reason="waiver: 临时跳过")
357
+ def test_ok():
358
+ assert False
359
+ """
360
+ proj = create_backend_project(
361
+ tmp_path,
362
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "C01")],
363
+ test_code,
364
+ )
365
+ res = validate_ac_alignment(proj)
366
+ check = _check(res)
367
+ assert check is not None and check["status"] == "WARN", check
368
+ assert "弱证据" in check["detail"], check
369
+
370
+
371
+ def test_backend_skipif_weak(tmp_path: Path):
372
+ test_code = """
373
+ import pytest
374
+
375
+ @pytest.mark.skipif(False, reason="off")
376
+ @pytest.mark.ac("AC-TEST-001", dims=["C01"])
377
+ def test_ok():
378
+ assert True
379
+ """
380
+ proj = create_backend_project(
381
+ tmp_path,
382
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "C01")],
383
+ test_code,
384
+ )
385
+ res = validate_ac_alignment(proj)
386
+ check = _check(res)
387
+ assert check is not None and check["status"] == "WARN", check
388
+ assert "弱证据" in check["detail"], check
389
+
390
+
391
+ def test_backend_multi_ac_dims_warn(tmp_path: Path):
392
+ test_code = """
393
+ import pytest
394
+
395
+ @pytest.mark.ac("AC-TEST-001", "AC-TEST-002", dims=["C01"])
396
+ def test_ok():
397
+ assert True
398
+ """
399
+ proj = create_backend_project(
400
+ tmp_path,
401
+ [
402
+ ("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "C01"),
403
+ ("`AC-TEST-002`", "Given x When y Then z", "`R-001`", "", "C01"),
404
+ ],
405
+ test_code,
406
+ )
407
+ res = validate_ac_alignment(proj)
408
+ check = _check(res)
409
+ assert check is not None and check["status"] == "WARN", check
410
+ assert "一测多AC" in check["detail"], check
411
+
412
+
413
+ def test_backend_docstring_ac_not_dims(tmp_path: Path):
414
+ test_code = '''
415
+ import pytest
416
+
417
+ @pytest.mark.ac("AC-TEST-001", dims=["C01"])
418
+ def test_ok():
419
+ """也提到 AC-OTHER-001。"""
420
+ assert True
421
+ '''
422
+ proj = create_backend_project(
423
+ tmp_path,
424
+ [
425
+ ("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "C01"),
426
+ ("`AC-OTHER-001`", "Given x When y Then z", "`R-001`", "", ""),
427
+ ],
428
+ test_code,
429
+ )
430
+ res = validate_ac_alignment(proj)
431
+ check = _check(res)
432
+ assert check is not None and check["status"] == "PASS", check
433
+
434
+
435
+ def test_backend_dims_shape_warn(tmp_path: Path):
436
+ test_code = """
437
+ import pytest
438
+
439
+ @pytest.mark.ac("AC-TEST-001", dims={"C01"})
440
+ def test_ok():
441
+ assert True
442
+ """
443
+ proj = create_backend_project(
444
+ tmp_path,
445
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "C01")],
446
+ test_code,
447
+ )
448
+ res = validate_ac_alignment(proj)
449
+ check = _check(res)
450
+ assert check is not None and check["status"] == "WARN", check
451
+ assert "dims形态不支持" in check["detail"], check
452
+
453
+
454
+ def test_backend_unknown_dim_warn(tmp_path: Path):
455
+ test_code = """
456
+ import pytest
457
+
458
+ @pytest.mark.ac("AC-TEST-001", dims=["C99"])
459
+ def test_ok():
460
+ assert True
461
+ """
462
+ proj = create_backend_project(
463
+ tmp_path,
464
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "C01")],
465
+ test_code,
466
+ )
467
+ res = validate_ac_alignment(proj)
468
+ check = _check(res)
469
+ assert check is not None and check["status"] == "WARN", check
470
+ assert "未知维度" in check["detail"], check
471
+
472
+
473
+ def test_backend_three_column_na(tmp_path: Path):
474
+ test_code = """
475
+ import pytest
476
+
477
+ @pytest.mark.ac("AC-TEST-001")
478
+ def test_ok():
479
+ assert True
480
+ """
481
+ proj = create_backend_project(
482
+ tmp_path,
483
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`")],
484
+ test_code,
485
+ header=["AC ID", "场景", "回指规则"],
486
+ )
487
+ res = validate_ac_alignment(proj)
488
+ check = _check(res)
489
+ assert check is not None and check["status"] == "N/A", check
490
+
491
+
492
+ def test_backend_standard_e2e_four_column_not_warn(tmp_path: Path):
493
+ test_code = """
494
+ import pytest
495
+
496
+ @pytest.mark.ac("AC-TEST-001", dims=["C01"])
497
+ def test_ok():
498
+ assert True
499
+ """
500
+ proj = create_backend_project(
501
+ tmp_path,
502
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "C01,C02")],
503
+ test_code,
504
+ header=["AC ID", "场景", "回指规则", "适用覆盖维度"],
505
+ )
506
+ res = validate_ac_alignment(proj)
507
+ check = _check(res)
508
+ assert check is not None and check["status"] == "N/A", check
509
+
510
+
511
+ def test_backend_header_in_fourth_column_warns(tmp_path: Path):
512
+ test_code = """
513
+ import pytest
514
+
515
+ @pytest.mark.ac("AC-TEST-001", dims=["C01"])
516
+ def test_ok():
517
+ assert True
518
+ """
519
+ proj = create_backend_project(
520
+ tmp_path,
521
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "C01")],
522
+ test_code,
523
+ header=["AC ID", "场景", "回指规则", "后端适用覆盖维度"],
524
+ )
525
+ res = validate_ac_alignment(proj)
526
+ check = _check(res)
527
+ assert check is not None and check["status"] == "WARN", check
528
+ assert "第 4 列" in check["detail"], check
529
+
530
+
531
+ def test_backend_notes_column_with_dims_warns(tmp_path: Path):
532
+ test_code = 'def test_unrelated():\n assert True\n'
533
+ proj = create_backend_project(
534
+ tmp_path,
535
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "C01")],
536
+ test_code,
537
+ header=["AC ID", "场景", "回指规则", "适用覆盖维度", "备注"],
538
+ )
539
+ res = validate_ac_alignment(proj)
540
+ check = _check(res)
541
+ assert check is not None and check["status"] == "WARN", check
542
+ assert "疑似后端维度列" in check["detail"], check
543
+
544
+
545
+ def test_backend_template_placeholder_na(tmp_path: Path):
546
+ proj = create_backend_project(
547
+ tmp_path,
548
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "N/A")],
549
+ "def test_unrelated():\n assert True\n",
550
+ )
551
+ res = validate_ac_alignment(proj)
552
+ check = _check(res)
553
+ assert check is not None and check["status"] == "N/A", check
554
+
555
+
556
+ def test_backend_no_backend_test_na(tmp_path: Path):
557
+ test_code = """
558
+ import pytest
559
+
560
+ @pytest.mark.ac("AC-TEST-001", dims=["C01"])
561
+ def test_ok():
562
+ assert True
563
+ """
564
+ proj = create_backend_project(
565
+ tmp_path,
566
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "C01")],
567
+ test_code,
568
+ with_backend_test=False,
569
+ )
570
+ res = validate_ac_alignment(proj)
571
+ check = _check(res)
572
+ assert check is not None and check["status"] == "N/A", check
573
+ assert "backend_test" in check["detail"], check
574
+
575
+
576
+ def test_backend_unknown_extractor_unverified(tmp_path: Path):
577
+ test_code = 'def test_unrelated():\n assert True\n'
578
+ proj = create_backend_project(
579
+ tmp_path,
580
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "C01")],
581
+ test_code,
582
+ extractor="unittest",
583
+ )
584
+ res = validate_ac_alignment(proj)
585
+ check = _check(res, "后端测试提取通道")
586
+ assert check is not None and check["status"] == "WARN", check
587
+ assert "[UNVERIFIED]" in check["detail"], check
588
+ assert res.status == "WARN", res.status
589
+
590
+
591
+ def test_backend_multi_app_uses_global_test_dir(tmp_path: Path):
592
+ apps = [
593
+ {"name": "api1", "path": "api1", "kind": "backend", "role": "landing", "stack": "python"},
594
+ {"name": "api2", "path": "api2", "kind": "backend", "role": "landing", "stack": "python"},
595
+ ]
596
+ test_code = """
597
+ import pytest
598
+
599
+ @pytest.mark.ac("AC-TEST-001", dims=["C01"])
600
+ def test_ok():
601
+ assert True
602
+ """
603
+ proj = create_backend_project(
604
+ tmp_path,
605
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "C01")],
606
+ test_code,
607
+ apps=apps,
608
+ )
609
+ res = validate_ac_alignment(proj)
610
+ check = _check(res)
611
+ assert check is not None and check["status"] == "PASS", check
612
+ assert res.status == "PASS", res.status
613
+
614
+
615
+ def test_backend_multi_app_root_test_dir_scans_all_apps(tmp_path: Path):
616
+ apps = [
617
+ {"name": "api1", "path": "api1", "kind": "backend", "role": "landing", "stack": "python"},
618
+ {"name": "api2", "path": "api2", "kind": "backend", "role": "landing", "stack": "python"},
619
+ ]
620
+ proj = create_backend_project(
621
+ tmp_path,
622
+ [
623
+ ("`AC-A-001`", "Given x When y Then z", "`R-001`", "", "C01"),
624
+ ("`AC-B-001`", "Given x When y Then z", "`R-002`", "", "C01"),
625
+ ],
626
+ None,
627
+ apps=apps,
628
+ )
629
+ profile = proj / "product" / "stack-profile.yaml"
630
+ profile.write_text(
631
+ profile.read_text(encoding="utf-8").replace("test_dir: backend/tests", "test_dir: ."),
632
+ encoding="utf-8",
633
+ )
634
+ for app, ac in (("api1", "AC-A-001"), ("api2", "AC-B-001")):
635
+ d = proj / app / "tests"
636
+ d.mkdir(parents=True)
637
+ (d / "test_x.py").write_text(
638
+ f"import pytest\n\n@pytest.mark.ac('{ac}', dims=['C01'])\ndef test_ok():\n assert True\n",
639
+ encoding="utf-8",
640
+ )
641
+ res = validate_ac_alignment(proj)
642
+ assert res.status == "PASS", [(c["check"], c["status"], c["detail"]) for c in res.checks]
643
+ cov = _check(res, "AC覆盖对账")
644
+ assert cov is not None and "覆盖: 2" in cov["detail"], cov
645
+
646
+
647
+ def test_backend_star_test_scan_face(tmp_path: Path):
648
+ proj = create_backend_project(
649
+ tmp_path,
650
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "C01")],
651
+ None,
652
+ test_dir_files={
653
+ "foo_test.py": 'import pytest\n\n@pytest.mark.ac("AC-TEST-001", dims=["C01"])\ndef test_x():\n assert True\n'
654
+ },
655
+ )
656
+ res = validate_ac_alignment(proj)
657
+ check = _check(res, "测试扫描面")
658
+ assert check is not None and check["status"] == "WARN", check
659
+ assert "[SCAN]" in check["detail"], check
660
+ assert "foo_test.py" in check["detail"], check
661
+
662
+
663
+ def test_backend_syntax_error_warn(tmp_path: Path):
664
+ proj = create_backend_project(
665
+ tmp_path,
666
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "C01")],
667
+ None,
668
+ test_dir_files={"test_bad.py": "def broken(:\n"},
669
+ )
670
+ res = validate_ac_alignment(proj)
671
+ check = _check(res, "测试提取完整性")
672
+ assert check is not None and check["status"] == "WARN", check
673
+ assert "[PARSE]" in check["detail"], check
674
+ assert "test_bad.py" in check["detail"], check
675
+ assert res.status == "WARN", res.status
676
+
677
+
678
+ def test_backend_empty_root_na(tmp_path: Path):
679
+ proj = create_backend_project(tmp_path, [], None)
680
+ res = validate_ac_alignment(proj)
681
+ assert res.status == "N/A"
682
+ check = _check(res, "AC对账门禁运行态")
683
+ assert check is not None and check["status"] == "N/A", check
684
+
685
+
686
+ def test_backend_dims_string_and_tuple(tmp_path: Path):
687
+ for idx, dims in enumerate(['"C01,C02"', '("C01", "C02")']):
688
+ root = tmp_path / str(idx)
689
+ test_code = f"""
690
+ import pytest
691
+
692
+ @pytest.mark.ac("AC-TEST-001", dims={dims})
693
+ def test_ok():
694
+ assert True
695
+ """
696
+ proj = create_backend_project(
697
+ root,
698
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "C01,C02")],
699
+ test_code,
700
+ )
701
+ res = validate_ac_alignment(proj)
702
+ check = _check(res)
703
+ assert check is not None and check["status"] == "PASS", (dims, check)
704
+
705
+
706
+ def test_backend_module_pytestmark_weak(tmp_path: Path):
707
+ test_code = """
708
+ import pytest
709
+ pytestmark = pytest.mark.skipif(False, reason="off")
710
+
711
+ @pytest.mark.ac("AC-TEST-001", dims=["C01"])
712
+ def test_ok():
713
+ assert True
714
+ """
715
+ proj = create_backend_project(
716
+ tmp_path,
717
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "C01")],
718
+ test_code,
719
+ )
720
+ res = validate_ac_alignment(proj)
721
+ check = _check(res)
722
+ assert check is not None and check["status"] == "WARN", check
723
+ assert "弱证据" in check["detail"], check
724
+
725
+
726
+ def test_backend_class_pytestmark_weak(tmp_path: Path):
727
+ test_code = """
728
+ import pytest
729
+
730
+ class TestX:
731
+ pytestmark = pytest.mark.skipif(False, reason="off")
732
+
733
+ @pytest.mark.ac("AC-TEST-001", dims=["C01"])
734
+ def test_ok(self):
735
+ assert True
736
+ """
737
+ proj = create_backend_project(
738
+ tmp_path,
739
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "C01")],
740
+ test_code,
741
+ )
742
+ res = validate_ac_alignment(proj)
743
+ check = _check(res)
744
+ assert check is not None and check["status"] == "WARN", check
745
+ assert "弱证据" in check["detail"], check
746
+
747
+
748
+ def test_backend_pytestmark_ac_unsupported(tmp_path: Path):
749
+ test_code = """
750
+ import pytest
751
+ pytestmark = pytest.mark.ac("AC-TEST-001")
752
+
753
+ @pytest.mark.ac("AC-TEST-001", dims=["C01"])
754
+ def test_ok():
755
+ assert True
756
+ """
757
+ proj = create_backend_project(
758
+ tmp_path,
759
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "C01")],
760
+ test_code,
761
+ )
762
+ res = validate_ac_alignment(proj)
763
+ check = _check(res)
764
+ assert check is not None and check["status"] == "WARN", check
765
+ assert "模块/类级AC标记不支持" in check["detail"], check
766
+
767
+
768
+ def test_backend_prd_unknown_token_warn(tmp_path: Path):
769
+ test_code = """
770
+ import pytest
771
+
772
+ @pytest.mark.ac("AC-TEST-001", dims=["C01"])
773
+ def test_ok():
774
+ assert True
775
+ """
776
+ proj = create_backend_project(
777
+ tmp_path,
778
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "C01,C99")],
779
+ test_code,
780
+ )
781
+ res = validate_ac_alignment(proj)
782
+ check = _check(res)
783
+ assert check is not None and check["status"] == "WARN", check
784
+ assert "未知 token" in check["detail"] and "C99" in check["detail"], check
785
+
786
+
787
+ def test_backend_cross_prd_duplicate_warn(tmp_path: Path):
788
+ test_code = """
789
+ import pytest
790
+
791
+ @pytest.mark.ac("AC-TEST-001", dims=["C01"])
792
+ def test_ok():
793
+ assert True
794
+ """
795
+ proj = create_backend_project(
796
+ tmp_path,
797
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "C01")],
798
+ test_code,
799
+ )
800
+ (proj / "product" / "prd" / "other-prd.md").write_text(
801
+ "# 另一域 PRD\n## 五、验收标准\n"
802
+ "| AC ID | 场景 | 回指规则 | 适用覆盖维度 | 后端适用覆盖维度 |\n"
803
+ "|---|---|---|---|---|\n"
804
+ "| `AC-TEST-001` | Given x When y Then z | `R-002` | | C01 |\n",
805
+ encoding="utf-8",
806
+ )
807
+ res = validate_ac_alignment(proj)
808
+ check = _check(res)
809
+ assert check is not None and check["status"] == "WARN", check
810
+ assert "重复声明" in check["detail"], check
811
+
812
+
813
+ def test_backend_notes_ac_id_no_false_warn(tmp_path: Path):
814
+ proj = create_backend_project(
815
+ tmp_path,
816
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "AC-C01-001")],
817
+ "def test_unrelated():\n assert True\n",
818
+ header=["AC ID", "场景", "回指规则", "适用覆盖维度", "备注"],
819
+ )
820
+ res = validate_ac_alignment(proj)
821
+ check = _check(res)
822
+ assert check is not None and check["status"] == "N/A", check
823
+
824
+
825
+ def test_backend_real_template_placeholder_na(tmp_path: Path):
826
+ template = (PKG_ROOT / "templates" / "prd" / "domain.md").read_text(encoding="utf-8")
827
+ proj = create_backend_project(
828
+ tmp_path,
829
+ [],
830
+ 'import pytest\n\n@pytest.mark.ac("AC-TEST-001")\ndef test_ok():\n assert True\n',
831
+ )
832
+ (proj / "product" / "prd" / "test-prd.md").write_text(
833
+ template.replace("<CODE>", "TEST"), encoding="utf-8"
834
+ )
835
+ res = validate_ac_alignment(proj)
836
+ check = _check(res)
837
+ assert check is not None and check["status"] == "N/A", check
838
+ assert "零测试" not in check["detail"], check
839
+
840
+
841
+ def test_backend_six_column_pass(tmp_path: Path):
842
+ test_code = """
843
+ import pytest
844
+
845
+ @pytest.mark.ac("AC-TEST-001", dims=["C01"])
846
+ def test_ok():
847
+ assert True
848
+ """
849
+ proj = create_backend_project(
850
+ tmp_path,
851
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "", "C01")],
852
+ test_code,
853
+ header=["AC ID", "场景", "回指规则", "适用覆盖维度", "备注", "后端适用覆盖维度"],
854
+ )
855
+ res = validate_ac_alignment(proj)
856
+ check = _check(res)
857
+ assert check is not None and check["status"] == "PASS", check
858
+
859
+
860
+ def test_backend_empty_dims_warn(tmp_path: Path):
861
+ test_code = """
862
+ import pytest
863
+
864
+ @pytest.mark.ac("AC-TEST-001", dims=[])
865
+ def test_ok():
866
+ assert True
867
+ """
868
+ proj = create_backend_project(
869
+ tmp_path,
870
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "C01")],
871
+ test_code,
872
+ )
873
+ res = validate_ac_alignment(proj)
874
+ check = _check(res)
875
+ assert check is not None and check["status"] == "WARN", check
876
+ assert "空dims" in check["detail"], check
877
+
878
+
879
+ def test_backend_parametrize_marks_weak(tmp_path: Path):
880
+ test_code = """
881
+ import pytest
882
+
883
+ @pytest.mark.parametrize("x", [pytest.param(1, marks=pytest.mark.skip)])
884
+ @pytest.mark.ac("AC-TEST-001", dims=["C01"])
885
+ def test_ok(x):
886
+ assert x
887
+ """
888
+ proj = create_backend_project(
889
+ tmp_path,
890
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "C01")],
891
+ test_code,
892
+ )
893
+ res = validate_ac_alignment(proj)
894
+ check = _check(res)
895
+ assert check is not None and check["status"] == "WARN", check
896
+ assert "弱证据" in check["detail"], check
897
+
898
+
899
+ def test_backend_enforce_dim_gap_warn(tmp_path: Path):
900
+ test_code = """
901
+ import pytest
902
+
903
+ @pytest.mark.ac("AC-TEST-001", dims=["C01"])
904
+ def test_ok():
905
+ assert True
906
+ """
907
+ proj = create_backend_project(
908
+ tmp_path,
909
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "C01,C02")],
910
+ test_code,
911
+ mode="enforce",
912
+ )
913
+ res = validate_ac_alignment(proj)
914
+ check = _check(res)
915
+ assert check is not None and check["status"] == "WARN", check
916
+ assert res.status == "WARN", res.status
917
+
918
+
919
+ # ---------------------------------------------------------------------------
920
+ # D163:三形态适用性 / extractor 边界 / 诚实状态
921
+ # ---------------------------------------------------------------------------
922
+
923
+ def test_frontend_only_ac_gate_na(tmp_path: Path):
924
+ test_code = """
925
+ import pytest
926
+
927
+ @pytest.mark.ac("AC-TEST-001")
928
+ def test_ok():
929
+ assert True
930
+ """
931
+ apps = [{"name": "web", "path": "frontend", "kind": "frontend", "role": "landing", "stack": "react"}]
932
+ proj = create_backend_project(
933
+ tmp_path,
934
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "C01")],
935
+ test_code,
936
+ apps=apps,
937
+ )
938
+ res = validate_ac_alignment(proj)
939
+ assert res.status == "N/A", res.status
940
+ check = _check(res, "门控态")
941
+ assert check is not None and check["status"] == "N/A", check
942
+ assert "backend/fullstack" in check["detail"], check
943
+ assert not (proj / ".atlas" / "runs" / "backend-ac-alignment.json").exists()
944
+
945
+
946
+ def test_missing_apps_dangling_ac_still_fails(tmp_path: Path):
947
+ test_code = """
948
+ import pytest
949
+
950
+ @pytest.mark.ac("AC-GHOST-999")
951
+ def test_fake():
952
+ assert True
953
+ """
954
+ proj = create_backend_project(
955
+ tmp_path,
956
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "C01")],
957
+ test_code,
958
+ )
959
+ (proj / "product" / "stack-profile.yaml").write_text("product: x\n", encoding="utf-8")
960
+ res = validate_ac_alignment(proj)
961
+ assert res.status == "FAIL", res.status
962
+ check = _check(res, "无主AC拦截")
963
+ assert check is not None and check["status"] == "FAIL", check
964
+ assert "AC-GHOST-999" in check["detail"], check
965
+ applicability = _check(res, "后端通道适用性")
966
+ assert applicability is not None and applicability["status"] == "WARN", applicability
967
+
968
+
969
+ def test_missing_apps_without_input_unverified(tmp_path: Path):
970
+ proj = create_backend_project(
971
+ tmp_path,
972
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "C01")],
973
+ None,
974
+ )
975
+ (proj / "product" / "stack-profile.yaml").write_text("product: x\n", encoding="utf-8")
976
+ res = validate_ac_alignment(proj)
977
+ applicability = _check(res, "后端通道适用性")
978
+ assert applicability is not None and applicability["status"] == "WARN", applicability
979
+ assert "[UNVERIFIED]" in applicability["detail"], applicability
980
+ assert res.status == "WARN", res.status
981
+
982
+
983
+ def test_backend_no_extractor_without_input_unverified(tmp_path: Path):
984
+ proj = create_backend_project(
985
+ tmp_path,
986
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "C01")],
987
+ None,
988
+ )
989
+ res = validate_ac_alignment(proj)
990
+ check = _check(res, "后端测试提取通道")
991
+ assert check is not None and check["status"] == "WARN", check
992
+ assert "[UNVERIFIED]" in check["detail"], check
993
+ assert res.status == "WARN", res.status
994
+
995
+
996
+ def test_backend_non_python_without_extractor_unverified_not_zero_coverage(tmp_path: Path):
997
+ proj = create_backend_project(
998
+ tmp_path,
999
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "C01")],
1000
+ None,
1001
+ test_dir_files={"a.test.ts": "// no Python test input\n"},
1002
+ )
1003
+ res = validate_ac_alignment(proj)
1004
+ check = _check(res, "后端测试提取通道")
1005
+ assert check is not None and check["status"] == "WARN", check
1006
+ assert "[UNVERIFIED]" in check["detail"], check
1007
+ cov = _check(res, "AC覆盖对账")
1008
+ assert cov is None, cov # 未验证分支不应再产出 ZERO-COVERAGE
1009
+
1010
+
1011
+ def test_backend_zero_coverage_advisory_warns(tmp_path: Path):
1012
+ proj = create_backend_project(
1013
+ tmp_path,
1014
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "C01")],
1015
+ "def test_unrelated():\n assert True\n",
1016
+ with_backend_test=False,
1017
+ )
1018
+ res = validate_ac_alignment(proj)
1019
+ check = _check(res, "AC覆盖对账")
1020
+ assert check is not None and check["status"] == "WARN", check
1021
+ assert "[ZERO-COVERAGE]" in check["detail"], check
1022
+ assert res.status == "WARN", res.status
1023
+
1024
+
1025
+ def test_backend_enforce_parse_errors_fail(tmp_path: Path):
1026
+ proj = create_backend_project(
1027
+ tmp_path,
1028
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "C01")],
1029
+ None,
1030
+ test_dir_files={"test_bad.py": "def broken(:\n"},
1031
+ mode="enforce",
1032
+ )
1033
+ res = validate_ac_alignment(proj)
1034
+ check = _check(res, "测试提取完整性")
1035
+ assert check is not None and check["status"] == "FAIL", check
1036
+ assert "[PARSE]" in check["detail"], check
1037
+ assert res.status == "FAIL", res.status
1038
+
1039
+
1040
+ def test_record_schema_rejects_bad_records(tmp_path: Path):
1041
+ base = {
1042
+ "name": "test_x",
1043
+ "file": "backend/tests/test_x.py",
1044
+ "line": 1,
1045
+ "acs": {"AC-X-001"},
1046
+ "marker_acs": {"AC-X-001"},
1047
+ "doc_acs": set(),
1048
+ "dims": set(),
1049
+ "dims_bad": [],
1050
+ "dims_shape_ok": True,
1051
+ "dims_present": False,
1052
+ "skipped": False,
1053
+ "waiver": "",
1054
+ "weak": False,
1055
+ "marker_scope_unsupported": False,
1056
+ }
1057
+ assert vaa._record_schema_errors([base], tmp_path) == []
1058
+ bad_union = dict(base, acs={"AC-X-001"}, marker_acs=set())
1059
+ assert any("acs 必须等于" in e for e in vaa._record_schema_errors([bad_union], tmp_path))
1060
+ bad_line = dict(base, line=0)
1061
+ assert any("line 必须为正整数" in e for e in vaa._record_schema_errors([bad_line], tmp_path))
1062
+ dup = dict(base, name="test_y")
1063
+ assert any("(file,line) 重复" in e for e in vaa._record_schema_errors([base, dup], tmp_path))
1064
+ absolute = dict(base, file=str(tmp_path / "x.py"))
1065
+ assert any("相对路径" in e for e in vaa._record_schema_errors([absolute], tmp_path))
1066
+
1067
+
1068
+ # ---------------------------------------------------------------------------
1069
+ # D164:TS / Vitest / Jest 内置 extractor(Batch C1)
1070
+ # ---------------------------------------------------------------------------
1071
+
1072
+ def test_backend_vitest_title_ac_and_dims_pass(tmp_path: Path):
1073
+ proj = create_backend_project(
1074
+ tmp_path,
1075
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "C01")],
1076
+ None,
1077
+ extractor="vitest",
1078
+ test_dir_files={
1079
+ "refund.test.ts": (
1080
+ "/** @atlas-dims C01 */\n"
1081
+ "it('[AC-TEST-001] 余额不足时拒绝退款', () => {});\n"
1082
+ ),
1083
+ },
1084
+ )
1085
+ res = validate_ac_alignment(proj)
1086
+ assert res.status == "PASS", [(c["check"], c["status"], c["detail"]) for c in res.checks]
1087
+ cov = _check(res, "AC覆盖对账")
1088
+ assert cov is not None and "覆盖: 1" in cov["detail"], cov
1089
+ dim = _check(res)
1090
+ assert dim is not None and dim["status"] == "PASS", dim
1091
+
1092
+
1093
+ def test_backend_vitest_dangling_ac_fails(tmp_path: Path):
1094
+ proj = create_backend_project(
1095
+ tmp_path,
1096
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "C01")],
1097
+ None,
1098
+ extractor="vitest",
1099
+ test_dir_files={
1100
+ "refund.test.ts": "it('[AC-GHOST-999] 假挂载', () => {});\n",
1101
+ },
1102
+ )
1103
+ res = validate_ac_alignment(proj)
1104
+ assert res.status == "FAIL", res.status
1105
+ check = _check(res, "无主AC拦截")
1106
+ assert check is not None and "AC-GHOST-999" in check["detail"], check
1107
+
1108
+
1109
+ def test_backend_jest_skip_without_waiver_fails(tmp_path: Path):
1110
+ proj = create_backend_project(
1111
+ tmp_path,
1112
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "C01")],
1113
+ None,
1114
+ extractor="jest",
1115
+ test_dir_files={
1116
+ "__tests__/refund.spec.js": "test.skip('[AC-TEST-001] 暂不做', () => {});\n",
1117
+ },
1118
+ )
1119
+ res = validate_ac_alignment(proj)
1120
+ assert res.status == "FAIL", res.status
1121
+ check = _check(res, "无效跳过拦截")
1122
+ assert check is not None and check["status"] == "FAIL", check
1123
+
1124
+
1125
+ def test_backend_vitest_unwaived_describe_skip_fails(tmp_path: Path):
1126
+ proj = create_backend_project(
1127
+ tmp_path,
1128
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "C01")],
1129
+ None,
1130
+ extractor="vitest",
1131
+ test_dir_files={
1132
+ "refund.test.ts": (
1133
+ "describe.skip('group', () => {\n"
1134
+ " it('[AC-TEST-001] 暂不做', () => {});\n"
1135
+ "});\n"
1136
+ ),
1137
+ },
1138
+ )
1139
+ res = validate_ac_alignment(proj)
1140
+ assert res.status == "FAIL", res.status
1141
+ check = _check(res, "无效跳过拦截")
1142
+ assert check is not None and "AC-TEST-001" in check["detail"], check
1143
+
1144
+
1145
+
1146
+ def test_backend_junit5_title_ac_and_dims_pass(tmp_path: Path):
1147
+ proj = create_backend_project(
1148
+ tmp_path,
1149
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "C01")],
1150
+ None,
1151
+ extractor="junit5",
1152
+ test_dir_files={
1153
+ "RefundTest.java": (
1154
+ "class RefundTest {\n"
1155
+ " @Test\n"
1156
+ " @DisplayName(\"[AC-TEST-001] 余额不足拒绝\")\n"
1157
+ " @Tag(\"atlas-dims-C01\")\n"
1158
+ " void refundRejected() {}\n"
1159
+ "}\n"
1160
+ ),
1161
+ },
1162
+ )
1163
+ res = validate_ac_alignment(proj)
1164
+ assert res.status == "PASS", [(c["check"], c["status"], c["detail"]) for c in res.checks]
1165
+ dim = _check(res)
1166
+ assert dim is not None and dim["status"] == "PASS", dim
1167
+
1168
+
1169
+ def test_backend_junit5_dangling_ac_fails(tmp_path: Path):
1170
+ proj = create_backend_project(
1171
+ tmp_path,
1172
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "C01")],
1173
+ None,
1174
+ extractor="junit5",
1175
+ test_dir_files={
1176
+ "RefundTest.java": (
1177
+ "class RefundTest {\n"
1178
+ " @Test\n"
1179
+ " @DisplayName(\"[AC-GHOST-999] 假挂载\")\n"
1180
+ " void fake() {}\n"
1181
+ "}\n"
1182
+ ),
1183
+ },
1184
+ )
1185
+ res = validate_ac_alignment(proj)
1186
+ assert res.status == "FAIL", res.status
1187
+ check = _check(res, "无主AC拦截")
1188
+ assert check is not None and "AC-GHOST-999" in check["detail"], check
1189
+
1190
+
1191
+ def test_backend_junit5_disabled_without_waiver_fails(tmp_path: Path):
1192
+ proj = create_backend_project(
1193
+ tmp_path,
1194
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "C01")],
1195
+ None,
1196
+ extractor="junit5",
1197
+ test_dir_files={
1198
+ "RefundTest.java": (
1199
+ "class RefundTest {\n"
1200
+ " @Test\n"
1201
+ " @DisplayName(\"[AC-TEST-001] 暂不做\")\n"
1202
+ " @Disabled(\"later\")\n"
1203
+ " void pending() {}\n"
1204
+ "}\n"
1205
+ ),
1206
+ },
1207
+ )
1208
+ res = validate_ac_alignment(proj)
1209
+ assert res.status == "FAIL", res.status
1210
+ check = _check(res, "无效跳过拦截")
1211
+ assert check is not None and check["status"] == "FAIL", check
1212
+
1213
+
1214
+ def _external_extractor(path: Path, body: str) -> str:
1215
+ path.write_text(body, encoding="utf-8")
1216
+ return f"{sys.executable} {path}"
1217
+
1218
+
1219
+ def test_backend_external_command_passes(tmp_path: Path):
1220
+ script = tmp_path / "extract.py"
1221
+ cmd = _external_extractor(script, (
1222
+ "import json\n"
1223
+ "print(json.dumps({'schema_version': 1, 'input_state': 'present', 'tests': [{\n"
1224
+ " 'name': 'T.ac', 'file': 'backend/tests/T.java', 'line': 3,\n"
1225
+ " 'acs': ['AC-TEST-001'], 'marker_acs': ['AC-TEST-001'], 'doc_acs': [],\n"
1226
+ " 'dims': ['C01'], 'dims_bad': [], 'dims_shape_ok': True, 'dims_present': True,\n"
1227
+ " 'skipped': False, 'waiver': '', 'weak': False, 'marker_scope_unsupported': False\n"
1228
+ "}], 'parse_errors': [], 'scan_face': []}))\n"
1229
+ ))
1230
+ proj = create_backend_project(
1231
+ tmp_path,
1232
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "C01")],
1233
+ None,
1234
+ extractor_command=cmd,
1235
+ )
1236
+ res = validate_ac_alignment(proj)
1237
+ assert res.status == "PASS", [(c["check"], c["status"], c["detail"]) for c in res.checks]
1238
+ dim = _check(res)
1239
+ assert dim is not None and dim["status"] == "PASS", dim
1240
+
1241
+
1242
+ def test_backend_external_command_invalid_json_fails(tmp_path: Path):
1243
+ script = tmp_path / "extract.py"
1244
+ cmd = _external_extractor(script, "print('not json')\n")
1245
+ proj = create_backend_project(
1246
+ tmp_path,
1247
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "C01")],
1248
+ None,
1249
+ extractor_command=cmd,
1250
+ )
1251
+ res = validate_ac_alignment(proj)
1252
+ assert res.status == "FAIL", res.status
1253
+ check = _check(res, "测试提取通道")
1254
+ assert check is not None and "[EXTRACTOR]" in check["detail"], check
1255
+
1256
+
1257
+ def test_backend_external_command_nonzero_fails(tmp_path: Path):
1258
+ script = tmp_path / "extract.py"
1259
+ cmd = _external_extractor(script, "import sys\nsys.stderr.write('bad\\n')\nsys.exit(3)\n")
1260
+ proj = create_backend_project(
1261
+ tmp_path,
1262
+ [("`AC-TEST-001`", "Given x When y Then z", "`R-001`", "", "C01")],
1263
+ None,
1264
+ extractor_command=cmd,
1265
+ )
1266
+ res = validate_ac_alignment(proj)
1267
+ assert res.status == "FAIL", res.status
1268
+ check = _check(res, "测试提取通道")
1269
+ assert check is not None and "退出码 3" in check["detail"], check
1270
+
1271
+
1272
+ def test_backend_junit5_multi_module_test_dir_root(tmp_path: Path):
1273
+ """多模块 Maven/Gradle:单 app + test_dir: . 时递归盘点各模块测试。"""
1274
+ proj = create_backend_project(
1275
+ tmp_path,
1276
+ [
1277
+ ("`AC-MODA-001`", "Given x When y Then z", "`R-001`", "", "C01"),
1278
+ ("`AC-MODB-001`", "Given x When y Then z", "`R-002`", "", "C01"),
1279
+ ],
1280
+ None,
1281
+ extractor="junit5",
1282
+ )
1283
+ profile = proj / "product" / "stack-profile.yaml"
1284
+ profile.write_text(
1285
+ profile.read_text(encoding="utf-8").replace("test_dir: backend/tests", "test_dir: ."),
1286
+ encoding="utf-8",
1287
+ )
1288
+ for module, ac in (("module-a", "AC-MODA-001"), ("module-b", "AC-MODB-001")):
1289
+ d = proj / module / "src" / "test" / "java" / "com" / "x"
1290
+ d.mkdir(parents=True)
1291
+ (d / f"{module.title()}Test.java").write_text(
1292
+ "class T {\n"
1293
+ " @Test\n"
1294
+ f" @DisplayName(\"[{ac}] case\")\n"
1295
+ " @Tag(\"atlas-dims-C01\")\n"
1296
+ " void t() {}\n"
1297
+ "}\n",
1298
+ encoding="utf-8",
1299
+ )
1300
+ res = validate_ac_alignment(proj)
1301
+ assert res.status == "PASS", [(c["check"], c["status"], c["detail"]) for c in res.checks]
1302
+ cov = _check(res, "AC覆盖对账")
1303
+ assert cov is not None and "覆盖: 2" in cov["detail"], cov