@tea-agent/loop-agent 0.37.0 → 0.37.1-beta.0

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 (192) hide show
  1. package/AGENTS.md +2 -6
  2. package/CHANGELOG.md +71 -144
  3. package/README.md +1 -1
  4. package/bin/loop-agent.js +1 -37
  5. package/dist/application/task-lifecycle/advance.js +0 -1
  6. package/dist/application/task-lifecycle/observe.js +0 -15
  7. package/dist/application/task-lifecycle/plan-transitions.js +0 -15
  8. package/dist/cli/command-definitions.js +0 -7
  9. package/dist/cli/program.js +3 -8
  10. package/dist/commands/init-upgrade.js +19 -351
  11. package/dist/commands/init.js +67 -14
  12. package/dist/commands/run-dag-progress.js +0 -14
  13. package/dist/commands/task-advance.js +3 -33
  14. package/dist/executors/dag-pi-executor.js +30 -47
  15. package/dist/executors/pi-sdk-executor.js +0 -34
  16. package/dist/executors/shell-executor.js +95 -6
  17. package/dist/shared/operator/capabilities.js +13 -200
  18. package/dist/shared/package-metadata.js +0 -42
  19. package/dist/task/contract/project.js +0 -4
  20. package/dist/task/source-prepare/completeness.js +0 -53
  21. package/dist/task/source-prepare/parse-intent.js +12 -82
  22. package/dist/task/source-prepare/prepare.js +5 -102
  23. package/dist/task/source-prepare/semantic-intake.js +23 -144
  24. package/dist/worker/console/chat/artifact-card.js +1 -8
  25. package/dist/worker/console/chat/chat-event-store.js +0 -152
  26. package/dist/worker/console/chat/human-gate-card.js +1 -9
  27. package/dist/worker/console/chat/operation-card.js +2 -71
  28. package/dist/worker/console/chat/pi-runtime.js +62 -111
  29. package/dist/worker/console/chat/routes.js +10 -43
  30. package/dist/worker/console/chat/session-store.js +1 -9
  31. package/dist/worker/console/chat/shortcuts.js +7 -9
  32. package/dist/worker/console/chat/turn-process.js +23 -204
  33. package/dist/worker/console/chat/workspace-landing.js +1 -2
  34. package/dist/worker/console/operation-runner.js +6 -155
  35. package/dist/worker/console/operator-actions.js +14 -391
  36. package/dist/worker/console/operator-user-error.js +0 -4
  37. package/dist/worker/console/recovery-cta.js +3 -50
  38. package/dist/worker/console/static/assets/index-HX1pbOyl.css +1 -0
  39. package/dist/worker/console/static/assets/index-M0BLEBfh.js +56 -0
  40. package/dist/worker/console/static/index.html +2 -2
  41. package/dist/worker/console/static-src/app/console-types.js +9 -13
  42. package/dist/worker/console/static-src/app/useOperatorActions.js +2 -4
  43. package/dist/worker/console/static-src/app/useRecoveryActions.js +56 -65
  44. package/dist/worker/console/static-src/app/useRecoveryConsole.js +1 -73
  45. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +30 -114
  46. package/dist/worker/console/static-src/operator-chat/refs.js +0 -3
  47. package/dist/worker/console/static-src/operator-chat/spatial-overlay.js +1 -2
  48. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +13 -67
  49. package/dist/worker/console/static-src/operator-chat/useChatThread.js +6 -12
  50. package/dist/worker/console/static-src/operator-chat/useComposer.js +8 -45
  51. package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +3 -7
  52. package/dist/worker/loop-agent/loop-agent-client.js +3 -17
  53. package/dist/worker/observability/read-model.js +0 -20
  54. package/dist/worker/observe/spec-evidence.js +8 -3
  55. package/dist/worker/observe/static/operator-chrome.d.ts +0 -1
  56. package/dist/worker/observe/static/operator-chrome.js +3 -10
  57. package/dist/worker/observe/static/views/dag-inspector.js +71 -6
  58. package/dist/worker/observe/static/views/dag.js +0 -12
  59. package/dist/worker/preflight.js +1 -2
  60. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +707 -37
  61. package/dist/workflows/dag/backend-test-case-manifest.js +4 -0
  62. package/dist/workflows/dag/backend-test-markdown-workflow.js +109 -7
  63. package/dist/workflows/dag/backend-test-module-stem.js +5 -0
  64. package/dist/workflows/dag/backend-test-pytest-collection.js +345 -24
  65. package/dist/workflows/dag/backend-test-scenario-param.js +890 -164
  66. package/dist/workflows/dag/backend-test-writer-completeness.js +47 -16
  67. package/dist/workflows/dag/dynamic-runtime/map.js +24 -8
  68. package/dist/workflows/dag/dynamic-runtime/shared.js +1 -9
  69. package/dist/workflows/dag/frontend-implementation-contract.js +39 -233
  70. package/dist/workflows/dag/frontend-prewrite-gate.js +61 -364
  71. package/dist/workflows/dag/frontend-repair.js +18 -219
  72. package/dist/workflows/dag/frontend-verification-trace.js +32 -47
  73. package/dist/workflows/dag/init-hybrid.js +82 -165
  74. package/dist/workflows/dag/node-execution.js +0 -89
  75. package/dist/workflows/dag/recovery-recommendation.js +0 -58
  76. package/dist/workflows/dag/runner.js +15 -333
  77. package/dist/workflows/dag/scheduler.js +3 -257
  78. package/dist/workflows/dag/types.js +9 -130
  79. package/docs/README.md +3 -3
  80. package/docs/architecture/evolution.md +67 -102
  81. package/docs/templates/backend-test-dag.json +50 -32
  82. package/docs/templates/init-managed-agents.md +2 -5
  83. package/harness.json +2 -2
  84. package/package.json +3 -9
  85. package/skills/loop-agent/SKILL.md +0 -1
  86. package/skills/loop-agent/references/command-reference.md +0 -4
  87. package/dist/build-stamp.json +0 -6
  88. package/dist/commands/dag-request-interrupt.js +0 -20
  89. package/dist/worker/console/chat/assistant-content.js +0 -121
  90. package/dist/worker/console/chat/semantic-activity.js +0 -477
  91. package/dist/worker/console/operation-run-facts.js +0 -190
  92. package/dist/worker/console/operation-wait.js +0 -355
  93. package/dist/worker/console/recovery-error-copy.js +0 -198
  94. package/dist/worker/console/static/assets/abnfDiagram-N423BO3Z-DSvF6RXQ.js +0 -1
  95. package/dist/worker/console/static/assets/arc-DuI4ogtJ.js +0 -1
  96. package/dist/worker/console/static/assets/architectureDiagram-T3A2C74G-DdBbgD4y.js +0 -36
  97. package/dist/worker/console/static/assets/blockDiagram-VBNYF7ZC-xQC2HELm.js +0 -132
  98. package/dist/worker/console/static/assets/c4Diagram-5PPSVZJV-DPMSgwIZ.js +0 -10
  99. package/dist/worker/console/static/assets/channel-DLVz9TYW.js +0 -1
  100. package/dist/worker/console/static/assets/chunk-2GRJ4B5K-WTEKTxin.js +0 -1
  101. package/dist/worker/console/static/assets/chunk-2Q5K7J3B-B6vZ0Pv_.js +0 -1
  102. package/dist/worker/console/static/assets/chunk-5RXB4S5H-BrerNAix.js +0 -231
  103. package/dist/worker/console/static/assets/chunk-5VM5RSS4-D_ch2aaI.js +0 -15
  104. package/dist/worker/console/static/assets/chunk-6Q2QTUOP-VvfacNrq.js +0 -88
  105. package/dist/worker/console/static/assets/chunk-GF5L2VYU-B-uGFIg2.js +0 -206
  106. package/dist/worker/console/static/assets/chunk-JWPE2WC7-BlysCtod.js +0 -1
  107. package/dist/worker/console/static/assets/chunk-KBJHAD2P-BjAUAfcT.js +0 -1
  108. package/dist/worker/console/static/assets/chunk-RYQCIY6F-BeZnBmb-.js +0 -1
  109. package/dist/worker/console/static/assets/chunk-XXDRQBXY-Bbfe3UJG.js +0 -1
  110. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BeAuGcO9.js +0 -1
  111. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BeAuGcO9.js +0 -1
  112. package/dist/worker/console/static/assets/cose-bilkent-JH36ORCC-BKJQnYVM.js +0 -1
  113. package/dist/worker/console/static/assets/cynefin-VYW2F7L2-B1wtI3hU.js +0 -166
  114. package/dist/worker/console/static/assets/cynefinDiagram-MW4NZA55-DCF6DBSu.js +0 -62
  115. package/dist/worker/console/static/assets/cytoscape.esm-yzknjiTM.js +0 -321
  116. package/dist/worker/console/static/assets/dagre-VZM6K2ZE-BJyNGRlP.js +0 -4
  117. package/dist/worker/console/static/assets/defaultLocale-DX6XiGOO.js +0 -1
  118. package/dist/worker/console/static/assets/diagram-7IWD3JNH-B7j1-VsZ.js +0 -30
  119. package/dist/worker/console/static/assets/diagram-B4RE2ZJO-BREJ140a.js +0 -3
  120. package/dist/worker/console/static/assets/diagram-LBJQPF4R-CDPHmHGH.js +0 -24
  121. package/dist/worker/console/static/assets/diagram-Q27KOJAE-BtZLrez1.js +0 -24
  122. package/dist/worker/console/static/assets/diagram-UB23O5K3-mzybu8oU.js +0 -41
  123. package/dist/worker/console/static/assets/ebnfDiagram-BXEA7PRR-Dk3I2uO6.js +0 -1
  124. package/dist/worker/console/static/assets/erDiagram-JOGREHBK-BT97u_tQ.js +0 -85
  125. package/dist/worker/console/static/assets/flowDiagram-UKHOOZJN-CMOtYzui.js +0 -156
  126. package/dist/worker/console/static/assets/ganttDiagram-PKOTCBZU-DiNydkuY.js +0 -292
  127. package/dist/worker/console/static/assets/gitGraphDiagram-DS77QQ5N-Cwt6fLkl.js +0 -106
  128. package/dist/worker/console/static/assets/graph-DOmOIIwC.js +0 -1
  129. package/dist/worker/console/static/assets/index-DQxiso1w.js +0 -325
  130. package/dist/worker/console/static/assets/index-_j0Hzo69.css +0 -1
  131. package/dist/worker/console/static/assets/infoDiagram-6WML65LV-AsGIGKKB.js +0 -2
  132. package/dist/worker/console/static/assets/init-Gi6I4Gst.js +0 -1
  133. package/dist/worker/console/static/assets/ishikawaDiagram-WSZJBQD7-BL8ITZh3.js +0 -70
  134. package/dist/worker/console/static/assets/journeyDiagram-NVQOT4AX-VPKAUX8M.js +0 -139
  135. package/dist/worker/console/static/assets/kanban-definition-27J2QSJJ-BL56hfGp.js +0 -89
  136. package/dist/worker/console/static/assets/layout-D-LzfAck.js +0 -1
  137. package/dist/worker/console/static/assets/linear-BmSG3nIZ.js +0 -1
  138. package/dist/worker/console/static/assets/map-DxJ2ADlA.js +0 -1
  139. package/dist/worker/console/static/assets/mermaid.core-DWGK9kwP.js +0 -308
  140. package/dist/worker/console/static/assets/mindmap-definition-FAOFIHXS--vvKLjo0.js +0 -96
  141. package/dist/worker/console/static/assets/ordinal-Cboi1Yqb.js +0 -1
  142. package/dist/worker/console/static/assets/pegDiagram-VL7TDLO6-B41mxb2-.js +0 -1
  143. package/dist/worker/console/static/assets/pieDiagram-7S7Q4E2Y-DTlu77sw.js +0 -39
  144. package/dist/worker/console/static/assets/quadrantDiagram-CIZ2JOQS-Bk5A7alv.js +0 -7
  145. package/dist/worker/console/static/assets/railroadDiagram-AXF67PYL-Csf9jzQy.js +0 -1
  146. package/dist/worker/console/static/assets/requirementDiagram-LRYGKXZP-CeptoSNx.js +0 -84
  147. package/dist/worker/console/static/assets/sankeyDiagram-W5VNT64P-A-DLCLer.js +0 -40
  148. package/dist/worker/console/static/assets/sequenceDiagram-SI44F4Z6-22VbPdOI.js +0 -162
  149. package/dist/worker/console/static/assets/sizeCapture-X5ZJPWSS-DWvAyBC9.js +0 -1
  150. package/dist/worker/console/static/assets/stateDiagram-OKZ733FA-qICZ75yT.js +0 -1
  151. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DzKvfHTX.js +0 -1
  152. package/dist/worker/console/static/assets/swimlanes-SLNWSIFB-xi7lcMyp.js +0 -2
  153. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Cxqr_qM1.js +0 -8
  154. package/dist/worker/console/static/assets/timeline-definition-Z64GVDOM-Bu6Nl6O2.js +0 -120
  155. package/dist/worker/console/static/assets/vennDiagram-T6HMQDX7-BzMz4Bn9.js +0 -34
  156. package/dist/worker/console/static/assets/wardleyDiagram-T6FBY63Y-CxaJnzc5.js +0 -78
  157. package/dist/worker/console/static/assets/xychartDiagram-ELKLHX3M-5JK-omQK.js +0 -7
  158. package/dist/worker/console/static/fonts/katex/KaTeX_AMS-Regular.woff2 +0 -0
  159. package/dist/worker/console/static/fonts/katex/KaTeX_Caligraphic-Bold.woff2 +0 -0
  160. package/dist/worker/console/static/fonts/katex/KaTeX_Caligraphic-Regular.woff2 +0 -0
  161. package/dist/worker/console/static/fonts/katex/KaTeX_Fraktur-Bold.woff2 +0 -0
  162. package/dist/worker/console/static/fonts/katex/KaTeX_Fraktur-Regular.woff2 +0 -0
  163. package/dist/worker/console/static/fonts/katex/KaTeX_Main-Bold.woff2 +0 -0
  164. package/dist/worker/console/static/fonts/katex/KaTeX_Main-BoldItalic.woff2 +0 -0
  165. package/dist/worker/console/static/fonts/katex/KaTeX_Main-Italic.woff2 +0 -0
  166. package/dist/worker/console/static/fonts/katex/KaTeX_Main-Regular.woff2 +0 -0
  167. package/dist/worker/console/static/fonts/katex/KaTeX_Math-BoldItalic.woff2 +0 -0
  168. package/dist/worker/console/static/fonts/katex/KaTeX_Math-Italic.woff2 +0 -0
  169. package/dist/worker/console/static/fonts/katex/KaTeX_SansSerif-Bold.woff2 +0 -0
  170. package/dist/worker/console/static/fonts/katex/KaTeX_SansSerif-Italic.woff2 +0 -0
  171. package/dist/worker/console/static/fonts/katex/KaTeX_SansSerif-Regular.woff2 +0 -0
  172. package/dist/worker/console/static/fonts/katex/KaTeX_Script-Regular.woff2 +0 -0
  173. package/dist/worker/console/static/fonts/katex/KaTeX_Size1-Regular.woff2 +0 -0
  174. package/dist/worker/console/static/fonts/katex/KaTeX_Size2-Regular.woff2 +0 -0
  175. package/dist/worker/console/static/fonts/katex/KaTeX_Size3-Regular.woff2 +0 -0
  176. package/dist/worker/console/static/fonts/katex/KaTeX_Size4-Regular.woff2 +0 -0
  177. package/dist/worker/console/static/fonts/katex/KaTeX_Typewriter-Regular.woff2 +0 -0
  178. package/dist/worker/console/static-src/operator-chat/activity-journey.js +0 -125
  179. package/dist/worker/console/static-src/operator-chat/activity-rail-presentation.js +0 -73
  180. package/dist/worker/console/static-src/operator-chat/activity-references.js +0 -20
  181. package/dist/worker/console/static-src/operator-chat/slash-palette-layout.js +0 -24
  182. package/dist/worker/console/static-src/operator-chat/slash-palette-nav.js +0 -141
  183. package/dist/worker/console/static-src/operator-chat/useActivityRailTransition.js +0 -59
  184. package/dist/worker/observability/interrupt-eligibility.js +0 -264
  185. package/dist/workflows/dag/contract-output-registry.js +0 -14
  186. package/dist/workflows/dag/contract-validator-registrations.js +0 -8
  187. package/dist/workflows/dag/frontend-recovery-plan.js +0 -73
  188. package/dist/workflows/dag/frontend-recovery-root-manifest.js +0 -123
  189. package/dist/workflows/dag/frontend-recovery-run.js +0 -539
  190. package/dist/workflows/dag/frontend-writer-recovery.js +0 -106
  191. package/dist/workflows/dag/frontend-writer-rollback.js +0 -821
  192. package/dist/workflows/dag/interrupt-request.js +0 -559
