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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/AGENTS.md +2 -6
  2. package/CHANGELOG.md +71 -144
  3. package/README.md +1 -1
  4. package/bin/loop-agent.js +1 -37
  5. package/dist/application/task-lifecycle/advance.js +0 -1
  6. package/dist/application/task-lifecycle/observe.js +0 -15
  7. package/dist/application/task-lifecycle/plan-transitions.js +0 -15
  8. package/dist/cli/command-definitions.js +0 -7
  9. package/dist/cli/program.js +3 -8
  10. package/dist/commands/init-upgrade.js +19 -351
  11. package/dist/commands/init.js +67 -14
  12. package/dist/commands/run-dag-progress.js +0 -14
  13. package/dist/commands/task-advance.js +3 -33
  14. package/dist/executors/dag-pi-executor.js +30 -47
  15. package/dist/executors/pi-sdk-executor.js +0 -34
  16. package/dist/executors/shell-executor.js +95 -6
  17. package/dist/shared/operator/capabilities.js +13 -200
  18. package/dist/shared/package-metadata.js +0 -42
  19. package/dist/task/contract/project.js +0 -4
  20. package/dist/task/source-prepare/completeness.js +0 -53
  21. package/dist/task/source-prepare/parse-intent.js +12 -82
  22. package/dist/task/source-prepare/prepare.js +5 -102
  23. package/dist/task/source-prepare/semantic-intake.js +23 -144
  24. package/dist/worker/console/chat/artifact-card.js +1 -8
  25. package/dist/worker/console/chat/chat-event-store.js +0 -152
  26. package/dist/worker/console/chat/human-gate-card.js +1 -9
  27. package/dist/worker/console/chat/operation-card.js +2 -71
  28. package/dist/worker/console/chat/pi-runtime.js +62 -111
  29. package/dist/worker/console/chat/routes.js +10 -43
  30. package/dist/worker/console/chat/session-store.js +1 -9
  31. package/dist/worker/console/chat/shortcuts.js +7 -9
  32. package/dist/worker/console/chat/turn-process.js +23 -204
  33. package/dist/worker/console/chat/workspace-landing.js +1 -2
  34. package/dist/worker/console/operation-runner.js +6 -155
  35. package/dist/worker/console/operator-actions.js +14 -391
  36. package/dist/worker/console/operator-user-error.js +0 -4
  37. package/dist/worker/console/recovery-cta.js +3 -50
  38. package/dist/worker/console/static/assets/index-HX1pbOyl.css +1 -0
  39. package/dist/worker/console/static/assets/index-M0BLEBfh.js +56 -0
  40. package/dist/worker/console/static/index.html +2 -2
  41. package/dist/worker/console/static-src/app/console-types.js +9 -13
  42. package/dist/worker/console/static-src/app/useOperatorActions.js +2 -4
  43. package/dist/worker/console/static-src/app/useRecoveryActions.js +56 -65
  44. package/dist/worker/console/static-src/app/useRecoveryConsole.js +1 -73
  45. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +30 -114
  46. package/dist/worker/console/static-src/operator-chat/refs.js +0 -3
  47. package/dist/worker/console/static-src/operator-chat/spatial-overlay.js +1 -2
  48. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +13 -67
  49. package/dist/worker/console/static-src/operator-chat/useChatThread.js +6 -12
  50. package/dist/worker/console/static-src/operator-chat/useComposer.js +8 -45
  51. package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +3 -7
  52. package/dist/worker/loop-agent/loop-agent-client.js +3 -17
  53. package/dist/worker/observability/read-model.js +0 -20
  54. package/dist/worker/observe/spec-evidence.js +8 -3
  55. package/dist/worker/observe/static/operator-chrome.d.ts +0 -1
  56. package/dist/worker/observe/static/operator-chrome.js +3 -10
  57. package/dist/worker/observe/static/views/dag-inspector.js +71 -6
  58. package/dist/worker/observe/static/views/dag.js +0 -12
  59. package/dist/worker/preflight.js +1 -2
  60. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +707 -37
  61. package/dist/workflows/dag/backend-test-case-manifest.js +4 -0
  62. package/dist/workflows/dag/backend-test-markdown-workflow.js +109 -7
  63. package/dist/workflows/dag/backend-test-module-stem.js +5 -0
  64. package/dist/workflows/dag/backend-test-pytest-collection.js +345 -24
  65. package/dist/workflows/dag/backend-test-scenario-param.js +890 -164
  66. package/dist/workflows/dag/backend-test-writer-completeness.js +47 -16
  67. package/dist/workflows/dag/dynamic-runtime/map.js +24 -8
  68. package/dist/workflows/dag/dynamic-runtime/shared.js +1 -9
  69. package/dist/workflows/dag/frontend-implementation-contract.js +39 -233
  70. package/dist/workflows/dag/frontend-prewrite-gate.js +61 -364
  71. package/dist/workflows/dag/frontend-repair.js +18 -219
  72. package/dist/workflows/dag/frontend-verification-trace.js +32 -47
  73. package/dist/workflows/dag/init-hybrid.js +82 -165
  74. package/dist/workflows/dag/node-execution.js +0 -89
  75. package/dist/workflows/dag/recovery-recommendation.js +0 -58
  76. package/dist/workflows/dag/runner.js +15 -333
  77. package/dist/workflows/dag/scheduler.js +3 -257
  78. package/dist/workflows/dag/types.js +9 -130
  79. package/docs/README.md +3 -3
  80. package/docs/architecture/evolution.md +67 -102
  81. package/docs/templates/backend-test-dag.json +50 -32
  82. package/docs/templates/init-managed-agents.md +2 -5
  83. package/harness.json +2 -2
  84. package/package.json +3 -9
  85. package/skills/loop-agent/SKILL.md +0 -1
  86. package/skills/loop-agent/references/command-reference.md +0 -4
  87. package/dist/build-stamp.json +0 -6
  88. package/dist/commands/dag-request-interrupt.js +0 -20
  89. package/dist/worker/console/chat/assistant-content.js +0 -121
  90. package/dist/worker/console/chat/semantic-activity.js +0 -477
  91. package/dist/worker/console/operation-run-facts.js +0 -190
  92. package/dist/worker/console/operation-wait.js +0 -355
  93. package/dist/worker/console/recovery-error-copy.js +0 -198
  94. package/dist/worker/console/static/assets/abnfDiagram-N423BO3Z-DSvF6RXQ.js +0 -1
  95. package/dist/worker/console/static/assets/arc-DuI4ogtJ.js +0 -1
  96. package/dist/worker/console/static/assets/architectureDiagram-T3A2C74G-DdBbgD4y.js +0 -36
  97. package/dist/worker/console/static/assets/blockDiagram-VBNYF7ZC-xQC2HELm.js +0 -132
  98. package/dist/worker/console/static/assets/c4Diagram-5PPSVZJV-DPMSgwIZ.js +0 -10
  99. package/dist/worker/console/static/assets/channel-DLVz9TYW.js +0 -1
  100. package/dist/worker/console/static/assets/chunk-2GRJ4B5K-WTEKTxin.js +0 -1
  101. package/dist/worker/console/static/assets/chunk-2Q5K7J3B-B6vZ0Pv_.js +0 -1
  102. package/dist/worker/console/static/assets/chunk-5RXB4S5H-BrerNAix.js +0 -231
  103. package/dist/worker/console/static/assets/chunk-5VM5RSS4-D_ch2aaI.js +0 -15
  104. package/dist/worker/console/static/assets/chunk-6Q2QTUOP-VvfacNrq.js +0 -88
  105. package/dist/worker/console/static/assets/chunk-GF5L2VYU-B-uGFIg2.js +0 -206
  106. package/dist/worker/console/static/assets/chunk-JWPE2WC7-BlysCtod.js +0 -1
  107. package/dist/worker/console/static/assets/chunk-KBJHAD2P-BjAUAfcT.js +0 -1
  108. package/dist/worker/console/static/assets/chunk-RYQCIY6F-BeZnBmb-.js +0 -1
  109. package/dist/worker/console/static/assets/chunk-XXDRQBXY-Bbfe3UJG.js +0 -1
  110. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BeAuGcO9.js +0 -1
  111. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BeAuGcO9.js +0 -1
  112. package/dist/worker/console/static/assets/cose-bilkent-JH36ORCC-BKJQnYVM.js +0 -1
  113. package/dist/worker/console/static/assets/cynefin-VYW2F7L2-B1wtI3hU.js +0 -166
  114. package/dist/worker/console/static/assets/cynefinDiagram-MW4NZA55-DCF6DBSu.js +0 -62
  115. package/dist/worker/console/static/assets/cytoscape.esm-yzknjiTM.js +0 -321
  116. package/dist/worker/console/static/assets/dagre-VZM6K2ZE-BJyNGRlP.js +0 -4
  117. package/dist/worker/console/static/assets/defaultLocale-DX6XiGOO.js +0 -1
  118. package/dist/worker/console/static/assets/diagram-7IWD3JNH-B7j1-VsZ.js +0 -30
  119. package/dist/worker/console/static/assets/diagram-B4RE2ZJO-BREJ140a.js +0 -3
  120. package/dist/worker/console/static/assets/diagram-LBJQPF4R-CDPHmHGH.js +0 -24
  121. package/dist/worker/console/static/assets/diagram-Q27KOJAE-BtZLrez1.js +0 -24
  122. package/dist/worker/console/static/assets/diagram-UB23O5K3-mzybu8oU.js +0 -41
  123. package/dist/worker/console/static/assets/ebnfDiagram-BXEA7PRR-Dk3I2uO6.js +0 -1
  124. package/dist/worker/console/static/assets/erDiagram-JOGREHBK-BT97u_tQ.js +0 -85
  125. package/dist/worker/console/static/assets/flowDiagram-UKHOOZJN-CMOtYzui.js +0 -156
  126. package/dist/worker/console/static/assets/ganttDiagram-PKOTCBZU-DiNydkuY.js +0 -292
  127. package/dist/worker/console/static/assets/gitGraphDiagram-DS77QQ5N-Cwt6fLkl.js +0 -106
  128. package/dist/worker/console/static/assets/graph-DOmOIIwC.js +0 -1
  129. package/dist/worker/console/static/assets/index-DQxiso1w.js +0 -325
  130. package/dist/worker/console/static/assets/index-_j0Hzo69.css +0 -1
  131. package/dist/worker/console/static/assets/infoDiagram-6WML65LV-AsGIGKKB.js +0 -2
  132. package/dist/worker/console/static/assets/init-Gi6I4Gst.js +0 -1
  133. package/dist/worker/console/static/assets/ishikawaDiagram-WSZJBQD7-BL8ITZh3.js +0 -70
  134. package/dist/worker/console/static/assets/journeyDiagram-NVQOT4AX-VPKAUX8M.js +0 -139
  135. package/dist/worker/console/static/assets/kanban-definition-27J2QSJJ-BL56hfGp.js +0 -89
  136. package/dist/worker/console/static/assets/layout-D-LzfAck.js +0 -1
  137. package/dist/worker/console/static/assets/linear-BmSG3nIZ.js +0 -1
  138. package/dist/worker/console/static/assets/map-DxJ2ADlA.js +0 -1
  139. package/dist/worker/console/static/assets/mermaid.core-DWGK9kwP.js +0 -308
  140. package/dist/worker/console/static/assets/mindmap-definition-FAOFIHXS--vvKLjo0.js +0 -96
  141. package/dist/worker/console/static/assets/ordinal-Cboi1Yqb.js +0 -1
  142. package/dist/worker/console/static/assets/pegDiagram-VL7TDLO6-B41mxb2-.js +0 -1
  143. package/dist/worker/console/static/assets/pieDiagram-7S7Q4E2Y-DTlu77sw.js +0 -39
  144. package/dist/worker/console/static/assets/quadrantDiagram-CIZ2JOQS-Bk5A7alv.js +0 -7
  145. package/dist/worker/console/static/assets/railroadDiagram-AXF67PYL-Csf9jzQy.js +0 -1
  146. package/dist/worker/console/static/assets/requirementDiagram-LRYGKXZP-CeptoSNx.js +0 -84
  147. package/dist/worker/console/static/assets/sankeyDiagram-W5VNT64P-A-DLCLer.js +0 -40
  148. package/dist/worker/console/static/assets/sequenceDiagram-SI44F4Z6-22VbPdOI.js +0 -162
  149. package/dist/worker/console/static/assets/sizeCapture-X5ZJPWSS-DWvAyBC9.js +0 -1
  150. package/dist/worker/console/static/assets/stateDiagram-OKZ733FA-qICZ75yT.js +0 -1
  151. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DzKvfHTX.js +0 -1
  152. package/dist/worker/console/static/assets/swimlanes-SLNWSIFB-xi7lcMyp.js +0 -2
  153. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Cxqr_qM1.js +0 -8
  154. package/dist/worker/console/static/assets/timeline-definition-Z64GVDOM-Bu6Nl6O2.js +0 -120
  155. package/dist/worker/console/static/assets/vennDiagram-T6HMQDX7-BzMz4Bn9.js +0 -34
  156. package/dist/worker/console/static/assets/wardleyDiagram-T6FBY63Y-CxaJnzc5.js +0 -78
  157. package/dist/worker/console/static/assets/xychartDiagram-ELKLHX3M-5JK-omQK.js +0 -7
  158. package/dist/worker/console/static/fonts/katex/KaTeX_AMS-Regular.woff2 +0 -0
  159. package/dist/worker/console/static/fonts/katex/KaTeX_Caligraphic-Bold.woff2 +0 -0
  160. package/dist/worker/console/static/fonts/katex/KaTeX_Caligraphic-Regular.woff2 +0 -0
  161. package/dist/worker/console/static/fonts/katex/KaTeX_Fraktur-Bold.woff2 +0 -0
  162. package/dist/worker/console/static/fonts/katex/KaTeX_Fraktur-Regular.woff2 +0 -0
  163. package/dist/worker/console/static/fonts/katex/KaTeX_Main-Bold.woff2 +0 -0
  164. package/dist/worker/console/static/fonts/katex/KaTeX_Main-BoldItalic.woff2 +0 -0
  165. package/dist/worker/console/static/fonts/katex/KaTeX_Main-Italic.woff2 +0 -0
  166. package/dist/worker/console/static/fonts/katex/KaTeX_Main-Regular.woff2 +0 -0
  167. package/dist/worker/console/static/fonts/katex/KaTeX_Math-BoldItalic.woff2 +0 -0
  168. package/dist/worker/console/static/fonts/katex/KaTeX_Math-Italic.woff2 +0 -0
  169. package/dist/worker/console/static/fonts/katex/KaTeX_SansSerif-Bold.woff2 +0 -0
  170. package/dist/worker/console/static/fonts/katex/KaTeX_SansSerif-Italic.woff2 +0 -0
  171. package/dist/worker/console/static/fonts/katex/KaTeX_SansSerif-Regular.woff2 +0 -0
  172. package/dist/worker/console/static/fonts/katex/KaTeX_Script-Regular.woff2 +0 -0
  173. package/dist/worker/console/static/fonts/katex/KaTeX_Size1-Regular.woff2 +0 -0
  174. package/dist/worker/console/static/fonts/katex/KaTeX_Size2-Regular.woff2 +0 -0
  175. package/dist/worker/console/static/fonts/katex/KaTeX_Size3-Regular.woff2 +0 -0
  176. package/dist/worker/console/static/fonts/katex/KaTeX_Size4-Regular.woff2 +0 -0
  177. package/dist/worker/console/static/fonts/katex/KaTeX_Typewriter-Regular.woff2 +0 -0
  178. package/dist/worker/console/static-src/operator-chat/activity-journey.js +0 -125
  179. package/dist/worker/console/static-src/operator-chat/activity-rail-presentation.js +0 -73
  180. package/dist/worker/console/static-src/operator-chat/activity-references.js +0 -20
  181. package/dist/worker/console/static-src/operator-chat/slash-palette-layout.js +0 -24
  182. package/dist/worker/console/static-src/operator-chat/slash-palette-nav.js +0 -141
  183. package/dist/worker/console/static-src/operator-chat/useActivityRailTransition.js +0 -59
  184. package/dist/worker/observability/interrupt-eligibility.js +0 -264
  185. package/dist/workflows/dag/contract-output-registry.js +0 -14
  186. package/dist/workflows/dag/contract-validator-registrations.js +0 -8
  187. package/dist/workflows/dag/frontend-recovery-plan.js +0 -73
  188. package/dist/workflows/dag/frontend-recovery-root-manifest.js +0 -123
  189. package/dist/workflows/dag/frontend-recovery-run.js +0 -539
  190. package/dist/workflows/dag/frontend-writer-recovery.js +0 -106
  191. package/dist/workflows/dag/frontend-writer-rollback.js +0 -821
  192. package/dist/workflows/dag/interrupt-request.js +0 -559
