@tea-agent/loop-agent 0.38.0 → 0.38.2

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 (147) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/dist/application/dag/generate-task-dag.js +9 -0
  3. package/dist/build-stamp.json +3 -3
  4. package/dist/commands/init-upgrade.js +97 -19
  5. package/dist/executors/dag-pi-executor.js +42 -4
  6. package/dist/executors/shell-executor.js +269 -15
  7. package/dist/infrastructure/harness/atomic-write.js +2 -1
  8. package/dist/shared/openspec-spec.js +76 -0
  9. package/dist/shared/operator/capabilities.js +149 -27
  10. package/dist/task/config-types.js +43 -0
  11. package/dist/task/frontend-project-capability.js +292 -38
  12. package/dist/worker/console/chat/deferred-turn.js +91 -0
  13. package/dist/worker/console/chat/mcp-inventory.js +221 -0
  14. package/dist/worker/console/chat/pi-runtime.js +165 -83
  15. package/dist/worker/console/chat/repo-walk.js +10 -1
  16. package/dist/worker/console/chat/routes.js +477 -55
  17. package/dist/worker/console/chat/semantic-activity.js +20 -8
  18. package/dist/worker/console/chat/session-store.js +146 -2
  19. package/dist/worker/console/chat/turn-process.js +17 -30
  20. package/dist/worker/console/harness-pi-model-matrix.js +137 -0
  21. package/dist/worker/console/operator-actions.js +128 -4
  22. package/dist/worker/console/operator-user-error.js +19 -0
  23. package/dist/worker/console/pi-readiness.js +8 -1
  24. package/dist/worker/console/recovery-cta.js +4 -4
  25. package/dist/worker/console/recovery-error-copy.js +4 -4
  26. package/dist/worker/console/security.js +20 -1
  27. package/dist/worker/console/server.js +6 -1
  28. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-DSvF6RXQ.js → abnfDiagram-N423BO3Z-C4bmo3iY.js} +1 -1
  29. package/dist/worker/console/static/assets/{arc-DuI4ogtJ.js → arc-SAD7McOS.js} +1 -1
  30. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-DdBbgD4y.js → architectureDiagram-T3A2C74G-DAVLUdK1.js} +1 -1
  31. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-xQC2HELm.js → blockDiagram-VBNYF7ZC-CPPdv4vj.js} +1 -1
  32. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DPMSgwIZ.js → c4Diagram-5PPSVZJV-D1GRGZax.js} +1 -1
  33. package/dist/worker/console/static/assets/channel-R6oQIDkw.js +1 -0
  34. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-WTEKTxin.js → chunk-2GRJ4B5K-D_Bq2ZqK.js} +1 -1
  35. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-B6vZ0Pv_.js → chunk-2Q5K7J3B-CPLVtamK.js} +1 -1
  36. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-BrerNAix.js → chunk-5RXB4S5H-DnaZWcL3.js} +1 -1
  37. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-D_ch2aaI.js → chunk-5VM5RSS4-BRTmFgnq.js} +1 -1
  38. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-VvfacNrq.js → chunk-6Q2QTUOP-BRlfYZoH.js} +1 -1
  39. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-B-uGFIg2.js → chunk-GF5L2VYU-CjNybD4u.js} +1 -1
  40. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-BlysCtod.js → chunk-JWPE2WC7-D3e-74fG.js} +1 -1
  41. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-BjAUAfcT.js → chunk-KBJHAD2P-Br3ir6f2.js} +1 -1
  42. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-BeZnBmb-.js → chunk-RYQCIY6F-1NgVuRbh.js} +1 -1
  43. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-Bbfe3UJG.js → chunk-XXDRQBXY-BTYuvuWN.js} +1 -1
  44. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BPXlmPWI.js +1 -0
  45. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BPXlmPWI.js +1 -0
  46. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-BKJQnYVM.js → cose-bilkent-JH36ORCC-BC6Bgosf.js} +1 -1
  47. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-B1wtI3hU.js → cynefin-VYW2F7L2-DV-HypfL.js} +1 -1
  48. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-DCF6DBSu.js → cynefinDiagram-MW4NZA55-DhTaddO-.js} +1 -1
  49. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BJyNGRlP.js → dagre-VZM6K2ZE-BnEcOmiI.js} +1 -1
  50. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-B7j1-VsZ.js → diagram-7IWD3JNH-xphGRVKp.js} +1 -1
  51. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-BREJ140a.js → diagram-B4RE2ZJO-CrdD1mRG.js} +1 -1
  52. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-CDPHmHGH.js → diagram-LBJQPF4R-D4YdLsaF.js} +1 -1
  53. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-BtZLrez1.js → diagram-Q27KOJAE-LCKyGZ5U.js} +1 -1
  54. package/dist/worker/console/static/assets/{diagram-UB23O5K3-mzybu8oU.js → diagram-UB23O5K3-BeiTe7t0.js} +1 -1
  55. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-Dk3I2uO6.js → ebnfDiagram-BXEA7PRR-BO2cq8GS.js} +1 -1
  56. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-BT97u_tQ.js → erDiagram-JOGREHBK-CmWww3Nt.js} +1 -1
  57. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-CMOtYzui.js → flowDiagram-UKHOOZJN-C2kkUYg5.js} +1 -1
  58. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-DiNydkuY.js → ganttDiagram-PKOTCBZU-DY_JZ2iQ.js} +1 -1
  59. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Cwt6fLkl.js → gitGraphDiagram-DS77QQ5N-BizhCSw9.js} +1 -1
  60. package/dist/worker/console/static/assets/index-BXN89VPX.js +325 -0
  61. package/dist/worker/console/static/assets/index-CuH5CNFu.css +1 -0
  62. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-AsGIGKKB.js → infoDiagram-6WML65LV-Dlo2eK_d.js} +1 -1
  63. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-BL8ITZh3.js → ishikawaDiagram-WSZJBQD7-3EIlIIuZ.js} +1 -1
  64. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-VPKAUX8M.js → journeyDiagram-NVQOT4AX-DiMDe5Yh.js} +1 -1
  65. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-BL56hfGp.js → kanban-definition-27J2QSJJ-sN553oIN.js} +1 -1
  66. package/dist/worker/console/static/assets/{linear-BmSG3nIZ.js → linear-D9Py31Ld.js} +1 -1
  67. package/dist/worker/console/static/assets/{mermaid.core-DWGK9kwP.js → mermaid.core-D7Occzk_.js} +5 -5
  68. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS--vvKLjo0.js → mindmap-definition-FAOFIHXS-DjDRboiR.js} +1 -1
  69. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-B41mxb2-.js → pegDiagram-VL7TDLO6-Cgc4qlzp.js} +1 -1
  70. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-DTlu77sw.js → pieDiagram-7S7Q4E2Y-DxZj8pKd.js} +1 -1
  71. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-Bk5A7alv.js → quadrantDiagram-CIZ2JOQS-CJ50MngR.js} +1 -1
  72. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-Csf9jzQy.js → railroadDiagram-AXF67PYL-BY-OD8Tr.js} +1 -1
  73. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-CeptoSNx.js → requirementDiagram-LRYGKXZP-CQFButfK.js} +1 -1
  74. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-A-DLCLer.js → sankeyDiagram-W5VNT64P-BB_QUK_b.js} +1 -1
  75. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-22VbPdOI.js → sequenceDiagram-SI44F4Z6-CqhXZjyx.js} +1 -1
  76. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-DWvAyBC9.js → sizeCapture-X5ZJPWSS-2l1U4rgJ.js} +1 -1
  77. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-qICZ75yT.js → stateDiagram-OKZ733FA-BX1FbrhJ.js} +1 -1
  78. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DJKi0Qel.js +1 -0
  79. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-xi7lcMyp.js → swimlanes-SLNWSIFB-DOSnT7hb.js} +2 -2
  80. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-YYe7EEm5.js +8 -0
  81. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-Bu6Nl6O2.js → timeline-definition-Z64GVDOM-DVJQA7AN.js} +1 -1
  82. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-BzMz4Bn9.js → vennDiagram-T6HMQDX7-74uxl9gx.js} +1 -1
  83. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CxaJnzc5.js → wardleyDiagram-T6FBY63Y-DsBxlcg_.js} +1 -1
  84. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-5JK-omQK.js → xychartDiagram-ELKLHX3M-snuW9Mjw.js} +1 -1
  85. package/dist/worker/console/static/index.html +2 -2
  86. package/dist/worker/console/static-src/app/console-types.js +39 -0
  87. package/dist/worker/console/static-src/app/useOperatorActions.js +1 -1
  88. package/dist/worker/console/static-src/app/useRecoveryActions.js +2 -2
  89. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +82 -22
  90. package/dist/worker/console/static-src/operator-chat/mutation-gate.js +45 -0
  91. package/dist/worker/console/static-src/operator-chat/resource-diagnostics.js +204 -0
  92. package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +8 -1
  93. package/dist/worker/console/static-src/operator-chat/turn-submission.js +42 -0
  94. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +18 -0
  95. package/dist/worker/console/static-src/operator-chat/useChatStream.js +216 -10
  96. package/dist/worker/console/static-src/operator-chat/useChatThread.js +50 -22
  97. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +26 -1
  98. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +755 -51
  99. package/dist/workflows/dag/backend-test-case-manifest.js +4 -0
  100. package/dist/workflows/dag/backend-test-execution-contract.js +18 -9
  101. package/dist/workflows/dag/backend-test-gap-fill.js +205 -0
  102. package/dist/workflows/dag/backend-test-intake-context.js +32 -0
  103. package/dist/workflows/dag/backend-test-layout.js +133 -0
  104. package/dist/workflows/dag/backend-test-markdown-workflow.js +167 -32
  105. package/dist/workflows/dag/backend-test-module-stem.js +5 -0
  106. package/dist/workflows/dag/backend-test-pytest-collection.js +368 -33
  107. package/dist/workflows/dag/backend-test-result-contract.js +1 -1
  108. package/dist/workflows/dag/backend-test-scenario-param.js +884 -140
  109. package/dist/workflows/dag/backend-test-scenario-partitions.js +249 -0
  110. package/dist/workflows/dag/backend-test-writer-completeness.js +100 -56
  111. package/dist/workflows/dag/contract-validator-registrations.js +3 -1
  112. package/dist/workflows/dag/convergence/controller.js +141 -0
  113. package/dist/workflows/dag/dynamic-runtime/map.js +24 -8
  114. package/dist/workflows/dag/dynamic-runtime/shared.js +1 -1
  115. package/dist/workflows/dag/failure-routing.js +9 -4
  116. package/dist/workflows/dag/frontend-implementation-contract.js +386 -0
  117. package/dist/workflows/dag/frontend-plan-render.js +90 -0
  118. package/dist/workflows/dag/frontend-prewrite-gate.js +479 -28
  119. package/dist/workflows/dag/init-hybrid.js +920 -110
  120. package/dist/workflows/dag/lifecycle.js +4 -0
  121. package/dist/workflows/dag/node-execution.js +77 -8
  122. package/dist/workflows/dag/prompt.js +45 -0
  123. package/dist/workflows/dag/report.js +6 -0
  124. package/dist/workflows/dag/rerun-feedback.js +212 -0
  125. package/dist/workflows/dag/rerun-plan.js +93 -27
  126. package/dist/workflows/dag/rerun-run.js +3 -0
  127. package/dist/workflows/dag/rerun-task.js +61 -2
  128. package/dist/workflows/dag/retry-policy.js +33 -0
  129. package/dist/workflows/dag/types.js +148 -4
  130. package/dist/workflows/dag/validate.js +7 -2
  131. package/docs/templates/backend-test-dag.json +60 -35
  132. package/docs/templates/frontend-design-contract.md +40 -14
  133. package/docs/templates/frontend-implementation-contract.schema.json +9 -2
  134. package/docs/templates/frontend-task-constraints.md +40 -13
  135. package/docs/templates/frontend-task-requirement.md +54 -20
  136. package/harness.json +3 -1
  137. package/package.json +1 -1
  138. package/skills/frontend-design-review/references/review-checklist.md +2 -0
  139. package/skills/frontend-implementation/references/design-spec.md +16 -19
  140. package/skills/frontend-implementation/references/node-contracts.md +15 -11
  141. package/dist/worker/console/static/assets/channel-DLVz9TYW.js +0 -1
  142. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BeAuGcO9.js +0 -1
  143. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BeAuGcO9.js +0 -1
  144. package/dist/worker/console/static/assets/index-DQxiso1w.js +0 -325
  145. package/dist/worker/console/static/assets/index-_j0Hzo69.css +0 -1
  146. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DzKvfHTX.js +0 -1
  147. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Cxqr_qM1.js +0 -8
