@tea-agent/loop-agent 0.33.7-beta.0 → 0.34.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.
Files changed (83) hide show
  1. package/CHANGELOG.md +38 -17
  2. package/dist/application/task-lifecycle/advance.js +254 -4
  3. package/dist/application/task-lifecycle/gates.js +50 -0
  4. package/dist/application/task-lifecycle/observe.js +11 -2
  5. package/dist/commands/init-upgrade.js +32 -1
  6. package/dist/commands/init.js +94 -3
  7. package/dist/executors/shell-executor.js +4 -91
  8. package/dist/executors/shell-write-guard.js +26 -8
  9. package/dist/shared/operator/capabilities.js +72 -42
  10. package/dist/shared/resilient-git.js +133 -0
  11. package/dist/task/source-prepare/artifact-meta.js +137 -0
  12. package/dist/task/source-prepare/index.js +2 -0
  13. package/dist/task/source-prepare/parse-intent.js +58 -10
  14. package/dist/task/source-prepare/prepare.js +180 -16
  15. package/dist/task/source-prepare/reference-integrity.js +18 -2
  16. package/dist/task/source-prepare/semantic-intake.js +404 -0
  17. package/dist/worker/console/app-data.js +2 -0
  18. package/dist/worker/console/chat/chat-event-store.js +190 -25
  19. package/dist/worker/console/chat/model-resolver.js +17 -0
  20. package/dist/worker/console/chat/pi-console-config.js +250 -32
  21. package/dist/worker/console/chat/pi-runtime.js +1007 -188
  22. package/dist/worker/console/chat/resource-loader.js +5 -4
  23. package/dist/worker/console/chat/routes.js +495 -157
  24. package/dist/worker/console/chat/runtime-context.js +48 -12
  25. package/dist/worker/console/chat/runtime-selection.js +59 -0
  26. package/dist/worker/console/chat/session-store.js +39 -0
  27. package/dist/worker/console/chat/shortcuts.js +1 -0
  28. package/dist/worker/console/chat/tool-adapter.js +9 -3
  29. package/dist/worker/console/chat/tools.js +5 -1
  30. package/dist/worker/console/dag-execution-receipt.js +380 -0
  31. package/dist/worker/console/operator-actions.js +559 -68
  32. package/dist/worker/console/server.js +8 -15
  33. package/dist/worker/console/static/assets/index-BQkhJpV8.css +1 -0
  34. package/dist/worker/console/static/assets/index-CMHovlqG.js +32 -0
  35. package/dist/worker/console/static/index.html +2 -2
  36. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +45 -8
  37. package/dist/worker/console/static-src/operator-chat/landing-density.js +23 -0
  38. package/dist/worker/console/static-src/operator-chat/refs.js +9 -0
  39. package/dist/worker/console/static-src/operator-chat/runtime-snapshot-store.js +257 -0
  40. package/dist/worker/console/static-src/operator-chat/session-title-watcher.js +128 -0
  41. package/dist/worker/console/static-src/operator-chat/sidebar-split.js +90 -0
  42. package/dist/worker/console/static-src/operator-chat/spatial-overlay.js +37 -0
  43. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +125 -22
  44. package/dist/worker/console/static-src/operator-chat/useChatStream.js +215 -184
  45. package/dist/worker/console/static-src/operator-chat/useChatThread.js +49 -5
  46. package/dist/worker/console/static-src/operator-chat/useComposer.js +17 -0
  47. package/dist/worker/console/static-src/operator-chat/useOverlayFocus.js +84 -0
  48. package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +225 -74
  49. package/dist/worker/console/static-src/operator-chat/useRuntimeSnapshot.js +196 -0
  50. package/dist/worker/console/static-src/operator-chat/useWorkspaceLayout.js +58 -0
  51. package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +31 -0
  52. package/dist/worker/delivery/final-verification.js +13 -5
  53. package/dist/worker/delivery/package.js +31 -19
  54. package/dist/worker/delivery/verification-bundle.js +6 -4
  55. package/dist/worker/observe/static/operator-chrome.css +5 -2
  56. package/dist/worker/observe/static/operator-chrome.js +6 -1
  57. package/dist/worker/observe/static/styles.css +39 -9
  58. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +33 -462
  59. package/dist/workflows/dag/backend-test-case-manifest.js +0 -4
  60. package/dist/workflows/dag/backend-test-markdown-workflow.js +1 -25
  61. package/dist/workflows/dag/backend-test-module-stem.js +0 -5
  62. package/dist/workflows/dag/backend-test-pytest-collection.js +24 -345
  63. package/dist/workflows/dag/backend-test-scenario-param.js +82 -269
  64. package/dist/workflows/dag/backend-test-writer-completeness.js +16 -47
  65. package/dist/workflows/dag/dynamic-runtime/map.js +8 -24
  66. package/dist/workflows/dag/frontend-worktree-diff.js +12 -27
  67. package/dist/workflows/dag/init-hybrid.js +34 -46
  68. package/dist/workflows/dag/types.js +0 -7
  69. package/dist/workflows/dag/workspace-checkpoint.js +8 -27
  70. package/docs/templates/backend-test-dag.json +29 -32
  71. package/harness.json +1 -1
  72. package/package.json +1 -1
  73. package/skills/local-jacoco-coverage/SKILL.md +281 -0
  74. package/skills/local-jacoco-coverage/references/requirement-to-source-mapping.md +85 -0
  75. package/skills/local-jacoco-coverage/references/runtime-alignment.md +106 -0
  76. package/skills/local-jacoco-coverage/scripts/run-coverage-analysis.sh +148 -0
  77. package/skills/local-jacoco-coverage/scripts/start-jacoco-agent.sh +110 -0
  78. package/skills/loop-agent/references/command-reference.md +3 -1
  79. package/skills/loop-agent/references/source-and-plan-practice.md +13 -0
  80. package/skills/loop-agent/references/task-workflow.md +4 -0
  81. package/dist/worker/console/chat/instruction-skills.js +0 -217
  82. package/dist/worker/console/static/assets/index-CnUXAqxG.css +0 -1
  83. package/dist/worker/console/static/assets/index-CteJFFL2.js +0 -29
