@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.
Files changed (69) hide show
  1. package/CHANGELOG.md +39 -23
  2. package/dist/application/task-lifecycle/advance.js +4 -254
  3. package/dist/application/task-lifecycle/gates.js +0 -50
  4. package/dist/application/task-lifecycle/observe.js +2 -11
  5. package/dist/commands/init-upgrade.js +1 -32
  6. package/dist/commands/init.js +3 -94
  7. package/dist/executors/shell-executor.js +91 -4
  8. package/dist/executors/shell-write-guard.js +8 -26
  9. package/dist/shared/operator/capabilities.js +42 -72
  10. package/dist/task/source-prepare/index.js +0 -2
  11. package/dist/task/source-prepare/parse-intent.js +10 -58
  12. package/dist/task/source-prepare/prepare.js +16 -180
  13. package/dist/task/source-prepare/reference-integrity.js +2 -18
  14. package/dist/worker/console/app-data.js +0 -2
  15. package/dist/worker/console/chat/chat-event-store.js +25 -190
  16. package/dist/worker/console/chat/instruction-skills.js +217 -0
  17. package/dist/worker/console/chat/pi-console-config.js +32 -250
  18. package/dist/worker/console/chat/pi-runtime.js +71 -625
  19. package/dist/worker/console/chat/resource-loader.js +4 -5
  20. package/dist/worker/console/chat/routes.js +146 -324
  21. package/dist/worker/console/chat/runtime-context.js +12 -48
  22. package/dist/worker/console/chat/runtime-selection.js +0 -59
  23. package/dist/worker/console/chat/shortcuts.js +0 -1
  24. package/dist/worker/console/chat/tool-adapter.js +3 -9
  25. package/dist/worker/console/chat/tools.js +1 -5
  26. package/dist/worker/console/operator-actions.js +68 -559
  27. package/dist/worker/console/server.js +15 -8
  28. package/dist/worker/console/static/assets/index-CnUXAqxG.css +1 -0
  29. package/dist/worker/console/static/assets/index-CteJFFL2.js +29 -0
  30. package/dist/worker/console/static/index.html +2 -2
  31. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +8 -45
  32. package/dist/worker/console/static-src/operator-chat/refs.js +0 -9
  33. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +0 -16
  34. package/dist/worker/console/static-src/operator-chat/useChatStream.js +184 -210
  35. package/dist/worker/console/static-src/operator-chat/useChatThread.js +5 -49
  36. package/dist/worker/console/static-src/operator-chat/useComposer.js +0 -17
  37. package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +74 -225
  38. package/dist/worker/delivery/final-verification.js +5 -13
  39. package/dist/worker/delivery/package.js +19 -31
  40. package/dist/worker/delivery/verification-bundle.js +4 -6
  41. package/dist/worker/observe/static/operator-chrome.css +2 -5
  42. package/dist/worker/observe/static/operator-chrome.js +1 -6
  43. package/dist/worker/observe/static/styles.css +9 -39
  44. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +462 -33
  45. package/dist/workflows/dag/backend-test-case-manifest.js +4 -0
  46. package/dist/workflows/dag/backend-test-markdown-workflow.js +25 -1
  47. package/dist/workflows/dag/backend-test-module-stem.js +5 -0
  48. package/dist/workflows/dag/backend-test-pytest-collection.js +345 -24
  49. package/dist/workflows/dag/backend-test-scenario-param.js +552 -124
  50. package/dist/workflows/dag/backend-test-writer-completeness.js +47 -16
  51. package/dist/workflows/dag/dynamic-runtime/map.js +24 -8
  52. package/dist/workflows/dag/frontend-worktree-diff.js +27 -12
  53. package/dist/workflows/dag/init-hybrid.js +49 -37
  54. package/dist/workflows/dag/types.js +7 -0
  55. package/dist/workflows/dag/workspace-checkpoint.js +27 -8
  56. package/docs/templates/backend-test-dag.json +33 -30
  57. package/harness.json +1 -1
  58. package/package.json +1 -1
  59. package/skills/loop-agent/references/command-reference.md +1 -3
  60. package/skills/loop-agent/references/source-and-plan-practice.md +0 -13
  61. package/skills/loop-agent/references/task-workflow.md +0 -4
  62. package/dist/shared/resilient-git.js +0 -133
  63. package/dist/task/source-prepare/artifact-meta.js +0 -137
  64. package/dist/task/source-prepare/semantic-intake.js +0 -404
  65. package/dist/worker/console/dag-execution-receipt.js +0 -380
  66. package/dist/worker/console/static/assets/index-BUOLppPr.js +0 -28
  67. package/dist/worker/console/static/assets/index-C1KzazY5.css +0 -1
  68. package/dist/worker/console/static-src/operator-chat/runtime-snapshot-store.js +0 -257
  69. 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 = 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,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 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 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: normalizedIntent,
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 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}`);
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
- if (dictMatch && !field) {
255
- return { kind: "dict", text: (dictMatch[0] ?? dictMatch[1] ?? "{}").replace(/\s+/g, " ").slice(0, 160) };
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 (dictMatch && field) {
258
- const dict = dictMatch[0] ?? dictMatch[1] ?? "";
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*([^,}\\n]+)`).exec(dict);
564
+ const valueMatch = new RegExp(`["']${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']\\s*:\\s*([^,}\n]+)`).exec(dictText);
264
565
  const raw = valueMatch?.[1]?.trim() ?? "";
