@tea-agent/loop-agent 0.25.4 → 0.25.6

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 (40) hide show
  1. package/AGENTS.md +6 -0
  2. package/CHANGELOG.md +60 -0
  3. package/dist/commands/client-recovery.js +209 -62
  4. package/dist/commands/init.js +68 -129
  5. package/dist/executors/dag-pi-executor.js +80 -15
  6. package/dist/executors/model-routing.js +1 -1
  7. package/dist/executors/shell-executor.js +127 -0
  8. package/dist/executors/shell-write-guard.js +21 -7
  9. package/dist/worker/console/repo-fingerprint.js +7 -1
  10. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +1281 -0
  11. package/dist/workflows/dag/backend-test-case-manifest.js +59 -1
  12. package/dist/workflows/dag/backend-test-markdown-workflow.js +236 -16
  13. package/dist/workflows/dag/convergence/controller.js +134 -9
  14. package/dist/workflows/dag/frontend-test-l5-report.js +138 -0
  15. package/dist/workflows/dag/init-hybrid.js +270 -80
  16. package/dist/workflows/dag/node-execution.js +64 -11
  17. package/dist/workflows/dag/prompt.js +118 -4
  18. package/dist/workflows/dag/retry-policy.js +5 -4
  19. package/dist/workflows/dag/scheduler.js +32 -5
  20. package/dist/workflows/dag/types.js +7 -4
  21. package/dist/workflows/dag/validate.js +3 -2
  22. package/docs/architecture/dag-execution.md +7 -4
  23. package/docs/architecture/runtime-boundaries.md +1 -1
  24. package/docs/init-surface.manifest.json +3 -1
  25. package/docs/templates/README.md +1 -0
  26. package/docs/templates/agent-dag.base.json +1 -1
  27. package/docs/templates/agent-dag.final-verification.json +1 -1
  28. package/docs/templates/agent-dag.supervised-implementation.json +1 -1
  29. package/docs/templates/backend-test-dag.json +41 -14
  30. package/docs/templates/frontend-test-dag.json +32 -2
  31. package/docs/templates/hybrid-dag.json +1 -1
  32. package/docs/templates/init-managed-agents.md +137 -0
  33. package/examples/decision-gate-agent-dag.json +1 -1
  34. package/examples/example-dag.json +1 -1
  35. package/examples/hybrid-loop-agent-dag.json +1 -1
  36. package/harness.json +1 -1
  37. package/package.json +1 -1
  38. package/skills/loop-agent/references/command-reference.md +5 -4
  39. package/skills/loop-agent/references/hybrid-dag.md +2 -2
  40. package/skills/loop-agent/references/model-routing.md +1 -1
@@ -44,7 +44,7 @@ const gapSchema = z
44
44
  const caseSchema = z
45
45
  .object({
46
46
  caseId: z.string().regex(BACKEND_TEST_CASE_ID_PATTERN),
47
- acIds: z.array(z.string().regex(BACKEND_TEST_AC_ID_PATTERN)).min(1),
47
+ acIds: z.array(z.string().regex(BACKEND_TEST_AC_ID_PATTERN)),
48
48
  title: z.string().min(1),
49
49
  category: backendTestCaseCategorySchema,
50
50
  automationStatus: backendTestCaseAutomationStatusSchema,
@@ -96,6 +96,64 @@ export const backendTestCaseManifestSchema = z
96
96
  requirementIds: z.array(z.string().regex(/^(?:REQ|BR|AC)-[A-Z0-9]+(?:-[A-Z0-9]+)*$/)),
97
97
  })
98
98
  .strict(),
