@tea-agent/loop-agent 0.38.1 → 0.38.3

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 (180) hide show
  1. package/CHANGELOG.md +126 -12
  2. package/README.md +2 -0
  3. package/bin/loop-agent.js +7 -3
  4. package/dist/application/dag/generate-task-dag.js +9 -0
  5. package/dist/application/task-lifecycle/advance.js +4 -0
  6. package/dist/build-stamp.json +3 -3
  7. package/dist/commands/init.js +2 -14
  8. package/dist/commands/task-advance.js +31 -0
  9. package/dist/executors/dag-pi-executor.js +99 -5
  10. package/dist/executors/pi-event-serializer.js +42 -1
  11. package/dist/executors/pi-executor.js +51 -0
  12. package/dist/executors/pi-extension-resolver.js +233 -0
  13. package/dist/executors/pi-sdk-executor.js +187 -59
  14. package/dist/executors/shell-executor.js +324 -16
  15. package/dist/executors/shell-write-guard.js +7 -0
  16. package/dist/governance/manifest-types.js +0 -2
  17. package/dist/shared/operator/capabilities.js +87 -22
  18. package/dist/task/config-types.js +22 -0
  19. package/dist/task/contract/constants.js +1 -0
  20. package/dist/task/contract/diff.js +26 -0
  21. package/dist/task/contract/project.js +5 -0
  22. package/dist/task/contract/schema.js +6 -1
  23. package/dist/task/source-prepare/build-draft.js +15 -0
  24. package/dist/worker/console/chat/deferred-turn.js +91 -0
  25. package/dist/worker/console/chat/pi-runtime.js +10 -5
  26. package/dist/worker/console/chat/routes.js +458 -47
  27. package/dist/worker/console/chat/semantic-activity.js +13 -13
  28. package/dist/worker/console/chat/session-store.js +146 -2
  29. package/dist/worker/console/harness-pi-model-matrix.js +2 -11
  30. package/dist/worker/console/operator-actions.js +5 -4
  31. package/dist/worker/console/pi-readiness.js +5 -4
  32. package/dist/worker/console/recovery-cta.js +4 -4
  33. package/dist/worker/console/recovery-error-copy.js +4 -4
  34. package/dist/worker/console/server.js +6 -1
  35. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-Hfsot6rP.js → abnfDiagram-N423BO3Z-C4kVYweA.js} +1 -1
  36. package/dist/worker/console/static/assets/{arc-YGwEASin.js → arc-Bj2M8iO5.js} +1 -1
  37. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-CmOM0F92.js → architectureDiagram-T3A2C74G-CLlXe4-6.js} +1 -1
  38. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-DDLuaHC5.js → blockDiagram-VBNYF7ZC-psEp5xH0.js} +1 -1
  39. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-B0Um43W4.js → c4Diagram-5PPSVZJV-DeKcOCGJ.js} +1 -1
  40. package/dist/worker/console/static/assets/channel-CPF4N7pf.js +1 -0
  41. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-DvBN4JZc.js → chunk-2GRJ4B5K-K0C744p1.js} +1 -1
  42. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-B1UVgW4E.js → chunk-2Q5K7J3B-xbNw4J9-.js} +1 -1
  43. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-Cf0GgXzX.js → chunk-5RXB4S5H-DjLaSDUO.js} +1 -1
  44. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-DbNA24sj.js → chunk-5VM5RSS4-CtiPlKel.js} +1 -1
  45. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-Bi_0Be06.js → chunk-6Q2QTUOP-DZtolir7.js} +1 -1
  46. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-C8pPGvRd.js → chunk-GF5L2VYU-CPcdNeew.js} +1 -1
  47. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-BitQY8Z-.js → chunk-JWPE2WC7-NgHc6V37.js} +1 -1
  48. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-U67GCiEo.js → chunk-KBJHAD2P-DQ_T-hg8.js} +1 -1
  49. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-IoJwXPBA.js → chunk-RYQCIY6F-DgLsxcVP.js} +1 -1
  50. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-YDZ_UHgE.js → chunk-XXDRQBXY-UrVoM9z1.js} +1 -1
  51. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-C2DwA_Y1.js +1 -0
  52. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-C2DwA_Y1.js +1 -0
  53. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-CUZgsiRC.js → cose-bilkent-JH36ORCC-CLkYvTb3.js} +1 -1
  54. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-COmCh4pT.js → cynefin-VYW2F7L2-BwT_xKrE.js} +1 -1
  55. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-Ta6FSm68.js → cynefinDiagram-MW4NZA55-Bi9tuzif.js} +1 -1
  56. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-UdcR-VW9.js → dagre-VZM6K2ZE-BCIqkWBV.js} +1 -1
  57. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-DybcO3Et.js → diagram-7IWD3JNH-Dip6l9_Z.js} +1 -1
  58. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-wErDFWLw.js → diagram-B4RE2ZJO-B-xkh_wK.js} +1 -1
  59. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-DkVHZWqz.js → diagram-LBJQPF4R-DZO0kTt_.js} +1 -1
  60. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-DdqWs7QN.js → diagram-Q27KOJAE-D6ZplNbZ.js} +1 -1
  61. package/dist/worker/console/static/assets/{diagram-UB23O5K3-C9dMVmDo.js → diagram-UB23O5K3-BPekbhoS.js} +1 -1
  62. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-B2T2WcBK.js → ebnfDiagram-BXEA7PRR-BtmaJpK5.js} +1 -1
  63. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-Bt9rjDFq.js → erDiagram-JOGREHBK-6DS0Xc44.js} +1 -1
  64. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-U8ic_tvi.js → flowDiagram-UKHOOZJN-CihnROSm.js} +1 -1
  65. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-BfQJdQoO.js → ganttDiagram-PKOTCBZU-CZC4zOE2.js} +1 -1
  66. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-CpMftH4O.js → gitGraphDiagram-DS77QQ5N-Dq1fhzGN.js} +1 -1
  67. package/dist/worker/console/static/assets/index-DTZOKgAn.js +404 -0
  68. package/dist/worker/console/static/assets/index-Ya5FE7cD.css +1 -0
  69. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-DzqWXNO_.js → infoDiagram-6WML65LV-DlSl4BGx.js} +1 -1
  70. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-CJmGQ5Yp.js → ishikawaDiagram-WSZJBQD7-snnzFl_U.js} +1 -1
  71. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-ChPtKFLz.js → journeyDiagram-NVQOT4AX-BDE7qpMx.js} +1 -1
  72. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-PRe7wB5u.js → kanban-definition-27J2QSJJ-CD2Ci04G.js} +1 -1
  73. package/dist/worker/console/static/assets/{linear-DpKX6QNp.js → linear-DdnapKIH.js} +1 -1
  74. package/dist/worker/console/static/assets/{mermaid.core-Dnl76b7Y.js → mermaid.core-BVjAT9b8.js} +5 -5
  75. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-S9PCXc3J.js → mindmap-definition-FAOFIHXS-D0yJk-zA.js} +1 -1
  76. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-Du8e-FSX.js → pegDiagram-VL7TDLO6-BVo9tFP-.js} +1 -1
  77. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-CY6OmHTc.js → pieDiagram-7S7Q4E2Y-DnOV-mZ_.js} +1 -1
  78. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-C65GPhpj.js → quadrantDiagram-CIZ2JOQS-CzUJo58i.js} +1 -1
  79. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-U6IUiTCL.js → railroadDiagram-AXF67PYL-Bvikrolh.js} +1 -1
  80. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-D-I_9Qhi.js → requirementDiagram-LRYGKXZP-DtaSVCap.js} +1 -1
  81. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-B57Fhed0.js → sankeyDiagram-W5VNT64P-C2c9A0wW.js} +1 -1
  82. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-CJOtiw21.js → sequenceDiagram-SI44F4Z6-DmXJcU7r.js} +1 -1
  83. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-FyX8zxSh.js → sizeCapture-X5ZJPWSS-B4GUFW92.js} +1 -1
  84. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-eF-pbs9t.js → stateDiagram-OKZ733FA-Dsad2MXf.js} +1 -1
  85. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-B8FB_cNk.js +1 -0
  86. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-CcfkA-Dq.js → swimlanes-SLNWSIFB-DGq48Fbi.js} +2 -2
  87. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-CrZYaWfx.js +8 -0
  88. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-DH8FjnOK.js → timeline-definition-Z64GVDOM-CSSi6OFf.js} +1 -1
  89. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-beoTagJg.js → vennDiagram-T6HMQDX7-BqyzPrWv.js} +1 -1
  90. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CQSfw4cK.js → wardleyDiagram-T6FBY63Y-DlznVb6S.js} +1 -1
  91. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-CQgzQ6jn.js → xychartDiagram-ELKLHX3M-CfBcgJ_K.js} +1 -1
  92. package/dist/worker/console/static/index.html +2 -2
  93. package/dist/worker/console/static-src/app/console-types.js +0 -12
  94. package/dist/worker/console/static-src/app/useOperatorActions.js +1 -1
  95. package/dist/worker/console/static-src/app/useRecoveryActions.js +2 -2
  96. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +38 -1
  97. package/dist/worker/console/static-src/operator-chat/open-preview-in-browser.js +328 -0
  98. package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +8 -1
  99. package/dist/worker/console/static-src/operator-chat/turn-submission.js +42 -0
  100. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +3 -0
  101. package/dist/worker/console/static-src/operator-chat/useChatStream.js +204 -11
  102. package/dist/worker/console/static-src/operator-chat/useChatThread.js +23 -1
  103. package/dist/worker/observe/node-input.js +72 -3
  104. package/dist/worker/observe/static/dag-history-labels.js +4 -0
  105. package/dist/worker/observe/static/state.js +2 -2
  106. package/dist/worker/observe/static/views/dag-inspector.js +51 -0
  107. package/dist/worker/observe/static/views/session-timeline.js +18 -5
  108. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +809 -53
  109. package/dist/workflows/dag/backend-test-case-manifest.js +4 -0
  110. package/dist/workflows/dag/backend-test-execution-contract.js +18 -9
  111. package/dist/workflows/dag/backend-test-gap-fill.js +205 -0
  112. package/dist/workflows/dag/backend-test-intake-context.js +32 -0
  113. package/dist/workflows/dag/backend-test-layout.js +133 -0
  114. package/dist/workflows/dag/backend-test-markdown-workflow.js +167 -32
  115. package/dist/workflows/dag/backend-test-module-stem.js +5 -0
  116. package/dist/workflows/dag/backend-test-pytest-collection.js +368 -33
  117. package/dist/workflows/dag/backend-test-result-contract.js +1 -1
  118. package/dist/workflows/dag/backend-test-scenario-param.js +884 -140
  119. package/dist/workflows/dag/backend-test-scenario-partitions.js +321 -0
  120. package/dist/workflows/dag/backend-test-writer-completeness.js +100 -56
  121. package/dist/workflows/dag/contract-output-registry.js +15 -0
  122. package/dist/workflows/dag/contract-validator-registrations.js +2 -1
  123. package/dist/workflows/dag/convergence/controller.js +141 -0
  124. package/dist/workflows/dag/dynamic-runtime/map.js +24 -8
  125. package/dist/workflows/dag/dynamic-runtime/shared.js +1 -1
  126. package/dist/workflows/dag/failure-category.js +4 -0
  127. package/dist/workflows/dag/frontend-implementation-contract.js +355 -26
  128. package/dist/workflows/dag/frontend-prewrite-gate.js +96 -84
  129. package/dist/workflows/dag/frontend-test-case-quality.js +5 -13
  130. package/dist/workflows/dag/frontend-test-environment-probe.js +227 -0
  131. package/dist/workflows/dag/frontend-test-markdown.js +61 -0
  132. package/dist/workflows/dag/frontend-test-result-contract.js +10 -18
  133. package/dist/workflows/dag/frontend-test-standard-scenarios.js +68 -0
  134. package/dist/workflows/dag/init-hybrid.js +720 -177
  135. package/dist/workflows/dag/node-execution.js +164 -26
  136. package/dist/workflows/dag/prompt.js +60 -1
  137. package/dist/workflows/dag/rerun-feedback.js +212 -0
  138. package/dist/workflows/dag/rerun-plan.js +112 -27
  139. package/dist/workflows/dag/rerun-run.js +3 -0
  140. package/dist/workflows/dag/rerun-task.js +61 -2
  141. package/dist/workflows/dag/retry-policy.js +18 -0
  142. package/dist/workflows/dag/structured-output-repair.js +712 -0
  143. package/dist/workflows/dag/types.js +121 -0
  144. package/dist/workflows/dag/validate.js +41 -1
  145. package/docs/operations/local-development-environment.md +1 -5
  146. package/docs/skills/vetted-skill-registry.md +14 -0
  147. package/docs/templates/README.md +1 -1
  148. package/docs/templates/agent-dag.schema.json +44 -0
  149. package/docs/templates/backend-test-dag.json +60 -35
  150. package/docs/templates/frontend-test-dag.json +8 -10
  151. package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +1 -1
  152. package/docs/templates/harness.schema.json +0 -8
  153. package/harness.json +1 -8
  154. package/package.json +8 -4
  155. package/skills/analyze-product-requirements/SKILL.md +8 -5
  156. package/skills/analyze-product-requirements/references/acceptance-criteria.md +1 -1
  157. package/skills/analyze-product-requirements/references/clarification-and-knowledge.md +10 -6
  158. package/skills/analyze-product-requirements/references/example.md +50 -0
  159. package/skills/analyze-product-requirements/references/forward-test-cases.md +11 -7
  160. package/skills/analyze-product-requirements/references/kb-integration.md +5 -5
  161. package/skills/analyze-product-requirements/references/product-analysis-schema.md +8 -4
  162. package/skills/analyze-product-requirements/references/product-requirement-schema.md +2 -2
  163. package/skills/analyze-product-requirements/references/requirement-clarification-schema.md +1 -1
  164. package/skills/analyze-product-requirements/scripts/test-validators.mjs +11 -1
  165. package/skills/analyze-product-requirements/scripts/validate-product-analysis.mjs +20 -1
  166. package/skills/analyze-product-requirements/scripts/validate-requirement-clarification.mjs +9 -0
  167. package/skills/codebase-scout/SKILL.md +1 -1
  168. package/skills/frontend-implementation/references/node-contracts.md +2 -2
  169. package/skills/improve-codebase-architecture/SKILL.md +81 -0
  170. package/skills/improve-codebase-architecture/deepening.md +37 -0
  171. package/skills/improve-codebase-architecture/html-report.md +123 -0
  172. package/skills/improve-codebase-architecture/interface-design.md +44 -0
  173. package/skills/improve-codebase-architecture/language.md +53 -0
  174. package/dist/worker/console/static/assets/channel-Dp_2Aqf1.js +0 -1
  175. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL--CODy37j.js +0 -1
  176. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE--CODy37j.js +0 -1
  177. package/dist/worker/console/static/assets/index-BA5-N5ER.css +0 -1
  178. package/dist/worker/console/static/assets/index-DKPXBrdc.js +0 -325
  179. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-B-mvnLEc.js +0 -1
  180. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-JtpBmIv4.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 { applyOpenApiPartitionDomainPolicy, 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;
@@ -355,6 +486,55 @@ export async function extractBackendTestOpenApiRules(input) {
355
486
  }
356
487
  return rules.sort((left, right) => left.ruleKey.localeCompare(right.ruleKey));
357
488
  }
