@tea-agent/loop-agent 0.33.6-beta.0 → 0.33.7-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 = variantFromLabel.length > 0
103
- ? variantFromLabel
104
- : allPoints.filter((tp) => /(EMPTY|MISSING|NULL|MIN|MAX|INVALID|WRONG|ENUM|PATTERN|NOMINAL|BOUNDARY)/i.test(tp));
104
+ const variantTestPoints = variantLabelIsNone
105
+ ? []
106
+ : variantFromLabel.length > 0
107
+ ? variantFromLabel
108
+ : variantLabelDeclared
109
+ ? []
110
+ : allPoints.filter((tp) => /(EMPTY|MISSING|NULL|MIN|MAX|INVALID|WRONG|ENUM|PATTERN|NOMINAL|BOUNDARY)/i.test(tp));
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,123 @@ 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 preciseLine = /场景意图\s*[::]\s*(TP-[A-Z0-9-]+)\s*[|;,,]\s*operation\s*=\s*([^|;,,\n]+)\s*[|;,,]\s*target\s*=\s*([A-Za-z0-9_.-]+)\s*[|;,,]\s*intent\s*=\s*([a-z0-9+._:-]+)(?:\s*[|;,,]\s*bound\s*=\s*(\d+))?(?:\s*[|;,,]\s*example\s*=\s*([^|\n]+))?/i.exec(caseBody);
121
- const legacyLine = /场景意图\s*[::]\s*([a-z0-9+._:-]+)(?:\s*[|;,,]\s*field\s*=\s*([A-Za-z0-9_.]+))?(?:\s*[|;,,]\s*bound\s*=\s*(\d+))?(?:\s*[|;,,]\s*example\s*=\s*([^|\n]+))?/i.exec(caseBody);
122
- const fallbackLine = new RegExp(`${tpId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[^\\n]{0,160}?intent\\s*[=:]\\s*([a-z0-9+._:-]+)`, "i").exec(caseBody);
123
- const machineLine = preciseLine ?? legacyLine ?? fallbackLine;
124
- if (machineLine) {
125
- const rawIntent = (preciseLine ? preciseLine[4] : machineLine[1] ?? "unknown").toLowerCase();
126
- const target = preciseLine?.[3];
127
- const field = target && target !== "request" ? target.split(".").at(-1) : legacyLine?.[2];
128
- const boundRaw = preciseLine?.[5] ?? legacyLine?.[3];
129
- const example = (preciseLine?.[6] ?? legacyLine?.[4])?.trim();
130
- const bound = boundRaw ? Number(boundRaw) : undefined;
131
- const normalizedIntent = rawIntent === "nominal-operation" ? "nominal" : rawIntent;
132
- if (normalizedIntent.startsWith("custom-literal:")) {
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 fieldScopedBound = field
226
+ ? new RegExp(`${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[^\\n]{0,80}?(?:maxLength|max(?:imum)?|上限|最大长度)\\s*[=::]?\\s*(\\d{1,4})`, "i").exec(`${caseBody}\n${dataSection}`) ??
227
+ new RegExp(`(?:maxLength|max(?:imum)?|上限|最大长度)\\s*[=::]?\\s*(\\d{1,4})[^\\n]{0,80}?${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "i").exec(`${caseBody}\n${dataSection}`)
228
+ : undefined;
229
+ const boundFromBody = fieldScopedBound ??
230
+ /(?:maxLength|max(?:imum)?|上限|最大长度)\s*[=::]?\s*(\d{1,4})/i.exec(`${caseBody}\n${dataSection}`) ??
231
+ /(?:minLength|min(?:imum)?|下限|最小长度)\s*[=::]?\s*(\d{1,4})/i.exec(`${caseBody}\n${dataSection}`);
232
+ const bound = machine.bound && machine.bound > 0
233
+ ? machine.bound
234
+ : boundFromBody
235
+ ? Number(boundFromBody[1])
236
+ : machine.bound;
237
+ if (rawIntent.toLowerCase().startsWith("custom-literal:")) {
238
+ const intent = resolveCustomLiteralIntent(rawIntent, field);
133
239
  return {
134
- intent: normalizedIntent,
240
+ intent,
135
241
  field,
136
242
  bound,
137
243
  example,
@@ -175,7 +281,13 @@ export function inferScenarioParamIntent(input) {
175
281
  ? fieldFromTp.charAt(0).toLowerCase() + fieldFromTp.slice(1)
176
282
  : undefined;
177
283
  const dataSection = sectionBody(caseBody, ["测试数据", "Test Data", "操作步骤", "Steps"]);
178
- const boundMatch = /(?:maxLength|max(?:imum)?|上限|最大长度)\s*[=::]?\s*(\d{1,4})/i.exec(`${caseBody}\n${dataSection}`) ?? /(?:minLength|min(?:imum)?|下限|最小长度)\s*[=::]?\s*(\d{1,4})/i.exec(`${caseBody}\n${dataSection}`);
284
+ const fieldScopedBound = field
285
+ ? new RegExp(`${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[^\\n]{0,80}?(?:maxLength|max(?:imum)?|上限|最大长度)\\s*[=::]?\\s*(\\d{1,4})`, "i").exec(`${caseBody}\n${dataSection}`) ??
286
+ new RegExp(`(?:maxLength|max(?:imum)?|上限|最大长度)\\s*[=::]?\\s*(\\d{1,4})[^\\n]{0,80}?${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "i").exec(`${caseBody}\n${dataSection}`)
287
+ : undefined;
288
+ const boundMatch = fieldScopedBound ??
289
+ /(?:maxLength|max(?:imum)?|上限|最大长度)\s*[=::]?\s*(\d{1,4})/i.exec(`${caseBody}\n${dataSection}`) ??
290
+ /(?:minLength|min(?:imum)?|下限|最小长度)\s*[=::]?\s*(\d{1,4})/i.exec(`${caseBody}\n${dataSection}`);
179
291
  const bound = boundMatch ? Number(boundMatch[1]) : undefined;
180
292
  const exampleMatch = /(?:反例|invalid example|example)\s*[=::]\s*[`"]?([^`"\n]+)[`"]?/i.exec(caseBody);
181
293
  const example = exampleMatch?.[1]?.trim();
@@ -248,18 +360,27 @@ export function observeParamFeatures(block, field) {
248
360
  if (/\bFaker\b|\bfake\b|\brandom\b|\buuid4\b|\bfactory\b/i.test(block)) {
249
361
  return { kind: "call", text: "dynamic-or-faker" };
250
362
  }
251
- // Prefer dict-like payload extraction.
363
+ // Prefer dict-like payload extraction when a real object literal is present.
364
+ // Writers often build the payload in the test body and only pass scalar intent
365
+ // values in pytest.param; in that case fall through to positional observation.
252
366
  const dictMatch = /\{\s*([\s\S]*?)\s*\}/.exec(block) ??
253
367
  /(?:payload|body|data|json)\s*=\s*(\{[\s\S]*?\})/.exec(block);
254
- if (dictMatch && !field) {
255
- return { kind: "dict", text: (dictMatch[0] ?? dictMatch[1] ?? "{}").replace(/\s+/g, " ").slice(0, 160) };
368
+ const dictText = dictMatch?.[0] ?? dictMatch?.[1] ?? "";
369
+ const fieldPattern = field
370
+ ? new RegExp(`["']${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']\\s*:`)
371
+ : undefined;
372
+ const dictHasField = Boolean(fieldPattern && fieldPattern.test(dictText));
373
+ const dictLooksLikePayload = Boolean(dictMatch) &&
374
+ (/["'][A-Za-z_][A-Za-z0-9_]*["']\s*:/.test(dictText) ||
375
+ /(?:payload|body|data|json)\s*=/.test(block));
376
+ if (dictLooksLikePayload && !field) {
377
+ return { kind: "dict", text: dictText.replace(/\s+/g, " ").slice(0, 160) };
256
378
  }
257
- if (dictMatch && field) {
258
- const dict = dictMatch[0] ?? dictMatch[1] ?? "";
259
- const fieldPattern = new RegExp(`["']${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']\\s*:`);
260
- if (!fieldPattern.test(dict)) {
379
+ if (dictLooksLikePayload && field) {
380
+ if (!dictHasField) {
261
381
  return { kind: "missing-key", text: `missing:${field}` };
262
382
  }
383
+ const dict = dictText;
263
384
  const valueMatch = new RegExp(`["']${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']\\s*:\\s*([^,}\\n]+)`).exec(dict);
264
385
  const raw = valueMatch?.[1]?.trim() ?? "";
265
386
  if (raw === "None" || raw === "null") {
@@ -268,6 +389,19 @@ export function observeParamFeatures(block, field) {
268
389
  if (raw === '""' || raw === "''") {
269
390
  return { kind: "empty-string", text: '""', length: 0, literal: "" };
270
391
  }
392
+ const repeatedValue = /^(["'])([^"'\\])\1\s*\*\s*(\d{1,5})$/.exec(raw);
393
+ if (repeatedValue) {
394
+ const ch = repeatedValue[2] ?? "x";
395
+ const length = Number(repeatedValue[3] ?? "0");
396
+ const value = ch.repeat(Math.max(0, length));
397
+ return {
398
+ kind: length === 0 ? "empty-string" : "string",
399
+ text: JSON.stringify(value),
400
+ length,
401
+ hasUppercase: /[A-Z]/.test(value),
402
+ literal: value,
403
+ };
404
+ }
271
405
  const str = /^["']([\s\S]*)["']$/.exec(raw);
272
406
  if (str) {
273
407
  const value = str[1] ?? "";
@@ -296,31 +430,83 @@ export function observeParamFeatures(block, field) {
296
430
  }
297
431
  return { kind: "unknown", text: raw || compact.slice(0, 120) };
298
432
  }
299
- // Positional first arg string/None.
300
- const firstArg = /pytest\.param\s*\(\s*(None|True|False|-?\d+(?:\.\d+)?|"""[\s\S]*?"""|'''[\s\S]*?'''|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')/.exec(block);
301
- if (firstArg) {
302
- const raw = firstArg[1];
303
- if (raw === "None")
304
- return { kind: "none", text: "None", literal: "None" };
305
- if (raw === '""' || raw === "''") {
306
- return { kind: "empty-string", text: '""', length: 0, literal: "" };
433
+ // Repeated string literal: "a" * 100 / 'x'*101
434
+ const repeated = /pytest\.param\s*\(\s*(["'])([^"'\\])\1\s*\*\s*(\d{1,5})/.exec(block) ??
435
+ /(["'])([^"'\\])\1\s*\*\s*(\d{1,5})/.exec(block);
436
+ if (repeated && (!field || !/\{/.test(block))) {
437
+ const ch = repeated[2] ?? "x";
438
+ const length = Number(repeated[3] ?? "0");
439
+ const value = ch.repeat(Math.max(0, length));
440
+ return {
441
+ kind: length === 0 ? "empty-string" : "string",
442
+ text: JSON.stringify(value),
443
+ length,
444
+ hasUppercase: /[A-Z]/.test(value),
445
+ literal: value,
446
+ };
447
+ }
448
+ // Positional args: skip a leading TP-id label when writers emit
449
+ // pytest.param("TP-...", actual_value, id="TP-...").
450
+ const paramOpen = /pytest\.param\s*\(/.exec(block);
451
+ if (paramOpen) {
452
+ const afterOpen = block.slice((paramOpen.index ?? 0) + paramOpen[0].length);
453
+ const positionalLiterals = [];
454
+ const literalRe = /\s*(None|True|False|-?\d+(?:\.\d+)?|"""[\s\S]*?"""|'''[\s\S]*?'''|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')\s*(?:,|$)/g;
455
+ let match;
456
+ while ((match = literalRe.exec(afterOpen)) !== null) {
457
+ // Stop once keyword args begin (id=/marks=) after a comma segment without a pure literal.
458
+ const between = afterOpen.slice(0, match.index);
459
+ if (/(?:^|,)\s*(?:id|marks)\s*=/.test(between + match[0]))
460
+ break;
461
+ positionalLiterals.push(match[1]);
462
+ if (positionalLiterals.length >= 4)
463
+ break;
307
464
  }
308
- const str = /^["']([\s\S]*)["']$/.exec(raw) ?? /^("""|''')([\s\S]*)\1$/.exec(raw);
309
- if (str) {
465
+ const idMatch = /\bid\s*=\s*["'](TP-[A-Z0-9-]+)["']/i.exec(block);
466
+ const idTp = idMatch?.[1]?.toUpperCase();
467
+ const candidates = positionalLiterals.filter((raw) => {
468
+ const str = /^["']([\s\S]*)["']$/.exec(raw) ?? /^("""|''')([\s\S]*)\1$/.exec(raw);
469
+ if (!str)
470
+ return true;
310
471
  const value = (str[2] ?? str[1] ?? "").replace(/^["']|["']$/g, "");
311
- return {
312
- kind: "string",
313
- text: JSON.stringify(value),
314
- length: value.length,
315
- hasUppercase: /[A-Z]/.test(value),
316
- literal: value,
317
- };
472
+ // Drop pure TP label positionals that only echo the param id.
473
+ if (/^TP-[A-Z0-9-]+$/i.test(value) && (!idTp || value.toUpperCase() === idTp)) {
474
+ return false;
475
+ }
476
+ return true;
477
+ });
478
+ const raw = candidates[0] ?? positionalLiterals[0];
479
+ if (raw) {
480
+ if (raw === "None")
481
+ return { kind: "none", text: "None", literal: "None" };
482
+ if (raw === '""' || raw === "''") {
483
+ return { kind: "empty-string", text: '""', length: 0, literal: "" };
484
+ }
485
+ const str = /^["']([\s\S]*)["']$/.exec(raw) ?? /^("""|''')([\s\S]*)\1$/.exec(raw);
486
+ if (str) {
487
+ const value = (str[2] ?? str[1] ?? "").replace(/^["']|["']$/g, "");
488
+ return {
489
+ kind: value.length === 0
490
+ ? "empty-string"
491
+ : "string",
492
+ text: JSON.stringify(value),
493
+ length: value.length,
494
+ hasUppercase: /[A-Z]/.test(value),
495
+ literal: value,
496
+ };
497
+ }
498
+ if (/^-?\d+(?:\.\d+)?$/.test(raw)) {
499
+ return { kind: "number", text: raw, literal: raw };
500
+ }
501
+ if (raw === "True" || raw === "False") {
502
+ return { kind: "boolean", text: raw, literal: raw };
503
+ }
504
+ return { kind: "unknown", text: raw };
318
505
  }
319
- return { kind: "unknown", text: raw };
320
506
  }
321
507
  return { kind: "unknown", text: compact.slice(0, 160) };
322
508
  }
323
- function compareIntent(intent, observed, bound, example) {
509
+ function compareIntent(intent, observed, bound, example, field) {
324
510
  if (intent === "unknown")
325
511
  return "UNDETERMINED";
326
512
  if (observed.kind === "call" || observed.kind === "name") {
@@ -329,7 +515,10 @@ function compareIntent(intent, observed, bound, example) {
329
515
  switch (intent) {
330
516
  case "empty":
331
517
  return observed.kind === "empty-string" ||
332
- (observed.kind === "string" && observed.length === 0)
518
+ (observed.kind === "string" &&
519
+ (observed.length === 0 ||
520
+ (typeof observed.literal === "string" &&
521
+ /^\s+$/.test(observed.literal))))
333
522
  ? "MATCH"
334
523
  : observed.kind === "unknown"
335
524
  ? "UNDETERMINED"
@@ -347,45 +536,39 @@ function compareIntent(intent, observed, bound, example) {
347
536
  ? "UNDETERMINED"
348
537
  : "MISMATCH";
349
538
  case "max":
350
- return bound !== undefined &&
351
- observed.kind === "string" &&
352
- observed.length === bound
353
- ? "MATCH"
354
- : bound === undefined
355
- ? "UNDETERMINED"
356
- : observed.kind === "string"
357
- ? "MISMATCH"
358
- : "UNDETERMINED";
539
+ if (bound === undefined)
540
+ return "UNDETERMINED";
541
+ if (observed.kind === "string" && observed.length === bound)
542
+ return "MATCH";
543
+ // Writers may pass the exact length as a numeric positional instead of a repeated string.
544
+ if (observed.kind === "number" && Number(observed.literal) === bound)
545
+ return "MATCH";
546
+ return observed.kind === "string" || observed.kind === "number" ? "MISMATCH" : "UNDETERMINED";
359
547
  case "max+1":
360
- return bound !== undefined &&
361
- observed.kind === "string" &&
362
- observed.length === bound + 1
363
- ? "MATCH"
364
- : bound === undefined
365
- ? "UNDETERMINED"
366
- : observed.kind === "string"
367
- ? "MISMATCH"
368
- : "UNDETERMINED";
548
+ if (bound === undefined)
549
+ return "UNDETERMINED";
550
+ if (observed.kind === "string" && observed.length === bound + 1)
551
+ return "MATCH";
552
+ if (observed.kind === "number" && Number(observed.literal) === bound + 1)
553
+ return "MATCH";
554
+ return observed.kind === "string" || observed.kind === "number" ? "MISMATCH" : "UNDETERMINED";
369
555
  case "min":
370
- return bound !== undefined &&
371
- observed.kind === "string" &&
372
- observed.length === bound
373
- ? "MATCH"
374
- : bound === undefined
375
- ? "UNDETERMINED"
376
- : observed.kind === "string"
377
- ? "MISMATCH"
378
- : "UNDETERMINED";
556
+ if (bound === undefined)
557
+ return "UNDETERMINED";
558
+ if (observed.kind === "string" && observed.length === bound)
559
+ return "MATCH";
560
+ if (observed.kind === "number" && Number(observed.literal) === bound)
561
+ return "MATCH";
562
+ return observed.kind === "string" || observed.kind === "number" ? "MISMATCH" : "UNDETERMINED";
379
563
  case "min-1":
380
- return bound !== undefined &&
381
- observed.kind === "string" &&
382
- observed.length === Math.max(0, bound - 1)
383
- ? "MATCH"
384
- : bound === undefined
385
- ? "UNDETERMINED"
386
- : observed.kind === "string"
387
- ? "MISMATCH"
388
- : "UNDETERMINED";
564
+ if (bound === undefined)
565
+ return "UNDETERMINED";
566
+ const expectedMin1 = Math.max(0, bound - 1);
567
+ if (observed.kind === "string" && observed.length === expectedMin1)
568
+ return "MATCH";
569
+ if (observed.kind === "number" && Number(observed.literal) === expectedMin1)
570
+ return "MATCH";
571
+ return observed.kind === "string" || observed.kind === "number" ? "MISMATCH" : "UNDETERMINED";
389
572
  case "pattern-invalid":
390
573
  if (observed.kind === "string" && example !== undefined) {
391
574
  return observed.literal === example ? "MATCH" : "MISMATCH";
@@ -405,6 +588,10 @@ function compareIntent(intent, observed, bound, example) {
405
588
  ? "UNDETERMINED"
406
589
  : "UNDETERMINED";
407
590
  case "nominal":
591
+ // Request-level nominal (no body field) may legitimately pass None/empty query filters.
592
+ if (!field && (observed.kind === "none" || observed.kind === "missing-key")) {
593
+ return "MATCH";
594
+ }
408
595
  return observed.kind === "string" ||
409
596
  observed.kind === "number" ||
410
597
  observed.kind === "boolean" ||
@@ -512,7 +699,7 @@ export async function assessBackendScenarioParamConsistency(input) {
512
699
  const observed = observeParamFeatures(block, inferred.field);
513
700
  const status = !source || fallbackSourced
514
701
  ? "UNDETERMINED"
515
- : compareIntent(inferred.intent, observed, inferred.bound, inferred.example);
702
+ : compareIntent(inferred.intent, observed, inferred.bound, inferred.example, inferred.field);
516
703
  const repairability = repairabilityFor(status, inferred.intent, observed, inferred.example);
517
704
  entries.push({
518
705
  caseId: testCase.caseId,