99
+ materializationStatus: z.enum(["available", "partial", "unavailable"]).optional(),
100
+ sourceFactsIssues: z.array(z.string().min(1)).optional(),
101
+ coverageScope: z.object({
102
+ changeClassification: z.enum(["new-operation", "contract-change", "behavior-change", "bugfix", "implementation-optimization"]),
103
+ coveragePolicy: z.enum(["full-contract", "affected-contract-full", "affected-behavior-full", "reproduction-plus-neighbors", "change-focused-plus-regression-floor"]),
104
+ affectedOperations: z.array(z.string().min(1)),
105
+ affectedRuleKeys: z.array(z.string().min(1)),
106
+ regressionFloor: z.array(z.string().min(1)),
107
+ scopeEvidence: z.array(z.string().min(1)),
108
+ completenessClaim: z.enum(["affected-operations-full", "affected-scope"]),
109
+ }).strict().optional(),
110
+ ruleCoverageSummary: z.object({
111
+ explicitAcCount: z.number().int().min(0),
112
+ coveredAcCount: z.number().int().min(0),
113
+ ruleCount: z.number().int().min(0),
114
+ coveredRuleCount: z.number().int().min(0),
115
+ testPointCount: z.number().int().min(0),
116
+ coveredTestPointCount: z.number().int().min(0),
117
+ enumValueCount: z.number().int().min(0),
118
+ coveredEnumValueCount: z.number().int().min(0),
119
+ invalidEquivalenceClassCount: z.number().int().min(0),
120
+ coveredInvalidEquivalenceClassCount: z.number().int().min(0),
121
+ boundaryPointCount: z.number().int().min(0),
122
+ coveredBoundaryPointCount: z.number().int().min(0),
123
+ formatClassCount: z.number().int().min(0),
124
+ coveredFormatClassCount: z.number().int().min(0),
125
+ businessStateCount: z.number().int().min(0),
126
+ coveredBusinessStateCount: z.number().int().min(0),
127
+ gapCount: z.number().int().min(0),
128
+ conflictCount: z.number().int().min(0),
129
+ variantTestPointCount: z.number().int().min(0),
130
+ assertionTestPointCount: z.number().int().min(0),
131
+ crossCuttingTestPointCount: z.number().int().min(0),
132
+ unclassifiedTestPointCount: z.number().int().min(0),
133
+ duplicateBindingTestPointCount: z.number().int().min(0),
134
+ }).strict().optional(),
135
+ correspondenceSummary: z.object({
136
+ markdownModuleCount: z.number().int().min(0),
137
+ exactModuleCount: z.number().int().min(0),
138
+ markdownCaseCount: z.number().int().min(0),
139
+ exactCorrespondenceCount: z.number().int().min(0),
140
+ primarySymbolCount: z.number().int().min(0),
141
+ missingPytestCount: z.number().int().min(0),
142
+ multiplePytestCount: z.number().int().min(0),
143
+ extraPytestCount: z.number().int().min(0),
144
+ scriptMismatchCount: z.number().int().min(0),
145
+ testPointCount: z.number().int().min(0),
146
+ mappedTestPointCount: z.number().int().min(0),
147
+ variantTestPointCount: z.number().int().min(0),
148
+ assertionTestPointCount: z.number().int().min(0),
149
+ crossCuttingTestPointCount: z.number().int().min(0),
150
+ unclassifiedTestPointCount: z.number().int().min(0),
151
+ duplicateBindingTestPointCount: z.number().int().min(0),
152
+ }).strict().optional(),
153
+ artifactRefs: z.object({
154
+ caseCoverageFacts: z.object({ path: z.string().min(1), sha256: z.string().regex(/^[a-f0-9]{64}$/).optional() }).strict(),
155
+ correspondenceFacts: z.object({ path: z.string().min(1), sha256: z.string().regex(/^[a-f0-9]{64}$/).optional() }).strict(),
156
+ }).strict().optional(),
99
157
  cases: z.array(caseSchema),
100
158
  evidenceGaps: z.array(gapSchema).default([]),
101
159
  coverageSummary: z
@@ -6,6 +6,7 @@ import { createWriteStream } from "node:fs";
6
6
  import path from "node:path";
7
7
  import { promisify } from "node:util";
8
8
  import { parseJacocoXml, } from "./backend-test-coverage-contract.js";
9
+ import { backendTestCaseManifestSchema, computeCaseManifestCoverageSummary, } from "./backend-test-case-manifest.js";
9
10
  const execFileAsync = promisify(execFile);
10
11
  // Canonical contract is three digits (BE-MODULE-001). Readers accept legacy/
11
12
  // model-drift two-digit suffixes and canonicalize them in memory so one bad
@@ -247,12 +248,20 @@ function extractSourceReferences(input) {
247
248
  // rejects them instead of silently accepting a nearby bound prefix.
248
249
  return unique([...boundMatches, ...citedPaths]);
249
250
  }
251
+ function hasStructuredMarkdownTable(value, headerPattern) {
252
+ const rows = value.split(/\r?\n/).filter((line) => /^\s*\|.*\|\s*$/.test(line));
253
+ if (rows.length < 3 || !headerPattern.test(rows[0]))
254
+ return false;
255
+ return /^\s*\|(?:\s*:?-{3,}:?\s*\|)+\s*$/i.test(rows[1]) && rows.slice(2).some((row) => row.split("|").some((cell) => cell.trim().length > 0));
256
+ }
250
257
  function hasNumberedListItem(value) {
251
258
  return (/^\s*\d+[.)]\s+\S+/m.test(value) ||
252
- /^\s*\|\s*\d+\s*\|\s*\S+/m.test(value));
259
+ /^\s*\|\s*\d+\s*\|\s*\S+/m.test(value) ||
260
+ hasStructuredMarkdownTable(value, /(?:步骤|操作|测试点|参数|step|action|operation|test point|parameter)/i));
253
261
  }