489
+ export async function extractBackendTestOpenApiPartitionAxes(input) {
490
+ const axes = [];
491
+ const seen = new Set();
492
+ for (const referencePath of input.sourceBinding.referencePaths) {
493
+ const physicalPath = resolveBoundSourcePath(input.workspaceRoot, input.sourceBinding.taskId, referencePath);
494
+ let document;
495
+ try {
496
+ const parsed = YAML.parse(await readFile(physicalPath, "utf8"));
497
+ if (!isRecord(parsed) || !isRecord(parsed.paths))
498
+ continue;
499
+ document = parsed;
500
+ }
501
+ catch {
502
+ continue;
503
+ }
504
+ for (const [apiPath, pathItem] of Object.entries(document.paths)) {
505
+ if (!isRecord(pathItem))
506
+ continue;
507
+ const operation = pathItem.get;
508
+ if (!isRecord(operation))
509
+ continue;
510
+ const parameterValues = [
511
+ ...(Array.isArray(pathItem.parameters) ? pathItem.parameters : []),
512
+ ...(Array.isArray(operation.parameters) ? operation.parameters : []),
513
+ ];
514
+ for (const rawParameter of parameterValues) {
515
+ const parameter = resolveOpenApiSchema(document, rawParameter);
516
+ if (!parameter || typeof parameter.name !== "string")
517
+ continue;
518
+ const location = parameter.in === "path" ? "path" : parameter.in === "query" ? "query" : undefined;
519
+ if (!location)
520
+ continue;
521
+ const property = resolveOpenApiSchema(document, parameter.schema);
522
+ const enumValues = property && Array.isArray(property.enum) ? property.enum.map(String) : null;
523
+ const identity = `GET ${apiPath}|${location}|${parameter.name}`;
524
+ if (seen.has(identity))
525
+ continue;
526
+ seen.add(identity);
527
+ axes.push({
528
+ operation: `GET ${apiPath}`,
529
+ name: parameter.name,
530
+ in: location,
531
+ enumValues,
532
+ });
533
+ }
534
+ }
535
+ }
536
+ return axes;
537
+ }
358
538
  function orderedUnique(values) {
359
539
  const result = [];
360
540
  const seen = new Set();
@@ -385,8 +565,14 @@ function sectionBody(body, aliases) {
385
565
  }
386
566
  function listTokens(body, aliases, pattern) {
387
567
  const section = sectionBody(body, aliases).replaceAll("`", "");
388
- const source = pattern.source.replace(/^\^/, "").replace(/\$$/, "");
389
- return orderedUnique(section.match(new RegExp(source, "g")) ?? []);
568
+ // Prefer dedicated token patterns; never strip ^/$ from alternation-based anchors
569
+ // (that would turn `^A$|^B$` into `A$|^B` and break scanning).
570
+ const tokenSource = pattern === RULE_KEY
571
+ ? RULE_KEY_TOKEN.source
572
+ : pattern === TEST_POINT
573
+ ? TEST_POINT.source.replace(/^\^/, "").replace(/\$$/, "")
574
+ : pattern.source.replace(/^\^/, "").replace(/\$$/, "");
575
+ return orderedUnique(section.match(new RegExp(tokenSource, "g")) ?? []);
390
576
  }
391
577
  function scenarioTypes(body) {
392
578
  return orderedUnique(sectionBody(body, CASE_SECTION_ALIASES.scenarioTypes)
@@ -423,8 +609,40 @@ function testPointBindingFacts(body, testPoints) {
423
609
  }
424
610
  function declaredPrimarySymbols(body) {
425
611
  const value = automationLabelBody(body, ["primary symbol", "Primary Symbol", "主测试符号"]);
612
+ if (/^(?:none|无|n\/a|not-applicable)$/i.test(value.trim()))
613
+ return [];
426
614
  return orderedUnique(value.match(/\btest_[A-Za-z0-9_]+\b/g) ?? []);
427
615
  }
616
+ function isEvidenceOnlyMarkdownCase(testCase) {
617
+ const hasVariant = testCase.testPointBindings.some((item) => item.mode === "variant");
618
+ const hasNonVariant = testCase.testPointBindings.some((item) => item.mode === "assertion" || item.mode === "cross-cutting");
619
+ return (testCase.declaredScripts.length === 0 &&
620
+ testCase.declaredPrimarySymbols.length === 0 &&
621
+ !hasVariant &&
622
+ hasNonVariant);
623
+ }
624
+ function payloadContract(body) {
625
+ const contractValue = automationLabelBody(body, ["Payload Contract", "载荷合同"]);
626
+ const requiredValue = automationLabelBody(body, ["Payload Required Paths", "载荷必需路径"]);
627
+ const allowedValue = automationLabelBody(body, ["Payload Allowed Paths", "载荷允许路径"]);
628
+ const enumValue = automationLabelBody(body, ["Payload Enum", "载荷枚举"]);
629
+ const tokens = (value) => orderedUnique(value.split(/[,;,;]/).map((item) => item.trim()).filter((item) => /^[A-Za-z_][A-Za-z0-9_.]*$/.test(item)));
630
+ const enumValues = {};
631
+ for (const assignment of enumValue.split(/[,;,;]/).map((item) => item.trim()).filter(Boolean)) {
632
+ const match = /^([A-Za-z_][A-Za-z0-9_.]*)\s*(?:=|:)\s*(.+)$/.exec(assignment);
633
+ if (!match)
634
+ continue;
635
+ enumValues[match[1]] = orderedUnique(match[2].split(/\||\//).map((item) => item.trim().replace(/[((].*$/, "").trim().replace(/^['"]|['"]$/g, "")).filter(Boolean));
636
+ }
637
+ const noPayload = /^(?:none|无|not-applicable|n\/a)(?:\s|[((]|$)/i.test(contractValue.trim());
638
+ return {
639
+ declared: noPayload || requiredValue.length > 0 || allowedValue.length > 0 || enumValue.length > 0,
640
+ noPayload,
641
+ requiredPaths: tokens(requiredValue),
642
+ allowedPaths: tokens(allowedValue),
643
+ enumValues,
644
+ };
645
+ }
428
646
  function cleanTitle(heading, id) {
429
647
  return heading.replace(/^##\s+/, "").replace(/^BE-[A-Z0-9_-]+-\d{2,3}\s*(?:[||—–-]\s*)?/i, "").trim() || id;
430
648
  }
@@ -437,17 +655,18 @@ async function exists(filePath) {
437
655
  return false;
438
656
  }
439
657
  }
440
- async function markdownModuleFiles(workspaceRoot) {
441
- const dir = path.join(workspaceRoot, "testcase", "md");
658
+ async function markdownModuleFiles(workspaceRoot, layout) {
659
+ const resolved = layout ?? resolveBackendTestLayout(undefined);
660
+ const dir = path.join(workspaceRoot, ...resolved.markdownDir.split("/"));
442
661
  const entries = await readdir(dir, { withFileTypes: true });
443
662
  return entries
444
663
  .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".md") && entry.name.toLowerCase() !== "readme.md")
445
664
  .map((entry) => path.join(dir, entry.name))
446
665
  .sort();
447
666
  }
448
- async function parseMarkdownCases(workspaceRoot) {
667
+ async function parseMarkdownCases(workspaceRoot, layout) {
449
668
  const result = [];
450
- for (const file of await markdownModuleFiles(workspaceRoot)) {
669
+ for (const file of await markdownModuleFiles(workspaceRoot, layout)) {
451
670
  const markdown = await readFile(file, "utf8");
452
671
  const headings = [...markdown.matchAll(CASE_HEADING)];
453
672
  for (let index = 0; index < headings.length; index += 1) {
@@ -466,6 +685,7 @@ async function parseMarkdownCases(workspaceRoot) {
466
685
  scenarioTypes: scenarioTypes(body),
467
686
  declaredScripts: declaredScripts(body),
468
687
  declaredPrimarySymbols: declaredPrimarySymbols(body),
688
+ payloadContract: payloadContract(body),
469
689
  body,
470
690
  });
471
691
  }
@@ -483,8 +703,17 @@ function parseTableRow(line) {
483
703
  index += 1;
484
704
  }
485
705
  else if (char === "|") {
486
- cells.push(current.trim());
487
- current = "";
706
+ const prev = trimmed[index - 1] ?? "";
707
+ const next = trimmed[index + 1] ?? "";
708
+ // In-cell enum/value pipes look like ACTIVE|ARCHIVED (alnum on both sides).
709
+ // Column separators use " | " or touch markdown separator dashes (---|---).
710
+ if (/[A-Za-z0-9_]/.test(prev) && /[A-Za-z0-9_]/.test(next)) {
711
+ current += "|";
712
+ }
713
+ else {
714
+ cells.push(current.trim());
715
+ current = "";
716
+ }
488
717
  }
489
718
  else {
490
719
  current += char;
@@ -637,15 +866,16 @@ function renderCoverageTable(rules) {
637
866
  ];
638
867
  }
639
868
  export async function analyzeBackendTestCaseCoverage(input) {
869
+ const layout = input.layout ?? resolveBackendTestLayout(undefined);
640
870
  const findings = [];
641
- const readmePath = path.join(input.workspaceRoot, "testcase", "md", "README.md");
871
+ const readmePath = path.join(input.workspaceRoot, ...layout.readmePath.split("/"));
642
872
  const readme = (await exists(readmePath)) ? await readFile(readmePath, "utf8") : "";
643
873
  const coverageScope = parseCoverageScope(readme);
644
874
  findings.push(...coverageScope.findings);
645
875
  const matrix = parseCoverageMatrix(readme);
646
876
  findings.push(...matrix.findings);
647
- const cases = await parseMarkdownCases(input.workspaceRoot);
648
- for (const file of await markdownModuleFiles(input.workspaceRoot)) {
877
+ const cases = await parseMarkdownCases(input.workspaceRoot, layout);
878
+ for (const file of await markdownModuleFiles(input.workspaceRoot, layout)) {
649
879
  const markdown = await readFile(file, "utf8");
650
880
  for (const match of markdown.matchAll(NON_CANONICAL_CASE_HEADING)) {
651
881
  findings.push(`non-canonical Case ID heading must use BE-<MODULE>-<NNN>: ${match[1]}`);
@@ -687,7 +917,10 @@ export async function analyzeBackendTestCaseCoverage(input) {
687
917
  if (!rowCaseIds.includes(testCase.caseId))
688
918
  findings.push(`${testCase.caseId} references ${ruleKey} but is absent from its Coverage Matrix Case IDs`);
689
919
  }
690
- let status = declaredStatus;
920
+ const normalizedDeclaredStatus = /^(?:COVERED|PARTIAL|GAP|CONFLICT)$/.test(declaredStatus)
921
+ ? declaredStatus
922
+ : "PARTIAL";
923
+ let status = normalizedDeclaredStatus;
691
924
  if (status === "COVERED" && missingTestPoints.length > 0)
692
925
  status = "PARTIAL";
693
926
  if (missingTestPoints.length > 0)
@@ -707,7 +940,7 @@ export async function analyzeBackendTestCaseCoverage(input) {
707
940
  rule: row.Rule || "unavailable",
708
941
  requiredTestPoints,
709
942
  caseIds: rowCaseIds,
710
- declaredStatus: /^(?:COVERED|PARTIAL|GAP|CONFLICT)$/.test(declaredStatus) ? declaredStatus : "PARTIAL",
943
+ declaredStatus: normalizedDeclaredStatus,
711
944
  coveredTestPoints,
712
945
  missingTestPoints,
713
946
  status,
@@ -749,8 +982,12 @@ export async function analyzeBackendTestCaseCoverage(input) {
749
982
  findings.push(`${testCase.caseId} missing section or entries: 测试点`);
750
983
  if (testCase.scenarioTypes.length === 0)
751
984
  findings.push(`${testCase.caseId} missing section or entries: 场景类型`);
752
- if (testCase.declaredPrimarySymbols.length !== 1)
985
+ if (isEvidenceOnlyMarkdownCase(testCase)) {
986
+ // Evidence-only meta cases intentionally declare 脚本/primary symbol as 无 and only keep assertion/cross-cutting TPs.
987
+ }
988
+ else if (testCase.declaredPrimarySymbols.length !== 1) {
753
989
  findings.push(`${testCase.caseId} must declare exactly one primary symbol`);
990
+ }
754
991
  if (testCase.unclassifiedTestPoints.length > 0)
755
992
  findings.push(`${testCase.caseId} has unclassified Test Points: ${testCase.unclassifiedTestPoints.join(", ")}`);
756
993
  if (testCase.duplicateBindingTestPoints.length > 0)
@@ -772,8 +1009,37 @@ export async function analyzeBackendTestCaseCoverage(input) {
772
1009
  if (uniqueOwners.length > 1)
773
1010
  findings.push(`${testPoint} is bound as a non-cross-cutting Test Point by multiple Cases: ${uniqueOwners.join(", ")}`);
774
1011
  }
775
- const explicitAc = input.sourceBinding.requirementIds.filter((id) => id.startsWith("AC-"));
1012
+ const generatedMarkdown = [readme, ...cases.map((item) => item.body)].join("\n\n");
1013
+ const sourceCoverageFacts = await collectSourceCoverageFacts({
1014
+ workspaceRoot: input.workspaceRoot,
1015
+ sourceBinding: input.sourceBinding,
1016
+ generatedMarkdown,
1017
+ });
1018
+ const residualSourceFacts = sourceCoverageFacts.filter((fact) => !fact.covered);
1019
+ for (const fact of residualSourceFacts) {
1020
+ findings.push(`source coverage is missing ${fact.kind} ${fact.key} from ${fact.sourcePath}`);
1021
+ }
1022
+ const sourceRequirementIds = sourceCoverageFacts
1023
+ .filter((fact) => fact.kind === "requirement-id")
1024
+ .map((fact) => fact.key);
1025
+ const explicitAc = orderedUnique([
1026
+ ...input.sourceBinding.requirementIds.filter((id) => id.startsWith("AC-")),
1027
+ ...sourceRequirementIds.filter((id) => id.startsWith("AC-")),
1028
+ ]);
776
1029
  const coveredAc = new Set(cases.flatMap((item) => item.acIds));
1030
+ for (const acId of explicitAc) {
1031
+ if (!coveredAc.has(acId))
1032
+ findings.push(`explicit source AC is missing from Markdown Cases: ${acId}`);
1033
+ }
1034
+ // Plan C: deterministic query/filter axis partitions (fail-closed on missing slots).
1035
+ const parsedPartitions = parseScenarioPartitions(readme);
1036
+ findings.push(...parsedPartitions.issues);
1037
+ const openApiPartitionAxes = await extractBackendTestOpenApiPartitionAxes(input);
1038
+ const partitionedBySource = applyOpenApiPartitionDomainPolicy(parsedPartitions.partitions, openApiPartitionAxes);
1039
+ findings.push(...partitionedBySource.issues);
1040
+ const declaredPointUniverse = new Set(cases.flatMap((item) => item.testPoints));
1041
+ const partitionAssessment = assessScenarioPartitionCoverage(partitionedBySource.partitions, declaredPointUniverse);
1042
+ findings.push(...partitionAssessment.findings);
777
1043
  const [enumValueCount, coveredEnumValueCount] = dimensionCounts(rules, /enum/);
778
1044
  const [boundaryPointCount, coveredBoundaryPointCount] = dimensionCounts(rules, /boundary|length|numeric/);
779
1045
  const [formatClassCount, coveredFormatClassCount] = dimensionCounts(rules, /format|pattern|charset/);
@@ -782,20 +1048,27 @@ export async function analyzeBackendTestCaseCoverage(input) {
782
1048
  const invalidEquivalenceClassCount = rules.reduce((sum, rule) => sum + rule.requiredTestPoints.filter((point) => invalidPointPattern.test(point)).length, 0);
783
1049
  const coveredInvalidEquivalenceClassCount = rules.reduce((sum, rule) => sum + rule.coveredTestPoints.filter((point) => invalidPointPattern.test(point)).length, 0);
784
1050
  const facts = backendTestCaseCoverageFactsSchema.parse({
785
- schemaId: "backend-test-case-coverage-facts-v3",
786
- schemaVersion: 3,
1051
+ schemaId: "backend-test-case-coverage-facts-v4",
1052
+ schemaVersion: 4,
787
1053
  taskId: input.sourceBinding.taskId,
788
1054
  status: findings.length === 0 ? "PASS" : "FAIL",
789
1055
  sourceBinding: input.sourceBinding,
790
1056
  inputFiles: await inputFileFacts(input.workspaceRoot, [
791
1057
  ...((await exists(readmePath)) ? [readmePath] : []),
792
- ...(await markdownModuleFiles(input.workspaceRoot)),
1058
+ ...(await markdownModuleFiles(input.workspaceRoot, layout)),
793
1059
  ...(await Promise.all(input.sourceBinding.referencePaths.map(async (referencePath) => {
794
1060
  const physicalPath = resolveBoundSourcePath(input.workspaceRoot, input.sourceBinding.taskId, referencePath);
795
1061
  return (await exists(physicalPath)) ? physicalPath : "";
796
1062
  }))).filter(Boolean),
797
1063
  ]),
798
1064
  coverageScope: coverageScope.scope,
1065
+ sourceCoverage: {
1066
+ sourceFactCount: sourceCoverageFacts.length,
1067
+ coveredSourceFactCount: sourceCoverageFacts.length - residualSourceFacts.length,
1068
+ residualSourceFactCount: residualSourceFacts.length,
1069
+ facts: sourceCoverageFacts,
1070
+ residual: residualSourceFacts,
1071
+ },
799
1072
  summary: {
800
1073
  explicitAcCount: explicitAc.length,
801
1074
  coveredAcCount: explicitAc.filter((id) => coveredAc.has(id)).length,
@@ -823,6 +1096,7 @@ export async function analyzeBackendTestCaseCoverage(input) {
823
1096
  },
824
1097
  cases: cases.map(({ body: _body, ...item }) => item),
825
1098
  rules,
1099
+ scenarioPartitions: partitionAssessment.facts,
826
1100
  findings: orderedUnique(findings),
827
1101
  evidenceGaps,
828
1102
  conflicts,
@@ -846,6 +1120,22 @@ export async function analyzeBackendTestCaseCoverage(input) {
846
1120
  `- Scope Evidence: ${facts.coverageScope.scopeEvidence.join(", ") || "Unavailable"}`,
847
1121
  ...(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
1122
  "",
1123
+ "## Source Coverage Inventory",
1124
+ "",
1125
+ `- Source facts: ${facts.sourceCoverage.sourceFactCount}`,
1126
+ `- Covered source facts: ${facts.sourceCoverage.coveredSourceFactCount}`,
1127
+ `- Residual source facts: ${facts.sourceCoverage.residualSourceFactCount}`,
1128
+ "",
1129
+ "| Kind | Key | Source Anchor | Source SHA-256 | Exact Quote | Status |",
1130
+ "|---|---|---|---|---|---|",
1131
+ ...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"} |`),
1132
+ "",
1133
+ "## Residual Missing Source Scenarios",
1134
+ "",
1135
+ ...(facts.sourceCoverage.residual.length > 0
1136
+ ? 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.`)
1137
+ : ["- None"]),
1138
+ "",
849
1139
  "## Coverage Summary",
850
1140
  "",
851
1141
  "| Dimension | Total | Covered |",
@@ -902,13 +1192,329 @@ export async function analyzeBackendTestCaseCoverage(input) {
902
1192
  function normalizeModuleStem(markdownPath) {
903
1193
  return path.posix.basename(markdownPath).replace(/\.md$/i, "").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").replace(/_+/g, "_");
904
1194
  }
905
- function expectedScript(markdownPath) {
906
- return `testcase/test_${normalizeModuleStem(markdownPath)}.py`;
1195
+ function expectedScript(markdownPath, layout) {
1196
+ const resolved = layout ?? resolveBackendTestLayout(undefined);
1197
+ return `${resolved.scriptDir}/test_${normalizeModuleStem(markdownPath)}.py`;
907
1198
  }
908
1199
  function symbolCaseId(symbol) {
909
1200
  const match = symbol.match(/^test_(BE(?:_[A-Z0-9]+)+?_\d{2,3})(?:_|$)/i);
910
1201
  return match ? canonicalCaseId(match[1].replaceAll("_", "-")) : undefined;
911
1202
  }
1203
+ function pythonPayloadShapes(source) {
1204
+ const program = String.raw `
1205
+ import ast,json,sys
1206
+ source=sys.stdin.buffer.read().decode('utf-8')
1207
+ tree=ast.parse(source)
1208
+ GLOBAL_PAYLOADS={}
1209
+ GLOBAL_VALUES={}
1210
+ for top in tree.body:
1211
+ if isinstance(top,(ast.Assign,ast.AnnAssign)):
1212
+ 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 []))
1213
+ if top.value is None: continue
1214
+ candidates=[]
1215
+ for node in ast.walk(top.value):
1216
+ 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])
1217
+ 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)
1218
+ for name in names:
1219
+ GLOBAL_PAYLOADS[name]=candidates
1220
+ GLOBAL_VALUES[name]=top.value
1221
+ FUNCTION_PAYLOADS={}
1222
+ FUNCTION_TRANSPORT_SHAPES={}
1223
+ TRANSPORT_PAYLOAD_PARAMS={}
1224
+ for top in ast.walk(tree):
1225
+ if isinstance(top,(ast.FunctionDef,ast.AsyncFunctionDef)) and not top.name.startswith('test_'):
1226
+ candidates=[]
1227
+ assigned={}
1228
+ mutated={}
1229
+ returned_names=set()
1230
+ positional=[arg.arg for arg in [*top.args.posonlyargs,*top.args.args]]
1231
+ call_offset=1 if positional and positional[0] in {'self','cls'} else 0
1232
+ transport=[]
1233
+ for node in ast.walk(top):
1234
+ if isinstance(node,(ast.Assign,ast.AnnAssign)):
1235
+ targets=node.targets if isinstance(node,ast.Assign) else [node.target]
1236
+ if node.value is None: continue
1237
+ for target in targets:
1238
+ if isinstance(target,ast.Name): assigned[target.id]=node.value
1239
+ for target in targets:
1240
+ if isinstance(target,ast.Subscript) and isinstance(target.value,ast.Name) and isinstance(target.slice,ast.Constant) and isinstance(target.slice.value,str):
1241
+ mutated.setdefault(target.value.id,[]).append((target.slice.value,node.value))
1242
+ if isinstance(node,ast.Return):
1243
+ if isinstance(node.value,ast.Dict): candidates.append(node.value)
1244
+ elif isinstance(node.value,ast.Name) and node.value.id in assigned:
1245
+ returned=assigned[node.value.id]
1246
+ if isinstance(returned,ast.Dict): candidates.append(returned)
1247
+ elif isinstance(returned,ast.Call) and isinstance(returned.func,ast.Name) and returned.func.id in FUNCTION_PAYLOADS:
1248
+ candidates.extend(FUNCTION_PAYLOADS[returned.func.id])
1249
+ returned_names.add(node.value.id)
1250
+ if isinstance(node,ast.Call):
1251
+ for keyword in node.keywords:
1252
+ 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:
1253
+ transport.append((keyword.value.id,positional.index(keyword.value.id)-call_offset))
1254
+ if isinstance(node.func,ast.Attribute) and node.func.attr.lower() in {'post','put','patch'} and len(node.args) >= 2:
1255
+ candidate=node.args[1]
1256
+ if isinstance(candidate,ast.Name) and candidate.id in positional:
1257
+ transport.append((candidate.id,positional.index(candidate.id)-call_offset))
1258
+ callee=None
1259
+ if isinstance(node.func,ast.Name): callee=node.func.id
1260
+ elif isinstance(node.func,ast.Attribute): callee=node.func.attr
1261
+ for _nested_name,nested_position in TRANSPORT_PAYLOAD_PARAMS.get(callee,[]):
1262
+ matched=node.args[nested_position] if nested_position >= 0 and nested_position < len(node.args) else None
1263
+ if isinstance(matched,ast.Name) and matched.id in positional:
1264
+ transport.append((matched.id,positional.index(matched.id)-call_offset))
1265
+ for returned_name in returned_names:
1266
+ entries=mutated.get(returned_name,[])
1267
+ if entries:
1268
+ candidates.append(ast.Dict(keys=[ast.Constant(key) for key,_value in entries],values=[value for _key,value in entries]))
1269
+ transport_shapes=[]
1270
+ for node in ast.walk(top):
1271
+ if not isinstance(node,ast.Call): continue
1272
+ for keyword in node.keywords:
1273
+ 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:
1274
+ transport_shapes.append(assigned[keyword.value.id])
1275
+ if candidates: FUNCTION_PAYLOADS[top.name]=candidates
1276
+ if transport_shapes: FUNCTION_TRANSPORT_SHAPES[top.name]=transport_shapes
1277
+ if transport: TRANSPORT_PAYLOAD_PARAMS[top.name]=list(dict.fromkeys(transport))
1278
+ # Propagate transport payload positions through local wrapper chains after all
1279
+ # direct transport methods/functions are known. ast.walk may visit module-level
1280
+ # wrappers before class methods, so one-pass discovery is order-sensitive.
1281
+ for _round in range(6):
1282
+ changed=False
1283
+ for top in ast.walk(tree):
1284
+ if not isinstance(top,(ast.FunctionDef,ast.AsyncFunctionDef)) or top.name.startswith('test_'): continue
1285
+ positional=[arg.arg for arg in [*top.args.posonlyargs,*top.args.args]]
1286
+ call_offset=1 if positional and positional[0] in {'self','cls'} else 0
1287
+ current=list(TRANSPORT_PAYLOAD_PARAMS.get(top.name,[]))
1288
+ for node in ast.walk(top):
1289
+ if not isinstance(node,ast.Call): continue
1290
+ callee=node.func.id if isinstance(node.func,ast.Name) else (node.func.attr if isinstance(node.func,ast.Attribute) else None)
1291
+ for _nested_name,nested_position in TRANSPORT_PAYLOAD_PARAMS.get(callee,[]):
1292
+ matched=node.args[nested_position] if nested_position >= 0 and nested_position < len(node.args) else None
1293
+ if isinstance(matched,ast.Name) and matched.id in positional:
1294
+ item=(matched.id,positional.index(matched.id)-call_offset)
1295
+ if item not in current:
1296
+ current.append(item); changed=True
1297
+ if current: TRANSPORT_PAYLOAD_PARAMS[top.name]=current
1298
+ if not changed: break
1299
+
1300
+ # A local row factory may return pytest.param(_payload_builder(...), ..., id=tp).
1301
+ # Treat its first positional payload builder as shape evidence while keeping the
1302
+ # writer contract free to require direct pytest.param rows for scenario checks.
1303
+ for top in ast.walk(tree):
1304
+ if not isinstance(top,(ast.FunctionDef,ast.AsyncFunctionDef)) or top.name.startswith('test_'): continue
1305
+ for node in ast.walk(top):
1306
+ if not isinstance(node,ast.Return) or not isinstance(node.value,ast.Call): continue
1307
+ returned=node.value
1308
+ if not isinstance(returned.func,ast.Attribute) or returned.func.attr != 'param' or not returned.args: continue
1309
+ payload=returned.args[0]
1310
+ if isinstance(payload,ast.Call) and isinstance(payload.func,ast.Name) and payload.func.id in FUNCTION_PAYLOADS:
1311
+ FUNCTION_PAYLOADS[top.name]=list(FUNCTION_PAYLOADS[payload.func.id])
1312
+ # Resolve module-level parameter collections after local payload/row factories
1313
+ # are known (for example CASE_ROWS = [_post_case(...) , ...]).
1314
+ for top in tree.body:
1315
+ if not isinstance(top,ast.Assign): continue
1316
+ names=[target.id for target in top.targets if isinstance(target,ast.Name)]
1317
+ resolved=[]
1318
+ for node in ast.walk(top.value):
1319
+ if isinstance(node,ast.Call) and isinstance(node.func,ast.Name) and node.func.id in FUNCTION_PAYLOADS:
1320
+ resolved.extend(FUNCTION_PAYLOADS[node.func.id])
1321
+ for name in names:
1322
+ if resolved: GLOBAL_PAYLOADS[name]=list(GLOBAL_PAYLOADS.get(name,[]))+resolved
1323
+
1324
+ def literal(node):
1325
+ if isinstance(node, ast.Constant) and isinstance(node.value, (str,int,float,bool,type(None))):
1326
+ return repr(node.value)
1327
+ return None
1328
+
1329
+ def collect_dict(node, prefix, paths, values):
1330
+ if not isinstance(node, ast.Dict): return
1331
+ for key,value in zip(node.keys,node.values):
1332
+ if not isinstance(key, ast.Constant) or not isinstance(key.value,str): continue
1333
+ # Ignore writer helper/meta keys that are not DTO request fields.
1334
+ 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_'):
1335
+ continue
1336
+ current = key.value if not prefix else prefix + '.' + key.value
1337
+ paths.add(current)
1338
+ lv=literal(value)
1339
+ if lv is not None: values.setdefault(current,set()).add(lv)
1340
+ if isinstance(value, ast.Dict): collect_dict(value,current,paths,values)
1341
+ elif isinstance(value,(ast.List,ast.Tuple)):
1342
+ for item in value.elts:
1343
+ if isinstance(item,ast.Dict): collect_dict(item,current,paths,values)
1344
+
1345
+ PAYLOAD_NAMES={'payload','body','request_body','request_json','dto','json','data','json_body'}
1346
+
1347
+ def shape_only(node):
1348
+ if isinstance(node,ast.Dict):
1349
+ values=[]
1350
+ for value in node.values:
1351
+ if isinstance(value,(ast.Dict,ast.List,ast.Tuple)): values.append(shape_only(value))
1352
+ else: values.append(ast.Name(id='_PAYLOAD_VALUE_',ctx=ast.Load()))
1353
+ return ast.Dict(keys=node.keys,values=values)
1354
+ if isinstance(node,(ast.List,ast.Tuple)):
1355
+ 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]
1356
+ return ast.List(elts=items,ctx=ast.Load()) if isinstance(node,ast.List) else ast.Tuple(elts=items,ctx=ast.Load())
1357
+ return node
1358
+
1359
+ def payload_expr_nodes(expr, assigned, parameters, seen=None):
1360
+ seen=set() if seen is None else seen
1361
+ if isinstance(expr,ast.Dict): return [expr]
1362
+ if isinstance(expr,ast.Name):
1363
+ if expr.id in seen: return []
1364
+ seen.add(expr.id)
1365
+ if expr.id in assigned: return payload_expr_nodes(assigned[expr.id],assigned,parameters,seen)
1366
+ if expr.id in parameters:
1367
+ result=[]
1368
+ for value in parameters[expr.id]: result.extend(payload_expr_nodes(value,assigned,parameters,set(seen)))
1369
+ return result
1370
+ return list(GLOBAL_PAYLOADS.get(expr.id,[]))
1371
+ if isinstance(expr,ast.Subscript) and isinstance(expr.value,ast.Name) and isinstance(expr.slice,ast.Constant) and isinstance(expr.slice.value,str):
1372
+ result=[]
1373
+ for candidate in parameters.get(expr.value.id,[]):
1374
+ if not isinstance(candidate,ast.Dict): continue
1375
+ for key,value in zip(candidate.keys,candidate.values):
1376
+ if isinstance(key,ast.Constant) and key.value == expr.slice.value:
1377
+ result.extend(shape_only(item) for item in payload_expr_nodes(value,assigned,parameters,set(seen)))
1378
+ return result
1379
+ if isinstance(expr,ast.Call):
1380
+ result=[]
1381
+ if isinstance(expr.func,ast.Name) and expr.func.id in FUNCTION_PAYLOADS:
1382
+ result.extend(FUNCTION_PAYLOADS[expr.func.id])
1383
+ override_keys=[]; override_values=[]
1384
+ for keyword in expr.keywords:
1385
+ if keyword.arg:
1386
+ override_keys.append(ast.Constant(keyword.arg)); override_values.append(keyword.value)
1387
+ if override_keys: result.append(ast.Dict(keys=override_keys,values=override_values))
1388
+ # A transport body may be assembled by a local combinator such as
1389
+ # _apply_patch(_base_body(...), patch). Because this traversal starts
1390
+ # only from an identified transport-body position, recursively merging
1391
+ # argument shapes is narrower than scanning arbitrary helper calls.
1392
+ for argument in expr.args:
1393
+ result.extend(payload_expr_nodes(argument,assigned,parameters,set(seen)))
1394
+ for keyword in expr.keywords:
1395
+ result.extend(payload_expr_nodes(keyword.value,assigned,parameters,set(seen)))
1396
+ return result
1397
+ return []
1398
+
1399
+ def decorator_parameter_values(dec):
1400
+ result={}
1401
+ 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
1402
+ if not isinstance(dec.args[0],ast.Constant) or not isinstance(dec.args[0].value,str): return result
1403
+ names=[item.strip() for item in dec.args[0].value.split(',')]
1404
+ rows=dec.args[1]
1405
+ if isinstance(rows,ast.Name) and rows.id in GLOBAL_VALUES: rows=GLOBAL_VALUES[rows.id]
1406
+ for node in ast.walk(rows):
1407
+ if not isinstance(node,ast.Call) or not isinstance(node.func,ast.Attribute) or node.func.attr != 'param': continue
1408
+ for index,name in enumerate(names):
1409
+ if index < len(node.args): result.setdefault(name,[]).append(node.args[index])
1410
+ return result
1411
+
1412
+ def decorator_payload_nodes(parameters,assigned):
1413
+ result=[]
1414
+ for name,values in parameters.items():
1415
+ if name.lower() not in PAYLOAD_NAMES: continue
1416
+ for value in values: result.extend(payload_expr_nodes(value,assigned,parameters))
1417
+ return result
1418
+
1419
+ def fallback_payload_nodes(fn):
1420
+ result=[]
1421
+ assigned={}
1422
+ for node in ast.walk(fn):
1423
+ if isinstance(node,(ast.Assign,ast.AnnAssign)):
1424
+ if node.value is None: continue
1425
+ targets=node.targets if isinstance(node,ast.Assign) else [node.target]
1426
+ for target in targets:
1427
+ if isinstance(target,ast.Name): assigned[target.id]=node.value
1428
+ parameters={}
1429
+ for dec in fn.decorator_list:
1430
+ for name,values in decorator_parameter_values(dec).items(): parameters.setdefault(name,[]).extend(values)
1431
+ for node in ast.walk(fn):
1432
+ if not isinstance(node,ast.Call): continue
1433
+ callee=None
1434
+ if isinstance(node.func,ast.Name): callee=node.func.id
1435
+ elif isinstance(node.func,ast.Attribute): callee=node.func.attr
1436
+ result.extend(FUNCTION_TRANSPORT_SHAPES.get(callee,[]))
1437
+ # Wrapper payloads are fallback evidence: they are useful when the Case
1438
+ # contract requires a body, but must not contaminate GET/DELETE targets
1439
+ # merely because setup creates a resource first.
1440
+ for parameter_name,position in TRANSPORT_PAYLOAD_PARAMS.get(callee,[]):
1441
+ matched=next((keyword.value for keyword in node.keywords if keyword.arg == parameter_name),None)
1442
+ if matched is None and position >= 0 and position < len(node.args): matched=node.args[position]
1443
+ if matched is not None: result.extend(payload_expr_nodes(matched,assigned,parameters))
1444
+ return result
1445
+
1446
+ def is_direct_transport_call(node):
1447
+ if not isinstance(node,ast.Call) or not isinstance(node.func,ast.Attribute): return False
1448
+ if node.func.attr.lower() not in {'get','post','put','patch','delete','request'}: return False
1449
+ receiver_tokens=[]
1450
+ current=node.func.value
1451
+ while isinstance(current,ast.Attribute):
1452
+ receiver_tokens.append(current.attr.lower())
1453
+ current=current.value
1454
+ if isinstance(current,ast.Name): receiver_tokens.append(current.id.lower())
1455
+ return any(token in {'requests','httpx','client','session','api_client','http_client'} or token.endswith('_client') for token in receiver_tokens)
1456
+
1457
+ def payload_nodes(fn):
1458
+ result=[]
1459
+ assigned={}
1460
+ for node in ast.walk(fn):
1461
+ if isinstance(node,(ast.Assign,ast.AnnAssign)):
1462
+ if node.value is None: continue
1463
+ targets=node.targets if isinstance(node,ast.Assign) else [node.target]
1464
+ for target in targets:
1465
+ if isinstance(target,ast.Name): assigned[target.id]=node.value
1466
+ parameters={}
1467
+ for dec in fn.decorator_list:
1468
+ for name,values in decorator_parameter_values(dec).items(): parameters.setdefault(name,[]).extend(values)
1469
+ result.extend(decorator_payload_nodes(parameters,assigned))
1470
+ for dec in fn.decorator_list:
1471
+ for node in ast.walk(dec):
1472
+ if isinstance(node,ast.Call) and isinstance(node.func,ast.Name) and node.func.id in FUNCTION_PAYLOADS:
1473
+ result.extend(payload_expr_nodes(node,assigned,parameters))
1474
+ elif isinstance(node,ast.Name) and node.id in GLOBAL_PAYLOADS:
1475
+ result.extend(GLOBAL_PAYLOADS[node.id])
1476
+ # Observe local payload/body variables only when they flow into a direct
1477
+ # HTTP transport call. Arbitrary setup variables are not target requests.
1478
+ for node in ast.walk(fn):
1479
+ if not is_direct_transport_call(node): continue
1480
+ for keyword in node.keywords:
1481
+ if keyword.arg in PAYLOAD_NAMES: result.extend(payload_expr_nodes(keyword.value,assigned,parameters))
1482
+ return result
1483
+
1484
+ out={}
1485
+ for fn in ast.walk(tree):
1486
+ if isinstance(fn,(ast.FunctionDef,ast.AsyncFunctionDef)) and fn.name.startswith('test_'):
1487
+ paths=set(); values={}
1488
+ fallback_paths=set(); fallback_values={}
1489
+ for node in payload_nodes(fn): collect_dict(node,'',paths,values)
1490
+ for node in fallback_payload_nodes(fn): collect_dict(node,'',fallback_paths,fallback_values)
1491
+ out[fn.name]={
1492
+ 'paths':sorted(paths),
1493
+ 'values':{key:sorted(items) for key,items in sorted(values.items())},
1494
+ 'fallbackPaths':sorted(fallback_paths),
1495
+ 'fallbackValues':{key:sorted(items) for key,items in sorted(fallback_values.items())},
1496
+ }
1497
+ print(json.dumps(out,ensure_ascii=True))
1498
+ `;
1499
+ // Pass bytes and decode stdin explicitly as UTF-8 in Python. Clean-env
1500
+ // Windows Python may expose stdin as GBK; decoding generated Chinese/emoji
1501
+ // source through that locale can create surrogateescape code points and make
1502
+ // ast.parse fail with "surrogates not allowed".
1503
+ const result = spawnSync("python", ["-c", program], { input: Buffer.from(source, "utf8"), encoding: "utf8", maxBuffer: 4 * 1024 * 1024, windowsHide: true });
1504
+ if (result.status !== 0 || !result.stdout.trim())
1505
+ return new Map();
1506
+ try {
1507
+ const parsed = JSON.parse(result.stdout);
1508
+ return new Map(Object.entries(parsed));
1509
+ }
1510
+ catch {
1511
+ return new Map();
1512
+ }
1513
+ }
1514
+ function metadataCaseIds(region) {
1515
+ return orderedUnique([...region.matchAll(/^\s*Case-ID\s*:\s*(BE-[A-Z0-9]+(?:-[A-Z0-9]+)*)\s*$/gmi)]
1516
+ .map((match) => canonicalCaseId(match[1])));
1517
+ }
912
1518
  function metadataTestPoints(region, label) {
913
1519
  const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
914
1520
  const match = new RegExp(`^\\s*${escaped}\\s*:\\s*(.*)$`, "mi").exec(region);
@@ -928,10 +1534,35 @@ function pytestFunctionRegionStart(source, functionIndex) {
928
1534
  }
929
1535
  return regionStart;
930
1536
  }
1537
+ function pytestFunctionBodyEnd(source, signatureEnd, functionIndent) {
1538
+ let cursor = source.indexOf("\n", signatureEnd);
1539
+ if (cursor < 0)
1540
+ return source.length;
1541
+ cursor += 1;
1542
+ const baseIndent = functionIndent.replaceAll("\t", " ").length;
1543
+ while (cursor < source.length) {
1544
+ const nextLine = source.indexOf("\n", cursor);
1545
+ const lineEnd = nextLine < 0 ? source.length : nextLine;
1546
+ const line = source.slice(cursor, lineEnd).replace(/\r$/, "");
1547
+ if (line.trim()) {
1548
+ const indentation = line.match(/^[ \t]*/)?.[0]?.replaceAll("\t", " ").length ?? 0;
1549
+ if (indentation <= baseIndent)
1550
+ return cursor;
1551
+ }
1552
+ if (nextLine < 0)
1553
+ return source.length;
1554
+ cursor = nextLine + 1;
1555
+ }
1556
+ return source.length;
1557
+ }
931
1558
  function pytestParameterCollections(source) {
932
1559
  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])));
1560
+ for (const match of source.matchAll(/^(_?[A-Z][A-Z0-9_]*)\s*=\s*\[(?![^\r\n]*\])([\s\S]*?)^\]/gm)) {
1561
+ const explicitIds = [...match[2].matchAll(/\bid\s*=\s*["'](TP-[A-Z0-9-]+)["']/g)].map((item) => item[1]);
1562
+ const factoryIds = explicitIds.length === 0
1563
+ ? [...match[2].matchAll(/["'](TP-[A-Z0-9-]+)["']/g)].map((item) => item[1])
1564
+ : [];
1565
+ collections.set(match[1], orderedUnique([...explicitIds, ...factoryIds]));
935
1566
  }
936
1567
  return collections;
937
1568
  }
@@ -939,21 +1570,29 @@ function pytestSymbols(script, source) {
939
1570
  const matches = [...source.matchAll(/^([ \t]*)(?:async\s+)?def\s+(test_[A-Za-z0-9_]+)\s*\([^)]*\)\s*(?:->\s*[^:\r\n]+)?\s*:/gm)];
940
1571
  const regionStarts = matches.map((match) => pytestFunctionRegionStart(source, match.index));
941
1572
  const parameterCollections = pytestParameterCollections(source);
1573
+ const payloadShapes = pythonPayloadShapes(source);
942
1574
  return matches.map((match, index) => {
943
1575
  const regionStart = regionStarts[index];
944
- const regionEnd = regionStarts[index + 1] ?? source.length;
1576
+ const regionEnd = pytestFunctionBodyEnd(source, match.index + match[0].length, match[1]);
945
1577
  const region = source.slice(match.index, regionEnd);
946
1578
  const decorators = source.slice(regionStart, match.index);
947
- const ids = caseIds(region);
1579
+ const ids = metadataCaseIds(region);
948
1580
  const fromSymbol = symbolCaseId(match[2]);
949
1581
  if (fromSymbol)
950
1582
  ids.unshift(fromSymbol);
1583
+ const explicitParameterIds = [...decorators.matchAll(/\bid\s*=\s*["'](TP-[A-Z0-9-]+)["']/g)].map((item) => item[1]);
1584
+ const collectionParameterIds = [...parameterCollections.entries()].flatMap(([name, values]) => new RegExp(`\\b${name}\\b`).test(decorators) ? values : []);
1585
+ const factoryParameterIds = explicitParameterIds.length === 0 && collectionParameterIds.length === 0
1586
+ ? [...decorators.matchAll(/["'](TP-[A-Z0-9-]+)["']/g)].map((item) => item[1])
1587
+ : [];
951
1588
  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 : []),
1589
+ ...explicitParameterIds,
1590
+ ...collectionParameterIds,
1591
+ ...factoryParameterIds,
954
1592
  ]);
955
1593
  const assertionTestPoints = metadataTestPoints(region, "Assertion-Test-Points");
956
1594
  const crossCuttingTestPoints = metadataTestPoints(region, "Cross-Cutting-Test-Points");
1595
+ const payloadShape = payloadShapes.get(match[2]) ?? { paths: [], values: {} };
957
1596
  return {
958
1597
  script,
959
1598
  symbol: match[2],
@@ -962,13 +1601,69 @@ function pytestSymbols(script, source) {
962
1601
  parameterIds,
963
1602
  assertionTestPoints,
964
1603
  crossCuttingTestPoints,
1604
+ payloadPaths: payloadShape.paths,
1605
+ payloadValues: payloadShape.values,
1606
+ fallbackPayloadPaths: payloadShape.fallbackPaths ?? [],
1607
+ fallbackPayloadValues: payloadShape.fallbackValues ?? {},
965
1608
  };
966
1609
  });
967
1610
  }
1611
+ function executionSignature(testCase) {
1612
+ const operation = (testCase.body.match(/\b(?:GET|POST|PUT|PATCH|DELETE)\s+\/[^\s`|)]+/i)?.[0] ?? "operation-unavailable").toUpperCase();
1613
+ const inputPartition = [sectionBody(testCase.body, ["测试数据", "Test Data"]), testCase.scenarioTypes.join(" "), testCase.testPoints.join(" ")].join(" ");
1614
+ const precondition = sectionBody(testCase.body, ["前置条件", "Preconditions", "Precondition"]);
1615
+ const expectedOutcome = sectionBody(testCase.body, ["预期结果", "Expected Results", "Expected Result"]);
1616
+ const normalized = [operation, inputPartition, precondition, expectedOutcome]
1617
+ .map((value) => value.replaceAll(/\s+/g, " ").trim().toLowerCase())
1618
+ .join("\n");
1619
+ return createHash("sha256").update(normalized).digest("hex");
1620
+ }
1621
+ function assessPayloadContract(testCase, refs) {
1622
+ const requiredPaths = testCase.payloadContract.requiredPaths;
1623
+ const directObservedPaths = orderedUnique(refs.flatMap((item) => item.payloadPaths));
1624
+ const useFallback = requiredPaths.length > 0 && directObservedPaths.length === 0;
1625
+ const analyzedObservedPaths = useFallback
1626
+ ? orderedUnique(refs.flatMap((item) => item.fallbackPayloadPaths))
1627
+ : directObservedPaths;
1628
+ // A no-body target contract describes the target GET/DELETE request, not
1629
+ // setup POST/PUT calls in the same lifecycle test function.
1630
+ const observedPaths = testCase.payloadContract.noPayload ? [] : analyzedObservedPaths;
1631
+ const allowedPaths = testCase.payloadContract.allowedPaths;
1632
+ if (!testCase.payloadContract.declared) {
1633
+ return { status: "UNAVAILABLE", requiredPaths, allowedPaths, observedPaths, missingPaths: [], unexpectedPaths: [], enumMismatches: [] };
1634
+ }
1635
+ const missingPaths = requiredPaths.filter((item) => !observedPaths.includes(item));
1636
+ const unexpectedPaths = testCase.payloadContract.noPayload
1637
+ ? observedPaths
1638
+ : allowedPaths.length > 0
1639
+ ? observedPaths.filter((item) => !allowedPaths.includes(item) && !allowedPaths.some((allowed) => item.startsWith(`${allowed}.`)))
1640
+ : [];
1641
+ const enumMismatches = [];
1642
+ const delegatesInvalidEnumToScenarioGate = testCase.testPointBindings
1643
+ .filter((binding) => binding.mode === "variant")
1644
+ .some((binding) => /(?:ENUM|CATEGORY).*(?:INVALID|UNKNOWN|CASE|WHITESPACE|EMPTY|WRONG-TYPE)|(?:INVALID|UNKNOWN|CASE|WHITESPACE|EMPTY|WRONG-TYPE).*(?:ENUM|CATEGORY)/.test(binding.testPoint));
1645
+ for (const [field, allowed] of Object.entries(testCase.payloadContract.enumValues)) {
1646
+ const observed = orderedUnique(refs.flatMap((item) => (useFallback ? item.fallbackPayloadValues[field] : item.payloadValues[field]) ?? []).map((value) => value.replace(/^['"]|['"]$/g, "")));
1647
+ for (const value of observed) {
1648
+ if (!allowed.includes(value) && !delegatesInvalidEnumToScenarioGate)
1649
+ enumMismatches.push(`${field}=${value}; allowed=${allowed.join("|")}`);
1650
+ }
1651
+ }
1652
+ return {
1653
+ status: missingPaths.length > 0 || unexpectedPaths.length > 0 || enumMismatches.length > 0 ? "UNSAFE" : "SAFE",
1654
+ requiredPaths,
1655
+ allowedPaths,
1656
+ observedPaths,
1657
+ missingPaths,
1658
+ unexpectedPaths,
1659
+ enumMismatches,
1660
+ };
1661
+ }
968
1662
  export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
969
- const cases = await parseMarkdownCases(input.workspaceRoot);
1663
+ const layout = input.layout ?? resolveBackendTestLayout(undefined);
1664
+ const cases = await parseMarkdownCases(input.workspaceRoot, layout);
970
1665
  const modulePaths = orderedUnique(cases.map((item) => item.markdownPath));
971
- const scripts = orderedUnique(cases.flatMap((item) => [...item.declaredScripts, expectedScript(item.markdownPath)]));
1666
+ const scripts = orderedUnique(cases.flatMap((item) => [...item.declaredScripts, expectedScript(item.markdownPath, layout)]));
972
1667
  const allSymbols = [];
973
1668
  const scriptFiles = [];
974
1669
  for (const script of scripts) {
@@ -981,7 +1676,7 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
981
1676
  const findings = [];
982
1677
  const entries = [];
983
1678
  for (const testCase of cases) {
984
- const expected = expectedScript(testCase.markdownPath);
1679
+ const expected = expectedScript(testCase.markdownPath, layout);
985
1680
  const declared = testCase.declaredScripts[0];
986
1681
  const refs = allSymbols.filter((item) => item.caseIds.includes(testCase.caseId));
987
1682
  const actualScripts = orderedUnique(refs.map((item) => item.script));
@@ -998,21 +1693,44 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
998
1693
  ...crossCuttingTestPoints.filter((point) => crossCuttingBindings.includes(point)),
999
1694
  ]);
1000
1695
  const declaredPrimarySymbol = testCase.declaredPrimarySymbols[0];
1696
+ const evidenceOnly = isEvidenceOnlyMarkdownCase(testCase);
1001
1697
  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";
1698
+ let status = evidenceOnly && refs.length === 0
1699
+ ? "EXACT_1_TO_1"
1700
+ : refs.length === 0
1701
+ ? "MISSING_PYTEST"
1702
+ : refs.length > 1
1703
+ ? "MULTIPLE_PYTEST"
1704
+ : "EXACT_1_TO_1";
1003
1705
  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")
1706
+ const payloadAssessment = assessPayloadContract(testCase, refs);
1707
+ const signature = executionSignature(testCase);
1708
+ if (evidenceOnly) {
1709
+ if (refs.length > 0) {
1010
1710
  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"}`);
1711
+ entryFindings.push(`${testCase.caseId} evidence-only Markdown Case must not materialize a business pytest symbol; actualSymbols=${symbols.join(", ") || "none"}`);
1712
+ }
1713
+ }
1714
+ else {
1715
+ if (refs.length === 0)
1716
+ entryFindings.push(`${testCase.caseId} has no pytest symbol`);
1717
+ if (refs.length > 1)
1718
+ entryFindings.push(`${testCase.caseId} maps to multiple pytest symbols: ${symbols.join(", ")}`);
1719
+ if (!declared || declared !== expected || actualScripts.some((script) => script !== expected) || !declaredPrimarySymbol || symbols.some((symbol) => symbol !== declaredPrimarySymbol)) {
1720
+ if (refs.length > 0 && status === "EXACT_1_TO_1")
1721
+ status = "SCRIPT_MISMATCH";
1722
+ 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"}`);
1723
+ }
1012
1724
  }
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));
1725
+ const missingVariant = evidenceOnly
1726
+ ? []
1727
+ : variantTestPoints.filter((point) => !parameterIds.includes(point));
1728
+ const missingAssertion = evidenceOnly
1729
+ ? []
1730
+ : assertionTestPoints.filter((point) => !assertionBindings.includes(point));
1731
+ const missingCrossCutting = evidenceOnly
1732
+ ? []
1733
+ : crossCuttingTestPoints.filter((point) => !crossCuttingBindings.includes(point));
1016
1734
  const extraPoints = orderedUnique([...parameterIds, ...assertionBindings, ...crossCuttingBindings]).filter((point) => !testCase.testPoints.includes(point));
1017
1735
  if (testCase.unclassifiedTestPoints.length > 0 || testCase.duplicateBindingTestPoints.length > 0) {
1018
1736
  if (refs.length > 0 && status === "EXACT_1_TO_1")
@@ -1039,6 +1757,16 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
1039
1757
  status = "TEST_POINT_BINDING_EXTRA";
1040
1758
  entryFindings.push(`${testCase.caseId} has extra pytest Test Point bindings: ${extraPoints.join(", ")}`);
1041
1759
  }
1760
+ if (payloadAssessment.status === "UNAVAILABLE") {
1761
+ if (refs.length > 0 && status === "EXACT_1_TO_1")
1762
+ status = "PAYLOAD_CONTRACT_UNAVAILABLE";
1763
+ entryFindings.push(`${testCase.caseId} has no deterministic Payload Contract declaration`);
1764
+ }
1765
+ else if (payloadAssessment.status === "UNSAFE") {
1766
+ if (refs.length > 0 && status === "EXACT_1_TO_1")
1767
+ status = "PAYLOAD_SHAPE_MISMATCH";
1768
+ entryFindings.push(`${testCase.caseId} payload contract mismatch: missing=${payloadAssessment.missingPaths.join(", ") || "none"}; unexpected=${payloadAssessment.unexpectedPaths.join(", ") || "none"}; enum=${payloadAssessment.enumMismatches.join(", ") || "none"}`);
1769
+ }
1042
1770
  findings.push(...entryFindings);
1043
1771
  entries.push({
1044
1772
  markdownModule: testCase.markdownPath,
@@ -1056,6 +1784,8 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
1056
1784
  parameterIds,
1057
1785
  cardinality,
1058
1786
  status,
1787
+ payloadAssessment,
1788
+ executionSignature: signature,
1059
1789
  findings: entryFindings,
1060
1790
  });
1061
1791
  }
@@ -1082,23 +1812,41 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
1082
1812
  parameterIds: symbol.parameterIds,
1083
1813
  cardinality: "0:1",
1084
1814
  status: unknownId ? "EXTRA_PYTEST" : "SYMBOL_MISSING_CASE_ID",
1815
+ payloadAssessment: {
1816
+ status: "UNAVAILABLE",
1817
+ requiredPaths: [],
1818
+ allowedPaths: [],
1819
+ observedPaths: symbol.payloadPaths,
1820
+ missingPaths: [],
1821
+ unexpectedPaths: [],
1822
+ enumMismatches: [],
1823
+ },
1085
1824
  findings: [message],
1086
1825
  });
1087
1826
  }
1827
+ const duplicateSignatures = new Map();
1828
+ for (const entry of entries.filter((item) => item.caseId && item.executionSignature)) {
1829
+ const owners = duplicateSignatures.get(entry.executionSignature) ?? [];
1830
+ owners.push(entry.caseId);
1831
+ duplicateSignatures.set(entry.executionSignature, owners);
1832
+ }
1833
+ const duplicateSignatureGroups = [...duplicateSignatures.entries()].filter(([, owners]) => orderedUnique(owners).length > 1);
1834
+ for (const [signature, owners] of duplicateSignatureGroups)
1835
+ findings.push(`duplicate execution signature ${signature}: ${orderedUnique(owners).join(", ")}`);
1088
1836
  const exactModuleCount = modulePaths.filter((modulePath) => {
1089
- const expected = expectedScript(modulePath);
1837
+ const expected = expectedScript(modulePath, layout);
1090
1838
  const moduleCases = cases.filter((item) => item.markdownPath === modulePath);
1091
1839
  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
1840
  }).length;
1093
1841
  const caseEntries = entries.filter((entry) => entry.cardinality !== "0:1");
1094
1842
  const facts = backendTestMarkdownPytestCorrespondenceFactsSchema.parse({
1095
- schemaId: "backend-test-markdown-pytest-correspondence-facts-v2",
1096
- schemaVersion: 2,
1843
+ schemaId: "backend-test-markdown-pytest-correspondence-facts-v3",
1844
+ schemaVersion: 3,
1097
1845
  taskId: input.taskId,
1098
1846
  status: findings.length === 0 ? "PASS" : "FAIL",
1099
1847
  inputFiles: await inputFileFacts(input.workspaceRoot, [
1100
- path.join(input.workspaceRoot, "testcase", "md", "README.md"),
1101
- ...(await markdownModuleFiles(input.workspaceRoot)),
1848
+ path.join(input.workspaceRoot, ...layout.readmePath.split("/")),
1849
+ ...(await markdownModuleFiles(input.workspaceRoot, layout)),
1102
1850
  ...scriptFiles,
1103
1851
  ].filter((file) => file && path.isAbsolute(file))),
1104
1852
  summary: {
@@ -1118,6 +1866,10 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
1118
1866
  crossCuttingTestPointCount: cases.reduce((sum, item) => sum + item.testPointBindings.filter((binding) => binding.mode === "cross-cutting").length, 0),
1119
1867
  unclassifiedTestPointCount: cases.reduce((sum, item) => sum + item.unclassifiedTestPoints.length, 0),
1120
1868
  duplicateBindingTestPointCount: cases.reduce((sum, item) => sum + item.duplicateBindingTestPoints.length, 0),
1869
+ payloadSafeCount: caseEntries.filter((entry) => entry.payloadAssessment.status === "SAFE").length,
1870
+ payloadUnsafeCount: caseEntries.filter((entry) => entry.payloadAssessment.status === "UNSAFE").length,
1871
+ payloadUnavailableCount: caseEntries.filter((entry) => entry.payloadAssessment.status === "UNAVAILABLE").length,
1872
+ duplicateExecutionSignatureCount: duplicateSignatureGroups.length,
1121
1873
  },
1122
1874
  entries,
1123
1875
  findings: orderedUnique(findings),
@@ -1145,12 +1897,16 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
1145
1897
  `- Cross-Cutting Test Points: ${facts.summary.crossCuttingTestPointCount}`,
1146
1898
  `- Unclassified Test Points: ${facts.summary.unclassifiedTestPointCount}`,
1147
1899
  `- Duplicate Test Point Bindings: ${facts.summary.duplicateBindingTestPointCount}`,
1900
+ `- Payload SAFE: ${facts.summary.payloadSafeCount}`,
1901
+ `- Payload UNSAFE: ${facts.summary.payloadUnsafeCount}`,
1902
+ `- Payload UNAVAILABLE: ${facts.summary.payloadUnavailableCount}`,
1903
+ `- Duplicate Execution Signatures: ${facts.summary.duplicateExecutionSignatureCount}`,
1148
1904
  "",
1149
1905
  "## Correspondence Matrix",
1150
1906
  "",
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} |`),
1907
+ "| 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 |",
1908
+ "|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|",
1909
+ ...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
1910
  "",
1155
1911
  "## Findings",
1156
1912
  "",