@tea-agent/loop-agent 0.38.1 → 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.
- package/CHANGELOG.md +109 -13
- package/dist/application/dag/generate-task-dag.js +9 -0
- package/dist/build-stamp.json +3 -3
- package/dist/commands/init.js +2 -14
- package/dist/executors/dag-pi-executor.js +41 -4
- package/dist/executors/shell-executor.js +269 -15
- package/dist/governance/manifest-types.js +0 -2
- package/dist/shared/operator/capabilities.js +87 -22
- package/dist/task/config-types.js +22 -0
- package/dist/worker/console/chat/deferred-turn.js +91 -0
- package/dist/worker/console/chat/pi-runtime.js +10 -5
- package/dist/worker/console/chat/routes.js +458 -47
- package/dist/worker/console/chat/semantic-activity.js +13 -13
- package/dist/worker/console/chat/session-store.js +146 -2
- package/dist/worker/console/harness-pi-model-matrix.js +2 -11
- package/dist/worker/console/operator-actions.js +5 -4
- package/dist/worker/console/pi-readiness.js +1 -0
- package/dist/worker/console/recovery-cta.js +4 -4
- package/dist/worker/console/recovery-error-copy.js +4 -4
- package/dist/worker/console/server.js +6 -1
- package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-Hfsot6rP.js → abnfDiagram-N423BO3Z-C4bmo3iY.js} +1 -1
- package/dist/worker/console/static/assets/{arc-YGwEASin.js → arc-SAD7McOS.js} +1 -1
- package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-CmOM0F92.js → architectureDiagram-T3A2C74G-DAVLUdK1.js} +1 -1
- package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-DDLuaHC5.js → blockDiagram-VBNYF7ZC-CPPdv4vj.js} +1 -1
- package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-B0Um43W4.js → c4Diagram-5PPSVZJV-D1GRGZax.js} +1 -1
- package/dist/worker/console/static/assets/channel-R6oQIDkw.js +1 -0
- package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-DvBN4JZc.js → chunk-2GRJ4B5K-D_Bq2ZqK.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-B1UVgW4E.js → chunk-2Q5K7J3B-CPLVtamK.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5RXB4S5H-Cf0GgXzX.js → chunk-5RXB4S5H-DnaZWcL3.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5VM5RSS4-DbNA24sj.js → chunk-5VM5RSS4-BRTmFgnq.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-Bi_0Be06.js → chunk-6Q2QTUOP-BRlfYZoH.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-GF5L2VYU-C8pPGvRd.js → chunk-GF5L2VYU-CjNybD4u.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-JWPE2WC7-BitQY8Z-.js → chunk-JWPE2WC7-D3e-74fG.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-KBJHAD2P-U67GCiEo.js → chunk-KBJHAD2P-Br3ir6f2.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-RYQCIY6F-IoJwXPBA.js → chunk-RYQCIY6F-1NgVuRbh.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-XXDRQBXY-YDZ_UHgE.js → chunk-XXDRQBXY-BTYuvuWN.js} +1 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BPXlmPWI.js +1 -0
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BPXlmPWI.js +1 -0
- package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-CUZgsiRC.js → cose-bilkent-JH36ORCC-BC6Bgosf.js} +1 -1
- package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-COmCh4pT.js → cynefin-VYW2F7L2-DV-HypfL.js} +1 -1
- package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-Ta6FSm68.js → cynefinDiagram-MW4NZA55-DhTaddO-.js} +1 -1
- package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-UdcR-VW9.js → dagre-VZM6K2ZE-BnEcOmiI.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-7IWD3JNH-DybcO3Et.js → diagram-7IWD3JNH-xphGRVKp.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-wErDFWLw.js → diagram-B4RE2ZJO-CrdD1mRG.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-LBJQPF4R-DkVHZWqz.js → diagram-LBJQPF4R-D4YdLsaF.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-Q27KOJAE-DdqWs7QN.js → diagram-Q27KOJAE-LCKyGZ5U.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-UB23O5K3-C9dMVmDo.js → diagram-UB23O5K3-BeiTe7t0.js} +1 -1
- package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-B2T2WcBK.js → ebnfDiagram-BXEA7PRR-BO2cq8GS.js} +1 -1
- package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-Bt9rjDFq.js → erDiagram-JOGREHBK-CmWww3Nt.js} +1 -1
- package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-U8ic_tvi.js → flowDiagram-UKHOOZJN-C2kkUYg5.js} +1 -1
- package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-BfQJdQoO.js → ganttDiagram-PKOTCBZU-DY_JZ2iQ.js} +1 -1
- package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-CpMftH4O.js → gitGraphDiagram-DS77QQ5N-BizhCSw9.js} +1 -1
- package/dist/worker/console/static/assets/index-BXN89VPX.js +325 -0
- package/dist/worker/console/static/assets/index-CuH5CNFu.css +1 -0
- package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-DzqWXNO_.js → infoDiagram-6WML65LV-Dlo2eK_d.js} +1 -1
- package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-CJmGQ5Yp.js → ishikawaDiagram-WSZJBQD7-3EIlIIuZ.js} +1 -1
- package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-ChPtKFLz.js → journeyDiagram-NVQOT4AX-DiMDe5Yh.js} +1 -1
- package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-PRe7wB5u.js → kanban-definition-27J2QSJJ-sN553oIN.js} +1 -1
- package/dist/worker/console/static/assets/{linear-DpKX6QNp.js → linear-D9Py31Ld.js} +1 -1
- package/dist/worker/console/static/assets/{mermaid.core-Dnl76b7Y.js → mermaid.core-D7Occzk_.js} +5 -5
- package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-S9PCXc3J.js → mindmap-definition-FAOFIHXS-DjDRboiR.js} +1 -1
- package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-Du8e-FSX.js → pegDiagram-VL7TDLO6-Cgc4qlzp.js} +1 -1
- package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-CY6OmHTc.js → pieDiagram-7S7Q4E2Y-DxZj8pKd.js} +1 -1
- package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-C65GPhpj.js → quadrantDiagram-CIZ2JOQS-CJ50MngR.js} +1 -1
- package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-U6IUiTCL.js → railroadDiagram-AXF67PYL-BY-OD8Tr.js} +1 -1
- package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-D-I_9Qhi.js → requirementDiagram-LRYGKXZP-CQFButfK.js} +1 -1
- package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-B57Fhed0.js → sankeyDiagram-W5VNT64P-BB_QUK_b.js} +1 -1
- package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-CJOtiw21.js → sequenceDiagram-SI44F4Z6-CqhXZjyx.js} +1 -1
- package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-FyX8zxSh.js → sizeCapture-X5ZJPWSS-2l1U4rgJ.js} +1 -1
- package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-eF-pbs9t.js → stateDiagram-OKZ733FA-BX1FbrhJ.js} +1 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DJKi0Qel.js +1 -0
- package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-CcfkA-Dq.js → swimlanes-SLNWSIFB-DOSnT7hb.js} +2 -2
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-YYe7EEm5.js +8 -0
- package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-DH8FjnOK.js → timeline-definition-Z64GVDOM-DVJQA7AN.js} +1 -1
- package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-beoTagJg.js → vennDiagram-T6HMQDX7-74uxl9gx.js} +1 -1
- package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CQSfw4cK.js → wardleyDiagram-T6FBY63Y-DsBxlcg_.js} +1 -1
- package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-CQgzQ6jn.js → xychartDiagram-ELKLHX3M-snuW9Mjw.js} +1 -1
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/app/console-types.js +0 -12
- package/dist/worker/console/static-src/app/useOperatorActions.js +1 -1
- package/dist/worker/console/static-src/app/useRecoveryActions.js +2 -2
- package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +38 -1
- package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +8 -1
- package/dist/worker/console/static-src/operator-chat/turn-submission.js +42 -0
- package/dist/worker/console/static-src/operator-chat/useChatSessions.js +3 -0
- package/dist/worker/console/static-src/operator-chat/useChatStream.js +204 -11
- package/dist/worker/console/static-src/operator-chat/useChatThread.js +23 -1
- package/dist/workflows/dag/backend-test-case-coverage-analysis.js +755 -51
- package/dist/workflows/dag/backend-test-case-manifest.js +4 -0
- package/dist/workflows/dag/backend-test-execution-contract.js +18 -9
- package/dist/workflows/dag/backend-test-gap-fill.js +205 -0
- package/dist/workflows/dag/backend-test-intake-context.js +32 -0
- package/dist/workflows/dag/backend-test-layout.js +133 -0
- package/dist/workflows/dag/backend-test-markdown-workflow.js +167 -32
- package/dist/workflows/dag/backend-test-module-stem.js +5 -0
- package/dist/workflows/dag/backend-test-pytest-collection.js +368 -33
- package/dist/workflows/dag/backend-test-result-contract.js +1 -1
- package/dist/workflows/dag/backend-test-scenario-param.js +884 -140
- package/dist/workflows/dag/backend-test-scenario-partitions.js +249 -0
- package/dist/workflows/dag/backend-test-writer-completeness.js +100 -56
- package/dist/workflows/dag/contract-validator-registrations.js +2 -1
- package/dist/workflows/dag/convergence/controller.js +141 -0
- package/dist/workflows/dag/dynamic-runtime/map.js +24 -8
- package/dist/workflows/dag/dynamic-runtime/shared.js +1 -1
- package/dist/workflows/dag/frontend-implementation-contract.js +236 -8
- package/dist/workflows/dag/frontend-prewrite-gate.js +95 -35
- package/dist/workflows/dag/init-hybrid.js +652 -87
- package/dist/workflows/dag/node-execution.js +45 -18
- package/dist/workflows/dag/prompt.js +45 -0
- package/dist/workflows/dag/rerun-feedback.js +212 -0
- package/dist/workflows/dag/rerun-plan.js +93 -27
- package/dist/workflows/dag/rerun-run.js +3 -0
- package/dist/workflows/dag/rerun-task.js +61 -2
- package/dist/workflows/dag/retry-policy.js +18 -0
- package/dist/workflows/dag/types.js +98 -0
- package/docs/templates/backend-test-dag.json +60 -35
- package/docs/templates/harness.schema.json +0 -8
- package/harness.json +1 -8
- package/package.json +1 -1
- package/skills/frontend-implementation/references/node-contracts.md +2 -2
- package/dist/worker/console/static/assets/channel-Dp_2Aqf1.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL--CODy37j.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE--CODy37j.js +0 -1
- package/dist/worker/console/static/assets/index-BA5-N5ER.css +0 -1
- package/dist/worker/console/static/assets/index-DKPXBrdc.js +0 -325
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-B-mvnLEc.js +0 -1
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-JtpBmIv4.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
|
|
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(
|
|
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 =
|
|
103
|
-
?
|
|
104
|
-
:
|
|
105
|
-
|
|
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:
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
255
|
-
|
|
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 (
|
|
258
|
-
|
|
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*([^,}
|
|
721
|
+
const valueMatch = new RegExp(`["']${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']\\s*:\\s*([^,}\n]+)`).exec(dictText);
|
|
264
722
|
const raw = valueMatch?.[1]?.trim() ?? "";
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
269
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 === "
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
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
|
-
: "
|
|
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
|
|
420
|
-
|
|
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(
|
|
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
|
|
513
|
-
const
|
|
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
|
-
:
|
|
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
|
-
//
|
|
728
|
-
//
|
|
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" };
|