@tea-agent/loop-agent 0.38.0 → 0.38.2

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