@@ -98,16 +98,10 @@ 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());
103
101
  const variantFromLabel = orderedUnique((variantLabel.match(/\bTP-[A-Z0-9-]+\b/g) ?? []).filter((item) => item.toUpperCase() !== "NONE"));
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));
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));
111
105
  const scriptMatch = body.match(/`?(testcase\/[A-Za-z0-9_./-]*test_[A-Za-z0-9_.-]*\.py)`?/i);
112
106
  cases.push({
113
107
  caseId,
@@ -121,123 +115,23 @@ async function listMarkdownCases(workspaceRoot) {
121
115
  }
122
116
  return cases;
123
117
  }
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
- }
198
118
  export function inferScenarioParamIntent(input) {
199
119
  const { tpId, caseBody } = input;
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);
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:")) {
239
133
  return {
240
- intent,
134
+ intent: normalizedIntent,
241
135
  field,
242
136
  bound,
243
137
  example,
@@ -281,13 +175,7 @@ export function inferScenarioParamIntent(input) {
281
175
  ? fieldFromTp.charAt(0).toLowerCase() + fieldFromTp.slice(1)
282
176
  : undefined;
283
177
  const dataSection = sectionBody(caseBody, ["测试数据", "Test Data", "操作步骤", "Steps"]);
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}`);
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}`);
291
179
  const bound = boundMatch ? Number(boundMatch[1]) : undefined;
292
180
  const exampleMatch = /(?:反例|invalid example|example)\s*[=::]\s*[`"]?([^`"\n]+)[`"]?/i.exec(caseBody);
293
181
  const example = exampleMatch?.[1]?.trim();
@@ -360,27 +248,18 @@ export function observeParamFeatures(block, field) {
360
248
  if (/\bFaker\b|\bfake\b|\brandom\b|\buuid4\b|\bfactory\b/i.test(block)) {
361
249
  return { kind: "call", text: "dynamic-or-faker" };
362
250
  }
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.
251
+ // Prefer dict-like payload extraction.
366
252
  const dictMatch = /\{\s*([\s\S]*?)\s*\}/.exec(block) ??
367
253
  /(?:payload|body|data|json)\s*=\s*(\{[\s\S]*?\})/.exec(block);
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) };
254
+ if (dictMatch && !field) {
255
+ return { kind: "dict", text: (dictMatch[0] ?? dictMatch[1] ?? "{}").replace(/\s+/g, " ").slice(0, 160) };
378
256
  }
379
- if (dictLooksLikePayload && field) {
380
- if (!dictHasField) {
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)) {
381
261
  return { kind: "missing-key", text: `missing:${field}` };
382
262
  }
383
- const dict = dictText;
384
263
  const valueMatch = new RegExp(`["']${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']\\s*:\\s*([^,}\\n]+)`).exec(dict);
385
264
  const raw = valueMatch?.[1]?.trim() ?? "";
