@tea-agent/loop-agent 0.42.0-next.1 → 0.42.0-next.11

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 (247) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +321 -1
  3. package/dist/application/dag/generate-task-dag.js +75 -19
  4. package/dist/application/dag/run-dag.js +41 -0
  5. package/dist/application/task-lifecycle/advance.js +24 -5
  6. package/dist/application/task-lifecycle/observe.js +171 -17
  7. package/dist/application/task-lifecycle/plan-transitions.js +42 -7
  8. package/dist/application/task-lifecycle/recommendations.js +13 -2
  9. package/dist/build-stamp.json +3 -3
  10. package/dist/cli/command-definitions.js +7 -0
  11. package/dist/cli/program.js +6 -1
  12. package/dist/commands/client-recovery.js +3 -0
  13. package/dist/commands/dag-follow-up.js +138 -0
  14. package/dist/commands/dag-rerun-task.js +2 -0
  15. package/dist/commands/init.js +27 -1
  16. package/dist/commands/task-advance.js +19 -0
  17. package/dist/executors/dag-pi-executor.js +4010 -78
  18. package/dist/executors/pi-executor.js +15 -4
  19. package/dist/executors/pi-extension-resolver.js +14 -2
  20. package/dist/executors/pi-read-budget-policy.js +239 -0
  21. package/dist/executors/shell-executor.js +978 -188
  22. package/dist/executors/shell-write-guard.js +7 -0
  23. package/dist/infrastructure/console/operation-store.js +226 -6
  24. package/dist/shared/dag-failure-category.js +12 -0
  25. package/dist/shared/openspec-spec.js +70 -4
  26. package/dist/shared/operator/capabilities.js +7 -0
  27. package/dist/task/config-types.js +105 -5
  28. package/dist/task/contract/adopt.js +4 -0
  29. package/dist/task/contract/import-revision.js +4 -0
  30. package/dist/task/contract/project.js +3 -0
  31. package/dist/task/contract/schema.js +2 -1
  32. package/dist/task/frontend-project-capability.js +203 -20
  33. package/dist/task/runtime.js +5 -2
  34. package/dist/task/source-prepare/build-draft.js +3 -3
  35. package/dist/task/source-prepare/fragment-inventory.js +64 -25
  36. package/dist/task/source-prepare/prepare.js +126 -21
  37. package/dist/task/source-prepare/semantic-intake.js +6 -2
  38. package/dist/task/source-references.js +22 -1
  39. package/dist/worker/console/chat/chat-ui-policy.js +4 -2
  40. package/dist/worker/console/chat/model-resolver.js +7 -4
  41. package/dist/worker/console/chat/pi-runtime.js +258 -11
  42. package/dist/worker/console/chat/repo-browser.js +10 -3
  43. package/dist/worker/console/chat/routes.js +165 -71
  44. package/dist/worker/console/chat/sdd-data-alignment.js +222 -0
  45. package/dist/worker/console/chat/session-catalog.js +2 -0
  46. package/dist/worker/console/chat/session-store.js +159 -70
  47. package/dist/worker/console/chat/shortcuts.js +19 -3
  48. package/dist/worker/console/chat/turn-process.js +111 -56
  49. package/dist/worker/console/frontend-human-decision-adapter.js +19 -0
  50. package/dist/worker/console/frontend-split-operation-adapter.js +20 -0
  51. package/dist/worker/console/index.js +3 -0
  52. package/dist/worker/console/operator-actions.js +169 -7
  53. package/dist/worker/console/pi-readiness.js +3 -2
  54. package/dist/worker/console/server.js +23 -1
  55. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-DZZ8m3PO.js → abnfDiagram-N423BO3Z-CcS17TBr.js} +1 -1
  56. package/dist/worker/console/static/assets/{arc-D6PvaVd-.js → arc-COptKq2S.js} +1 -1
  57. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-B_OTOiI8.js → architectureDiagram-T3A2C74G-h4LKMHjP.js} +1 -1
  58. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-Bv6rqHBg.js → blockDiagram-VBNYF7ZC-COA1MOH0.js} +1 -1
  59. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-B8eHr0oz.js → c4Diagram-5PPSVZJV-DJUf0QPm.js} +1 -1
  60. package/dist/worker/console/static/assets/channel-DdBCaOJ6.js +1 -0
  61. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-DYwR0im2.js → chunk-2GRJ4B5K-mtWfKrUX.js} +1 -1
  62. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-D2WPGqXt.js → chunk-2Q5K7J3B-B8pXsxDQ.js} +1 -1
  63. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-CQISJ_I7.js → chunk-5RXB4S5H-ipKzByl1.js} +1 -1
  64. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-C0o2Du1e.js → chunk-5VM5RSS4-DYi3Ald_.js} +1 -1
  65. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-4f8kr-U7.js → chunk-6Q2QTUOP-DQtGYoty.js} +1 -1
  66. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-D_OWvXzX.js → chunk-GF5L2VYU-BU0qS2YV.js} +1 -1
  67. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-CasdPz5X.js → chunk-JWPE2WC7-DVH9dIGE.js} +1 -1
  68. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-Cj8lRrla.js → chunk-KBJHAD2P-Dt60SmgM.js} +1 -1
  69. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-CUvD1FSd.js → chunk-RYQCIY6F-BoaUGuEY.js} +1 -1
  70. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-CqLw_eqB.js → chunk-XXDRQBXY-_WHFDTp2.js} +1 -1
  71. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DZFra1GO.js +1 -0
  72. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DZFra1GO.js +1 -0
  73. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-ku-WqJPx.js → cose-bilkent-JH36ORCC-BxkbTIRd.js} +1 -1
  74. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-Bq_PYDhl.js → cynefin-VYW2F7L2-Bf2UVnoG.js} +1 -1
  75. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-BiGZV641.js → cynefinDiagram-MW4NZA55-Bo23q1J_.js} +1 -1
  76. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-C4JRlglF.js → dagre-VZM6K2ZE-Dd_UU49i.js} +1 -1
  77. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-B0dNeWYG.js → diagram-7IWD3JNH-ebUa1a9y.js} +1 -1
  78. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-Cj35YvYM.js → diagram-B4RE2ZJO-BucthU8r.js} +1 -1
  79. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-uzQoJ2-8.js → diagram-LBJQPF4R-BgqNpAkm.js} +1 -1
  80. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-D1a-Buoz.js → diagram-Q27KOJAE-DC4q5NGa.js} +1 -1
  81. package/dist/worker/console/static/assets/{diagram-UB23O5K3-XjRrLRSs.js → diagram-UB23O5K3-CBqCaMeb.js} +1 -1
  82. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-Da_O24cW.js → ebnfDiagram-BXEA7PRR-CGtnbQ3-.js} +1 -1
  83. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-BLJ8jrYU.js → erDiagram-JOGREHBK-CG3LUao5.js} +1 -1
  84. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-B9GrLjM0.js → flowDiagram-UKHOOZJN-D2ZVgoFS.js} +1 -1
  85. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-CeJ0TqiK.js → ganttDiagram-PKOTCBZU-DoYFZiKt.js} +1 -1
  86. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Bm2eNNFX.js → gitGraphDiagram-DS77QQ5N-CKoP1s6j.js} +1 -1
  87. package/dist/worker/console/static/assets/index-CAZ2fC_X.css +1 -0
  88. package/dist/worker/console/static/assets/index-vbTcFnFs.js +449 -0
  89. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-DB26i3d8.js → infoDiagram-6WML65LV-Duofv8p2.js} +1 -1
  90. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-BOGRyeZT.js → ishikawaDiagram-WSZJBQD7-D2nlCkA1.js} +1 -1
  91. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-CZRoBO_6.js → journeyDiagram-NVQOT4AX-Dd4IHdus.js} +1 -1
  92. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-qlWhJyoB.js → kanban-definition-27J2QSJJ-Bi20AOdt.js} +1 -1
  93. package/dist/worker/console/static/assets/{linear-CfUiDDB3.js → linear-D59YJ9kB.js} +1 -1
  94. package/dist/worker/console/static/assets/{mermaid.core-BQe6fpqj.js → mermaid.core-Bl13LpNa.js} +5 -5
  95. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-zFzWHw64.js → mindmap-definition-FAOFIHXS-C6DLP5eY.js} +1 -1
  96. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-DFGUqjZO.js → pegDiagram-VL7TDLO6-BjO4cy64.js} +1 -1
  97. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-BB5i0l8Z.js → pieDiagram-7S7Q4E2Y-D7i2qZTG.js} +1 -1
  98. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-obbVZ_X5.js → quadrantDiagram-CIZ2JOQS-Dx8o_h0y.js} +1 -1
  99. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-BwdaNzc1.js → railroadDiagram-AXF67PYL-B55orI_t.js} +1 -1
  100. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-CkTJaugh.js → requirementDiagram-LRYGKXZP-zWaehp6f.js} +1 -1
  101. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-BwJ-hIgq.js → sankeyDiagram-W5VNT64P-CcA-pjvD.js} +1 -1
  102. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-BzSgmm7w.js → sequenceDiagram-SI44F4Z6-BOFbzNHI.js} +1 -1
  103. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-Bn3oj5Q1.js → sizeCapture-X5ZJPWSS-BjAejah1.js} +1 -1
  104. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-dxhGtL8K.js → stateDiagram-OKZ733FA-DXYwxJwZ.js} +1 -1
  105. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-Clg3V9t1.js +1 -0
  106. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-BLpEInya.js → swimlanes-SLNWSIFB-OYiOch8n.js} +2 -2
  107. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-DX1dxAAW.js +8 -0
  108. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-ChJGYcXy.js → timeline-definition-Z64GVDOM-cZfH3nmU.js} +1 -1
  109. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-DK-qjRer.js → vennDiagram-T6HMQDX7-BDE7b3E1.js} +1 -1
  110. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-3qeaWAg-.js → wardleyDiagram-T6FBY63Y-kyyGy9WJ.js} +1 -1
  111. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-CootlyP9.js → xychartDiagram-ELKLHX3M-CJj6VTog.js} +1 -1
  112. package/dist/worker/console/static/index.html +2 -2
  113. package/dist/worker/console/static-src/active-run-badge.js +17 -0
  114. package/dist/worker/console/static-src/app/useRecoveryConsole.js +5 -5
  115. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +19 -8
  116. package/dist/worker/console/static-src/operator-chat/input-history.js +8 -6
  117. package/dist/worker/console/static-src/operator-chat/open-preview-in-browser.js +15 -7
  118. package/dist/worker/console/static-src/operator-chat/runtime-snapshot-store.js +10 -0
  119. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +258 -23
  120. package/dist/worker/console/static-src/operator-chat/useChatThread.js +112 -4
  121. package/dist/worker/console/static-src/operator-chat/useComposer.js +13 -4
  122. package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +29 -6
  123. package/dist/worker/console/static-src/operator-chat/useRuntimeSnapshot.js +8 -2
  124. package/dist/worker/console/static-src/shell/console-update-reload.js +36 -9
  125. package/dist/worker/console/static-src/shell/workspace-route.js +11 -0
  126. package/dist/worker/console/workspace-context.js +115 -1
  127. package/dist/worker/materialize/frontend-split-task-materializer.js +72 -0
  128. package/dist/worker/observe/routes.js +4 -0
  129. package/dist/worker/observe/static/constants.js +22 -22
  130. package/dist/worker/observe/static/dag-context-reason-labels.js +19 -0
  131. package/dist/worker/observe/static/dag-helpers.js +4 -2
  132. package/dist/worker/observe/static/dag-history-labels.js +1 -0
  133. package/dist/worker/observe/static/dag-inspector-humanize.d.ts +16 -0
  134. package/dist/worker/observe/static/dag-inspector-humanize.js +339 -0
  135. package/dist/worker/observe/static/dag-node-purpose.js +13 -8
  136. package/dist/worker/observe/static/index.html +4 -4
  137. package/dist/worker/observe/static/inspect-workspace.js +23 -0
  138. package/dist/worker/observe/static/kpi.js +2 -2
  139. package/dist/worker/observe/static/operator-chrome.d.ts +10 -2
  140. package/dist/worker/observe/static/operator-chrome.js +37 -23
  141. package/dist/worker/observe/static/prompt-restart-candidates.js +4 -2
  142. package/dist/worker/observe/static/relations.js +7 -7
  143. package/dist/worker/observe/static/router.d.ts +12 -1
  144. package/dist/worker/observe/static/router.js +48 -6
  145. package/dist/worker/observe/static/run-processing.js +4 -2
  146. package/dist/worker/observe/static/shell-chrome.js +2 -2
  147. package/dist/worker/observe/static/state.js +5 -0
  148. package/dist/worker/observe/static/styles.css +231 -0
  149. package/dist/worker/observe/static/views/dag-graph.js +1 -1
  150. package/dist/worker/observe/static/views/dag-inspector.js +336 -181
  151. package/dist/worker/observe/static/views/dag.js +26 -14
  152. package/dist/worker/observe/static/views/dags.js +3 -1
  153. package/dist/worker/observe/static/views/dashboard.js +14 -6
  154. package/dist/worker/observe/static/views/pool.js +7 -2
  155. package/dist/worker/observe/static/views/run.js +12 -3
  156. package/dist/worker/observe/static/views/session-timeline.js +74 -13
  157. package/dist/worker/observe/static/views/task.js +7 -2
  158. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +215 -28
  159. package/dist/workflows/dag/backend-test-markdown-workflow.js +13 -1
  160. package/dist/workflows/dag/backend-test-plan-protocol.js +210 -0
  161. package/dist/workflows/dag/backend-test-scenario-param.js +172 -25
  162. package/dist/workflows/dag/backend-test-scenario-partitions.js +62 -1
  163. package/dist/workflows/dag/backend-test-writer-completeness.js +104 -12
  164. package/dist/workflows/dag/contract-validator-registrations.js +1 -2
  165. package/dist/workflows/dag/dag-retry-schema.js +138 -0
  166. package/dist/workflows/dag/frontend-closeout.js +221 -0
  167. package/dist/workflows/dag/frontend-design-policy.js +400 -0
  168. package/dist/workflows/dag/frontend-human-decision.js +182 -0
  169. package/dist/workflows/dag/frontend-implementation-contract.js +1237 -192
  170. package/dist/workflows/dag/frontend-plan-render.js +2 -1
  171. package/dist/workflows/dag/frontend-prewrite-gate.js +256 -350
  172. package/dist/workflows/dag/frontend-provider-capability-matrix.js +159 -0
  173. package/dist/workflows/dag/frontend-recovery-capsule.js +455 -0
  174. package/dist/workflows/dag/frontend-recovery-controller.js +226 -0
  175. package/dist/workflows/dag/frontend-recovery-lineage.js +178 -0
  176. package/dist/workflows/dag/frontend-recovery-plan.js +21 -10
  177. package/dist/workflows/dag/frontend-recovery-run.js +166 -34
  178. package/dist/workflows/dag/frontend-repair.js +1 -432
  179. package/dist/workflows/dag/frontend-review-context.js +261 -15
  180. package/dist/workflows/dag/frontend-review-findings.js +270 -0
  181. package/dist/workflows/dag/frontend-shadow-dual-write.js +941 -0
  182. package/dist/workflows/dag/frontend-shape-capsule-store.js +191 -0
  183. package/dist/workflows/dag/frontend-shape-facts.js +419 -0
  184. package/dist/workflows/dag/frontend-shape.js +435 -0
  185. package/dist/workflows/dag/frontend-source-fidelity-ledger.js +108 -0
  186. package/dist/workflows/dag/frontend-split-application-service.js +203 -0
  187. package/dist/workflows/dag/frontend-split-orchestrator.js +899 -0
  188. package/dist/workflows/dag/frontend-typed-event-store.js +452 -0
  189. package/dist/workflows/dag/frontend-typed-event-transaction.js +180 -0
  190. package/dist/workflows/dag/frontend-verification-trace.js +252 -25
  191. package/dist/workflows/dag/frontend-worktree-diff.js +250 -17
  192. package/dist/workflows/dag/frontend-writer-admission.js +319 -0
  193. package/dist/workflows/dag/frontend-writer-status.js +256 -0
  194. package/dist/workflows/dag/init-hybrid.js +1031 -564
  195. package/dist/workflows/dag/interrupt-request.js +7 -0
  196. package/dist/workflows/dag/node-execution.js +854 -4
  197. package/dist/workflows/dag/prompt.js +70 -3
  198. package/dist/workflows/dag/recovery-lease.js +170 -0
  199. package/dist/workflows/dag/report.js +37 -1
  200. package/dist/workflows/dag/rerun-feedback.js +315 -1
  201. package/dist/workflows/dag/rerun-plan.js +17 -6
  202. package/dist/workflows/dag/rerun-run.js +44 -6
  203. package/dist/workflows/dag/rerun-task.js +251 -13
  204. package/dist/workflows/dag/retry-policy.js +230 -104
  205. package/dist/workflows/dag/runner.js +580 -124
  206. package/dist/workflows/dag/scheduler.js +133 -20
  207. package/dist/workflows/dag/types.js +244 -16
  208. package/dist/workflows/dag/validate.js +28 -12
  209. package/docs/examples/README.md +5 -0
  210. package/docs/init-surface.manifest.json +30 -12
  211. package/docs/skills/vetted-skill-registry.md +4 -2
  212. package/docs/templates/README.md +2 -0
  213. package/docs/templates/agent-dag-report.schema.json +8 -2
  214. package/docs/templates/agent-dag.schema.json +1 -1
  215. package/docs/templates/backend-test-dag.json +25 -20
  216. package/docs/templates/frontend-implementation-contract.schema.json +4 -1
  217. package/docs/templates/frontend-implementation-dag.json +89 -0
  218. package/docs/templates/spec-registry.schema.json +45 -0
  219. package/harness.json +1 -1
  220. package/package.json +4 -3
  221. package/skills/frontend-bounded-implement/SKILL.md +15 -14
  222. package/skills/frontend-bounded-implement/references/code-standards.md +19 -0
  223. package/skills/frontend-contract/SKILL.md +23 -0
  224. package/skills/frontend-contract/references/contract-protocol.md +34 -0
  225. package/skills/frontend-design-review/SKILL.md +22 -41
  226. package/skills/frontend-plan/SKILL.md +26 -0
  227. package/skills/frontend-plan/references/decision-contract.md +37 -0
  228. package/skills/frontend-plan/references/design-decisions.md +17 -0
  229. package/skills/frontend-review/SKILL.md +20 -15
  230. package/skills/frontend-review/references/review-findings.md +6 -7
  231. package/skills/frontend-scout/SKILL.md +25 -0
  232. package/skills/frontend-scout/references/design-evidence.md +16 -0
  233. package/skills/frontend-scout/references/scout-evidence.md +23 -0
  234. package/skills/frontend-verification/SKILL.md +1 -1
  235. package/skills/loop-agent/references/command-reference.md +1 -0
  236. package/skills/loop-agent/references/hybrid-dag.md +2 -2
  237. package/dist/worker/console/static/assets/channel-BU5gOilw.js +0 -1
  238. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DIzKJGHr.js +0 -1
  239. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DIzKJGHr.js +0 -1
  240. package/dist/worker/console/static/assets/index-H9rFJiGL.css +0 -1
  241. package/dist/worker/console/static/assets/index-xwu9GxEc.js +0 -451
  242. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-Cld2qK9v.js +0 -1
  243. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-TIHpiT7w.js +0 -8
  244. package/skills/frontend-implementation/SKILL.md +0 -52
  245. package/skills/frontend-implementation/references/code-standards.md +0 -33
  246. package/skills/frontend-implementation/references/design-spec.md +0 -56
  247. package/skills/frontend-implementation/references/node-contracts.md +0 -31