@@ -1,14 +1,25 @@
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 { assessScenarioPartitionCoverage, parseScenarioPartitions, } from "./backend-test-scenario-partitions.js";
6
9
  import { backendTestCaseManifestSchema, computeCaseManifestCoverageSummary, } from "./backend-test-case-manifest.js";
7
10
  const CASE_HEADING = /^##\s+(BE-[A-Z0-9_-]+-\d{3})\b.*$/gm;
8
11
  const CASE_ID_IN_TEXT = /\bBE-[A-Z0-9_-]+-\d{3}\b/g;
9
12
  const NON_CANONICAL_CASE_HEADING = /^##\s+(BE-[A-Z0-9_-]+-(?:\d{2}|\d{2,3}[A-Z]+))\b.*$/gm;
10
13
  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]+)*$/;
14
+ const SOURCE_RULE_ID_IN_TEXT = /\b(?:AC|REQ|BR)-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/g;
15
+ const SOURCE_ERROR_CODE_IN_TEXT = /\b[A-Z][0-9]{5,}\b/g;
16
+ const JSON_FIELD_IN_TEXT = /["']([A-Za-z][A-Za-z0-9_]*)["']\s*:/g;
17
+ 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;
18
+ const NATURAL_BUSINESS_RULE_KEYWORDS = /(?:事务|原子性|回滚|幂等|唯一性|唯一|状态转换|状态流转|权限|角色|租户|跨字段|transaction|atomic|rollback|idempoten|unique|state transition|authorization|permission|tenant|cross-field)/i;
19
+ const NATURAL_BUSINESS_RULE_NORMATIVE = /(?:必须|不得|禁止|仅当|应当|需要|不能|保证|must|shall|required|must not|only when|guarantee)/i;
20
+ // Token form is used for section scanning; anchored form validates full cells.
21
+ const RULE_KEY_TOKEN = /(?:AC|REQ|BR|API|GET|POST|PUT|PATCH|DELETE)-[A-Z0-9]+(?:-[A-Z0-9]+)*|[A-Z][0-9]{5,}/;
22
+ const RULE_KEY = new RegExp(`^(?:${RULE_KEY_TOKEN.source})$`);
12
23
  const TEST_POINT = /^TP-[A-Z0-9]+(?:-[A-Z0-9]+)*$/;
13
24
  const COVERAGE_HEADERS = [
14
25
  "Rule Key",
@@ -87,6 +98,13 @@ const coverageCaseSchema = z.object({
87
98
  scenarioTypes: z.array(z.string()),
88
99
  declaredScripts: z.array(z.string()),
89
100
  declaredPrimarySymbols: z.array(z.string()),
101
+ payloadContract: z.object({
102
+ declared: z.boolean(),
103
+ noPayload: z.boolean(),
104
+ requiredPaths: z.array(z.string()),
105
+ allowedPaths: z.array(z.string()),
106
+ enumValues: z.record(z.string(), z.array(z.string())),
107
+ }).strict(),
90
108
  }).strict();
91
109
  const coverageRuleSchema = z.object({
92
110
  ruleKey: z.string().min(1),
@@ -102,6 +120,23 @@ const coverageRuleSchema = z.object({
102
120
  missingTestPoints: z.array(z.string()),
103
121
  status: z.enum(["COVERED", "PARTIAL", "GAP", "CONFLICT"]),
104
122
  }).strict();
123
+ const sourceCoverageFactSchema = z.object({
124
+ kind: z.enum(["requirement-id", "error-code", "dto-field", "business-rule"]),
125
+ key: z.string().min(1),
126
+ sourcePath: z.string().min(1),
127
+ sourceSha256: sha256Schema,
128
+ lineStart: z.number().int().positive(),
129
+ lineEnd: z.number().int().positive(),
130
+ quote: z.string().min(1).max(1000),
131
+ covered: z.boolean(),
132
+ }).strict();
133
+ const sourceCoverageSchema = z.object({
134
+ sourceFactCount: z.number().int().min(0),
135
+ coveredSourceFactCount: z.number().int().min(0),
136
+ residualSourceFactCount: z.number().int().min(0),
137
+ facts: z.array(sourceCoverageFactSchema),
138
+ residual: z.array(sourceCoverageFactSchema),
139
+ }).strict();
105
140
  const coverageScopeSchema = z.object({
106
141
  changeClassification: z.enum(CHANGE_CLASSIFICATIONS),
107
142
  coveragePolicy: z.enum(COVERAGE_POLICIES),
@@ -112,13 +147,14 @@ const coverageScopeSchema = z.object({
112
147
  completenessClaim: z.enum(["affected-operations-full", "affected-scope"]),
113
148
  }).strict();
114
149
  export const backendTestCaseCoverageFactsSchema = z.object({
115
- schemaId: z.literal("backend-test-case-coverage-facts-v3"),
116
- schemaVersion: z.literal(3),
150
+ schemaId: z.literal("backend-test-case-coverage-facts-v4"),
151
+ schemaVersion: z.literal(4),
117
152
  taskId: z.string().min(1),
118
153
  status: z.enum(["PASS", "FAIL", "UNAVAILABLE"]),
119
154
  sourceBinding: sourceBindingSchema,
120
155
  inputFiles: z.array(inputFileSchema),
121
156
  coverageScope: coverageScopeSchema,
157
+ sourceCoverage: sourceCoverageSchema,
122
158
  summary: z.object({
123
159
  explicitAcCount: z.number().int().min(0),
124
160
  coveredAcCount: z.number().int().min(0),
@@ -146,6 +182,27 @@ export const backendTestCaseCoverageFactsSchema = z.object({
146
182
  }).strict(),
147
183
  cases: z.array(coverageCaseSchema),
148
184
  rules: z.array(coverageRuleSchema),
185
+ /** Plan C: declared query/filter axis partitions and their slot coverage. */
186
+ scenarioPartitions: z.object({
187
+ partitions: z.array(z.object({
188
+ partitionId: z.string(),
189
+ operation: z.string(),
190
+ axis: z.string(),
191
+ domain: z.array(z.string()),
192
+ optional: z.boolean(),
193
+ complementExpected: z.string(),
194
+ bindRule: z.string(),
195
+ slotIds: z.array(z.string()),
196
+ })),
197
+ slots: z.array(z.object({
198
+ slotId: z.string(),
199
+ partitionId: z.string(),
200
+ kind: z.string(),
201
+ intent: z.string(),
202
+ expected: z.string(),
203
+ })),
204
+ missingSlots: z.array(z.string()),
205
+ }),
149
206
  findings: z.array(z.string()),
150
207
  evidenceGaps: z.array(z.string()),
151
208
  conflicts: z.array(z.string()),
@@ -177,12 +234,24 @@ const correspondenceEntrySchema = z.object({
177
234
  "CROSS_CUTTING_EVIDENCE_MISSING",
178
235
  "TEST_POINT_BINDING_DUPLICATE",
179
236
  "TEST_POINT_BINDING_EXTRA",
237
+ "PAYLOAD_CONTRACT_UNAVAILABLE",
238
+ "PAYLOAD_SHAPE_MISMATCH",
180
239
  ]),
240
+ payloadAssessment: z.object({
241
+ status: z.enum(["SAFE", "UNSAFE", "UNAVAILABLE"]),
242
+ requiredPaths: z.array(z.string()),
243
+ allowedPaths: z.array(z.string()),
244
+ observedPaths: z.array(z.string()),
245
+ missingPaths: z.array(z.string()),
246
+ unexpectedPaths: z.array(z.string()),
247
+ enumMismatches: z.array(z.string()),
248
+ }).strict(),
249
+ executionSignature: z.string().min(1).optional(),
181
250
  findings: z.array(z.string()),
182
251
  }).strict();
183
252
  export const backendTestMarkdownPytestCorrespondenceFactsSchema = z.object({
184
- schemaId: z.literal("backend-test-markdown-pytest-correspondence-facts-v2"),
185
- schemaVersion: z.literal(2),
253
+ schemaId: z.literal("backend-test-markdown-pytest-correspondence-facts-v3"),
254
+ schemaVersion: z.literal(3),
186
255
  taskId: z.string().min(1),
187
256
  status: z.enum(["PASS", "FAIL", "UNAVAILABLE"]),
188
257
  inputFiles: z.array(inputFileSchema),
@@ -203,6 +272,10 @@ export const backendTestMarkdownPytestCorrespondenceFactsSchema = z.object({
203
272
  crossCuttingTestPointCount: z.number().int().min(0),
204
273
  unclassifiedTestPointCount: z.number().int().min(0),
205
274
  duplicateBindingTestPointCount: z.number().int().min(0),
275
+ payloadSafeCount: z.number().int().min(0),
276
+ payloadUnsafeCount: z.number().int().min(0),
277
+ payloadUnavailableCount: z.number().int().min(0),
278
+ duplicateExecutionSignatureCount: z.number().int().min(0),
206
279
  }).strict(),
207
280
  entries: z.array(correspondenceEntrySchema),
208
281
  findings: z.array(z.string()),
@@ -236,6 +309,64 @@ function openApiTestPoints(field, dimension, values = []) {
236
309
  return [`${prefix}-VALID-CLASS`, `${prefix}-VALID-MIXED`, `${prefix}-UPPERCASE`, `${prefix}-WHITESPACE`, `${prefix}-PUNCTUATION`, `${prefix}-SLASH`, `${prefix}-EMOJI`, `${prefix}-CONTROL`];
237
310
  return [`${prefix}-PRESENT`, `${prefix}-MISSING`, `${prefix}-NULL`, `${prefix}-WRONG-TYPE`];
238
311
  }
312
+ async function collectSourceCoverageFacts(input) {
313
+ const paths = orderedUnique([
314
+ input.sourceBinding.requirementPath,
315
+ ...input.sourceBinding.referencePaths,
316
+ ]);
317
+ const facts = [];
318
+ const seen = new Set();
319
+ for (const sourcePath of paths) {
320
+ const physicalPath = resolveBoundSourcePath(input.workspaceRoot, input.sourceBinding.taskId, sourcePath);
321
+ let text;
322
+ try {
323
+ text = await readFile(physicalPath, "utf8");
324
+ }
325
+ catch {
326
+ continue;
327
+ }
328
+ const sourceSha256 = createHash("sha256").update(await readFile(physicalPath)).digest("hex");
329
+ const lines = text.split(/\r?\n/);
330
+ const add = (kind, key, lineIndex, quote) => {
331
+ const normalizedQuote = quote.trim().slice(0, 1000);
332
+ if (!normalizedQuote)
333
+ return;
334
+ const identity = `${kind}:${key}:${sourcePath}:${lineIndex + 1}`;
335
+ if (seen.has(identity))
336
+ return;
337
+ seen.add(identity);
338
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
339
+ const covered = kind === "business-rule"
340
+ ? input.generatedMarkdown.includes(normalizedQuote)
341
+ : new RegExp(`\\b${escaped}\\b`).test(input.generatedMarkdown);
342
+ facts.push({
343
+ kind,
344
+ key,
345
+ sourcePath,
346
+ sourceSha256,
347
+ lineStart: lineIndex + 1,
348
+ lineEnd: lineIndex + 1,
349
+ quote: normalizedQuote,
350
+ covered,
351
+ });
352
+ };
353
+ lines.forEach((line, lineIndex) => {
354
+ for (const key of line.match(SOURCE_RULE_ID_IN_TEXT) ?? [])
355
+ add("requirement-id", key, lineIndex, line);
356
+ for (const key of line.match(SOURCE_ERROR_CODE_IN_TEXT) ?? [])
357
+ add("error-code", key, lineIndex, line);
358
+ for (const match of line.matchAll(JSON_FIELD_IN_TEXT))
359
+ add("dto-field", match[1], lineIndex, line);
360
+ for (const match of line.matchAll(DTO_FIELD_IN_TEXT))
361
+ add("dto-field", match[1], lineIndex, line);
362
+ const trimmed = line.trim();
363
+ if (trimmed.length >= 8 && trimmed.length <= 1000 && !/^\s*(?:#|\||[-:]{3,})/.test(trimmed) && NATURAL_BUSINESS_RULE_KEYWORDS.test(trimmed) && NATURAL_BUSINESS_RULE_NORMATIVE.test(trimmed)) {
364
+ add("business-rule", `BR-SOURCE-${sourceSha256.slice(0, 8).toUpperCase()}-L${lineIndex + 1}`, lineIndex, trimmed);
365
+ }
366
+ });
367
+ }
368
+ return facts.sort((left, right) => left.kind.localeCompare(right.kind) || left.key.localeCompare(right.key) || left.sourcePath.localeCompare(right.sourcePath) || left.lineStart - right.lineStart);
369
+ }
239
370
  function resolveBoundSourcePath(workspaceRoot, taskId, sourcePath) {
240
371
  if (path.isAbsolute(sourcePath))
241
372
  return sourcePath;
@@ -385,8 +516,14 @@ function sectionBody(body, aliases) {
385
516
  }
386
517
  function listTokens(body, aliases, pattern) {
387
518
  const section = sectionBody(body, aliases).replaceAll("`", "");
388
- const source = pattern.source.replace(/^\^/, "").replace(/\$$/, "");
389
- return orderedUnique(section.match(new RegExp(source, "g")) ?? []);
519
+ // Prefer dedicated token patterns; never strip ^/$ from alternation-based anchors
520
+ // (that would turn `^A$|^B$` into `A$|^B` and break scanning).
521
+ const tokenSource = pattern === RULE_KEY
522
+ ? RULE_KEY_TOKEN.source
523
+ : pattern === TEST_POINT
524
+ ? TEST_POINT.source.replace(/^\^/, "").replace(/\$$/, "")
525
+ : pattern.source.replace(/^\^/, "").replace(/\$$/, "");
526
+ return orderedUnique(section.match(new RegExp(tokenSource, "g")) ?? []);
390
527
  }
391
528
  function scenarioTypes(body) {
392
529
  return orderedUnique(sectionBody(body, CASE_SECTION_ALIASES.scenarioTypes)
@@ -423,8 +560,40 @@ function testPointBindingFacts(body, testPoints) {
423
560
  }
424
561
  function declaredPrimarySymbols(body) {
425
562
  const value = automationLabelBody(body, ["primary symbol", "Primary Symbol", "主测试符号"]);
563
+ if (/^(?:none|无|n\/a|not-applicable)$/i.test(value.trim()))
564
+ return [];
426
565
  return orderedUnique(value.match(/\btest_[A-Za-z0-9_]+\b/g) ?? []);
427
566
  }
567
+ function isEvidenceOnlyMarkdownCase(testCase) {
568
+ const hasVariant = testCase.testPointBindings.some((item) => item.mode === "variant");
569
+ const hasNonVariant = testCase.testPointBindings.some((item) => item.mode === "assertion" || item.mode === "cross-cutting");
570
+ return (testCase.declaredScripts.length === 0 &&
571
+ testCase.declaredPrimarySymbols.length === 0 &&
572
+ !hasVariant &&
573
+ hasNonVariant);
574
+ }
575
+ function payloadContract(body) {
576
+ const contractValue = automationLabelBody(body, ["Payload Contract", "载荷合同"]);
577
+ const requiredValue = automationLabelBody(body, ["Payload Required Paths", "载荷必需路径"]);
578
+ const allowedValue = automationLabelBody(body, ["Payload Allowed Paths", "载荷允许路径"]);
579
+ const enumValue = automationLabelBody(body, ["Payload Enum", "载荷枚举"]);
580
+ const tokens = (value) => orderedUnique(value.split(/[,;,;]/).map((item) => item.trim()).filter((item) => /^[A-Za-z_][A-Za-z0-9_.]*$/.test(item)));
581
+ const enumValues = {};
582
+ for (const assignment of enumValue.split(/[,;,;]/).map((item) => item.trim()).filter(Boolean)) {
583
+ const match = /^([A-Za-z_][A-Za-z0-9_.]*)\s*(?:=|:)\s*(.+)$/.exec(assignment);
584
+ if (!match)
585
+ continue;
586
+ enumValues[match[1]] = orderedUnique(match[2].split(/\||\//).map((item) => item.trim().replace(/[((].*$/, "").trim().replace(/^['"]|['"]$/g, "")).filter(Boolean));
587
+ }
588
+ const noPayload = /^(?:none|无|not-applicable|n\/a)(?:\s|[((]|$)/i.test(contractValue.trim());
589
+ return {
590
+ declared: noPayload || requiredValue.length > 0 || allowedValue.length > 0 || enumValue.length > 0,
591
+ noPayload,
592
+ requiredPaths: tokens(requiredValue),
593
+ allowedPaths: tokens(allowedValue),
594
+ enumValues,
595
+ };
596
+ }
428
597
  function cleanTitle(heading, id) {
429
598
  return heading.replace(/^##\s+/, "").replace(/^BE-[A-Z0-9_-]+-\d{2,3}\s*(?:[||—–-]\s*)?/i, "").trim() || id;
430
599
  }
@@ -437,17 +606,18 @@ async function exists(filePath) {
437
606
  return false;
438
607
  }
439
608
  }
440
- async function markdownModuleFiles(workspaceRoot) {
441
- const dir = path.join(workspaceRoot, "testcase", "md");
609
+ async function markdownModuleFiles(workspaceRoot, layout) {
610
+ const resolved = layout ?? resolveBackendTestLayout(undefined);
611
+ const dir = path.join(workspaceRoot, ...resolved.markdownDir.split("/"));
442
612
  const entries = await readdir(dir, { withFileTypes: true });
443
613
  return entries
444
614
  .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".md") && entry.name.toLowerCase() !== "readme.md")
445
615
  .map((entry) => path.join(dir, entry.name))
446
616
  .sort();
447
617
  }
448
- async function parseMarkdownCases(workspaceRoot) {
618
+ async function parseMarkdownCases(workspaceRoot, layout) {
449
619
  const result = [];
450
- for (const file of await markdownModuleFiles(workspaceRoot)) {
620
+ for (const file of await markdownModuleFiles(workspaceRoot, layout)) {
451
621
  const markdown = await readFile(file, "utf8");
452
622
  const headings = [...markdown.matchAll(CASE_HEADING)];
453
623
  for (let index = 0; index < headings.length; index += 1) {
@@ -466,6 +636,7 @@ async function parseMarkdownCases(workspaceRoot) {
466
636
  scenarioTypes: scenarioTypes(body),
467
637
  declaredScripts: declaredScripts(body),
468
638
  declaredPrimarySymbols: declaredPrimarySymbols(body),
639
+ payloadContract: payloadContract(body),
469
640
  body,
470
641
  });
471
642
  }
@@ -483,8 +654,17 @@ function parseTableRow(line) {
483
654
  index += 1;
484
655
  }
485
656
  else if (char === "|") {
486
- cells.push(current.trim());
487
- current = "";
657
+ const prev = trimmed[index - 1] ?? "";
658
+ const next = trimmed[index + 1] ?? "";
659
+ // In-cell enum/value pipes look like ACTIVE|ARCHIVED (alnum on both sides).
660
+ // Column separators use " | " or touch markdown separator dashes (---|---).
661
+ if (/[A-Za-z0-9_]/.test(prev) && /[A-Za-z0-9_]/.test(next)) {
662
+ current += "|";
663
+ }
664
+ else {
665
+ cells.push(current.trim());
666
+ current = "";
667
+ }
488
668
  }
489
669
  else {
490
670
  current += char;
@@ -637,15 +817,16 @@ function renderCoverageTable(rules) {
637
817
  ];
638
818
  }
639
819
  export async function analyzeBackendTestCaseCoverage(input) {
820
+ const layout = input.layout ?? resolveBackendTestLayout(undefined);
640
821
  const findings = [];
641
- const readmePath = path.join(input.workspaceRoot, "testcase", "md", "README.md");
822
+ const readmePath = path.join(input.workspaceRoot, ...layout.readmePath.split("/"));
642
823
  const readme = (await exists(readmePath)) ? await readFile(readmePath, "utf8") : "";
643
824
  const coverageScope = parseCoverageScope(readme);
644
825
  findings.push(...coverageScope.findings);
645
826
  const matrix = parseCoverageMatrix(readme);
646
827
  findings.push(...matrix.findings);
647
- const cases = await parseMarkdownCases(input.workspaceRoot);
648
- for (const file of await markdownModuleFiles(input.workspaceRoot)) {
828
+ const cases = await parseMarkdownCases(input.workspaceRoot, layout);
829
+ for (const file of await markdownModuleFiles(input.workspaceRoot, layout)) {
649
830
  const markdown = await readFile(file, "utf8");
650
831
  for (const match of markdown.matchAll(NON_CANONICAL_CASE_HEADING)) {
651
832
  findings.push(`non-canonical Case ID heading must use BE-<MODULE>-<NNN>: ${match[1]}`);
@@ -687,7 +868,10 @@ export async function analyzeBackendTestCaseCoverage(input) {
687
868
  if (!rowCaseIds.includes(testCase.caseId))
688
869
  findings.push(`${testCase.caseId} references ${ruleKey} but is absent from its Coverage Matrix Case IDs`);
689
870
  }
690
- let status = declaredStatus;
871
+ const normalizedDeclaredStatus = /^(?:COVERED|PARTIAL|GAP|CONFLICT)$/.test(declaredStatus)
872
+ ? declaredStatus
873
+ : "PARTIAL";
874
+ let status = normalizedDeclaredStatus;
691
875
  if (status === "COVERED" && missingTestPoints.length > 0)
692
876
  status = "PARTIAL";
693
877
  if (missingTestPoints.length > 0)
@@ -707,7 +891,7 @@ export async function analyzeBackendTestCaseCoverage(input) {
707
891
  rule: row.Rule || "unavailable",
708
892
  requiredTestPoints,
709
893
  caseIds: rowCaseIds,
710
- declaredStatus: /^(?:COVERED|PARTIAL|GAP|CONFLICT)$/.test(declaredStatus) ? declaredStatus : "PARTIAL",
894
+ declaredStatus: normalizedDeclaredStatus,
711
895
  coveredTestPoints,
712
896
  missingTestPoints,
713
897
  status,
@@ -749,8 +933,12 @@ export async function analyzeBackendTestCaseCoverage(input) {
749
933
  findings.push(`${testCase.caseId} missing section or entries: 测试点`);
750
934
  if (testCase.scenarioTypes.length === 0)
751
935
  findings.push(`${testCase.caseId} missing section or entries: 场景类型`);
752
- if (testCase.declaredPrimarySymbols.length !== 1)
936
+ if (isEvidenceOnlyMarkdownCase(testCase)) {
937
+ // Evidence-only meta cases intentionally declare 脚本/primary symbol as 无 and only keep assertion/cross-cutting TPs.
938
+ }
939
+ else if (testCase.declaredPrimarySymbols.length !== 1) {
753
940
  findings.push(`${testCase.caseId} must declare exactly one primary symbol`);
941
+ }
754
942
  if (testCase.unclassifiedTestPoints.length > 0)
755
943
  findings.push(`${testCase.caseId} has unclassified Test Points: ${testCase.unclassifiedTestPoints.join(", ")}`);
756
944
  if (testCase.duplicateBindingTestPoints.length > 0)
@@ -772,8 +960,34 @@ export async function analyzeBackendTestCaseCoverage(input) {
772
960
  if (uniqueOwners.length > 1)
773
961
  findings.push(`${testPoint} is bound as a non-cross-cutting Test Point by multiple Cases: ${uniqueOwners.join(", ")}`);
774
962
  }
775
- const explicitAc = input.sourceBinding.requirementIds.filter((id) => id.startsWith("AC-"));
963
+ const generatedMarkdown = [readme, ...cases.map((item) => item.body)].join("\n\n");
964
+ const sourceCoverageFacts = await collectSourceCoverageFacts({
965
+ workspaceRoot: input.workspaceRoot,
966
+ sourceBinding: input.sourceBinding,
967
+ generatedMarkdown,
968
+ });
969
+ const residualSourceFacts = sourceCoverageFacts.filter((fact) => !fact.covered);
970
+ for (const fact of residualSourceFacts) {
971
+ findings.push(`source coverage is missing ${fact.kind} ${fact.key} from ${fact.sourcePath}`);
972
+ }
973
+ const sourceRequirementIds = sourceCoverageFacts
974
+ .filter((fact) => fact.kind === "requirement-id")
975
+ .map((fact) => fact.key);
976
+ const explicitAc = orderedUnique([
977
+ ...input.sourceBinding.requirementIds.filter((id) => id.startsWith("AC-")),
978
+ ...sourceRequirementIds.filter((id) => id.startsWith("AC-")),
979
+ ]);
776
980
  const coveredAc = new Set(cases.flatMap((item) => item.acIds));
981
+ for (const acId of explicitAc) {
982
+ if (!coveredAc.has(acId))
983
+ findings.push(`explicit source AC is missing from Markdown Cases: ${acId}`);
984
+ }
985
+ // Plan C: deterministic query/filter axis partitions (fail-closed on missing slots).
986
+ const parsedPartitions = parseScenarioPartitions(readme);
987
+ findings.push(...parsedPartitions.issues);
988
+ const declaredPointUniverse = new Set(cases.flatMap((item) => item.testPoints));
989
+ const partitionAssessment = assessScenarioPartitionCoverage(parsedPartitions.partitions, declaredPointUniverse);
990
+ findings.push(...partitionAssessment.findings);
777
991
  const [enumValueCount, coveredEnumValueCount] = dimensionCounts(rules, /enum/);
778
992
  const [boundaryPointCount, coveredBoundaryPointCount] = dimensionCounts(rules, /boundary|length|numeric/);
779
993
  const [formatClassCount, coveredFormatClassCount] = dimensionCounts(rules, /format|pattern|charset/);
@@ -782,8 +996,8 @@ export async function analyzeBackendTestCaseCoverage(input) {
782
996
  const invalidEquivalenceClassCount = rules.reduce((sum, rule) => sum + rule.requiredTestPoints.filter((point) => invalidPointPattern.test(point)).length, 0);
783
997
  const coveredInvalidEquivalenceClassCount = rules.reduce((sum, rule) => sum + rule.coveredTestPoints.filter((point) => invalidPointPattern.test(point)).length, 0);
784
998
  const facts = backendTestCaseCoverageFactsSchema.parse({
785
- schemaId: "backend-test-case-coverage-facts-v3",
786
- schemaVersion: 3,
999
+ schemaId: "backend-test-case-coverage-facts-v4",
1000
+ schemaVersion: 4,
787
1001
  taskId: input.sourceBinding.taskId,
788
1002
  status: findings.length === 0 ? "PASS" : "FAIL",
789
1003
  sourceBinding: input.sourceBinding,
@@ -796,6 +1010,13 @@ export async function analyzeBackendTestCaseCoverage(input) {
796
1010
  }))).filter(Boolean),
797
1011
  ]),
798
1012
  coverageScope: coverageScope.scope,
1013
+ sourceCoverage: {
1014
+ sourceFactCount: sourceCoverageFacts.length,
1015
+ coveredSourceFactCount: sourceCoverageFacts.length - residualSourceFacts.length,
1016
+ residualSourceFactCount: residualSourceFacts.length,
1017
+ facts: sourceCoverageFacts,
1018
+ residual: residualSourceFacts,
1019
+ },
799
1020
  summary: {
800
1021
  explicitAcCount: explicitAc.length,
801
1022
  coveredAcCount: explicitAc.filter((id) => coveredAc.has(id)).length,
@@ -823,6 +1044,7 @@ export async function analyzeBackendTestCaseCoverage(input) {
823
1044
  },
824
1045
  cases: cases.map(({ body: _body, ...item }) => item),
825
1046
  rules,
1047
+ scenarioPartitions: partitionAssessment.facts,
826
1048
  findings: orderedUnique(findings),
827
1049
  evidenceGaps,
828
1050
  conflicts,
@@ -846,6 +1068,22 @@ export async function analyzeBackendTestCaseCoverage(input) {
846
1068
  `- Scope Evidence: ${facts.coverageScope.scopeEvidence.join(", ") || "Unavailable"}`,
847
1069
  ...(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
1070
  "",
1071
+ "## Source Coverage Inventory",
1072
+ "",
1073
+ `- Source facts: ${facts.sourceCoverage.sourceFactCount}`,
1074
+ `- Covered source facts: ${facts.sourceCoverage.coveredSourceFactCount}`,
1075
+ `- Residual source facts: ${facts.sourceCoverage.residualSourceFactCount}`,
1076
+ "",
1077
+ "| Kind | Key | Source Anchor | Source SHA-256 | Exact Quote | Status |",
1078
+ "|---|---|---|---|---|---|",
1079
+ ...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"} |`),
1080
+ "",
1081
+ "## Residual Missing Source Scenarios",
1082
+ "",
1083
+ ...(facts.sourceCoverage.residual.length > 0
1084
+ ? 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.`)
1085
+ : ["- None"]),
1086
+ "",
849
1087
  "## Coverage Summary",
850
1088
  "",
851
1089
  "| Dimension | Total | Covered |",
@@ -902,13 +1140,329 @@ export async function analyzeBackendTestCaseCoverage(input) {
902
1140
  function normalizeModuleStem(markdownPath) {
903
1141
  return path.posix.basename(markdownPath).replace(/\.md$/i, "").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").replace(/_+/g, "_");
904
1142
  }
905
- function expectedScript(markdownPath) {
906
- return `testcase/test_${normalizeModuleStem(markdownPath)}.py`;
1143
+ function expectedScript(markdownPath, layout) {
1144
+ const resolved = layout ?? resolveBackendTestLayout(undefined);
1145
+ return `${resolved.scriptDir}/test_${normalizeModuleStem(markdownPath)}.py`;
907
1146
  }
908
1147
  function symbolCaseId(symbol) {
909
1148
  const match = symbol.match(/^test_(BE(?:_[A-Z0-9]+)+?_\d{2,3})(?:_|$)/i);
910
1149
  return match ? canonicalCaseId(match[1].replaceAll("_", "-")) : undefined;
911
1150
  }
1151
+ function pythonPayloadShapes(source) {
1152
+ const program = String.raw `
1153
+ import ast,json,sys
1154
+ source=sys.stdin.buffer.read().decode('utf-8')
1155
+ tree=ast.parse(source)
1156
+ GLOBAL_PAYLOADS={}
1157
+ GLOBAL_VALUES={}
1158
+ for top in tree.body:
1159
+ if isinstance(top,(ast.Assign,ast.AnnAssign)):
1160
+ 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 []))
1161
+ if top.value is None: continue
1162
+ candidates=[]
1163
+ for node in ast.walk(top.value):
1164
+ 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])
1165
+ 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)
1166
+ for name in names:
1167
+ GLOBAL_PAYLOADS[name]=candidates
1168
+ GLOBAL_VALUES[name]=top.value
1169
+ FUNCTION_PAYLOADS={}
1170
+ FUNCTION_TRANSPORT_SHAPES={}
1171
+ TRANSPORT_PAYLOAD_PARAMS={}
1172
+ for top in ast.walk(tree):
1173
+ if isinstance(top,(ast.FunctionDef,ast.AsyncFunctionDef)) and not top.name.startswith('test_'):
1174
+ candidates=[]
1175
+ assigned={}
1176
+ mutated={}
1177
+ returned_names=set()
1178
+ positional=[arg.arg for arg in [*top.args.posonlyargs,*top.args.args]]
1179
+ call_offset=1 if positional and positional[0] in {'self','cls'} else 0
1180
+ transport=[]
1181
+ for node in ast.walk(top):
1182
+ if isinstance(node,(ast.Assign,ast.AnnAssign)):
1183
+ targets=node.targets if isinstance(node,ast.Assign) else [node.target]
1184
+ if node.value is None: continue
1185
+ for target in targets:
1186
+ if isinstance(target,ast.Name): assigned[target.id]=node.value
1187
+ for target in targets:
1188
+ if isinstance(target,ast.Subscript) and isinstance(target.value,ast.Name) and isinstance(target.slice,ast.Constant) and isinstance(target.slice.value,str):
1189
+ mutated.setdefault(target.value.id,[]).append((target.slice.value,node.value))
1190
+ if isinstance(node,ast.Return):
1191
+ if isinstance(node.value,ast.Dict): candidates.append(node.value)
1192
+ elif isinstance(node.value,ast.Name) and node.value.id in assigned:
1193
+ returned=assigned[node.value.id]
1194
+ if isinstance(returned,ast.Dict): candidates.append(returned)
1195
+ elif isinstance(returned,ast.Call) and isinstance(returned.func,ast.Name) and returned.func.id in FUNCTION_PAYLOADS:
1196
+ candidates.extend(FUNCTION_PAYLOADS[returned.func.id])
1197
+ returned_names.add(node.value.id)
1198
+ if isinstance(node,ast.Call):
1199
+ for keyword in node.keywords:
1200
+ 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:
1201
+ transport.append((keyword.value.id,positional.index(keyword.value.id)-call_offset))
1202
+ if isinstance(node.func,ast.Attribute) and node.func.attr.lower() in {'post','put','patch'} and len(node.args) >= 2:
1203
+ candidate=node.args[1]
1204
+ if isinstance(candidate,ast.Name) and candidate.id in positional:
1205
+ transport.append((candidate.id,positional.index(candidate.id)-call_offset))
1206
+ callee=None
1207
+ if isinstance(node.func,ast.Name): callee=node.func.id
1208
+ elif isinstance(node.func,ast.Attribute): callee=node.func.attr
1209
+ for _nested_name,nested_position in TRANSPORT_PAYLOAD_PARAMS.get(callee,[]):
1210
+ matched=node.args[nested_position] if nested_position >= 0 and nested_position < len(node.args) else None
1211
+ if isinstance(matched,ast.Name) and matched.id in positional:
1212
+ transport.append((matched.id,positional.index(matched.id)-call_offset))
1213
+ for returned_name in returned_names:
1214
+ entries=mutated.get(returned_name,[])
1215
+ if entries:
1216
+ candidates.append(ast.Dict(keys=[ast.Constant(key) for key,_value in entries],values=[value for _key,value in entries]))
1217
+ transport_shapes=[]
1218
+ for node in ast.walk(top):
1219
+ if not isinstance(node,ast.Call): continue
1220
+ for keyword in node.keywords:
1221
+ 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:
1222
+ transport_shapes.append(assigned[keyword.value.id])
1223
+ if candidates: FUNCTION_PAYLOADS[top.name]=candidates
1224
+ if transport_shapes: FUNCTION_TRANSPORT_SHAPES[top.name]=transport_shapes
1225
+ if transport: TRANSPORT_PAYLOAD_PARAMS[top.name]=list(dict.fromkeys(transport))
1226
+ # Propagate transport payload positions through local wrapper chains after all
1227
+ # direct transport methods/functions are known. ast.walk may visit module-level
1228
+ # wrappers before class methods, so one-pass discovery is order-sensitive.
1229
+ for _round in range(6):
1230
+ changed=False
1231
+ for top in ast.walk(tree):
1232
+ if not isinstance(top,(ast.FunctionDef,ast.AsyncFunctionDef)) or top.name.startswith('test_'): continue
1233
+ positional=[arg.arg for arg in [*top.args.posonlyargs,*top.args.args]]
1234
+ call_offset=1 if positional and positional[0] in {'self','cls'} else 0
1235
+ current=list(TRANSPORT_PAYLOAD_PARAMS.get(top.name,[]))
1236
+ for node in ast.walk(top):
1237
+ if not isinstance(node,ast.Call): continue
1238
+ callee=node.func.id if isinstance(node.func,ast.Name) else (node.func.attr if isinstance(node.func,ast.Attribute) else None)
1239
+ for _nested_name,nested_position in TRANSPORT_PAYLOAD_PARAMS.get(callee,[]):
1240
+ matched=node.args[nested_position] if nested_position >= 0 and nested_position < len(node.args) else None
1241
+ if isinstance(matched,ast.Name) and matched.id in positional:
1242
+ item=(matched.id,positional.index(matched.id)-call_offset)
1243
+ if item not in current:
1244
+ current.append(item); changed=True
1245
+ if current: TRANSPORT_PAYLOAD_PARAMS[top.name]=current
1246
+ if not changed: break
1247
+
1248
+ # A local row factory may return pytest.param(_payload_builder(...), ..., id=tp).
1249
+ # Treat its first positional payload builder as shape evidence while keeping the
1250
+ # writer contract free to require direct pytest.param rows for scenario checks.
1251
+ for top in ast.walk(tree):
1252
+ if not isinstance(top,(ast.FunctionDef,ast.AsyncFunctionDef)) or top.name.startswith('test_'): continue
1253
+ for node in ast.walk(top):
1254
+ if not isinstance(node,ast.Return) or not isinstance(node.value,ast.Call): continue
1255
+ returned=node.value
1256
+ if not isinstance(returned.func,ast.Attribute) or returned.func.attr != 'param' or not returned.args: continue
1257
+ payload=returned.args[0]
1258
+ if isinstance(payload,ast.Call) and isinstance(payload.func,ast.Name) and payload.func.id in FUNCTION_PAYLOADS:
1259
+ FUNCTION_PAYLOADS[top.name]=list(FUNCTION_PAYLOADS[payload.func.id])
1260
+ # Resolve module-level parameter collections after local payload/row factories
1261
+ # are known (for example CASE_ROWS = [_post_case(...) , ...]).
1262
+ for top in tree.body:
1263
+ if not isinstance(top,ast.Assign): continue
1264
+ names=[target.id for target in top.targets if isinstance(target,ast.Name)]
1265
+ resolved=[]
1266
+ for node in ast.walk(top.value):
1267
+ if isinstance(node,ast.Call) and isinstance(node.func,ast.Name) and node.func.id in FUNCTION_PAYLOADS:
1268
+ resolved.extend(FUNCTION_PAYLOADS[node.func.id])
1269
+ for name in names:
1270
+ if resolved: GLOBAL_PAYLOADS[name]=list(GLOBAL_PAYLOADS.get(name,[]))+resolved
1271
+
1272
+ def literal(node):
1273
+ if isinstance(node, ast.Constant) and isinstance(node.value, (str,int,float,bool,type(None))):
1274
+ return repr(node.value)
1275
+ return None
1276
+
1277
+ def collect_dict(node, prefix, paths, values):
1278
+ if not isinstance(node, ast.Dict): return
1279
+ for key,value in zip(node.keys,node.values):
1280
+ if not isinstance(key, ast.Constant) or not isinstance(key.value,str): continue
1281
+ # Ignore writer helper/meta keys that are not DTO request fields.
1282
+ 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_'):
1283
+ continue
1284
+ current = key.value if not prefix else prefix + '.' + key.value
1285
+ paths.add(current)
1286
+ lv=literal(value)
1287
+ if lv is not None: values.setdefault(current,set()).add(lv)
1288
+ if isinstance(value, ast.Dict): collect_dict(value,current,paths,values)
1289
+ elif isinstance(value,(ast.List,ast.Tuple)):
1290
+ for item in value.elts:
1291
+ if isinstance(item,ast.Dict): collect_dict(item,current,paths,values)
1292
+
1293
+ PAYLOAD_NAMES={'payload','body','request_body','request_json','dto','json','data','json_body'}
1294
+
1295
+ def shape_only(node):
1296
+ if isinstance(node,ast.Dict):
1297
+ values=[]
1298
+ for value in node.values:
1299
+ if isinstance(value,(ast.Dict,ast.List,ast.Tuple)): values.append(shape_only(value))
1300
+ else: values.append(ast.Name(id='_PAYLOAD_VALUE_',ctx=ast.Load()))
1301
+ return ast.Dict(keys=node.keys,values=values)
1302
+ if isinstance(node,(ast.List,ast.Tuple)):
1303
+ 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]
1304
+ return ast.List(elts=items,ctx=ast.Load()) if isinstance(node,ast.List) else ast.Tuple(elts=items,ctx=ast.Load())
1305
+ return node
1306
+
1307
+ def payload_expr_nodes(expr, assigned, parameters, seen=None):
1308
+ seen=set() if seen is None else seen
1309
+ if isinstance(expr,ast.Dict): return [expr]
1310
+ if isinstance(expr,ast.Name):
1311
+ if expr.id in seen: return []
1312
+ seen.add(expr.id)
1313
+ if expr.id in assigned: return payload_expr_nodes(assigned[expr.id],assigned,parameters,seen)
1314
+ if expr.id in parameters:
1315
+ result=[]
1316
+ for value in parameters[expr.id]: result.extend(payload_expr_nodes(value,assigned,parameters,set(seen)))
1317
+ return result
1318
+ return list(GLOBAL_PAYLOADS.get(expr.id,[]))
1319
+ if isinstance(expr,ast.Subscript) and isinstance(expr.value,ast.Name) and isinstance(expr.slice,ast.Constant) and isinstance(expr.slice.value,str):
1320
+ result=[]
1321
+ for candidate in parameters.get(expr.value.id,[]):
1322
+ if not isinstance(candidate,ast.Dict): continue
1323
+ for key,value in zip(candidate.keys,candidate.values):
1324
+ if isinstance(key,ast.Constant) and key.value == expr.slice.value:
1325
+ result.extend(shape_only(item) for item in payload_expr_nodes(value,assigned,parameters,set(seen)))
1326
+ return result
1327
+ if isinstance(expr,ast.Call):
1328
+ result=[]
1329
+ if isinstance(expr.func,ast.Name) and expr.func.id in FUNCTION_PAYLOADS:
1330
+ result.extend(FUNCTION_PAYLOADS[expr.func.id])
1331
+ override_keys=[]; override_values=[]
1332
+ for keyword in expr.keywords:
1333
+ if keyword.arg:
1334
+ override_keys.append(ast.Constant(keyword.arg)); override_values.append(keyword.value)
1335
+ if override_keys: result.append(ast.Dict(keys=override_keys,values=override_values))
1336
+ # A transport body may be assembled by a local combinator such as
1337
+ # _apply_patch(_base_body(...), patch). Because this traversal starts
1338
+ # only from an identified transport-body position, recursively merging
1339
+ # argument shapes is narrower than scanning arbitrary helper calls.
1340
+ for argument in expr.args:
1341
+ result.extend(payload_expr_nodes(argument,assigned,parameters,set(seen)))
1342
+ for keyword in expr.keywords:
1343
+ result.extend(payload_expr_nodes(keyword.value,assigned,parameters,set(seen)))
1344
+ return result
1345
+ return []
1346
+
1347
+ def decorator_parameter_values(dec):
1348
+ result={}
1349
+ 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
1350
+ if not isinstance(dec.args[0],ast.Constant) or not isinstance(dec.args[0].value,str): return result
1351
+ names=[item.strip() for item in dec.args[0].value.split(',')]
1352
+ rows=dec.args[1]
1353
+ if isinstance(rows,ast.Name) and rows.id in GLOBAL_VALUES: rows=GLOBAL_VALUES[rows.id]
1354
+ for node in ast.walk(rows):
1355
+ if not isinstance(node,ast.Call) or not isinstance(node.func,ast.Attribute) or node.func.attr != 'param': continue
1356
+ for index,name in enumerate(names):
1357
+ if index < len(node.args): result.setdefault(name,[]).append(node.args[index])
1358
+ return result
1359
+
1360
+ def decorator_payload_nodes(parameters,assigned):
1361
+ result=[]
1362
+ for name,values in parameters.items():
1363
+ if name.lower() not in PAYLOAD_NAMES: continue
1364
+ for value in values: result.extend(payload_expr_nodes(value,assigned,parameters))
1365
+ return result
1366
+
1367
+ def fallback_payload_nodes(fn):
1368
+ result=[]
1369
+ assigned={}
1370
+ for node in ast.walk(fn):
1371
+ if isinstance(node,(ast.Assign,ast.AnnAssign)):
1372
+ if node.value is None: continue
1373
+ targets=node.targets if isinstance(node,ast.Assign) else [node.target]
1374
+ for target in targets:
1375
+ if isinstance(target,ast.Name): assigned[target.id]=node.value
1376
+ parameters={}
1377
+ for dec in fn.decorator_list:
1378
+ for name,values in decorator_parameter_values(dec).items(): parameters.setdefault(name,[]).extend(values)
1379
+ for node in ast.walk(fn):
1380
+ if not isinstance(node,ast.Call): continue
1381
+ callee=None
1382
+ if isinstance(node.func,ast.Name): callee=node.func.id
1383
+ elif isinstance(node.func,ast.Attribute): callee=node.func.attr
1384
+ result.extend(FUNCTION_TRANSPORT_SHAPES.get(callee,[]))
1385
+ # Wrapper payloads are fallback evidence: they are useful when the Case
1386
+ # contract requires a body, but must not contaminate GET/DELETE targets
1387
+ # merely because setup creates a resource first.
1388
+ for parameter_name,position in TRANSPORT_PAYLOAD_PARAMS.get(callee,[]):
1389
+ matched=next((keyword.value for keyword in node.keywords if keyword.arg == parameter_name),None)
1390
+ if matched is None and position >= 0 and position < len(node.args): matched=node.args[position]
1391
+ if matched is not None: result.extend(payload_expr_nodes(matched,assigned,parameters))
1392
+ return result
1393
+
1394
+ def is_direct_transport_call(node):
1395
+ if not isinstance(node,ast.Call) or not isinstance(node.func,ast.Attribute): return False
1396
+ if node.func.attr.lower() not in {'get','post','put','patch','delete','request'}: return False
1397
+ receiver_tokens=[]
1398
+ current=node.func.value
1399
+ while isinstance(current,ast.Attribute):
1400
+ receiver_tokens.append(current.attr.lower())
1401
+ current=current.value
1402
+ if isinstance(current,ast.Name): receiver_tokens.append(current.id.lower())
1403
+ return any(token in {'requests','httpx','client','session','api_client','http_client'} or token.endswith('_client') for token in receiver_tokens)
1404
+
1405
+ def payload_nodes(fn):
1406
+ result=[]
1407
+ assigned={}
1408
+ for node in ast.walk(fn):
1409
+ if isinstance(node,(ast.Assign,ast.AnnAssign)):
1410
+ if node.value is None: continue
1411
+ targets=node.targets if isinstance(node,ast.Assign) else [node.target]
1412
+ for target in targets:
1413
+ if isinstance(target,ast.Name): assigned[target.id]=node.value
1414
+ parameters={}
1415
+ for dec in fn.decorator_list:
1416
+ for name,values in decorator_parameter_values(dec).items(): parameters.setdefault(name,[]).extend(values)
1417
+ result.extend(decorator_payload_nodes(parameters,assigned))
1418
+ for dec in fn.decorator_list:
1419
+ for node in ast.walk(dec):
1420
+ if isinstance(node,ast.Call) and isinstance(node.func,ast.Name) and node.func.id in FUNCTION_PAYLOADS:
1421
+ result.extend(payload_expr_nodes(node,assigned,parameters))
1422
+ elif isinstance(node,ast.Name) and node.id in GLOBAL_PAYLOADS:
1423
+ result.extend(GLOBAL_PAYLOADS[node.id])
1424
+ # Observe local payload/body variables only when they flow into a direct
1425
+ # HTTP transport call. Arbitrary setup variables are not target requests.
1426
+ for node in ast.walk(fn):
1427
+ if not is_direct_transport_call(node): continue
1428
+ for keyword in node.keywords:
1429
+ if keyword.arg in PAYLOAD_NAMES: result.extend(payload_expr_nodes(keyword.value,assigned,parameters))
1430
+ return result
1431
+
1432
+ out={}
1433
+ for fn in ast.walk(tree):
1434
+ if isinstance(fn,(ast.FunctionDef,ast.AsyncFunctionDef)) and fn.name.startswith('test_'):
1435
+ paths=set(); values={}
1436
+ fallback_paths=set(); fallback_values={}
1437
+ for node in payload_nodes(fn): collect_dict(node,'',paths,values)
1438
+ for node in fallback_payload_nodes(fn): collect_dict(node,'',fallback_paths,fallback_values)
1439
+ out[fn.name]={
1440
+ 'paths':sorted(paths),
1441
+ 'values':{key:sorted(items) for key,items in sorted(values.items())},
1442
+ 'fallbackPaths':sorted(fallback_paths),
1443
+ 'fallbackValues':{key:sorted(items) for key,items in sorted(fallback_values.items())},
1444
+ }
1445
+ print(json.dumps(out,ensure_ascii=True))
1446
+ `;
1447
+ // Pass bytes and decode stdin explicitly as UTF-8 in Python. Clean-env
1448
+ // Windows Python may expose stdin as GBK; decoding generated Chinese/emoji
1449
+ // source through that locale can create surrogateescape code points and make
1450
+ // ast.parse fail with "surrogates not allowed".
1451
+ const result = spawnSync("python", ["-c", program], { input: Buffer.from(source, "utf8"), encoding: "utf8", maxBuffer: 4 * 1024 * 1024, windowsHide: true });
1452
+ if (result.status !== 0 || !result.stdout.trim())
1453
+ return new Map();
1454
+ try {
1455
+ const parsed = JSON.parse(result.stdout);
1456
+ return new Map(Object.entries(parsed));
1457
+ }
1458
+ catch {
1459
+ return new Map();
1460
+ }
1461
+ }
1462
+ function metadataCaseIds(region) {
1463
+ return orderedUnique([...region.matchAll(/^\s*Case-ID\s*:\s*(BE-[A-Z0-9]+(?:-[A-Z0-9]+)*)\s*$/gmi)]
1464
+ .map((match) => canonicalCaseId(match[1])));
1465
+ }
912
1466
  function metadataTestPoints(region, label) {
913
1467
  const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
914
1468
  const match = new RegExp(`^\\s*${escaped}\\s*:\\s*(.*)$`, "mi").exec(region);
@@ -928,10 +1482,35 @@ function pytestFunctionRegionStart(source, functionIndex) {
928
1482
  }
929
1483
  return regionStart;
930
1484
  }
1485
+ function pytestFunctionBodyEnd(source, signatureEnd, functionIndent) {
1486
+ let cursor = source.indexOf("\n", signatureEnd);
1487
+ if (cursor < 0)
1488
+ return source.length;
1489
+ cursor += 1;
1490
+ const baseIndent = functionIndent.replaceAll("\t", " ").length;
1491
+ while (cursor < source.length) {
1492
+ const nextLine = source.indexOf("\n", cursor);
1493
+ const lineEnd = nextLine < 0 ? source.length : nextLine;
1494
+ const line = source.slice(cursor, lineEnd).replace(/\r$/, "");
1495
+ if (line.trim()) {
1496
+ const indentation = line.match(/^[ \t]*/)?.[0]?.replaceAll("\t", " ").length ?? 0;
1497
+ if (indentation <= baseIndent)
1498
+ return cursor;
1499
+ }
1500
+ if (nextLine < 0)
1501
+ return source.length;
1502
+ cursor = nextLine + 1;
1503
+ }
1504
+ return source.length;
1505
+ }
931
1506
  function pytestParameterCollections(source) {
932
1507
  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])));
1508
+ for (const match of source.matchAll(/^(_?[A-Z][A-Z0-9_]*)\s*=\s*\[(?![^\r\n]*\])([\s\S]*?)^\]/gm)) {
1509
+ const explicitIds = [...match[2].matchAll(/\bid\s*=\s*["'](TP-[A-Z0-9-]+)["']/g)].map((item) => item[1]);
1510
+ const factoryIds = explicitIds.length === 0
1511
+ ? [...match[2].matchAll(/["'](TP-[A-Z0-9-]+)["']/g)].map((item) => item[1])
1512
+ : [];
1513
+ collections.set(match[1], orderedUnique([...explicitIds, ...factoryIds]));
935
1514
  }
936
1515
  return collections;
937
1516
  }
@@ -939,21 +1518,29 @@ function pytestSymbols(script, source) {
939
1518
  const matches = [...source.matchAll(/^([ \t]*)(?:async\s+)?def\s+(test_[A-Za-z0-9_]+)\s*\([^)]*\)\s*(?:->\s*[^:\r\n]+)?\s*:/gm)];
940
1519
  const regionStarts = matches.map((match) => pytestFunctionRegionStart(source, match.index));
941
1520
  const parameterCollections = pytestParameterCollections(source);
1521
+ const payloadShapes = pythonPayloadShapes(source);
942
1522
  return matches.map((match, index) => {
943
1523
  const regionStart = regionStarts[index];
944
- const regionEnd = regionStarts[index + 1] ?? source.length;
1524
+ const regionEnd = pytestFunctionBodyEnd(source, match.index + match[0].length, match[1]);
945
1525
  const region = source.slice(match.index, regionEnd);
946
1526
  const decorators = source.slice(regionStart, match.index);
947
- const ids = caseIds(region);
1527
+ const ids = metadataCaseIds(region);
948
1528
  const fromSymbol = symbolCaseId(match[2]);
949
1529
  if (fromSymbol)
950
1530
  ids.unshift(fromSymbol);
1531
+ const explicitParameterIds = [...decorators.matchAll(/\bid\s*=\s*["'](TP-[A-Z0-9-]+)["']/g)].map((item) => item[1]);
1532
+ const collectionParameterIds = [...parameterCollections.entries()].flatMap(([name, values]) => new RegExp(`\\b${name}\\b`).test(decorators) ? values : []);
1533
+ const factoryParameterIds = explicitParameterIds.length === 0 && collectionParameterIds.length === 0
1534
+ ? [...decorators.matchAll(/["'](TP-[A-Z0-9-]+)["']/g)].map((item) => item[1])
1535
+ : [];
951
1536
  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 : []),
1537
+ ...explicitParameterIds,
1538
+ ...collectionParameterIds,
1539
+ ...factoryParameterIds,
954
1540
  ]);
955
1541
  const assertionTestPoints = metadataTestPoints(region, "Assertion-Test-Points");
956
1542
  const crossCuttingTestPoints = metadataTestPoints(region, "Cross-Cutting-Test-Points");
1543
+ const payloadShape = payloadShapes.get(match[2]) ?? { paths: [], values: {} };
957
1544
  return {
958
1545
  script,
959
1546
  symbol: match[2],
@@ -962,13 +1549,69 @@ function pytestSymbols(script, source) {
962
1549
  parameterIds,
963
1550
  assertionTestPoints,
964
1551
  crossCuttingTestPoints,
1552
+ payloadPaths: payloadShape.paths,
1553
+ payloadValues: payloadShape.values,
1554
+ fallbackPayloadPaths: payloadShape.fallbackPaths ?? [],
1555
+ fallbackPayloadValues: payloadShape.fallbackValues ?? {},
965
1556
  };
966
1557
  });
967
1558
  }
1559
+ function executionSignature(testCase) {
1560
+ const operation = (testCase.body.match(/\b(?:GET|POST|PUT|PATCH|DELETE)\s+\/[^\s`|)]+/i)?.[0] ?? "operation-unavailable").toUpperCase();
1561
+ const inputPartition = [sectionBody(testCase.body, ["测试数据", "Test Data"]), testCase.scenarioTypes.join(" "), testCase.testPoints.join(" ")].join(" ");
1562
+ const precondition = sectionBody(testCase.body, ["前置条件", "Preconditions", "Precondition"]);
1563
+ const expectedOutcome = sectionBody(testCase.body, ["预期结果", "Expected Results", "Expected Result"]);
1564
+ const normalized = [operation, inputPartition, precondition, expectedOutcome]
1565
+ .map((value) => value.replaceAll(/\s+/g, " ").trim().toLowerCase())
1566
+ .join("\n");
1567
+ return createHash("sha256").update(normalized).digest("hex");
1568
+ }
1569
+ function assessPayloadContract(testCase, refs) {
1570
+ const requiredPaths = testCase.payloadContract.requiredPaths;
1571
+ const directObservedPaths = orderedUnique(refs.flatMap((item) => item.payloadPaths));
1572
+ const useFallback = requiredPaths.length > 0 && directObservedPaths.length === 0;
1573
+ const analyzedObservedPaths = useFallback
1574
+ ? orderedUnique(refs.flatMap((item) => item.fallbackPayloadPaths))
1575
+ : directObservedPaths;
1576
+ // A no-body target contract describes the target GET/DELETE request, not
1577
+ // setup POST/PUT calls in the same lifecycle test function.
1578
+ const observedPaths = testCase.payloadContract.noPayload ? [] : analyzedObservedPaths;
1579
+ const allowedPaths = testCase.payloadContract.allowedPaths;
1580
+ if (!testCase.payloadContract.declared) {
1581
+ return { status: "UNAVAILABLE", requiredPaths, allowedPaths, observedPaths, missingPaths: [], unexpectedPaths: [], enumMismatches: [] };
1582
+ }
1583
+ const missingPaths = requiredPaths.filter((item) => !observedPaths.includes(item));
1584
+ const unexpectedPaths = testCase.payloadContract.noPayload
1585
+ ? observedPaths
1586
+ : allowedPaths.length > 0
1587
+ ? observedPaths.filter((item) => !allowedPaths.includes(item) && !allowedPaths.some((allowed) => item.startsWith(`${allowed}.`)))
1588
+ : [];
1589
+ const enumMismatches = [];
1590
+ const delegatesInvalidEnumToScenarioGate = testCase.testPointBindings
1591
+ .filter((binding) => binding.mode === "variant")
1592
+ .some((binding) => /(?:ENUM|CATEGORY).*(?:INVALID|UNKNOWN|CASE|WHITESPACE|EMPTY|WRONG-TYPE)|(?:INVALID|UNKNOWN|CASE|WHITESPACE|EMPTY|WRONG-TYPE).*(?:ENUM|CATEGORY)/.test(binding.testPoint));
1593
+ for (const [field, allowed] of Object.entries(testCase.payloadContract.enumValues)) {
1594
+ const observed = orderedUnique(refs.flatMap((item) => (useFallback ? item.fallbackPayloadValues[field] : item.payloadValues[field]) ?? []).map((value) => value.replace(/^['"]|['"]$/g, "")));
1595
+ for (const value of observed) {
1596
+ if (!allowed.includes(value) && !delegatesInvalidEnumToScenarioGate)
1597
+ enumMismatches.push(`${field}=${value}; allowed=${allowed.join("|")}`);
1598
+ }
1599
+ }
1600
+ return {
1601
+ status: missingPaths.length > 0 || unexpectedPaths.length > 0 || enumMismatches.length > 0 ? "UNSAFE" : "SAFE",
1602
+ requiredPaths,
1603
+ allowedPaths,
1604
+ observedPaths,
1605
+ missingPaths,
1606
+ unexpectedPaths,
1607
+ enumMismatches,
1608
+ };
1609
+ }
968
1610
  export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
969
- const cases = await parseMarkdownCases(input.workspaceRoot);
1611
+ const layout = input.layout ?? resolveBackendTestLayout(undefined);
1612
+ const cases = await parseMarkdownCases(input.workspaceRoot, layout);
970
1613
  const modulePaths = orderedUnique(cases.map((item) => item.markdownPath));
971
- const scripts = orderedUnique(cases.flatMap((item) => [...item.declaredScripts, expectedScript(item.markdownPath)]));
1614
+ const scripts = orderedUnique(cases.flatMap((item) => [...item.declaredScripts, expectedScript(item.markdownPath, layout)]));
972
1615
  const allSymbols = [];
973
1616
  const scriptFiles = [];
974
1617
  for (const script of scripts) {
@@ -981,7 +1624,7 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
981
1624
  const findings = [];
982
1625
  const entries = [];
983
1626
  for (const testCase of cases) {
984
- const expected = expectedScript(testCase.markdownPath);
1627
+ const expected = expectedScript(testCase.markdownPath, layout);
985
1628
  const declared = testCase.declaredScripts[0];
986
1629
  const refs = allSymbols.filter((item) => item.caseIds.includes(testCase.caseId));
987
1630
  const actualScripts = orderedUnique(refs.map((item) => item.script));
@@ -998,21 +1641,44 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
998
1641
  ...crossCuttingTestPoints.filter((point) => crossCuttingBindings.includes(point)),
999
1642
  ]);
1000
1643
  const declaredPrimarySymbol = testCase.declaredPrimarySymbols[0];
1644
+ const evidenceOnly = isEvidenceOnlyMarkdownCase(testCase);
1001
1645
  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";
1646
+ let status = evidenceOnly && refs.length === 0
1647
+ ? "EXACT_1_TO_1"
1648
+ : refs.length === 0
1649
+ ? "MISSING_PYTEST"
1650
+ : refs.length > 1
1651
+ ? "MULTIPLE_PYTEST"
1652
+ : "EXACT_1_TO_1";
1003
1653
  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")
1654
+ const payloadAssessment = assessPayloadContract(testCase, refs);
1655
+ const signature = executionSignature(testCase);
1656
+ if (evidenceOnly) {
1657
+ if (refs.length > 0) {
1010
1658
  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"}`);
1659
+ entryFindings.push(`${testCase.caseId} evidence-only Markdown Case must not materialize a business pytest symbol; actualSymbols=${symbols.join(", ") || "none"}`);
1660
+ }
1012
1661
  }
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));
1662
+ else {
1663
+ if (refs.length === 0)
1664
+ entryFindings.push(`${testCase.caseId} has no pytest symbol`);
1665
+ if (refs.length > 1)
1666
+ entryFindings.push(`${testCase.caseId} maps to multiple pytest symbols: ${symbols.join(", ")}`);
1667
+ if (!declared || declared !== expected || actualScripts.some((script) => script !== expected) || !declaredPrimarySymbol || symbols.some((symbol) => symbol !== declaredPrimarySymbol)) {
1668
+ if (refs.length > 0 && status === "EXACT_1_TO_1")
1669
+ status = "SCRIPT_MISMATCH";
1670
+ 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"}`);
1671
+ }
1672
+ }
1673
+ const missingVariant = evidenceOnly
1674
+ ? []
1675
+ : variantTestPoints.filter((point) => !parameterIds.includes(point));
1676
+ const missingAssertion = evidenceOnly
1677
+ ? []
1678
+ : assertionTestPoints.filter((point) => !assertionBindings.includes(point));
1679
+ const missingCrossCutting = evidenceOnly
1680
+ ? []
1681
+ : crossCuttingTestPoints.filter((point) => !crossCuttingBindings.includes(point));
1016
1682
  const extraPoints = orderedUnique([...parameterIds, ...assertionBindings, ...crossCuttingBindings]).filter((point) => !testCase.testPoints.includes(point));
1017
1683
  if (testCase.unclassifiedTestPoints.length > 0 || testCase.duplicateBindingTestPoints.length > 0) {
1018
1684
  if (refs.length > 0 && status === "EXACT_1_TO_1")
@@ -1039,6 +1705,16 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
1039
1705
  status = "TEST_POINT_BINDING_EXTRA";
1040
1706
  entryFindings.push(`${testCase.caseId} has extra pytest Test Point bindings: ${extraPoints.join(", ")}`);
1041
1707
  }
1708
+ if (payloadAssessment.status === "UNAVAILABLE") {
1709
+ if (refs.length > 0 && status === "EXACT_1_TO_1")
1710
+ status = "PAYLOAD_CONTRACT_UNAVAILABLE";
1711
+ entryFindings.push(`${testCase.caseId} has no deterministic Payload Contract declaration`);
1712
+ }
1713
+ else if (payloadAssessment.status === "UNSAFE") {
1714
+ if (refs.length > 0 && status === "EXACT_1_TO_1")
1715
+ status = "PAYLOAD_SHAPE_MISMATCH";
1716
+ entryFindings.push(`${testCase.caseId} payload contract mismatch: missing=${payloadAssessment.missingPaths.join(", ") || "none"}; unexpected=${payloadAssessment.unexpectedPaths.join(", ") || "none"}; enum=${payloadAssessment.enumMismatches.join(", ") || "none"}`);
1717
+ }
1042
1718
  findings.push(...entryFindings);
1043
1719
  entries.push({
1044
1720
  markdownModule: testCase.markdownPath,
@@ -1056,6 +1732,8 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
1056
1732
  parameterIds,
1057
1733
  cardinality,
1058
1734
  status,
1735
+ payloadAssessment,
1736
+ executionSignature: signature,
1059
1737
  findings: entryFindings,
1060
1738
  });
1061
1739
  }
@@ -1082,22 +1760,40 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
1082
1760
  parameterIds: symbol.parameterIds,
1083
1761
  cardinality: "0:1",
1084
1762
  status: unknownId ? "EXTRA_PYTEST" : "SYMBOL_MISSING_CASE_ID",
1763
+ payloadAssessment: {
1764
+ status: "UNAVAILABLE",
1765
+ requiredPaths: [],
1766
+ allowedPaths: [],
1767
+ observedPaths: symbol.payloadPaths,
1768
+ missingPaths: [],
1769
+ unexpectedPaths: [],
1770
+ enumMismatches: [],
1771
+ },
1085
1772
  findings: [message],
1086
1773
  });
1087
1774
  }
1775
+ const duplicateSignatures = new Map();
1776
+ for (const entry of entries.filter((item) => item.caseId && item.executionSignature)) {
1777
+ const owners = duplicateSignatures.get(entry.executionSignature) ?? [];
1778
+ owners.push(entry.caseId);
1779
+ duplicateSignatures.set(entry.executionSignature, owners);
1780
+ }
1781
+ const duplicateSignatureGroups = [...duplicateSignatures.entries()].filter(([, owners]) => orderedUnique(owners).length > 1);
1782
+ for (const [signature, owners] of duplicateSignatureGroups)
1783
+ findings.push(`duplicate execution signature ${signature}: ${orderedUnique(owners).join(", ")}`);
1088
1784
  const exactModuleCount = modulePaths.filter((modulePath) => {
1089
- const expected = expectedScript(modulePath);
1785
+ const expected = expectedScript(modulePath, layout);
1090
1786
  const moduleCases = cases.filter((item) => item.markdownPath === modulePath);
1091
1787
  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
1788
  }).length;
1093
1789
  const caseEntries = entries.filter((entry) => entry.cardinality !== "0:1");
1094
1790
  const facts = backendTestMarkdownPytestCorrespondenceFactsSchema.parse({
1095
- schemaId: "backend-test-markdown-pytest-correspondence-facts-v2",
1096
- schemaVersion: 2,
1791
+ schemaId: "backend-test-markdown-pytest-correspondence-facts-v3",
1792
+ schemaVersion: 3,
1097
1793
  taskId: input.taskId,
1098
1794
  status: findings.length === 0 ? "PASS" : "FAIL",
1099
1795
  inputFiles: await inputFileFacts(input.workspaceRoot, [
1100
- path.join(input.workspaceRoot, "testcase", "md", "README.md"),
1796
+ path.join(input.workspaceRoot, ...layout.readmePath.split("/")),
1101
1797
  ...(await markdownModuleFiles(input.workspaceRoot)),
1102
1798
  ...scriptFiles,
1103
1799
  ].filter((file) => file && path.isAbsolute(file))),
@@ -1118,6 +1814,10 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
1118
1814
  crossCuttingTestPointCount: cases.reduce((sum, item) => sum + item.testPointBindings.filter((binding) => binding.mode === "cross-cutting").length, 0),
1119
1815
  unclassifiedTestPointCount: cases.reduce((sum, item) => sum + item.unclassifiedTestPoints.length, 0),
1120
1816
  duplicateBindingTestPointCount: cases.reduce((sum, item) => sum + item.duplicateBindingTestPoints.length, 0),
1817
+ payloadSafeCount: caseEntries.filter((entry) => entry.payloadAssessment.status === "SAFE").length,
1818
+ payloadUnsafeCount: caseEntries.filter((entry) => entry.payloadAssessment.status === "UNSAFE").length,
1819
+ payloadUnavailableCount: caseEntries.filter((entry) => entry.payloadAssessment.status === "UNAVAILABLE").length,
1820
+ duplicateExecutionSignatureCount: duplicateSignatureGroups.length,
1121
1821
  },
1122
1822
  entries,
1123
1823
  findings: orderedUnique(findings),
@@ -1145,12 +1845,16 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
1145
1845
  `- Cross-Cutting Test Points: ${facts.summary.crossCuttingTestPointCount}`,
1146
1846
  `- Unclassified Test Points: ${facts.summary.unclassifiedTestPointCount}`,
1147
1847
  `- Duplicate Test Point Bindings: ${facts.summary.duplicateBindingTestPointCount}`,
1848
+ `- Payload SAFE: ${facts.summary.payloadSafeCount}`,
1849
+ `- Payload UNSAFE: ${facts.summary.payloadUnsafeCount}`,
1850
+ `- Payload UNAVAILABLE: ${facts.summary.payloadUnavailableCount}`,
1851
+ `- Duplicate Execution Signatures: ${facts.summary.duplicateExecutionSignatureCount}`,
1148
1852
  "",
1149
1853
  "## Correspondence Matrix",
1150
1854
  "",
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} |`),
1855
+ "| 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 |",
1856
+ "|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|",
1857
+ ...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
1858
  "",
1155
1859
  "## Findings",
1156
1860
  "",