@@ -1,4 +1,5 @@
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";
@@ -8,7 +9,15 @@ const CASE_HEADING = /^##\s+(BE-[A-Z0-9_-]+-\d{3})\b.*$/gm;
8
9
  const CASE_ID_IN_TEXT = /\bBE-[A-Z0-9_-]+-\d{3}\b/g;
9
10
  const NON_CANONICAL_CASE_HEADING = /^##\s+(BE-[A-Z0-9_-]+-(?:\d{2}|\d{2,3}[A-Z]+))\b.*$/gm;
10
11
  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]+)*$/;
12
+ const SOURCE_RULE_ID_IN_TEXT = /\b(?:AC|REQ|BR)-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/g;
13
+ const SOURCE_ERROR_CODE_IN_TEXT = /\b[A-Z][0-9]{5,}\b/g;
14
+ const JSON_FIELD_IN_TEXT = /["']([A-Za-z][A-Za-z0-9_]*)["']\s*:/g;
15
+ 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;
16
+ const NATURAL_BUSINESS_RULE_KEYWORDS = /(?:事务|原子性|回滚|幂等|唯一性|唯一|状态转换|状态流转|权限|角色|租户|跨字段|transaction|atomic|rollback|idempoten|unique|state transition|authorization|permission|tenant|cross-field)/i;
17
+ const NATURAL_BUSINESS_RULE_NORMATIVE = /(?:必须|不得|禁止|仅当|应当|需要|不能|保证|must|shall|required|must not|only when|guarantee)/i;
18
+ // Token form is used for section scanning; anchored form validates full cells.
19
+ const RULE_KEY_TOKEN = /(?:AC|REQ|BR|API|GET|POST|PUT|PATCH|DELETE)-[A-Z0-9]+(?:-[A-Z0-9]+)*|[A-Z][0-9]{5,}/;
20
+ const RULE_KEY = new RegExp(`^(?:${RULE_KEY_TOKEN.source})$`);
12
21
  const TEST_POINT = /^TP-[A-Z0-9]+(?:-[A-Z0-9]+)*$/;
13
22
  const COVERAGE_HEADERS = [
14
23
  "Rule Key",
@@ -87,6 +96,13 @@ const coverageCaseSchema = z.object({
87
96
  scenarioTypes: z.array(z.string()),
88
97
  declaredScripts: z.array(z.string()),
89
98
  declaredPrimarySymbols: z.array(z.string()),
99
+ payloadContract: z.object({
100
+ declared: z.boolean(),
101
+ noPayload: z.boolean(),
102
+ requiredPaths: z.array(z.string()),
103
+ allowedPaths: z.array(z.string()),
104
+ enumValues: z.record(z.string(), z.array(z.string())),
105
+ }).strict(),
90
106
  }).strict();
91
107
  const coverageRuleSchema = z.object({
92
108
  ruleKey: z.string().min(1),
@@ -102,6 +118,23 @@ const coverageRuleSchema = z.object({
102
118
  missingTestPoints: z.array(z.string()),
103
119
  status: z.enum(["COVERED", "PARTIAL", "GAP", "CONFLICT"]),
104
120
  }).strict();
121
+ const sourceCoverageFactSchema = z.object({
122
+ kind: z.enum(["requirement-id", "error-code", "dto-field", "business-rule"]),
123
+ key: z.string().min(1),
124
+ sourcePath: z.string().min(1),
125
+ sourceSha256: sha256Schema,
126
+ lineStart: z.number().int().positive(),
127
+ lineEnd: z.number().int().positive(),
128
+ quote: z.string().min(1).max(1000),
129
+ covered: z.boolean(),
130
+ }).strict();
131
+ const sourceCoverageSchema = z.object({
132
+ sourceFactCount: z.number().int().min(0),
133
+ coveredSourceFactCount: z.number().int().min(0),
134
+ residualSourceFactCount: z.number().int().min(0),
135
+ facts: z.array(sourceCoverageFactSchema),
136
+ residual: z.array(sourceCoverageFactSchema),
137
+ }).strict();
105
138
  const coverageScopeSchema = z.object({
106
139
  changeClassification: z.enum(CHANGE_CLASSIFICATIONS),
107
140
  coveragePolicy: z.enum(COVERAGE_POLICIES),
@@ -112,13 +145,14 @@ const coverageScopeSchema = z.object({
112
145
  completenessClaim: z.enum(["affected-operations-full", "affected-scope"]),
113
146
  }).strict();
114
147
  export const backendTestCaseCoverageFactsSchema = z.object({
115
- schemaId: z.literal("backend-test-case-coverage-facts-v3"),
116
- schemaVersion: z.literal(3),
148
+ schemaId: z.literal("backend-test-case-coverage-facts-v4"),
149
+ schemaVersion: z.literal(4),
117
150
  taskId: z.string().min(1),
118
151
  status: z.enum(["PASS", "FAIL", "UNAVAILABLE"]),
119
152
  sourceBinding: sourceBindingSchema,
120
153
  inputFiles: z.array(inputFileSchema),
121
154
  coverageScope: coverageScopeSchema,
155
+ sourceCoverage: sourceCoverageSchema,
122
156
  summary: z.object({
123
157
  explicitAcCount: z.number().int().min(0),
124
158
  coveredAcCount: z.number().int().min(0),
@@ -177,12 +211,24 @@ const correspondenceEntrySchema = z.object({
177
211
  "CROSS_CUTTING_EVIDENCE_MISSING",
178
212
  "TEST_POINT_BINDING_DUPLICATE",
179
213
  "TEST_POINT_BINDING_EXTRA",
214
+ "PAYLOAD_CONTRACT_UNAVAILABLE",
215
+ "PAYLOAD_SHAPE_MISMATCH",
180
216
  ]),
217
+ payloadAssessment: z.object({
218
+ status: z.enum(["SAFE", "UNSAFE", "UNAVAILABLE"]),
219
+ requiredPaths: z.array(z.string()),
220
+ allowedPaths: z.array(z.string()),
221
+ observedPaths: z.array(z.string()),
222
+ missingPaths: z.array(z.string()),
223
+ unexpectedPaths: z.array(z.string()),
224
+ enumMismatches: z.array(z.string()),
225
+ }).strict(),
226
+ executionSignature: z.string().min(1).optional(),
181
227
  findings: z.array(z.string()),
182
228
  }).strict();
183
229
  export const backendTestMarkdownPytestCorrespondenceFactsSchema = z.object({
184
- schemaId: z.literal("backend-test-markdown-pytest-correspondence-facts-v2"),
185
- schemaVersion: z.literal(2),
230
+ schemaId: z.literal("backend-test-markdown-pytest-correspondence-facts-v3"),
231
+ schemaVersion: z.literal(3),
186
232
  taskId: z.string().min(1),
187
233
  status: z.enum(["PASS", "FAIL", "UNAVAILABLE"]),
188
234
  inputFiles: z.array(inputFileSchema),
@@ -203,6 +249,10 @@ export const backendTestMarkdownPytestCorrespondenceFactsSchema = z.object({
203
249
  crossCuttingTestPointCount: z.number().int().min(0),
204
250
  unclassifiedTestPointCount: z.number().int().min(0),
205
251
  duplicateBindingTestPointCount: z.number().int().min(0),
252
+ payloadSafeCount: z.number().int().min(0),
253
+ payloadUnsafeCount: z.number().int().min(0),
254
+ payloadUnavailableCount: z.number().int().min(0),
255
+ duplicateExecutionSignatureCount: z.number().int().min(0),
206
256
  }).strict(),
207
257
  entries: z.array(correspondenceEntrySchema),
208
258
  findings: z.array(z.string()),
@@ -236,6 +286,64 @@ function openApiTestPoints(field, dimension, values = []) {
236
286
  return [`${prefix}-VALID-CLASS`, `${prefix}-VALID-MIXED`, `${prefix}-UPPERCASE`, `${prefix}-WHITESPACE`, `${prefix}-PUNCTUATION`, `${prefix}-SLASH`, `${prefix}-EMOJI`, `${prefix}-CONTROL`];
237
287
  return [`${prefix}-PRESENT`, `${prefix}-MISSING`, `${prefix}-NULL`, `${prefix}-WRONG-TYPE`];
238
288
  }
289
+ async function collectSourceCoverageFacts(input) {
290
+ const paths = orderedUnique([
291
+ input.sourceBinding.requirementPath,
292
+ ...input.sourceBinding.referencePaths,
293
+ ]);
294
+ const facts = [];
295
+ const seen = new Set();
296
+ for (const sourcePath of paths) {
297
+ const physicalPath = resolveBoundSourcePath(input.workspaceRoot, input.sourceBinding.taskId, sourcePath);
298
+ let text;
299
+ try {
300
+ text = await readFile(physicalPath, "utf8");
301
+ }
302
+ catch {
303
+ continue;
304
+ }
305
+ const sourceSha256 = createHash("sha256").update(await readFile(physicalPath)).digest("hex");
306
+ const lines = text.split(/\r?\n/);
307
+ const add = (kind, key, lineIndex, quote) => {
308
+ const normalizedQuote = quote.trim().slice(0, 1000);
309
+ if (!normalizedQuote)
310
+ return;
311
+ const identity = `${kind}:${key}:${sourcePath}:${lineIndex + 1}`;
312
+ if (seen.has(identity))
313
+ return;
314
+ seen.add(identity);
315
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
316
+ const covered = kind === "business-rule"
317
+ ? input.generatedMarkdown.includes(normalizedQuote)
318
+ : new RegExp(`\\b${escaped}\\b`).test(input.generatedMarkdown);
319
+ facts.push({
320
+ kind,
321
+ key,
322
+ sourcePath,
323
+ sourceSha256,
324
+ lineStart: lineIndex + 1,
325
+ lineEnd: lineIndex + 1,
326
+ quote: normalizedQuote,
327
+ covered,
328
+ });
329
+ };
330
+ lines.forEach((line, lineIndex) => {
331
+ for (const key of line.match(SOURCE_RULE_ID_IN_TEXT) ?? [])
332
+ add("requirement-id", key, lineIndex, line);
333
+ for (const key of line.match(SOURCE_ERROR_CODE_IN_TEXT) ?? [])
334
+ add("error-code", key, lineIndex, line);
335
+ for (const match of line.matchAll(JSON_FIELD_IN_TEXT))
336
+ add("dto-field", match[1], lineIndex, line);
337
+ for (const match of line.matchAll(DTO_FIELD_IN_TEXT))
338
+ add("dto-field", match[1], lineIndex, line);
339
+ const trimmed = line.trim();
340
+ if (trimmed.length >= 8 && trimmed.length <= 1000 && !/^\s*(?:#|\||[-:]{3,})/.test(trimmed) && NATURAL_BUSINESS_RULE_KEYWORDS.test(trimmed) && NATURAL_BUSINESS_RULE_NORMATIVE.test(trimmed)) {
341
+ add("business-rule", `BR-SOURCE-${sourceSha256.slice(0, 8).toUpperCase()}-L${lineIndex + 1}`, lineIndex, trimmed);
342
+ }
343
+ });
344
+ }
345
+ return facts.sort((left, right) => left.kind.localeCompare(right.kind) || left.key.localeCompare(right.key) || left.sourcePath.localeCompare(right.sourcePath) || left.lineStart - right.lineStart);
346
+ }
239
347
  function resolveBoundSourcePath(workspaceRoot, taskId, sourcePath) {
240
348
  if (path.isAbsolute(sourcePath))
241
349
  return sourcePath;
@@ -385,8 +493,14 @@ function sectionBody(body, aliases) {
385
493
  }
386
494
  function listTokens(body, aliases, pattern) {
387
495
  const section = sectionBody(body, aliases).replaceAll("`", "");
388
- const source = pattern.source.replace(/^\^/, "").replace(/\$$/, "");
389
- return orderedUnique(section.match(new RegExp(source, "g")) ?? []);
496
+ // Prefer dedicated token patterns; never strip ^/$ from alternation-based anchors
497
+ // (that would turn `^A$|^B$` into `A$|^B` and break scanning).
498
+ const tokenSource = pattern === RULE_KEY
499
+ ? RULE_KEY_TOKEN.source
500
+ : pattern === TEST_POINT
501
+ ? TEST_POINT.source.replace(/^\^/, "").replace(/\$$/, "")
502
+ : pattern.source.replace(/^\^/, "").replace(/\$$/, "");
503
+ return orderedUnique(section.match(new RegExp(tokenSource, "g")) ?? []);
390
504
  }
391
505
  function scenarioTypes(body) {
392
506
  return orderedUnique(sectionBody(body, CASE_SECTION_ALIASES.scenarioTypes)
@@ -423,8 +537,40 @@ function testPointBindingFacts(body, testPoints) {
423
537
  }
424
538
  function declaredPrimarySymbols(body) {
425
539
  const value = automationLabelBody(body, ["primary symbol", "Primary Symbol", "主测试符号"]);
540
+ if (/^(?:none|无|n\/a|not-applicable)$/i.test(value.trim()))
541
+ return [];
426
542
  return orderedUnique(value.match(/\btest_[A-Za-z0-9_]+\b/g) ?? []);
427
543
  }
544
+ function isEvidenceOnlyMarkdownCase(testCase) {
545
+ const hasVariant = testCase.testPointBindings.some((item) => item.mode === "variant");
546
+ const hasNonVariant = testCase.testPointBindings.some((item) => item.mode === "assertion" || item.mode === "cross-cutting");
547
+ return (testCase.declaredScripts.length === 0 &&
548
+ testCase.declaredPrimarySymbols.length === 0 &&
549
+ !hasVariant &&
550
+ hasNonVariant);
551
+ }
552
+ function payloadContract(body) {
553
+ const contractValue = automationLabelBody(body, ["Payload Contract", "载荷合同"]);
554
+ const requiredValue = automationLabelBody(body, ["Payload Required Paths", "载荷必需路径"]);
555
+ const allowedValue = automationLabelBody(body, ["Payload Allowed Paths", "载荷允许路径"]);
556
+ const enumValue = automationLabelBody(body, ["Payload Enum", "载荷枚举"]);
557
+ const tokens = (value) => orderedUnique(value.split(/[,;,;]/).map((item) => item.trim()).filter((item) => /^[A-Za-z_][A-Za-z0-9_.]*$/.test(item)));
558
+ const enumValues = {};
559
+ for (const assignment of enumValue.split(/[,;,;]/).map((item) => item.trim()).filter(Boolean)) {
560
+ const match = /^([A-Za-z_][A-Za-z0-9_.]*)\s*(?:=|:)\s*(.+)$/.exec(assignment);
561
+ if (!match)
562
+ continue;
563
+ enumValues[match[1]] = orderedUnique(match[2].split(/\||\//).map((item) => item.trim().replace(/[((].*$/, "").trim().replace(/^['"]|['"]$/g, "")).filter(Boolean));
564
+ }
565
+ const noPayload = /^(?:none|无|not-applicable|n\/a)(?:\s|[((]|$)/i.test(contractValue.trim());
566
+ return {
567
+ declared: noPayload || requiredValue.length > 0 || allowedValue.length > 0 || enumValue.length > 0,
568
+ noPayload,
569
+ requiredPaths: tokens(requiredValue),
570
+ allowedPaths: tokens(allowedValue),
571
+ enumValues,
572
+ };
573
+ }
428
574
  function cleanTitle(heading, id) {
429
575
  return heading.replace(/^##\s+/, "").replace(/^BE-[A-Z0-9_-]+-\d{2,3}\s*(?:[||—–-]\s*)?/i, "").trim() || id;
430
576
  }
@@ -466,6 +612,7 @@ async function parseMarkdownCases(workspaceRoot) {
466
612
  scenarioTypes: scenarioTypes(body),
467
613
  declaredScripts: declaredScripts(body),
468
614
  declaredPrimarySymbols: declaredPrimarySymbols(body),
615
+ payloadContract: payloadContract(body),
469
616
  body,
470
617
  });
471
618
  }
@@ -483,8 +630,17 @@ function parseTableRow(line) {
483
630
  index += 1;
484
631
  }
485
632
  else if (char === "|") {
486
- cells.push(current.trim());
487
- current = "";
633
+ const prev = trimmed[index - 1] ?? "";
634
+ const next = trimmed[index + 1] ?? "";
635
+ // In-cell enum/value pipes look like ACTIVE|ARCHIVED (alnum on both sides).
636
+ // Column separators use " | " or touch markdown separator dashes (---|---).
637
+ if (/[A-Za-z0-9_]/.test(prev) && /[A-Za-z0-9_]/.test(next)) {
638
+ current += "|";
639
+ }
640
+ else {
641
+ cells.push(current.trim());
642
+ current = "";
643
+ }
488
644
  }
489
645
  else {
490
646
  current += char;
@@ -687,7 +843,10 @@ export async function analyzeBackendTestCaseCoverage(input) {
687
843
  if (!rowCaseIds.includes(testCase.caseId))
688
844
  findings.push(`${testCase.caseId} references ${ruleKey} but is absent from its Coverage Matrix Case IDs`);
689
845
  }
690
- let status = declaredStatus;
846
+ const normalizedDeclaredStatus = /^(?:COVERED|PARTIAL|GAP|CONFLICT)$/.test(declaredStatus)
847
+ ? declaredStatus
848
+ : "PARTIAL";
849
+ let status = normalizedDeclaredStatus;
691
850
  if (status === "COVERED" && missingTestPoints.length > 0)
692
851
  status = "PARTIAL";
693
852
  if (missingTestPoints.length > 0)
@@ -707,7 +866,7 @@ export async function analyzeBackendTestCaseCoverage(input) {
707
866
  rule: row.Rule || "unavailable",
708
867
  requiredTestPoints,
709
868
  caseIds: rowCaseIds,
710
- declaredStatus: /^(?:COVERED|PARTIAL|GAP|CONFLICT)$/.test(declaredStatus) ? declaredStatus : "PARTIAL",
869
+ declaredStatus: normalizedDeclaredStatus,
711
870
  coveredTestPoints,
712
871
  missingTestPoints,
713
872
  status,
@@ -749,8 +908,12 @@ export async function analyzeBackendTestCaseCoverage(input) {
749
908
  findings.push(`${testCase.caseId} missing section or entries: 测试点`);
750
909
  if (testCase.scenarioTypes.length === 0)
751
910
  findings.push(`${testCase.caseId} missing section or entries: 场景类型`);
752
- if (testCase.declaredPrimarySymbols.length !== 1)
911
+ if (isEvidenceOnlyMarkdownCase(testCase)) {
912
+ // Evidence-only meta cases intentionally declare 脚本/primary symbol as 无 and only keep assertion/cross-cutting TPs.
913
+ }
914
+ else if (testCase.declaredPrimarySymbols.length !== 1) {
753
915
  findings.push(`${testCase.caseId} must declare exactly one primary symbol`);
916
+ }
754
917
  if (testCase.unclassifiedTestPoints.length > 0)
755
918
  findings.push(`${testCase.caseId} has unclassified Test Points: ${testCase.unclassifiedTestPoints.join(", ")}`);
756
919
  if (testCase.duplicateBindingTestPoints.length > 0)
@@ -772,8 +935,28 @@ export async function analyzeBackendTestCaseCoverage(input) {
772
935
  if (uniqueOwners.length > 1)
773
936
  findings.push(`${testPoint} is bound as a non-cross-cutting Test Point by multiple Cases: ${uniqueOwners.join(", ")}`);
774
937
  }
775
- const explicitAc = input.sourceBinding.requirementIds.filter((id) => id.startsWith("AC-"));
938
+ const generatedMarkdown = [readme, ...cases.map((item) => item.body)].join("\n\n");
939
+ const sourceCoverageFacts = await collectSourceCoverageFacts({
940
+ workspaceRoot: input.workspaceRoot,
941
+ sourceBinding: input.sourceBinding,
942
+ generatedMarkdown,
943
+ });
944
+ const residualSourceFacts = sourceCoverageFacts.filter((fact) => !fact.covered);
945
+ for (const fact of residualSourceFacts) {
946
+ findings.push(`source coverage is missing ${fact.kind} ${fact.key} from ${fact.sourcePath}`);
947
+ }
948
+ const sourceRequirementIds = sourceCoverageFacts
949
+ .filter((fact) => fact.kind === "requirement-id")
950
+ .map((fact) => fact.key);
951
+ const explicitAc = orderedUnique([
952
+ ...input.sourceBinding.requirementIds.filter((id) => id.startsWith("AC-")),
953
+ ...sourceRequirementIds.filter((id) => id.startsWith("AC-")),
954
+ ]);
776
955
  const coveredAc = new Set(cases.flatMap((item) => item.acIds));
956
+ for (const acId of explicitAc) {
957
+ if (!coveredAc.has(acId))
958
+ findings.push(`explicit source AC is missing from Markdown Cases: ${acId}`);
959
+ }
777
960
  const [enumValueCount, coveredEnumValueCount] = dimensionCounts(rules, /enum/);
778
961
  const [boundaryPointCount, coveredBoundaryPointCount] = dimensionCounts(rules, /boundary|length|numeric/);
779
962
  const [formatClassCount, coveredFormatClassCount] = dimensionCounts(rules, /format|pattern|charset/);
@@ -782,8 +965,8 @@ export async function analyzeBackendTestCaseCoverage(input) {
782
965
  const invalidEquivalenceClassCount = rules.reduce((sum, rule) => sum + rule.requiredTestPoints.filter((point) => invalidPointPattern.test(point)).length, 0);
783
966
  const coveredInvalidEquivalenceClassCount = rules.reduce((sum, rule) => sum + rule.coveredTestPoints.filter((point) => invalidPointPattern.test(point)).length, 0);
784
967
  const facts = backendTestCaseCoverageFactsSchema.parse({
785
- schemaId: "backend-test-case-coverage-facts-v3",
786
- schemaVersion: 3,
968
+ schemaId: "backend-test-case-coverage-facts-v4",
969
+ schemaVersion: 4,
787
970
  taskId: input.sourceBinding.taskId,
788
971
  status: findings.length === 0 ? "PASS" : "FAIL",
789
972
  sourceBinding: input.sourceBinding,
@@ -796,6 +979,13 @@ export async function analyzeBackendTestCaseCoverage(input) {
796
979
  }))).filter(Boolean),
797
980
  ]),
798
981
  coverageScope: coverageScope.scope,
982
+ sourceCoverage: {
983
+ sourceFactCount: sourceCoverageFacts.length,
984
+ coveredSourceFactCount: sourceCoverageFacts.length - residualSourceFacts.length,
985
+ residualSourceFactCount: residualSourceFacts.length,
986
+ facts: sourceCoverageFacts,
987
+ residual: residualSourceFacts,
988
+ },
799
989
  summary: {
800
990
  explicitAcCount: explicitAc.length,
801
991
  coveredAcCount: explicitAc.filter((id) => coveredAc.has(id)).length,
@@ -846,6 +1036,22 @@ export async function analyzeBackendTestCaseCoverage(input) {
846
1036
  `- Scope Evidence: ${facts.coverageScope.scopeEvidence.join(", ") || "Unavailable"}`,
847
1037
  ...(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
1038
  "",
1039
+ "## Source Coverage Inventory",
1040
+ "",
1041
+ `- Source facts: ${facts.sourceCoverage.sourceFactCount}`,
1042
+ `- Covered source facts: ${facts.sourceCoverage.coveredSourceFactCount}`,
1043
+ `- Residual source facts: ${facts.sourceCoverage.residualSourceFactCount}`,
1044
+ "",
1045
+ "| Kind | Key | Source Anchor | Source SHA-256 | Exact Quote | Status |",
1046
+ "|---|---|---|---|---|---|",
1047
+ ...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"} |`),
1048
+ "",
1049
+ "## Residual Missing Source Scenarios",
1050
+ "",
1051
+ ...(facts.sourceCoverage.residual.length > 0
1052
+ ? 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.`)
1053
+ : ["- None"]),
1054
+ "",
849
1055
  "## Coverage Summary",
850
1056
  "",
851
1057
  "| Dimension | Total | Covered |",
@@ -909,6 +1115,321 @@ function symbolCaseId(symbol) {
909
1115
  const match = symbol.match(/^test_(BE(?:_[A-Z0-9]+)+?_\d{2,3})(?:_|$)/i);
910
1116
  return match ? canonicalCaseId(match[1].replaceAll("_", "-")) : undefined;
911
1117
  }
1118
+ function pythonPayloadShapes(source) {
1119
+ const program = String.raw `
1120
+ import ast,json,sys
1121
+ source=sys.stdin.buffer.read().decode('utf-8')
1122
+ tree=ast.parse(source)
1123
+ GLOBAL_PAYLOADS={}
1124
+ GLOBAL_VALUES={}
1125
+ for top in tree.body:
1126
+ if isinstance(top,(ast.Assign,ast.AnnAssign)):
1127
+ 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 []))
1128
+ if top.value is None: continue
1129
+ candidates=[]
1130
+ for node in ast.walk(top.value):
1131
+ 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])
1132
+ 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)
1133
+ for name in names:
1134
+ GLOBAL_PAYLOADS[name]=candidates
1135
+ GLOBAL_VALUES[name]=top.value
1136
+ FUNCTION_PAYLOADS={}
1137
+ FUNCTION_TRANSPORT_SHAPES={}
1138
+ TRANSPORT_PAYLOAD_PARAMS={}
1139
+ for top in ast.walk(tree):
1140
+ if isinstance(top,(ast.FunctionDef,ast.AsyncFunctionDef)) and not top.name.startswith('test_'):
1141
+ candidates=[]
1142
+ assigned={}
1143
+ mutated={}
1144
+ returned_names=set()
1145
+ positional=[arg.arg for arg in [*top.args.posonlyargs,*top.args.args]]
1146
+ call_offset=1 if positional and positional[0] in {'self','cls'} else 0
1147
+ transport=[]
1148
+ for node in ast.walk(top):
1149
+ if isinstance(node,(ast.Assign,ast.AnnAssign)):
1150
+ targets=node.targets if isinstance(node,ast.Assign) else [node.target]
1151
+ if node.value is None: continue
1152
+ for target in targets:
1153
+ if isinstance(target,ast.Name): assigned[target.id]=node.value
1154
+ for target in targets:
1155
+ if isinstance(target,ast.Subscript) and isinstance(target.value,ast.Name) and isinstance(target.slice,ast.Constant) and isinstance(target.slice.value,str):
1156
+ mutated.setdefault(target.value.id,[]).append((target.slice.value,node.value))
1157
+ if isinstance(node,ast.Return):
1158
+ if isinstance(node.value,ast.Dict): candidates.append(node.value)
1159
+ elif isinstance(node.value,ast.Name) and node.value.id in assigned:
1160
+ returned=assigned[node.value.id]
1161
+ if isinstance(returned,ast.Dict): candidates.append(returned)
1162
+ elif isinstance(returned,ast.Call) and isinstance(returned.func,ast.Name) and returned.func.id in FUNCTION_PAYLOADS:
1163
+ candidates.extend(FUNCTION_PAYLOADS[returned.func.id])
1164
+ returned_names.add(node.value.id)
1165
+ if isinstance(node,ast.Call):
1166
+ for keyword in node.keywords:
1167
+ 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:
1168
+ transport.append((keyword.value.id,positional.index(keyword.value.id)-call_offset))
1169
+ if isinstance(node.func,ast.Attribute) and node.func.attr.lower() in {'post','put','patch'} and len(node.args) >= 2:
1170
+ candidate=node.args[1]
1171
+ if isinstance(candidate,ast.Name) and candidate.id in positional:
1172
+ transport.append((candidate.id,positional.index(candidate.id)-call_offset))
1173
+ callee=None
1174
+ if isinstance(node.func,ast.Name): callee=node.func.id
1175
+ elif isinstance(node.func,ast.Attribute): callee=node.func.attr
1176
+ for _nested_name,nested_position in TRANSPORT_PAYLOAD_PARAMS.get(callee,[]):
1177
+ matched=node.args[nested_position] if nested_position >= 0 and nested_position < len(node.args) else None
1178
+ if isinstance(matched,ast.Name) and matched.id in positional:
1179
+ transport.append((matched.id,positional.index(matched.id)-call_offset))
1180
+ for returned_name in returned_names:
1181
+ entries=mutated.get(returned_name,[])
1182
+ if entries:
1183
+ candidates.append(ast.Dict(keys=[ast.Constant(key) for key,_value in entries],values=[value for _key,value in entries]))
1184
+ transport_shapes=[]
1185
+ for node in ast.walk(top):
1186
+ if not isinstance(node,ast.Call): continue
1187
+ for keyword in node.keywords:
1188
+ 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:
1189
+ transport_shapes.append(assigned[keyword.value.id])
1190
+ if candidates: FUNCTION_PAYLOADS[top.name]=candidates
1191
+ if transport_shapes: FUNCTION_TRANSPORT_SHAPES[top.name]=transport_shapes
1192
+ if transport: TRANSPORT_PAYLOAD_PARAMS[top.name]=list(dict.fromkeys(transport))
1193
+ # Propagate transport payload positions through local wrapper chains after all
1194
+ # direct transport methods/functions are known. ast.walk may visit module-level
1195
+ # wrappers before class methods, so one-pass discovery is order-sensitive.
1196
+ for _round in range(6):
1197
+ changed=False
1198
+ for top in ast.walk(tree):
1199
+ if not isinstance(top,(ast.FunctionDef,ast.AsyncFunctionDef)) or top.name.startswith('test_'): continue
1200
+ positional=[arg.arg for arg in [*top.args.posonlyargs,*top.args.args]]
1201
+ call_offset=1 if positional and positional[0] in {'self','cls'} else 0
1202
+ current=list(TRANSPORT_PAYLOAD_PARAMS.get(top.name,[]))
1203
+ for node in ast.walk(top):
1204
+ if not isinstance(node,ast.Call): continue
1205
+ callee=node.func.id if isinstance(node.func,ast.Name) else (node.func.attr if isinstance(node.func,ast.Attribute) else None)
1206
+ for _nested_name,nested_position in TRANSPORT_PAYLOAD_PARAMS.get(callee,[]):
1207
+ matched=node.args[nested_position] if nested_position >= 0 and nested_position < len(node.args) else None
1208
+ if isinstance(matched,ast.Name) and matched.id in positional:
1209
+ item=(matched.id,positional.index(matched.id)-call_offset)
1210
+ if item not in current:
1211
+ current.append(item); changed=True
1212
+ if current: TRANSPORT_PAYLOAD_PARAMS[top.name]=current
1213
+ if not changed: break
1214
+
1215
+ # A local row factory may return pytest.param(_payload_builder(...), ..., id=tp).
1216
+ # Treat its first positional payload builder as shape evidence while keeping the
1217
+ # writer contract free to require direct pytest.param rows for scenario checks.
1218
+ for top in ast.walk(tree):
1219
+ if not isinstance(top,(ast.FunctionDef,ast.AsyncFunctionDef)) or top.name.startswith('test_'): continue
1220
+ for node in ast.walk(top):
1221
+ if not isinstance(node,ast.Return) or not isinstance(node.value,ast.Call): continue
1222
+ returned=node.value
1223
+ if not isinstance(returned.func,ast.Attribute) or returned.func.attr != 'param' or not returned.args: continue
1224
+ payload=returned.args[0]
1225
+ if isinstance(payload,ast.Call) and isinstance(payload.func,ast.Name) and payload.func.id in FUNCTION_PAYLOADS:
1226
+ FUNCTION_PAYLOADS[top.name]=list(FUNCTION_PAYLOADS[payload.func.id])
1227
+ # Resolve module-level parameter collections after local payload/row factories
1228
+ # are known (for example CASE_ROWS = [_post_case(...) , ...]).
1229
+ for top in tree.body:
1230
+ if not isinstance(top,ast.Assign): continue
1231
+ names=[target.id for target in top.targets if isinstance(target,ast.Name)]
1232
+ resolved=[]
1233
+ for node in ast.walk(top.value):
1234
+ if isinstance(node,ast.Call) and isinstance(node.func,ast.Name) and node.func.id in FUNCTION_PAYLOADS:
1235
+ resolved.extend(FUNCTION_PAYLOADS[node.func.id])
1236
+ for name in names:
1237
+ if resolved: GLOBAL_PAYLOADS[name]=list(GLOBAL_PAYLOADS.get(name,[]))+resolved
1238
+
1239
+ def literal(node):
1240
+ if isinstance(node, ast.Constant) and isinstance(node.value, (str,int,float,bool,type(None))):
1241
+ return repr(node.value)
1242
+ return None
1243
+
1244
+ def collect_dict(node, prefix, paths, values):
1245
+ if not isinstance(node, ast.Dict): return
1246
+ for key,value in zip(node.keys,node.values):
1247
+ if not isinstance(key, ast.Constant) or not isinstance(key.value,str): continue
1248
+ # Ignore writer helper/meta keys that are not DTO request fields.
1249
+ 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_'):
1250
+ continue
1251
+ current = key.value if not prefix else prefix + '.' + key.value
1252
+ paths.add(current)
1253
+ lv=literal(value)
1254
+ if lv is not None: values.setdefault(current,set()).add(lv)
1255
+ if isinstance(value, ast.Dict): collect_dict(value,current,paths,values)
1256
+ elif isinstance(value,(ast.List,ast.Tuple)):
1257
+ for item in value.elts:
1258
+ if isinstance(item,ast.Dict): collect_dict(item,current,paths,values)
1259
+
1260
+ PAYLOAD_NAMES={'payload','body','request_body','request_json','dto','json','data','json_body'}
1261
+
1262
+ def shape_only(node):
1263
+ if isinstance(node,ast.Dict):
1264
+ values=[]
1265
+ for value in node.values:
1266
+ if isinstance(value,(ast.Dict,ast.List,ast.Tuple)): values.append(shape_only(value))
1267
+ else: values.append(ast.Name(id='_PAYLOAD_VALUE_',ctx=ast.Load()))
1268
+ return ast.Dict(keys=node.keys,values=values)
1269
+ if isinstance(node,(ast.List,ast.Tuple)):
1270
+ 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]
1271
+ return ast.List(elts=items,ctx=ast.Load()) if isinstance(node,ast.List) else ast.Tuple(elts=items,ctx=ast.Load())
1272
+ return node
1273
+
1274
+ def payload_expr_nodes(expr, assigned, parameters, seen=None):
1275
+ seen=set() if seen is None else seen
1276
+ if isinstance(expr,ast.Dict): return [expr]
1277
+ if isinstance(expr,ast.Name):
1278
+ if expr.id in seen: return []
1279
+ seen.add(expr.id)
1280
+ if expr.id in assigned: return payload_expr_nodes(assigned[expr.id],assigned,parameters,seen)
1281
+ if expr.id in parameters:
1282
+ result=[]
1283
+ for value in parameters[expr.id]: result.extend(payload_expr_nodes(value,assigned,parameters,set(seen)))
1284
+ return result
1285
+ return list(GLOBAL_PAYLOADS.get(expr.id,[]))
1286
+ if isinstance(expr,ast.Subscript) and isinstance(expr.value,ast.Name) and isinstance(expr.slice,ast.Constant) and isinstance(expr.slice.value,str):
1287
+ result=[]
1288
+ for candidate in parameters.get(expr.value.id,[]):
1289
+ if not isinstance(candidate,ast.Dict): continue
1290
+ for key,value in zip(candidate.keys,candidate.values):
1291
+ if isinstance(key,ast.Constant) and key.value == expr.slice.value:
1292
+ result.extend(shape_only(item) for item in payload_expr_nodes(value,assigned,parameters,set(seen)))
1293
+ return result
1294
+ if isinstance(expr,ast.Call):
1295
+ result=[]
1296
+ if isinstance(expr.func,ast.Name) and expr.func.id in FUNCTION_PAYLOADS:
1297
+ result.extend(FUNCTION_PAYLOADS[expr.func.id])
1298
+ override_keys=[]; override_values=[]
1299
+ for keyword in expr.keywords:
1300
+ if keyword.arg:
1301
+ override_keys.append(ast.Constant(keyword.arg)); override_values.append(keyword.value)
1302
+ if override_keys: result.append(ast.Dict(keys=override_keys,values=override_values))
1303
+ # A transport body may be assembled by a local combinator such as
1304
+ # _apply_patch(_base_body(...), patch). Because this traversal starts
1305
+ # only from an identified transport-body position, recursively merging
1306
+ # argument shapes is narrower than scanning arbitrary helper calls.
1307
+ for argument in expr.args:
1308
+ result.extend(payload_expr_nodes(argument,assigned,parameters,set(seen)))
1309
+ for keyword in expr.keywords:
1310
+ result.extend(payload_expr_nodes(keyword.value,assigned,parameters,set(seen)))
1311
+ return result
1312
+ return []
1313
+
1314
+ def decorator_parameter_values(dec):
1315
+ result={}
1316
+ 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
1317
+ if not isinstance(dec.args[0],ast.Constant) or not isinstance(dec.args[0].value,str): return result
1318
+ names=[item.strip() for item in dec.args[0].value.split(',')]
1319
+ rows=dec.args[1]
1320
+ if isinstance(rows,ast.Name) and rows.id in GLOBAL_VALUES: rows=GLOBAL_VALUES[rows.id]
1321
+ for node in ast.walk(rows):
1322
+ if not isinstance(node,ast.Call) or not isinstance(node.func,ast.Attribute) or node.func.attr != 'param': continue
1323
+ for index,name in enumerate(names):
1324
+ if index < len(node.args): result.setdefault(name,[]).append(node.args[index])
1325
+ return result
1326
+
1327
+ def decorator_payload_nodes(parameters,assigned):
1328
+ result=[]
1329
+ for name,values in parameters.items():
1330
+ if name.lower() not in PAYLOAD_NAMES: continue
1331
+ for value in values: result.extend(payload_expr_nodes(value,assigned,parameters))
1332
+ return result
1333
+
1334
+ def fallback_payload_nodes(fn):
1335
+ result=[]
1336
+ assigned={}
1337
+ for node in ast.walk(fn):
1338
+ if isinstance(node,(ast.Assign,ast.AnnAssign)):
1339
+ if node.value is None: continue
1340
+ targets=node.targets if isinstance(node,ast.Assign) else [node.target]
1341
+ for target in targets:
1342
+ if isinstance(target,ast.Name): assigned[target.id]=node.value
1343
+ parameters={}
1344
+ for dec in fn.decorator_list:
1345
+ for name,values in decorator_parameter_values(dec).items(): parameters.setdefault(name,[]).extend(values)
1346
+ for node in ast.walk(fn):
1347
+ if not isinstance(node,ast.Call): continue
1348
+ callee=None
1349
+ if isinstance(node.func,ast.Name): callee=node.func.id
1350
+ elif isinstance(node.func,ast.Attribute): callee=node.func.attr
1351
+ result.extend(FUNCTION_TRANSPORT_SHAPES.get(callee,[]))
1352
+ # Wrapper payloads are fallback evidence: they are useful when the Case
1353
+ # contract requires a body, but must not contaminate GET/DELETE targets
1354
+ # merely because setup creates a resource first.
1355
+ for parameter_name,position in TRANSPORT_PAYLOAD_PARAMS.get(callee,[]):
1356
+ matched=next((keyword.value for keyword in node.keywords if keyword.arg == parameter_name),None)
1357
+ if matched is None and position >= 0 and position < len(node.args): matched=node.args[position]
1358
+ if matched is not None: result.extend(payload_expr_nodes(matched,assigned,parameters))
1359
+ return result
1360
+
1361
+ def is_direct_transport_call(node):
1362
+ if not isinstance(node,ast.Call) or not isinstance(node.func,ast.Attribute): return False
1363
+ if node.func.attr.lower() not in {'get','post','put','patch','delete','request'}: return False
1364
+ receiver_tokens=[]
1365
+ current=node.func.value
1366
+ while isinstance(current,ast.Attribute):
1367
+ receiver_tokens.append(current.attr.lower())
1368
+ current=current.value
1369
+ if isinstance(current,ast.Name): receiver_tokens.append(current.id.lower())
1370
+ return any(token in {'requests','httpx','client','session','api_client','http_client'} or token.endswith('_client') for token in receiver_tokens)
1371
+
1372
+ def payload_nodes(fn):
1373
+ result=[]
1374
+ assigned={}
1375
+ for node in ast.walk(fn):
1376
+ if isinstance(node,(ast.Assign,ast.AnnAssign)):
1377
+ if node.value is None: continue
1378
+ targets=node.targets if isinstance(node,ast.Assign) else [node.target]
1379
+ for target in targets:
1380
+ if isinstance(target,ast.Name): assigned[target.id]=node.value
1381
+ parameters={}
1382
+ for dec in fn.decorator_list:
1383
+ for name,values in decorator_parameter_values(dec).items(): parameters.setdefault(name,[]).extend(values)
1384
+ result.extend(decorator_payload_nodes(parameters,assigned))
1385
+ for dec in fn.decorator_list:
1386
+ for node in ast.walk(dec):
1387
+ if isinstance(node,ast.Call) and isinstance(node.func,ast.Name) and node.func.id in FUNCTION_PAYLOADS:
1388
+ result.extend(payload_expr_nodes(node,assigned,parameters))
1389
+ elif isinstance(node,ast.Name) and node.id in GLOBAL_PAYLOADS:
1390
+ result.extend(GLOBAL_PAYLOADS[node.id])
1391
+ # Observe local payload/body variables only when they flow into a direct
1392
+ # HTTP transport call. Arbitrary setup variables are not target requests.
1393
+ for node in ast.walk(fn):
1394
+ if not is_direct_transport_call(node): continue
1395
+ for keyword in node.keywords:
1396
+ if keyword.arg in PAYLOAD_NAMES: result.extend(payload_expr_nodes(keyword.value,assigned,parameters))
1397
+ return result
1398
+
1399
+ out={}
1400
+ for fn in ast.walk(tree):
1401
+ if isinstance(fn,(ast.FunctionDef,ast.AsyncFunctionDef)) and fn.name.startswith('test_'):
1402
+ paths=set(); values={}
1403
+ fallback_paths=set(); fallback_values={}
1404
+ for node in payload_nodes(fn): collect_dict(node,'',paths,values)
1405
+ for node in fallback_payload_nodes(fn): collect_dict(node,'',fallback_paths,fallback_values)
1406
+ out[fn.name]={
1407
+ 'paths':sorted(paths),
1408
+ 'values':{key:sorted(items) for key,items in sorted(values.items())},
1409
+ 'fallbackPaths':sorted(fallback_paths),
1410
+ 'fallbackValues':{key:sorted(items) for key,items in sorted(fallback_values.items())},
1411
+ }
1412
+ print(json.dumps(out,ensure_ascii=True))
1413
+ `;
1414
+ // Pass bytes and decode stdin explicitly as UTF-8 in Python. Clean-env
1415
+ // Windows Python may expose stdin as GBK; decoding generated Chinese/emoji
1416
+ // source through that locale can create surrogateescape code points and make
1417
+ // ast.parse fail with "surrogates not allowed".
1418
+ const result = spawnSync("python", ["-c", program], { input: Buffer.from(source, "utf8"), encoding: "utf8", maxBuffer: 4 * 1024 * 1024, windowsHide: true });
1419
+ if (result.status !== 0 || !result.stdout.trim())
1420
+ return new Map();
1421
+ try {
1422
+ const parsed = JSON.parse(result.stdout);
1423
+ return new Map(Object.entries(parsed));
1424
+ }
1425
+ catch {
1426
+ return new Map();
1427
+ }
1428
+ }
1429
+ function metadataCaseIds(region) {
1430
+ return orderedUnique([...region.matchAll(/^\s*Case-ID\s*:\s*(BE-[A-Z0-9]+(?:-[A-Z0-9]+)*)\s*$/gmi)]
1431
+ .map((match) => canonicalCaseId(match[1])));
1432
+ }
912
1433
  function metadataTestPoints(region, label) {
913
1434
  const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
914
1435
  const match = new RegExp(`^\\s*${escaped}\\s*:\\s*(.*)$`, "mi").exec(region);
@@ -928,10 +1449,35 @@ function pytestFunctionRegionStart(source, functionIndex) {
928
1449
  }
929
1450
  return regionStart;
930
1451
  }
1452
+ function pytestFunctionBodyEnd(source, signatureEnd, functionIndent) {
1453
+ let cursor = source.indexOf("\n", signatureEnd);
1454
+ if (cursor < 0)
1455
+ return source.length;
1456
+ cursor += 1;
1457
+ const baseIndent = functionIndent.replaceAll("\t", " ").length;
1458
+ while (cursor < source.length) {
1459
+ const nextLine = source.indexOf("\n", cursor);
1460
+ const lineEnd = nextLine < 0 ? source.length : nextLine;
1461
+ const line = source.slice(cursor, lineEnd).replace(/\r$/, "");
1462
+ if (line.trim()) {
1463
+ const indentation = line.match(/^[ \t]*/)?.[0]?.replaceAll("\t", " ").length ?? 0;
1464
+ if (indentation <= baseIndent)
1465
+ return cursor;
1466
+ }
1467
+ if (nextLine < 0)
1468
+ return source.length;
1469
+ cursor = nextLine + 1;
1470
+ }
1471
+ return source.length;
1472
+ }
931
1473
  function pytestParameterCollections(source) {
932
1474
  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])));
1475
+ for (const match of source.matchAll(/^(_?[A-Z][A-Z0-9_]*)\s*=\s*\[(?![^\r\n]*\])([\s\S]*?)^\]/gm)) {
1476
+ const explicitIds = [...match[2].matchAll(/\bid\s*=\s*["'](TP-[A-Z0-9-]+)["']/g)].map((item) => item[1]);
1477
+ const factoryIds = explicitIds.length === 0
1478
+ ? [...match[2].matchAll(/["'](TP-[A-Z0-9-]+)["']/g)].map((item) => item[1])
1479
+ : [];
1480
+ collections.set(match[1], orderedUnique([...explicitIds, ...factoryIds]));
935
1481
  }
936
1482
  return collections;
937
1483
  }
@@ -939,21 +1485,29 @@ function pytestSymbols(script, source) {
939
1485
  const matches = [...source.matchAll(/^([ \t]*)(?:async\s+)?def\s+(test_[A-Za-z0-9_]+)\s*\([^)]*\)\s*(?:->\s*[^:\r\n]+)?\s*:/gm)];
940
1486
  const regionStarts = matches.map((match) => pytestFunctionRegionStart(source, match.index));
941
1487
  const parameterCollections = pytestParameterCollections(source);
1488
+ const payloadShapes = pythonPayloadShapes(source);
942
1489
  return matches.map((match, index) => {
943
1490
  const regionStart = regionStarts[index];
944
- const regionEnd = regionStarts[index + 1] ?? source.length;
1491
+ const regionEnd = pytestFunctionBodyEnd(source, match.index + match[0].length, match[1]);
945
1492
  const region = source.slice(match.index, regionEnd);
946
1493
  const decorators = source.slice(regionStart, match.index);
947
- const ids = caseIds(region);
1494
+ const ids = metadataCaseIds(region);
948
1495
  const fromSymbol = symbolCaseId(match[2]);
949
1496
  if (fromSymbol)
950
1497
  ids.unshift(fromSymbol);
1498
+ const explicitParameterIds = [...decorators.matchAll(/\bid\s*=\s*["'](TP-[A-Z0-9-]+)["']/g)].map((item) => item[1]);
1499
+ const collectionParameterIds = [...parameterCollections.entries()].flatMap(([name, values]) => new RegExp(`\\b${name}\\b`).test(decorators) ? values : []);
1500
+ const factoryParameterIds = explicitParameterIds.length === 0 && collectionParameterIds.length === 0
1501
+ ? [...decorators.matchAll(/["'](TP-[A-Z0-9-]+)["']/g)].map((item) => item[1])
1502
+ : [];
951
1503
  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 : []),
1504
+ ...explicitParameterIds,
1505
+ ...collectionParameterIds,
1506
+ ...factoryParameterIds,
954
1507
  ]);
955
1508
  const assertionTestPoints = metadataTestPoints(region, "Assertion-Test-Points");
956
1509
  const crossCuttingTestPoints = metadataTestPoints(region, "Cross-Cutting-Test-Points");
1510
+ const payloadShape = payloadShapes.get(match[2]) ?? { paths: [], values: {} };
957
1511
  return {
958
1512
  script,
959
1513
  symbol: match[2],
@@ -962,9 +1516,64 @@ function pytestSymbols(script, source) {
962
1516
  parameterIds,
963
1517
  assertionTestPoints,
964
1518
  crossCuttingTestPoints,
1519
+ payloadPaths: payloadShape.paths,
1520
+ payloadValues: payloadShape.values,
1521
+ fallbackPayloadPaths: payloadShape.fallbackPaths ?? [],
1522
+ fallbackPayloadValues: payloadShape.fallbackValues ?? {},
965
1523
  };
966
1524
  });
967
1525
  }
1526
+ function executionSignature(testCase) {
1527
+ const operation = (testCase.body.match(/\b(?:GET|POST|PUT|PATCH|DELETE)\s+\/[^\s`|)]+/i)?.[0] ?? "operation-unavailable").toUpperCase();
1528
+ const inputPartition = [sectionBody(testCase.body, ["测试数据", "Test Data"]), testCase.scenarioTypes.join(" "), testCase.testPoints.join(" ")].join(" ");
1529
+ const precondition = sectionBody(testCase.body, ["前置条件", "Preconditions", "Precondition"]);
1530
+ const expectedOutcome = sectionBody(testCase.body, ["预期结果", "Expected Results", "Expected Result"]);
1531
+ const normalized = [operation, inputPartition, precondition, expectedOutcome]
1532
+ .map((value) => value.replaceAll(/\s+/g, " ").trim().toLowerCase())
1533
+ .join("\n");
1534
+ return createHash("sha256").update(normalized).digest("hex");
1535
+ }
1536
+ function assessPayloadContract(testCase, refs) {
1537
+ const requiredPaths = testCase.payloadContract.requiredPaths;
1538
+ const directObservedPaths = orderedUnique(refs.flatMap((item) => item.payloadPaths));
1539
+ const useFallback = requiredPaths.length > 0 && directObservedPaths.length === 0;
1540
+ const analyzedObservedPaths = useFallback
1541
+ ? orderedUnique(refs.flatMap((item) => item.fallbackPayloadPaths))
1542
+ : directObservedPaths;
1543
+ // A no-body target contract describes the target GET/DELETE request, not
1544
+ // setup POST/PUT calls in the same lifecycle test function.
1545
+ const observedPaths = testCase.payloadContract.noPayload ? [] : analyzedObservedPaths;
1546
+ const allowedPaths = testCase.payloadContract.allowedPaths;
1547
+ if (!testCase.payloadContract.declared) {
1548
+ return { status: "UNAVAILABLE", requiredPaths, allowedPaths, observedPaths, missingPaths: [], unexpectedPaths: [], enumMismatches: [] };
1549
+ }
1550
+ const missingPaths = requiredPaths.filter((item) => !observedPaths.includes(item));
1551
+ const unexpectedPaths = testCase.payloadContract.noPayload
1552
+ ? observedPaths
1553
+ : allowedPaths.length > 0
1554
+ ? observedPaths.filter((item) => !allowedPaths.includes(item) && !allowedPaths.some((allowed) => item.startsWith(`${allowed}.`)))
1555
+ : [];
1556
+ const enumMismatches = [];
1557
+ const delegatesInvalidEnumToScenarioGate = testCase.testPointBindings
1558
+ .filter((binding) => binding.mode === "variant")
1559
+ .some((binding) => /(?:ENUM|CATEGORY).*(?:INVALID|UNKNOWN|CASE|WHITESPACE|EMPTY|WRONG-TYPE)|(?:INVALID|UNKNOWN|CASE|WHITESPACE|EMPTY|WRONG-TYPE).*(?:ENUM|CATEGORY)/.test(binding.testPoint));
1560
+ for (const [field, allowed] of Object.entries(testCase.payloadContract.enumValues)) {
1561
+ const observed = orderedUnique(refs.flatMap((item) => (useFallback ? item.fallbackPayloadValues[field] : item.payloadValues[field]) ?? []).map((value) => value.replace(/^['"]|['"]$/g, "")));
1562
+ for (const value of observed) {
1563
+ if (!allowed.includes(value) && !delegatesInvalidEnumToScenarioGate)
1564
+ enumMismatches.push(`${field}=${value}; allowed=${allowed.join("|")}`);
1565
+ }
1566
+ }
1567
+ return {
1568
+ status: missingPaths.length > 0 || unexpectedPaths.length > 0 || enumMismatches.length > 0 ? "UNSAFE" : "SAFE",
1569
+ requiredPaths,
1570
+ allowedPaths,
1571
+ observedPaths,
1572
+ missingPaths,
1573
+ unexpectedPaths,
1574
+ enumMismatches,
1575
+ };
1576
+ }
968
1577
  export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
969
1578
  const cases = await parseMarkdownCases(input.workspaceRoot);
970
1579
  const modulePaths = orderedUnique(cases.map((item) => item.markdownPath));
@@ -998,21 +1607,44 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
998
1607
  ...crossCuttingTestPoints.filter((point) => crossCuttingBindings.includes(point)),
999
1608
  ]);
1000
1609
  const declaredPrimarySymbol = testCase.declaredPrimarySymbols[0];
1610
+ const evidenceOnly = isEvidenceOnlyMarkdownCase(testCase);
1001
1611
  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";
1612
+ let status = evidenceOnly && refs.length === 0
1613
+ ? "EXACT_1_TO_1"
1614
+ : refs.length === 0
1615
+ ? "MISSING_PYTEST"
1616
+ : refs.length > 1
1617
+ ? "MULTIPLE_PYTEST"
1618
+ : "EXACT_1_TO_1";
1003
1619
  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")
1620
+ const payloadAssessment = assessPayloadContract(testCase, refs);
1621
+ const signature = executionSignature(testCase);
1622
+ if (evidenceOnly) {
1623
+ if (refs.length > 0) {
1010
1624
  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"}`);
1625
+ entryFindings.push(`${testCase.caseId} evidence-only Markdown Case must not materialize a business pytest symbol; actualSymbols=${symbols.join(", ") || "none"}`);
1626
+ }
1012
1627
  }
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));
1628
+ else {
1629
+ if (refs.length === 0)
1630
+ entryFindings.push(`${testCase.caseId} has no pytest symbol`);
1631
+ if (refs.length > 1)
1632
+ entryFindings.push(`${testCase.caseId} maps to multiple pytest symbols: ${symbols.join(", ")}`);
1633
+ if (!declared || declared !== expected || actualScripts.some((script) => script !== expected) || !declaredPrimarySymbol || symbols.some((symbol) => symbol !== declaredPrimarySymbol)) {
1634
+ if (refs.length > 0 && status === "EXACT_1_TO_1")
1635
+ status = "SCRIPT_MISMATCH";
1636
+ 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"}`);
1637
+ }
1638
+ }
1639
+ const missingVariant = evidenceOnly
1640
+ ? []
1641
+ : variantTestPoints.filter((point) => !parameterIds.includes(point));
1642
+ const missingAssertion = evidenceOnly
1643
+ ? []
1644
+ : assertionTestPoints.filter((point) => !assertionBindings.includes(point));
1645
+ const missingCrossCutting = evidenceOnly
1646
+ ? []
1647
+ : crossCuttingTestPoints.filter((point) => !crossCuttingBindings.includes(point));
1016
1648
  const extraPoints = orderedUnique([...parameterIds, ...assertionBindings, ...crossCuttingBindings]).filter((point) => !testCase.testPoints.includes(point));
1017
1649
  if (testCase.unclassifiedTestPoints.length > 0 || testCase.duplicateBindingTestPoints.length > 0) {
1018
1650
  if (refs.length > 0 && status === "EXACT_1_TO_1")
@@ -1039,6 +1671,16 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
1039
1671
  status = "TEST_POINT_BINDING_EXTRA";
1040
1672
  entryFindings.push(`${testCase.caseId} has extra pytest Test Point bindings: ${extraPoints.join(", ")}`);
1041
1673
  }
1674
+ if (payloadAssessment.status === "UNAVAILABLE") {
1675
+ if (refs.length > 0 && status === "EXACT_1_TO_1")
1676
+ status = "PAYLOAD_CONTRACT_UNAVAILABLE";
1677
+ entryFindings.push(`${testCase.caseId} has no deterministic Payload Contract declaration`);
1678
+ }
1679
+ else if (payloadAssessment.status === "UNSAFE") {
1680
+ if (refs.length > 0 && status === "EXACT_1_TO_1")
1681
+ status = "PAYLOAD_SHAPE_MISMATCH";
1682
+ entryFindings.push(`${testCase.caseId} payload contract mismatch: missing=${payloadAssessment.missingPaths.join(", ") || "none"}; unexpected=${payloadAssessment.unexpectedPaths.join(", ") || "none"}; enum=${payloadAssessment.enumMismatches.join(", ") || "none"}`);
1683
+ }
1042
1684
  findings.push(...entryFindings);
1043
1685
  entries.push({
1044
1686
  markdownModule: testCase.markdownPath,
@@ -1056,6 +1698,8 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
1056
1698
  parameterIds,
1057
1699
  cardinality,
1058
1700
  status,
1701
+ payloadAssessment,
1702
+ executionSignature: signature,
1059
1703
  findings: entryFindings,
1060
1704
  });
1061
1705
  }
@@ -1082,9 +1726,27 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
1082
1726
  parameterIds: symbol.parameterIds,
1083
1727
  cardinality: "0:1",
1084
1728
  status: unknownId ? "EXTRA_PYTEST" : "SYMBOL_MISSING_CASE_ID",
1729
+ payloadAssessment: {
1730
+ status: "UNAVAILABLE",
1731
+ requiredPaths: [],
1732
+ allowedPaths: [],
1733
+ observedPaths: symbol.payloadPaths,
1734
+ missingPaths: [],
1735
+ unexpectedPaths: [],
1736
+ enumMismatches: [],
1737
+ },
1085
1738
  findings: [message],
1086
1739
  });
1087
1740
  }
1741
+ const duplicateSignatures = new Map();
1742
+ for (const entry of entries.filter((item) => item.caseId && item.executionSignature)) {
1743
+ const owners = duplicateSignatures.get(entry.executionSignature) ?? [];
1744
+ owners.push(entry.caseId);
1745
+ duplicateSignatures.set(entry.executionSignature, owners);
1746
+ }
1747
+ const duplicateSignatureGroups = [...duplicateSignatures.entries()].filter(([, owners]) => orderedUnique(owners).length > 1);
1748
+ for (const [signature, owners] of duplicateSignatureGroups)
1749
+ findings.push(`duplicate execution signature ${signature}: ${orderedUnique(owners).join(", ")}`);
1088
1750
  const exactModuleCount = modulePaths.filter((modulePath) => {
1089
1751
  const expected = expectedScript(modulePath);
1090
1752
  const moduleCases = cases.filter((item) => item.markdownPath === modulePath);
@@ -1092,8 +1754,8 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
1092
1754
  }).length;
1093
1755
  const caseEntries = entries.filter((entry) => entry.cardinality !== "0:1");
1094
1756
  const facts = backendTestMarkdownPytestCorrespondenceFactsSchema.parse({
1095
- schemaId: "backend-test-markdown-pytest-correspondence-facts-v2",
1096
- schemaVersion: 2,
1757
+ schemaId: "backend-test-markdown-pytest-correspondence-facts-v3",
1758
+ schemaVersion: 3,
1097
1759
  taskId: input.taskId,
1098
1760
  status: findings.length === 0 ? "PASS" : "FAIL",
1099
1761
  inputFiles: await inputFileFacts(input.workspaceRoot, [
@@ -1118,6 +1780,10 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
1118
1780
  crossCuttingTestPointCount: cases.reduce((sum, item) => sum + item.testPointBindings.filter((binding) => binding.mode === "cross-cutting").length, 0),
1119
1781
  unclassifiedTestPointCount: cases.reduce((sum, item) => sum + item.unclassifiedTestPoints.length, 0),
1120
1782
  duplicateBindingTestPointCount: cases.reduce((sum, item) => sum + item.duplicateBindingTestPoints.length, 0),
1783
+ payloadSafeCount: caseEntries.filter((entry) => entry.payloadAssessment.status === "SAFE").length,
1784
+ payloadUnsafeCount: caseEntries.filter((entry) => entry.payloadAssessment.status === "UNSAFE").length,
1785
+ payloadUnavailableCount: caseEntries.filter((entry) => entry.payloadAssessment.status === "UNAVAILABLE").length,
1786
+ duplicateExecutionSignatureCount: duplicateSignatureGroups.length,
1121
1787
  },
1122
1788
  entries,
1123
1789
  findings: orderedUnique(findings),
@@ -1145,12 +1811,16 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
1145
1811
  `- Cross-Cutting Test Points: ${facts.summary.crossCuttingTestPointCount}`,
1146
1812
  `- Unclassified Test Points: ${facts.summary.unclassifiedTestPointCount}`,
1147
1813
  `- Duplicate Test Point Bindings: ${facts.summary.duplicateBindingTestPointCount}`,
1814
+ `- Payload SAFE: ${facts.summary.payloadSafeCount}`,
1815
+ `- Payload UNSAFE: ${facts.summary.payloadUnsafeCount}`,
1816
+ `- Payload UNAVAILABLE: ${facts.summary.payloadUnavailableCount}`,
1817
+ `- Duplicate Execution Signatures: ${facts.summary.duplicateExecutionSignatureCount}`,
1148
1818
  "",
1149
1819
  "## Correspondence Matrix",
1150
1820
  "",
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} |`),
1821
+ "| 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 |",
1822
+ "|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|",
1823
+ ...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
1824
  "",
1155
1825
  "## Findings",
1156
1826
  "",