@tea-agent/loop-agent 0.39.0-next.9 → 0.39.1

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 (288) hide show
  1. package/AGENTS.md +4 -1
  2. package/CHANGELOG.md +269 -69
  3. package/README.md +11 -5
  4. package/bin/loop-agent.js +7 -3
  5. package/dist/application/dag/generate-task-dag.js +9 -0
  6. package/dist/application/task-lifecycle/advance.js +16 -0
  7. package/dist/build-stamp.json +3 -3
  8. package/dist/cli/command-definitions.js +16 -5
  9. package/dist/cli/program.js +16 -2
  10. package/dist/cli/update/notifier.js +2 -2
  11. package/dist/cli/update/runtime-activity.js +1 -29
  12. package/dist/cli.js +2 -1
  13. package/dist/commands/dag-rerun.js +55 -1
  14. package/dist/commands/init-model-catalog.js +464 -0
  15. package/dist/commands/init-upgrade.js +265 -97
  16. package/dist/commands/init.js +428 -27
  17. package/dist/commands/inspect-next.js +8 -0
  18. package/dist/commands/task-advance.js +31 -0
  19. package/dist/executors/dag-pi-executor.js +124 -18
  20. package/dist/executors/pi-event-serializer.js +42 -1
  21. package/dist/executors/pi-executor.js +51 -0
  22. package/dist/executors/pi-extension-resolver.js +233 -0
  23. package/dist/executors/pi-playwright-cli-tool.js +74 -28
  24. package/dist/executors/pi-sdk-executor.js +187 -59
  25. package/dist/executors/shell-executor.js +555 -34
  26. package/dist/executors/shell-write-guard.js +7 -0
  27. package/dist/shared/dag-failure-category.js +138 -0
  28. package/dist/shared/dag-prompt-override.js +27 -0
  29. package/dist/shared/operator/capabilities.js +181 -27
  30. package/dist/shared/playwright-cli-command-policy.js +15 -0
  31. package/dist/shared/update/console-notifier.js +100 -0
  32. package/dist/{cli → shared}/update/npm-client.js +70 -15
  33. package/dist/{cli → shared}/update/state.js +41 -13
  34. package/dist/task/config-types.js +28 -0
  35. package/dist/task/contract/apply.js +11 -1
  36. package/dist/task/contract/constants.js +3 -0
  37. package/dist/task/contract/diff.js +26 -0
  38. package/dist/task/contract/hash.js +3 -0
  39. package/dist/task/contract/observe.js +25 -4
  40. package/dist/task/contract/paths.js +2 -1
  41. package/dist/task/contract/project.js +20 -1
  42. package/dist/task/contract/schema.js +8 -1
  43. package/dist/task/contract/transaction.js +12 -0
  44. package/dist/task/source-prepare/build-draft.js +15 -0
  45. package/dist/task/source-prepare/completeness.js +188 -0
  46. package/dist/task/source-prepare/fragment-inventory.js +474 -0
  47. package/dist/task/source-prepare/index.js +3 -0
  48. package/dist/task/source-prepare/ledger-reconciliation.js +127 -0
  49. package/dist/task/source-prepare/ledger-review.js +214 -0
  50. package/dist/task/source-prepare/ledger.js +545 -0
  51. package/dist/task/source-prepare/parse-intent.js +24 -4
  52. package/dist/task/source-prepare/prepare.js +262 -1
  53. package/dist/task/source-prepare/semantic-intake.js +19 -3
  54. package/dist/task/source-prepare/source-fidelity-pi.js +384 -0
  55. package/dist/task/source-prepare/types.js +22 -0
  56. package/dist/worker/cli.js +18 -2
  57. package/dist/worker/console/chat/deferred-turn.js +91 -0
  58. package/dist/worker/console/chat/mcp-inventory.js +221 -0
  59. package/dist/worker/console/chat/operation-card.js +8 -1
  60. package/dist/worker/console/chat/pi-console-config.js +81 -10
  61. package/dist/worker/console/chat/pi-runtime.js +165 -83
  62. package/dist/worker/console/chat/repo-walk.js +10 -1
  63. package/dist/worker/console/chat/routes.js +480 -55
  64. package/dist/worker/console/chat/semantic-activity.js +20 -8
  65. package/dist/worker/console/chat/session-store.js +146 -2
  66. package/dist/worker/console/chat/turn-process.js +10 -10
  67. package/dist/worker/console/console-handoff.js +82 -0
  68. package/dist/worker/console/console-update-and-init.js +115 -0
  69. package/dist/worker/console/console-update-runtime.js +84 -0
  70. package/dist/worker/console/dag-execution-receipt.js +33 -0
  71. package/dist/worker/console/draft-store.js +49 -0
  72. package/dist/worker/console/inspect-split.js +18 -0
  73. package/dist/worker/console/operation-run-facts.js +19 -3
  74. package/dist/worker/console/operation-store.js +1 -1
  75. package/dist/worker/console/operator-actions.js +320 -4
  76. package/dist/worker/console/operator-surface-health.js +1 -0
  77. package/dist/worker/console/operator-user-error.js +2 -2
  78. package/dist/worker/console/pi-readiness.js +5 -4
  79. package/dist/worker/console/prd-intake-bridge.js +13 -0
  80. package/dist/worker/console/recovery-cta.js +4 -4
  81. package/dist/worker/console/recovery-error-copy.js +4 -4
  82. package/dist/worker/console/routes.js +46 -2
  83. package/dist/worker/console/security.js +63 -9
  84. package/dist/worker/console/server.js +111 -7
  85. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-BLXFe4qI.js → abnfDiagram-N423BO3Z-CTx1TrNa.js} +1 -1
  86. package/dist/worker/console/static/assets/{arc-7iOJeqho.js → arc-B4i9cVxX.js} +1 -1
  87. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-CYoyj5K7.js → architectureDiagram-T3A2C74G-BAXE8_Rl.js} +1 -1
  88. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-fK01JNlR.js → blockDiagram-VBNYF7ZC-C5Xh59at.js} +1 -1
  89. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-bzbw5D_Z.js → c4Diagram-5PPSVZJV-B_asOzAu.js} +1 -1
  90. package/dist/worker/console/static/assets/channel-Dj_4Moei.js +1 -0
  91. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-Cf6hJFFG.js → chunk-2GRJ4B5K-wRO_iRNc.js} +1 -1
  92. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-DSFXR-et.js → chunk-2Q5K7J3B-ByOaSh_G.js} +1 -1
  93. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-BagJechl.js → chunk-5RXB4S5H-DjLzudan.js} +1 -1
  94. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-E7QNMFaW.js → chunk-5VM5RSS4-JCNGkXYT.js} +1 -1
  95. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-BG_eOBrC.js → chunk-6Q2QTUOP-BhrPXXMZ.js} +1 -1
  96. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-Bo42dqol.js → chunk-GF5L2VYU-TJ5JuEBT.js} +1 -1
  97. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-dibM-87v.js → chunk-JWPE2WC7-VVthn_mD.js} +1 -1
  98. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-Bytrp3Oe.js → chunk-KBJHAD2P-D60-Mny4.js} +1 -1
  99. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-DBABILrr.js → chunk-RYQCIY6F-zXv7aVye.js} +1 -1
  100. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-DCUk5Pf9.js → chunk-XXDRQBXY-By9VQoi1.js} +1 -1
  101. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-C0YYAyCa.js +1 -0
  102. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-C0YYAyCa.js +1 -0
  103. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-BlGE9v6E.js → cose-bilkent-JH36ORCC-C4zQLe-M.js} +1 -1
  104. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-DGl5ewph.js → cynefin-VYW2F7L2-wh5N2ReN.js} +1 -1
  105. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-DsJ_SVH_.js → cynefinDiagram-MW4NZA55-Dlu5wIqs.js} +1 -1
  106. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BIhjFdzv.js → dagre-VZM6K2ZE-CrDgthc6.js} +1 -1
  107. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-Dmy1ctTb.js → diagram-7IWD3JNH-zN1clg27.js} +1 -1
  108. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-DYCtWUF3.js → diagram-B4RE2ZJO-BN-o7J7-.js} +1 -1
  109. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-BMZFHVd2.js → diagram-LBJQPF4R-C8SuPt0d.js} +1 -1
  110. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-b_U8gfaO.js → diagram-Q27KOJAE-BoizuHbZ.js} +1 -1
  111. package/dist/worker/console/static/assets/{diagram-UB23O5K3-Dzy_bn-I.js → diagram-UB23O5K3-BzUzXixG.js} +1 -1
  112. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-DJCiMNEI.js → ebnfDiagram-BXEA7PRR-Ev3lA4Cf.js} +1 -1
  113. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-28lD016B.js → erDiagram-JOGREHBK-GALUzRbs.js} +1 -1
  114. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-DGyHJmIw.js → flowDiagram-UKHOOZJN-CZJN65Sb.js} +1 -1
  115. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-CzdwUjrO.js → ganttDiagram-PKOTCBZU-JLB9OESl.js} +1 -1
  116. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-D9bGwuo8.js → gitGraphDiagram-DS77QQ5N-CcvCr4GD.js} +1 -1
  117. package/dist/worker/console/static/assets/index-CW88Fjbw.js +406 -0
  118. package/dist/worker/console/static/assets/index-DP0ZJl39.css +1 -0
  119. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-LJgJUQFC.js → infoDiagram-6WML65LV-HG5z7aVg.js} +1 -1
  120. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-98LaB8zb.js → ishikawaDiagram-WSZJBQD7-BHEu9chR.js} +1 -1
  121. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-D-ISzwlQ.js → journeyDiagram-NVQOT4AX-Cp_Up8-a.js} +1 -1
  122. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-gfYOI-EL.js → kanban-definition-27J2QSJJ-DvVeY-em.js} +1 -1
  123. package/dist/worker/console/static/assets/{linear-CyntZgKV.js → linear-aGmcjgTn.js} +1 -1
  124. package/dist/worker/console/static/assets/{mermaid.core-BBOR7dg0.js → mermaid.core-Biyo-tqA.js} +5 -5
  125. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-CQAgwPMk.js → mindmap-definition-FAOFIHXS-DyxY2pIb.js} +1 -1
  126. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-DkMiZFuV.js → pegDiagram-VL7TDLO6-CkdGuc50.js} +1 -1
  127. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-DLNXa_0J.js → pieDiagram-7S7Q4E2Y-NNQI9xVR.js} +1 -1
  128. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-Dc7bdzjm.js → quadrantDiagram-CIZ2JOQS-PAJkuz_G.js} +1 -1
  129. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-BxMB0QqW.js → railroadDiagram-AXF67PYL-ZwrQod0O.js} +1 -1
  130. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-FjumoKn4.js → requirementDiagram-LRYGKXZP-D3ZfrzM2.js} +1 -1
  131. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-CkhKc_E3.js → sankeyDiagram-W5VNT64P-DDGFmWk5.js} +1 -1
  132. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-DoBWqTTH.js → sequenceDiagram-SI44F4Z6-DO6mmXTA.js} +1 -1
  133. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-CQeh_YeE.js → sizeCapture-X5ZJPWSS-DMbCQiJt.js} +1 -1
  134. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-LuHmFW-I.js → stateDiagram-OKZ733FA-Di_YUEfC.js} +1 -1
  135. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DrdDvela.js +1 -0
  136. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-CHwbDctY.js → swimlanes-SLNWSIFB-Bn2PphNJ.js} +2 -2
  137. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-DPkEAULl.js +8 -0
  138. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-CoMngk0X.js → timeline-definition-Z64GVDOM-DPh8pRsp.js} +1 -1
  139. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-CURYbtbO.js → vennDiagram-T6HMQDX7-GMfl26pW.js} +1 -1
  140. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-BdqSECjU.js → wardleyDiagram-T6FBY63Y-DTlmVrmF.js} +1 -1
  141. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-DbMUYuAg.js → xychartDiagram-ELKLHX3M-YGBjKAS5.js} +1 -1
  142. package/dist/worker/console/static/index.html +2 -2
  143. package/dist/worker/console/static-src/app/console-types.js +40 -1
  144. package/dist/worker/console/static-src/app/useOperatorActions.js +6 -1
  145. package/dist/worker/console/static-src/app/useRecoveryActions.js +2 -2
  146. package/dist/worker/console/static-src/app/useRecoveryConsole.js +38 -2
  147. package/dist/worker/console/static-src/app/useRunProgress.js +46 -0
  148. package/dist/worker/console/static-src/app/useTaskWizard.js +73 -1
  149. package/dist/worker/console/static-src/operator-chat/cards/failure-category-advice.js +25 -0
  150. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +74 -26
  151. package/dist/worker/console/static-src/operator-chat/input-history.js +76 -0
  152. package/dist/worker/console/static-src/operator-chat/mutation-gate.js +1 -1
  153. package/dist/worker/console/static-src/operator-chat/open-preview-in-browser.js +328 -0
  154. package/dist/worker/console/static-src/operator-chat/resource-diagnostics.js +204 -0
  155. package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +8 -1
  156. package/dist/worker/console/static-src/operator-chat/turn-submission.js +54 -0
  157. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +3 -0
  158. package/dist/worker/console/static-src/operator-chat/useChatStream.js +237 -11
  159. package/dist/worker/console/static-src/operator-chat/useChatThread.js +50 -22
  160. package/dist/worker/console/static-src/operator-chat/useComposer.js +32 -0
  161. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +26 -1
  162. package/dist/worker/console/static-src/pages/tasks/run-id-resolution.js +79 -0
  163. package/dist/worker/console/static-src/pages/tasks/run-ownership-verify.js +23 -0
  164. package/dist/worker/console/static-src/pages/tasks/run-panel-progress.js +110 -0
  165. package/dist/worker/loop-agent/loop-agent-client.js +7 -3
  166. package/dist/worker/materialize/harness-task-lineage.js +5 -2
  167. package/dist/worker/observability/init-runtime-activity.js +26 -0
  168. package/dist/worker/observability/read-model.js +22 -0
  169. package/dist/worker/observe/node-input.js +160 -3
  170. package/dist/worker/observe/node-process.js +377 -0
  171. package/dist/worker/observe/routes.js +224 -3
  172. package/dist/worker/observe/shell-handler-keys.js +34 -0
  173. package/dist/worker/observe/static/api.js +67 -1
  174. package/dist/worker/observe/static/constants.js +8 -1
  175. package/dist/worker/observe/static/dag-history-labels.js +4 -0
  176. package/dist/worker/observe/static/format.js +60 -0
  177. package/dist/worker/observe/static/index.html +5 -0
  178. package/dist/worker/observe/static/markdown-render.js +20 -1
  179. package/dist/worker/observe/static/operator-chrome.js +4 -0
  180. package/dist/worker/observe/static/prompt-restart-candidates.d.ts +18 -0
  181. package/dist/worker/observe/static/prompt-restart-candidates.js +36 -0
  182. package/dist/worker/observe/static/state.js +13 -2
  183. package/dist/worker/observe/static/styles.css +85 -0
  184. package/dist/worker/observe/static/views/dag-inspector.js +912 -8
  185. package/dist/worker/observe/static/views/dag.js +8 -1
  186. package/dist/worker/observe/static/views/failures.js +5 -2
  187. package/dist/worker/observe/static/views/session-timeline.js +78 -9
  188. package/dist/worker/observe/static/views/task.js +55 -0
  189. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +856 -54
  190. package/dist/workflows/dag/backend-test-case-manifest.js +4 -0
  191. package/dist/workflows/dag/backend-test-execution-contract.js +18 -9
  192. package/dist/workflows/dag/backend-test-gap-fill.js +205 -0
  193. package/dist/workflows/dag/backend-test-intake-context.js +32 -0
  194. package/dist/workflows/dag/backend-test-layout.js +133 -0
  195. package/dist/workflows/dag/backend-test-markdown-workflow.js +343 -33
  196. package/dist/workflows/dag/backend-test-module-stem.js +5 -0
  197. package/dist/workflows/dag/backend-test-pytest-collection.js +454 -36
  198. package/dist/workflows/dag/backend-test-result-contract.js +1 -1
  199. package/dist/workflows/dag/backend-test-scenario-param.js +884 -140
  200. package/dist/workflows/dag/backend-test-scenario-partitions.js +321 -0
  201. package/dist/workflows/dag/backend-test-writer-completeness.js +110 -61
  202. package/dist/workflows/dag/contract-output-registry.js +15 -0
  203. package/dist/workflows/dag/contract-validator-registrations.js +3 -1
  204. package/dist/workflows/dag/convergence/controller.js +141 -0
  205. package/dist/workflows/dag/dynamic-runtime/loop-until.js +1 -1
  206. package/dist/workflows/dag/dynamic-runtime/map.js +25 -9
  207. package/dist/workflows/dag/dynamic-runtime/shared.js +1 -1
  208. package/dist/workflows/dag/failure-category.js +7 -116
  209. package/dist/workflows/dag/frontend-implementation-contract.js +465 -6
  210. package/dist/workflows/dag/frontend-plan-render.js +24 -0
  211. package/dist/workflows/dag/frontend-prewrite-gate.js +293 -84
  212. package/dist/workflows/dag/frontend-repair.js +14 -0
  213. package/dist/workflows/dag/frontend-review-context.js +92 -10
  214. package/dist/workflows/dag/frontend-risk.js +15 -2
  215. package/dist/workflows/dag/frontend-test-case-checklist.js +30 -4
  216. package/dist/workflows/dag/frontend-test-case-manifest.js +11 -4
  217. package/dist/workflows/dag/frontend-test-case-quality.js +11 -16
  218. package/dist/workflows/dag/frontend-test-environment-probe.js +230 -0
  219. package/dist/workflows/dag/frontend-test-html-report.js +3 -1
  220. package/dist/workflows/dag/frontend-test-l5-report.js +3 -1
  221. package/dist/workflows/dag/frontend-test-layout.js +159 -0
  222. package/dist/workflows/dag/frontend-test-markdown.js +61 -0
  223. package/dist/workflows/dag/frontend-test-result-contract.js +188 -42
  224. package/dist/workflows/dag/frontend-test-standard-scenarios.js +70 -0
  225. package/dist/workflows/dag/frontend-verification-trace.js +29 -4
  226. package/dist/workflows/dag/init-hybrid.js +1019 -209
  227. package/dist/workflows/dag/node-execution.js +228 -15
  228. package/dist/workflows/dag/prompt.js +60 -1
  229. package/dist/workflows/dag/rerun-feedback.js +212 -0
  230. package/dist/workflows/dag/rerun-plan.js +224 -27
  231. package/dist/workflows/dag/rerun-run.js +61 -6
  232. package/dist/workflows/dag/rerun-task.js +61 -2
  233. package/dist/workflows/dag/retry-policy.js +109 -0
  234. package/dist/workflows/dag/structured-output-repair.js +712 -0
  235. package/dist/workflows/dag/types.js +185 -12
  236. package/dist/workflows/dag/validate.js +64 -20
  237. package/docs/README.md +1 -0
  238. package/docs/architecture/evolution.md +45 -0
  239. package/docs/init-surface.manifest.json +6 -0
  240. package/docs/operations/README.md +1 -1
  241. package/docs/operations/local-development-environment.md +1 -5
  242. package/docs/skills/vetted-skill-registry.md +14 -0
  243. package/docs/templates/README.md +2 -2
  244. package/docs/templates/agent-dag.schema.json +44 -0
  245. package/docs/templates/backend-test-dag.json +60 -35
  246. package/docs/templates/frontend-implementation-contract.schema.json +681 -24
  247. package/docs/templates/frontend-test-case-checklist.md +2 -2
  248. package/docs/templates/frontend-test-dag.json +8 -10
  249. package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +1 -1
  250. package/docs/templates/init-managed-agents.md +13 -3
  251. package/harness.json +6 -4
  252. package/package.json +12 -4
  253. package/scripts/next-info.mjs +356 -0
  254. package/scripts/next-publish-gate.mjs +238 -0
  255. package/scripts/release-source-binding.mjs +251 -0
  256. package/skills/analyze-product-requirements/SKILL.md +8 -5
  257. package/skills/analyze-product-requirements/references/acceptance-criteria.md +1 -1
  258. package/skills/analyze-product-requirements/references/clarification-and-knowledge.md +10 -6
  259. package/skills/analyze-product-requirements/references/example.md +50 -0
  260. package/skills/analyze-product-requirements/references/forward-test-cases.md +11 -7
  261. package/skills/analyze-product-requirements/references/kb-integration.md +5 -5
  262. package/skills/analyze-product-requirements/references/product-analysis-schema.md +8 -4
  263. package/skills/analyze-product-requirements/references/product-requirement-schema.md +2 -2
  264. package/skills/analyze-product-requirements/references/requirement-clarification-schema.md +1 -1
  265. package/skills/analyze-product-requirements/scripts/test-validators.mjs +11 -1
  266. package/skills/analyze-product-requirements/scripts/validate-product-analysis.mjs +20 -1
  267. package/skills/analyze-product-requirements/scripts/validate-requirement-clarification.mjs +9 -0
  268. package/skills/codebase-scout/SKILL.md +1 -1
  269. package/skills/fe-test-ui-scout/SKILL.md +65 -0
  270. package/skills/fe-test-ui-scout/references/ledger-schema.md +62 -0
  271. package/skills/fe-test-ui-scout/references/recon-protocol.md +54 -0
  272. package/skills/frontend-implementation/references/node-contracts.md +2 -2
  273. package/skills/improve-codebase-architecture/SKILL.md +81 -0
  274. package/skills/improve-codebase-architecture/deepening.md +37 -0
  275. package/skills/improve-codebase-architecture/html-report.md +123 -0
  276. package/skills/improve-codebase-architecture/interface-design.md +44 -0
  277. package/skills/improve-codebase-architecture/language.md +53 -0
  278. package/skills/loop-agent/references/command-reference.md +11 -4
  279. package/skills/loop-agent/references/hybrid-dag.md +7 -0
  280. package/skills/playwright-cli/SKILL.md +23 -1
  281. package/skills/playwright-cli-case-generator/SKILL.md +29 -8
  282. package/dist/worker/console/static/assets/channel-BBOTJHvD.js +0 -1
  283. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-Cm8Yf8tD.js +0 -1
  284. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-Cm8Yf8tD.js +0 -1
  285. package/dist/worker/console/static/assets/index-B-qWGThd.js +0 -325
  286. package/dist/worker/console/static/assets/index-C-xRgY_0.css +0 -1
  287. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DZihfdKc.js +0 -1
  288. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-CKnXCjHX.js +0 -8