254
262
  function hasAssertableExpectedResult(value) {
255
- return /^\s*(?:\d+[.)]|[-*+])\s+\S+/m.test(value);
263
+ return (/^\s*(?:\d+[.)]|[-*+])\s+\S+/m.test(value) ||
264
+ hasStructuredMarkdownTable(value, /(?:预期|期望|断言|状态|结果|expected|assert|status|result)/i));
256
265
  }
257
266
  function resolveBoundSourceReference(input) {
258
267
  if (!input.sourceBinding) {
@@ -879,7 +888,10 @@ function reportOutputLines(value, prefix) {
879
888
  const trimmed = line.startsWith(underscorePrefix)
880
889
  ? line.slice(underscorePrefix.length)
881
890
  : line.slice(spacePrefix.length);
882
- return redactBackendTestOutput(trimmed.trim()).slice(0, 4000);
891
+ // Preserve a complete single-line JSON payload through parsing. Bounding
892
+ // happens after structural extraction; slicing here can corrupt valid JSON
893
+ // and make a large passed response appear as an empty body.
894
+ return redactBackendTestOutput(trimmed.trim());
883
895
  });
884
896
  }
885
897
  /**
@@ -896,9 +908,16 @@ export function normalizeBackendTestHttpLogText(value) {
896
908
  while (i < lines.length) {
897
909
  const raw = lines[i];
898
910
  const line = raw.trim();
899
- // Already-contract lines: pass through.
900
- if (/^(HTTP_REQUEST|HTTP REQUEST|HTTP_RESPONSE|HTTP RESPONSE)\b/i.test(line)) {
901
- out.push(raw);
911
+ // Canonical and compatible single-line forms. Normalize case, separator,
912
+ // and an optional colon so downstream parsing only consumes the canonical
913
+ // HTTP_REQUEST / HTTP_RESPONSE protocol. Examples accepted here include
914
+ // `HTTP_REQUEST {...}`, `HTTP REQUEST method=...`, and the generated-helper
915
+ // dialect `HTTP request: {...}`.
916
+ const protocolLine = line.match(/^HTTP(?:_|\s+)(REQUEST|RESPONSE)\b\s*:?\s*(.*)$/i);
917
+ if (protocolLine) {
918
+ const canonicalPrefix = `HTTP_${protocolLine[1].toUpperCase()}`;
919
+ const payload = protocolLine[2]?.trim() ?? "";
920
+ out.push(payload ? `${canonicalPrefix} ${payload}` : canonicalPrefix);
902
921
  i += 1;
903
922
  continue;
904
923
  }
@@ -1000,12 +1019,13 @@ function parseHttpLogPayload(line, kind) {
1000
1019
  const trimmed = line.trim();
1001
1020
  if (!trimmed)
1002
1021
  return {};
1022
+ let parsedJson;
1003
1023
  let kv = {};
1004
1024
  // Try JSON first (legacy HTTP_REQUEST {"method":...} shape).
1005
1025
  if (trimmed.startsWith("{")) {
1006
1026
  try {
1007
- const parsed = JSON.parse(trimmed);
1008
- for (const [key, value] of Object.entries(parsed)) {
1027
+ parsedJson = JSON.parse(trimmed);
1028
+ for (const [key, value] of Object.entries(parsedJson)) {
1009
1029
  kv[key.toLowerCase()] = typeof value === "string" ? value : safeStringify(value);
1010
1030
  }
1011
1031
  }
@@ -1017,21 +1037,68 @@ function parseHttpLogPayload(line, kind) {
1017
1037
  kv = extractKvPairs(trimmed);
1018
1038
  }
1019
1039
  if (kind === "request") {
1040
+ const queryEnvelope = asHttpLogRecord(parsedJson?.query);
1041
+ const method = kv.method ?? httpLogScalar(queryEnvelope?.method);
1042
+ const url = kv.url ?? kv.path ?? httpLogScalar(queryEnvelope?.url) ?? httpLogScalar(queryEnvelope?.path);
1043
+ let body = kv.parameters ?? kv.payload ?? kv.data;
1044
+ if (!body && parsedJson) {
1045
+ const parameters = {};
1046
+ if (queryEnvelope) {
1047
+ const businessQuery = Object.fromEntries(Object.entries(queryEnvelope).filter(([key]) => !["method", "url", "path"].includes(key)));
1048
+ parameters.query = businessQuery;
1049
+ }
1050
+ else if (Object.hasOwn(parsedJson, "query")) {
1051
+ parameters.query = parsedJson.query;
1052
+ }
1053
+ for (const key of ["body", "json"]) {
1054
+ if (Object.hasOwn(parsedJson, key))
1055
+ parameters[key] = parsedJson[key];
1056
+ }
1057
+ if (Object.keys(parameters).length > 0)
1058
+ body = boundedHttpLogBody(parameters);
1059
+ }
1020
1060
  return {
1021
- method: kv.method,
1022
- url: kv.url,
1023
- body: kv.parameters ?? kv.body ?? kv.json ?? kv.data ?? kv.query ?? kv.payload,
1061
+ method,
1062
+ url,
1063
+ body: boundedHttpLogBody(body ?? kv.body ?? kv.json ?? kv.query),
1024
1064
  };
1025
1065
  }
1026
- const status = kv.status_code ?? kv.status;
1066
+ const nestedJson = asHttpLogRecord(parsedJson?.json);
1067
+ const nestedTransport = !kv.status_code && !kv.status
1068
+ && Boolean(nestedJson && (Object.hasOwn(nestedJson, "status_code") || Object.hasOwn(nestedJson, "body")));
1069
+ const status = kv.status_code ?? kv.status
1070
+ ?? (nestedTransport ? httpLogScalar(nestedJson?.status_code ?? nestedJson?.status) : undefined);
1071
+ const body = kv.result
1072
+ ?? (nestedTransport ? boundedHttpLogBody(nestedJson?.body ?? nestedJson) : kv.json)
1073
+ ?? kv.body
1074
+ ?? kv.text;
1027
1075
  return {
1028
1076
  method: kv.method,
1029
- url: kv.url,
1077
+ url: kv.url ?? kv.path,
1030
1078
  status,
1031
1079
  statusText: kv.status_text ?? (status ? HTTP_STATUS_TEXT[status] : undefined),
1032
- body: kv.result ?? kv.body ?? kv.text,
1080
+ body: boundedHttpLogBody(body),
1033
1081
  };
1034
1082
  }
1083
+ function asHttpLogRecord(value) {
1084
+ return value !== null && typeof value === "object" && !Array.isArray(value)
1085
+ ? value
1086
+ : undefined;
1087
+ }
1088
+ function httpLogScalar(value) {
1089
+ return typeof value === "string" || typeof value === "number"
1090
+ ? String(value)
1091
+ : undefined;
1092
+ }
1093
+ function boundedHttpLogBody(value) {
1094
+ if (value === undefined)
1095
+ return undefined;
1096
+ const text = typeof value === "string" ? value : safeStringify(value);
1097
+ const maxChars = 4000;
1098
+ return text.length <= maxChars
1099
+ ? text
1100
+ : `${text.slice(0, maxChars)}\n… [truncated ${text.length - maxChars} chars]`;
1101
+ }
1035
1102
  function extractKvPairs(value) {
1036
1103
  const result = {};
1037
1104
  // Split on top-level spaces but keep {...}/[...] payloads intact.
@@ -1242,7 +1309,9 @@ export function renderBackendTestHtml(input) {
1242
1309
  const passRate = input.parsed.tests > 0 ? input.parsed.passed / input.parsed.tests : 0;
1243
1310
  const catalog = new Map((input.cases ?? []).map((item) => [item.id, item]));
1244
1311
  const caseValidation = parseAdvisorySummary(input.caseValidationSummary);
1312
+ const caseCoverage = parseAdvisorySummary(input.caseCoverageSummary);
1245
1313
  const traceability = parseAdvisorySummary(input.traceabilitySummary);
1314
+ const correspondence = parseAdvisorySummary(input.correspondenceSummary);
1246
1315
  const failures = input.parsed.cases.filter((result) => result.status !== "passed");
1247
1316
  const headColor = failed ? { fg: "#b42318", bg: "linear-gradient(135deg,#fef3f2 0,#fee4e2 100%)", border: "#fda29b", icon: "✗" } : { fg: "#067647", bg: "linear-gradient(135deg,#ecfdf3 0,#d1fadf 100%)", border: "#abefc6", icon: "✓" };
1248
1317
  const orderedCases = input.parsed.cases
@@ -1271,7 +1340,7 @@ export function renderBackendTestHtml(input) {
1271
1340
  const metricCard = (label, value, valueColor = "#172033") => `<div style="padding:14px 16px;border:1px solid #e3e8ef;border-radius:12px;background:#fbfcfe"><span style="color:#667085;font-size:0.78rem">${escapeHtml(label)}</span><div style="font-size:22px;font-weight:700;color:${valueColor}">${value}</div></div>`;
1272
1341
  const metrics = `<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:12px;margin-top:14px">${metricCard("用例总数", input.parsed.tests)}${metricCard("通过", input.parsed.passed, "#067647")}${metricCard("失败", input.parsed.failed)}${metricCard("错误", input.parsed.errors)}${metricCard("跳过", input.parsed.skipped)}<div style="padding:14px 16px;border:1px solid #e3e8ef;border-radius:12px;background:#fbfcfe"><span style="color:#667085;font-size:0.78rem">通过率</span><div style="font-size:22px;font-weight:700;color:#067647">${(passRate * 100).toFixed(2)}%</div><span style="color:#667085;font-size:0.78rem">耗时 ${formatDuration(input.parsed.durationMs)}</span></div></div>`;
1273
1342
  const summaryBanner = `<div style="display:flex;align-items:center;gap:16px;padding:16px 18px;border-radius:14px;background:${headColor.bg};border:1px solid ${headColor.border}"><div style="flex:0 0 auto;width:44px;height:44px;border-radius:999px;background:${headColor.fg};display:flex;align-items:center;justify-content:center;color:#fff;font-size:24px;font-weight:900;box-shadow:0 4px 12px ${headColor.fg}4d">${headColor.icon}</div><div><div style="color:${headColor.fg};font-size:1.05rem;font-weight:800;line-height:1.3">${failed ? "本轮测试未通过" : "本轮测试通过"}</div><div style="color:${headColor.fg};font-size:0.88rem;margin-top:2px">${failed ? `${input.parsed.failed + input.parsed.errors} 条用例失败或错误` : `${input.parsed.passed} 条用例全部执行成功`}</div></div></div>`;
1274
- const qualityBlock = `<div style="margin-top:18px"><div style="color:#17365d;font-size:16px;font-weight:700;margin:0 4px 6px">质量校验</div><div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px;margin-top:8px">${renderQualityCard("Markdown 用例校验", caseValidation, input.caseValidationSummary)}${renderQualityCard("Markdown → pytest 追溯", traceability, input.traceabilitySummary)}</div></div>`;
1343
+ const qualityBlock = `<div style="margin-top:18px"><div style="color:#17365d;font-size:16px;font-weight:700;margin:0 4px 6px">质量校验</div><div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px;margin-top:8px">${renderQualityCard("Markdown 用例校验", caseValidation, input.caseValidationSummary)}${renderQualityCard("用例场景覆盖分析", caseCoverage, input.caseCoverageSummary)}${renderQualityCard("Markdown → pytest 追溯", traceability, input.traceabilitySummary)}${renderQualityCard("Markdown → pytest 一一对应", correspondence, input.correspondenceSummary)}</div></div>`;
1275
1344
  const failureOverview = failures.length > 0
1276
1345
  ? `<div style="margin-top:18px"><div style="color:#17365d;font-size:16px;font-weight:700;margin:0 4px 6px">失败概览</div><div style="display:grid;gap:10px;margin-top:8px">${failures.map((result) => {
1277
1346
  const caseId = resolveReportCaseId(result, catalog);
@@ -1381,6 +1450,118 @@ function dumpJacocoExec(host, port, destPath, connectTimeoutMs) {
1381
1450
  socket.pipe(writeStream);
1382
1451
  });
1383
1452
  }
1453
+ /**
1454
+ * Deterministically derive a Backend Test Case Manifest v1 from final
1455
+ * Markdown cases (testcase/md/**) and generated pytest scripts
1456
+ * (testcase/test_*.py). No model involvement: AC IDs come from the case
1457
+ * 验收标准/Acceptance Criteria section, automation status comes from whether
1458
+ * a pytest test function references the Case ID. The output feeds
1459
+ * computeCaseManifestCoverageSummary so the L-5 dashboard shows real
1460
+ * AC/automation coverage instead of unavailable.
1461
+ */
1462
+ export async function deriveBackendTestCaseManifest(input) {
1463
+ const files = await markdownFiles(input.workspaceRoot);
1464
+ const manifestCases = [];
1465
+ for (const file of files) {
1466
+ if (path.basename(file).toLowerCase() === "readme.md")
1467
+ continue;
1468
+ const content = await readFile(file, "utf8");
1469
+ for (const testCase of splitCases(content)) {
1470
+ const acIds = unique(testCase.body.match(AC_ID) ?? []);
1471
+ if (acIds.length === 0)
1472
+ continue; // skip cases without AC binding
1473
+ const mappedScripts = extractMappedPytestScripts(testCase.body);
1474
+ const category = inferCaseCategory(testCase.body);
1475
+ let generated = false;
1476
+ let symbol;
1477
+ let scriptFile;
1478
+ for (const script of mappedScripts) {
1479
+ if (!isSafeBackendPytestScript(script, input.workspaceRoot))
1480
+ continue;
1481
+ const absolute = path.resolve(input.workspaceRoot, script);
1482
+ if (!(await exists(absolute)))
1483
+ continue;
1484
+ const source = await readFile(absolute, "utf8");
1485
+ const match = findCaseIdInPytest(source, testCase.id);
1486
+ if (match) {
1487
+ generated = true;
1488
+ symbol = match;
1489
+ scriptFile = script;
1490
+ break;
1491
+ }
1492
+ }
1493
+ manifestCases.push({
1494
+ caseId: testCase.id,
1495
+ acIds,
1496
+ title: inferCaseTitle(testCase.body, testCase.id),
1497
+ category,
1498
+ automationStatus: generated ? "generated" : "planned",
1499
+ ...(scriptFile ? { file: scriptFile } : {}),
1500
+ ...(symbol ? { symbol } : {}),
1501
+ ...(generated ? {} : { gapReason: "pytest symbol not yet generated or not associated" }),
1502
+ });
1503
+ }
1504
+ }
1505
+ const manifest = backendTestCaseManifestSchema.parse({
1506
+ schemaVersion: 1,
1507
+ sourceBinding: {
1508
+ taskId: input.sourceBinding.taskId,
1509
+ requirementPath: input.sourceBinding.requirementPath,
1510
+ requirementSha256: input.sourceBinding.requirementSha256,
1511
+ referencePaths: input.sourceBinding.referencePaths,
1512
+ requirementIds: input.sourceBinding.requirementIds,
1513
+ },
1514
+ cases: manifestCases,
1515
+ evidenceGaps: [],
1516
+ coverageSummary: computeCaseManifestCoverageSummary({
1517
+ cases: manifestCases,
1518
+ evidenceGaps: [],
1519
+ sourceBinding: input.sourceBinding,
1520
+ }),
1521
+ });
1522
+ return manifest;
1523
+ }
1524
+ function inferCaseCategory(body) {
1525
+ const text = body.toLowerCase();
1526
+ if (/非法|无效|错误|不存在|越权|negative|invalid|unauthorized|forbidden|40[0-9]/.test(text))
1527
+ return "negative";
1528
+ if (/边界|boundary|edge|limit|max|min/.test(text))
1529
+ return "boundary";
1530
+ if (/鉴权|权限|登录|token|auth|permission|role/.test(text))
1531
+ return "auth";
1532
+ if (/状态|流转|state|transition|workflow/.test(text))
1533
+ return "state-transition";
1534
+ if (/超时|timeout/.test(text))
1535
+ return "timeout";
1536
+ if (/并发|concurren/.test(text))
1537
+ return "concurrency";
1538
+ return "positive";
1539
+ }
1540
+ function inferCaseTitle(body, caseId) {
1541
+ // ## BE-XXX-001|中文标题 → extract title after the delimiter
1542
+ const heading = body.split(/\r?\n/, 1)[0] ?? "";
1543
+ const delimiterMatch = heading.match(/[||]\s*(.+)$/);
1544
+ if (delimiterMatch)
1545
+ return delimiterMatch[1].trim();
1546
+ return caseId;
1547
+ }
1548
+ function findCaseIdInPytest(source, caseId) {
1549
+ for (const match of source.matchAll(/^(\s*)(?:async\s+)?def\s+(test_[A-Za-z0-9_]+)\s*\([^)]*\)\s*(?:->\s*[^:\r\n]+)?\s*:/gm)) {
1550
+ const symbol = match[2];
1551
+ const region = testFunctionRegion({
1552
+ source,
1553
+ functionIndex: match.index,
1554
+ functionHeaderEnd: match.index + match[0].length,
1555
+ });
1556
+ const ids = new Set(caseIdsInText(region));
1557
+ const fromSymbol = symbolCaseId(symbol);
1558
+ if (fromSymbol)
1559
+ ids.add(fromSymbol);
1560
+ if (ids.has(caseId))
1561
+ return symbol;
1562
+ }
1563
+ return undefined;
1564
+ }
1384
1565
  function inferModuleScript(classname) {
1385
1566
  // classname like "test_process_definition_list.py::TestX::test_a" → take the file stem.
1386
1567
  const first = classname.split("::")[0] ?? classname;
@@ -1444,7 +1625,9 @@ export function renderBackendTestL5Dashboard(input) {
1444
1625
  return `<div style="padding:13px 14px;border:1px solid #e4e9f1;border-radius:11px;background:#fbfcfe"><div style="display:flex;justify-content:space-between;align-items:center;gap:8px"><span style="color:#2a3c5a;font-size:12px;font-weight:700;font-family:ui-monospace,SFMono-Regular,Menlo,monospace">${escapeHtml(stat.script)}</span><span style="display:inline-flex;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:800;color:${badge.fg};background:${badge.bg}">${badge.t}</span></div><div style="height:6px;margin:9px 0 6px;background:#edf1f6;border-radius:99px;overflow:hidden;display:flex"><i style="display:block;height:100%;width:${passPct}%;background:#15815c"></i><i style="display:block;height:100%;width:${failPct}%;background:#d34661"></i></div></div>`;
1445
1626
  }).join("");