@@ -307,14 +307,18 @@ export function classifyBackendTestCorrespondenceRepair(facts, layout) {
307
307
  const markdownReasons = [];
308
308
  const pytestReasons = [];
309
309
  const counts = bindingCounts(entry);
310
- if (entry.testPoints.some((point) => (counts.get(point) ?? 0) !== 1) ||
311
- [...counts.keys()].some((point) => !entry.testPoints.includes(point))) {
310
+ if (entry.cardinality !== "0:1" &&
311
+ entry.testPoints.some((point) => (counts.get(point) ?? 0) !== 1)) {
312
+ // Only ### 测试点 inventory must be bound exactly once. Extra
313
+ // automation-mapping TPs (typical: 横切 listed only under 跨切证据)
314
+ // are pytest-owned docstring/repair defects, not Markdown BLOCKED.
312
315
  markdownReasons.push("markdown-test-point-binding-invalid");
313
316
  }
314
317
  if (entry.declaredScript && entry.declaredScript !== entry.expectedScript) {
315
318
  markdownReasons.push("markdown-script-declaration-invalid");
316
319
  }
317
- if (entry.cardinality === "1:0")
320
+ const evidenceOnly = isEvidenceOnlyCorrespondenceEntry(entry);
321
+ if (!evidenceOnly && entry.cardinality === "1:0")
318
322
  pytestReasons.push("missing-pytest-symbol");
319
323
  if (entry.cardinality === "1:N")
320
324
  pytestReasons.push("multiple-pytest-symbols");
@@ -325,12 +329,15 @@ export function classifyBackendTestCorrespondenceRepair(facts, layout) {
325
329
  if (entry.declaredPrimarySymbol && entry.pytestSymbols.some((symbol) => symbol !== entry.declaredPrimarySymbol)) {
326
330
  pytestReasons.push("pytest-primary-symbol-mismatch");
327
331
  }
328
- if (entry.variantTestPoints.some((point) => !entry.parameterIds.includes(point)))
332
+ if (!evidenceOnly && entry.variantTestPoints.some((point) => !entry.parameterIds.includes(point))) {
329
333
  pytestReasons.push("variant-parameter-missing");
330
- if (entry.assertionTestPoints.some((point) => !entry.mappedTestPoints.includes(point)))
334
+ }
335
+ if (!evidenceOnly && entry.assertionTestPoints.some((point) => !entry.mappedTestPoints.includes(point))) {
331
336
  pytestReasons.push("assertion-binding-missing");
332
- if (entry.crossCuttingTestPoints.some((point) => !entry.mappedTestPoints.includes(point)))
337
+ }
338
+ if (!evidenceOnly && entry.crossCuttingTestPoints.some((point) => !entry.mappedTestPoints.includes(point))) {
333
339
  pytestReasons.push("cross-cutting-binding-missing");
340
+ }
334
341
  if (entry.parameterIds.some((point) => !entry.testPoints.includes(point)))
335
342
  pytestReasons.push("pytest-test-point-binding-extra");
336
343
  if (entry.payloadAssessment.status === "UNSAFE")
@@ -339,7 +346,7 @@ export function classifyBackendTestCorrespondenceRepair(facts, layout) {
339
346
  !["TEST_POINT_BINDING_DUPLICATE", "PAYLOAD_CONTRACT_UNAVAILABLE"].includes(entry.status)) {
340
347
  pytestReasons.push(`correspondence-${entry.status.toLowerCase().replaceAll("_", "-")}`);
341
348
  }
342
- if (entry.status === "PAYLOAD_CONTRACT_UNAVAILABLE") {
349
+ if (entry.status === "PAYLOAD_CONTRACT_UNAVAILABLE" && entry.cardinality !== "0:1") {
343
350
  markdownReasons.push("markdown-payload-contract-unavailable");
344
351
  }
345
352
  const paths = orderedUnique([entry.expectedScript, ...entry.actualScripts].filter(safePytestPath));
@@ -668,6 +675,92 @@ function automationLabelBody(body, labels) {
668
675
  const match = new RegExp(`^\\s*[-*+]\\s*(?:${escaped})\\s*[::]\\s*(.*)$`, "mi").exec(automation);
669
676
  return match?.[1]?.replaceAll("`", "").trim() ?? "";
670
677
  }
678
+ const BINDING_NEGATION_OR_AMBIGUITY = /(?:不是|并非|非变体|非断言|非横切|可能|待定|或者|\b(?:TBD|unknown)\b)/i;
679
+ function explicitBindingModeFromLine(line) {
680
+ if (BINDING_NEGATION_OR_AMBIGUITY.test(line))
681
+ return undefined;
682
+ const modes = TEST_POINT_BINDING_MODES.filter((mode) => AUTOMATION_BINDING_LABELS[mode].some((label) => line.includes(label)));
683
+ return modes.length === 1 ? modes[0] : undefined;
684
+ }
685
+ /**
686
+ * Canonicalize only explicit, unambiguous Test Point classification marks.
687
+ * The model occasionally writes `TP-X(变体测试点)`, `[场景断言测试点] TP-X`
688
+ * or a pipe-delimited equivalent. Those forms carry deterministic semantics,
689
+ * but are not the machine contract consumed by automationLabelBody(). Keep the
690
+ * original prose and materialize the same binding under `### 自动化映射` as the
691
+ * canonical single-line labels. Never infer a mode from ordinary prose.
692
+ */
693
+ export function canonicalizeBackendTestMarkdownTestPointBindings(markdown) {
694
+ const headings = [...markdown.matchAll(CASE_HEADING)];
695
+ let output = "";
696
+ let cursor = 0;
697
+ const unsafeTestPoints = [];
698
+ const canonicalizedTestPoints = [];
699
+ for (let index = 0; index < headings.length; index += 1) {
700
+ const heading = headings[index];
701
+ const start = heading.index;
702
+ const end = headings[index + 1]?.index ?? markdown.length;
703
+ const body = markdown.slice(start, end);
704
+ output += markdown.slice(cursor, start);
705
+ cursor = end;
706
+ const declared = listTokens(body, CASE_SECTION_ALIASES.testPoints, TEST_POINT);
707
+ const current = testPointBindingFacts(body, declared);
708
+ const currentByMode = new Map(TEST_POINT_BINDING_MODES.map((mode) => [
709
+ mode,
710
+ current.testPointBindings.filter((item) => item.mode === mode).map((item) => item.testPoint),
711
+ ]));
712
+ const unresolved = new Set(current.unclassifiedTestPoints);
713
+ for (const line of body.split(/\r?\n/)) {
714
+ const mode = explicitBindingModeFromLine(line);
715
+ if (!mode)
716
+ continue;
717
+ const tokens = orderedUnique(line.match(/\bTP-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/g) ?? []);
718
+ for (const token of tokens) {
719
+ if (!unresolved.has(token))
720
+ continue;
721
+ const existingModes = TEST_POINT_BINDING_MODES.filter((candidate) => (currentByMode.get(candidate) ?? []).includes(token));
722
+ if (existingModes.length > 0)
723
+ continue;
724
+ currentByMode.set(mode, orderedUnique([...(currentByMode.get(mode) ?? []), token]));
725
+ unresolved.delete(token);
726
+ canonicalizedTestPoints.push(token);
727
+ }
728
+ }
729
+ unsafeTestPoints.push(...unresolved);
730
+ if (unresolved.size === current.unclassifiedTestPoints.length) {
731
+ output += body;
732
+ continue;
733
+ }
734
+ const automationMarker = /^###\s+(?:自动化映射|Automation Mapping)\s*$/mi.exec(body);
735
+ if (!automationMarker) {
736
+ output += body;
737
+ continue;
738
+ }
739
+ const automationStart = automationMarker.index + automationMarker[0].length;
740
+ const rest = body.slice(automationStart);
741
+ const nextSection = /^###\s+/m.exec(rest);
742
+ const automationEnd = automationStart + (nextSection?.index ?? rest.length);
743
+ let automation = body.slice(automationStart, automationEnd);
744
+ for (const mode of TEST_POINT_BINDING_MODES) {
745
+ const labels = AUTOMATION_BINDING_LABELS[mode];
746
+ const escaped = labels.map((label) => label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
747
+ const canonicalLine = `- ${AUTOMATION_BINDING_LABELS[mode][0]}:${(currentByMode.get(mode) ?? []).join("; ") || "无"}`;
748
+ const linePattern = new RegExp(`^\\s*[-*+]\\s*(?:${escaped})\\s*[::].*$`, "mi");
749
+ if (linePattern.test(automation))
750
+ automation = automation.replace(linePattern, canonicalLine);
751
+ else
752
+ automation = `${automation.replace(/\s*$/, "")}\n${canonicalLine}\n`;
753
+ }
754
+ output += body.slice(0, automationStart) + automation + body.slice(automationEnd);
755
+ }
756
+ output += markdown.slice(cursor);
757
+ return {
758
+ markdown: output,
759
+ changed: output !== markdown,
760
+ unsafeTestPoints: orderedUnique(unsafeTestPoints),
761
+ canonicalizedTestPoints: orderedUnique(canonicalizedTestPoints),
762
+ };
763
+ }
671
764
  export function inspectBackendTestMarkdownTestPointBindings(markdown) {
672
765
  const headings = [...markdown.matchAll(CASE_HEADING)];
673
766
  return headings.map((heading, index) => {
@@ -710,6 +803,16 @@ function isEvidenceOnlyMarkdownCase(testCase) {
710
803
  !hasVariant &&
711
804
  hasNonVariant);
712
805
  }
806
+ function isEvidenceOnlyCorrespondenceEntry(entry) {
807
+ return (entry.status === "EXACT_1_TO_1" &&
808
+ entry.cardinality === "1:0" &&
809
+ !entry.declaredScript &&
810
+ !entry.declaredPrimarySymbol &&
811
+ entry.variantTestPoints.length === 0 &&
812
+ (entry.assertionTestPoints.length > 0 || entry.crossCuttingTestPoints.length > 0) &&
813
+ entry.pytestSymbols.length === 0 &&
814
+ entry.actualScripts.length === 0);
815
+ }
713
816
  function payloadContract(body) {
714
817
  const contractValue = automationLabelBody(body, ["Payload Contract", "载荷合同"]);
715
818
  const requiredValue = automationLabelBody(body, ["Payload Required Paths", "载荷必需路径"]);
@@ -718,7 +821,7 @@ function payloadContract(body) {
718
821
  const tokens = (value) => orderedUnique(value
719
822
  .split(/[,;,;]/)
720
823
  .map((item) => item.trim())
721
- .filter((item) => !/^(?:none|无|not-applicable|n\/a)$/i.test(item))
824
+ .filter((item) => !/^(?:none|无|not-applicable|n\/a|true|false)$/i.test(item))
722
825
  .filter((item) => /^[A-Za-z_][A-Za-z0-9_.]*$/.test(item)));
723
826
  const enumValues = {};
724
827
  for (const assignment of enumValue.split(/[,;,;]/).map((item) => item.trim()).filter(Boolean)) {
@@ -1312,8 +1415,16 @@ export async function analyzeBackendTestCaseCoverage(input) {
1312
1415
  function normalizeModuleStem(markdownPath) {
1313
1416
  return path.posix.basename(markdownPath).replace(/\.md$/i, "").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").replace(/_+/g, "_");
1314
1417
  }
1315
- function expectedScript(markdownPath, layout) {
1418
+ function expectedScript(markdownPath, layout, declaredScripts = []) {
1316
1419
  const resolved = layout ?? resolveBackendTestLayout(undefined);
1420
+ if (declaredScripts.length === 1) {
1421
+ const candidate = declaredScripts[0].replaceAll("\\", "/").replace(/^\.\//, "");
1422
+ const prefix = resolved.scriptDir === "." ? "" : `${resolved.scriptDir}/`;
1423
+ const relative = prefix && candidate.startsWith(prefix) ? candidate.slice(prefix.length) : candidate;
1424
+ if ((!prefix || candidate.startsWith(prefix)) && !relative.includes("..") && /^test_[A-Za-z0-9_.-]+\.py$/.test(path.posix.basename(candidate))) {
1425
+ return candidate;
1426
+ }
1427
+ }
1317
1428
  return `${resolved.scriptDir}/test_${normalizeModuleStem(markdownPath)}.py`;
1318
1429
  }
1319
1430
  function symbolCaseId(symbol) {
@@ -1369,8 +1480,15 @@ for top in ast.walk(tree):
1369
1480
  returned_names.add(node.value.id)
1370
1481
  if isinstance(node,ast.Call):
1371
1482
  for keyword in node.keywords:
1372
- if keyword.arg in {'payload','body','request_body','request_json','dto','json','data','json_body'} and isinstance(keyword.value,ast.Name) and keyword.value.id in positional:
1483
+ if keyword.arg in {'payload','body','request_body','request_json','dto','json','data','json_body','params','query','query_params'} and isinstance(keyword.value,ast.Name) and keyword.value.id in positional:
1373
1484
  transport.append((keyword.value.id,positional.index(keyword.value.id)-call_offset))
1485
+ if keyword.arg is None and isinstance(keyword.value,ast.Name):
1486
+ alias=keyword.value.id
1487
+ assigned_value=assigned.get(alias)
1488
+ if isinstance(assigned_value,ast.Name) and assigned_value.id in positional:
1489
+ transport.append((assigned_value.id,positional.index(assigned_value.id)-call_offset))
1490
+ elif isinstance(assigned_value,ast.Call) and isinstance(assigned_value.func,ast.Name) and assigned_value.func.id == 'dict' and assigned_value.args and isinstance(assigned_value.args[0],ast.Name) and assigned_value.args[0].id in positional:
1491
+ transport.append((assigned_value.args[0].id,positional.index(assigned_value.args[0].id)-call_offset))
1374
1492
  if isinstance(node.func,ast.Attribute) and node.func.attr.lower() in {'post','put','patch'} and len(node.args) >= 2:
1375
1493
  candidate=node.args[1]
1376
1494
  if isinstance(candidate,ast.Name) and candidate.id in positional:
@@ -1382,6 +1500,17 @@ for top in ast.walk(tree):
1382
1500
  matched=node.args[nested_position] if nested_position >= 0 and nested_position < len(node.args) else None
1383
1501
  if isinstance(matched,ast.Name) and matched.id in positional:
1384
1502
  transport.append((matched.id,positional.index(matched.id)-call_offset))
1503
+ # Assignment order in ast.walk is not source order. Re-scan **kwargs
1504
+ # aliases once the complete local assignment table is available.
1505
+ for node in ast.walk(top):
1506
+ if not isinstance(node,ast.Call): continue
1507
+ for keyword in node.keywords:
1508
+ if keyword.arg is not None or not isinstance(keyword.value,ast.Name): continue
1509
+ assigned_value=assigned.get(keyword.value.id)
1510
+ if isinstance(assigned_value,ast.Name) and assigned_value.id in positional:
1511
+ transport.append((assigned_value.id,positional.index(assigned_value.id)-call_offset))
1512
+ elif isinstance(assigned_value,ast.Call) and isinstance(assigned_value.func,ast.Name) and assigned_value.func.id == 'dict' and assigned_value.args and isinstance(assigned_value.args[0],ast.Name) and assigned_value.args[0].id in positional:
1513
+ transport.append((assigned_value.args[0].id,positional.index(assigned_value.args[0].id)-call_offset))
1385
1514
  for returned_name in returned_names:
1386
1515
  entries=mutated.get(returned_name,[])
1387
1516
  if entries:
@@ -1390,7 +1519,7 @@ for top in ast.walk(tree):
1390
1519
  for node in ast.walk(top):
1391
1520
  if not isinstance(node,ast.Call): continue
1392
1521
  for keyword in node.keywords:
1393
- if keyword.arg in {'payload','body','request_body','request_json','dto','json','data','json_body'} and isinstance(keyword.value,ast.Name) and keyword.value.id in assigned:
1522
+ if keyword.arg in {'payload','body','request_body','request_json','dto','json','data','json_body','params','query','query_params'} and isinstance(keyword.value,ast.Name) and keyword.value.id in assigned:
1394
1523
  transport_shapes.append(assigned[keyword.value.id])
1395
1524
  if candidates: FUNCTION_PAYLOADS[top.name]=candidates
1396
1525
  if transport_shapes: FUNCTION_TRANSPORT_SHAPES[top.name]=transport_shapes
@@ -1446,8 +1575,9 @@ def literal(node):
1446
1575
  return repr(node.value)
1447
1576
  return None
1448
1577
 
1449
- def collect_dict(node, prefix, paths, values):
1578
+ def collect_dict(node, prefix, paths, values, parameters=None):
1450
1579
  if not isinstance(node, ast.Dict): return
1580
+ parameters = parameters or {}
1451
1581
  for key,value in zip(node.keys,node.values):
1452
1582
  if not isinstance(key, ast.Constant) or not isinstance(key.value,str): continue
1453
1583
  # Ignore writer helper/meta keys that are not DTO request fields.
@@ -1457,12 +1587,16 @@ def collect_dict(node, prefix, paths, values):
1457
1587
  paths.add(current)
1458
1588
  lv=literal(value)
1459
1589
  if lv is not None: values.setdefault(current,set()).add(lv)
1460
- if isinstance(value, ast.Dict): collect_dict(value,current,paths,values)
1590
+ elif isinstance(value, ast.Name) and value.id in parameters:
1591
+ for bound in parameters[value.id]:
1592
+ bound_lv=literal(bound)
1593
+ if bound_lv is not None: values.setdefault(current,set()).add(bound_lv)
1594
+ if isinstance(value, ast.Dict): collect_dict(value,current,paths,values,parameters)
1461
1595
  elif isinstance(value,(ast.List,ast.Tuple)):
1462
1596
  for item in value.elts:
1463
- if isinstance(item,ast.Dict): collect_dict(item,current,paths,values)
1597
+ if isinstance(item,ast.Dict): collect_dict(item,current,paths,values,parameters)
1464
1598
 
1465
- PAYLOAD_NAMES={'payload','body','request_body','request_json','dto','json','data','json_body'}
1599
+ PAYLOAD_NAMES={'payload','body','request_body','request_json','dto','json','data','json_body','params','query','query_params'}
1466
1600
 
1467
1601
  def shape_only(node):
1468
1602
  if isinstance(node,ast.Dict):
@@ -1543,6 +1677,7 @@ def decorator_payload_nodes(parameters,assigned):
1543
1677
 
1544
1678
  def decorator_payload_nodes_by_tp(fn,assigned):
1545
1679
  result={}
1680
+ function_payload_nodes=payload_nodes(fn)
1546
1681
  for dec in fn.decorator_list:
1547
1682
  if not isinstance(dec,ast.Call) or not isinstance(dec.func,ast.Attribute) or dec.func.attr != 'parametrize' or len(dec.args) < 2: continue
1548
1683
  if not isinstance(dec.args[0],ast.Constant) or not isinstance(dec.args[0].value,str): continue
@@ -1559,9 +1694,11 @@ def decorator_payload_nodes_by_tp(fn,assigned):
1559
1694
  parameters={}
1560
1695
  for index,name in enumerate(names):
1561
1696
  if index < len(node.args): parameters.setdefault(name,[]).append(node.args[index])
1562
- payload_nodes=decorator_payload_nodes(parameters,assigned)
1697
+ payload_nodes_for_tp=decorator_payload_nodes(parameters,assigned)
1698
+ if not payload_nodes_for_tp:
1699
+ payload_nodes_for_tp=function_payload_nodes
1563
1700
  paths=set(); values={}
1564
- for payload_node in payload_nodes: collect_dict(payload_node,'',paths,values)
1701
+ for payload_node in payload_nodes_for_tp: collect_dict(payload_node,'',paths,values,parameters)
1565
1702
  result[tp]={key:sorted(items) for key,items in sorted(values.items())}
1566
1703
  return result
1567
1704
 
@@ -1603,6 +1740,20 @@ def is_direct_transport_call(node):
1603
1740
  if isinstance(current,ast.Name): receiver_tokens.append(current.id.lower())
1604
1741
  return any(token in {'requests','httpx','client','session','api_client','http_client'} or token.endswith('_client') for token in receiver_tokens)
1605
1742
 
1743
+ HTTP_METHODS={'GET','POST','PUT','PATCH','DELETE','HEAD','OPTIONS'}
1744
+ HTTP_FIXTURE_NAMES={'http','http_client','client','api_client','session'}
1745
+
1746
+ def is_fixture_http_call(node):
1747
+ # R12: http("POST", path, json_body={"title": title}) is a Name-call fixture, not client.post.
1748
+ # R15: http_client(operation, path, json_body={...}) still transports when operation is a parametrized method.
1749
+ if not isinstance(node,ast.Call) or not isinstance(node.func,ast.Name) or not node.args: return False
1750
+ first=node.args[0]
1751
+ if isinstance(first,ast.Constant) and isinstance(first.value,str) and first.value.upper() in HTTP_METHODS:
1752
+ return True
1753
+ func=node.func.id.lower()
1754
+ httpish=func in HTTP_FIXTURE_NAMES or func.endswith('_client')
1755
+ return httpish and isinstance(first,ast.Name)
1756
+
1606
1757
  def payload_nodes(fn):
1607
1758
  result=[]
1608
1759
  assigned={}
@@ -1625,7 +1776,7 @@ def payload_nodes(fn):
1625
1776
  # Observe local payload/body variables only when they flow into a direct
1626
1777
  # HTTP transport call. Arbitrary setup variables are not target requests.
1627
1778
  for node in ast.walk(fn):
1628
- if not is_direct_transport_call(node): continue
1779
+ if not (is_direct_transport_call(node) or is_fixture_http_call(node)): continue
1629
1780
  for keyword in node.keywords:
1630
1781
  if keyword.arg in PAYLOAD_NAMES: result.extend(payload_expr_nodes(keyword.value,assigned,parameters))
1631
1782
  return result
@@ -1635,8 +1786,8 @@ for fn in ast.walk(tree):
1635
1786
  if isinstance(fn,(ast.FunctionDef,ast.AsyncFunctionDef)) and fn.name.startswith('test_'):
1636
1787
  paths=set(); values={}
1637
1788
  fallback_paths=set(); fallback_values={}
1638
- for node in payload_nodes(fn): collect_dict(node,'',paths,values)
1639
- for node in fallback_payload_nodes(fn): collect_dict(node,'',fallback_paths,fallback_values)
1789
+ for node in payload_nodes(fn): collect_dict(node,'',paths,values,{})
1790
+ for node in fallback_payload_nodes(fn): collect_dict(node,'',fallback_paths,fallback_values,{})
1640
1791
  out[fn.name]={
1641
1792
  'paths':sorted(paths),
1642
1793
  'values':{key:sorted(items) for key,items in sorted(values.items())},
@@ -1688,14 +1839,39 @@ function metadataTestPoints(region, label) {
1688
1839
  const match = new RegExp(`^\\s*${escaped}\\s*:\\s*(.*)$`, "mi").exec(region);
1689
1840
  return orderedUnique(match?.[1]?.match(/\bTP-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/g) ?? []);
1690
1841
  }
1842
+ function isPytestDecoratorOrContinuationLine(line) {
1843
+ const trimmed = line.trim();
1844
+ if (!trimmed)
1845
+ return false;
1846
+ if (trimmed.startsWith("#"))
1847
+ return true;
1848
+ if (trimmed.startsWith("@"))
1849
+ return true;
1850
+ // Multi-line decorator argument lists: @pytest.mark.parametrize(\n "x", [\n pytest.param(..., id="TP-...")\n ]\n)
1851
+ return /^[\]\),]/.test(trimmed) || /[\(\[{,]$/.test(trimmed) || /\bpytest\.param\s*\(/.test(trimmed);
1852
+ }
1691
1853
  function pytestFunctionRegionStart(source, functionIndex) {
1692
1854
  let regionStart = source.lastIndexOf("\n", functionIndex - 1) + 1;
1693
1855
  let cursor = regionStart;
1856
+ let blankRun = 0;
1694
1857
  while (cursor > 0) {
1695
1858
  const previousEnd = cursor - 1;
1696
1859
  const previousStart = source.lastIndexOf("\n", previousEnd - 1) + 1;
1697
1860
  const previous = source.slice(previousStart, previousEnd).replace(/\r$/, "");
1698
- if (!previous.trim())
1861
+ const trimmed = previous.trim();
1862
+ if (!trimmed) {
1863
+ blankRun += 1;
1864
+ // Keep walking across a small blank gap so a decorator is still bound to
1865
+ // the following test when writers insert one empty line before `def`.
1866
+ if (blankRun > 2)
1867
+ break;
1868
+ cursor = previousStart;
1869
+ continue;
1870
+ }
1871
+ blankRun = 0;
1872
+ if (/^(?:async\s+)?def\s+/.test(trimmed) || /^(?:async\s+)?class\s+/.test(trimmed))
1873
+ break;
1874
+ if (!isPytestDecoratorOrContinuationLine(previous))
1699
1875
  break;
1700
1876
  regionStart = previousStart;
1701
1877
  cursor = previousStart;
@@ -1725,12 +1901,14 @@ function pytestFunctionBodyEnd(source, signatureEnd, functionIndent) {
1725
1901
  }
1726
1902
  function pytestParameterCollections(source) {
1727
1903
  const collections = new Map();
1728
- for (const match of source.matchAll(/^(_?[A-Z][A-Z0-9_]*)\s*=\s*\[(?![^\r\n]*\])([\s\S]*?)^\]/gm)) {
1904
+ for (const match of source.matchAll(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*\[(?![^\r\n]*\])([\s\S]*?)^\]/gm)) {
1729
1905
  const explicitIds = [...match[2].matchAll(/\bid\s*=\s*["'](TP-[A-Z0-9-]+)["']/g)].map((item) => item[1]);
1730
1906
  const factoryIds = explicitIds.length === 0
1731
1907
  ? [...match[2].matchAll(/["'](TP-[A-Z0-9-]+)["']/g)].map((item) => item[1])
1732
1908
  : [];
1733
- collections.set(match[1], orderedUnique([...explicitIds, ...factoryIds]));
1909
+ const ids = orderedUnique([...explicitIds, ...factoryIds]);
1910
+ if (ids.length > 0)
1911
+ collections.set(match[1], ids);
1734
1912
  }
1735
1913
  return collections;
1736
1914
  }
@@ -1792,6 +1970,12 @@ function normalizePythonLiteralValue(value) {
1792
1970
  if ((trimmed.startsWith("'") && trimmed.endsWith("'")) || (trimmed.startsWith('"') && trimmed.endsWith('"'))) {
1793
1971
  return trimmed.slice(1, -1);
1794
1972
  }
1973
+ if (trimmed === "True")
1974
+ return "true";
1975
+ if (trimmed === "False")
1976
+ return "false";
1977
+ if (trimmed === "None")
1978
+ return "null";
1795
1979
  return trimmed;
1796
1980
  }
1797
1981
  function intentionalInvalidEnumVariant(input) {
@@ -1836,13 +2020,15 @@ function assessPayloadContract(testCase, refs) {
1836
2020
  : [];
1837
2021
  const enumMismatches = [];
1838
2022
  for (const [field, allowed] of Object.entries(testCase.payloadContract.enumValues)) {
1839
- const observed = orderedUnique(refs.flatMap((item) => (useFallback ? item.fallbackPayloadValues[field] : item.payloadValues[field]) ?? []));
2023
+ const aggregated = orderedUnique(refs.flatMap((item) => (useFallback ? item.fallbackPayloadValues[field] : item.payloadValues[field]) ?? []));
2024
+ const byTestPoint = orderedUnique(refs.flatMap((item) => Object.values(item.payloadValuesByTestPoint).flatMap((values) => values[field] ?? [])));
2025
+ const observed = orderedUnique([...aggregated, ...byTestPoint]);
1840
2026
  for (const rawValue of observed) {
1841
2027
  const value = normalizePythonLiteralValue(rawValue);
1842
2028
  if (allowed.includes(value))
1843
2029
  continue;
1844
2030
  const associatedTestPoints = orderedUnique(refs.flatMap((item) => Object.entries(item.payloadValuesByTestPoint)
1845
- .filter(([, values]) => (values[field] ?? []).includes(rawValue))
2031
+ .filter(([, values]) => (values[field] ?? []).some((itemValue) => itemValue === rawValue || normalizePythonLiteralValue(itemValue) === value))
1846
2032
  .map(([testPoint]) => testPoint)));
1847
2033
  const intentionallyInvalid = associatedTestPoints.length > 0 && associatedTestPoints.every((testPoint) => intentionalInvalidEnumVariant({ testCase, testPoint, field, observedValue: rawValue, allowed }));
1848
2034
  if (!intentionallyInvalid)
@@ -1863,7 +2049,7 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
1863
2049
  const layout = input.layout ?? resolveBackendTestLayout(undefined);
1864
2050
  const cases = await parseMarkdownCases(input.workspaceRoot, layout);
1865
2051
  const modulePaths = orderedUnique(cases.map((item) => item.markdownPath));
1866
- const scripts = orderedUnique(cases.flatMap((item) => [...item.declaredScripts, expectedScript(item.markdownPath, layout)]));
2052
+ const scripts = orderedUnique(cases.flatMap((item) => [expectedScript(item.markdownPath, layout, item.declaredScripts)]));
1867
2053
  const allSymbols = [];
1868
2054
  const scriptFiles = [];
1869
2055
  for (const script of scripts) {
@@ -1876,7 +2062,7 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
1876
2062
  const findings = [];
1877
2063
  const entries = [];
1878
2064
  for (const testCase of cases) {
1879
- const expected = expectedScript(testCase.markdownPath, layout);
2065
+ const expected = expectedScript(testCase.markdownPath, layout, testCase.declaredScripts);
1880
2066
  const declared = testCase.declaredScripts[0];
1881
2067
  const refs = allSymbols.filter((item) => item.caseIds.includes(testCase.caseId));
1882
2068
  const actualScripts = orderedUnique(refs.map((item) => item.script));
@@ -2034,8 +2220,9 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
2034
2220
  for (const [signature, owners] of duplicateSignatureGroups)
2035
2221
  findings.push(`duplicate execution signature ${signature}: ${orderedUnique(owners).join(", ")}`);
2036
2222
  const exactModuleCount = modulePaths.filter((modulePath) => {
2037
- const expected = expectedScript(modulePath, layout);
2038
2223
  const moduleCases = cases.filter((item) => item.markdownPath === modulePath);
2224
+ const moduleDeclaredScripts = orderedUnique(moduleCases.flatMap((item) => item.declaredScripts));
2225
+ const expected = expectedScript(modulePath, layout, moduleDeclaredScripts);
2039
2226
  return moduleCases.every((item) => item.declaredScripts.length === 1 && item.declaredScripts[0] === expected) && scriptFiles.some((file) => path.relative(input.workspaceRoot, file).replaceAll(path.sep, "/") === expected);
2040
2227
  }).length;
2041
2228
  const caseEntries = entries.filter((entry) => entry.cardinality !== "0:1");
@@ -285,6 +285,7 @@ export async function resolveBackendTestManifestModules(workspaceRoot, layout) {
285
285
  moduleIndexHeadingCount,
286
286
  declaredStems: [],
287
287
  declaredMdFiles: [],
288
+ declaredScriptByMarkdown: {},
288
289
  declaredMissing: [],
289
290
  unmanifested: [],
290
291
  });
@@ -306,6 +307,15 @@ export async function resolveBackendTestManifestModules(workspaceRoot, layout) {
306
307
  return invalid("duplicate", moduleIndexHeadingCount);
307
308
  }
308
309
  const declaredStems = extractDeclaredModuleIndexFilenames(readme);
310
+ const declaredScriptByMarkdown = {};
311
+ const mdPrefix = escapeRegExp(`${resolved.markdownDir}/`);
312
+ const scriptPrefix = escapeRegExp(`${resolved.scriptDir}/`);
313
+ for (const line of readme.split(/\r?\n/)) {
314
+ const markdown = new RegExp(`(${mdPrefix}[A-Za-z0-9_.-]+\\.md)`).exec(line)?.[1];
315
+ const pytest = new RegExp(`(${scriptPrefix}[A-Za-z0-9_./-]+\\.py)`).exec(line)?.[1];
316
+ if (markdown && pytest)
317
+ declaredScriptByMarkdown[markdown] = pytest;
318
+ }
309
319
  if (declaredStems.length === 0) {
310
320
  return invalid("empty-unparsable", moduleIndexHeadingCount);
311
321
  }
@@ -345,6 +355,7 @@ export async function resolveBackendTestManifestModules(workspaceRoot, layout) {
345
355
  moduleIndexHeadingCount,
346
356
  declaredStems,
347
357
  declaredMdFiles: declaredMdFiles.sort(),
358
+ declaredScriptByMarkdown,
348
359
  declaredMissing: declaredMissing.sort(),
349
360
  unmanifested: unmanifested.sort(),
350
361
  };
@@ -694,7 +705,8 @@ export async function validateBackendMarkdownCases(input) {
694
705
  if (!hasAssertableExpectedResult(expected)) {
695
706
  findings.push(`${testCase.id} has no structured expected result`);
696
707
  }
697
- const expectedScript = expectedBackendTestPytestScriptForMarkdownModule(relativeFile, layout);
708
+ const expectedScript = manifest.declaredScriptByMarkdown[relativeFile] ??
709
+ expectedBackendTestPytestScriptForMarkdownModule(relativeFile, layout);
698
710
  const mappedScripts = extractMappedPytestScripts(testCase.body);
699
711
  if (isEvidenceOnlyMarkdownCaseBody(testCase.body)) {
700
712
  // Evidence-only meta cases intentionally declare 脚本/primary symbol as 无 and keep only assertion/cross-cutting TPs.