@tea-agent/loop-agent 0.33.6 → 0.33.7-beta.1
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 +39 -23
- package/dist/application/task-lifecycle/advance.js +4 -254
- package/dist/application/task-lifecycle/gates.js +0 -50
- package/dist/application/task-lifecycle/observe.js +2 -11
- package/dist/commands/init-upgrade.js +1 -32
- package/dist/commands/init.js +3 -94
- package/dist/executors/shell-executor.js +91 -4
- package/dist/executors/shell-write-guard.js +8 -26
- package/dist/shared/operator/capabilities.js +42 -72
- package/dist/task/source-prepare/index.js +0 -2
- package/dist/task/source-prepare/parse-intent.js +10 -58
- package/dist/task/source-prepare/prepare.js +16 -180
- package/dist/task/source-prepare/reference-integrity.js +2 -18
- package/dist/worker/console/app-data.js +0 -2
- package/dist/worker/console/chat/chat-event-store.js +25 -190
- package/dist/worker/console/chat/instruction-skills.js +217 -0
- package/dist/worker/console/chat/pi-console-config.js +32 -250
- package/dist/worker/console/chat/pi-runtime.js +71 -625
- package/dist/worker/console/chat/resource-loader.js +4 -5
- package/dist/worker/console/chat/routes.js +146 -324
- package/dist/worker/console/chat/runtime-context.js +12 -48
- package/dist/worker/console/chat/runtime-selection.js +0 -59
- package/dist/worker/console/chat/shortcuts.js +0 -1
- package/dist/worker/console/chat/tool-adapter.js +3 -9
- package/dist/worker/console/chat/tools.js +1 -5
- package/dist/worker/console/operator-actions.js +68 -559
- package/dist/worker/console/server.js +15 -8
- package/dist/worker/console/static/assets/index-CnUXAqxG.css +1 -0
- package/dist/worker/console/static/assets/index-CteJFFL2.js +29 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +8 -45
- package/dist/worker/console/static-src/operator-chat/refs.js +0 -9
- package/dist/worker/console/static-src/operator-chat/useChatSessions.js +0 -16
- package/dist/worker/console/static-src/operator-chat/useChatStream.js +184 -210
- package/dist/worker/console/static-src/operator-chat/useChatThread.js +5 -49
- package/dist/worker/console/static-src/operator-chat/useComposer.js +0 -17
- package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +74 -225
- package/dist/worker/delivery/final-verification.js +5 -13
- package/dist/worker/delivery/package.js +19 -31
- package/dist/worker/delivery/verification-bundle.js +4 -6
- package/dist/worker/observe/static/operator-chrome.css +2 -5
- package/dist/worker/observe/static/operator-chrome.js +1 -6
- package/dist/worker/observe/static/styles.css +9 -39
- package/dist/workflows/dag/backend-test-case-coverage-analysis.js +462 -33
- package/dist/workflows/dag/backend-test-case-manifest.js +4 -0
- package/dist/workflows/dag/backend-test-markdown-workflow.js +25 -1
- 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 +552 -124
- 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/frontend-worktree-diff.js +27 -12
- package/dist/workflows/dag/init-hybrid.js +49 -37
- package/dist/workflows/dag/types.js +7 -0
- package/dist/workflows/dag/workspace-checkpoint.js +27 -8
- package/docs/templates/backend-test-dag.json +33 -30
- package/harness.json +1 -1
- package/package.json +1 -1
- package/skills/loop-agent/references/command-reference.md +1 -3
- package/skills/loop-agent/references/source-and-plan-practice.md +0 -13
- package/skills/loop-agent/references/task-workflow.md +0 -4
- package/dist/shared/resilient-git.js +0 -133
- package/dist/task/source-prepare/artifact-meta.js +0 -137
- package/dist/task/source-prepare/semantic-intake.js +0 -404
- package/dist/worker/console/dag-execution-receipt.js +0 -380
- package/dist/worker/console/static/assets/index-BUOLppPr.js +0 -28
- package/dist/worker/console/static/assets/index-C1KzazY5.css +0 -1
- package/dist/worker/console/static-src/operator-chat/runtime-snapshot-store.js +0 -257
- package/dist/worker/console/static-src/operator-chat/useRuntimeSnapshot.js +0 -196
|
@@ -98,10 +98,16 @@ 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));
|
|
105
111
|
const scriptMatch = body.match(/`?(testcase\/[A-Za-z0-9_./-]*test_[A-Za-z0-9_.-]*\.py)`?/i);
|
|
106
112
|
cases.push({
|
|
107
113
|
caseId,
|
|
@@ -115,23 +121,153 @@ async function listMarkdownCases(workspaceRoot) {
|
|
|
115
121
|
}
|
|
116
122
|
return cases;
|
|
117
123
|
}
|
|
124
|
+
function parseScenarioIntentLine(line) {
|
|
125
|
+
// Target may contain commas (body.title,body.status). Intent may contain `|` / spaces.
|
|
126
|
+
// Stop field values only at known trailing keys or end-of-line.
|
|
127
|
+
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*(.+?)(?:\s*[|;]\s*bound\s*=\s*(\d+))?(?:\s*[|;]\s*example\s*=\s*([^;\n]+))?(?:\s*[|;]\s*expectedCode\s*=\s*[^;\n]+)?\s*$/i.exec(line.trim());
|
|
128
|
+
if (precise) {
|
|
129
|
+
return {
|
|
130
|
+
tpId: precise[1],
|
|
131
|
+
rawIntent: (precise[4] ?? "unknown").trim(),
|
|
132
|
+
target: precise[3]?.trim(),
|
|
133
|
+
bound: precise[5] ? Number(precise[5]) : undefined,
|
|
134
|
+
example: precise[6]?.trim(),
|
|
135
|
+
legacy: false,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
const legacy = /场景意图\s*[::]\s*([a-z0-9+._:-]+)(?:\s*[|;,,]\s*field\s*=\s*([A-Za-z0-9_.]+))?(?:\s*[|;,,]\s*bound\s*=\s*(\d+))?(?:\s*[|;,,]\s*example\s*=\s*([^|\n]+))?/i.exec(line.trim());
|
|
139
|
+
if (legacy) {
|
|
140
|
+
return {
|
|
141
|
+
rawIntent: (legacy[1] ?? "unknown").trim(),
|
|
142
|
+
field: legacy[2],
|
|
143
|
+
bound: legacy[3] ? Number(legacy[3]) : undefined,
|
|
144
|
+
example: legacy[4]?.trim(),
|
|
145
|
+
legacy: true,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
function resolveScenarioIntentField(target, field) {
|
|
151
|
+
if (field)
|
|
152
|
+
return field;
|
|
153
|
+
if (!target || target === "request")
|
|
154
|
+
return undefined;
|
|
155
|
+
const bodyTargets = target
|
|
156
|
+
.split(/\s*,\s*/)
|
|
157
|
+
.map((item) => item.trim())
|
|
158
|
+
.filter((item) => /^(?:body|json|payload)\./i.test(item));
|
|
159
|
+
const preferred = bodyTargets.find((item) => /\.status$/i.test(item)) ??
|
|
160
|
+
bodyTargets[0] ??
|
|
161
|
+
target.split(/\s*,\s*/)[0];
|
|
162
|
+
return preferred?.split(".").at(-1);
|
|
163
|
+
}
|
|
164
|
+
function resolveCustomLiteralIntent(rawIntent, field) {
|
|
165
|
+
const lower = rawIntent.toLowerCase();
|
|
166
|
+
if (!lower.startsWith("custom-literal:")) {
|
|
167
|
+
return CLOSED_SET.has(lower) ? lower : "unknown";
|
|
168
|
+
}
|
|
169
|
+
// Preserve literal case: enum values such as ACTIVE must not be lowercased.
|
|
170
|
+
const prefixLength = rawIntent.toLowerCase().startsWith("custom-literal:")
|
|
171
|
+
? rawIntent.indexOf(":") + 1
|
|
172
|
+
: "custom-literal:".length;
|
|
173
|
+
const payload = rawIntent.slice(prefixLength);
|
|
174
|
+
if (!payload.includes("|") && !payload.includes(",")) {
|
|
175
|
+
return `custom-literal:${payload}`;
|
|
176
|
+
}
|
|
177
|
+
const parts = payload.split(/\s*[|,]\s*/).map((item) => item.trim()).filter(Boolean);
|
|
178
|
+
if (field) {
|
|
179
|
+
const byField = parts.find((part) => part.toLowerCase().startsWith(`${field.toLowerCase()}=`));
|
|
180
|
+
if (byField) {
|
|
181
|
+
return `custom-literal:${byField.slice(field.length + 1)}`;
|
|
182
|
+
}
|
|
183
|
+
if (/^status$/i.test(field)) {
|
|
184
|
+
const enumLike = parts.find((part) => /^(?:ACTIVE|ARCHIVED|INACTIVE|DISABLED|ENABLED)$/i.test(part));
|
|
185
|
+
if (enumLike)
|
|
186
|
+
return `custom-literal:${enumLike}`;
|
|
187
|
+
}
|
|
188
|
+
if (/^title$/i.test(field)) {
|
|
189
|
+
const titleLike = parts.find((part) => !/^(?:ACTIVE|ARCHIVED|INACTIVE|DISABLED|ENABLED)$/i.test(part));
|
|
190
|
+
if (titleLike)
|
|
191
|
+
return `custom-literal:${titleLike}`;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return parts.length === 1
|
|
195
|
+
? `custom-literal:${parts[0]}`
|
|
196
|
+
: "unknown";
|
|
197
|
+
}
|
|
118
198
|
export function inferScenarioParamIntent(input) {
|
|
119
199
|
const { tpId, caseBody } = input;
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
200
|
+
const intentLines = caseBody
|
|
201
|
+
.split(/\r?\n/)
|
|
202
|
+
.map((line) => line.trim())
|
|
203
|
+
.filter((line) => /场景意图\s*[::]/.test(line));
|
|
204
|
+
const parsedLines = intentLines
|
|
205
|
+
.map((line) => parseScenarioIntentLine(line))
|
|
206
|
+
.filter((item) => Boolean(item));
|
|
207
|
+
const preciseForTp = parsedLines.find((item) => item.tpId && item.tpId.toUpperCase() === tpId.toUpperCase());
|
|
208
|
+
const legacyLine = parsedLines.find((item) => item.legacy);
|
|
209
|
+
const fallbackLine = new RegExp(`${tpId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[^\\n]{0,200}?intent\\s*[=:]\\s*([^|;,\uff0c\\n]+)`, "i").exec(caseBody);
|
|
210
|
+
const machine = preciseForTp ??
|
|
211
|
+
(legacyLine && parsedLines.filter((item) => !item.legacy).length === 0 ? legacyLine : undefined) ??
|
|
212
|
+
(fallbackLine
|
|
213
|
+
? {
|
|
214
|
+
rawIntent: fallbackLine[1] ?? "unknown",
|
|
215
|
+
legacy: true,
|
|
216
|
+
}
|
|
217
|
+
: undefined);
|
|
218
|
+
if (machine) {
|
|
219
|
+
const field = resolveScenarioIntentField(machine.target, machine.field);
|
|
220
|
+
const rawIntent = machine.rawIntent.trim();
|
|
221
|
+
const example = machine.example;
|
|
222
|
+
const lower = rawIntent.toLowerCase();
|
|
223
|
+
const normalizedIntent = lower === "nominal-operation" ? "nominal" : lower;
|
|
224
|
+
const dataSection = sectionBody(caseBody, ["测试数据", "Test Data", "操作步骤", "Steps"]);
|
|
225
|
+
const haystack = `${caseBody}\n${dataSection}`;
|
|
226
|
+
const fieldEsc = field
|
|
227
|
+
? field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
228
|
+
: undefined;
|
|
229
|
+
const maxBoundFromBody = fieldEsc
|
|
230
|
+
? new RegExp(`${fieldEsc}[^\\n]{0,80}?(?:maxLength|max(?:imum)?|上限|最大长度)\\s*[=::]?\\s*(\\d{1,4})`, "i").exec(haystack) ??
|
|
231
|
+
new RegExp(`(?:maxLength|max(?:imum)?|上限|最大长度)\\s*[=::]?\\s*(\\d{1,4})[^\\n]{0,80}?${fieldEsc}`, "i").exec(haystack) ??
|
|
232
|
+
/(?:maxLength|max(?:imum)?|上限|最大长度)\s*[=::]?\s*(\d{1,4})/i.exec(haystack)
|
|
233
|
+
: /(?:maxLength|max(?:imum)?|上限|最大长度)\s*[=::]?\s*(\d{1,4})/i.exec(haystack);
|
|
234
|
+
const minBoundFromBody = fieldEsc
|
|
235
|
+
? new RegExp(`${fieldEsc}[^\\n]{0,80}?(?:minLength|min(?:imum)?|下限|最小长度)\\s*[=::]?\\s*(\\d{1,4})`, "i").exec(haystack) ??
|
|
236
|
+
new RegExp(`(?:minLength|min(?:imum)?|下限|最小长度)\\s*[=::]?\\s*(\\d{1,4})[^\\n]{0,80}?${fieldEsc}`, "i").exec(haystack) ??
|
|
237
|
+
/(?:minLength|min(?:imum)?|下限|最小长度)\s*[=::]?\s*(\d{1,4})/i.exec(haystack)
|
|
238
|
+
: /(?:minLength|min(?:imum)?|下限|最小长度)\s*[=::]?\s*(\d{1,4})/i.exec(haystack);
|
|
239
|
+
const prefersMinBound = normalizedIntent === "min" ||
|
|
240
|
+
normalizedIntent === "min-1" ||
|
|
241
|
+
/(?:MIN|LEN1|LENGTH-?1|最短|最小)/i.test(tpId);
|
|
242
|
+
const boundFromBody = prefersMinBound
|
|
243
|
+
? minBoundFromBody ?? maxBoundFromBody
|
|
244
|
+
: maxBoundFromBody ?? minBoundFromBody;
|
|
245
|
+
const boundFromTp = /(?:LEN(?:GTH)?|长度)[_-]?(\d{1,5})(?:[_-]|$)/i.exec(tpId) ??
|
|
246
|
+
/(?:MIN|MAX)[_-]?(\d{1,5})(?:[_-]|$)/i.exec(tpId);
|
|
247
|
+
const lengthIntent = normalizedIntent === "min" ||
|
|
248
|
+
normalizedIntent === "min-1" ||
|
|
249
|
+
normalizedIntent === "max" ||
|
|
250
|
+
normalizedIntent === "max+1";
|
|
251
|
+
// Length intents: explicit machine bound >0, then TP-encoded LEN1/LEN100,
|
|
252
|
+
// then field min/maxLength. Prevents maxLength=100 shadowing min/LEN1.
|
|
253
|
+
const boundFromTpNumber = boundFromTp ? Number(boundFromTp[1]) : undefined;
|
|
254
|
+
// max+1 TP tokens encode the oversized length (LEN101), so convert to base bound.
|
|
255
|
+
const boundFromTpNormalized = boundFromTpNumber === undefined
|
|
256
|
+
? undefined
|
|
257
|
+
: normalizedIntent === "max+1"
|
|
258
|
+
? Math.max(0, boundFromTpNumber - 1)
|
|
259
|
+
: boundFromTpNumber;
|
|
260
|
+
const bound = machine.bound && machine.bound > 0
|
|
261
|
+
? machine.bound
|
|
262
|
+
: lengthIntent && boundFromTpNormalized !== undefined
|
|
263
|
+
? boundFromTpNormalized
|
|
264
|
+
: boundFromBody
|
|
265
|
+
? Number(boundFromBody[1])
|
|
266
|
+
: machine.bound;
|
|
267
|
+
if (rawIntent.toLowerCase().startsWith("custom-literal:")) {
|
|
268
|
+
const intent = resolveCustomLiteralIntent(rawIntent, field);
|
|
133
269
|
return {
|
|
134
|
-
intent
|
|
270
|
+
intent,
|
|
135
271
|
field,
|
|
136
272
|
bound,
|
|
137
273
|
example,
|
|
@@ -169,13 +305,29 @@ export function inferScenarioParamIntent(input) {
|
|
|
169
305
|
"VARIANT",
|
|
170
306
|
"WHITESPACE",
|
|
171
307
|
"UNKNOWN",
|
|
308
|
+
"HEALTH",
|
|
309
|
+
"UP",
|
|
310
|
+
"OK",
|
|
311
|
+
"GET",
|
|
312
|
+
"API",
|
|
313
|
+
"STATUS",
|
|
314
|
+
"CODE",
|
|
315
|
+
"HTTP",
|
|
316
|
+
"RESPONSE",
|
|
317
|
+
"REQUEST",
|
|
172
318
|
].includes(part.toUpperCase()))[0]
|
|
173
319
|
?.replace(/_/g, "") ?? undefined;
|
|
174
320
|
const field = fieldFromTp && fieldFromTp.length > 1
|
|
175
321
|
? fieldFromTp.charAt(0).toLowerCase() + fieldFromTp.slice(1)
|
|
176
322
|
: undefined;
|
|
177
323
|
const dataSection = sectionBody(caseBody, ["测试数据", "Test Data", "操作步骤", "Steps"]);
|
|
178
|
-
const
|
|
324
|
+
const fieldScopedBound = field
|
|
325
|
+
? new RegExp(`${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[^\\n]{0,80}?(?:maxLength|max(?:imum)?|上限|最大长度)\\s*[=::]?\\s*(\\d{1,4})`, "i").exec(`${caseBody}\n${dataSection}`) ??
|
|
326
|
+
new RegExp(`(?:maxLength|max(?:imum)?|上限|最大长度)\\s*[=::]?\\s*(\\d{1,4})[^\\n]{0,80}?${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "i").exec(`${caseBody}\n${dataSection}`)
|
|
327
|
+
: undefined;
|
|
328
|
+
const boundMatch = fieldScopedBound ??
|
|
329
|
+
/(?:maxLength|max(?:imum)?|上限|最大长度)\s*[=::]?\s*(\d{1,4})/i.exec(`${caseBody}\n${dataSection}`) ??
|
|
330
|
+
/(?:minLength|min(?:imum)?|下限|最小长度)\s*[=::]?\s*(\d{1,4})/i.exec(`${caseBody}\n${dataSection}`);
|
|
179
331
|
const bound = boundMatch ? Number(boundMatch[1]) : undefined;
|
|
180
332
|
const exampleMatch = /(?:反例|invalid example|example)\s*[=::]\s*[`"]?([^`"\n]+)[`"]?/i.exec(caseBody);
|
|
181
333
|
const example = exampleMatch?.[1]?.trim();
|
|
@@ -214,6 +366,147 @@ export function inferScenarioParamIntent(input) {
|
|
|
214
366
|
}
|
|
215
367
|
return { intent: "unknown", field, bound, example, intentSource: "tp-fallback" };
|
|
216
368
|
}
|
|
369
|
+
const OMIT_SENTINEL_RE = /^(?:_OMIT|OMIT|MISSING|Ellipsis|\.\.\.)\b/;
|
|
370
|
+
const HTTP_EXPECTED_CODE_RE = /^(?:2xx|3xx|4xx|5xx|[1-5]\d{2}|HTTP_[1-5]\d{2})$/i;
|
|
371
|
+
/** Semantic custom-literal tokens that describe a value class, not a raw string. */
|
|
372
|
+
function isWhitespaceSemanticLiteral(token) {
|
|
373
|
+
return /^(?:whitespace-only|ws-only|blank|blank-only|spaces-only|space-only|empty-ws|empty-whitespace)$/i.test(token.trim());
|
|
374
|
+
}
|
|
375
|
+
function isWhitespacePaddedSemanticLiteral(token) {
|
|
376
|
+
return /^(?:whitespace-padded|ws-padded|padded-whitespace|leading-trailing-ws|trim-target|trim|trimmed|pad|padded)$/i.test(token.trim());
|
|
377
|
+
}
|
|
378
|
+
function isWhitespaceOnlyString(value) {
|
|
379
|
+
return value.length > 0 && /^\s+$/.test(value);
|
|
380
|
+
}
|
|
381
|
+
function isWhitespacePaddedString(value) {
|
|
382
|
+
return value.length > 0 && value !== value.trim() && value.trim().length > 0;
|
|
383
|
+
}
|
|
384
|
+
function normalizeScenarioLiteralText(value) {
|
|
385
|
+
// Writers sometimes emit visible-space placeholders (U+2420) or NBSP variants.
|
|
386
|
+
return value
|
|
387
|
+
.replace(/␠/g, " ")
|
|
388
|
+
.replace(/ /g, " ")
|
|
389
|
+
.replace(/ /g, " ")
|
|
390
|
+
.replace(/ /g, " ");
|
|
391
|
+
}
|
|
392
|
+
function stripWrappingQuotes(value) {
|
|
393
|
+
const trimmed = value.trim();
|
|
394
|
+
if ((trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) ||
|
|
395
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2)) {
|
|
396
|
+
return trimmed.slice(1, -1);
|
|
397
|
+
}
|
|
398
|
+
return value;
|
|
399
|
+
}
|
|
400
|
+
function normalizeCustomLiteralExpected(raw) {
|
|
401
|
+
return normalizeScenarioLiteralText(stripWrappingQuotes(raw));
|
|
402
|
+
}
|
|
403
|
+
function isHttpExpectedCodeLiteral(value) {
|
|
404
|
+
return HTTP_EXPECTED_CODE_RE.test(value.trim());
|
|
405
|
+
}
|
|
406
|
+
/** Decode common Python string escapes found in source text (not JSON). */
|
|
407
|
+
function unescapePythonStringLiteral(value) {
|
|
408
|
+
return value
|
|
409
|
+
.replace(/\\n/g, "\n")
|
|
410
|
+
.replace(/\\t/g, "\t")
|
|
411
|
+
.replace(/\\r/g, "\r")
|
|
412
|
+
.replace(/\\'/g, "'")
|
|
413
|
+
.replace(/\\"/g, '"')
|
|
414
|
+
.replace(/\\\\/g, "\\");
|
|
415
|
+
}
|
|
416
|
+
function featuresFromStringValue(value) {
|
|
417
|
+
const decoded = normalizeScenarioLiteralText(unescapePythonStringLiteral(value));
|
|
418
|
+
return {
|
|
419
|
+
kind: decoded.length === 0 ? "empty-string" : "string",
|
|
420
|
+
text: JSON.stringify(decoded),
|
|
421
|
+
length: decoded.length,
|
|
422
|
+
hasUppercase: /[A-Z]/.test(decoded),
|
|
423
|
+
literal: decoded,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
function observeLiteralToken(raw, field) {
|
|
427
|
+
const trimmed = raw.trim();
|
|
428
|
+
if (!trimmed)
|
|
429
|
+
return undefined;
|
|
430
|
+
if (OMIT_SENTINEL_RE.test(trimmed) || trimmed === "...") {
|
|
431
|
+
return {
|
|
432
|
+
kind: "missing-key",
|
|
433
|
+
text: field ? `missing:${field}` : "missing-key",
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
if (trimmed === "None" || trimmed === "null") {
|
|
437
|
+
return { kind: "none", text: "None", literal: "None" };
|
|
438
|
+
}
|
|
439
|
+
if (trimmed === '""' || trimmed === "''") {
|
|
440
|
+
return { kind: "empty-string", text: '""', length: 0, literal: "" };
|
|
441
|
+
}
|
|
442
|
+
const repeatedValue = /^(['"])([^'"\\])\1\s*\*\s*(\d{1,5})$/.exec(trimmed);
|
|
443
|
+
if (repeatedValue) {
|
|
444
|
+
const ch = repeatedValue[2] ?? "x";
|
|
445
|
+
const length = Number(repeatedValue[3] ?? "0");
|
|
446
|
+
const value = ch.repeat(Math.max(0, length));
|
|
447
|
+
return featuresFromStringValue(value);
|
|
448
|
+
}
|
|
449
|
+
const triple = /^("""|''')([\s\S]*)\1$/.exec(trimmed);
|
|
450
|
+
if (triple) {
|
|
451
|
+
return featuresFromStringValue(triple[2] ?? "");
|
|
452
|
+
}
|
|
453
|
+
const str = /^['"]([\s\S]*)['"]$/.exec(trimmed);
|
|
454
|
+
if (str) {
|
|
455
|
+
return featuresFromStringValue(str[1] ?? "");
|
|
456
|
+
}
|
|
457
|
+
if (/^-?\d+(?:\.\d+)?$/.test(trimmed)) {
|
|
458
|
+
return { kind: "number", text: trimmed, literal: trimmed };
|
|
459
|
+
}
|
|
460
|
+
if (trimmed === "True" || trimmed === "False") {
|
|
461
|
+
return { kind: "boolean", text: trimmed, literal: trimmed };
|
|
462
|
+
}
|
|
463
|
+
if (trimmed.startsWith("["))
|
|
464
|
+
return { kind: "list", text: trimmed };
|
|
465
|
+
if (trimmed.startsWith("{"))
|
|
466
|
+
return { kind: "dict", text: trimmed };
|
|
467
|
+
if (/\(/.test(trimmed)) {
|
|
468
|
+
// Tuple/list positional wrappers: (_TITLE_LEN1, "2xx") → first meaningful token.
|
|
469
|
+
const inner = trimmed.replace(/^\(/, "").replace(/\)$/, "");
|
|
470
|
+
const first = inner.split(",")[0]?.trim();
|
|
471
|
+
if (first && first !== trimmed) {
|
|
472
|
+
return observeLiteralToken(first, field);
|
|
473
|
+
}
|
|
474
|
+
return { kind: "call", text: trimmed };
|
|
475
|
+
}
|
|
476
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(trimmed)) {
|
|
477
|
+
if (OMIT_SENTINEL_RE.test(trimmed)) {
|
|
478
|
+
return {
|
|
479
|
+
kind: "missing-key",
|
|
480
|
+
text: field ? `missing:${field}` : "missing-key",
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
// Length helper names used by writers:
|
|
484
|
+
// _TITLE_LEN1 / _CONTENT_LEN2000 / _TITLE_MAX_LENGTH / _CONTENT_OVER_LENGTH
|
|
485
|
+
const lenFromName = /(?:^|_)LEN(?:GTH)?_?(\d{1,5})$/i.exec(trimmed) ??
|
|
486
|
+
/LEN(?:GTH)?_?(\d{1,5})$/i.exec(trimmed);
|
|
487
|
+
if (lenFromName) {
|
|
488
|
+
const length = Number(lenFromName[1] ?? "0");
|
|
489
|
+
return {
|
|
490
|
+
kind: "number",
|
|
491
|
+
text: trimmed,
|
|
492
|
+
length,
|
|
493
|
+
literal: String(length),
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
// Opaque max/min/oversize helpers resolve against Markdown bound in compareIntent.
|
|
497
|
+
if (/(?:OVER|OVERSIZE|TOO[_-]?LONG|MAX[_-]?PLUS[_-]?1|MAXPLUS1)/i.test(trimmed)) {
|
|
498
|
+
return { kind: "name", text: trimmed, length: -2, literal: "oversize-helper" };
|
|
499
|
+
}
|
|
500
|
+
if (/(?:MAX[_-]?LENGTH|LENGTH[_-]?MAX|_MAX_LEN(?:GTH)?$)/i.test(trimmed)) {
|
|
501
|
+
return { kind: "name", text: trimmed, length: -1, literal: "max-length-helper" };
|
|
502
|
+
}
|
|
503
|
+
if (/(?:MIN[_-]?LENGTH|LENGTH[_-]?MIN|_MIN_LEN(?:GTH)?$)/i.test(trimmed)) {
|
|
504
|
+
return { kind: "name", text: trimmed, length: -3, literal: "min-length-helper" };
|
|
505
|
+
}
|
|
506
|
+
return { kind: "name", text: trimmed };
|
|
507
|
+
}
|
|
508
|
+
return { kind: "unknown", text: trimmed };
|
|
509
|
+
}
|
|
217
510
|
export function extractPytestParamBlock(source, tpId) {
|
|
218
511
|
const idPattern = new RegExp(`id\\s*=\\s*["']${tpId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']`);
|
|
219
512
|
const matches = [...source.matchAll(/pytest\.param\s*\(/g)];
|
|
@@ -248,88 +541,160 @@ export function observeParamFeatures(block, field) {
|
|
|
248
541
|
if (/\bFaker\b|\bfake\b|\brandom\b|\buuid4\b|\bfactory\b/i.test(block)) {
|
|
249
542
|
return { kind: "call", text: "dynamic-or-faker" };
|
|
250
543
|
}
|
|
251
|
-
// Prefer dict-like payload extraction.
|
|
544
|
+
// Prefer dict-like payload extraction when a real object literal is present.
|
|
545
|
+
// Writers often build the payload in the test body and only pass scalar intent
|
|
546
|
+
// values in pytest.param; in that case fall through to positional observation.
|
|
252
547
|
const dictMatch = /\{\s*([\s\S]*?)\s*\}/.exec(block) ??
|
|
253
548
|
/(?:payload|body|data|json)\s*=\s*(\{[\s\S]*?\})/.exec(block);
|
|
254
|
-
|
|
255
|
-
|
|
549
|
+
const dictText = dictMatch?.[0] ?? dictMatch?.[1] ?? "";
|
|
550
|
+
const fieldPattern = field
|
|
551
|
+
? new RegExp(`["']${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']\\s*:`)
|
|
552
|
+
: undefined;
|
|
553
|
+
const dictHasField = Boolean(fieldPattern && fieldPattern.test(dictText));
|
|
554
|
+
const dictLooksLikePayload = Boolean(dictMatch) &&
|
|
555
|
+
(/["'][A-Za-z_][A-Za-z0-9_]*["']\s*:/.test(dictText) ||
|
|
556
|
+
/(?:payload|body|data|json)\s*=/.test(block));
|
|
557
|
+
if (dictLooksLikePayload && !field) {
|
|
558
|
+
return { kind: "dict", text: dictText.replace(/\s+/g, " ").slice(0, 160) };
|
|
256
559
|
}
|
|
257
|
-
if (
|
|
258
|
-
|
|
259
|
-
const fieldPattern = new RegExp(`["']${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']\\s*:`);
|
|
260
|
-
if (!fieldPattern.test(dict)) {
|
|
560
|
+
if (dictLooksLikePayload && field) {
|
|
561
|
+
if (!dictHasField) {
|
|
261
562
|
return { kind: "missing-key", text: `missing:${field}` };
|
|
262
563
|
}
|
|
263
|
-
const valueMatch = new RegExp(`["']${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']\\s*:\\s*([^,}
|
|
564
|
+
const valueMatch = new RegExp(`["']${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']\\s*:\\s*([^,}\n]+)`).exec(dictText);
|
|
264
565
|
const raw = valueMatch?.[1]?.trim() ?? "";
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
566
|
+
return (observeLiteralToken(raw, field) ?? {
|
|
567
|
+
kind: "unknown",
|
|
568
|
+
text: raw || compact.slice(0, 120),
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
// Repeated string literal: "a" * 100 / 'x'*101
|
|
572
|
+
const repeated = /pytest\.param\s*\(\s*(["'])([^"'\\])\1\s*\*\s*(\d{1,5})/.exec(block) ??
|
|
573
|
+
/(["'])([^"'\\])\1\s*\*\s*(\d{1,5})/.exec(block);
|
|
574
|
+
if (repeated && (!field || !/\{/.test(block))) {
|
|
575
|
+
const ch = repeated[2] ?? "x";
|
|
576
|
+
const length = Number(repeated[3] ?? "0");
|
|
577
|
+
const value = ch.repeat(Math.max(0, length));
|
|
578
|
+
return {
|
|
579
|
+
kind: length === 0 ? "empty-string" : "string",
|
|
580
|
+
text: JSON.stringify(value),
|
|
581
|
+
length,
|
|
582
|
+
hasUppercase: /[A-Z]/.test(value),
|
|
583
|
+
literal: value,
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
// Bare omit sentinel before id=...
|
|
587
|
+
if (/pytest\.param\s*\(\s*(?:_OMIT|OMIT|MISSING|\.\.\.|Ellipsis)\s*(?:,|\)|$)/.test(block)) {
|
|
588
|
+
return {
|
|
589
|
+
kind: "missing-key",
|
|
590
|
+
text: field ? `missing:${field}` : "missing-key",
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
// Positional args: skip a leading TP-id label when writers emit
|
|
594
|
+
// pytest.param("TP-...", actual_value, id="TP-...").
|
|
595
|
+
// Also skip HTTP expected-code tokens (2xx/400) that are not field values.
|
|
596
|
+
const paramOpen = /pytest\.param\s*\(/.exec(block);
|
|
597
|
+
if (paramOpen) {
|
|
598
|
+
const afterOpen = block.slice((paramOpen.index ?? 0) + paramOpen[0].length);
|
|
599
|
+
const positionalTokens = [];
|
|
600
|
+
const tokenRe = /\s*(_OMIT|OMIT|MISSING|Ellipsis|\.\.\.|None|True|False|-?\d+(?:\.\d+)?|"""[\s\S]*?"""|\'\'\'[\s\S]*?\'\'\'|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[A-Za-z_][A-Za-z0-9_]*)\s*(?:,|\)|$)/g;
|
|
601
|
+
let match;
|
|
602
|
+
while ((match = tokenRe.exec(afterOpen)) !== null) {
|
|
603
|
+
const between = afterOpen.slice(0, match.index);
|
|
604
|
+
if (/(?:^|,)\s*(?:id|marks)\s*=/.test(between + match[0]))
|
|
605
|
+
break;
|
|
606
|
+
if (/^(?:id|marks)$/i.test(match[1] ?? ""))
|
|
607
|
+
break;
|
|
608
|
+
positionalTokens.push(match[1]);
|
|
609
|
+
if (positionalTokens.length >= 6)
|
|
610
|
+
break;
|
|
296
611
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
612
|
+
const idMatch = /\bid\s*=\s*["'](TP-[A-Z0-9-]+)["']/i.exec(block);
|
|
613
|
+
const idTp = idMatch?.[1]?.toUpperCase();
|
|
614
|
+
const candidates = positionalTokens.filter((raw) => {
|
|
615
|
+
const observed = observeLiteralToken(raw, field);
|
|
616
|
+
if (!observed)
|
|
617
|
+
return false;
|
|
618
|
+
if (observed.kind === "string" &&
|
|
619
|
+
observed.literal &&
|
|
620
|
+
/^TP-[A-Z0-9-]+$/i.test(observed.literal) &&
|
|
621
|
+
(!idTp || observed.literal.toUpperCase() === idTp)) {
|
|
622
|
+
return false;
|
|
623
|
+
}
|
|
624
|
+
// Only drop string expected-code tokens ("2xx"/"400"). Bare numbers stay
|
|
625
|
+
// because they are commonly length bounds (e.g. 100 for maxLength).
|
|
626
|
+
if (observed.kind === "string" &&
|
|
627
|
+
observed.literal &&
|
|
628
|
+
isHttpExpectedCodeLiteral(observed.literal)) {
|
|
629
|
+
return false;
|
|
630
|
+
}
|
|
631
|
+
return true;
|
|
632
|
+
});
|
|
633
|
+
const raw = candidates[0];
|
|
634
|
+
if (raw) {
|
|
635
|
+
return (observeLiteralToken(raw, field) ?? {
|
|
636
|
+
kind: "unknown",
|
|
637
|
+
text: raw,
|
|
638
|
+
});
|
|
307
639
|
}
|
|
308
|
-
|
|
309
|
-
if (
|
|
310
|
-
|
|
311
|
-
return {
|
|
312
|
-
kind: "string",
|
|
313
|
-
text: JSON.stringify(value),
|
|
314
|
-
length: value.length,
|
|
315
|
-
hasUppercase: /[A-Z]/.test(value),
|
|
316
|
-
literal: value,
|
|
317
|
-
};
|
|
640
|
+
// Only expected-code / TP-label tokens remain -> treat as omitted field value.
|
|
641
|
+
if (positionalTokens.length > 0 && field) {
|
|
642
|
+
return { kind: "missing-key", text: `missing:${field}` };
|
|
318
643
|
}
|
|
319
|
-
return { kind: "unknown", text: raw };
|
|
320
644
|
}
|
|
321
645
|
return { kind: "unknown", text: compact.slice(0, 160) };
|
|
322
646
|
}
|
|
323
|
-
function
|
|
647
|
+
function observedLengthValue(observed) {
|
|
648
|
+
if (observed.kind === "number" && observed.literal !== undefined) {
|
|
649
|
+
const n = Number(observed.literal);
|
|
650
|
+
return Number.isFinite(n) ? n : undefined;
|
|
651
|
+
}
|
|
652
|
+
if (observed.kind === "string") {
|
|
653
|
+
// Bare digit strings in pytest.param are length tokens, not character payloads.
|
|
654
|
+
if (observed.literal && /^-?\d+$/.test(observed.literal.trim())) {
|
|
655
|
+
const n = Number(observed.literal.trim());
|
|
656
|
+
return Number.isFinite(n) ? n : undefined;
|
|
657
|
+
}
|
|
658
|
+
return observed.length;
|
|
659
|
+
}
|
|
660
|
+
return undefined;
|
|
661
|
+
}
|
|
662
|
+
function isMaxLengthHelper(observed) {
|
|
663
|
+
return (observed.literal === "max-length-helper" ||
|
|
664
|
+
(observed.kind === "name" &&
|
|
665
|
+
/(?:MAX[_-]?LENGTH|LENGTH[_-]?MAX|_MAX_LEN(?:GTH)?$)/i.test(observed.text)));
|
|
666
|
+
}
|
|
667
|
+
function isMinLengthHelper(observed) {
|
|
668
|
+
return (observed.literal === "min-length-helper" ||
|
|
669
|
+
(observed.kind === "name" &&
|
|
670
|
+
/(?:MIN[_-]?LENGTH|LENGTH[_-]?MIN|_MIN_LEN(?:GTH)?$)/i.test(observed.text)));
|
|
671
|
+
}
|
|
672
|
+
function isOversizeLengthHelper(observed) {
|
|
673
|
+
return (observed.literal === "oversize-helper" ||
|
|
674
|
+
(observed.kind === "name" &&
|
|
675
|
+
/(?:OVER|OVERSIZE|TOO[_-]?LONG|MAX[_-]?PLUS[_-]?1|MAXPLUS1)/i.test(observed.text)));
|
|
676
|
+
}
|
|
677
|
+
function compareIntent(intent, observed, bound, example, field) {
|
|
324
678
|
if (intent === "unknown")
|
|
325
679
|
return "UNDETERMINED";
|
|
326
|
-
|
|
680
|
+
// Opaque length helpers are resolved below against bound; other names stay undetermined.
|
|
681
|
+
if (observed.kind === "call" ||
|
|
682
|
+
(observed.kind === "name" &&
|
|
683
|
+
!isMaxLengthHelper(observed) &&
|
|
684
|
+
!isMinLengthHelper(observed) &&
|
|
685
|
+
!isOversizeLengthHelper(observed))) {
|
|
327
686
|
return "UNDETERMINED";
|
|
328
687
|
}
|
|
329
688
|
switch (intent) {
|
|
330
689
|
case "empty":
|
|
690
|
+
// Empty-string samples match; OMIT/DEFAULT writers often use missing-key instead.
|
|
691
|
+
if (observed.kind === "missing-key")
|
|
692
|
+
return "MATCH";
|
|
331
693
|
return observed.kind === "empty-string" ||
|
|
332
|
-
(observed.kind === "string" &&
|
|
694
|
+
(observed.kind === "string" &&
|
|
695
|
+
(observed.length === 0 ||
|
|
696
|
+
(typeof observed.literal === "string" &&
|
|
697
|
+
/^\s+$/.test(observed.literal))))
|
|
333
698
|
? "MATCH"
|
|
334
699
|
: observed.kind === "unknown"
|
|
335
700
|
? "UNDETERMINED"
|
|
@@ -347,45 +712,49 @@ function compareIntent(intent, observed, bound, example) {
|
|
|
347
712
|
? "UNDETERMINED"
|
|
348
713
|
: "MISMATCH";
|
|
349
714
|
case "max":
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
715
|
+
if (bound === undefined)
|
|
716
|
+
return "UNDETERMINED";
|
|
717
|
+
{
|
|
718
|
+
if (isMaxLengthHelper(observed))
|
|
719
|
+
return "MATCH";
|
|
720
|
+
const len = observedLengthValue(observed);
|
|
721
|
+
if (len === bound)
|
|
722
|
+
return "MATCH";
|
|
723
|
+
return len !== undefined ? "MISMATCH" : "UNDETERMINED";
|
|
724
|
+
}
|
|
359
725
|
case "max+1":
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
726
|
+
if (bound === undefined)
|
|
727
|
+
return "UNDETERMINED";
|
|
728
|
+
{
|
|
729
|
+
if (isOversizeLengthHelper(observed))
|
|
730
|
+
return "MATCH";
|
|
731
|
+
const len = observedLengthValue(observed);
|
|
732
|
+
// Accept base+1 or the oversized length itself when bound already is oversized.
|
|
733
|
+
if (len === bound + 1 || len === bound)
|
|
734
|
+
return "MATCH";
|
|
735
|
+
return len !== undefined ? "MISMATCH" : "UNDETERMINED";
|
|
736
|
+
}
|
|
369
737
|
case "min":
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
738
|
+
if (bound === undefined)
|
|
739
|
+
return "UNDETERMINED";
|
|
740
|
+
{
|
|
741
|
+
if (isMinLengthHelper(observed))
|
|
742
|
+
return "MATCH";
|
|
743
|
+
const len = observedLengthValue(observed);
|
|
744
|
+
if (len === bound)
|
|
745
|
+
return "MATCH";
|
|
746
|
+
return len !== undefined ? "MISMATCH" : "UNDETERMINED";
|
|
747
|
+
}
|
|
379
748
|
case "min-1":
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
749
|
+
if (bound === undefined)
|
|
750
|
+
return "UNDETERMINED";
|
|
751
|
+
{
|
|
752
|
+
const expectedMin1 = Math.max(0, bound - 1);
|
|
753
|
+
const len = observedLengthValue(observed);
|
|
754
|
+
if (len === expectedMin1)
|
|
755
|
+
return "MATCH";
|
|
756
|
+
return len !== undefined ? "MISMATCH" : "UNDETERMINED";
|
|
757
|
+
}
|
|
389
758
|
case "pattern-invalid":
|
|
390
759
|
if (observed.kind === "string" && example !== undefined) {
|
|
391
760
|
return observed.literal === example ? "MATCH" : "MISMATCH";
|
|
@@ -401,10 +770,21 @@ function compareIntent(intent, observed, bound, example) {
|
|
|
401
770
|
? "MISMATCH"
|
|
402
771
|
: "UNDETERMINED";
|
|
403
772
|
case "enum-invalid":
|
|
404
|
-
|
|
773
|
+
// A concrete non-empty invalid enum literal is a valid negative sample.
|
|
774
|
+
if (observed.kind === "string" && (observed.literal?.length ?? 0) > 0) {
|
|
775
|
+
return "MATCH";
|
|
776
|
+
}
|
|
777
|
+
if (observed.kind === "number" || observed.kind === "boolean") {
|
|
778
|
+
return "MATCH";
|
|
779
|
+
}
|
|
780
|
+
return observed.kind === "unknown" || observed.kind === "missing-key"
|
|
405
781
|
? "UNDETERMINED"
|
|
406
|
-
: "
|
|
782
|
+
: "MISMATCH";
|
|
407
783
|
case "nominal":
|
|
784
|
+
// Request-level nominal (no body field) may legitimately pass None/empty query filters.
|
|
785
|
+
if (!field && (observed.kind === "none" || observed.kind === "missing-key")) {
|
|
786
|
+
return "MATCH";
|
|
787
|
+
}
|
|
408
788
|
return observed.kind === "string" ||
|
|
409
789
|
observed.kind === "number" ||
|
|
410
790
|
observed.kind === "boolean" ||
|
|
@@ -416,8 +796,36 @@ function compareIntent(intent, observed, bound, example) {
|
|
|
416
796
|
: "UNDETERMINED";
|
|
417
797
|
default:
|
|
418
798
|
if (intent.startsWith("custom-literal:")) {
|
|
419
|
-
const
|
|
420
|
-
|
|
799
|
+
const expectedRaw = intent.slice("custom-literal:".length);
|
|
800
|
+
const expected = normalizeCustomLiteralExpected(expectedRaw);
|
|
801
|
+
const literal = normalizeScenarioLiteralText(observed.literal ?? "");
|
|
802
|
+
if (isWhitespaceSemanticLiteral(expected) || isWhitespaceOnlyString(expected)) {
|
|
803
|
+
return observed.kind === "empty-string" ||
|
|
804
|
+
(observed.kind === "string" && isWhitespaceOnlyString(literal))
|
|
805
|
+
? "MATCH"
|
|
806
|
+
: observed.kind === "unknown"
|
|
807
|
+
? "UNDETERMINED"
|
|
808
|
+
: "MISMATCH";
|
|
809
|
+
}
|
|
810
|
+
if (isWhitespacePaddedSemanticLiteral(expected)) {
|
|
811
|
+
// trim/padded requires non-empty content after strip; all-whitespace is empty/ws-only.
|
|
812
|
+
if (isWhitespaceOnlyString(literal) || observed.kind === "empty-string") {
|
|
813
|
+
return "MISMATCH";
|
|
814
|
+
}
|
|
815
|
+
return observed.kind === "string" && isWhitespacePaddedString(literal)
|
|
816
|
+
? "MATCH"
|
|
817
|
+
: observed.kind === "unknown"
|
|
818
|
+
? "UNDETERMINED"
|
|
819
|
+
: "MISMATCH";
|
|
820
|
+
}
|
|
821
|
+
if (isWhitespacePaddedString(expected)) {
|
|
822
|
+
return observed.kind === "string" && literal === expected
|
|
823
|
+
? "MATCH"
|
|
824
|
+
: observed.kind === "unknown"
|
|
825
|
+
? "UNDETERMINED"
|
|
826
|
+
: "MISMATCH";
|
|
827
|
+
}
|
|
828
|
+
return literal === expected
|
|
421
829
|
? "MATCH"
|
|
422
830
|
: observed.kind === "unknown"
|
|
423
831
|
? "UNDETERMINED"
|
|
@@ -483,8 +891,17 @@ function suggestedFixFor(intent, field, bound, example) {
|
|
|
483
891
|
: undefined;
|
|
484
892
|
default:
|
|
485
893
|
if (intent.startsWith("custom-literal:")) {
|
|
894
|
+
const expected = intent.slice("custom-literal:".length);
|
|
895
|
+
if (isWhitespaceSemanticLiteral(expected)) {
|
|
896
|
+
return field ? `set ${field}=" "` : 'set value=" "';
|
|
897
|
+
}
|
|
898
|
+
if (isWhitespacePaddedSemanticLiteral(expected)) {
|
|
899
|
+
return field
|
|
900
|
+
? `set ${field}=" ${field}-value "`
|
|
901
|
+
: 'set value=" value "';
|
|
902
|
+
}
|
|
486
903
|
return field
|
|
487
|
-
? `set ${field}=${JSON.stringify(
|
|
904
|
+
? `set ${field}=${JSON.stringify(expected)}`
|
|
488
905
|
: undefined;
|
|
489
906
|
}
|
|
490
907
|
return undefined;
|
|
@@ -510,9 +927,20 @@ export async function assessBackendScenarioParamConsistency(input) {
|
|
|
510
927
|
const fallbackSourced = inferred.intentSource === "tp-fallback";
|
|
511
928
|
const block = source ? extractPytestParamBlock(source, tpId) : undefined;
|
|
512
929
|
const observed = observeParamFeatures(block, inferred.field);
|
|
513
|
-
|
|
930
|
+
// Request-level / health checks often have no pytest.param payload row.
|
|
931
|
+
// Treat missing param blocks for nominal/unknown intents as MATCH so they
|
|
932
|
+
// do not pollute eligibility with false UNDETERMINED residuals.
|
|
933
|
+
const requestLevelNoParam = Boolean(source) &&
|
|
934
|
+
!block &&
|
|
935
|
+
observed.text === "param-block-missing" &&
|
|
936
|
+
(inferred.intent === "nominal" || inferred.intent === "unknown");
|
|
937
|
+
const status = !source
|
|
514
938
|
? "UNDETERMINED"
|
|
515
|
-
:
|
|
939
|
+
: requestLevelNoParam
|
|
940
|
+
? "MATCH"
|
|
941
|
+
: fallbackSourced
|
|
942
|
+
? "UNDETERMINED"
|
|
943
|
+
: compareIntent(inferred.intent, observed, inferred.bound, inferred.example, inferred.field);
|
|
516
944
|
const repairability = repairabilityFor(status, inferred.intent, observed, inferred.example);
|
|
517
945
|
entries.push({
|
|
518
946
|
caseId: testCase.caseId,
|