@tea-agent/loop-agent 0.28.13 → 0.29.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 (33) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +44 -15
  3. package/dist/commands/client-recovery.js +56 -1
  4. package/dist/commands/init-upgrade.js +186 -21
  5. package/dist/executors/dag-pi-executor.js +49 -2
  6. package/dist/executors/shell-executor.js +135 -0
  7. package/dist/worker/console/app-data.js +132 -11
  8. package/dist/worker/console/chat/pi-runtime.js +24 -42
  9. package/dist/worker/console/chat/resource-loader.js +11 -20
  10. package/dist/worker/console/chat/routes.js +7 -8
  11. package/dist/worker/console/chat/runtime-context.js +1 -1
  12. package/dist/worker/console/chat/tools.js +67 -54
  13. package/dist/worker/console/operation-runner.js +15 -1
  14. package/dist/worker/console/operation-store.js +70 -49
  15. package/dist/worker/console/operator-actions.js +57 -1
  16. package/dist/worker/console/static/assets/index-Cwx-ZVEQ.js +29 -0
  17. package/dist/worker/console/static/favicon.svg +37 -0
  18. package/dist/worker/console/static/index.html +2 -1
  19. package/dist/workflows/dag/backend-test-scenario-param.js +846 -0
  20. package/dist/workflows/dag/backend-test-writer-completeness.js +418 -0
  21. package/dist/workflows/dag/init-hybrid.js +20 -6
  22. package/dist/workflows/dag/node-execution.js +31 -2
  23. package/dist/workflows/dag/retry-policy.js +55 -18
  24. package/dist/workflows/dag/types.js +1 -0
  25. package/dist/workflows/dag/validate.js +38 -3
  26. package/docs/operations/README.md +1 -1
  27. package/docs/templates/README.md +1 -1
  28. package/docs/templates/agent-dag.schema.json +3 -3
  29. package/docs/templates/backend-test-dag.json +36 -11
  30. package/docs/templates/init-managed-agents.md +1 -1
  31. package/harness.json +2 -2
  32. package/package.json +2 -1
  33. package/dist/worker/console/static/assets/index-BfRgtLF4.js +0 -29