265
- if (raw === "None" || raw === "null") {
266
- return { kind: "none", text: "None", literal: "None" };
267
- }
268
- if (raw === '""' || raw === "''") {
269
- return { kind: "empty-string", text: '""', length: 0, literal: "" };
270
- }
271
- const str = /^["']([\s\S]*)["']$/.exec(raw);
272
- if (str) {
273
- const value = str[1] ?? "";
274
- return {
275
- kind: "string",
276
- text: JSON.stringify(value),
277
- length: value.length,
278
- hasUppercase: /[A-Z]/.test(value),
279
- literal: value,
280
- };
281
- }
282
- if (/^\d+(?:\.\d+)?$/.test(raw)) {
283
- return { kind: "number", text: raw, literal: raw };
284
- }
285
- if (raw === "True" || raw === "False") {
286
- return { kind: "boolean", text: raw, literal: raw };
287
- }
288
- if (raw.startsWith("["))
289
- return { kind: "list", text: raw };
290
- if (raw.startsWith("{"))
291
- return { kind: "dict", text: raw };
292
- if (/\(/.test(raw))
293
- return { kind: "call", text: raw };
294
- if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(raw)) {
295
- return { kind: "name", text: raw };
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
- return { kind: "unknown", text: raw || compact.slice(0, 120) };
298
- }
299
- // Positional first arg string/None.
300
- const firstArg = /pytest\.param\s*\(\s*(None|True|False|-?\d+(?:\.\d+)?|"""[\s\S]*?"""|'''[\s\S]*?'''|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')/.exec(block);
301
- if (firstArg) {
302
- const raw = firstArg[1];
303
- if (raw === "None")
304
- return { kind: "none", text: "None", literal: "None" };
305
- if (raw === '""' || raw === "''") {
306
- return { kind: "empty-string", text: '""', length: 0, literal: "" };
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
- const str = /^["']([\s\S]*)["']$/.exec(raw) ?? /^("""|''')([\s\S]*)\1$/.exec(raw);
309
- if (str) {
310
- const value = (str[2] ?? str[1] ?? "").replace(/^["']|["']$/g, "");
311
- return {
312
- kind: "string",
313
- text: JSON.stringify(value),
314
- length: value.length,
315
- hasUppercase: /[A-Z]/.test(value),
316
- literal: value,
317
- };
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 compareIntent(intent, observed, bound, example) {
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
- if (observed.kind === "call" || observed.kind === "name") {
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" && observed.length === 0)
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
- 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";
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
- 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";
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
- 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";
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
- 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";
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
- return observed.kind === "string" || observed.kind === "number"
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
- : "UNDETERMINED";
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 expected = intent.slice("custom-literal:".length);
420
- return observed.literal === expected
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(intent.slice("custom-literal:".length))}`
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
- const status = !source || fallbackSourced
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
- : compareIntent(inferred.intent, observed, inferred.bound, inferred.example);
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,