@@ -1,14 +1,26 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { spawnSync } from "node:child_process";
2
3
  import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
3
4
  import path from "node:path";
4
5
  import { z } from "zod";
5
6
  import YAML from "yaml";
7
+ import { resolveBackendTestLayout, } from "./backend-test-layout.js";
8
+ import { resolveBackendTestManifestModules, backendManifestStatusFinding } from "./backend-test-markdown-workflow.js";
9
+ import { applyOpenApiPartitionDomainPolicy, assessScenarioPartitionCoverage, parseScenarioPartitions, } from "./backend-test-scenario-partitions.js";
6
10
  import { backendTestCaseManifestSchema, computeCaseManifestCoverageSummary, } from "./backend-test-case-manifest.js";
7
11
  const CASE_HEADING = /^##\s+(BE-[A-Z0-9_-]+-\d{3})\b.*$/gm;
8
12
  const CASE_ID_IN_TEXT = /\bBE-[A-Z0-9_-]+-\d{3}\b/g;
9
13
  const NON_CANONICAL_CASE_HEADING = /^##\s+(BE-[A-Z0-9_-]+-(?:\d{2}|\d{2,3}[A-Z]+))\b.*$/gm;
10
14
  const AC_ID_IN_TEXT = /\bAC-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/g;
11
- const RULE_KEY = /^(?:AC|REQ|BR|API|GET|POST|PUT|PATCH|DELETE)-[A-Z0-9]+(?:-[A-Z0-9]+)*$/;
15
+ const SOURCE_RULE_ID_IN_TEXT = /\b(?:AC|REQ|BR)-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/g;
16
+ const SOURCE_ERROR_CODE_IN_TEXT = /\b[A-Z][0-9]{5,}\b/g;
17
+ const JSON_FIELD_IN_TEXT = /["']([A-Za-z][A-Za-z0-9_]*)["']\s*:/g;
18
+ const DTO_FIELD_IN_TEXT = /\b(?:private|public|protected)\s+(?:static\s+)?(?:final\s+)?[A-Za-z_$][A-Za-z0-9_$<>,.?\[\] ]*\s+([a-z][A-Za-z0-9_]*)\s*[;=]/g;
19
+ const NATURAL_BUSINESS_RULE_KEYWORDS = /(?:事务|原子性|回滚|幂等|唯一性|唯一|状态转换|状态流转|权限|角色|租户|跨字段|transaction|atomic|rollback|idempoten|unique|state transition|authorization|permission|tenant|cross-field)/i;
20
+ const NATURAL_BUSINESS_RULE_NORMATIVE = /(?:必须|不得|禁止|仅当|应当|需要|不能|保证|must|shall|required|must not|only when|guarantee)/i;
21
+ // Token form is used for section scanning; anchored form validates full cells.
22
+ const RULE_KEY_TOKEN = /(?:AC|REQ|BR|API|GET|POST|PUT|PATCH|DELETE)-[A-Z0-9]+(?:-[A-Z0-9]+)*|[A-Z][0-9]{5,}/;
23
+ const RULE_KEY = new RegExp(`^(?:${RULE_KEY_TOKEN.source})$`);
12
24
  const TEST_POINT = /^TP-[A-Z0-9]+(?:-[A-Z0-9]+)*$/;
13
25
  const COVERAGE_HEADERS = [
14
26
  "Rule Key",
@@ -87,6 +99,13 @@ const coverageCaseSchema = z.object({
87
99
  scenarioTypes: z.array(z.string()),
88
100
  declaredScripts: z.array(z.string()),
89
101
  declaredPrimarySymbols: z.array(z.string()),
102
+ payloadContract: z.object({
103
+ declared: z.boolean(),
104
+ noPayload: z.boolean(),
105
+ requiredPaths: z.array(z.string()),
106
+ allowedPaths: z.array(z.string()),
107
+ enumValues: z.record(z.string(), z.array(z.string())),
108
+ }).strict(),
90
109
  }).strict();
91
110
  const coverageRuleSchema = z.object({
92
111
  ruleKey: z.string().min(1),
@@ -102,6 +121,23 @@ const coverageRuleSchema = z.object({
102
121
  missingTestPoints: z.array(z.string()),
103
122
  status: z.enum(["COVERED", "PARTIAL", "GAP", "CONFLICT"]),
104
123
  }).strict();
124
+ const sourceCoverageFactSchema = z.object({
125
+ kind: z.enum(["requirement-id", "error-code", "dto-field", "business-rule"]),
126
+ key: z.string().min(1),
127
+ sourcePath: z.string().min(1),
128
+ sourceSha256: sha256Schema,
129
+ lineStart: z.number().int().positive(),
130
+ lineEnd: z.number().int().positive(),
131
+ quote: z.string().min(1).max(1000),
132
+ covered: z.boolean(),
133
+ }).strict();
134
+ const sourceCoverageSchema = z.object({
135
+ sourceFactCount: z.number().int().min(0),
136
+ coveredSourceFactCount: z.number().int().min(0),
137
+ residualSourceFactCount: z.number().int().min(0),
138
+ facts: z.array(sourceCoverageFactSchema),
139
+ residual: z.array(sourceCoverageFactSchema),
140
+ }).strict();
105
141
  const coverageScopeSchema = z.object({
106
142
  changeClassification: z.enum(CHANGE_CLASSIFICATIONS),
107
143
  coveragePolicy: z.enum(COVERAGE_POLICIES),
@@ -112,13 +148,14 @@ const coverageScopeSchema = z.object({
112
148
  completenessClaim: z.enum(["affected-operations-full", "affected-scope"]),
113
149
  }).strict();
114
150
  export const backendTestCaseCoverageFactsSchema = z.object({
115
- schemaId: z.literal("backend-test-case-coverage-facts-v3"),
116
- schemaVersion: z.literal(3),
151
+ schemaId: z.literal("backend-test-case-coverage-facts-v4"),
152
+ schemaVersion: z.literal(4),
117
153
  taskId: z.string().min(1),
118
154
  status: z.enum(["PASS", "FAIL", "UNAVAILABLE"]),
119
155
  sourceBinding: sourceBindingSchema,
120
156
  inputFiles: z.array(inputFileSchema),
121
157
  coverageScope: coverageScopeSchema,
158
+ sourceCoverage: sourceCoverageSchema,
122
159
  summary: z.object({
123
160
  explicitAcCount: z.number().int().min(0),
124
161
  coveredAcCount: z.number().int().min(0),
@@ -146,6 +183,27 @@ export const backendTestCaseCoverageFactsSchema = z.object({
146
183
  }).strict(),
147
184
  cases: z.array(coverageCaseSchema),
148
185
  rules: z.array(coverageRuleSchema),
186
+ /** Plan C: declared query/filter axis partitions and their slot coverage. */
187
+ scenarioPartitions: z.object({
188
+ partitions: z.array(z.object({
189
+ partitionId: z.string(),
190
+ operation: z.string(),
191
+ axis: z.string(),
192
+ domain: z.array(z.string()),
193
+ optional: z.boolean(),
194
+ complementExpected: z.string(),
195
+ bindRule: z.string(),
196
+ slotIds: z.array(z.string()),
197
+ })),
198
+ slots: z.array(z.object({
199
+ slotId: z.string(),
200
+ partitionId: z.string(),
201
+ kind: z.string(),
202
+ intent: z.string(),
203
+ expected: z.string(),
204
+ })),
205
+ missingSlots: z.array(z.string()),
206
+ }),
149
207
  findings: z.array(z.string()),
150
208
  evidenceGaps: z.array(z.string()),
151
209
  conflicts: z.array(z.string()),
@@ -177,12 +235,24 @@ const correspondenceEntrySchema = z.object({
177
235
  "CROSS_CUTTING_EVIDENCE_MISSING",
178
236
  "TEST_POINT_BINDING_DUPLICATE",
179
237
  "TEST_POINT_BINDING_EXTRA",
238
+ "PAYLOAD_CONTRACT_UNAVAILABLE",
239
+ "PAYLOAD_SHAPE_MISMATCH",
180
240
  ]),
241
+ payloadAssessment: z.object({
242
+ status: z.enum(["SAFE", "UNSAFE", "UNAVAILABLE"]),
243
+ requiredPaths: z.array(z.string()),
244
+ allowedPaths: z.array(z.string()),
245
+ observedPaths: z.array(z.string()),
246
+ missingPaths: z.array(z.string()),
247
+ unexpectedPaths: z.array(z.string()),
248
+ enumMismatches: z.array(z.string()),
249
+ }).strict(),
250
+ executionSignature: z.string().min(1).optional(),
181
251
  findings: z.array(z.string()),
182
252
  }).strict();
183
253
  export const backendTestMarkdownPytestCorrespondenceFactsSchema = z.object({
184
- schemaId: z.literal("backend-test-markdown-pytest-correspondence-facts-v2"),
185
- schemaVersion: z.literal(2),
254
+ schemaId: z.literal("backend-test-markdown-pytest-correspondence-facts-v3"),
255
+ schemaVersion: z.literal(3),
186
256
  taskId: z.string().min(1),
187
257
  status: z.enum(["PASS", "FAIL", "UNAVAILABLE"]),
188
258
  inputFiles: z.array(inputFileSchema),
@@ -203,6 +273,10 @@ export const backendTestMarkdownPytestCorrespondenceFactsSchema = z.object({
203
273
  crossCuttingTestPointCount: z.number().int().min(0),
204
274
  unclassifiedTestPointCount: z.number().int().min(0),
205
275
  duplicateBindingTestPointCount: z.number().int().min(0),
276
+ payloadSafeCount: z.number().int().min(0),
277
+ payloadUnsafeCount: z.number().int().min(0),
278
+ payloadUnavailableCount: z.number().int().min(0),
279
+ duplicateExecutionSignatureCount: z.number().int().min(0),
206
280
  }).strict(),
207
281
  entries: z.array(correspondenceEntrySchema),
208
282
  findings: z.array(z.string()),
@@ -236,6 +310,64 @@ function openApiTestPoints(field, dimension, values = []) {
236
310
  return [`${prefix}-VALID-CLASS`, `${prefix}-VALID-MIXED`, `${prefix}-UPPERCASE`, `${prefix}-WHITESPACE`, `${prefix}-PUNCTUATION`, `${prefix}-SLASH`, `${prefix}-EMOJI`, `${prefix}-CONTROL`];
237
311
  return [`${prefix}-PRESENT`, `${prefix}-MISSING`, `${prefix}-NULL`, `${prefix}-WRONG-TYPE`];
238
312
  }
313
+ async function collectSourceCoverageFacts(input) {
314
+ const paths = orderedUnique([
315
+ input.sourceBinding.requirementPath,
316
+ ...input.sourceBinding.referencePaths,
317
+ ]);
318
+ const facts = [];
319
+ const seen = new Set();
320
+ for (const sourcePath of paths) {
321
+ const physicalPath = resolveBoundSourcePath(input.workspaceRoot, input.sourceBinding.taskId, sourcePath);
322
+ let text;
323
+ try {
324
+ text = await readFile(physicalPath, "utf8");
325
+ }
326
+ catch {
327
+ continue;
328
+ }
329
+ const sourceSha256 = createHash("sha256").update(await readFile(physicalPath)).digest("hex");
330
+ const lines = text.split(/\r?\n/);
331
+ const add = (kind, key, lineIndex, quote) => {
332
+ const normalizedQuote = quote.trim().slice(0, 1000);
333
+ if (!normalizedQuote)
334
+ return;
335
+ const identity = `${kind}:${key}:${sourcePath}:${lineIndex + 1}`;
336
+ if (seen.has(identity))
337
+ return;
338
+ seen.add(identity);
339
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
340
+ const covered = kind === "business-rule"
341
+ ? input.generatedMarkdown.includes(normalizedQuote)
342
+ : new RegExp(`\\b${escaped}\\b`).test(input.generatedMarkdown);
343
+ facts.push({
344
+ kind,
345
+ key,
346
+ sourcePath,
347
+ sourceSha256,
348
+ lineStart: lineIndex + 1,
349
+ lineEnd: lineIndex + 1,
350
+ quote: normalizedQuote,
351
+ covered,
352
+ });
353
+ };
354
+ lines.forEach((line, lineIndex) => {
355
+ for (const key of line.match(SOURCE_RULE_ID_IN_TEXT) ?? [])
356
+ add("requirement-id", key, lineIndex, line);
357
+ for (const key of line.match(SOURCE_ERROR_CODE_IN_TEXT) ?? [])
358
+ add("error-code", key, lineIndex, line);
359
+ for (const match of line.matchAll(JSON_FIELD_IN_TEXT))
360
+ add("dto-field", match[1], lineIndex, line);
361
+ for (const match of line.matchAll(DTO_FIELD_IN_TEXT))
362
+ add("dto-field", match[1], lineIndex, line);
363
+ const trimmed = line.trim();
364
+ if (trimmed.length >= 8 && trimmed.length <= 1000 && !/^\s*(?:#|\||[-:]{3,})/.test(trimmed) && NATURAL_BUSINESS_RULE_KEYWORDS.test(trimmed) && NATURAL_BUSINESS_RULE_NORMATIVE.test(trimmed)) {
365
+ add("business-rule", `BR-SOURCE-${sourceSha256.slice(0, 8).toUpperCase()}-L${lineIndex + 1}`, lineIndex, trimmed);
366
+ }
367
+ });
368
+ }
369
+ return facts.sort((left, right) => left.kind.localeCompare(right.kind) || left.key.localeCompare(right.key) || left.sourcePath.localeCompare(right.sourcePath) || left.lineStart - right.lineStart);
370
+ }
239
371
  function resolveBoundSourcePath(workspaceRoot, taskId, sourcePath) {
240
372
  if (path.isAbsolute(sourcePath))
241
373
  return sourcePath;
@@ -355,6 +487,55 @@ export async function extractBackendTestOpenApiRules(input) {
355
487
  }
356
488
  return rules.sort((left, right) => left.ruleKey.localeCompare(right.ruleKey));
357
489
  }
490
+ export async function extractBackendTestOpenApiPartitionAxes(input) {
491
+ const axes = [];
492
+ const seen = new Set();
493
+ for (const referencePath of input.sourceBinding.referencePaths) {
494
+ const physicalPath = resolveBoundSourcePath(input.workspaceRoot, input.sourceBinding.taskId, referencePath);
495
+ let document;
496
+ try {
497
+ const parsed = YAML.parse(await readFile(physicalPath, "utf8"));
498
+ if (!isRecord(parsed) || !isRecord(parsed.paths))
499
+ continue;
500
+ document = parsed;
501
+ }
502
+ catch {
503
+ continue;
504
+ }
505
+ for (const [apiPath, pathItem] of Object.entries(document.paths)) {
506
+ if (!isRecord(pathItem))
507
+ continue;
508
+ const operation = pathItem.get;
509
+ if (!isRecord(operation))
510
+ continue;
511
+ const parameterValues = [
512
+ ...(Array.isArray(pathItem.parameters) ? pathItem.parameters : []),
513
+ ...(Array.isArray(operation.parameters) ? operation.parameters : []),
514
+ ];
515
+ for (const rawParameter of parameterValues) {
516
+ const parameter = resolveOpenApiSchema(document, rawParameter);
517
+ if (!parameter || typeof parameter.name !== "string")
518
+ continue;
519
+ const location = parameter.in === "path" ? "path" : parameter.in === "query" ? "query" : undefined;
520
+ if (!location)
521
+ continue;
522
+ const property = resolveOpenApiSchema(document, parameter.schema);
523
+ const enumValues = property && Array.isArray(property.enum) ? property.enum.map(String) : null;
524
+ const identity = `GET ${apiPath}|${location}|${parameter.name}`;
525
+ if (seen.has(identity))
526
+ continue;
527
+ seen.add(identity);
528
+ axes.push({
529
+ operation: `GET ${apiPath}`,
530
+ name: parameter.name,
531
+ in: location,
532
+ enumValues,
533
+ });
534
+ }
535
+ }
536
+ }
537
+ return axes;
538
+ }
358
539
  function orderedUnique(values) {
359
540
  const result = [];
360
541
  const seen = new Set();
@@ -385,8 +566,14 @@ function sectionBody(body, aliases) {
385
566
  }
386
567
  function listTokens(body, aliases, pattern) {
387
568
  const section = sectionBody(body, aliases).replaceAll("`", "");
388
- const source = pattern.source.replace(/^\^/, "").replace(/\$$/, "");
389
- return orderedUnique(section.match(new RegExp(source, "g")) ?? []);
569
+ // Prefer dedicated token patterns; never strip ^/$ from alternation-based anchors
570
+ // (that would turn `^A$|^B$` into `A$|^B` and break scanning).
571
+ const tokenSource = pattern === RULE_KEY
572
+ ? RULE_KEY_TOKEN.source
573
+ : pattern === TEST_POINT
574
+ ? TEST_POINT.source.replace(/^\^/, "").replace(/\$$/, "")
575
+ : pattern.source.replace(/^\^/, "").replace(/\$$/, "");
576
+ return orderedUnique(section.match(new RegExp(tokenSource, "g")) ?? []);
390
577
  }
391
578
  function scenarioTypes(body) {
392
579
  return orderedUnique(sectionBody(body, CASE_SECTION_ALIASES.scenarioTypes)
@@ -423,8 +610,40 @@ function testPointBindingFacts(body, testPoints) {
423
610
  }
424
611
  function declaredPrimarySymbols(body) {
425
612
  const value = automationLabelBody(body, ["primary symbol", "Primary Symbol", "主测试符号"]);
613
+ if (/^(?:none|无|n\/a|not-applicable)$/i.test(value.trim()))
614
+ return [];
426
615
  return orderedUnique(value.match(/\btest_[A-Za-z0-9_]+\b/g) ?? []);
427
616
  }
617
+ function isEvidenceOnlyMarkdownCase(testCase) {
618
+ const hasVariant = testCase.testPointBindings.some((item) => item.mode === "variant");
619
+ const hasNonVariant = testCase.testPointBindings.some((item) => item.mode === "assertion" || item.mode === "cross-cutting");
620
+ return (testCase.declaredScripts.length === 0 &&
621
+ testCase.declaredPrimarySymbols.length === 0 &&
622
+ !hasVariant &&
623
+ hasNonVariant);
624
+ }
625
+ function payloadContract(body) {
626
+ const contractValue = automationLabelBody(body, ["Payload Contract", "载荷合同"]);
627
+ const requiredValue = automationLabelBody(body, ["Payload Required Paths", "载荷必需路径"]);
628
+ const allowedValue = automationLabelBody(body, ["Payload Allowed Paths", "载荷允许路径"]);
629
+ const enumValue = automationLabelBody(body, ["Payload Enum", "载荷枚举"]);
630
+ const tokens = (value) => orderedUnique(value.split(/[,;,;]/).map((item) => item.trim()).filter((item) => /^[A-Za-z_][A-Za-z0-9_.]*$/.test(item)));
631
+ const enumValues = {};
632
+ for (const assignment of enumValue.split(/[,;,;]/).map((item) => item.trim()).filter(Boolean)) {
633
+ const match = /^([A-Za-z_][A-Za-z0-9_.]*)\s*(?:=|:)\s*(.+)$/.exec(assignment);
634
+ if (!match)
635
+ continue;
636
+ enumValues[match[1]] = orderedUnique(match[2].split(/\||\//).map((item) => item.trim().replace(/[((].*$/, "").trim().replace(/^['"]|['"]$/g, "")).filter(Boolean));
637
+ }
638
+ const noPayload = /^(?:none|无|not-applicable|n\/a)(?:\s|[((]|$)/i.test(contractValue.trim());
639
+ return {
640
+ declared: noPayload || requiredValue.length > 0 || allowedValue.length > 0 || enumValue.length > 0,
641
+ noPayload,
642
+ requiredPaths: tokens(requiredValue),
643
+ allowedPaths: tokens(allowedValue),
644
+ enumValues,
645
+ };
646
+ }
428
647
  function cleanTitle(heading, id) {
429
648
  return heading.replace(/^##\s+/, "").replace(/^BE-[A-Z0-9_-]+-\d{2,3}\s*(?:[||—–-]\s*)?/i, "").trim() || id;
430
649
  }
@@ -437,17 +656,26 @@ async function exists(filePath) {
437
656
  return false;
438
657
  }
439
658
  }
440
- async function markdownModuleFiles(workspaceRoot) {
441
- const dir = path.join(workspaceRoot, "testcase", "md");
659
+ async function markdownModuleFiles(workspaceRoot, layout) {
660
+ const resolved = layout ?? resolveBackendTestLayout(undefined);
661
+ const dir = path.join(workspaceRoot, ...resolved.markdownDir.split("/"));
442
662
  const entries = await readdir(dir, { withFileTypes: true });
443
- return entries
663
+ const all = entries
444
664
  .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".md") && entry.name.toLowerCase() !== "readme.md")
445
665
  .map((entry) => path.join(dir, entry.name))
446
666
  .sort();
667
+ // Current-round Markdown scope is the README Module Index manifest: unmanifested
668
+ // legacy .md files must not enter Case/coverage/partition statistics. Workspaces
669
+ // without a Module Index fall back to the whole directory (prior behaviour).
670
+ const manifest = await resolveBackendTestManifestModules(workspaceRoot, resolved);
671
+ if (manifest.manifestStatus !== "valid")
672
+ return [];
673
+ const declaredAbs = new Set(manifest.declaredMdFiles.map((rel) => path.join(workspaceRoot, ...rel.split("/")).toLowerCase()));
674
+ return all.filter((file) => declaredAbs.has(file.toLowerCase())).sort();
447
675
  }
448
- async function parseMarkdownCases(workspaceRoot) {
676
+ async function parseMarkdownCases(workspaceRoot, layout) {
449
677
  const result = [];
450
- for (const file of await markdownModuleFiles(workspaceRoot)) {
678
+ for (const file of await markdownModuleFiles(workspaceRoot, layout)) {
451
679
  const markdown = await readFile(file, "utf8");
452
680
  const headings = [...markdown.matchAll(CASE_HEADING)];
453
681
  for (let index = 0; index < headings.length; index += 1) {
@@ -466,6 +694,7 @@ async function parseMarkdownCases(workspaceRoot) {
466
694
  scenarioTypes: scenarioTypes(body),
467
695
  declaredScripts: declaredScripts(body),
468
696
  declaredPrimarySymbols: declaredPrimarySymbols(body),
697
+ payloadContract: payloadContract(body),
469
698
  body,
470
699
  });
471
700
  }
@@ -483,8 +712,17 @@ function parseTableRow(line) {
483
712
  index += 1;
484
713
  }
485
714
  else if (char === "|") {
486
- cells.push(current.trim());
487
- current = "";
715
+ const prev = trimmed[index - 1] ?? "";
716
+ const next = trimmed[index + 1] ?? "";
717
+ // In-cell enum/value pipes look like ACTIVE|ARCHIVED (alnum on both sides).
718
+ // Column separators use " | " or touch markdown separator dashes (---|---).
719
+ if (/[A-Za-z0-9_]/.test(prev) && /[A-Za-z0-9_]/.test(next)) {
720
+ current += "|";
721
+ }
722
+ else {
723
+ cells.push(current.trim());
724
+ current = "";
725
+ }
488
726
  }
489
727
  else {
490
728
  current += char;
@@ -637,15 +875,26 @@ function renderCoverageTable(rules) {
637
875
  ];
638
876
  }
639
877
  export async function analyzeBackendTestCaseCoverage(input) {
878
+ const layout = input.layout ?? resolveBackendTestLayout(undefined);
640
879
  const findings = [];
641
- const readmePath = path.join(input.workspaceRoot, "testcase", "md", "README.md");
880
+ const readmePath = path.join(input.workspaceRoot, ...layout.readmePath.split("/"));
642
881
  const readme = (await exists(readmePath)) ? await readFile(readmePath, "utf8") : "";
643
882
  const coverageScope = parseCoverageScope(readme);
644
883
  findings.push(...coverageScope.findings);
645
884
  const matrix = parseCoverageMatrix(readme);
646
885
  findings.push(...matrix.findings);
647
- const cases = await parseMarkdownCases(input.workspaceRoot);
648
- for (const file of await markdownModuleFiles(input.workspaceRoot)) {
886
+ const manifest = await resolveBackendTestManifestModules(input.workspaceRoot, layout);
887
+ // Fail closed on README Module Index entries declared but never materialized.
888
+ if (manifest.declaredMissing.length > 0) {
889
+ findings.push(`declared Markdown module is missing (Module Index): ${manifest.declaredMissing.join(", ")}`);
890
+ }
891
+ // Fail closed on every non-valid Module Index state: without a valid unique
892
+ // Module Index the current round must not re-scan historical `.md` files.
893
+ if (manifest.manifestStatus !== "valid") {
894
+ findings.push(backendManifestStatusFinding(manifest.manifestStatus, manifest.moduleIndexHeadingCount));
895
+ }
896
+ const cases = await parseMarkdownCases(input.workspaceRoot, layout);
897
+ for (const file of await markdownModuleFiles(input.workspaceRoot, layout)) {
649
898
  const markdown = await readFile(file, "utf8");
650
899
  for (const match of markdown.matchAll(NON_CANONICAL_CASE_HEADING)) {
651
900
  findings.push(`non-canonical Case ID heading must use BE-<MODULE>-<NNN>: ${match[1]}`);
@@ -687,7 +936,10 @@ export async function analyzeBackendTestCaseCoverage(input) {
687
936
  if (!rowCaseIds.includes(testCase.caseId))
688
937
  findings.push(`${testCase.caseId} references ${ruleKey} but is absent from its Coverage Matrix Case IDs`);
689
938
  }
690
- let status = declaredStatus;
939
+ const normalizedDeclaredStatus = /^(?:COVERED|PARTIAL|GAP|CONFLICT)$/.test(declaredStatus)
940
+ ? declaredStatus
941
+ : "PARTIAL";
942
+ let status = normalizedDeclaredStatus;
691
943
  if (status === "COVERED" && missingTestPoints.length > 0)
692
944
  status = "PARTIAL";
693
945
  if (missingTestPoints.length > 0)
@@ -707,7 +959,7 @@ export async function analyzeBackendTestCaseCoverage(input) {
707
959
  rule: row.Rule || "unavailable",
708
960
  requiredTestPoints,
709
961
  caseIds: rowCaseIds,
710
- declaredStatus: /^(?:COVERED|PARTIAL|GAP|CONFLICT)$/.test(declaredStatus) ? declaredStatus : "PARTIAL",
962
+ declaredStatus: normalizedDeclaredStatus,
711
963
  coveredTestPoints,
712
964
  missingTestPoints,
713
965
  status,
@@ -749,8 +1001,12 @@ export async function analyzeBackendTestCaseCoverage(input) {
749
1001
  findings.push(`${testCase.caseId} missing section or entries: 测试点`);
750
1002
  if (testCase.scenarioTypes.length === 0)
751
1003
  findings.push(`${testCase.caseId} missing section or entries: 场景类型`);
752
- if (testCase.declaredPrimarySymbols.length !== 1)
1004
+ if (isEvidenceOnlyMarkdownCase(testCase)) {
1005
+ // Evidence-only meta cases intentionally declare 脚本/primary symbol as 无 and only keep assertion/cross-cutting TPs.
1006
+ }
1007
+ else if (testCase.declaredPrimarySymbols.length !== 1) {
753
1008
  findings.push(`${testCase.caseId} must declare exactly one primary symbol`);
1009
+ }
754
1010
  if (testCase.unclassifiedTestPoints.length > 0)
755
1011
  findings.push(`${testCase.caseId} has unclassified Test Points: ${testCase.unclassifiedTestPoints.join(", ")}`);
756
1012
  if (testCase.duplicateBindingTestPoints.length > 0)
@@ -772,8 +1028,37 @@ export async function analyzeBackendTestCaseCoverage(input) {
772
1028
  if (uniqueOwners.length > 1)
773
1029
  findings.push(`${testPoint} is bound as a non-cross-cutting Test Point by multiple Cases: ${uniqueOwners.join(", ")}`);
774
1030
  }
775
- const explicitAc = input.sourceBinding.requirementIds.filter((id) => id.startsWith("AC-"));
1031
+ const generatedMarkdown = [readme, ...cases.map((item) => item.body)].join("\n\n");
1032
+ const sourceCoverageFacts = await collectSourceCoverageFacts({
1033
+ workspaceRoot: input.workspaceRoot,
1034
+ sourceBinding: input.sourceBinding,
1035
+ generatedMarkdown,
1036
+ });
1037
+ const residualSourceFacts = sourceCoverageFacts.filter((fact) => !fact.covered);
1038
+ for (const fact of residualSourceFacts) {
1039
+ findings.push(`source coverage is missing ${fact.kind} ${fact.key} from ${fact.sourcePath}`);
1040
+ }
1041
+ const sourceRequirementIds = sourceCoverageFacts
1042
+ .filter((fact) => fact.kind === "requirement-id")
1043
+ .map((fact) => fact.key);
1044
+ const explicitAc = orderedUnique([
1045
+ ...input.sourceBinding.requirementIds.filter((id) => id.startsWith("AC-")),
1046
+ ...sourceRequirementIds.filter((id) => id.startsWith("AC-")),
1047
+ ]);
776
1048
  const coveredAc = new Set(cases.flatMap((item) => item.acIds));
1049
+ for (const acId of explicitAc) {
1050
+ if (!coveredAc.has(acId))
1051
+ findings.push(`explicit source AC is missing from Markdown Cases: ${acId}`);
1052
+ }
1053
+ // Plan C: deterministic query/filter axis partitions (fail-closed on missing slots).
1054
+ const parsedPartitions = parseScenarioPartitions(readme);
1055
+ findings.push(...parsedPartitions.issues);
1056
+ const openApiPartitionAxes = await extractBackendTestOpenApiPartitionAxes(input);
1057
+ const partitionedBySource = applyOpenApiPartitionDomainPolicy(parsedPartitions.partitions, openApiPartitionAxes);
1058
+ findings.push(...partitionedBySource.issues);
1059
+ const declaredPointUniverse = new Set(cases.flatMap((item) => item.testPoints));
1060
+ const partitionAssessment = assessScenarioPartitionCoverage(partitionedBySource.partitions, declaredPointUniverse);
1061
+ findings.push(...partitionAssessment.findings);
777
1062
  const [enumValueCount, coveredEnumValueCount] = dimensionCounts(rules, /enum/);
778
1063
  const [boundaryPointCount, coveredBoundaryPointCount] = dimensionCounts(rules, /boundary|length|numeric/);
779
1064
  const [formatClassCount, coveredFormatClassCount] = dimensionCounts(rules, /format|pattern|charset/);
@@ -782,20 +1067,27 @@ export async function analyzeBackendTestCaseCoverage(input) {
782
1067
  const invalidEquivalenceClassCount = rules.reduce((sum, rule) => sum + rule.requiredTestPoints.filter((point) => invalidPointPattern.test(point)).length, 0);
783
1068
  const coveredInvalidEquivalenceClassCount = rules.reduce((sum, rule) => sum + rule.coveredTestPoints.filter((point) => invalidPointPattern.test(point)).length, 0);
784
1069
  const facts = backendTestCaseCoverageFactsSchema.parse({
785
- schemaId: "backend-test-case-coverage-facts-v3",
786
- schemaVersion: 3,
1070
+ schemaId: "backend-test-case-coverage-facts-v4",
1071
+ schemaVersion: 4,
787
1072
  taskId: input.sourceBinding.taskId,
788
1073
  status: findings.length === 0 ? "PASS" : "FAIL",
789
1074
  sourceBinding: input.sourceBinding,
790
1075
  inputFiles: await inputFileFacts(input.workspaceRoot, [
791
1076
  ...((await exists(readmePath)) ? [readmePath] : []),
792
- ...(await markdownModuleFiles(input.workspaceRoot)),
1077
+ ...(await markdownModuleFiles(input.workspaceRoot, layout)),
793
1078
  ...(await Promise.all(input.sourceBinding.referencePaths.map(async (referencePath) => {
794
1079
  const physicalPath = resolveBoundSourcePath(input.workspaceRoot, input.sourceBinding.taskId, referencePath);
795
1080
  return (await exists(physicalPath)) ? physicalPath : "";
796
1081
  }))).filter(Boolean),
797
1082
  ]),
798
1083
  coverageScope: coverageScope.scope,
1084
+ sourceCoverage: {
1085
+ sourceFactCount: sourceCoverageFacts.length,
1086
+ coveredSourceFactCount: sourceCoverageFacts.length - residualSourceFacts.length,
1087
+ residualSourceFactCount: residualSourceFacts.length,
1088
+ facts: sourceCoverageFacts,
1089
+ residual: residualSourceFacts,
1090
+ },
799
1091
  summary: {
800
1092
  explicitAcCount: explicitAc.length,
801
1093
  coveredAcCount: explicitAc.filter((id) => coveredAc.has(id)).length,
@@ -823,6 +1115,7 @@ export async function analyzeBackendTestCaseCoverage(input) {
823
1115
  },
824
1116
  cases: cases.map(({ body: _body, ...item }) => item),
825
1117
  rules,
1118
+ scenarioPartitions: partitionAssessment.facts,
826
1119
  findings: orderedUnique(findings),
827
1120
  evidenceGaps,
828
1121
  conflicts,
@@ -835,6 +1128,15 @@ export async function analyzeBackendTestCaseCoverage(input) {
835
1128
  "",
836
1129
  facts.status,
837
1130
  "",
1131
+ `- Manifest scope: ${manifest.manifestStatus === "valid" ? "declared-modules" : `fails-closed:${manifest.manifestStatus}`} (declared-missing=${manifest.declaredMissing.length}, unmanifested-advisory=${manifest.unmanifested.length}, module-index-heading-count=${manifest.moduleIndexHeadingCount})`,
1132
+ "",
1133
+ "## Unmanifested Markdown Modules",
1134
+ "",
1135
+ "- Advisory: existing .md files not declared by the README Module Index are excluded from Case/coverage/partition statistics for this round.",
1136
+ ...(manifest.unmanifested.length > 0
1137
+ ? manifest.unmanifested.map((item) => `- ${item}`)
1138
+ : ["- None"]),
1139
+ "",
838
1140
  "## Coverage Scope",
839
1141
  "",
840
1142
  `- Change Classification: ${facts.coverageScope.changeClassification}`,
@@ -846,6 +1148,22 @@ export async function analyzeBackendTestCaseCoverage(input) {
846
1148
  `- Scope Evidence: ${facts.coverageScope.scopeEvidence.join(", ") || "Unavailable"}`,
847
1149
  ...(facts.coverageScope.completenessClaim === "affected-scope" ? ["- Coverage is scoped to affected operations; it is not whole-API completeness."] : ["- Coverage fully evaluates the declared affected operations; it is not whole-API completeness unless every operation is explicitly listed."]),
848
1150
  "",
1151
+ "## Source Coverage Inventory",
1152
+ "",
1153
+ `- Source facts: ${facts.sourceCoverage.sourceFactCount}`,
1154
+ `- Covered source facts: ${facts.sourceCoverage.coveredSourceFactCount}`,
1155
+ `- Residual source facts: ${facts.sourceCoverage.residualSourceFactCount}`,
1156
+ "",
1157
+ "| Kind | Key | Source Anchor | Source SHA-256 | Exact Quote | Status |",
1158
+ "|---|---|---|---|---|---|",
1159
+ ...facts.sourceCoverage.facts.map((fact) => `| ${fact.kind} | ${fact.key} | ${fact.sourcePath}:L${fact.lineStart} | ${fact.sourceSha256} | ${fact.quote.replaceAll("|", "\\|")} | ${fact.covered ? "COVERED" : "MISSING_AFTER_SYNC"} |`),
1160
+ "",
1161
+ "## Residual Missing Source Scenarios",
1162
+ "",
1163
+ ...(facts.sourceCoverage.residual.length > 0
1164
+ ? facts.sourceCoverage.residual.map((fact) => `- ${fact.kind} \`${fact.key}\` from \`${fact.sourcePath}:L${fact.lineStart}\` (sha256 \`${fact.sourceSha256}\`), quote: “${fact.quote}”: missing after the bounded Markdown synchronization round; no full-coverage claim is allowed.`)
1165
+ : ["- None"]),
1166
+ "",
849
1167
  "## Coverage Summary",
850
1168
  "",
851
1169
  "| Dimension | Total | Covered |",
@@ -902,13 +1220,347 @@ export async function analyzeBackendTestCaseCoverage(input) {
902
1220
  function normalizeModuleStem(markdownPath) {
903
1221
  return path.posix.basename(markdownPath).replace(/\.md$/i, "").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").replace(/_+/g, "_");
904
1222
  }
905
- function expectedScript(markdownPath) {
906
- return `testcase/test_${normalizeModuleStem(markdownPath)}.py`;
1223
+ function expectedScript(markdownPath, layout) {
1224
+ const resolved = layout ?? resolveBackendTestLayout(undefined);
1225
+ return `${resolved.scriptDir}/test_${normalizeModuleStem(markdownPath)}.py`;
907
1226
  }
908
1227
  function symbolCaseId(symbol) {
909
1228
  const match = symbol.match(/^test_(BE(?:_[A-Z0-9]+)+?_\d{2,3})(?:_|$)/i);
910
1229
  return match ? canonicalCaseId(match[1].replaceAll("_", "-")) : undefined;
911
1230
  }
1231
+ function pythonPayloadShapes(source) {
1232
+ const program = String.raw `
1233
+ import ast,json,sys
1234
+ source=sys.stdin.buffer.read().decode('utf-8')
1235
+ tree=ast.parse(source)
1236
+ GLOBAL_PAYLOADS={}
1237
+ GLOBAL_VALUES={}
1238
+ for top in tree.body:
1239
+ if isinstance(top,(ast.Assign,ast.AnnAssign)):
1240
+ names=([target.id for target in top.targets if isinstance(target,ast.Name)] if isinstance(top,ast.Assign) else ([top.target.id] if isinstance(top.target,ast.Name) else []))
1241
+ if top.value is None: continue
1242
+ candidates=[]
1243
+ for node in ast.walk(top.value):
1244
+ if isinstance(node,ast.Call) and isinstance(node.func,ast.Attribute) and node.func.attr == 'param' and node.args and isinstance(node.args[0],ast.Dict): candidates.append(node.args[0])
1245
+ if isinstance(top.value,ast.Dict) and any(name.lower() in {'payload','body','request_body','request_json','dto'} for name in names): candidates.append(top.value)
1246
+ for name in names:
1247
+ GLOBAL_PAYLOADS[name]=candidates
1248
+ GLOBAL_VALUES[name]=top.value
1249
+ FUNCTION_PAYLOADS={}
1250
+ FUNCTION_TRANSPORT_SHAPES={}
1251
+ TRANSPORT_PAYLOAD_PARAMS={}
1252
+ for top in ast.walk(tree):
1253
+ if isinstance(top,(ast.FunctionDef,ast.AsyncFunctionDef)) and not top.name.startswith('test_'):
1254
+ candidates=[]
1255
+ assigned={}
1256
+ mutated={}
1257
+ returned_names=set()
1258
+ positional=[arg.arg for arg in [*top.args.posonlyargs,*top.args.args]]
1259
+ call_offset=1 if positional and positional[0] in {'self','cls'} else 0
1260
+ transport=[]
1261
+ for node in ast.walk(top):
1262
+ if isinstance(node,(ast.Assign,ast.AnnAssign)):
1263
+ targets=node.targets if isinstance(node,ast.Assign) else [node.target]
1264
+ if node.value is None: continue
1265
+ for target in targets:
1266
+ if isinstance(target,ast.Name): assigned[target.id]=node.value
1267
+ for target in targets:
1268
+ if isinstance(target,ast.Subscript) and isinstance(target.value,ast.Name) and isinstance(target.slice,ast.Constant) and isinstance(target.slice.value,str):
1269
+ mutated.setdefault(target.value.id,[]).append((target.slice.value,node.value))
1270
+ if isinstance(node,ast.Return):
1271
+ if isinstance(node.value,ast.Dict): candidates.append(node.value)
1272
+ elif isinstance(node.value,ast.Name) and node.value.id in assigned:
1273
+ returned=assigned[node.value.id]
1274
+ if isinstance(returned,ast.Dict): candidates.append(returned)
1275
+ elif isinstance(returned,ast.Call) and isinstance(returned.func,ast.Name) and returned.func.id in FUNCTION_PAYLOADS:
1276
+ candidates.extend(FUNCTION_PAYLOADS[returned.func.id])
1277
+ returned_names.add(node.value.id)
1278
+ if isinstance(node,ast.Call):
1279
+ for keyword in node.keywords:
1280
+ 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:
1281
+ transport.append((keyword.value.id,positional.index(keyword.value.id)-call_offset))
1282
+ if isinstance(node.func,ast.Attribute) and node.func.attr.lower() in {'post','put','patch'} and len(node.args) >= 2:
1283
+ candidate=node.args[1]
1284
+ if isinstance(candidate,ast.Name) and candidate.id in positional:
1285
+ transport.append((candidate.id,positional.index(candidate.id)-call_offset))
1286
+ callee=None
1287
+ if isinstance(node.func,ast.Name): callee=node.func.id
1288
+ elif isinstance(node.func,ast.Attribute): callee=node.func.attr
1289
+ for _nested_name,nested_position in TRANSPORT_PAYLOAD_PARAMS.get(callee,[]):
1290
+ matched=node.args[nested_position] if nested_position >= 0 and nested_position < len(node.args) else None
1291
+ if isinstance(matched,ast.Name) and matched.id in positional:
1292
+ transport.append((matched.id,positional.index(matched.id)-call_offset))
1293
+ for returned_name in returned_names:
1294
+ entries=mutated.get(returned_name,[])
1295
+ if entries:
1296
+ candidates.append(ast.Dict(keys=[ast.Constant(key) for key,_value in entries],values=[value for _key,value in entries]))
1297
+ transport_shapes=[]
1298
+ for node in ast.walk(top):
1299
+ if not isinstance(node,ast.Call): continue
1300
+ for keyword in node.keywords:
1301
+ 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:
1302
+ transport_shapes.append(assigned[keyword.value.id])
1303
+ if candidates: FUNCTION_PAYLOADS[top.name]=candidates
1304
+ if transport_shapes: FUNCTION_TRANSPORT_SHAPES[top.name]=transport_shapes
1305
+ if transport: TRANSPORT_PAYLOAD_PARAMS[top.name]=list(dict.fromkeys(transport))
1306
+ # Propagate transport payload positions through local wrapper chains after all
1307
+ # direct transport methods/functions are known. ast.walk may visit module-level
1308
+ # wrappers before class methods, so one-pass discovery is order-sensitive.
1309
+ for _round in range(6):
1310
+ changed=False
1311
+ for top in ast.walk(tree):
1312
+ if not isinstance(top,(ast.FunctionDef,ast.AsyncFunctionDef)) or top.name.startswith('test_'): continue
1313
+ positional=[arg.arg for arg in [*top.args.posonlyargs,*top.args.args]]
1314
+ call_offset=1 if positional and positional[0] in {'self','cls'} else 0
1315
+ current=list(TRANSPORT_PAYLOAD_PARAMS.get(top.name,[]))
1316
+ for node in ast.walk(top):
1317
+ if not isinstance(node,ast.Call): continue
1318
+ callee=node.func.id if isinstance(node.func,ast.Name) else (node.func.attr if isinstance(node.func,ast.Attribute) else None)
1319
+ for _nested_name,nested_position in TRANSPORT_PAYLOAD_PARAMS.get(callee,[]):
1320
+ matched=node.args[nested_position] if nested_position >= 0 and nested_position < len(node.args) else None
1321
+ if isinstance(matched,ast.Name) and matched.id in positional:
1322
+ item=(matched.id,positional.index(matched.id)-call_offset)
1323
+ if item not in current:
1324
+ current.append(item); changed=True
1325
+ if current: TRANSPORT_PAYLOAD_PARAMS[top.name]=current
1326
+ if not changed: break
1327
+
1328
+ # A local row factory may return pytest.param(_payload_builder(...), ..., id=tp).
1329
+ # Treat its first positional payload builder as shape evidence while keeping the
1330
+ # writer contract free to require direct pytest.param rows for scenario checks.
1331
+ for top in ast.walk(tree):
1332
+ if not isinstance(top,(ast.FunctionDef,ast.AsyncFunctionDef)) or top.name.startswith('test_'): continue
1333
+ for node in ast.walk(top):
1334
+ if not isinstance(node,ast.Return) or not isinstance(node.value,ast.Call): continue
1335
+ returned=node.value
1336
+ if not isinstance(returned.func,ast.Attribute) or returned.func.attr != 'param' or not returned.args: continue
1337
+ payload=returned.args[0]
1338
+ if isinstance(payload,ast.Call) and isinstance(payload.func,ast.Name) and payload.func.id in FUNCTION_PAYLOADS:
1339
+ FUNCTION_PAYLOADS[top.name]=list(FUNCTION_PAYLOADS[payload.func.id])
1340
+ # Resolve module-level parameter collections after local payload/row factories
1341
+ # are known (for example CASE_ROWS = [_post_case(...) , ...]).
1342
+ for top in tree.body:
1343
+ if not isinstance(top,ast.Assign): continue
1344
+ names=[target.id for target in top.targets if isinstance(target,ast.Name)]
1345
+ resolved=[]
1346
+ for node in ast.walk(top.value):
1347
+ if isinstance(node,ast.Call) and isinstance(node.func,ast.Name) and node.func.id in FUNCTION_PAYLOADS:
1348
+ resolved.extend(FUNCTION_PAYLOADS[node.func.id])
1349
+ for name in names:
1350
+ if resolved: GLOBAL_PAYLOADS[name]=list(GLOBAL_PAYLOADS.get(name,[]))+resolved
1351
+
1352
+ def literal(node):
1353
+ if isinstance(node, ast.Constant) and isinstance(node.value, (str,int,float,bool,type(None))):
1354
+ return repr(node.value)
1355
+ return None
1356
+
1357
+ def collect_dict(node, prefix, paths, values):
1358
+ if not isinstance(node, ast.Dict): return
1359
+ for key,value in zip(node.keys,node.values):
1360
+ if not isinstance(key, ast.Constant) or not isinstance(key.value,str): continue
1361
+ # Ignore writer helper/meta keys that are not DTO request fields.
1362
+ if key.value in {'expect','expected','expected_status','expected_code','echo_title','echo_content','echo_status'} or key.value.startswith('echo_') or key.value.startswith('expected_'):
1363
+ continue
1364
+ current = key.value if not prefix else prefix + '.' + key.value
1365
+ paths.add(current)
1366
+ lv=literal(value)
1367
+ if lv is not None: values.setdefault(current,set()).add(lv)
1368
+ if isinstance(value, ast.Dict): collect_dict(value,current,paths,values)
1369
+ elif isinstance(value,(ast.List,ast.Tuple)):
1370
+ for item in value.elts:
1371
+ if isinstance(item,ast.Dict): collect_dict(item,current,paths,values)
1372
+
1373
+ PAYLOAD_NAMES={'payload','body','request_body','request_json','dto','json','data','json_body'}
1374
+
1375
+ def shape_only(node):
1376
+ if isinstance(node,ast.Dict):
1377
+ values=[]
1378
+ for value in node.values:
1379
+ if isinstance(value,(ast.Dict,ast.List,ast.Tuple)): values.append(shape_only(value))
1380
+ else: values.append(ast.Name(id='_PAYLOAD_VALUE_',ctx=ast.Load()))
1381
+ return ast.Dict(keys=node.keys,values=values)
1382
+ if isinstance(node,(ast.List,ast.Tuple)):
1383
+ items=[shape_only(item) if isinstance(item,(ast.Dict,ast.List,ast.Tuple)) else ast.Name(id='_PAYLOAD_VALUE_',ctx=ast.Load()) for item in node.elts]
1384
+ return ast.List(elts=items,ctx=ast.Load()) if isinstance(node,ast.List) else ast.Tuple(elts=items,ctx=ast.Load())
1385
+ return node
1386
+
1387
+ def payload_expr_nodes(expr, assigned, parameters, seen=None):
1388
+ seen=set() if seen is None else seen
1389
+ if isinstance(expr,ast.Dict): return [expr]
1390
+ if isinstance(expr,ast.Name):
1391
+ if expr.id in seen: return []
1392
+ seen.add(expr.id)
1393
+ if expr.id in assigned: return payload_expr_nodes(assigned[expr.id],assigned,parameters,seen)
1394
+ if expr.id in parameters:
1395
+ result=[]
1396
+ for value in parameters[expr.id]: result.extend(payload_expr_nodes(value,assigned,parameters,set(seen)))
1397
+ return result
1398
+ return list(GLOBAL_PAYLOADS.get(expr.id,[]))
1399
+ if isinstance(expr,ast.Subscript) and isinstance(expr.value,ast.Name) and isinstance(expr.slice,ast.Constant) and isinstance(expr.slice.value,str):
1400
+ result=[]
1401
+ for candidate in parameters.get(expr.value.id,[]):
1402
+ if not isinstance(candidate,ast.Dict): continue
1403
+ for key,value in zip(candidate.keys,candidate.values):
1404
+ if isinstance(key,ast.Constant) and key.value == expr.slice.value:
1405
+ result.extend(shape_only(item) for item in payload_expr_nodes(value,assigned,parameters,set(seen)))
1406
+ return result
1407
+ if isinstance(expr,ast.Call):
1408
+ result=[]
1409
+ if isinstance(expr.func,ast.Name) and expr.func.id in FUNCTION_PAYLOADS:
1410
+ result.extend(FUNCTION_PAYLOADS[expr.func.id])
1411
+ override_keys=[]; override_values=[]
1412
+ for keyword in expr.keywords:
1413
+ if keyword.arg:
1414
+ override_keys.append(ast.Constant(keyword.arg)); override_values.append(keyword.value)
1415
+ if override_keys: result.append(ast.Dict(keys=override_keys,values=override_values))
1416
+ # A transport body may be assembled by a local combinator such as
1417
+ # _apply_patch(_base_body(...), patch). Because this traversal starts
1418
+ # only from an identified transport-body position, recursively merging
1419
+ # argument shapes is narrower than scanning arbitrary helper calls.
1420
+ for argument in expr.args:
1421
+ result.extend(payload_expr_nodes(argument,assigned,parameters,set(seen)))
1422
+ for keyword in expr.keywords:
1423
+ result.extend(payload_expr_nodes(keyword.value,assigned,parameters,set(seen)))
1424
+ return result
1425
+ return []
1426
+
1427
+ def decorator_parameter_values(dec):
1428
+ result={}
1429
+ if not isinstance(dec,ast.Call) or not isinstance(dec.func,ast.Attribute) or dec.func.attr != 'parametrize' or len(dec.args) < 2: return result
1430
+ if not isinstance(dec.args[0],ast.Constant) or not isinstance(dec.args[0].value,str): return result
1431
+ names=[item.strip() for item in dec.args[0].value.split(',')]
1432
+ rows=dec.args[1]
1433
+ if isinstance(rows,ast.Name) and rows.id in GLOBAL_VALUES: rows=GLOBAL_VALUES[rows.id]
1434
+ for node in ast.walk(rows):
1435
+ if not isinstance(node,ast.Call) or not isinstance(node.func,ast.Attribute) or node.func.attr != 'param': continue
1436
+ for index,name in enumerate(names):
1437
+ if index < len(node.args): result.setdefault(name,[]).append(node.args[index])
1438
+ return result
1439
+
1440
+ def decorator_payload_nodes(parameters,assigned):
1441
+ result=[]
1442
+ for name,values in parameters.items():
1443
+ if name.lower() not in PAYLOAD_NAMES: continue
1444
+ for value in values: result.extend(payload_expr_nodes(value,assigned,parameters))
1445
+ return result
1446
+
1447
+ def fallback_payload_nodes(fn):
1448
+ result=[]
1449
+ assigned={}
1450
+ for node in ast.walk(fn):
1451
+ if isinstance(node,(ast.Assign,ast.AnnAssign)):
1452
+ if node.value is None: continue
1453
+ targets=node.targets if isinstance(node,ast.Assign) else [node.target]
1454
+ for target in targets:
1455
+ if isinstance(target,ast.Name): assigned[target.id]=node.value
1456
+ parameters={}
1457
+ for dec in fn.decorator_list:
1458
+ for name,values in decorator_parameter_values(dec).items(): parameters.setdefault(name,[]).extend(values)
1459
+ for node in ast.walk(fn):
1460
+ if not isinstance(node,ast.Call): continue
1461
+ callee=None
1462
+ if isinstance(node.func,ast.Name): callee=node.func.id
1463
+ elif isinstance(node.func,ast.Attribute): callee=node.func.attr
1464
+ result.extend(FUNCTION_TRANSPORT_SHAPES.get(callee,[]))
1465
+ # Wrapper payloads are fallback evidence: they are useful when the Case
1466
+ # contract requires a body, but must not contaminate GET/DELETE targets
1467
+ # merely because setup creates a resource first.
1468
+ for parameter_name,position in TRANSPORT_PAYLOAD_PARAMS.get(callee,[]):
1469
+ matched=next((keyword.value for keyword in node.keywords if keyword.arg == parameter_name),None)
1470
+ if matched is None and position >= 0 and position < len(node.args): matched=node.args[position]
1471
+ if matched is not None: result.extend(payload_expr_nodes(matched,assigned,parameters))
1472
+ return result
1473
+
1474
+ def is_direct_transport_call(node):
1475
+ if not isinstance(node,ast.Call) or not isinstance(node.func,ast.Attribute): return False
1476
+ if node.func.attr.lower() not in {'get','post','put','patch','delete','request'}: return False
1477
+ receiver_tokens=[]
1478
+ current=node.func.value
1479
+ while isinstance(current,ast.Attribute):
1480
+ receiver_tokens.append(current.attr.lower())
1481
+ current=current.value
1482
+ if isinstance(current,ast.Name): receiver_tokens.append(current.id.lower())
1483
+ return any(token in {'requests','httpx','client','session','api_client','http_client'} or token.endswith('_client') for token in receiver_tokens)
1484
+
1485
+ def payload_nodes(fn):
1486
+ result=[]
1487
+ assigned={}
1488
+ for node in ast.walk(fn):
1489
+ if isinstance(node,(ast.Assign,ast.AnnAssign)):
1490
+ if node.value is None: continue
1491
+ targets=node.targets if isinstance(node,ast.Assign) else [node.target]
1492
+ for target in targets:
1493
+ if isinstance(target,ast.Name): assigned[target.id]=node.value
1494
+ parameters={}
1495
+ for dec in fn.decorator_list:
1496
+ for name,values in decorator_parameter_values(dec).items(): parameters.setdefault(name,[]).extend(values)
1497
+ result.extend(decorator_payload_nodes(parameters,assigned))
1498
+ for dec in fn.decorator_list:
1499
+ for node in ast.walk(dec):
1500
+ if isinstance(node,ast.Call) and isinstance(node.func,ast.Name) and node.func.id in FUNCTION_PAYLOADS:
1501
+ result.extend(payload_expr_nodes(node,assigned,parameters))
1502
+ elif isinstance(node,ast.Name) and node.id in GLOBAL_PAYLOADS:
1503
+ result.extend(GLOBAL_PAYLOADS[node.id])
1504
+ # Observe local payload/body variables only when they flow into a direct
1505
+ # HTTP transport call. Arbitrary setup variables are not target requests.
1506
+ for node in ast.walk(fn):
1507
+ if not is_direct_transport_call(node): continue
1508
+ for keyword in node.keywords:
1509
+ if keyword.arg in PAYLOAD_NAMES: result.extend(payload_expr_nodes(keyword.value,assigned,parameters))
1510
+ return result
1511
+
1512
+ out={}
1513
+ for fn in ast.walk(tree):
1514
+ if isinstance(fn,(ast.FunctionDef,ast.AsyncFunctionDef)) and fn.name.startswith('test_'):
1515
+ paths=set(); values={}
1516
+ fallback_paths=set(); fallback_values={}
1517
+ for node in payload_nodes(fn): collect_dict(node,'',paths,values)
1518
+ for node in fallback_payload_nodes(fn): collect_dict(node,'',fallback_paths,fallback_values)
1519
+ out[fn.name]={
1520
+ 'paths':sorted(paths),
1521
+ 'values':{key:sorted(items) for key,items in sorted(values.items())},
1522
+ 'fallbackPaths':sorted(fallback_paths),
1523
+ 'fallbackValues':{key:sorted(items) for key,items in sorted(fallback_values.items())},
1524
+ }
1525
+ print(json.dumps(out,ensure_ascii=True))
1526
+ `;
1527
+ // Pass bytes and decode stdin explicitly as UTF-8 in Python. Clean-env
1528
+ // Windows Python may expose stdin as GBK; decoding generated Chinese/emoji
1529
+ // source through that locale can create surrogateescape code points and make
1530
+ // ast.parse fail with "surrogates not allowed".
1531
+ // POSIX images usually expose only `python3` (modern macOS has no `python`);
1532
+ // Windows often has a real `python` plus a Microsoft Store `python3` stub.
1533
+ // A missing interpreter must not silently degrade to empty payload shapes.
1534
+ const interpreterCandidates = process.platform === "win32" ? ["python", "python3"] : ["python3", "python"];
1535
+ let result;
1536
+ for (const interpreter of interpreterCandidates) {
1537
+ const attempt = spawnSync(interpreter, ["-c", program], {
1538
+ input: Buffer.from(source, "utf8"),
1539
+ encoding: "utf8",
1540
+ maxBuffer: 4 * 1024 * 1024,
1541
+ windowsHide: true,
1542
+ });
1543
+ // Interpreter missing/unspawnable → try the next candidate. A spawned
1544
+ // interpreter that exits nonzero analyzed real input; do not retry then.
1545
+ if (attempt.error)
1546
+ continue;
1547
+ result = attempt;
1548
+ break;
1549
+ }
1550
+ if (!result || result.status !== 0 || !result.stdout.trim())
1551
+ return new Map();
1552
+ try {
1553
+ const parsed = JSON.parse(result.stdout);
1554
+ return new Map(Object.entries(parsed));
1555
+ }
1556
+ catch {
1557
+ return new Map();
1558
+ }
1559
+ }
1560
+ function metadataCaseIds(region) {
1561
+ return orderedUnique([...region.matchAll(/^\s*Case-ID\s*:\s*(BE-[A-Z0-9]+(?:-[A-Z0-9]+)*)\s*$/gmi)]
1562
+ .map((match) => canonicalCaseId(match[1])));
1563
+ }
912
1564
  function metadataTestPoints(region, label) {
913
1565
  const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
914
1566
  const match = new RegExp(`^\\s*${escaped}\\s*:\\s*(.*)$`, "mi").exec(region);
@@ -928,10 +1580,35 @@ function pytestFunctionRegionStart(source, functionIndex) {
928
1580
  }
929
1581
  return regionStart;
930
1582
  }
1583
+ function pytestFunctionBodyEnd(source, signatureEnd, functionIndent) {
1584
+ let cursor = source.indexOf("\n", signatureEnd);
1585
+ if (cursor < 0)
1586
+ return source.length;
1587
+ cursor += 1;
1588
+ const baseIndent = functionIndent.replaceAll("\t", " ").length;
1589
+ while (cursor < source.length) {
1590
+ const nextLine = source.indexOf("\n", cursor);
1591
+ const lineEnd = nextLine < 0 ? source.length : nextLine;
1592
+ const line = source.slice(cursor, lineEnd).replace(/\r$/, "");
1593
+ if (line.trim()) {
1594
+ const indentation = line.match(/^[ \t]*/)?.[0]?.replaceAll("\t", " ").length ?? 0;
1595
+ if (indentation <= baseIndent)
1596
+ return cursor;
1597
+ }
1598
+ if (nextLine < 0)
1599
+ return source.length;
1600
+ cursor = nextLine + 1;
1601
+ }
1602
+ return source.length;
1603
+ }
931
1604
  function pytestParameterCollections(source) {
932
1605
  const collections = new Map();
933
- for (const match of source.matchAll(/^([A-Z][A-Z0-9_]*)\s*=\s*\[([\s\S]*?)^\]/gm)) {
934
- collections.set(match[1], orderedUnique([...match[2].matchAll(/\bid\s*=\s*["'](TP-[A-Z0-9-]+)["']/g)].map((item) => item[1])));
1606
+ for (const match of source.matchAll(/^(_?[A-Z][A-Z0-9_]*)\s*=\s*\[(?![^\r\n]*\])([\s\S]*?)^\]/gm)) {
1607
+ const explicitIds = [...match[2].matchAll(/\bid\s*=\s*["'](TP-[A-Z0-9-]+)["']/g)].map((item) => item[1]);
1608
+ const factoryIds = explicitIds.length === 0
1609
+ ? [...match[2].matchAll(/["'](TP-[A-Z0-9-]+)["']/g)].map((item) => item[1])
1610
+ : [];
1611
+ collections.set(match[1], orderedUnique([...explicitIds, ...factoryIds]));
935
1612
  }
936
1613
  return collections;
937
1614
  }
@@ -939,21 +1616,29 @@ function pytestSymbols(script, source) {
939
1616
  const matches = [...source.matchAll(/^([ \t]*)(?:async\s+)?def\s+(test_[A-Za-z0-9_]+)\s*\([^)]*\)\s*(?:->\s*[^:\r\n]+)?\s*:/gm)];
940
1617
  const regionStarts = matches.map((match) => pytestFunctionRegionStart(source, match.index));
941
1618
  const parameterCollections = pytestParameterCollections(source);
1619
+ const payloadShapes = pythonPayloadShapes(source);
942
1620
  return matches.map((match, index) => {
943
1621
  const regionStart = regionStarts[index];
944
- const regionEnd = regionStarts[index + 1] ?? source.length;
1622
+ const regionEnd = pytestFunctionBodyEnd(source, match.index + match[0].length, match[1]);
945
1623
  const region = source.slice(match.index, regionEnd);
946
1624
  const decorators = source.slice(regionStart, match.index);
947
- const ids = caseIds(region);
1625
+ const ids = metadataCaseIds(region);
948
1626
  const fromSymbol = symbolCaseId(match[2]);
949
1627
  if (fromSymbol)
950
1628
  ids.unshift(fromSymbol);
1629
+ const explicitParameterIds = [...decorators.matchAll(/\bid\s*=\s*["'](TP-[A-Z0-9-]+)["']/g)].map((item) => item[1]);
1630
+ const collectionParameterIds = [...parameterCollections.entries()].flatMap(([name, values]) => new RegExp(`\\b${name}\\b`).test(decorators) ? values : []);
1631
+ const factoryParameterIds = explicitParameterIds.length === 0 && collectionParameterIds.length === 0
1632
+ ? [...decorators.matchAll(/["'](TP-[A-Z0-9-]+)["']/g)].map((item) => item[1])
1633
+ : [];
951
1634
  const parameterIds = orderedUnique([
952
- ...[...decorators.matchAll(/\bid\s*=\s*["'](TP-[A-Z0-9-]+)["']/g)].map((item) => item[1]),
953
- ...[...parameterCollections.entries()].flatMap(([name, values]) => new RegExp(`\\b${name}\\b`).test(decorators) ? values : []),
1635
+ ...explicitParameterIds,
1636
+ ...collectionParameterIds,
1637
+ ...factoryParameterIds,
954
1638
  ]);
955
1639
  const assertionTestPoints = metadataTestPoints(region, "Assertion-Test-Points");
956
1640
  const crossCuttingTestPoints = metadataTestPoints(region, "Cross-Cutting-Test-Points");
1641
+ const payloadShape = payloadShapes.get(match[2]) ?? { paths: [], values: {} };
957
1642
  return {
958
1643
  script,
959
1644
  symbol: match[2],
@@ -962,13 +1647,69 @@ function pytestSymbols(script, source) {
962
1647
  parameterIds,
963
1648
  assertionTestPoints,
964
1649
  crossCuttingTestPoints,
1650
+ payloadPaths: payloadShape.paths,
1651
+ payloadValues: payloadShape.values,
1652
+ fallbackPayloadPaths: payloadShape.fallbackPaths ?? [],
1653
+ fallbackPayloadValues: payloadShape.fallbackValues ?? {},
965
1654
  };
966
1655
  });
967
1656
  }
1657
+ function executionSignature(testCase) {
1658
+ const operation = (testCase.body.match(/\b(?:GET|POST|PUT|PATCH|DELETE)\s+\/[^\s`|)]+/i)?.[0] ?? "operation-unavailable").toUpperCase();
1659
+ const inputPartition = [sectionBody(testCase.body, ["测试数据", "Test Data"]), testCase.scenarioTypes.join(" "), testCase.testPoints.join(" ")].join(" ");
1660
+ const precondition = sectionBody(testCase.body, ["前置条件", "Preconditions", "Precondition"]);
1661
+ const expectedOutcome = sectionBody(testCase.body, ["预期结果", "Expected Results", "Expected Result"]);
1662
+ const normalized = [operation, inputPartition, precondition, expectedOutcome]
1663
+ .map((value) => value.replaceAll(/\s+/g, " ").trim().toLowerCase())
1664
+ .join("\n");
1665
+ return createHash("sha256").update(normalized).digest("hex");
1666
+ }
1667
+ function assessPayloadContract(testCase, refs) {
1668
+ const requiredPaths = testCase.payloadContract.requiredPaths;
1669
+ const directObservedPaths = orderedUnique(refs.flatMap((item) => item.payloadPaths));
1670
+ const useFallback = requiredPaths.length > 0 && directObservedPaths.length === 0;
1671
+ const analyzedObservedPaths = useFallback
1672
+ ? orderedUnique(refs.flatMap((item) => item.fallbackPayloadPaths))
1673
+ : directObservedPaths;
1674
+ // A no-body target contract describes the target GET/DELETE request, not
1675
+ // setup POST/PUT calls in the same lifecycle test function.
1676
+ const observedPaths = testCase.payloadContract.noPayload ? [] : analyzedObservedPaths;
1677
+ const allowedPaths = testCase.payloadContract.allowedPaths;
1678
+ if (!testCase.payloadContract.declared) {
1679
+ return { status: "UNAVAILABLE", requiredPaths, allowedPaths, observedPaths, missingPaths: [], unexpectedPaths: [], enumMismatches: [] };
1680
+ }
1681
+ const missingPaths = requiredPaths.filter((item) => !observedPaths.includes(item));
1682
+ const unexpectedPaths = testCase.payloadContract.noPayload
1683
+ ? observedPaths
1684
+ : allowedPaths.length > 0
1685
+ ? observedPaths.filter((item) => !allowedPaths.includes(item) && !allowedPaths.some((allowed) => item.startsWith(`${allowed}.`)))
1686
+ : [];
1687
+ const enumMismatches = [];
1688
+ const delegatesInvalidEnumToScenarioGate = testCase.testPointBindings
1689
+ .filter((binding) => binding.mode === "variant")
1690
+ .some((binding) => /(?:ENUM|CATEGORY).*(?:INVALID|UNKNOWN|CASE|WHITESPACE|EMPTY|WRONG-TYPE)|(?:INVALID|UNKNOWN|CASE|WHITESPACE|EMPTY|WRONG-TYPE).*(?:ENUM|CATEGORY)/.test(binding.testPoint));
1691
+ for (const [field, allowed] of Object.entries(testCase.payloadContract.enumValues)) {
1692
+ const observed = orderedUnique(refs.flatMap((item) => (useFallback ? item.fallbackPayloadValues[field] : item.payloadValues[field]) ?? []).map((value) => value.replace(/^['"]|['"]$/g, "")));
1693
+ for (const value of observed) {
1694
+ if (!allowed.includes(value) && !delegatesInvalidEnumToScenarioGate)
1695
+ enumMismatches.push(`${field}=${value}; allowed=${allowed.join("|")}`);
1696
+ }
1697
+ }
1698
+ return {
1699
+ status: missingPaths.length > 0 || unexpectedPaths.length > 0 || enumMismatches.length > 0 ? "UNSAFE" : "SAFE",
1700
+ requiredPaths,
1701
+ allowedPaths,
1702
+ observedPaths,
1703
+ missingPaths,
1704
+ unexpectedPaths,
1705
+ enumMismatches,
1706
+ };
1707
+ }
968
1708
  export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
969
- const cases = await parseMarkdownCases(input.workspaceRoot);
1709
+ const layout = input.layout ?? resolveBackendTestLayout(undefined);
1710
+ const cases = await parseMarkdownCases(input.workspaceRoot, layout);
970
1711
  const modulePaths = orderedUnique(cases.map((item) => item.markdownPath));
971
- const scripts = orderedUnique(cases.flatMap((item) => [...item.declaredScripts, expectedScript(item.markdownPath)]));
1712
+ const scripts = orderedUnique(cases.flatMap((item) => [...item.declaredScripts, expectedScript(item.markdownPath, layout)]));
972
1713
  const allSymbols = [];
973
1714
  const scriptFiles = [];
974
1715
  for (const script of scripts) {
@@ -981,7 +1722,7 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
981
1722
  const findings = [];
982
1723
  const entries = [];
983
1724
  for (const testCase of cases) {
984
- const expected = expectedScript(testCase.markdownPath);
1725
+ const expected = expectedScript(testCase.markdownPath, layout);
985
1726
  const declared = testCase.declaredScripts[0];
986
1727
  const refs = allSymbols.filter((item) => item.caseIds.includes(testCase.caseId));
987
1728
  const actualScripts = orderedUnique(refs.map((item) => item.script));
@@ -998,21 +1739,44 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
998
1739
  ...crossCuttingTestPoints.filter((point) => crossCuttingBindings.includes(point)),
999
1740
  ]);
1000
1741
  const declaredPrimarySymbol = testCase.declaredPrimarySymbols[0];
1742
+ const evidenceOnly = isEvidenceOnlyMarkdownCase(testCase);
1001
1743
  const cardinality = refs.length === 0 ? "1:0" : refs.length === 1 ? "1:1" : "1:N";
1002
- let status = refs.length === 0 ? "MISSING_PYTEST" : refs.length > 1 ? "MULTIPLE_PYTEST" : "EXACT_1_TO_1";
1744
+ let status = evidenceOnly && refs.length === 0
1745
+ ? "EXACT_1_TO_1"
1746
+ : refs.length === 0
1747
+ ? "MISSING_PYTEST"
1748
+ : refs.length > 1
1749
+ ? "MULTIPLE_PYTEST"
1750
+ : "EXACT_1_TO_1";
1003
1751
  const entryFindings = [];
1004
- if (refs.length === 0)
1005
- entryFindings.push(`${testCase.caseId} has no pytest symbol`);
1006
- if (refs.length > 1)
1007
- entryFindings.push(`${testCase.caseId} maps to multiple pytest symbols: ${symbols.join(", ")}`);
1008
- if (!declared || declared !== expected || actualScripts.some((script) => script !== expected) || !declaredPrimarySymbol || symbols.some((symbol) => symbol !== declaredPrimarySymbol)) {
1009
- if (refs.length > 0 && status === "EXACT_1_TO_1")
1752
+ const payloadAssessment = assessPayloadContract(testCase, refs);
1753
+ const signature = executionSignature(testCase);
1754
+ if (evidenceOnly) {
1755
+ if (refs.length > 0) {
1010
1756
  status = "SCRIPT_MISMATCH";
1011
- entryFindings.push(`${testCase.caseId} script/primary-symbol mapping mismatch: declaredScript=${declared ?? "none"}, expectedScript=${expected}, actual=${actualScripts.join(", ") || "none"}, declaredPrimarySymbol=${declaredPrimarySymbol ?? "none"}, actualSymbols=${symbols.join(", ") || "none"}`);
1757
+ entryFindings.push(`${testCase.caseId} evidence-only Markdown Case must not materialize a business pytest symbol; actualSymbols=${symbols.join(", ") || "none"}`);
1758
+ }
1759
+ }
1760
+ else {
1761
+ if (refs.length === 0)
1762
+ entryFindings.push(`${testCase.caseId} has no pytest symbol`);
1763
+ if (refs.length > 1)
1764
+ entryFindings.push(`${testCase.caseId} maps to multiple pytest symbols: ${symbols.join(", ")}`);
1765
+ if (!declared || declared !== expected || actualScripts.some((script) => script !== expected) || !declaredPrimarySymbol || symbols.some((symbol) => symbol !== declaredPrimarySymbol)) {
1766
+ if (refs.length > 0 && status === "EXACT_1_TO_1")
1767
+ status = "SCRIPT_MISMATCH";
1768
+ entryFindings.push(`${testCase.caseId} script/primary-symbol mapping mismatch: declaredScript=${declared ?? "none"}, expectedScript=${expected}, actual=${actualScripts.join(", ") || "none"}, declaredPrimarySymbol=${declaredPrimarySymbol ?? "none"}, actualSymbols=${symbols.join(", ") || "none"}`);
1769
+ }
1012
1770
  }
1013
- const missingVariant = variantTestPoints.filter((point) => !parameterIds.includes(point));
1014
- const missingAssertion = assertionTestPoints.filter((point) => !assertionBindings.includes(point));
1015
- const missingCrossCutting = crossCuttingTestPoints.filter((point) => !crossCuttingBindings.includes(point));
1771
+ const missingVariant = evidenceOnly
1772
+ ? []
1773
+ : variantTestPoints.filter((point) => !parameterIds.includes(point));
1774
+ const missingAssertion = evidenceOnly
1775
+ ? []
1776
+ : assertionTestPoints.filter((point) => !assertionBindings.includes(point));
1777
+ const missingCrossCutting = evidenceOnly
1778
+ ? []
1779
+ : crossCuttingTestPoints.filter((point) => !crossCuttingBindings.includes(point));
1016
1780
  const extraPoints = orderedUnique([...parameterIds, ...assertionBindings, ...crossCuttingBindings]).filter((point) => !testCase.testPoints.includes(point));
1017
1781
  if (testCase.unclassifiedTestPoints.length > 0 || testCase.duplicateBindingTestPoints.length > 0) {
1018
1782
  if (refs.length > 0 && status === "EXACT_1_TO_1")
@@ -1039,6 +1803,16 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
1039
1803
  status = "TEST_POINT_BINDING_EXTRA";
1040
1804
  entryFindings.push(`${testCase.caseId} has extra pytest Test Point bindings: ${extraPoints.join(", ")}`);
1041
1805
  }
1806
+ if (payloadAssessment.status === "UNAVAILABLE") {
1807
+ if (refs.length > 0 && status === "EXACT_1_TO_1")
1808
+ status = "PAYLOAD_CONTRACT_UNAVAILABLE";
1809
+ entryFindings.push(`${testCase.caseId} has no deterministic Payload Contract declaration`);
1810
+ }
1811
+ else if (payloadAssessment.status === "UNSAFE") {
1812
+ if (refs.length > 0 && status === "EXACT_1_TO_1")
1813
+ status = "PAYLOAD_SHAPE_MISMATCH";
1814
+ entryFindings.push(`${testCase.caseId} payload contract mismatch: missing=${payloadAssessment.missingPaths.join(", ") || "none"}; unexpected=${payloadAssessment.unexpectedPaths.join(", ") || "none"}; enum=${payloadAssessment.enumMismatches.join(", ") || "none"}`);
1815
+ }
1042
1816
  findings.push(...entryFindings);
1043
1817
  entries.push({
1044
1818
  markdownModule: testCase.markdownPath,
@@ -1056,6 +1830,8 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
1056
1830
  parameterIds,
1057
1831
  cardinality,
1058
1832
  status,
1833
+ payloadAssessment,
1834
+ executionSignature: signature,
1059
1835
  findings: entryFindings,
1060
1836
  });
1061
1837
  }
@@ -1082,23 +1858,41 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
1082
1858
  parameterIds: symbol.parameterIds,
1083
1859
  cardinality: "0:1",
1084
1860
  status: unknownId ? "EXTRA_PYTEST" : "SYMBOL_MISSING_CASE_ID",
1861
+ payloadAssessment: {
1862
+ status: "UNAVAILABLE",
1863
+ requiredPaths: [],
1864
+ allowedPaths: [],
1865
+ observedPaths: symbol.payloadPaths,
1866
+ missingPaths: [],
1867
+ unexpectedPaths: [],
1868
+ enumMismatches: [],
1869
+ },
1085
1870
  findings: [message],
1086
1871
  });
1087
1872
  }
1873
+ const duplicateSignatures = new Map();
1874
+ for (const entry of entries.filter((item) => item.caseId && item.executionSignature)) {
1875
+ const owners = duplicateSignatures.get(entry.executionSignature) ?? [];
1876
+ owners.push(entry.caseId);
1877
+ duplicateSignatures.set(entry.executionSignature, owners);
1878
+ }
1879
+ const duplicateSignatureGroups = [...duplicateSignatures.entries()].filter(([, owners]) => orderedUnique(owners).length > 1);
1880
+ for (const [signature, owners] of duplicateSignatureGroups)
1881
+ findings.push(`duplicate execution signature ${signature}: ${orderedUnique(owners).join(", ")}`);
1088
1882
  const exactModuleCount = modulePaths.filter((modulePath) => {
1089
- const expected = expectedScript(modulePath);
1883
+ const expected = expectedScript(modulePath, layout);
1090
1884
  const moduleCases = cases.filter((item) => item.markdownPath === modulePath);
1091
1885
  return moduleCases.every((item) => item.declaredScripts.length === 1 && item.declaredScripts[0] === expected) && scriptFiles.some((file) => path.relative(input.workspaceRoot, file).replaceAll(path.sep, "/") === expected);
1092
1886
  }).length;
1093
1887
  const caseEntries = entries.filter((entry) => entry.cardinality !== "0:1");
1094
1888
  const facts = backendTestMarkdownPytestCorrespondenceFactsSchema.parse({
1095
- schemaId: "backend-test-markdown-pytest-correspondence-facts-v2",
1096
- schemaVersion: 2,
1889
+ schemaId: "backend-test-markdown-pytest-correspondence-facts-v3",
1890
+ schemaVersion: 3,
1097
1891
  taskId: input.taskId,
1098
1892
  status: findings.length === 0 ? "PASS" : "FAIL",
1099
1893
  inputFiles: await inputFileFacts(input.workspaceRoot, [
1100
- path.join(input.workspaceRoot, "testcase", "md", "README.md"),
1101
- ...(await markdownModuleFiles(input.workspaceRoot)),
1894
+ path.join(input.workspaceRoot, ...layout.readmePath.split("/")),
1895
+ ...(await markdownModuleFiles(input.workspaceRoot, layout)),
1102
1896
  ...scriptFiles,
1103
1897
  ].filter((file) => file && path.isAbsolute(file))),
1104
1898
  summary: {
@@ -1118,6 +1912,10 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
1118
1912
  crossCuttingTestPointCount: cases.reduce((sum, item) => sum + item.testPointBindings.filter((binding) => binding.mode === "cross-cutting").length, 0),
1119
1913
  unclassifiedTestPointCount: cases.reduce((sum, item) => sum + item.unclassifiedTestPoints.length, 0),
1120
1914
  duplicateBindingTestPointCount: cases.reduce((sum, item) => sum + item.duplicateBindingTestPoints.length, 0),
1915
+ payloadSafeCount: caseEntries.filter((entry) => entry.payloadAssessment.status === "SAFE").length,
1916
+ payloadUnsafeCount: caseEntries.filter((entry) => entry.payloadAssessment.status === "UNSAFE").length,
1917
+ payloadUnavailableCount: caseEntries.filter((entry) => entry.payloadAssessment.status === "UNAVAILABLE").length,
1918
+ duplicateExecutionSignatureCount: duplicateSignatureGroups.length,
1121
1919
  },
1122
1920
  entries,
1123
1921
  findings: orderedUnique(findings),
@@ -1145,12 +1943,16 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
1145
1943
  `- Cross-Cutting Test Points: ${facts.summary.crossCuttingTestPointCount}`,
1146
1944
  `- Unclassified Test Points: ${facts.summary.unclassifiedTestPointCount}`,
1147
1945
  `- Duplicate Test Point Bindings: ${facts.summary.duplicateBindingTestPointCount}`,
1946
+ `- Payload SAFE: ${facts.summary.payloadSafeCount}`,
1947
+ `- Payload UNSAFE: ${facts.summary.payloadUnsafeCount}`,
1948
+ `- Payload UNAVAILABLE: ${facts.summary.payloadUnavailableCount}`,
1949
+ `- Duplicate Execution Signatures: ${facts.summary.duplicateExecutionSignatureCount}`,
1148
1950
  "",
1149
1951
  "## Correspondence Matrix",
1150
1952
  "",
1151
- "| Markdown Module | Case ID | Variant | Assertion | Cross-Cutting | Mapped | Declared Script | Primary Symbol | Actual Script | Pytest Symbol | Parameter IDs | Cardinality | Status |",
1152
- "|---|---|---|---|---|---|---|---|---|---|---|---|---|",
1153
- ...facts.entries.map((entry) => `| ${entry.markdownModule} | ${entry.caseId ?? "—"} | ${entry.variantTestPoints.join(", ") || "—"} | ${entry.assertionTestPoints.join(", ") || "—"} | ${entry.crossCuttingTestPoints.join(", ") || "—"} | ${entry.mappedTestPoints.join(", ") || "—"} | ${entry.declaredScript ?? "—"} | ${entry.declaredPrimarySymbol ?? "—"} | ${entry.actualScripts.join(", ") || "—"} | ${entry.pytestSymbols.join(", ") || "—"} | ${entry.parameterIds.join(", ") || "—"} | ${entry.cardinality} | ${entry.status} |`),
1953
+ "| Markdown Module | Case ID | Variant | Assertion | Cross-Cutting | Mapped | Declared Script | Primary Symbol | Actual Script | Pytest Symbol | Parameter IDs | Payload | Missing Paths | Unexpected Paths | Enum Mismatch | Cardinality | Status |",
1954
+ "|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|",
1955
+ ...facts.entries.map((entry) => `| ${entry.markdownModule} | ${entry.caseId ?? "—"} | ${entry.variantTestPoints.join(", ") || "—"} | ${entry.assertionTestPoints.join(", ") || "—"} | ${entry.crossCuttingTestPoints.join(", ") || "—"} | ${entry.mappedTestPoints.join(", ") || "—"} | ${entry.declaredScript ?? "—"} | ${entry.declaredPrimarySymbol ?? "—"} | ${entry.actualScripts.join(", ") || "—"} | ${entry.pytestSymbols.join(", ") || "—"} | ${entry.parameterIds.join(", ") || "—"} | ${entry.payloadAssessment.status} | ${entry.payloadAssessment.missingPaths.join(", ") || "—"} | ${entry.payloadAssessment.unexpectedPaths.join(", ") || "—"} | ${entry.payloadAssessment.enumMismatches.join(", ") || "—"} | ${entry.cardinality} | ${entry.status} |`),
1154
1956
  "",
1155
1957
  "## Findings",
1156
1958
  "",