386
265
  if (raw === "None" || raw === "null") {
@@ -389,19 +268,6 @@ export function observeParamFeatures(block, field) {
389
268
  if (raw === '""' || raw === "''") {
390
269
  return { kind: "empty-string", text: '""', length: 0, literal: "" };
391
270
  }
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
- }
405
271
  const str = /^["']([\s\S]*)["']$/.exec(raw);
406
272
  if (str) {
407
273
  const value = str[1] ?? "";
@@ -430,83 +296,31 @@ export function observeParamFeatures(block, field) {
430
296
  }
431
297
  return { kind: "unknown", text: raw || compact.slice(0, 120) };
432
298
  }
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;
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: "" };
464
307
  }
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;
308
+ const str = /^["']([\s\S]*)["']$/.exec(raw) ?? /^("""|''')([\s\S]*)\1$/.exec(raw);
309
+ if (str) {
471
310
  const value = (str[2] ?? str[1] ?? "").replace(/^["']|["']$/g, "");
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 };
311
+ return {
312
+ kind: "string",
313
+ text: JSON.stringify(value),
314
+ length: value.length,
315
+ hasUppercase: /[A-Z]/.test(value),
316
+ literal: value,
317
+ };
505
318
  }
319
+ return { kind: "unknown", text: raw };
506
320
  }
507
321
  return { kind: "unknown", text: compact.slice(0, 160) };
508
322
  }