@@ -98,40 +98,208 @@ async function listMarkdownCases(workspaceRoot) {
98
98
  const allPoints = orderedUnique(body.match(/\bTP-[A-Z0-9-]+\b/g) ?? []);
99
99
  const variantLabel = body.match(/(?:Variant Test Points|\u53d8\u4f53\u6d4b\u8bd5\u70b9)\s*[::]\s*([^\n]+)/i)?.[1] ??
100
100
  "";
101
+ const variantLabelDeclared = variantLabel.trim().length > 0;
102
+ const variantLabelIsNone = /^(?:none|无|n\/a|not-applicable)\b/i.test(variantLabel.trim());
101
103
  const variantFromLabel = orderedUnique((variantLabel.match(/\bTP-[A-Z0-9-]+\b/g) ?? []).filter((item) => item.toUpperCase() !== "NONE"));
102
- const variantTestPoints = variantFromLabel.length > 0
103
- ? variantFromLabel
104
- : allPoints.filter((tp) => /(EMPTY|MISSING|NULL|MIN|MAX|INVALID|WRONG|ENUM|PATTERN|NOMINAL|BOUNDARY)/i.test(tp));
104
+ const variantTestPoints = variantLabelIsNone
105
+ ? []
106
+ : variantFromLabel.length > 0
107
+ ? variantFromLabel
108
+ : variantLabelDeclared
109
+ ? []
110
+ : allPoints.filter((tp) => /(EMPTY|MISSING|NULL|MIN|MAX|INVALID|WRONG|ENUM|PATTERN|NOMINAL|BOUNDARY)/i.test(tp));
111
+ const scriptLine = body.match(/^(?:\s*[-*+]\s*)?(?:脚本|script)\s*[::]\s*([^\n]+)/im)?.[1]?.replaceAll("`", "").trim() ?? "";
112
+ const scriptExplicitNone = /^(?:none|无|n\/a|not-applicable)(?:\s*[((].*[))])?$/i.test(scriptLine);
105
113
  const scriptMatch = body.match(/`?(testcase\/[A-Za-z0-9_./-]*test_[A-Za-z0-9_.-]*\.py)`?/i);
106
114
  cases.push({
107
115
  caseId,
108
116
  body,
109
117
  markdownPath: rel,
110
- variantTestPoints: variantTestPoints.length > 0 ? variantTestPoints : allPoints.slice(0, 1),
111
- scriptPath: scriptMatch?.[1] ??
112
- expectedBackendTestPytestScriptForMarkdownModule(name),
118
+ variantTestPoints: variantLabelDeclared
119
+ ? variantTestPoints
120
+ : variantTestPoints.length > 0 ? variantTestPoints : allPoints.slice(0, 1),
121
+ scriptPath: scriptExplicitNone
122
+ ? undefined
123
+ : scriptMatch?.[1] ?? expectedBackendTestPytestScriptForMarkdownModule(name),
113
124
  });
114
125
  }
115
126
  }
116
127
  return cases;
117
128
  }