@@ -0,0 +1,846 @@
1
+ import { createHash } from "node:crypto";
2
+ import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { z } from "zod";
5
+ import { expectedBackendTestPytestScriptForMarkdownModule, } from "./backend-test-markdown-workflow.js";
6
+ export const BACKEND_TEST_SCENARIO_PARAM_FACTS_SCHEMA_ID = "backend-test-scenario-param-consistency-facts-v1";
7
+ const factsSchema = z
8
+ .object({
9
+ schemaId: z.literal(BACKEND_TEST_SCENARIO_PARAM_FACTS_SCHEMA_ID),
10
+ phase: z.enum(["initial", "final"]),
11
+ repairEligible: z.boolean(),
12
+ repairAttempt: z.number().int().min(0).max(1),
13
+ strictScenarioParamGate: z.boolean(),
14
+ entries: z.array(z
15
+ .object({
16
+ caseId: z.string(),
17
+ tpId: z.string(),
18
+ field: z.string().optional(),
19
+ intent: z.string(),
20
+ observed: z.string(),
21
+ status: z.enum(["MATCH", "MISMATCH", "UNDETERMINED"]),
22
+ repairability: z.enum([
23
+ "repairable",
24
+ "blocked",
25
+ "none",
26
+ "undetermined-no-repair",
27
+ ]),
28
+ suggestedFix: z.string().optional(),
29
+ scriptPath: z.string().optional(),
30
+ bound: z.number().optional(),
31
+ example: z.string().optional(),
32
+ })
33
+ .strict()),
34
+ assetHashes: z.array(z.object({ path: z.string(), sha256: z.string() }).strict()),
35
+ summary: z
36
+ .object({
37
+ matchCount: z.number().int().min(0),
38
+ mismatchCount: z.number().int().min(0),
39
+ undeterminedCount: z.number().int().min(0),
40
+ repairableCount: z.number().int().min(0),
41
+ blockedCount: z.number().int().min(0),
42
+ })
43
+ .strict(),
44
+ })
45
+ .strict();
46
+ const CLOSED_SET = new Set([
47
+ "empty",
48
+ "missing",
49
+ "null",
50
+ "min-1",
51
+ "min",
52
+ "max",
53
+ "max+1",
54
+ "pattern-invalid",
55
+ "enum-invalid",
56
+ "wrong-type",
57
+ "nominal",
58
+ ]);
59
+ async function exists(filePath) {
60
+ try {
61
+ await access(filePath);
62
+ return true;
63
+ }
64
+ catch {
65
+ return false;
66
+ }
67
+ }
68
+ function orderedUnique(values) {
69
+ return [...new Set(values.filter(Boolean))];
70
+ }
71
+ function sha256(content) {
72
+ return createHash("sha256").update(content).digest("hex");
73
+ }
74
+ function sectionBody(body, headings) {
75
+ for (const heading of headings) {
76
+ const match = new RegExp(`^###\\s+${heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$([\\s\\S]*?)(?=^###\\s+|$)`, "mi").exec(body);
77
+ if (match)
78
+ return match[1] ?? "";
79
+ }
80
+ return "";
81
+ }
82
+ async function listMarkdownCases(workspaceRoot) {
83
+ const root = path.join(workspaceRoot, "testcase", "md");
84
+ if (!(await exists(root)))
85
+ return [];
86
+ const files = (await readdir(root)).filter((name) => name.toLowerCase().endsWith(".md") && name.toLowerCase() !== "readme.md");
87
+ const cases = [];
88
+ for (const name of files) {
89
+ const rel = path.posix.join("testcase/md", name);
90
+ const markdown = await readFile(path.join(workspaceRoot, rel), "utf8");
91
+ const headings = [...markdown.matchAll(/^##\s+(BE-[A-Z0-9_-]+-\d{2,3})\b.*$/gm)];
92
+ for (let index = 0; index < headings.length; index += 1) {
93
+ const heading = headings[index];
94
+ const caseId = heading[1];
95
+ const body = markdown.slice(heading.index, headings[index + 1]?.index ?? markdown.length);
96
+ const allPoints = orderedUnique(body.match(/\bTP-[A-Z0-9-]+\b/g) ?? []);
97
+ const variantLabel = body.match(/(?:Variant Test Points|\u53d8\u4f53\u6d4b\u8bd5\u70b9)\s*[::]\s*([^\n]+)/i)?.[1] ??
98
+ "";
99
+ const variantFromLabel = orderedUnique((variantLabel.match(/\bTP-[A-Z0-9-]+\b/g) ?? []).filter((item) => item.toUpperCase() !== "NONE"));
100
+ const variantTestPoints = variantFromLabel.length > 0
101
+ ? variantFromLabel
102
+ : allPoints.filter((tp) => /(EMPTY|MISSING|NULL|MIN|MAX|INVALID|WRONG|ENUM|PATTERN|NOMINAL|BOUNDARY)/i.test(tp));
103
+ const scriptMatch = body.match(/`?(testcase\/[A-Za-z0-9_./-]*test_[A-Za-z0-9_.-]*\.py)`?/i);
104
+ cases.push({
105
+ caseId,
106
+ body,
107
+ markdownPath: rel,
108
+ variantTestPoints: variantTestPoints.length > 0 ? variantTestPoints : allPoints.slice(0, 1),
109
+ scriptPath: scriptMatch?.[1] ??
110
+ expectedBackendTestPytestScriptForMarkdownModule(name),
111
+ });
112
+ }
113
+ }
114
+ return cases;
115
+ }
116
+ export function inferScenarioParamIntent(input) {
117
+ const { tpId, caseBody } = input;
118
+ const machineLine = /场景意图\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) ??
119
+ new RegExp(`${tpId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[^\\n]{0,120}?intent\\s*[=:]\\s*([a-z0-9+._-]+)`, "i").exec(caseBody);
120
+ if (machineLine) {
121
+ const rawIntent = (machineLine[1] ?? "unknown").toLowerCase();
122
+ const field = machineLine[2];
123
+ const bound = machineLine[3] ? Number(machineLine[3]) : undefined;
124
+ const example = machineLine[4]?.trim();
125
+ if (rawIntent.startsWith("custom-literal:")) {
126
+ return {
127
+ intent: rawIntent,
128
+ field,
129
+ bound,
130
+ example,
131
+ };
132
+ }
133
+ return {
134
+ intent: (CLOSED_SET.has(rawIntent) ? rawIntent : "unknown"),
135
+ field,
136
+ bound,
137
+ example,
138
+ };
139
+ }
140
+ const upper = tpId.toUpperCase();
141
+ const fieldFromTp = tpId
142
+ .replace(/^TP-/i, "")
143
+ .split("-")
144
+ .filter((part) => ![
145
+ "EMPTY",
146
+ "MISSING",
147
+ "NULL",
148
+ "MIN",
149
+ "MAX",
150
+ "PLUS",
151
+ "1",
152
+ "INVALID",
153
+ "WRONG",
154
+ "TYPE",
155
+ "ENUM",
156
+ "PATTERN",
157
+ "NOMINAL",
158
+ "BOUNDARY",
159
+ "CASE",
160
+ "VARIANT",
161
+ "WHITESPACE",
162
+ "UNKNOWN",
163
+ ].includes(part.toUpperCase()))[0]
164
+ ?.replace(/_/g, "") ?? undefined;
165
+ const field = fieldFromTp && fieldFromTp.length > 1
166
+ ? fieldFromTp.charAt(0).toLowerCase() + fieldFromTp.slice(1)
167
+ : undefined;
168
+ const dataSection = sectionBody(caseBody, ["测试数据", "Test Data", "操作步骤", "Steps"]);
169
+ 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}`);
170
+ const bound = boundMatch ? Number(boundMatch[1]) : undefined;
171
+ const exampleMatch = /(?:反例|invalid example|example)\s*[=::]\s*[`"]?([^`"\n]+)[`"]?/i.exec(caseBody);
172
+ const example = exampleMatch?.[1]?.trim();
173
+ if (/EMPTY|空白|空串|空字符串/.test(upper) || /空字符串|空串|empty string/i.test(caseBody)) {
174
+ return { intent: "empty", field, bound, example };
175
+ }
176
+ if (/MISSING|缺省|缺失|省略/.test(upper) || /缺少字段|缺失字段|missing field/i.test(caseBody)) {
177
+ return { intent: "missing", field, bound, example };
178
+ }
179
+ if (/\bNULL\b|空值/.test(upper) || /\bnull\b/i.test(caseBody)) {
180
+ return { intent: "null", field, bound, example };
181
+ }
182
+ if (/MAX[-_]?PLUS[-_]?1|MAX\+1|超长|越界/.test(upper)) {
183
+ return { intent: "max+1", field, bound, example };
184
+ }
185
+ if (/\bMAX\b|最大/.test(upper))
186
+ return { intent: "max", field, bound, example };
187
+ if (/MIN[-_]?1|最小减|min-1/i.test(upper)) {
188
+ return { intent: "min-1", field, bound, example };
189
+ }
190
+ if (/\bMIN\b|最小/.test(upper))
191
+ return { intent: "min", field, bound, example };
192
+ if (/WRONG[-_]?TYPE|类型错误/.test(upper)) {
193
+ return { intent: "wrong-type", field, bound, example };
194
+ }
195
+ if (/ENUM.*INVALID|INVALID.*ENUM|非法枚举/.test(upper)) {
196
+ return { intent: "enum-invalid", field, bound, example };
197
+ }
198
+ if (/PATTERN|FORMAT|UPPERCASE|大写|非法格式/.test(upper)) {
199
+ return { intent: "pattern-invalid", field, bound, example };
200
+ }
201
+ if (/NOMINAL|正常|合法|主路径|SUCCESS/.test(upper)) {
202
+ return { intent: "nominal", field, bound, example };
203
+ }
204
+ return { intent: "unknown", field, bound, example };
205
+ }
206
+ export function extractPytestParamBlock(source, tpId) {
207
+ const idPattern = new RegExp(`id\\s*=\\s*["']${tpId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']`);
208
+ const matches = [...source.matchAll(/pytest\.param\s*\(/g)];
209
+ for (const match of matches) {
210
+ const start = match.index ?? 0;
211
+ let depth = 0;
212
+ let end = -1;
213
+ for (let i = start; i < source.length; i += 1) {
214
+ const ch = source[i];
215
+ if (ch === "(")
216
+ depth += 1;
217
+ else if (ch === ")") {
218
+ depth -= 1;
219
+ if (depth === 0) {
220
+ end = i + 1;
221
+ break;
222
+ }
223
+ }
224
+ }
225
+ if (end < 0)
226
+ continue;
227
+ const block = source.slice(start, end);
228
+ if (idPattern.test(block))
229
+ return block;
230
+ }
231
+ return undefined;
232
+ }
233
+ export function observeParamFeatures(block, field) {
234
+ if (!block)
235
+ return { kind: "unknown", text: "param-block-missing" };
236
+ const compact = block.replace(/\s+/g, " ");
237
+ if (/\bFaker\b|\bfake\b|\brandom\b|\buuid4\b|\bfactory\b/i.test(block)) {
238
+ return { kind: "call", text: "dynamic-or-faker" };
239
+ }
240
+ // Prefer dict-like payload extraction.
241
+ const dictMatch = /\{\s*([\s\S]*?)\s*\}/.exec(block) ??
242
+ /(?:payload|body|data|json)\s*=\s*(\{[\s\S]*?\})/.exec(block);
243
+ if (dictMatch && field) {
244
+ const dict = dictMatch[0] ?? dictMatch[1] ?? "";
245
+ const fieldPattern = new RegExp(`["']${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']\\s*:`);
246
+ if (!fieldPattern.test(dict)) {
247
+ return { kind: "missing-key", text: `missing:${field}` };
248
+ }
249
+ const valueMatch = new RegExp(`["']${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']\\s*:\\s*([^,}\\n]+)`).exec(dict);
250
+ const raw = valueMatch?.[1]?.trim() ?? "";
251
+ if (raw === "None" || raw === "null") {
252
+ return { kind: "none", text: "None", literal: "None" };
253
+ }
254
+ if (raw === '""' || raw === "''") {
255
+ return { kind: "empty-string", text: '""', length: 0, literal: "" };
256
+ }
257
+ const str = /^["']([\s\S]*)["']$/.exec(raw);
258
+ if (str) {
259
+ const value = str[1] ?? "";
260
+ return {
261
+ kind: "string",
262
+ text: JSON.stringify(value),
263
+ length: value.length,
264
+ hasUppercase: /[A-Z]/.test(value),
265
+ literal: value,
266
+ };
267
+ }
268
+ if (/^\d+(?:\.\d+)?$/.test(raw)) {
269
+ return { kind: "number", text: raw, literal: raw };
270
+ }
271
+ if (raw === "True" || raw === "False") {
272
+ return { kind: "boolean", text: raw, literal: raw };
273
+ }
274
+ if (raw.startsWith("["))
275
+ return { kind: "list", text: raw };
276
+ if (raw.startsWith("{"))
277
+ return { kind: "dict", text: raw };
278
+ if (/\(/.test(raw))
279
+ return { kind: "call", text: raw };
280
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(raw)) {
281
+ return { kind: "name", text: raw };
282
+ }
283
+ return { kind: "unknown", text: raw || compact.slice(0, 120) };
284
+ }
285
+ // Positional first arg string/None.
286
+ const firstArg = /pytest\.param\s*\(\s*(None|True|False|-?\d+(?:\.\d+)?|"""[\s\S]*?"""|'''[\s\S]*?'''|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')/.exec(block);
287
+ if (firstArg) {
288
+ const raw = firstArg[1];
289
+ if (raw === "None")
290
+ return { kind: "none", text: "None", literal: "None" };
291
+ if (raw === '""' || raw === "''") {
292
+ return { kind: "empty-string", text: '""', length: 0, literal: "" };
293
+ }
294
+ const str = /^["']([\s\S]*)["']$/.exec(raw) ?? /^("""|''')([\s\S]*)\1$/.exec(raw);
295
+ if (str) {
296
+ const value = (str[2] ?? str[1] ?? "").replace(/^["']|["']$/g, "");
297
+ return {
298
+ kind: "string",
299
+ text: JSON.stringify(value),
300
+ length: value.length,
301
+ hasUppercase: /[A-Z]/.test(value),
302
+ literal: value,
303
+ };
304
+ }
305
+ return { kind: "unknown", text: raw };
306
+ }
307
+ return { kind: "unknown", text: compact.slice(0, 160) };
308
+ }
309
+ function compareIntent(intent, observed, bound) {
310
+ if (intent === "unknown")
311
+ return "UNDETERMINED";
312
+ if (observed.kind === "call" || observed.kind === "name") {
313
+ return "UNDETERMINED";
314
+ }
315
+ switch (intent) {
316
+ case "empty":
317
+ return observed.kind === "empty-string" ||
318
+ (observed.kind === "string" && observed.length === 0)
319
+ ? "MATCH"
320
+ : observed.kind === "unknown"
321
+ ? "UNDETERMINED"
322
+ : "MISMATCH";
323
+ case "missing":
324
+ return observed.kind === "missing-key"
325
+ ? "MATCH"
326
+ : observed.kind === "unknown"
327
+ ? "UNDETERMINED"
328
+ : "MISMATCH";
329
+ case "null":
330
+ return observed.kind === "none"
331
+ ? "MATCH"
332
+ : observed.kind === "unknown"
333
+ ? "UNDETERMINED"
334
+ : "MISMATCH";
335
+ case "max":
336
+ return bound !== undefined &&
337
+ observed.kind === "string" &&
338
+ observed.length === bound
339
+ ? "MATCH"
340
+ : bound === undefined
341
+ ? "UNDETERMINED"
342
+ : observed.kind === "string"
343
+ ? "MISMATCH"
344
+ : "UNDETERMINED";
345
+ case "max+1":
346
+ return bound !== undefined &&
347
+ observed.kind === "string" &&
348
+ observed.length === bound + 1
349
+ ? "MATCH"
350
+ : bound === undefined
351
+ ? "UNDETERMINED"
352
+ : observed.kind === "string"
353
+ ? "MISMATCH"
354
+ : "UNDETERMINED";
355
+ case "min":
356
+ return bound !== undefined &&
357
+ observed.kind === "string" &&
358
+ observed.length === bound
359
+ ? "MATCH"
360
+ : bound === undefined
361
+ ? "UNDETERMINED"
362
+ : observed.kind === "string"
363
+ ? "MISMATCH"
364
+ : "UNDETERMINED";
365
+ case "min-1":
366
+ return bound !== undefined &&
367
+ observed.kind === "string" &&
368
+ observed.length === Math.max(0, bound - 1)
369
+ ? "MATCH"
370
+ : bound === undefined
371
+ ? "UNDETERMINED"
372
+ : observed.kind === "string"
373
+ ? "MISMATCH"
374
+ : "UNDETERMINED";
375
+ case "pattern-invalid":
376
+ if (observed.kind === "string" && observed.hasUppercase)
377
+ return "MATCH";
378
+ if (observed.kind === "string")
379
+ return "MISMATCH";
380
+ return "UNDETERMINED";
381
+ case "wrong-type":
382
+ return observed.kind === "number" ||
383
+ observed.kind === "boolean" ||
384
+ observed.kind === "list" ||
385
+ observed.kind === "dict"
386
+ ? "MATCH"
387
+ : observed.kind === "string" || observed.kind === "empty-string"
388
+ ? "MISMATCH"
389
+ : "UNDETERMINED";
390
+ case "enum-invalid":
391
+ return observed.kind === "string" || observed.kind === "number"
392
+ ? "UNDETERMINED"
393
+ : "UNDETERMINED";
394
+ case "nominal":
395
+ return observed.kind === "string" ||
396
+ observed.kind === "number" ||
397
+ observed.kind === "boolean" ||
398
+ observed.kind === "dict" ||
399
+ observed.kind === "list"
400
+ ? "MATCH"
401
+ : observed.kind === "empty-string" || observed.kind === "none"
402
+ ? "MISMATCH"
403
+ : "UNDETERMINED";
404
+ default:
405
+ if (intent.startsWith("custom-literal:")) {
406
+ const expected = intent.slice("custom-literal:".length);
407
+ return observed.literal === expected
408
+ ? "MATCH"
409
+ : observed.kind === "unknown"
410
+ ? "UNDETERMINED"
411
+ : "MISMATCH";
412
+ }
413
+ return "UNDETERMINED";
414
+ }
415
+ }
416
+ function repairabilityFor(status, intent, observed, example) {
417
+ if (status === "MATCH")
418
+ return "none";
419
+ if (status === "UNDETERMINED")
420
+ return "undetermined-no-repair";
421
+ if (observed.kind === "call" || observed.kind === "name") {
422
+ return "undetermined-no-repair";
423
+ }
424
+ if (intent === "unknown")
425
+ return "blocked";
426
+ if ((intent === "pattern-invalid" ||
427
+ intent === "enum-invalid" ||
428
+ intent === "wrong-type") &&
429
+ !example &&
430
+ !intent.startsWith("custom-literal:")) {
431
+ // pattern-invalid with uppercase heuristic is still repairable to a fixed anti-example.
432
+ if (intent === "pattern-invalid")
433
+ return "repairable";
434
+ return "blocked";
435
+ }
436
+ if (CLOSED_SET.has(intent) || intent.startsWith("custom-literal:")) {
437
+ return "repairable";
438
+ }
439
+ return "blocked";
440
+ }
441
+ function suggestedFixFor(intent, field, bound, example) {
442
+ if (!field && intent !== "nominal")
443
+ return undefined;
444
+ switch (intent) {
445
+ case "empty":
446
+ return field ? `set ${field}=""` : 'set value=""';
447
+ case "missing":
448
+ return field ? `delete key ${field}` : "delete field key";
449
+ case "null":
450
+ return field ? `set ${field}=None` : "set value=None";
451
+ case "max":
452
+ return field && bound !== undefined
453
+ ? `set ${field}="x"*${bound}`
454
+ : undefined;
455
+ case "max+1":
456
+ return field && bound !== undefined
457
+ ? `set ${field}="x"*${bound + 1}`
458
+ : undefined;
459
+ case "min":
460
+ return field && bound !== undefined
461
+ ? `set ${field}="x"*${bound}`
462
+ : undefined;
463
+ case "min-1":
464
+ return field && bound !== undefined
465
+ ? `set ${field}="x"*${Math.max(0, bound - 1)}`
466
+ : undefined;
467
+ case "pattern-invalid":
468
+ return field
469
+ ? `set ${field}=${JSON.stringify(example ?? "INVALID")}`
470
+ : undefined;
471
+ case "enum-invalid":
472
+ case "wrong-type":
473
+ return field && example
474
+ ? `set ${field}=${JSON.stringify(example)}`
475
+ : undefined;
476
+ default:
477
+ if (intent.startsWith("custom-literal:")) {
478
+ return field
479
+ ? `set ${field}=${JSON.stringify(intent.slice("custom-literal:".length))}`
480
+ : undefined;
481
+ }
482
+ return undefined;
483
+ }
484
+ }
485
+ export async function assessBackendScenarioParamConsistency(input) {
486
+ const cases = await listMarkdownCases(input.workspaceRoot);
487
+ const entries = [];
488
+ const assetPaths = new Set();
489
+ for (const testCase of cases) {
490
+ if (!testCase.scriptPath)
491
+ continue;
492
+ assetPaths.add(testCase.scriptPath);
493
+ const absolute = path.join(input.workspaceRoot, testCase.scriptPath);
494
+ const source = (await exists(absolute))
495
+ ? await readFile(absolute, "utf8")
496
+ : "";
497
+ for (const tpId of testCase.variantTestPoints) {
498
+ const inferred = inferScenarioParamIntent({
499
+ tpId,
500
+ caseBody: testCase.body,
501
+ });
502
+ const block = source ? extractPytestParamBlock(source, tpId) : undefined;
503
+ const observed = observeParamFeatures(block, inferred.field);
504
+ const status = !source
505
+ ? "UNDETERMINED"
506
+ : compareIntent(inferred.intent, observed, inferred.bound);
507
+ const repairability = repairabilityFor(status, inferred.intent, observed, inferred.example);
508
+ entries.push({
509
+ caseId: testCase.caseId,
510
+ tpId,
511
+ field: inferred.field,
512
+ intent: inferred.intent,
513
+ observed: observed.text,
514
+ status,
515
+ repairability,
516
+ suggestedFix: suggestedFixFor(inferred.intent, inferred.field, inferred.bound, inferred.example),
517
+ scriptPath: testCase.scriptPath,
518
+ bound: inferred.bound,
519
+ example: inferred.example,
520
+ });
521
+ }
522
+ }
523
+ const assetHashes = [];
524
+ for (const rel of [...assetPaths].sort()) {
525
+ const absolute = path.join(input.workspaceRoot, rel);
526
+ if (!(await exists(absolute)))
527
+ continue;
528
+ assetHashes.push({
529
+ path: rel,
530
+ sha256: sha256(await readFile(absolute)),
531
+ });
532
+ }
533
+ const repairableCount = entries.filter((entry) => entry.repairability === "repairable" && entry.status === "MISMATCH").length;
534
+ const facts = {
535
+ schemaId: BACKEND_TEST_SCENARIO_PARAM_FACTS_SCHEMA_ID,
536
+ phase: input.phase,
537
+ repairEligible: input.phase === "initial" && repairableCount > 0,
538
+ repairAttempt: input.repairAttempt ?? (input.phase === "final" ? 1 : 0),
539
+ strictScenarioParamGate: input.strictScenarioParamGate === true,
540
+ entries,
541
+ assetHashes,
542
+ summary: {
543
+ matchCount: entries.filter((entry) => entry.status === "MATCH").length,
544
+ mismatchCount: entries.filter((entry) => entry.status === "MISMATCH")
545
+ .length,
546
+ undeterminedCount: entries.filter((entry) => entry.status === "UNDETERMINED").length,
547
+ repairableCount,
548
+ blockedCount: entries.filter((entry) => entry.repairability === "blocked")
549
+ .length,
550
+ },
551
+ };
552
+ const markdown = [
553
+ "# Backend Test Scenario-Param Consistency",
554
+ "",
555
+ `## Status`,
556
+ "",
557
+ facts.summary.mismatchCount > 0 ? "FAIL" : "PASS",
558
+ "",
559
+ `## Summary`,
560
+ "",
561
+ `- Phase: ${facts.phase}`,
562
+ `- Repair eligible: ${facts.repairEligible}`,
563
+ `- Repair attempt: ${facts.repairAttempt}`,
564
+ `- MATCH: ${facts.summary.matchCount}`,
565
+ `- MISMATCH: ${facts.summary.mismatchCount}`,
566
+ `- UNDETERMINED: ${facts.summary.undeterminedCount}`,
567
+ `- Repairable mismatches: ${facts.summary.repairableCount}`,
568
+ `- Blocked: ${facts.summary.blockedCount}`,
569
+ "",
570
+ "## Entries",
571
+ "",
572
+ "| Case | TP | Field | Intent | Observed | Status | Repairability | Suggested fix |",
573
+ "|---|---|---|---|---|---|---|---|",
574
+ ...facts.entries.map((entry) => `| ${entry.caseId} | ${entry.tpId} | ${entry.field ?? "—"} | ${entry.intent} | ${entry.observed.replaceAll("|", "\\|")} | ${entry.status} | ${entry.repairability} | ${entry.suggestedFix ?? "—"} |`),
575
+ "",
576
+ ].join("\n");
577
+ return { facts, markdown };
578
+ }
579
+ export async function writeBackendScenarioParamArtifacts(input) {
580
+ const contractsDir = path.join(input.runDir, "contracts");
581
+ const reportsDir = path.join(input.runDir, "reports");
582
+ await mkdir(contractsDir, { recursive: true });
583
+ await mkdir(reportsDir, { recursive: true });
584
+ const stem = input.facts.phase === "final"
585
+ ? "backend-test-scenario-param-consistency-final"
586
+ : "backend-test-scenario-param-consistency";
587
+ const factsPath = path.join(contractsDir, `${stem}.json`);
588
+ const reportPath = path.join(reportsDir, `${stem}.md`);
589
+ const parsed = factsSchema.parse(input.facts);
590
+ await writeFile(factsPath, JSON.stringify(parsed, null, 2), "utf8");
591
+ await writeFile(reportPath, input.markdown, "utf8");
592
+ // Keep a stable non-suffixed alias for final as well for consumers.
593
+ if (input.facts.phase === "final") {
594
+ await writeFile(path.join(contractsDir, "backend-test-scenario-param-consistency-facts.json"), JSON.stringify(parsed, null, 2), "utf8");
595
+ await writeFile(path.join(reportsDir, "backend-test-scenario-param-consistency.md"), input.markdown, "utf8");
596
+ }
597
+ else {
598
+ await writeFile(path.join(contractsDir, "backend-test-scenario-param-consistency-facts.json"), JSON.stringify(parsed, null, 2), "utf8");
599
+ }
600
+ return { factsPath, reportPath };
601
+ }
602
+ export async function readBackendScenarioParamFacts(filePath) {
603
+ const raw = JSON.parse(await readFile(filePath, "utf8"));
604
+ return factsSchema.parse(raw);
605
+ }
606
+ function replaceFieldInDictLiteral(dictLiteral, field, mode, valueLiteral) {
607
+ const fieldPattern = new RegExp(`([\\t ]*)(["']${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']\\s*:\\s*)([^,}\\n]+)`);
608
+ if (mode === "delete") {
609
+ if (!fieldPattern.test(dictLiteral))
610
+ return dictLiteral;
611
+ return dictLiteral
612
+ .replace(new RegExp(`,\\s*["']${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']\\s*:\\s*[^,}\\n]+`), "")
613
+ .replace(new RegExp(`["']${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']\\s*:\\s*[^,}\\n]+\\s*,?`), "");
614
+ }
615
+ if (fieldPattern.test(dictLiteral)) {
616
+ return dictLiteral.replace(fieldPattern, `$1$2${valueLiteral}`);
617
+ }
618
+ // Insert before closing brace.
619
+ const trimmed = dictLiteral.replace(/\s*\}$/, "");
620
+ const needsComma = /:\s*[^,}\s][^}]*$/.test(trimmed);
621
+ return `${trimmed}${needsComma ? ", " : ""}${JSON.stringify(field)}: ${valueLiteral}}`;
622
+ }
623
+ export function deterministicRewriteScenarioParam(input) {
624
+ const { entry } = input;
625
+ if (entry.repairability !== "repairable" || !entry.field) {
626
+ return { ok: false, source: input.source, detail: "not repairable or missing field" };
627
+ }
628
+ const block = extractPytestParamBlock(input.source, entry.tpId);
629
+ if (!block) {
630
+ return { ok: false, source: input.source, detail: "param block not found" };
631
+ }
632
+ // Prefer the first object literal in the param block (payload dict).
633
+ const dictMatch = /\{[^{}]*\}/.exec(block);
634
+ if (!dictMatch) {
635
+ return { ok: false, source: input.source, detail: "no dict payload in param" };
636
+ }
637
+ const originalDict = dictMatch[0];
638
+ const field = entry.field;
639
+ const bound = entry.bound;
640
+ let valueLiteral;
641
+ let mode = "set";
642
+ switch (entry.intent) {
643
+ case "empty":
644
+ valueLiteral = '""';
645
+ break;
646
+ case "missing":
647
+ mode = "delete";
648
+ break;
649
+ case "null":
650
+ valueLiteral = "None";
651
+ break;
652
+ case "max":
653
+ case "min":
654
+ if (bound === undefined) {
655
+ return { ok: false, source: input.source, detail: `missing bound for ${entry.intent}` };
656
+ }
657
+ valueLiteral = `"x" * ${bound}`;
658
+ break;
659
+ case "max+1":
660
+ if (bound === undefined) {
661
+ return { ok: false, source: input.source, detail: "missing bound for max+1" };
662
+ }
663
+ valueLiteral = `"x" * ${bound + 1}`;
664
+ break;
665
+ case "min-1":
666
+ if (bound === undefined) {
667
+ return { ok: false, source: input.source, detail: "missing bound for min-1" };
668
+ }
669
+ valueLiteral = `"x" * ${Math.max(0, bound - 1)}`;
670
+ break;
671
+ case "pattern-invalid":
672
+ valueLiteral = JSON.stringify(entry.example ?? "INVALID");
673
+ break;
674
+ default:
675
+ if (entry.intent.startsWith("custom-literal:")) {
676
+ valueLiteral = JSON.stringify(entry.intent.slice("custom-literal:".length));
677
+ break;
678
+ }
679
+ return {
680
+ ok: false,
681
+ source: input.source,
682
+ detail: `intent ${entry.intent} not in deterministic closed set`,
683
+ };
684
+ }
685
+ const nextDict = replaceFieldInDictLiteral(originalDict, field, mode, valueLiteral);
686
+ if (nextDict === originalDict) {
687
+ return { ok: false, source: input.source, detail: "rewrite produced no change" };
688
+ }
689
+ const nextBlock = block.replace(originalDict, nextDict);
690
+ const nextSource = input.source.replace(block, nextBlock);
691
+ if (nextSource.includes("pytest.skip") || nextSource.includes("pytest.xfail")) {
692
+ return { ok: false, source: input.source, detail: "rewrite introduced skip/xfail" };
693
+ }
694
+ return { ok: true, source: nextSource, detail: "rewritten" };
695
+ }
696
+ export async function applyDeterministicScenarioParamRepairs(input) {
697
+ const repairable = input.facts.entries.filter((entry) => entry.status === "MISMATCH" && entry.repairability === "repairable");
698
+ const byScript = new Map();
699
+ for (const entry of repairable) {
700
+ if (!entry.scriptPath)
701
+ continue;
702
+ const list = byScript.get(entry.scriptPath) ?? [];
703
+ list.push(entry);
704
+ byScript.set(entry.scriptPath, list);
705
+ }
706
+ const beforeHashes = [...input.facts.assetHashes];
707
+ const changedFiles = [];
708
+ const repaired = [];
709
+ const skipped = [];
710
+ const auditEntries = [];
711
+ for (const [scriptPath, entries] of byScript) {
712
+ const absolute = path.join(input.workspaceRoot, scriptPath);
713
+ if (!(await exists(absolute))) {
714
+ for (const entry of entries) {
715
+ skipped.push({ tpId: entry.tpId, detail: "script missing" });
716
+ auditEntries.push({
717
+ tpId: entry.tpId,
718
+ result: "skipped",
719
+ detail: "script missing",
720
+ });
721
+ }
722
+ continue;
723
+ }
724
+ let source = await readFile(absolute, "utf8");
725
+ let fileChanged = false;
726
+ for (const entry of entries) {
727
+ const result = deterministicRewriteScenarioParam({ source, entry });
728
+ if (!result.ok) {
729
+ skipped.push({ tpId: entry.tpId, detail: result.detail });
730
+ auditEntries.push({
731
+ tpId: entry.tpId,
732
+ result: "skipped-to-pi",
733
+ detail: result.detail,
734
+ });
735
+ continue;
736
+ }
737
+ source = result.source;
738
+ fileChanged = true;
739
+ repaired.push(entry.tpId);
740
+ auditEntries.push({
741
+ tpId: entry.tpId,
742
+ result: "rewritten",
743
+ detail: result.detail,
744
+ });
745
+ }
746
+ if (fileChanged) {
747
+ await writeFile(absolute, source, "utf8");
748
+ changedFiles.push(scriptPath);
749
+ }
750
+ }
751
+ const afterHashes = [];
752
+ for (const item of beforeHashes) {
753
+ const absolute = path.join(input.workspaceRoot, item.path);
754
+ if (!(await exists(absolute)))
755
+ continue;
756
+ afterHashes.push({
757
+ path: item.path,
758
+ sha256: sha256(await readFile(absolute)),
759
+ });
760
+ }
761
+ return {
762
+ changedFiles,
763
+ repaired,
764
+ skipped,
765
+ audit: { beforeHashes, afterHashes, entries: auditEntries },
766
+ };
767
+ }
768
+ export async function writeScenarioParamRepairAudit(input) {
769
+ const contractsDir = path.join(input.runDir, "contracts");
770
+ await mkdir(contractsDir, { recursive: true });
771
+ const filePath = path.join(contractsDir, "backend-test-scenario-param-repair-audit.json");
772
+ await writeFile(filePath, JSON.stringify({
773
+ schemaId: "backend-test-scenario-param-repair-audit-v1",
774
+ changedFiles: input.changedFiles,
775
+ ...input.audit,
776
+ }, null, 2), "utf8");
777
+ return filePath;
778
+ }
779
+ export function renderBackendTestFailureAnalysis(input) {
780
+ const overviewNarrative = input.failed === 0
781
+ ? "本轮无失败用例。报告仍保留场景-参数一致性与执行摘要,供审计。"
782
+ : `本轮共 ${input.failed} 个失败/错误用例。分类优先参考场景-参数一致性 final facts,再结合断言与环境证据。`;
783
+ return [
784
+ "# 测试失败用例分析报告",
785
+ "",
786
+ `> 报告生成时间:${input.generatedAt}`,
787
+ `> 测试报告来源:${input.htmlReportPath}`,
788
+ `> 测试总计:${input.total} 个用例(${input.passed} Passed, ${input.failed} Failed)`,
789
+ `> 测试耗时:${input.durationLabel}`,
790
+ `> 测试环境:${input.environmentSummary.split("\n")[0] ?? "unavailable"}`,
791
+ `> 接口:${input.primaryOperation ?? "—"}`,
792
+ `> 环境基址:${input.baseUrl ?? "—"}`,
793
+ `> 场景-参数一致性:${input.scenarioParamFinalStatus}(repairAttempt=${input.repairAttempt})`,
794
+ "",
795
+ "---",
796
+ "",
797
+ "## 一、失败用例概览",
798
+ "",
799
+ overviewNarrative,
800
+ "",
801
+ "| # | 测试用例 | 场景描述 | 期望码 | 实际码 | 失败分类 |",
802
+ "|---|---------|---------|--------|--------|---------|",
803
+ ...(input.failures.length
804
+ ? input.failures.map((item, index) => `| ${index + 1} | ${item.caseId} / ${item.name} | ${item.scenario.replaceAll("|", "\\|")} | ${item.expectedCode ?? "—"} | ${item.actualCode ?? "—"} | ${item.classification} |`)
805
+ : ["| — | — | — | — | — | 无失败 |"]),
806
+ "",
807
+ "---",
808
+ "",
809
+ "## 二、逐个失败用例详细分析",
810
+ "",
811
+ ...(input.failures.length
812
+ ? input.failures.flatMap((item, index) => [
813
+ `### 失败用例 ${index + 1}:${item.name}`,
814
+ "| 项目 | 内容 |",
815
+ "|------|------|",
816
+ `| 用例 ID | ${item.caseId} |`,
817
+ `| 接口 URL | ${item.scriptPath ?? "—"} |`,
818
+ `| 场景描述 | ${item.scenario.replaceAll("|", "\\|")} |`,
819
+ `| 需求依据 | ${item.requirementRef ?? "—"} |`,
820
+ `| 期望码 / 实际码 | ${item.expectedCode ?? "—"} / ${item.actualCode ?? "—"} |`,
821
+ `| 耗时 | ${item.durationLabel ?? "—"} |`,
822
+ `| 场景-参数评估 | ${item.scenarioParamStatus ?? "UNAVAILABLE"} |`,
823
+ "",
824
+ `**传入参数:** ${item.requestSummary ?? "见 pytest-html 执行日志"}`,
825
+ "",
826
+ `**失败原因:** ${item.message.replaceAll("|", "\\|")}`,
827
+ "",
828
+ `**缺陷定位:** ${item.classification}`,
829
+ "",
830
+ ])
831
+ : ["本轮无失败用例详析。", ""]),
832
+ ].join("\n");
833
+ }
834
+ export function classifyBackendTestFailureWithScenarioParam(input) {
835
+ if (input.scenarioParamStatus === "MISMATCH") {
836
+ return "测试脚本参数与场景不匹配(或自修后仍不一致)";
837
+ }
838
+ const evidence = [input.message, input.details].filter(Boolean).join("\n");
839
+ if (/ImportError|ModuleNotFoundError|fixture|CONNECTION|ECONNREFUSED/i.test(evidence)) {
840
+ return "环境或测试基础设施问题";
841
+ }
842
+ if (/assert\s+\d{3}\s*==\s*\d{3}|状态码/i.test(evidence)) {
843
+ return "后端行为与契约不一致";
844
+ }
845
+ return "证据不足-待确认";
846
+ }