509
- function compareIntent(intent, observed, bound, example, field) {
323
+ function compareIntent(intent, observed, bound, example) {
510
324
  if (intent === "unknown")
511
325
  return "UNDETERMINED";
512
326
  if (observed.kind === "call" || observed.kind === "name") {
@@ -515,10 +329,7 @@ function compareIntent(intent, observed, bound, example, field) {
515
329
  switch (intent) {
516
330
  case "empty":
517
331
  return observed.kind === "empty-string" ||
518
- (observed.kind === "string" &&
519
- (observed.length === 0 ||
520
- (typeof observed.literal === "string" &&
521
- /^\s+$/.test(observed.literal))))
332
+ (observed.kind === "string" && observed.length === 0)
522
333
  ? "MATCH"
523
334
  : observed.kind === "unknown"
524
335
  ? "UNDETERMINED"
@@ -536,39 +347,45 @@ function compareIntent(intent, observed, bound, example, field) {
536
347
  ? "UNDETERMINED"
537
348
  : "MISMATCH";
538
349
  case "max":
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";
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";
547
359
  case "max+1":
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";
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";
555
369
  case "min":
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";
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";
563
379
  case "min-1":
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";
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";
572
389
  case "pattern-invalid":
573
390
  if (observed.kind === "string" && example !== undefined) {
574
391
  return observed.literal === example ? "MATCH" : "MISMATCH";
@@ -588,10 +405,6 @@ function compareIntent(intent, observed, bound, example, field) {
588
405
  ? "UNDETERMINED"
589
406
  : "UNDETERMINED";
590
407
  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
- }
595
408
  return observed.kind === "string" ||
596
409
  observed.kind === "number" ||
597
410
  observed.kind === "boolean" ||
@@ -699,7 +512,7 @@ export async function assessBackendScenarioParamConsistency(input) {
699
512
  const observed = observeParamFeatures(block, inferred.field);
700
513
  const status = !source || fallbackSourced
701
514
  ? "UNDETERMINED"
702
- : compareIntent(inferred.intent, observed, inferred.bound, inferred.example, inferred.field);
515
+ : compareIntent(inferred.intent, observed, inferred.bound, inferred.example);
703
516
  const repairability = repairabilityFor(status, inferred.intent, observed, inferred.example);
704
517
  entries.push({
705
518
  caseId: testCase.caseId,
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { z } from "zod";
5
- import { BACKEND_TEST_MAX_MODULE_COUNT, isOpaqueHashBackendTestModuleStem, isPriorityOnlyBackendTestModuleStem, } from "./backend-test-module-stem.js";
5
+ import { isPriorityOnlyBackendTestModuleStem } from "./backend-test-module-stem.js";
6
6
  /**
7
7
  * Maps a backend-test generation writer task id to its writer-progress role.
8
8
  * Returns undefined for nodes that are not completeness-gated generators.
@@ -103,8 +103,6 @@ function moduleStemInvalidReason(raw) {
103
103
  const stem = normalizeBackendTestModuleStem(raw);
104
104
  if (isPriorityOnlyBackendTestModuleStem(stem))
105
105
  return "priority-only-module-stem";
106
- if (isOpaqueHashBackendTestModuleStem(stem))
107
- return "opaque-hash-module-stem";
108
106
  if (stem.toLowerCase() === "readme")
109
107
  return "reserved-module-stem";
110
108
  if (!/^[a-z][a-z0-9_]*$/.test(stem))
@@ -116,40 +114,28 @@ function moduleStemInvalidReason(raw) {
116
114
  function looksLikeValidModuleStem(raw) {
117
115
  return moduleStemInvalidReason(raw) === undefined;
118
116
  }
119
- function extractCanonicalModuleIndexSection(readme) {
120
- const lines = readme
121
- .replaceAll("\r\n", "\n")
122
- .replaceAll("\r", "\n")
123
- .split("\n");
124
- const headings = lines.flatMap((line, index) => line.trim() === "## Module Index" ? [index] : []);
125
- if (headings.length === 0) {
126
- return { section: "", structureIssues: ["missing-module-index"] };
127
- }
128
- if (headings.length > 1) {
129
- return { section: "", structureIssues: ["duplicate-module-index"] };
130
- }
131
- const start = headings[0] + 1;
132
- const relativeEnd = lines.slice(start).findIndex((line) => /^##\s+\S/.test(line.trim()));
133
- const end = relativeEnd < 0 ? lines.length : start + relativeEnd;
134
- return { section: lines.slice(start, end).join("\n"), structureIssues: [] };
135
- }
136
- /** Inspect only candidates inside the unique exact `## Module Index` section. */
117
+ /** Inspect only authoritative README module-index candidates. */
137
118
  export function inspectModuleStemsFromReadme(readme) {
138
- const extracted = extractCanonicalModuleIndexSection(readme);
139
119
  const candidates = [];
140
- const tableRowLines = extracted.section.split("\n").filter((line) => line.includes("|"));
120
+ const tableRowLines = readme
121
+ .replaceAll("\r\n", "\n")
122
+ .replaceAll("\r", "\n")
123
+ .split("\n")
124
+ .filter((line) => line.includes("|"));
141
125
  for (const line of tableRowLines) {
142
126
  for (const match of line.matchAll(/`?testcase\/md\/([A-Za-z0-9_.-]+)\.md`?/g)) {
143
127
  candidates.push(match[1]);
144
128
  }
145
129
  for (const match of line.matchAll(/\|\s*`?([A-Za-z0-9_.-]+)`?\s*\|\s*`?testcase\/test_/g)) {
146
- const raw = match[1];
147
- if (looksLikeValidModuleStem(raw) || moduleStemInvalidReason(raw) !== "invalid-syntax") {
148
- candidates.push(raw);
130
+ // This compatibility shape can also capture a numeric count column.
131
+ // Keep valid stems and the priority-only defect we diagnose, while
132
+ // ignoring non-stem incidental cells such as `1`.
133
+ if (looksLikeValidModuleStem(match[1]) || isPriorityOnlyBackendTestModuleStem(match[1])) {
134
+ candidates.push(match[1]);
149
135
  }
150
136
  }
151
137
  }
152
- for (const match of extracted.section.matchAll(/\[[^\]]+\]\(\.\/([A-Za-z0-9_.-]+)\.md\)/g)) {
138
+ for (const match of readme.matchAll(/\[[^\]]+\]\(\.\/([A-Za-z0-9_.-]+)\.md\)/g)) {
153
139
  candidates.push(match[1]);
154
140
  }
155
141
  const uniqueCandidates = orderedUnique(candidates);
@@ -159,18 +145,12 @@ export function inspectModuleStemsFromReadme(readme) {
159
145
  ? [{ raw, normalized: normalizeBackendTestModuleStem(raw), reasonCode }]
160
146
  : [];
161
147
  });
162
- const validStems = orderedUnique(uniqueCandidates
163
- .filter(looksLikeValidModuleStem)
164
- .map((stem) => normalizeBackendTestModuleStem(stem)));
165
- const structureIssues = [...extracted.structureIssues];
166
- if (validStems.length > BACKEND_TEST_MAX_MODULE_COUNT) {
167
- structureIssues.push(`excessive-module-count: ${validStems.length} > ${BACKEND_TEST_MAX_MODULE_COUNT}`);
168
- }
169
148
  return {
170
149
  candidates: uniqueCandidates,
171
- validStems,
150
+ validStems: orderedUnique(uniqueCandidates
151
+ .filter(looksLikeValidModuleStem)
152
+ .map((stem) => normalizeBackendTestModuleStem(stem))),
172
153
  invalid,
173
- structureIssues,
174
154
  };
175
155
  }
176
156
  /**
@@ -937,17 +917,6 @@ export async function assessBackendTestMdPlanCompleteness(workspaceRoot) {
937
917
  // are produced by map_agent children and are NOT required here.
938
918
  const stemInspection = inspectModuleStemsFromReadme(readme);
939
919
  const indexedStems = stemInspection.validStems;
940
- for (const structureIssue of stemInspection.structureIssues) {
941
- if (!brokenPaths.includes("testcase/md/README.md")) {
942
- brokenPaths.push("testcase/md/README.md");
943
- }
944
- issues.push({
945
- code: "T5",
946
- path: "testcase/md/README.md",
947
- detail: structureIssue,
948
- recoverable: true,
949
- });
950
- }
951
920
  for (const invalid of stemInspection.invalid) {
952
921
  if (!brokenPaths.includes("testcase/md/README.md")) {
953
922
  brokenPaths.push("testcase/md/README.md");
@@ -367,26 +367,19 @@ export async function executeDynamicMapExpansion(input) {
367
367
  }
368
368
  }
369
369
  const budgetBlockedIds = new Set(blockedChildren.map((entry) => entry.nodeId));
370
- const failOnTokenBudgetExhaustion = input.expansion.failOnTokenBudgetExhaustion === true;
371
370
  const failedChildren = tolerateChildFailures
372
371
  ? []
373
372
  : childNodeIds.filter((nodeId) => {
374
373
  const record = input.state.nodes[nodeId];
375
374
  if (!record || record.status === "FINISHED")
376
375
  return false;
377
- if (budgetBlockedIds.has(nodeId) || isBudgetBlockedRecord(record)) {
378
- return failOnTokenBudgetExhaustion;
379
- }
376
+ if (budgetBlockedIds.has(nodeId) || isBudgetBlockedRecord(record))
377
+ return false;
380
378
  return true;
381
379
  });
382
- const finishedItemCount = childNodeIds.filter((nodeId) => input.state.nodes[nodeId]?.status === "FINISHED").length;
383
- const blockedItemCount = budgetBlockedIds.size;
384
380
  const aggregate = {
385
381
  workflowNodeId: input.expansion.workflowNodeId,
386
382
  itemCount: items.length,
387
- expectedItemCount: items.length,
388
- finishedItemCount,
389
- blockedItemCount,
390
383
  children: childNodeIds.map((nodeId, index) => ({
391
384
  nodeId,
392
385
  item: items[index],
@@ -406,30 +399,21 @@ export async function executeDynamicMapExpansion(input) {
406
399
  ?.reason,
407
400
  })),
408
401
  tokensUsed: totalTokensUsed,
409
- maxTotalTokens: tokenBudget?.maxTotalTokens,
410
402
  caseOutcomeNotes: caseOutcomeNotes.length > 0 ? caseOutcomeNotes : undefined,
411
403
  };
412
- const tokenBudgetFailure = failOnTokenBudgetExhaustion && blockedItemCount > 0;
413
404
  return {
414
- // With tolerateChildFailures: barrier succeeds and product outcomes live in
415
- // case evidence. Backend-test opts into token-budget fail-closed because a
416
- // partial generated module set is never a valid downstream input.
417
- ok: failedChildren.length === 0 && !tokenBudgetFailure,
405
+ // With tolerateChildFailures: barrier always succeeds; product outcomes live in
406
+ // case evidence + result materialization. Without it: real child failures fail-close.
407
+ ok: failedChildren.length === 0,
418
408
  stdout: JSON.stringify(aggregate),
419
409
  stderr: caseOutcomeNotes.length > 0
420
410
  ? `map children recorded as case outcomes: ${caseOutcomeNotes
421
411
  .map((entry) => `${entry.nodeId}=${entry.status}:${entry.reason}`)
422
412
  .join(", ")}`
423
- : tokenBudgetFailure
424
- ? `dynamic map token budget exhausted: expected=${items.length} finished=${finishedItemCount} blocked=${blockedItemCount} tokensUsed=${totalTokensUsed} maxTotalTokens=${tokenBudget?.maxTotalTokens ?? "unbounded"}`
425
- : failedChildren.length > 0
426
- ? `dynamic map children failed: ${failedChildren.join(", ")}`
427
- : "",
428
- failureCategory: tokenBudgetFailure
429
- ? "dynamic-expansion-token-budget-exhausted"
430
413
  : failedChildren.length > 0
431
- ? "dynamic-expansion-child-failed"
432
- : "success",
414
+ ? `dynamic map children failed: ${failedChildren.join(", ")}`
415
+ : "",
416
+ failureCategory: failedChildren.length > 0 ? "dynamic-expansion-child-failed" : "success",
433
417
  durationMs: Date.now() - started,
434
418
  };
435
419
  }