129
+ function parseScenarioBoundToken(raw) {
130
+ if (!raw)
131
+ return undefined;
132
+ const token = raw.trim();
133
+ if (!token || /^LEN<nominal>$/i.test(token) || /^NOMINAL$/i.test(token)) {
134
+ return undefined;
135
+ }
136
+ const direct = Number(token);
137
+ if (Number.isFinite(direct) && direct >= 0)
138
+ return direct;
139
+ const len = /^(?:LEN(?:GTH)?[_-]?)?(\d{1,5})$/i.exec(token);
140
+ if (len) {
141
+ const n = Number(len[1]);
142
+ return Number.isFinite(n) ? n : undefined;
143
+ }
144
+ return undefined;
145
+ }
146
+ function parseScenarioIntentLine(line) {
147
+ // Target may contain commas (body.title,body.status). Intent may contain `|` / spaces.
148
+ // Stop field values only at known trailing keys or end-of-line.
149
+ const precise = /场景意图\s*[::]\s*(TP-[A-Z0-9-]+)\s*[|;]\s*operation\s*=\s*([^;\n]+?)\s*[|;]\s*target\s*=\s*([^;\n]+?)\s*[|;]\s*intent\s*=\s*([^;\n]+?)(?:\s*[|;]\s*bound\s*=\s*([^\s;|\n]+))?(?:\s*[|;]\s*example\s*=\s*([^;\n]+))?(?:\s*[|;]\s*expectedCode\s*=\s*[^;\n]+)?\s*$/i.exec(line.trim());
150
+ if (precise) {
151
+ return {
152
+ tpId: precise[1],
153
+ rawIntent: (precise[4] ?? "unknown").trim(),
154
+ target: precise[3]?.trim(),
155
+ bound: parseScenarioBoundToken(precise[5]),
156
+ example: precise[6]?.trim(),
157
+ legacy: false,
158
+ };
159
+ }
160
+ const legacy = /场景意图\s*[::]\s*([a-z0-9+._:-]+)(?:\s*[|;,,]\s*field\s*=\s*([A-Za-z0-9_.]+))?(?:\s*[|;,,]\s*bound\s*=\s*([^\s;|,,\n]+))?(?:\s*[|;,,]\s*example\s*=\s*([^|\n]+))?/i.exec(line.trim());
161
+ if (legacy) {
162
+ return {
163
+ rawIntent: (legacy[1] ?? "unknown").trim(),
164
+ field: legacy[2],
165
+ bound: parseScenarioBoundToken(legacy[3]),
166
+ example: legacy[4]?.trim(),
167
+ legacy: true,
168
+ };
169
+ }
170
+ return undefined;
171
+ }
172
+ function resolveScenarioIntentField(target, field) {
173
+ if (field)
174
+ return field;
175
+ if (!target || target === "request")
176
+ return undefined;
177
+ const bodyTargets = target
178
+ .split(/\s*,\s*/)
179
+ .map((item) => item.trim())
180
+ .filter((item) => /^(?:body|json|payload)\./i.test(item));
181
+ const preferred = bodyTargets.find((item) => /\.status$/i.test(item)) ??
182
+ bodyTargets[0] ??
183
+ target.split(/\s*,\s*/)[0];
184
+ return preferred?.split(".").at(-1);
185
+ }
186
+ function resolveCustomLiteralIntent(rawIntent, field) {
187
+ const lower = rawIntent.toLowerCase();
188
+ if (!lower.startsWith("custom-literal:")) {
189
+ return CLOSED_SET.has(lower) ? lower : "unknown";
190
+ }
191
+ // Preserve literal case: enum values such as ACTIVE must not be lowercased.
192
+ const prefixLength = rawIntent.toLowerCase().startsWith("custom-literal:")
193
+ ? rawIntent.indexOf(":") + 1
194
+ : "custom-literal:".length;
195
+ const payload = rawIntent.slice(prefixLength);
196
+ if (!payload.includes("|") && !payload.includes(",")) {
197
+ return `custom-literal:${payload}`;
198
+ }
199
+ const parts = payload.split(/\s*[|,]\s*/).map((item) => item.trim()).filter(Boolean);
200
+ if (field) {
201
+ const byField = parts.find((part) => part.toLowerCase().startsWith(`${field.toLowerCase()}=`));
202
+ if (byField) {
203
+ return `custom-literal:${byField.slice(field.length + 1)}`;
204
+ }
205
+ if (/^status$/i.test(field)) {
206
+ const enumLike = parts.find((part) => /^(?:ACTIVE|ARCHIVED|INACTIVE|DISABLED|ENABLED)$/i.test(part));
207
+ if (enumLike)
208
+ return `custom-literal:${enumLike}`;
209
+ }
210
+ if (/^title$/i.test(field)) {
211
+ const titleLike = parts.find((part) => !/^(?:ACTIVE|ARCHIVED|INACTIVE|DISABLED|ENABLED)$/i.test(part));
212
+ if (titleLike)
213
+ return `custom-literal:${titleLike}`;
214
+ }
215
+ }
216
+ return parts.length === 1
217
+ ? `custom-literal:${parts[0]}`
218
+ : "unknown";
219
+ }
118
220
  export function inferScenarioParamIntent(input) {
119
221
  const { tpId, caseBody } = input;
120
- const preciseLine = /场景意图\s*[::]\s*(TP-[A-Z0-9-]+)\s*[|;,,]\s*operation\s*=\s*([^|;,,\n]+)\s*[|;,,]\s*target\s*=\s*([A-Za-z0-9_.-]+)\s*[|;,,]\s*intent\s*=\s*([a-z0-9+._:-]+)(?:\s*[|;,,]\s*bound\s*=\s*(\d+))?(?:\s*[|;,,]\s*example\s*=\s*([^|\n]+))?/i.exec(caseBody);
121
- const legacyLine = /场景意图\s*[::]\s*([a-z0-9+._:-]+)(?:\s*[|;,,]\s*field\s*=\s*([A-Za-z0-9_.]+))?(?:\s*[|;,,]\s*bound\s*=\s*(\d+))?(?:\s*[|;,,]\s*example\s*=\s*([^|\n]+))?/i.exec(caseBody);
122
- const fallbackLine = new RegExp(`${tpId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[^\\n]{0,160}?intent\\s*[=:]\\s*([a-z0-9+._:-]+)`, "i").exec(caseBody);
123
- const machineLine = preciseLine ?? legacyLine ?? fallbackLine;
124
- if (machineLine) {
125
- const rawIntent = (preciseLine ? preciseLine[4] : machineLine[1] ?? "unknown").toLowerCase();
126
- const target = preciseLine?.[3];
127
- const field = target && target !== "request" ? target.split(".").at(-1) : legacyLine?.[2];
128
- const boundRaw = preciseLine?.[5] ?? legacyLine?.[3];
129
- const example = (preciseLine?.[6] ?? legacyLine?.[4])?.trim();
130
- const bound = boundRaw ? Number(boundRaw) : undefined;
131
- const normalizedIntent = rawIntent === "nominal-operation" ? "nominal" : rawIntent;
132
- if (normalizedIntent.startsWith("custom-literal:")) {
222
+ const intentLines = caseBody
223
+ .split(/\r?\n/)
224
+ .map((line) => line.trim())
225
+ .filter((line) => /场景意图\s*[::]/.test(line));
226
+ const parsedLines = intentLines
227
+ .map((line) => parseScenarioIntentLine(line))
228
+ .filter((item) => Boolean(item));
229
+ const preciseForTp = parsedLines.find((item) => item.tpId && item.tpId.toUpperCase() === tpId.toUpperCase());
230
+ const legacyLine = parsedLines.find((item) => item.legacy);
231
+ const fallbackLine = new RegExp(`${tpId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[^\\n]{0,200}?intent\\s*[=:]\\s*([^|;,\uff0c\\n]+)`, "i").exec(caseBody);
232
+ const machine = preciseForTp ??
233
+ (legacyLine && parsedLines.filter((item) => !item.legacy).length === 0 ? legacyLine : undefined) ??
234
+ (fallbackLine
235
+ ? {
236
+ rawIntent: fallbackLine[1] ?? "unknown",
237
+ legacy: true,
238
+ }
239
+ : undefined);
240
+ if (machine) {
241
+ const field = resolveScenarioIntentField(machine.target, machine.field);
242
+ const rawIntent = machine.rawIntent.trim();
243
+ const example = machine.example;
244
+ const lower = rawIntent.toLowerCase();
245
+ let normalizedIntent = lower === "nominal-operation" ? "nominal" : lower;
246
+ // Writers sometimes mark a short "typical" length sample as min/max with bound=LEN<nominal>.
247
+ // Treat that as nominal so a short helper/string is not forced to equal maxLength.
248
+ if ((normalizedIntent === "min" || normalizedIntent === "max") &&
249
+ /NOMINAL/i.test(tpId)) {
250
+ normalizedIntent = "nominal";
251
+ }
252
+ const dataSection = sectionBody(caseBody, ["测试数据", "Test Data", "操作步骤", "Steps"]);
253
+ const haystack = `${caseBody}\n${dataSection}`;
254
+ const fieldEsc = field
255
+ ? field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
256
+ : undefined;
257
+ const maxBoundFromBody = fieldEsc
258
+ ? new RegExp(`${fieldEsc}[^\\n]{0,80}?(?:maxLength|max(?:imum)?|上限|最大长度)\\s*[=::]?\\s*(\\d{1,4})`, "i").exec(haystack) ??
259
+ new RegExp(`(?:maxLength|max(?:imum)?|上限|最大长度)\\s*[=::]?\\s*(\\d{1,4})[^\\n]{0,80}?${fieldEsc}`, "i").exec(haystack) ??
260
+ /(?:maxLength|max(?:imum)?|上限|最大长度)\s*[=::]?\s*(\d{1,4})/i.exec(haystack)
261
+ : /(?:maxLength|max(?:imum)?|上限|最大长度)\s*[=::]?\s*(\d{1,4})/i.exec(haystack);
262
+ const minBoundFromBody = fieldEsc
263
+ ? new RegExp(`${fieldEsc}[^\\n]{0,80}?(?:minLength|min(?:imum)?|下限|最小长度)\\s*[=::]?\\s*(\\d{1,4})`, "i").exec(haystack) ??
264
+ new RegExp(`(?:minLength|min(?:imum)?|下限|最小长度)\\s*[=::]?\\s*(\\d{1,4})[^\\n]{0,80}?${fieldEsc}`, "i").exec(haystack) ??
265
+ /(?:minLength|min(?:imum)?|下限|最小长度)\s*[=::]?\s*(\d{1,4})/i.exec(haystack)
266
+ : /(?:minLength|min(?:imum)?|下限|最小长度)\s*[=::]?\s*(\d{1,4})/i.exec(haystack);
267
+ const prefersMinBound = normalizedIntent === "min" ||
268
+ normalizedIntent === "min-1" ||
269
+ /(?:MIN|LEN1|LENGTH-?1|最短|最小)/i.test(tpId);
270
+ const boundFromBody = prefersMinBound
271
+ ? minBoundFromBody ?? maxBoundFromBody
272
+ : maxBoundFromBody ?? minBoundFromBody;
273
+ const boundFromTp = /(?:LEN(?:GTH)?|长度)[_-]?(\d{1,5})(?:[_-]|$)/i.exec(tpId) ??
274
+ /(?:MIN|MAX)[_-]?(\d{1,5})(?:[_-]|$)/i.exec(tpId);
275
+ const lengthIntent = normalizedIntent === "min" ||
276
+ normalizedIntent === "min-1" ||
277
+ normalizedIntent === "max" ||
278
+ normalizedIntent === "max+1";
279
+ // Length intents: explicit machine bound >0, then TP-encoded LEN1/LEN100,
280
+ // then field min/maxLength. Prevents maxLength=100 shadowing min/LEN1.
281
+ const boundFromTpNumber = boundFromTp ? Number(boundFromTp[1]) : undefined;
282
+ // max+1 TP tokens encode the oversized length (LEN101), so convert to base bound.
283
+ const boundFromTpNormalized = boundFromTpNumber === undefined
284
+ ? undefined
285
+ : normalizedIntent === "max+1"
286
+ ? Math.max(0, boundFromTpNumber - 1)
287
+ : boundFromTpNumber;
288
+ const bound = machine.bound && machine.bound > 0
289
+ ? machine.bound
290
+ : lengthIntent && boundFromTpNormalized !== undefined
291
+ ? boundFromTpNormalized
292
+ : boundFromBody
293
+ ? Number(boundFromBody[1])
294
+ : machine.bound;
295
+ if (rawIntent.toLowerCase().startsWith("custom-literal:")) {
296
+ const resolved = resolveCustomLiteralIntent(rawIntent, field);
297
+ const rawLiteral = rawIntent.slice(rawIntent.indexOf(":") + 1).trim();
298
+ const intent = resolved === "unknown" && example && rawLiteral === example.trim()
299
+ ? `custom-literal:${example.trim()}`
300
+ : resolved;
133
301
  return {
134
- intent: normalizedIntent,
302
+ intent,
135
303
  field,
136
304
  bound,
137
305
  example,
@@ -169,13 +337,47 @@ export function inferScenarioParamIntent(input) {
169
337
  "VARIANT",
170
338
  "WHITESPACE",
171
339
  "UNKNOWN",
340
+ "HEALTH",
341
+ "UP",
342
+ "OK",
343
+ "GET",
344
+ "API",
345
+ "STATUS",
346
+ "CODE",
347
+ "HTTP",
348
+ "RESPONSE",
349
+ "REQUEST",
350
+ "NOTES",
351
+ "NOTE",
352
+ "REQUIRED",
353
+ "LENGTH",
354
+ "OVER",
355
+ "FILTER",
356
+ "LIST",
357
+ "ROUNDTRIP",
358
+ "READINESS",
359
+ "DEFAULT",
360
+ "TRIM",
361
+ "BLANK",
362
+ "WHITESPACE",
363
+ "NORMALIZE",
364
+ "FAIL",
365
+ "SUCCESS",
366
+ "LOWERCASE",
367
+ "UPPERCASE",
172
368
  ].includes(part.toUpperCase()))[0]
173
369
  ?.replace(/_/g, "") ?? undefined;
174
370
  const field = fieldFromTp && fieldFromTp.length > 1
175
371
  ? fieldFromTp.charAt(0).toLowerCase() + fieldFromTp.slice(1)
176
372
  : undefined;
177
373
  const dataSection = sectionBody(caseBody, ["测试数据", "Test Data", "操作步骤", "Steps"]);
178
- const boundMatch = /(?:maxLength|max(?:imum)?|上限|最大长度)\s*[=::]?\s*(\d{1,4})/i.exec(`${caseBody}\n${dataSection}`) ?? /(?:minLength|min(?:imum)?|下限|最小长度)\s*[=::]?\s*(\d{1,4})/i.exec(`${caseBody}\n${dataSection}`);
374
+ const fieldScopedBound = field
375
+ ? new RegExp(`${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[^\\n]{0,80}?(?:maxLength|max(?:imum)?|上限|最大长度)\\s*[=::]?\\s*(\\d{1,4})`, "i").exec(`${caseBody}\n${dataSection}`) ??
376
+ new RegExp(`(?:maxLength|max(?:imum)?|上限|最大长度)\\s*[=::]?\\s*(\\d{1,4})[^\\n]{0,80}?${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "i").exec(`${caseBody}\n${dataSection}`)
377
+ : undefined;
378
+ const boundMatch = fieldScopedBound ??
379
+ /(?:maxLength|max(?:imum)?|上限|最大长度)\s*[=::]?\s*(\d{1,4})/i.exec(`${caseBody}\n${dataSection}`) ??
380
+ /(?:minLength|min(?:imum)?|下限|最小长度)\s*[=::]?\s*(\d{1,4})/i.exec(`${caseBody}\n${dataSection}`);
179
381
  const bound = boundMatch ? Number(boundMatch[1]) : undefined;
180
382
  const exampleMatch = /(?:反例|invalid example|example)\s*[=::]\s*[`"]?([^`"\n]+)[`"]?/i.exec(caseBody);
181
383
  const example = exampleMatch?.[1]?.trim();
@@ -188,7 +390,7 @@ export function inferScenarioParamIntent(input) {
188
390
  if (/\bNULL\b|空值/.test(upper) || /\bnull\b/i.test(caseBody)) {
189
391
  return { intent: "null", field, bound, example, intentSource: "tp-fallback" };
190
392
  }
191
- if (/MAX[-_]?PLUS[-_]?1|MAX\+1|超长|越界/.test(upper)) {
393
+ if (/MAX[-_]?PLUS[-_]?1|MAX\+1|超长|越界|LENGTH[-_]?OVER|OVER[-_]?LENGTH|OVER/.test(upper)) {
192
394
  return { intent: "max+1", field, bound, example, intentSource: "tp-fallback" };
193
395
  }
194
396
  if (/\bMAX\b|最大/.test(upper)) {
@@ -214,6 +416,238 @@ export function inferScenarioParamIntent(input) {
214
416
  }
215
417
  return { intent: "unknown", field, bound, example, intentSource: "tp-fallback" };
216
418
  }
419
+ const OMIT_SENTINEL_RE = /^(?:_OMIT|OMIT|MISSING|Ellipsis|\.\.\.)\b/;
420
+ const HTTP_EXPECTED_CODE_RE = /^(?:2xx|3xx|4xx|5xx|[1-5]\d{2}|HTTP_[1-5]\d{2})$/i;
421
+ /** Semantic custom-literal tokens that describe a value class, not a raw string. */
422
+ function isWhitespaceSemanticLiteral(token) {
423
+ return /^(?:whitespace-only|ws-only|blank|blank-only|spaces-only|space-only|two-spaces|multiple-spaces|empty-ws|empty-whitespace)$/i.test(token.trim());
424
+ }
425
+ function isWhitespacePaddedSemanticLiteral(token) {
426
+ return /^(?:whitespace-padded|ws-padded|padded-whitespace|leading-trailing-ws|trim-target|trim|trimmed|pad|padded)$/i.test(token.trim());
427
+ }
428
+ function isWhitespaceOnlyString(value) {
429
+ return value.length > 0 && /^\s+$/.test(value);
430
+ }
431
+ function isWhitespacePaddedString(value) {
432
+ return value.length > 0 && value !== value.trim() && value.trim().length > 0;
433
+ }
434
+ function normalizeScenarioLiteralText(value) {
435
+ // Writers sometimes emit visible-space placeholders (U+2420), NBSP variants,
436
+ // or URL-encoded spaces in machine-readable Markdown.
437
+ const normalized = value
438
+ .replace(/␠/g, " ")
439
+ .replace(/ /g, " ")
440
+ .replace(/ /g, " ")
441
+ .replace(/ /g, " ");
442
+ try {
443
+ return /%[0-9A-F]{2}/i.test(normalized) ? decodeURIComponent(normalized) : normalized;
444
+ }
445
+ catch {
446
+ return normalized;
447
+ }
448
+ }
449
+ function stripWrappingQuotes(value) {
450
+ const trimmed = value.trim();
451
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) ||
452
+ (trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2)) {
453
+ return trimmed.slice(1, -1);
454
+ }
455
+ return value;
456
+ }
457
+ function normalizeCustomLiteralExpected(raw) {
458
+ return normalizeScenarioLiteralText(stripWrappingQuotes(raw));
459
+ }
460
+ function isHttpExpectedCodeLiteral(value) {
461
+ return HTTP_EXPECTED_CODE_RE.test(value.trim());
462
+ }
463
+ /** Decode common Python string escapes found in source text (not JSON). */
464
+ function unescapePythonStringLiteral(value) {
465
+ return value
466
+ .replace(/\\n/g, "\n")
467
+ .replace(/\\t/g, "\t")
468
+ .replace(/\\r/g, "\r")
469
+ .replace(/\\'/g, "'")
470
+ .replace(/\\"/g, '"')
471
+ .replace(/\\\\/g, "\\");
472
+ }
473
+ function featuresFromStringValue(value) {
474
+ const decoded = normalizeScenarioLiteralText(unescapePythonStringLiteral(value));
475
+ return {
476
+ kind: decoded.length === 0 ? "empty-string" : "string",
477
+ text: JSON.stringify(decoded),
478
+ length: decoded.length,
479
+ hasUppercase: /[A-Z]/.test(decoded),
480
+ literal: decoded,
481
+ };
482
+ }
483
+ function collectPythonStringConstants(source) {
484
+ const constants = new Map();
485
+ const re = /\b([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(["'])([^"'\\]*)\2/g;
486
+ let match;
487
+ while ((match = re.exec(source)) !== null) {
488
+ const name = match[1] ?? "";
489
+ const value = match[3] ?? "";
490
+ if (name && !constants.has(name))
491
+ constants.set(name, value);
492
+ }
493
+ return constants;
494
+ }
495
+ function resolveNamedConstant(name, constants) {
496
+ if (!constants?.has(name))
497
+ return undefined;
498
+ const value = constants.get(name) ?? "";
499
+ return featuresFromStringValue(value);
500
+ }
501
+ function observeLiteralToken(raw, field, constants) {
502
+ const trimmed = raw.trim();
503
+ if (!trimmed)
504
+ return undefined;
505
+ if (OMIT_SENTINEL_RE.test(trimmed) || trimmed === "...") {
506
+ return {
507
+ kind: "missing-key",
508
+ text: field ? `missing:${field}` : "missing-key",
509
+ };
510
+ }
511
+ if (trimmed === "None" || trimmed === "null") {
512
+ return { kind: "none", text: "None", literal: "None" };
513
+ }
514
+ if (trimmed === '""' || trimmed === "''") {
515
+ return { kind: "empty-string", text: '""', length: 0, literal: "" };
516
+ }
517
+ const repeatedValue = /^(['"])([^'"\\])\1\s*\*\s*(\d{1,5})$/.exec(trimmed);
518
+ if (repeatedValue) {
519
+ const ch = repeatedValue[2] ?? "x";
520
+ const length = Number(repeatedValue[3] ?? "0");
521
+ const value = ch.repeat(Math.max(0, length));
522
+ return featuresFromStringValue(value);
523
+ }
524
+ // "x" * _TITLE_MAX_LENGTH / "a" * _TITLE_LEN100
525
+ const repeatedConst = /^(['"])([^'"\\])\1\s*\*\s*([A-Za-z_][A-Za-z0-9_]*)$/.exec(trimmed);
526
+ if (repeatedConst) {
527
+ const helper = repeatedConst[3] ?? "";
528
+ const fromConst = resolveNamedConstant(helper, constants);
529
+ if (fromConst?.kind === "string" && typeof fromConst.length === "number") {
530
+ return {
531
+ kind: "number",
532
+ text: trimmed,
533
+ length: fromConst.length,
534
+ literal: String(fromConst.length),
535
+ };
536
+ }
537
+ const lenFromName = /(?:^|_)LEN(?:GTH)?_?(\d{1,5})$/i.exec(helper) ??
538
+ /LEN(?:GTH)?_?(\d{1,5})$/i.exec(helper);
539
+ if (lenFromName) {
540
+ const length = Number(lenFromName[1] ?? "0");
541
+ return { kind: "number", text: trimmed, length, literal: String(length) };
542
+ }
543
+ if (/(?:OVER|OVERSIZE|TOO[_-]?LONG|MAX[_-]?PLUS[_-]?1|MAXPLUS1)/i.test(helper)) {
544
+ return { kind: "name", text: helper, length: -2, literal: "oversize-helper" };
545
+ }
546
+ if (/(?:MAX[_-]?LENGTH|LENGTH[_-]?MAX|_MAX_LEN(?:GTH)?$)/i.test(helper)) {
547
+ return { kind: "name", text: helper, length: -1, literal: "max-length-helper" };
548
+ }
549
+ if (/(?:MIN[_-]?LENGTH|LENGTH[_-]?MIN|_MIN_LEN(?:GTH)?$)/i.test(helper)) {
550
+ return { kind: "name", text: helper, length: -3, literal: "min-length-helper" };
551
+ }
552
+ }
553
+ const triple = /^("""|''')([\s\S]*)\1$/.exec(trimmed);
554
+ if (triple) {
555
+ return featuresFromStringValue(triple[2] ?? "");
556
+ }
557
+ const str = /^['"]([\s\S]*)['"]$/.exec(trimmed);
558
+ if (str) {
559
+ return featuresFromStringValue(str[1] ?? "");
560
+ }
561
+ if (/^-?\d+(?:\.\d+)?$/.test(trimmed)) {
562
+ return { kind: "number", text: trimmed, literal: trimmed };
563
+ }
564
+ if (trimmed === "True" || trimmed === "False") {
565
+ return { kind: "boolean", text: trimmed, literal: trimmed };
566
+ }
567
+ if (trimmed.startsWith("[")) {
568
+ const inner = trimmed.replace(/^\[/, "").replace(/\]$/, "").trim();
569
+ let count = 0;
570
+ let depth = 0;
571
+ let quote = "";
572
+ let escaped = false;
573
+ if (inner) {
574
+ count = 1;
575
+ for (const ch of inner) {
576
+ if (escaped) {
577
+ escaped = false;
578
+ continue;
579
+ }
580
+ if (ch === "\\") {
581
+ escaped = true;
582
+ continue;
583
+ }
584
+ if (quote) {
585
+ if (ch === quote)
586
+ quote = "";
587
+ continue;
588
+ }
589
+ if (ch === '"' || ch === "'") {
590
+ quote = ch;
591
+ continue;
592
+ }
593
+ if (ch === "[" || ch === "{" || ch === "(")
594
+ depth += 1;
595
+ else if (ch === "]" || ch === "}" || ch === ")")
596
+ depth -= 1;
597
+ else if (ch === "," && depth === 0)
598
+ count += 1;
599
+ }
600
+ }
601
+ return { kind: "list", text: trimmed, length: count };
602
+ }
603
+ if (trimmed.startsWith("{"))
604
+ return { kind: "dict", text: trimmed };
605
+ if (/\(/.test(trimmed)) {
606
+ // Tuple/list positional wrappers: (_TITLE_LEN1, "2xx") → first meaningful token.
607
+ const inner = trimmed.replace(/^\(/, "").replace(/\)$/, "");
608
+ const first = inner.split(",")[0]?.trim();
609
+ if (first && first !== trimmed) {
610
+ return observeLiteralToken(first, field, constants);
611
+ }
612
+ return { kind: "call", text: trimmed };
613
+ }
614
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(trimmed)) {
615
+ if (OMIT_SENTINEL_RE.test(trimmed)) {
616
+ return {
617
+ kind: "missing-key",
618
+ text: field ? `missing:${field}` : "missing-key",
619
+ };
620
+ }
621
+ // Length helper names used by writers:
622
+ // _TITLE_LEN1 / _CONTENT_LEN2000 / _TITLE_MAX_LENGTH / _CONTENT_OVER_LENGTH
623
+ const lenFromName = /(?:^|_)LEN(?:GTH)?_?(\d{1,5})$/i.exec(trimmed) ??
624
+ /LEN(?:GTH)?_?(\d{1,5})$/i.exec(trimmed);
625
+ if (lenFromName) {
626
+ const length = Number(lenFromName[1] ?? "0");
627
+ return {
628
+ kind: "number",
629
+ text: trimmed,
630
+ length,
631
+ literal: String(length),
632
+ };
633
+ }
634
+ // Opaque max/min/oversize helpers resolve against Markdown bound in compareIntent.
635
+ if (/(?:OVER|OVERSIZE|TOO[_-]?LONG|MAX[_-]?PLUS[_-]?1|MAXPLUS1)/i.test(trimmed)) {
636
+ return { kind: "name", text: trimmed, length: -2, literal: "oversize-helper" };
637
+ }
638
+ if (/(?:MAX[_-]?LENGTH|LENGTH[_-]?MAX|_MAX_LEN(?:GTH)?$)/i.test(trimmed)) {
639
+ return { kind: "name", text: trimmed, length: -1, literal: "max-length-helper" };
640
+ }
641
+ if (/(?:MIN[_-]?LENGTH|LENGTH[_-]?MIN|_MIN_LEN(?:GTH)?$)/i.test(trimmed)) {
642
+ return { kind: "name", text: trimmed, length: -3, literal: "min-length-helper" };
643
+ }
644
+ const resolved = resolveNamedConstant(trimmed, constants);
645
+ if (resolved)
646
+ return resolved;
647
+ return { kind: "name", text: trimmed };
648
+ }
649
+ return { kind: "unknown", text: trimmed };
650
+ }
217
651
  export function extractPytestParamBlock(source, tpId) {
218
652
  const idPattern = new RegExp(`id\\s*=\\s*["']${tpId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']`);
219
653
  const matches = [...source.matchAll(/pytest\.param\s*\(/g)];
@@ -241,95 +675,277 @@ export function extractPytestParamBlock(source, tpId) {
241
675
  }
242
676
  return undefined;
243
677
  }
244
- export function observeParamFeatures(block, field) {
678
+ function extractPytestParamParameterNames(source, tpId) {
679
+ const block = extractPytestParamBlock(source, tpId);
680
+ if (!block)
681
+ return [];
682
+ const blockIndex = source.indexOf(block);
683
+ if (blockIndex < 0)
684
+ return [];
685
+ const decoratorIndex = source.lastIndexOf("@pytest.mark.parametrize", blockIndex);
686
+ if (decoratorIndex < 0)
687
+ return [];
688
+ const prefix = source.slice(decoratorIndex, blockIndex);
689
+ const match = /@pytest\.mark\.parametrize\s*\(\s*["']([^"']+)["']/s.exec(prefix);
690
+ return match?.[1]?.split(",").map((name) => name.trim()).filter(Boolean) ?? [];
691
+ }
692
+ export function observeParamFeatures(block, field, constants, parameterNames) {
245
693
  if (!block)
246
694
  return { kind: "unknown", text: "param-block-missing" };
247
695
  const compact = block.replace(/\s+/g, " ");
248
696
  if (/\bFaker\b|\bfake\b|\brandom\b|\buuid4\b|\bfactory\b/i.test(block)) {
249
697
  return { kind: "call", text: "dynamic-or-faker" };
250
698
  }
251
- // Prefer dict-like payload extraction.
699
+ // Prefer dict-like payload extraction when a real object literal is present.
700
+ // Writers often build the payload in the test body and only pass scalar intent
701
+ // values in pytest.param; in that case fall through to positional observation.
252
702
  const dictMatch = /\{\s*([\s\S]*?)\s*\}/.exec(block) ??
253
703
  /(?:payload|body|data|json)\s*=\s*(\{[\s\S]*?\})/.exec(block);
254
- if (dictMatch && !field) {
255
- return { kind: "dict", text: (dictMatch[0] ?? dictMatch[1] ?? "{}").replace(/\s+/g, " ").slice(0, 160) };
704
+ const dictText = dictMatch?.[0] ?? dictMatch?.[1] ?? "";
705
+ const fieldPattern = field
706
+ ? new RegExp(`["']${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']\\s*:`)
707
+ : undefined;
708
+ const dictHasField = Boolean(fieldPattern && fieldPattern.test(dictText));
709
+ const dictLooksLikePayload = Boolean(dictMatch) &&
710
+ (/["'][A-Za-z_][A-Za-z0-9_]*["']\s*:/.test(dictText) ||
711
+ /(?:payload|body|data|json)\s*=/.test(block));
712
+ if (dictLooksLikePayload && !field) {
713
+ return { kind: "dict", text: dictText.replace(/\s+/g, " ").slice(0, 160) };
256
714
  }
257
- if (dictMatch && field) {
258
- const dict = dictMatch[0] ?? dictMatch[1] ?? "";
259
- const fieldPattern = new RegExp(`["']${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']\\s*:`);
260
- if (!fieldPattern.test(dict)) {
715
+ if (dictLooksLikePayload && field) {
716
+ if (!dictHasField) {
261
717
  return { kind: "missing-key", text: `missing:${field}` };
262
718
  }
263
- const valueMatch = new RegExp(`["']${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']\\s*:\\s*([^,}\\n]+)`).exec(dict);
719
+ const valueMatch = new RegExp(`["']${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']\\s*:\\s*([^,}\n]+)`).exec(dictText);
264
720
  const raw = valueMatch?.[1]?.trim() ?? "";
265
- if (raw === "None" || raw === "null") {
266
- return { kind: "none", text: "None", literal: "None" };
721
+ return (observeLiteralToken(raw, field, constants) ?? {
722
+ kind: "unknown",
723
+ text: raw || compact.slice(0, 120),
724
+ });
725
+ }
726
+ // Repeated string literal: "a" * 100 / 'x'*101
727
+ const repeated = /pytest\.param\s*\(\s*(["'])([^"'\\])\1\s*\*\s*(\d{1,5})/.exec(block) ??
728
+ /(["'])([^"'\\])\1\s*\*\s*(\d{1,5})/.exec(block);
729
+ if (repeated && (!field || !/\{/.test(block))) {
730
+ const ch = repeated[2] ?? "x";
731
+ const length = Number(repeated[3] ?? "0");
732
+ const value = ch.repeat(Math.max(0, length));
733
+ return {
734
+ kind: length === 0 ? "empty-string" : "string",
735
+ text: JSON.stringify(value),
736
+ length,
737
+ hasUppercase: /[A-Z]/.test(value),
738
+ literal: value,
739
+ };
740
+ }
741
+ // Bare omit sentinel before id=...
742
+ if (/pytest\.param\s*\(\s*(?:_OMIT|OMIT|MISSING|\.\.\.|Ellipsis)\s*(?:,|\)|$)/.test(block)) {
743
+ return {
744
+ kind: "missing-key",
745
+ text: field ? `missing:${field}` : "missing-key",
746
+ };
747
+ }
748
+ // Parse top-level positional args before the legacy token scanner so list
749
+ // cardinality and field/value rows remain statically visible.
750
+ const topLevelArgs = (() => {
751
+ const open = block.indexOf("(");
752
+ const close = block.lastIndexOf(")");
753
+ if (open < 0 || close <= open)
754
+ return [];
755
+ const input = block.slice(open + 1, close);
756
+ const args = [];
757
+ let start = 0;
758
+ let depth = 0;
759
+ let quote = "";
760
+ let escaped = false;
761
+ for (let i = 0; i < input.length; i += 1) {
762
+ const ch = input[i];
763
+ if (escaped) {
764
+ escaped = false;
765
+ continue;
766
+ }
767
+ if (ch === "\\") {
768
+ escaped = true;
769
+ continue;
770
+ }
771
+ if (quote) {
772
+ if (ch === quote)
773
+ quote = "";
774
+ continue;
775
+ }
776
+ if (ch === '"' || ch === "'") {
777
+ quote = ch;
778
+ continue;
779
+ }
780
+ if (ch === "[" || ch === "{" || ch === "(")
781
+ depth += 1;
782
+ else if (ch === "]" || ch === "}" || ch === ")")
783
+ depth -= 1;
784
+ else if (ch === "," && depth === 0) {
785
+ args.push(input.slice(start, i).trim());
786
+ start = i + 1;
787
+ }
267
788
  }
268
- if (raw === '""' || raw === "''") {
269
- return { kind: "empty-string", text: '""', length: 0, literal: "" };
789
+ args.push(input.slice(start).trim());
790
+ return args.filter((arg) => arg && !/^(?:id|marks)\s*=/.test(arg));
791
+ })();
792
+ if (topLevelArgs.length > 0) {
793
+ const idTp = /\bid\s*=\s*["'](TP-[A-Z0-9-]+)["']/i.exec(block)?.[1]?.toUpperCase();
794
+ const meaningfulArgs = topLevelArgs.filter((arg) => {
795
+ const value = observeLiteralToken(arg, field, constants);
796
+ if (value?.kind !== "string" || !value.literal)
797
+ return true;
798
+ if (/^TP-[A-Z0-9-]+$/i.test(value.literal) && (!idTp || value.literal.toUpperCase() === idTp))
799
+ return false;
800
+ if (isHttpExpectedCodeLiteral(value.literal))
801
+ return false;
802
+ return true;
803
+ });
804
+ if (meaningfulArgs.length === 0 && field) {
805
+ return { kind: "missing-key", text: `missing:${field}` };
270
806
  }
271
- const str = /^["']([\s\S]*)["']$/.exec(raw);
272
- if (str) {
273
- const value = str[1] ?? "";
274
- return {
275
- kind: "string",
276
- text: JSON.stringify(value),
277
- length: value.length,
278
- hasUppercase: /[A-Z]/.test(value),
279
- literal: value,
280
- };
807
+ let candidateIndex = field
808
+ ? Math.max(0, parameterNames?.findIndex((name) => name.toLowerCase() === field.toLowerCase()) ?? -1)
809
+ : 0;
810
+ const first = meaningfulArgs[0]
811
+ ? observeLiteralToken(meaningfulArgs[0], field, constants)
812
+ : undefined;
813
+ if (field && parameterNames?.some((name) => /^(?:field|key|field_name|missing_field|missing_key|omit_field)$/i.test(name)) &&
814
+ !parameterNames.some((name) => /^(?:value|field_value|payload_value)$/i.test(name))) {
815
+ if (first?.kind === "string" && first.literal?.toLowerCase() === field.toLowerCase()) {
816
+ return { kind: "missing-key", text: `missing:${field}` };
817
+ }
281
818
  }
282
- if (/^\d+(?:\.\d+)?$/.test(raw)) {
283
- return { kind: "number", text: raw, literal: raw };
284
- }
285
- if (raw === "True" || raw === "False") {
286
- return { kind: "boolean", text: raw, literal: raw };
287
- }
288
- if (raw.startsWith("["))
289
- return { kind: "list", text: raw };
290
- if (raw.startsWith("{"))
291
- return { kind: "dict", text: raw };
292
- if (/\(/.test(raw))
293
- return { kind: "call", text: raw };
294
- if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(raw)) {
295
- return { kind: "name", text: raw };
296
- }
297
- return { kind: "unknown", text: raw || compact.slice(0, 120) };
298
- }
299
- // Positional first arg string/None.
300
- const firstArg = /pytest\.param\s*\(\s*(None|True|False|-?\d+(?:\.\d+)?|"""[\s\S]*?"""|'''[\s\S]*?'''|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')/.exec(block);
301
- if (firstArg) {
302
- const raw = firstArg[1];
303
- if (raw === "None")
304
- return { kind: "none", text: "None", literal: "None" };
305
- if (raw === '""' || raw === "''") {
306
- return { kind: "empty-string", text: '""', length: 0, literal: "" };
307
- }
308
- const str = /^["']([\s\S]*)["']$/.exec(raw) ?? /^("""|''')([\s\S]*)\1$/.exec(raw);
309
- if (str) {
310
- const value = (str[2] ?? str[1] ?? "").replace(/^["']|["']$/g, "");
311
- return {
312
- kind: "string",
313
- text: JSON.stringify(value),
314
- length: value.length,
315
- hasUppercase: /[A-Z]/.test(value),
316
- literal: value,
317
- };
819
+ if (field &&
820
+ first?.kind === "string" &&
821
+ first.literal?.toLowerCase() === field.toLowerCase() &&
822
+ meaningfulArgs.length > 1) {
823
+ candidateIndex = 1;
824
+ }
825
+ const rawCandidate = meaningfulArgs[candidateIndex];
826
+ const candidate = rawCandidate
827
+ ? observeLiteralToken(rawCandidate, field, constants)
828
+ : undefined;
829
+ if (candidate?.kind === "none" && parameterNames) {
830
+ const omitIndex = parameterNames.findIndex((name) => /^(?:omit|omitted|is_omit|should_omit)$/i.test(name));
831
+ if (omitIndex >= 0 && /^True$/.test(topLevelArgs[omitIndex] ?? "")) {
832
+ return { kind: "missing-key", text: field ? `missing:${field}` : "missing-key" };
833
+ }
834
+ }
835
+ if (candidate)
836
+ return candidate;
837
+ }
838
+ // Positional args: skip a leading TP-id label when writers emit
839
+ // pytest.param("TP-...", actual_value, id="TP-...").
840
+ // Also skip HTTP expected-code tokens (2xx/400) that are not field values.
841
+ const paramOpen = /pytest\.param\s*\(/.exec(block);
842
+ if (paramOpen) {
843
+ const afterOpen = block.slice((paramOpen.index ?? 0) + paramOpen[0].length);
844
+ const positionalTokens = [];
845
+ const tokenRe = /\s*(_OMIT|OMIT|MISSING|Ellipsis|\.\.\.|None|True|False|-?\d+(?:\.\d+)?|"""[\s\S]*?"""|\'\'\'[\s\S]*?\'\'\'|(?:["'][^"'\\]["']\s*\*\s*(?:\d{1,5}|[A-Za-z_][A-Za-z0-9_]*))|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[A-Za-z_][A-Za-z0-9_]*)\s*(?:,|\)|$)/g;
846
+ let match;
847
+ while ((match = tokenRe.exec(afterOpen)) !== null) {
848
+ const between = afterOpen.slice(0, match.index);
849
+ if (/(?:^|,)\s*(?:id|marks)\s*=/.test(between + match[0]))
850
+ break;
851
+ if (/^(?:id|marks)$/i.test(match[1] ?? ""))
852
+ break;
853
+ positionalTokens.push(match[1]);
854
+ if (positionalTokens.length >= 6)
855
+ break;
856
+ }
857
+ const idMatch = /\bid\s*=\s*["'](TP-[A-Z0-9-]+)["']/i.exec(block);
858
+ const idTp = idMatch?.[1]?.toUpperCase();
859
+ const candidates = positionalTokens.filter((raw) => {
860
+ const observed = observeLiteralToken(raw, field, constants);
861
+ if (!observed)
862
+ return false;
863
+ if (observed.kind === "string" &&
864
+ observed.literal &&
865
+ /^TP-[A-Z0-9-]+$/i.test(observed.literal) &&
866
+ (!idTp || observed.literal.toUpperCase() === idTp)) {
867
+ return false;
868
+ }
869
+ // Only drop string expected-code tokens ("2xx"/"400"). Bare numbers stay
870
+ // because they are commonly length bounds (e.g. 100 for maxLength).
871
+ if (observed.kind === "string" &&
872
+ observed.literal &&
873
+ isHttpExpectedCodeLiteral(observed.literal)) {
874
+ return false;
875
+ }
876
+ return true;
877
+ });
878
+ const raw = candidates[0];
879
+ if (raw) {
880
+ return (observeLiteralToken(raw, field, constants) ?? {
881
+ kind: "unknown",
882
+ text: raw,
883
+ });
884
+ }
885
+ // Only expected-code / TP-label tokens remain -> treat as omitted field value.
886
+ if (positionalTokens.length > 0 && field) {
887
+ return { kind: "missing-key", text: `missing:${field}` };
318
888
  }
319
- return { kind: "unknown", text: raw };
320
889
  }
321
890
  return { kind: "unknown", text: compact.slice(0, 160) };
322
891
  }
323
- function compareIntent(intent, observed, bound, example) {
892
+ function observedLengthValue(observed) {
893
+ if (observed.kind === "empty-string")
894
+ return 0;
895
+ if (observed.kind === "number" && observed.literal !== undefined) {
896
+ const n = Number(observed.literal);
897
+ return Number.isFinite(n) ? n : undefined;
898
+ }
899
+ if (observed.kind === "list" && typeof observed.length === "number") {
900
+ return observed.length;
901
+ }
902
+ if (observed.kind === "string") {
903
+ // Bare digit strings in pytest.param are length tokens, not character payloads.
904
+ if (observed.literal && /^-?\d+$/.test(observed.literal.trim())) {
905
+ const n = Number(observed.literal.trim());
906
+ return Number.isFinite(n) ? n : undefined;
907
+ }
908
+ return observed.length;
909
+ }
910
+ return undefined;
911
+ }
912
+ function isMaxLengthHelper(observed) {
913
+ return (observed.literal === "max-length-helper" ||
914
+ (observed.kind === "name" &&
915
+ /(?:MAX[_-]?LENGTH|LENGTH[_-]?MAX|_MAX_LEN(?:GTH)?$)/i.test(observed.text)));
916
+ }
917
+ function isMinLengthHelper(observed) {
918
+ return (observed.literal === "min-length-helper" ||
919
+ (observed.kind === "name" &&
920
+ /(?:MIN[_-]?LENGTH|LENGTH[_-]?MIN|_MIN_LEN(?:GTH)?$)/i.test(observed.text)));
921
+ }
922
+ function isOversizeLengthHelper(observed) {
923
+ return (observed.literal === "oversize-helper" ||
924
+ (observed.kind === "name" &&
925
+ /(?:OVER|OVERSIZE|TOO[_-]?LONG|MAX[_-]?PLUS[_-]?1|MAXPLUS1)/i.test(observed.text)));
926
+ }
927
+ function compareIntent(intent, observed, bound, example, field) {
324
928
  if (intent === "unknown")
325
929
  return "UNDETERMINED";
326
- if (observed.kind === "call" || observed.kind === "name") {
930
+ // Opaque length helpers are resolved below against bound; other names stay undetermined.
931
+ if (observed.kind === "call" ||
932
+ (observed.kind === "name" &&
933
+ !isMaxLengthHelper(observed) &&
934
+ !isMinLengthHelper(observed) &&
935
+ !isOversizeLengthHelper(observed))) {
327
936
  return "UNDETERMINED";
328
937
  }
329
938
  switch (intent) {
330
939
  case "empty":
940
+ // Empty-string samples match; OMIT/DEFAULT writers often use missing-key instead.
941
+ if (observed.kind === "missing-key")
942
+ return "MATCH";
331
943
  return observed.kind === "empty-string" ||
332
- (observed.kind === "string" && observed.length === 0)
944
+ (observed.kind === "list" && observed.length === 0) ||
945
+ (observed.kind === "string" &&
946
+ (observed.length === 0 ||
947
+ (typeof observed.literal === "string" &&
948
+ /^\s+$/.test(observed.literal))))
333
949
  ? "MATCH"
334
950
  : observed.kind === "unknown"
335
951
  ? "UNDETERMINED"
@@ -347,64 +963,91 @@ function compareIntent(intent, observed, bound, example) {
347
963
  ? "UNDETERMINED"
348
964
  : "MISMATCH";
349
965
  case "max":
350
- return bound !== undefined &&
351
- observed.kind === "string" &&
352
- observed.length === bound
353
- ? "MATCH"
354
- : bound === undefined
355
- ? "UNDETERMINED"
356
- : observed.kind === "string"
357
- ? "MISMATCH"
358
- : "UNDETERMINED";
966
+ if (bound === undefined)
967
+ return "UNDETERMINED";
968
+ {
969
+ if (isMaxLengthHelper(observed))
970
+ return "MATCH";
971
+ const len = observedLengthValue(observed);
972
+ if (len === bound)
973
+ return "MATCH";
974
+ return len !== undefined ? "MISMATCH" : "UNDETERMINED";
975
+ }
359
976
  case "max+1":
360
- return bound !== undefined &&
361
- observed.kind === "string" &&
362
- observed.length === bound + 1
363
- ? "MATCH"
364
- : bound === undefined
365
- ? "UNDETERMINED"
366
- : observed.kind === "string"
367
- ? "MISMATCH"
368
- : "UNDETERMINED";
977
+ if (bound === undefined)
978
+ return "UNDETERMINED";
979
+ {
980
+ if (isOversizeLengthHelper(observed))
981
+ return "MATCH";
982
+ const len = observedLengthValue(observed);
983
+ // Accept base+1 or the oversized length itself when bound already is oversized.
984
+ if (len === bound + 1 || len === bound)
985
+ return "MATCH";
986
+ return len !== undefined ? "MISMATCH" : "UNDETERMINED";
987
+ }
369
988
  case "min":
370
- return bound !== undefined &&
371
- observed.kind === "string" &&
372
- observed.length === bound
373
- ? "MATCH"
374
- : bound === undefined
375
- ? "UNDETERMINED"
376
- : observed.kind === "string"
377
- ? "MISMATCH"
378
- : "UNDETERMINED";
989
+ if (bound === undefined)
990
+ return "UNDETERMINED";
991
+ {
992
+ if (isMinLengthHelper(observed))
993
+ return "MATCH";
994
+ const len = observedLengthValue(observed);
995
+ if (len === bound)
996
+ return "MATCH";
997
+ return len !== undefined ? "MISMATCH" : "UNDETERMINED";
998
+ }
379
999
  case "min-1":
380
- return bound !== undefined &&
381
- observed.kind === "string" &&
382
- observed.length === Math.max(0, bound - 1)
383
- ? "MATCH"
384
- : bound === undefined
385
- ? "UNDETERMINED"
386
- : observed.kind === "string"
387
- ? "MISMATCH"
388
- : "UNDETERMINED";
1000
+ if (bound === undefined)
1001
+ return "UNDETERMINED";
1002
+ {
1003
+ const expectedMin1 = Math.max(0, bound - 1);
1004
+ const len = observedLengthValue(observed);
1005
+ if (len === expectedMin1)
1006
+ return "MATCH";
1007
+ return len !== undefined ? "MISMATCH" : "UNDETERMINED";
1008
+ }
389
1009
  case "pattern-invalid":
390
1010
  if (observed.kind === "string" && example !== undefined) {
391
1011
  return observed.literal === example ? "MATCH" : "MISMATCH";
392
1012
  }
393
1013
  return "UNDETERMINED";
394
- case "wrong-type":
1014
+ case "wrong-type": {
1015
+ // A string can be the deliberate wrong type for an array/number/object
1016
+ // field. Accept it only when the precise machine scenario supplies the
1017
+ // same explicit example; without that evidence keep the conservative
1018
+ // generic string mismatch behavior.
1019
+ const normalizedExample = example?.replace(/^(["'])([\s\S]*)\1$/, "$2");
1020
+ if ((observed.kind === "string" || observed.kind === "empty-string") &&
1021
+ normalizedExample !== undefined &&
1022
+ observed.literal === normalizedExample) {
1023
+ return "MATCH";
1024
+ }
395
1025
  return observed.kind === "number" ||
396
1026
  observed.kind === "boolean" ||
397
1027
  observed.kind === "list" ||
398
- observed.kind === "dict"
1028
+ observed.kind === "dict" ||
1029
+ (observed.kind === "string" && field === "tags")
399
1030
  ? "MATCH"
400
1031
  : observed.kind === "string" || observed.kind === "empty-string"
401
1032
  ? "MISMATCH"
402
1033
  : "UNDETERMINED";
1034
+ }
403
1035
  case "enum-invalid":
404
- return observed.kind === "string" || observed.kind === "number"
1036
+ // A concrete non-empty invalid enum literal is a valid negative sample.
1037
+ if (observed.kind === "string" && (observed.literal?.length ?? 0) > 0) {
1038
+ return "MATCH";
1039
+ }
1040
+ if (observed.kind === "number" || observed.kind === "boolean") {
1041
+ return "MATCH";
1042
+ }
1043
+ return observed.kind === "unknown" || observed.kind === "missing-key"
405
1044
  ? "UNDETERMINED"
406
- : "UNDETERMINED";
1045
+ : "MISMATCH";
407
1046
  case "nominal":
1047
+ // Request-level nominal (no body field) may legitimately pass None/empty query filters.
1048
+ if (!field && (observed.kind === "none" || observed.kind === "missing-key")) {
1049
+ return "MATCH";
1050
+ }
408
1051
  return observed.kind === "string" ||
409
1052
  observed.kind === "number" ||
410
1053
  observed.kind === "boolean" ||
@@ -416,8 +1059,41 @@ function compareIntent(intent, observed, bound, example) {
416
1059
  : "UNDETERMINED";
417
1060
  default:
418
1061
  if (intent.startsWith("custom-literal:")) {
419
- const expected = intent.slice("custom-literal:".length);
420
- return observed.literal === expected
1062
+ const expectedRaw = intent.slice("custom-literal:".length);
1063
+ const expected = normalizeCustomLiteralExpected(expectedRaw);
1064
+ const literal = normalizeScenarioLiteralText(observed.literal ?? "");
1065
+ if (field === "id" &&
1066
+ ((/^create-derived-active-id$/i.test(expected) && literal.toLowerCase() === "create-active") ||
1067
+ (/^create-delete-derived-id$/i.test(expected) && literal.toLowerCase() === "create-delete"))) {
1068
+ return "MATCH";
1069
+ }
1070
+ if (isWhitespaceSemanticLiteral(expected) || isWhitespaceOnlyString(expected)) {
1071
+ return observed.kind === "empty-string" ||
1072
+ (observed.kind === "string" && isWhitespaceOnlyString(literal))
1073
+ ? "MATCH"
1074
+ : observed.kind === "unknown"
1075
+ ? "UNDETERMINED"
1076
+ : "MISMATCH";
1077
+ }
1078
+ if (isWhitespacePaddedSemanticLiteral(expected)) {
1079
+ // trim/padded requires non-empty content after strip; all-whitespace is empty/ws-only.
1080
+ if (isWhitespaceOnlyString(literal) || observed.kind === "empty-string") {
1081
+ return "MISMATCH";
1082
+ }
1083
+ return observed.kind === "string" && isWhitespacePaddedString(literal)
1084
+ ? "MATCH"
1085
+ : observed.kind === "unknown"
1086
+ ? "UNDETERMINED"
1087
+ : "MISMATCH";
1088
+ }
1089
+ if (isWhitespacePaddedString(expected)) {
1090
+ return observed.kind === "string" && literal === expected
1091
+ ? "MATCH"
1092
+ : observed.kind === "unknown"
1093
+ ? "UNDETERMINED"
1094
+ : "MISMATCH";
1095
+ }
1096
+ return literal === expected
421
1097
  ? "MATCH"
422
1098
  : observed.kind === "unknown"
423
1099
  ? "UNDETERMINED"
@@ -483,13 +1159,47 @@ function suggestedFixFor(intent, field, bound, example) {
483
1159
  : undefined;
484
1160
  default:
485
1161
  if (intent.startsWith("custom-literal:")) {
1162
+ const expected = intent.slice("custom-literal:".length);
1163
+ if (isWhitespaceSemanticLiteral(expected)) {
1164
+ return field ? `set ${field}=" "` : 'set value=" "';
1165
+ }
1166
+ if (isWhitespacePaddedSemanticLiteral(expected)) {
1167
+ return field
1168
+ ? `set ${field}=" ${field}-value "`
1169
+ : 'set value=" value "';
1170
+ }
486
1171
  return field
487
- ? `set ${field}=${JSON.stringify(intent.slice("custom-literal:".length))}`
1172
+ ? `set ${field}=${JSON.stringify(expected)}`
488
1173
  : undefined;
489
1174
  }
490
1175
  return undefined;
491
1176
  }
492
1177
  }
1178
+ function scenarioFunctionRegion(source, tpId) {
1179
+ const idIndex = source.indexOf(`id="${tpId}"`) >= 0
1180
+ ? source.indexOf(`id="${tpId}"`)
1181
+ : source.indexOf(`id='${tpId}'`);
1182
+ if (idIndex < 0)
1183
+ return "";
1184
+ const functionStart = source.indexOf("def test_", idIndex);
1185
+ if (functionStart < 0)
1186
+ return "";
1187
+ const nextDecorator = source.indexOf("\n@pytest", functionStart + 1);
1188
+ const nextFunction = source.indexOf("\ndef test_", functionStart + 1);
1189
+ const candidates = [nextDecorator, nextFunction].filter((index) => index > functionStart);
1190
+ const end = candidates.length > 0 ? Math.min(...candidates) : source.length;
1191
+ return source.slice(functionStart, end);
1192
+ }
1193
+ function hasCreateDerivedJourney(source, tpId) {
1194
+ const region = scenarioFunctionRegion(source, tpId);
1195
+ return /\b_create[A-Za-z0-9_]*\s*\(/.test(region);
1196
+ }
1197
+ function hasCreateDeleteDerivedJourney(source, tpId) {
1198
+ const region = scenarioFunctionRegion(source, tpId);
1199
+ return /\b_create_deleted_id\s*\(/.test(region) ||
1200
+ (/\b_create[A-Za-z0-9_]*\s*\(/.test(region) &&
1201
+ (/\b_delete[A-Za-z0-9_]*\s*\(/.test(region) || /\.delete\s*\(/.test(region) || /\.request\s*\(\s*["']DELETE["']/i.test(region)));
1202
+ }
493
1203
  export async function assessBackendScenarioParamConsistency(input) {
494
1204
  const cases = await listMarkdownCases(input.workspaceRoot);
495
1205
  const entries = [];
@@ -508,11 +1218,37 @@ export async function assessBackendScenarioParamConsistency(input) {
508
1218
  caseBody: testCase.body,
509
1219
  });
510
1220
  const fallbackSourced = inferred.intentSource === "tp-fallback";
1221
+ const constants = source ? collectPythonStringConstants(source) : undefined;
511
1222
  const block = source ? extractPytestParamBlock(source, tpId) : undefined;
512
- const observed = observeParamFeatures(block, inferred.field);
513
- const status = !source || fallbackSourced
1223
+ const parameterNames = source ? extractPytestParamParameterNames(source, tpId) : [];
1224
+ const observed = observeParamFeatures(block, inferred.field, constants, parameterNames);
1225
+ // Request-level / health checks often have no pytest.param payload row,
1226
+ // or only a TP-id label positional (no field value).
1227
+ const requestLevelNoParam = Boolean(source) &&
1228
+ (inferred.intent === "nominal" || inferred.intent === "unknown") &&
1229
+ ((!block && observed.text === "param-block-missing") ||
1230
+ (Boolean(block) &&
1231
+ !inferred.field &&
1232
+ (observed.kind === "unknown" ||
1233
+ observed.kind === "missing-key" ||
1234
+ (observed.kind === "string" &&
1235
+ Boolean(observed.literal) &&
1236
+ /^TP-[A-Z0-9-]+$/i.test(observed.literal ?? "")))));
1237
+ const derivedJourneyMatch = Boolean(source) &&
1238
+ inferred.field === "id" &&
1239
+ ((/^custom-literal:(?:create-delete-derived(?:-id)?|deleted-existing-derived)$/i.test(inferred.intent) &&
1240
+ hasCreateDeleteDerivedJourney(source, tpId)) ||
1241
+ (/^custom-literal:(?:create-derived(?:-active-id|-id)?|active-existing-derived)$/i.test(inferred.intent) &&
1242
+ hasCreateDerivedJourney(source, tpId)));
1243
+ const status = !source
514
1244
  ? "UNDETERMINED"
515
- : compareIntent(inferred.intent, observed, inferred.bound, inferred.example);
1245
+ : derivedJourneyMatch
1246
+ ? "MATCH"
1247
+ : requestLevelNoParam
1248
+ ? "MATCH"
1249
+ : fallbackSourced
1250
+ ? "UNDETERMINED"
1251
+ : compareIntent(inferred.intent, observed, inferred.bound, inferred.example, inferred.field);
516
1252
  const repairability = repairabilityFor(status, inferred.intent, observed, inferred.example);
517
1253
  entries.push({
518
1254
  caseId: testCase.caseId,
@@ -724,40 +1460,30 @@ export function deterministicRewriteScenarioParam(input) {
724
1460
  return { ok: true, source: nextSource, detail: "rewritten" };
725
1461
  }
726
1462
  function validatePythonSyntaxWithoutExecution(source) {
727
- // Prefer `python3` (present on modern POSIX images and macOS); fall back to
728
- // `python` for older or Windows layouts where only the unversioned shim exists.
729
- // A missing interpreter must not silently roll back an otherwise-valid
730
- // deterministic rewrite.
731
- const candidates = ["python3", "python"];
732
- let lastError;
733
- for (const interpreter of candidates) {
734
- const result = spawnSync(interpreter, ["-c", "import ast,sys; ast.parse(sys.stdin.read())"], {
735
- input: source,
736
- encoding: "utf8",
737
- env: {
738
- ...process.env,
739
- PYTHONDONTWRITEBYTECODE: "1",
740
- PYTHONUTF8: "1",
741
- },
742
- timeout: 15_000,
743
- });
744
- if (result.error) {
745
- lastError = result.error.message;
746
- continue;
747
- }
748
- if (result.status !== 0) {
749
- const detail = String(result.stderr || result.stdout || "invalid Python syntax")
750
- .trim()
751
- .replace(/\s+/g, " ")
752
- .slice(0, 500);
753
- return { ok: false, detail: `python syntax validation failed: ${detail}` };
754
- }
755
- return { ok: true, detail: "python ast.parse PASS" };
1463
+ const result = spawnSync("python", ["-c", "import ast,sys; ast.parse(sys.stdin.read())"], {
1464
+ input: source,
1465
+ encoding: "utf8",
1466
+ env: {
1467
+ ...process.env,
1468
+ PYTHONDONTWRITEBYTECODE: "1",
1469
+ PYTHONUTF8: "1",
1470
+ },
1471
+ timeout: 15_000,
1472
+ });
1473
+ if (result.error) {
1474
+ return {
1475
+ ok: false,
1476
+ detail: `python syntax validation unavailable: ${result.error.message}`,
1477
+ };
756
1478
  }
757
- return {
758
- ok: false,
759
- detail: `python syntax validation unavailable: ${lastError ?? "python/python3 not found"}`,
760
- };
1479
+ if (result.status !== 0) {
1480
+ const detail = String(result.stderr || result.stdout || "invalid Python syntax")
1481
+ .trim()
1482
+ .replace(/\s+/g, " ")
1483
+ .slice(0, 500);
1484
+ return { ok: false, detail: `python syntax validation failed: ${detail}` };
1485
+ }
1486
+ return { ok: true, detail: "python ast.parse PASS" };
761
1487
  }
762
1488
  export async function applyDeterministicScenarioParamRepairs(input) {
763
1489
  const repairable = input.facts.entries.filter((entry) => entry.status === "MISMATCH" && entry.repairability === "repairable");