1446
1627
  const caseValidation = parseAdvisorySummary(input.caseValidationSummary);
1628
+ const caseCoverage = parseAdvisorySummary(input.caseCoverageSummary);
1447
1629
  const traceability = parseAdvisorySummary(input.traceabilitySummary);
1630
+ const correspondence = parseAdvisorySummary(input.correspondenceSummary);
1448
1631
  const qRow = (name, status, extra = "") => {
1449
1632
  const c = status === "PASS" ? { fg: "#15815c", bg: "#e7f7f0" } : status === "FAIL" ? { fg: "#d34661", bg: "#fff0f3" } : { fg: "#718097", bg: "#eef2f7" };
1450
1633
  return `<div style="display:flex;align-items:center;justify-content:space-between;padding:11px 13px;border:1px solid #e4e9f1;border-radius:10px;background:#fbfcfe"><span style="color:#2a3c5a;font-size:13px;font-weight:650">${escapeHtml(name)}</span><span style="font-size:11px;font-weight:800;color:${c.fg};background:${c.bg};padding:2px 8px;border-radius:999px">${status === "Unavailable" ? "UNAVAILABLE" : status}${extra}</span></div>`;
@@ -1496,7 +1679,9 @@ export function renderBackendTestL5Dashboard(input) {
1496
1679
  <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
1497
1680
  ${qRow("环境硬门(Shell)", "PASS")}
1498
1681
  ${qRow("Markdown 用例校验(节点4)", caseValidation.status, caseValidation.findings !== undefined ? ` · ${caseValidation.findings}` : "")}
1682
+ ${qRow("用例场景覆盖分析(节点4)", caseCoverage.status, caseCoverage.findings !== undefined ? ` · ${caseCoverage.findings}` : "")}
1499
1683
  ${qRow("Markdown → pytest 追溯(节点6)", traceability.status, traceability.findings !== undefined ? ` · ${traceability.findings}` : "")}
1684
+ ${qRow("Markdown → pytest 一一对应(节点6)", correspondence.status, correspondence.findings !== undefined ? ` · ${correspondence.findings}` : "")}
1500
1685
  ${qRow("HTTP 请求/响应日志(节点6 advisory)", traceability.status)}
1501
1686
  ${qRow("Markdown 敏感信息", "PASS")}
1502
1687
  ${qRow("代码覆盖率", m.lineCoverage.status === "unavailable" && m.lineCoverage.reason !== null ? "Unavailable" : "PASS")}
@@ -1504,14 +1689,27 @@ export function renderBackendTestL5Dashboard(input) {
1504
1689
  </section>
1505
1690
  ${failures ? `<section style="background:#fff;border:1px solid #e0e7f0;border-radius:15px;box-shadow:0 7px 20px rgba(25,53,92,.04);padding:22px;margin-top:20px"><div style="display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:12px"><h2 style="margin:0;color:#17365d;font-size:19px">缺陷记录</h2><span style="color:#718097;font-size:13px">${input.failures?.length ?? 0} 条 · 已关联用例与证据</span></div>${failures}</section>` : ""}
1506
1691
  <section style="background:#fff;border:1px solid #e0e7f0;border-radius:15px;box-shadow:0 7px 20px rgba(25,53,92,.04);padding:22px;margin-top:20px">
1507
- <p style="margin:0;color:#718097;font-size:12px">报告由 backend-test DAG 节点 7(execute-backend-pytest-and-html-report-shell)基于本轮真实执行产物确定性渲染。L-5 指标由 <code style="color:#17365d;font:11px ui-monospace,SFMono-Regular,Menlo,monospace">computeL5ReportMetrics</code> 机器计算。判定依据:pass=100%、AC=100%、automation≥90%、line≥80%、branch≥70%、skipped=0、无阻断级 Critical 风险。</p>
1692
+ <p style="margin:0;color:#718097;font-size:12px">报告由 backend-test DAG 节点 8(execute-backend-pytest-and-html-report-shell)基于本轮真实执行产物确定性渲染。L-5 指标由 <code style="color:#17365d;font:11px ui-monospace,SFMono-Regular,Menlo,monospace">computeL5ReportMetrics</code> 机器计算。判定依据:pass=100%、AC=100%、automation≥90%、line≥80%、branch≥70%、skipped=0、无阻断级 Critical 风险。</p>
1508
1693
  </section>
1509
1694
  </div></body></html>`;
1510
1695
  }
1511
1696
  export function renderBackendTestFacts(input) {
1512
1697
  const passRate = input.parsed.tests > 0 ? input.parsed.passed / input.parsed.tests : 0;
1513
1698
  const caseValidation = parseAdvisorySummary(input.caseValidationSummary);
1699
+ const caseCoverage = parseAdvisorySummary(input.caseCoverageSummary);
1514
1700
  const traceability = parseAdvisorySummary(input.traceabilitySummary);
1701
+ const correspondence = parseAdvisorySummary(input.correspondenceSummary);
1702
+ const correspondenceNumber = (label) => {
1703
+ const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1704
+ const match = new RegExp(`^- ${escaped}: (\\d+)$`, "mi").exec(input.correspondenceSummary ?? "");
1705
+ return match ? Number.parseInt(match[1], 10) : undefined;
1706
+ };
1707
+ const markdownCaseCount = correspondenceNumber("Markdown Cases");
1708
+ const primarySymbolCount = correspondenceNumber("Primary pytest symbols");
1709
+ const variantTestPointCount = correspondenceNumber("Variant Test Points");
1710
+ const assertionTestPointCount = correspondenceNumber("Assertion Test Points");
1711
+ const crossCuttingTestPointCount = correspondenceNumber("Cross-Cutting Test Points");
1712
+ const executionAmplification = markdownCaseCount && markdownCaseCount > 0 ? input.parsed.tests / markdownCaseCount : undefined;
1515
1713
  const catalog = new Map((input.cases ?? []).map((entry) => [entry.id, entry]));
1516
1714
  return ([
1517
1715
  "# 后端自动化测试执行报告",
@@ -1525,6 +1723,12 @@ export function renderBackendTestFacts(input) {
1525
1723
  `| ${input.pytestExitCode === 0 ? "通过" : "未通过"} | ${input.parsed.tests} | ${input.parsed.passed} | ${input.parsed.failed} | ${input.parsed.errors} | ${input.parsed.skipped} | ${(passRate * 100).toFixed(2)}% | ${formatDuration(input.parsed.durationMs)} |`,
1526
1724
  "",
1527
1725
  `- Pytest exit code: ${input.pytestExitCode}`,
1726
+ `- Markdown Cases: ${markdownCaseCount ?? "unavailable"}`,
1727
+ `- Primary pytest symbols: ${primarySymbolCount ?? "unavailable"}`,
1728
+ `- Collected pytest items: ${input.parsed.tests}`,
1729
+ `- Test Point bindings: variant=${variantTestPointCount ?? "unavailable"}, assertion=${assertionTestPointCount ?? "unavailable"}, cross-cutting=${crossCuttingTestPointCount ?? "unavailable"}`,
1730
+ `- Execution amplification (items/case): ${executionAmplification === undefined ? "unavailable" : executionAmplification.toFixed(2)}`,
1731
+ "- Pytest item count is an execution-unit count, not a business-scenario count.",
1528
1732
  "- Pytest-html report: valid",
1529
1733
  "- HTML valid: yes",
1530
1734
  "",
@@ -1533,7 +1737,9 @@ export function renderBackendTestFacts(input) {
1533
1737
  "| 校验项 | 状态 | Findings | 首要发现 |",
1534
1738
  "|---|---|---:|---|",
1535
1739
  `| Markdown 用例校验 | ${caseValidation.status} | ${caseValidation.findings ?? "不可用"} | ${caseValidation.firstFinding.replaceAll("|", "\\|")} |`,
1740
+ `| 用例场景覆盖分析 | ${caseCoverage.status} | ${caseCoverage.findings ?? "不可用"} | ${caseCoverage.firstFinding.replaceAll("|", "\\|")} |`,
1536
1741
  `| Markdown → pytest 追溯 | ${traceability.status} | ${traceability.findings ?? "不可用"} | ${traceability.firstFinding.replaceAll("|", "\\|")} |`,
1742
+ `| Markdown → pytest 一一对应 | ${correspondence.status} | ${correspondence.findings ?? "不可用"} | ${correspondence.firstFinding.replaceAll("|", "\\|")} |`,
1537
1743
  "",
1538
1744
  "## 失败概览",
1539
1745
  "",
@@ -1565,12 +1771,26 @@ export function renderBackendTestFacts(input) {
1565
1771
  "</details>",
1566
1772
  "",
1567
1773
  "<details>",
1774
+ "<summary>用例场景覆盖分析原文</summary>",
1775
+ "",
1776
+ input.caseCoverageSummary?.trim() ?? "Unavailable",
1777
+ "",
1778
+ "</details>",
1779
+ "",
1780
+ "<details>",
1568
1781
  "<summary>Markdown → pytest 追溯原文</summary>",
1569
1782
  "",
1570
1783
  input.traceabilitySummary?.trim() ?? "Unavailable",
1571
1784
  "",
1572
1785
  "</details>",
1573
1786
  "",
1787
+ "<details>",
1788
+ "<summary>Markdown → pytest 一一对应原文</summary>",
1789
+ "",
1790
+ input.correspondenceSummary?.trim() ?? "Unavailable",
1791
+ "",
1792
+ "</details>",
1793
+ "",
1574
1794
  "## 覆盖率证据",
1575
1795
  "",
1576
1796
  "- Code coverage: unavailable unless a separate validated coverage artifact exists.",