@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.
- package/AGENTS.md +2 -6
- package/CHANGELOG.md +71 -144
- package/README.md +1 -1
- package/bin/loop-agent.js +1 -37
- package/dist/application/task-lifecycle/advance.js +0 -1
- package/dist/application/task-lifecycle/observe.js +0 -15
- package/dist/application/task-lifecycle/plan-transitions.js +0 -15
- package/dist/cli/command-definitions.js +0 -7
- package/dist/cli/program.js +3 -8
- package/dist/commands/init-upgrade.js +19 -351
- package/dist/commands/init.js +67 -14
- package/dist/commands/run-dag-progress.js +0 -14
- package/dist/commands/task-advance.js +3 -33
- package/dist/executors/dag-pi-executor.js +30 -47
- package/dist/executors/pi-sdk-executor.js +0 -34
- package/dist/executors/shell-executor.js +95 -6
- package/dist/shared/operator/capabilities.js +13 -200
- package/dist/shared/package-metadata.js +0 -42
- package/dist/task/contract/project.js +0 -4
- package/dist/task/source-prepare/completeness.js +0 -53
- package/dist/task/source-prepare/parse-intent.js +12 -82
- package/dist/task/source-prepare/prepare.js +5 -102
- package/dist/task/source-prepare/semantic-intake.js +23 -144
- package/dist/worker/console/chat/artifact-card.js +1 -8
- package/dist/worker/console/chat/chat-event-store.js +0 -152
- package/dist/worker/console/chat/human-gate-card.js +1 -9
- package/dist/worker/console/chat/operation-card.js +2 -71
- package/dist/worker/console/chat/pi-runtime.js +62 -111
- package/dist/worker/console/chat/routes.js +10 -43
- package/dist/worker/console/chat/session-store.js +1 -9
- package/dist/worker/console/chat/shortcuts.js +7 -9
- package/dist/worker/console/chat/turn-process.js +23 -204
- package/dist/worker/console/chat/workspace-landing.js +1 -2
- package/dist/worker/console/operation-runner.js +6 -155
- package/dist/worker/console/operator-actions.js +14 -391
- package/dist/worker/console/operator-user-error.js +0 -4
- package/dist/worker/console/recovery-cta.js +3 -50
- package/dist/worker/console/static/assets/index-HX1pbOyl.css +1 -0
- package/dist/worker/console/static/assets/index-M0BLEBfh.js +56 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/app/console-types.js +9 -13
- package/dist/worker/console/static-src/app/useOperatorActions.js +2 -4
- package/dist/worker/console/static-src/app/useRecoveryActions.js +56 -65
- package/dist/worker/console/static-src/app/useRecoveryConsole.js +1 -73
- package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +30 -114
- package/dist/worker/console/static-src/operator-chat/refs.js +0 -3
- package/dist/worker/console/static-src/operator-chat/spatial-overlay.js +1 -2
- package/dist/worker/console/static-src/operator-chat/useChatSessions.js +13 -67
- package/dist/worker/console/static-src/operator-chat/useChatThread.js +6 -12
- package/dist/worker/console/static-src/operator-chat/useComposer.js +8 -45
- package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +3 -7
- package/dist/worker/loop-agent/loop-agent-client.js +3 -17
- package/dist/worker/observability/read-model.js +0 -20
- package/dist/worker/observe/spec-evidence.js +8 -3
- package/dist/worker/observe/static/operator-chrome.d.ts +0 -1
- package/dist/worker/observe/static/operator-chrome.js +3 -10
- package/dist/worker/observe/static/views/dag-inspector.js +71 -6
- package/dist/worker/observe/static/views/dag.js +0 -12
- package/dist/worker/preflight.js +1 -2
- package/dist/workflows/dag/backend-test-case-coverage-analysis.js +707 -37
- package/dist/workflows/dag/backend-test-case-manifest.js +4 -0
- package/dist/workflows/dag/backend-test-markdown-workflow.js +109 -7
- package/dist/workflows/dag/backend-test-module-stem.js +5 -0
- package/dist/workflows/dag/backend-test-pytest-collection.js +345 -24
- package/dist/workflows/dag/backend-test-scenario-param.js +890 -164
- package/dist/workflows/dag/backend-test-writer-completeness.js +47 -16
- package/dist/workflows/dag/dynamic-runtime/map.js +24 -8
- package/dist/workflows/dag/dynamic-runtime/shared.js +1 -9
- package/dist/workflows/dag/frontend-implementation-contract.js +39 -233
- package/dist/workflows/dag/frontend-prewrite-gate.js +61 -364
- package/dist/workflows/dag/frontend-repair.js +18 -219
- package/dist/workflows/dag/frontend-verification-trace.js +32 -47
- package/dist/workflows/dag/init-hybrid.js +82 -165
- package/dist/workflows/dag/node-execution.js +0 -89
- package/dist/workflows/dag/recovery-recommendation.js +0 -58
- package/dist/workflows/dag/runner.js +15 -333
- package/dist/workflows/dag/scheduler.js +3 -257
- package/dist/workflows/dag/types.js +9 -130
- package/docs/README.md +3 -3
- package/docs/architecture/evolution.md +67 -102
- package/docs/templates/backend-test-dag.json +50 -32
- package/docs/templates/init-managed-agents.md +2 -5
- package/harness.json +2 -2
- package/package.json +3 -9
- package/skills/loop-agent/SKILL.md +0 -1
- package/skills/loop-agent/references/command-reference.md +0 -4
- package/dist/build-stamp.json +0 -6
- package/dist/commands/dag-request-interrupt.js +0 -20
- package/dist/worker/console/chat/assistant-content.js +0 -121
- package/dist/worker/console/chat/semantic-activity.js +0 -477
- package/dist/worker/console/operation-run-facts.js +0 -190
- package/dist/worker/console/operation-wait.js +0 -355
- package/dist/worker/console/recovery-error-copy.js +0 -198
- package/dist/worker/console/static/assets/abnfDiagram-N423BO3Z-DSvF6RXQ.js +0 -1
- package/dist/worker/console/static/assets/arc-DuI4ogtJ.js +0 -1
- package/dist/worker/console/static/assets/architectureDiagram-T3A2C74G-DdBbgD4y.js +0 -36
- package/dist/worker/console/static/assets/blockDiagram-VBNYF7ZC-xQC2HELm.js +0 -132
- package/dist/worker/console/static/assets/c4Diagram-5PPSVZJV-DPMSgwIZ.js +0 -10
- package/dist/worker/console/static/assets/channel-DLVz9TYW.js +0 -1
- package/dist/worker/console/static/assets/chunk-2GRJ4B5K-WTEKTxin.js +0 -1
- package/dist/worker/console/static/assets/chunk-2Q5K7J3B-B6vZ0Pv_.js +0 -1
- package/dist/worker/console/static/assets/chunk-5RXB4S5H-BrerNAix.js +0 -231
- package/dist/worker/console/static/assets/chunk-5VM5RSS4-D_ch2aaI.js +0 -15
- package/dist/worker/console/static/assets/chunk-6Q2QTUOP-VvfacNrq.js +0 -88
- package/dist/worker/console/static/assets/chunk-GF5L2VYU-B-uGFIg2.js +0 -206
- package/dist/worker/console/static/assets/chunk-JWPE2WC7-BlysCtod.js +0 -1
- package/dist/worker/console/static/assets/chunk-KBJHAD2P-BjAUAfcT.js +0 -1
- package/dist/worker/console/static/assets/chunk-RYQCIY6F-BeZnBmb-.js +0 -1
- package/dist/worker/console/static/assets/chunk-XXDRQBXY-Bbfe3UJG.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BeAuGcO9.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BeAuGcO9.js +0 -1
- package/dist/worker/console/static/assets/cose-bilkent-JH36ORCC-BKJQnYVM.js +0 -1
- package/dist/worker/console/static/assets/cynefin-VYW2F7L2-B1wtI3hU.js +0 -166
- package/dist/worker/console/static/assets/cynefinDiagram-MW4NZA55-DCF6DBSu.js +0 -62
- package/dist/worker/console/static/assets/cytoscape.esm-yzknjiTM.js +0 -321
- package/dist/worker/console/static/assets/dagre-VZM6K2ZE-BJyNGRlP.js +0 -4
- package/dist/worker/console/static/assets/defaultLocale-DX6XiGOO.js +0 -1
- package/dist/worker/console/static/assets/diagram-7IWD3JNH-B7j1-VsZ.js +0 -30
- package/dist/worker/console/static/assets/diagram-B4RE2ZJO-BREJ140a.js +0 -3
- package/dist/worker/console/static/assets/diagram-LBJQPF4R-CDPHmHGH.js +0 -24
- package/dist/worker/console/static/assets/diagram-Q27KOJAE-BtZLrez1.js +0 -24
- package/dist/worker/console/static/assets/diagram-UB23O5K3-mzybu8oU.js +0 -41
- package/dist/worker/console/static/assets/ebnfDiagram-BXEA7PRR-Dk3I2uO6.js +0 -1
- package/dist/worker/console/static/assets/erDiagram-JOGREHBK-BT97u_tQ.js +0 -85
- package/dist/worker/console/static/assets/flowDiagram-UKHOOZJN-CMOtYzui.js +0 -156
- package/dist/worker/console/static/assets/ganttDiagram-PKOTCBZU-DiNydkuY.js +0 -292
- package/dist/worker/console/static/assets/gitGraphDiagram-DS77QQ5N-Cwt6fLkl.js +0 -106
- package/dist/worker/console/static/assets/graph-DOmOIIwC.js +0 -1
- package/dist/worker/console/static/assets/index-DQxiso1w.js +0 -325
- package/dist/worker/console/static/assets/index-_j0Hzo69.css +0 -1
- package/dist/worker/console/static/assets/infoDiagram-6WML65LV-AsGIGKKB.js +0 -2
- package/dist/worker/console/static/assets/init-Gi6I4Gst.js +0 -1
- package/dist/worker/console/static/assets/ishikawaDiagram-WSZJBQD7-BL8ITZh3.js +0 -70
- package/dist/worker/console/static/assets/journeyDiagram-NVQOT4AX-VPKAUX8M.js +0 -139
- package/dist/worker/console/static/assets/kanban-definition-27J2QSJJ-BL56hfGp.js +0 -89
- package/dist/worker/console/static/assets/layout-D-LzfAck.js +0 -1
- package/dist/worker/console/static/assets/linear-BmSG3nIZ.js +0 -1
- package/dist/worker/console/static/assets/map-DxJ2ADlA.js +0 -1
- package/dist/worker/console/static/assets/mermaid.core-DWGK9kwP.js +0 -308
- package/dist/worker/console/static/assets/mindmap-definition-FAOFIHXS--vvKLjo0.js +0 -96
- package/dist/worker/console/static/assets/ordinal-Cboi1Yqb.js +0 -1
- package/dist/worker/console/static/assets/pegDiagram-VL7TDLO6-B41mxb2-.js +0 -1
- package/dist/worker/console/static/assets/pieDiagram-7S7Q4E2Y-DTlu77sw.js +0 -39
- package/dist/worker/console/static/assets/quadrantDiagram-CIZ2JOQS-Bk5A7alv.js +0 -7
- package/dist/worker/console/static/assets/railroadDiagram-AXF67PYL-Csf9jzQy.js +0 -1
- package/dist/worker/console/static/assets/requirementDiagram-LRYGKXZP-CeptoSNx.js +0 -84
- package/dist/worker/console/static/assets/sankeyDiagram-W5VNT64P-A-DLCLer.js +0 -40
- package/dist/worker/console/static/assets/sequenceDiagram-SI44F4Z6-22VbPdOI.js +0 -162
- package/dist/worker/console/static/assets/sizeCapture-X5ZJPWSS-DWvAyBC9.js +0 -1
- package/dist/worker/console/static/assets/stateDiagram-OKZ733FA-qICZ75yT.js +0 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DzKvfHTX.js +0 -1
- package/dist/worker/console/static/assets/swimlanes-SLNWSIFB-xi7lcMyp.js +0 -2
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Cxqr_qM1.js +0 -8
- package/dist/worker/console/static/assets/timeline-definition-Z64GVDOM-Bu6Nl6O2.js +0 -120
- package/dist/worker/console/static/assets/vennDiagram-T6HMQDX7-BzMz4Bn9.js +0 -34
- package/dist/worker/console/static/assets/wardleyDiagram-T6FBY63Y-CxaJnzc5.js +0 -78
- package/dist/worker/console/static/assets/xychartDiagram-ELKLHX3M-5JK-omQK.js +0 -7
- package/dist/worker/console/static/fonts/katex/KaTeX_AMS-Regular.woff2 +0 -0
- package/dist/worker/console/static/fonts/katex/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/dist/worker/console/static/fonts/katex/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/dist/worker/console/static/fonts/katex/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/dist/worker/console/static/fonts/katex/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/dist/worker/console/static/fonts/katex/KaTeX_Main-Bold.woff2 +0 -0
- package/dist/worker/console/static/fonts/katex/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/dist/worker/console/static/fonts/katex/KaTeX_Main-Italic.woff2 +0 -0
- package/dist/worker/console/static/fonts/katex/KaTeX_Main-Regular.woff2 +0 -0
- package/dist/worker/console/static/fonts/katex/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/dist/worker/console/static/fonts/katex/KaTeX_Math-Italic.woff2 +0 -0
- package/dist/worker/console/static/fonts/katex/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/dist/worker/console/static/fonts/katex/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/dist/worker/console/static/fonts/katex/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/dist/worker/console/static/fonts/katex/KaTeX_Script-Regular.woff2 +0 -0
- package/dist/worker/console/static/fonts/katex/KaTeX_Size1-Regular.woff2 +0 -0
- package/dist/worker/console/static/fonts/katex/KaTeX_Size2-Regular.woff2 +0 -0
- package/dist/worker/console/static/fonts/katex/KaTeX_Size3-Regular.woff2 +0 -0
- package/dist/worker/console/static/fonts/katex/KaTeX_Size4-Regular.woff2 +0 -0
- package/dist/worker/console/static/fonts/katex/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/dist/worker/console/static-src/operator-chat/activity-journey.js +0 -125
- package/dist/worker/console/static-src/operator-chat/activity-rail-presentation.js +0 -73
- package/dist/worker/console/static-src/operator-chat/activity-references.js +0 -20
- package/dist/worker/console/static-src/operator-chat/slash-palette-layout.js +0 -24
- package/dist/worker/console/static-src/operator-chat/slash-palette-nav.js +0 -141
- package/dist/worker/console/static-src/operator-chat/useActivityRailTransition.js +0 -59
- package/dist/worker/observability/interrupt-eligibility.js +0 -264
- package/dist/workflows/dag/contract-output-registry.js +0 -14
- package/dist/workflows/dag/contract-validator-registrations.js +0 -8
- package/dist/workflows/dag/frontend-recovery-plan.js +0 -73
- package/dist/workflows/dag/frontend-recovery-root-manifest.js +0 -123
- package/dist/workflows/dag/frontend-recovery-run.js +0 -539
- package/dist/workflows/dag/frontend-writer-recovery.js +0 -106
- package/dist/workflows/dag/frontend-writer-rollback.js +0 -821
- 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 =
|
|
103
|
-
?
|
|
104
|
-
:
|
|
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:
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
255
|
-
|
|
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 (
|
|
258
|
-
|
|
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*([^,}
|
|
719
|
+
const valueMatch = new RegExp(`["']${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']\\s*:\\s*([^,}\n]+)`).exec(dictText);
|
|
264
720
|
const raw = valueMatch?.[1]?.trim() ?? "";
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
269
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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 (
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
if (
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
|
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
|
-
|
|
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 === "
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
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
|
-
: "
|
|
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
|
|
420
|
-
|
|
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(
|
|
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
|
|
513
|
-
const
|
|
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
|
-
:
|
|
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
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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");
|