@tea-agent/loop-agent 0.25.3 → 0.25.5

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 (49) hide show
  1. package/AGENTS.md +6 -0
  2. package/CHANGELOG.md +55 -0
  3. package/dist/application/dag/args.js +21 -1
  4. package/dist/application/dag/run-dag.js +1 -0
  5. package/dist/cli/command-definitions.js +1 -1
  6. package/dist/cli/program.js +82 -63
  7. package/dist/commands/client-recovery.js +209 -62
  8. package/dist/commands/init.js +206 -82
  9. package/dist/commands/run-dag-progress.js +109 -0
  10. package/dist/commands/run-dag.js +16 -5
  11. package/dist/executors/dag-pi-executor.js +80 -15
  12. package/dist/executors/model-routing.js +1 -1
  13. package/dist/executors/shell-executor.js +159 -0
  14. package/dist/executors/shell-write-guard.js +21 -7
  15. package/dist/worker/console/repo-fingerprint.js +7 -1
  16. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +964 -0
  17. package/dist/workflows/dag/backend-test-case-manifest.js +39 -1
  18. package/dist/workflows/dag/backend-test-markdown-workflow.js +306 -30
  19. package/dist/workflows/dag/backend-test-result-contract.js +35 -9
  20. package/dist/workflows/dag/convergence/controller.js +134 -9
  21. package/dist/workflows/dag/frontend-test-case-checklist.js +71 -0
  22. package/dist/workflows/dag/frontend-test-html-report.js +77 -0
  23. package/dist/workflows/dag/frontend-test-l5-report.js +138 -0
  24. package/dist/workflows/dag/frontend-test-result-contract.js +44 -1
  25. package/dist/workflows/dag/init-hybrid.js +267 -80
  26. package/dist/workflows/dag/node-execution.js +64 -11
  27. package/dist/workflows/dag/prompt.js +118 -4
  28. package/dist/workflows/dag/retry-policy.js +5 -4
  29. package/dist/workflows/dag/scheduler.js +32 -5
  30. package/dist/workflows/dag/types.js +10 -3
  31. package/dist/workflows/dag/validate.js +6 -3
  32. package/docs/architecture/dag-execution.md +7 -4
  33. package/docs/architecture/runtime-boundaries.md +1 -1
  34. package/docs/templates/agent-dag.base.json +1 -1
  35. package/docs/templates/agent-dag.final-verification.json +1 -1
  36. package/docs/templates/agent-dag.schema.json +6 -0
  37. package/docs/templates/agent-dag.supervised-implementation.json +1 -1
  38. package/docs/templates/backend-test-dag.json +40 -13
  39. package/docs/templates/backend-test-dag.review-cases.prompt.md +1 -1
  40. package/docs/templates/frontend-test-dag.json +62 -5
  41. package/docs/templates/hybrid-dag.json +1 -1
  42. package/examples/decision-gate-agent-dag.json +1 -1
  43. package/examples/example-dag.json +1 -1
  44. package/examples/hybrid-loop-agent-dag.json +1 -1
  45. package/harness.json +3 -2
  46. package/package.json +1 -1
  47. package/skills/loop-agent/references/command-reference.md +2 -1
  48. package/skills/loop-agent/references/hybrid-dag.md +2 -2
  49. 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,44 @@ 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
+ ruleCoverageSummary: z.object({
102
+ explicitAcCount: z.number().int().min(0),
103
+ coveredAcCount: z.number().int().min(0),
104
+ ruleCount: z.number().int().min(0),
105
+ coveredRuleCount: z.number().int().min(0),
106
+ testPointCount: z.number().int().min(0),
107
+ coveredTestPointCount: z.number().int().min(0),
108
+ enumValueCount: z.number().int().min(0),
109
+ coveredEnumValueCount: z.number().int().min(0),
110
+ invalidEquivalenceClassCount: z.number().int().min(0),
111
+ coveredInvalidEquivalenceClassCount: z.number().int().min(0),
112
+ boundaryPointCount: z.number().int().min(0),
113
+ coveredBoundaryPointCount: z.number().int().min(0),
114
+ formatClassCount: z.number().int().min(0),
115
+ coveredFormatClassCount: z.number().int().min(0),
116
+ businessStateCount: z.number().int().min(0),
117
+ coveredBusinessStateCount: z.number().int().min(0),
118
+ gapCount: z.number().int().min(0),
119
+ conflictCount: z.number().int().min(0),
120
+ }).strict().optional(),
121
+ correspondenceSummary: z.object({
122
+ markdownModuleCount: z.number().int().min(0),
123
+ exactModuleCount: z.number().int().min(0),
124
+ markdownCaseCount: z.number().int().min(0),
125
+ exactCorrespondenceCount: z.number().int().min(0),
126
+ missingPytestCount: z.number().int().min(0),
127
+ multiplePytestCount: z.number().int().min(0),
128
+ extraPytestCount: z.number().int().min(0),
129
+ scriptMismatchCount: z.number().int().min(0),
130
+ testPointCount: z.number().int().min(0),
131
+ mappedTestPointCount: z.number().int().min(0),
132
+ }).strict().optional(),
133
+ artifactRefs: z.object({
134
+ caseCoverageFacts: z.object({ path: z.string().min(1), sha256: z.string().regex(/^[a-f0-9]{64}$/).optional() }).strict(),
135
+ correspondenceFacts: z.object({ path: z.string().min(1), sha256: z.string().regex(/^[a-f0-9]{64}$/).optional() }).strict(),
136
+ }).strict().optional(),
99
137
  cases: z.array(caseSchema),
100
138
  evidenceGaps: z.array(gapSchema).default([]),
101
139
  coverageSummary: z
@@ -6,8 +6,13 @@ 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
- const CASE_ID = /\bBE-[A-Z0-9]+(?:-[A-Z0-9]+)*-\d{3}\b/g;
11
+ // Canonical contract is three digits (BE-MODULE-001). Readers accept legacy/
12
+ // model-drift two-digit suffixes and canonicalize them in memory so one bad
13
+ // heading width cannot collapse the whole manifest/report to zero cases.
14
+ const CASE_ID = /\bBE-[A-Z0-9_-]+-\d{2,3}\b/g;
15
+ const CANONICAL_CASE_ID = /\bBE-[A-Z0-9]+(?:-[A-Z0-9]+)*-\d{3}\b/;
11
16
  const AC_ID = /\bAC-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/g;
12
17
  const SECRET = /(?:password|passwd|secret|token|api[_-]?key|private[_-]?key|authorization)\s*[:=]\s*\S+/i;
13
18
  const SECRET_ASSIGNMENT = /\b(password|passwd|secret|token|api[_-]?key|private[_-]?key|authorization|cookie|set-cookie|credential)\b\s*[:=]\s*([^\r\n,;]+)/gi;
@@ -178,12 +183,27 @@ async function markdownFiles(root) {
178
183
  .map((entry) => path.join(directory, entry.name))
179
184
  .sort();
180
185
  }
186
+ function canonicalBackendCaseId(value) {
187
+ const upper = value.toUpperCase();
188
+ const match = upper.match(/^(BE-[A-Z0-9_-]+)-(\d{2,3})$/);
189
+ if (!match)
190
+ return upper;
191
+ const modulePart = match[1]
192
+ .replaceAll("_", "-")
193
+ .replace(/-+/g, "-")
194
+ .replace(/-$/, "");
195
+ return `${modulePart}-${match[2].padStart(3, "0")}`;
196
+ }
197
+ function caseIdsInText(value) {
198
+ return unique((value.match(CASE_ID) ?? []).map(canonicalBackendCaseId));
199
+ }
181
200
  function splitCases(markdown) {
182
201
  const headings = [
183
- ...markdown.matchAll(/^##\s+(BE-[A-Z0-9]+(?:-[A-Z0-9]+)*-\d{3})\b.*$/gm),
202
+ ...markdown.matchAll(/^##\s+(BE-[A-Z0-9_-]+-\d{2,3})\b.*$/gm),
184
203
  ];
185
204
  return headings.map((match, index) => ({
186
- id: match[1],
205
+ id: canonicalBackendCaseId(match[1]),
206
+ rawId: match[1],
187
207
  body: markdown.slice(match.index, headings[index + 1]?.index ?? markdown.length),
188
208
  }));
189
209
  }
@@ -228,12 +248,20 @@ function extractSourceReferences(input) {
228
248
  // rejects them instead of silently accepting a nearby bound prefix.
229
249
  return unique([...boundMatches, ...citedPaths]);
230
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
+ }
231
257
  function hasNumberedListItem(value) {
232
258
  return (/^\s*\d+[.)]\s+\S+/m.test(value) ||
233
- /^\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));
234
261
  }
235
262
  function hasAssertableExpectedResult(value) {
236
- 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));
237
265
  }
238
266
  function resolveBoundSourceReference(input) {
239
267
  if (!input.sourceBinding) {
@@ -396,6 +424,9 @@ export async function validateBackendMarkdownCases(input) {
396
424
  }
397
425
  for (const testCase of cases) {
398
426
  caseCount += 1;
427
+ if (!CANONICAL_CASE_ID.test(testCase.rawId)) {
428
+ findings.push(`${testCase.rawId} uses a non-canonical Case ID; use ${testCase.id} (hyphen-separated module, three-digit sequence)`);
429
+ }
399
430
  const previous = seen.get(testCase.id);
400
431
  if (previous) {
401
432
  findings.push(`duplicate case id ${testCase.id}: ${previous} and ${relativeFile}`);
@@ -545,8 +576,10 @@ function testFunctionRegion(input) {
545
576
  .join("\n");
546
577
  }
547
578
  function symbolCaseId(symbol) {
548
- const match = symbol.match(/^test_(BE(?:_[A-Z0-9]+)+?_\d{3})(?:_|$)/i);
549
- return match ? match[1].replaceAll("_", "-").toUpperCase() : undefined;
579
+ const match = symbol.match(/^test_(BE(?:_[A-Z0-9]+)+?_\d{2,3})(?:_|$)/i);
580
+ return match
581
+ ? canonicalBackendCaseId(match[1].replaceAll("_", "-"))
582
+ : undefined;
550
583
  }
551
584
  export async function validateBackendMarkdownTraceability(workspaceRoot) {
552
585
  const files = await markdownFiles(workspaceRoot);
@@ -606,7 +639,7 @@ export async function validateBackendMarkdownTraceability(workspaceRoot) {
606
639
  functionIndex: match.index,
607
640
  functionHeaderEnd: match.index + match[0].length,
608
641
  });
609
- const associatedIds = new Set(region.match(CASE_ID) ?? []);
642
+ const associatedIds = new Set(caseIdsInText(region));
610
643
  const fromSymbol = symbolCaseId(symbol);
611
644
  if (fromSymbol)
612
645
  associatedIds.add(fromSymbol);
@@ -711,7 +744,8 @@ export async function collectBackendTestMappedPytestScripts(workspaceRoot) {
711
744
  function cleanCaseTitle(rawHeading, id) {
712
745
  return rawHeading
713
746
  .replace(/^##\s+/, "")
714
- .replace(new RegExp(`^${escapeRegExp(id)}\\s*(?:[||—–-]\\s*)?`), "")
747
+ // Strip either canonical or compatible two-digit heading ID.
748
+ .replace(/^BE-[A-Z0-9_-]+-\d{2,3}\s*(?:[||—–-]\s*)?/i, "")
715
749
  .trim() || id;
716
750
  }
717
751
  export async function collectBackendTestHumanCaseCatalog(workspaceRoot) {
@@ -755,7 +789,7 @@ export async function collectBackendTestHumanCaseCatalog(workspaceRoot) {
755
789
  for (const match of content.matchAll(/^(\s*)(?:async\s+)?def\s+(test_[A-Za-z0-9_]+)\s*\([^)]*\)\s*(?:->\s*[^:\r\n]+)?\s*:/gm)) {
756
790
  const symbol = match[2];
757
791
  const region = testFunctionRegion({ source: content, functionIndex: match.index, functionHeaderEnd: match.index + match[0].length });
758
- const ids = new Set(region.match(CASE_ID) ?? []);
792
+ const ids = new Set(caseIdsInText(region));
759
793
  const fromSymbol = symbolCaseId(symbol);
760
794
  if (fromSymbol)
761
795
  ids.add(fromSymbol);
@@ -772,13 +806,13 @@ export async function collectBackendTestHumanCaseCatalog(workspaceRoot) {
772
806
  return [...catalog.values()].sort((left, right) => left.id.localeCompare(right.id));
773
807
  }
774
808
  function junitCaseId(name) {
775
- return symbolCaseId(name) ?? name.match(CASE_ID)?.[0];
809
+ return symbolCaseId(name) ?? caseIdsInText(name)[0];
776
810
  }
777
811
  /** Prefer function-name Case ID; fall back to first BE-* in free text (docstring/log). */
778
812
  function extractCaseIdFromText(value) {
779
813
  if (!value?.trim())
780
814
  return undefined;
781
- return junitCaseId(value) ?? value.match(CASE_ID)?.[0];
815
+ return junitCaseId(value) ?? caseIdsInText(value)[0];
782
816
  }
783
817
  function resolveReportCaseId(result, catalog) {
784
818
  const fromName = extractCaseIdFromText(result.name);
@@ -854,7 +888,10 @@ function reportOutputLines(value, prefix) {
854
888
  const trimmed = line.startsWith(underscorePrefix)
855
889
  ? line.slice(underscorePrefix.length)
856
890
  : line.slice(spacePrefix.length);
857
- 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());
858
895
  });
859
896
  }
860
897
  /**
@@ -871,9 +908,16 @@ export function normalizeBackendTestHttpLogText(value) {
871
908
  while (i < lines.length) {
872
909
  const raw = lines[i];
873
910
  const line = raw.trim();
874
- // Already-contract lines: pass through.
875
- if (/^(HTTP_REQUEST|HTTP REQUEST|HTTP_RESPONSE|HTTP RESPONSE)\b/i.test(line)) {
876
- 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);
877
921
  i += 1;
878
922
  continue;
879
923
  }
@@ -975,12 +1019,13 @@ function parseHttpLogPayload(line, kind) {
975
1019
  const trimmed = line.trim();
976
1020
  if (!trimmed)
977
1021
  return {};
1022
+ let parsedJson;
978
1023
  let kv = {};
979
1024
  // Try JSON first (legacy HTTP_REQUEST {"method":...} shape).
980
1025
  if (trimmed.startsWith("{")) {
981
1026
  try {
982
- const parsed = JSON.parse(trimmed);
983
- for (const [key, value] of Object.entries(parsed)) {
1027
+ parsedJson = JSON.parse(trimmed);
1028
+ for (const [key, value] of Object.entries(parsedJson)) {
984
1029
  kv[key.toLowerCase()] = typeof value === "string" ? value : safeStringify(value);
985
1030
  }
986
1031
  }
@@ -992,21 +1037,68 @@ function parseHttpLogPayload(line, kind) {
992
1037
  kv = extractKvPairs(trimmed);
993
1038
  }
994
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
+ }
995
1060
  return {
996
- method: kv.method,
997
- url: kv.url,
998
- 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),
999
1064
  };
1000
1065
  }
1001
- 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;
1002
1075
  return {
1003
1076
  method: kv.method,
1004
- url: kv.url,
1077
+ url: kv.url ?? kv.path,
1005
1078
  status,
1006
1079
  statusText: kv.status_text ?? (status ? HTTP_STATUS_TEXT[status] : undefined),
1007
- body: kv.result ?? kv.body ?? kv.text,
1080
+ body: boundedHttpLogBody(body),
1008
1081
  };
1009
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
+ }
1010
1102
  function extractKvPairs(value) {
1011
1103
  const result = {};
1012
1104
  // Split on top-level spaces but keep {...}/[...] payloads intact.
@@ -1175,15 +1267,62 @@ function renderHttpCallCard(call) {
1175
1267
  : "";
1176
1268
  return `<div style="border:1px solid #eef0f3;border-radius:10px;padding:12px 14px;margin-top:10px;background:#fcfdff">${header}${requestBlock}${responseBlock}</div>`;
1177
1269
  }
1270
+ function analyzeBackendTestFailure(result) {
1271
+ const evidence = [result.message, result.details].filter(Boolean).join("\n");
1272
+ const statusPatterns = [
1273
+ { pattern: /实际(?:\s*HTTP)?\s*状态码\s*[::]?\s*(\d{3})[\s\S]{0,80}?预期(?:\s*HTTP)?\s*状态码?\s*[::]?\s*(\d{3})/i, actualGroup: 1, expectedGroup: 2 },
1274
+ { pattern: /预期(?:\s*HTTP)?\s*状态码\s*[::]?\s*(\d{3})[\s\S]{0,80}?实际(?:\s*HTTP)?\s*状态码?\s*[::]?\s*(\d{3})/i, actualGroup: 2, expectedGroup: 1 },
1275
+ { pattern: /assert\s+(\d{3})\s*==\s*(\d{3})/i, actualGroup: 1, expectedGroup: 2 },
1276
+ { pattern: /expected\s*[:=]?\s*(\d{3})[\s\S]{0,80}?(?:actual|received|got)\s*[:=]?\s*(\d{3})/i, actualGroup: 2, expectedGroup: 1 },
1277
+ ];
1278
+ for (const candidate of statusPatterns) {
1279
+ const match = evidence.match(candidate.pattern);
1280
+ if (match) {
1281
+ const actual = match[candidate.actualGroup];
1282
+ const expected = match[candidate.expectedGroup];
1283
+ if (actual && expected) {
1284
+ return `HTTP 状态码不符合预期:期望 ${expected},实际 ${actual}。建议核对接口错误码契约、异常映射与测试前置数据。`;
1285
+ }
1286
+ }
1287
+ }
1288
+ const contentTypeMatch = evidence.match(/assert\s+(['"])(.*?)\1\.startswith\(\s*(['"])application\/json\3\s*\)/i);
1289
+ if (contentTypeMatch) {
1290
+ const actual = contentTypeMatch[2];
1291
+ const actualLabel = actual ? `为 ${actual}` : "为空值";
1292
+ return `响应 Content-Type 不符合预期:期望以 application/json 开头,实际${actualLabel}。建议核对接口响应头设置。`;
1293
+ }
1294
+ const assertionEvidence = (result.details ?? "")
1295
+ .split(/\r?\n/)
1296
+ .map((line) => line.trim().replace(/^E\s+/, ""))
1297
+ .find((line) => /^assert\b/i.test(line));
1298
+ const rawMessage = /^\d+:\s*AssertionError\s*$/i.test(result.message?.trim() ?? "") && assertionEvidence
1299
+ ? assertionEvidence
1300
+ : result.message || assertionEvidence || humanStatus(result.status);
1301
+ const message = rawMessage.replace(/\s+/g, " ").trim().slice(0, 240);
1302
+ if (result.status === "error") {
1303
+ return `测试执行发生错误:${message}。建议优先检查 fixture、依赖导入、环境连接与运行时异常。`;
1304
+ }
1305
+ return `断言未满足:${message}。建议结合失败详情及请求/响应证据核对接口行为与用例预期。`;
1306
+ }
1178
1307
  export function renderBackendTestHtml(input) {
1179
1308
  const failed = input.parsed.failed + input.parsed.errors > 0;
1180
1309
  const passRate = input.parsed.tests > 0 ? input.parsed.passed / input.parsed.tests : 0;
1181
1310
  const catalog = new Map((input.cases ?? []).map((item) => [item.id, item]));
1182
1311
  const caseValidation = parseAdvisorySummary(input.caseValidationSummary);
1312
+ const caseCoverage = parseAdvisorySummary(input.caseCoverageSummary);
1183
1313
  const traceability = parseAdvisorySummary(input.traceabilitySummary);
1314
+ const correspondence = parseAdvisorySummary(input.correspondenceSummary);
1184
1315
  const failures = input.parsed.cases.filter((result) => result.status !== "passed");
1185
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: "✓" };
1186
- const caseCards = input.parsed.cases.map((result) => {
1317
+ const orderedCases = input.parsed.cases
1318
+ .map((result, index) => ({ result, index }))
1319
+ .sort((left, right) => {
1320
+ const leftFailed = left.result.status === "failure" || left.result.status === "error" ? 0 : 1;
1321
+ const rightFailed = right.result.status === "failure" || right.result.status === "error" ? 0 : 1;
1322
+ return leftFailed - rightFailed || left.index - right.index;
1323
+ })
1324
+ .map(({ result }) => result);
1325
+ const caseCards = orderedCases.map((result) => {
1187
1326
  const caseId = resolveReportCaseId(result, catalog);
1188
1327
  const item = caseId === "未关联" ? undefined : catalog.get(caseId);
1189
1328
  const io = requestResponseSummary(result);
@@ -1201,20 +1340,23 @@ export function renderBackendTestHtml(input) {
1201
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>`;
1202
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>`;
1203
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>`;
1204
- 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)}${renderQualityCard("Markdown → pytest 追溯", traceability)}</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>`;
1205
1344
  const failureOverview = failures.length > 0
1206
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) => {
1207
1346
  const caseId = resolveReportCaseId(result, catalog);
1208
1347
  const c = statusColor(result.status);
1209
- return `<div style="border-left:4px solid ${c.fg};background:${c.bg};padding:10px 12px;border-radius:8px"><div style="font-weight:600;color:#172033;font-size:0.88rem">${escapeHtml(caseId)} · ${escapeHtml(catalog.get(caseId)?.title ?? result.name)}</div><div style="color:${c.fg};font-size:0.82rem;margin-top:2px">${escapeHtml(result.message || humanStatus(result.status))}</div></div>`;
1348
+ return `<div style="border-left:4px solid ${c.fg};background:${c.bg};padding:10px 12px;border-radius:8px"><div style="font-weight:600;color:#172033;font-size:0.88rem">${escapeHtml(caseId)} · ${escapeHtml(catalog.get(caseId)?.title ?? result.name)}</div><div style="color:${c.fg};font-size:0.82rem;margin-top:4px"><strong>原始失败:</strong>${escapeHtml(result.message || humanStatus(result.status))}</div><div style="color:#475467;font-size:0.82rem;margin-top:5px"><strong style="color:#17365d">原因分析:</strong>${escapeHtml(analyzeBackendTestFailure(result))}</div></div>`;
1210
1349
  }).join("")}</div></div>`
1211
1350
  : "";
1212
1351
  return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(input.title)}</title></head><body style="margin:0;background:linear-gradient(180deg,#eef2f7 0,#f7f9fc 40rem);color:#172033;font-family:system-ui,'Microsoft YaHei','PingFang SC',sans-serif;line-height:1.6"><div style="max-width:1000px;margin:0 auto;padding:32px 22px 56px"><div style="padding:8px 0 4px"><div style="color:#315f93;font-weight:700;letter-spacing:0.06em;font-size:0.76rem">后端自动化测试报告</div><h1 style="margin:6px 0 0;color:#17365d;font-size:24px;line-height:1.35">${escapeHtml(input.title)}</h1><p style="color:#667085;margin:8px 0 0;font-size:0.9rem">执行事实来自同一次 pytest 执行;用例默认折叠,点击任意用例可展开执行日志(接口、请求参数、响应结果)与检查点。</p></div><div style="background:#fff;border:1px solid #e3e8ef;border-radius:16px;padding:18px 20px;margin-top:16px;box-shadow:0 1px 2px rgba(16,24,40,0.04)">${summaryBanner}${metrics}</div><div style="background:#fff;border:1px solid #e3e8ef;border-radius:16px;padding:18px 20px;margin-top:16px;box-shadow:0 1px 2px rgba(16,24,40,0.04)">${qualityBlock}<div style="margin-top:12px;padding:10px 12px;background:#f6f8fa;border-radius:8px;font-size:0.82rem;color:#475467"><strong style="color:#667085">执行环境</strong>:${escapeHtml((input.environmentSummary || "不可用").split("\n").slice(0, 3).join(" ").slice(0, 200))}</div></div>${failureOverview}<div style="margin-top:18px"><div style="display:flex;align-items:baseline;justify-content:space-between;margin:0 4px 6px"><span style="color:#17365d;font-size:16px;font-weight:700">用例执行明细</span><span style="color:#98a2b3;font-size:0.8rem">共 ${input.parsed.cases.length} 条 · 点击展开</span></div>${caseCards || '<div style="padding:16px;border:1px dashed #e3e8ef;border-radius:12px;color:#667085;background:#fbfcfe;text-align:center">未发现可展示的用例。</div>'}</div></div></body></html>`;
1213
1352
  }
1214
- function renderQualityCard(title, summary) {
1353
+ function renderQualityCard(title, summary, rawSummary) {
1215
1354
  const colors = summary.status === "PASS" ? { fg: "#067647", bg: "#ecfdf3" } : summary.status === "FAIL" ? { fg: "#b42318", bg: "#fef3f2" } : { fg: "#9a6700", bg: "#fffaeb" };
1216
1355
  const label = summary.status === "Unavailable" ? "不可用" : summary.status;
1217
- return `<div style="padding:12px 14px;border:1px solid #e3e8ef;border-radius:10px;background:#fbfcfe"><div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px"><strong style="color:#17365d;font-size:0.88rem">${escapeHtml(title)}</strong><span style="display:inline-block;padding:2px 9px;border-radius:999px;font-weight:700;font-size:0.72rem;color:${colors.fg};background:${colors.bg}">${label}</span></div><div style="color:#667085;font-size:0.78rem">Findings:${summary.findings ?? "不可用"}</div><div style="color:#475467;font-size:0.8rem;margin-top:2px">${escapeHtml(summary.firstFinding)}</div></div>`;
1356
+ const details = rawSummary?.trim()
1357
+ ? `<details style="margin-top:9px;padding-top:8px;border-top:1px solid #e3e8ef"><summary style="cursor:pointer;color:#315f93;font-size:0.78rem;font-weight:700">查看详情 · ${escapeHtml(title)}详情</summary><pre style="white-space:pre-wrap;overflow-wrap:anywhere;background:#f6f8fa;color:#1f2937;padding:10px 12px;border-radius:8px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:0.74rem;margin:7px 0 0;border:1px solid #eceef2;line-height:1.55">${escapeHtml(rawSummary.trim())}</pre></details>`
1358
+ : "";
1359
+ return `<div style="padding:12px 14px;border:1px solid #e3e8ef;border-radius:10px;background:#fbfcfe"><div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px"><strong style="color:#17365d;font-size:0.88rem">${escapeHtml(title)}</strong><span style="display:inline-block;padding:2px 9px;border-radius:999px;font-weight:700;font-size:0.72rem;color:${colors.fg};background:${colors.bg}">${label}</span></div><div style="color:#667085;font-size:0.78rem">Findings:${summary.findings ?? "不可用"}</div><div style="color:#475467;font-size:0.8rem;margin-top:2px">${escapeHtml(summary.firstFinding)}</div>${details}</div>`;
1218
1360
  }
1219
1361
  /**
1220
1362
  * Dump JaCoCo execution data over TCP from a JaCoCo tcpserver agent, convert
@@ -1308,6 +1450,118 @@ function dumpJacocoExec(host, port, destPath, connectTimeoutMs) {
1308
1450
  socket.pipe(writeStream);
1309
1451
  });
1310
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
+ }
1311
1565
  function inferModuleScript(classname) {
1312
1566
  // classname like "test_process_definition_list.py::TestX::test_a" → take the file stem.
1313
1567
  const first = classname.split("::")[0] ?? classname;
@@ -1371,7 +1625,9 @@ export function renderBackendTestL5Dashboard(input) {
1371
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>`;
1372
1626
  }).join("");
1373
1627
  const caseValidation = parseAdvisorySummary(input.caseValidationSummary);
1628
+ const caseCoverage = parseAdvisorySummary(input.caseCoverageSummary);
1374
1629
  const traceability = parseAdvisorySummary(input.traceabilitySummary);
1630
+ const correspondence = parseAdvisorySummary(input.correspondenceSummary);
1375
1631
  const qRow = (name, status, extra = "") => {
1376
1632
  const c = status === "PASS" ? { fg: "#15815c", bg: "#e7f7f0" } : status === "FAIL" ? { fg: "#d34661", bg: "#fff0f3" } : { fg: "#718097", bg: "#eef2f7" };
1377
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>`;
@@ -1423,7 +1679,9 @@ export function renderBackendTestL5Dashboard(input) {
1423
1679
  <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
1424
1680
  ${qRow("环境硬门(Shell)", "PASS")}
1425
1681
  ${qRow("Markdown 用例校验(节点4)", caseValidation.status, caseValidation.findings !== undefined ? ` · ${caseValidation.findings}` : "")}
1682
+ ${qRow("用例场景覆盖分析(节点4)", caseCoverage.status, caseCoverage.findings !== undefined ? ` · ${caseCoverage.findings}` : "")}
1426
1683
  ${qRow("Markdown → pytest 追溯(节点6)", traceability.status, traceability.findings !== undefined ? ` · ${traceability.findings}` : "")}
1684
+ ${qRow("Markdown → pytest 一一对应(节点6)", correspondence.status, correspondence.findings !== undefined ? ` · ${correspondence.findings}` : "")}
1427
1685
  ${qRow("HTTP 请求/响应日志(节点6 advisory)", traceability.status)}
1428
1686
  ${qRow("Markdown 敏感信息", "PASS")}
1429
1687
  ${qRow("代码覆盖率", m.lineCoverage.status === "unavailable" && m.lineCoverage.reason !== null ? "Unavailable" : "PASS")}
@@ -1431,14 +1689,16 @@ export function renderBackendTestL5Dashboard(input) {
1431
1689
  </section>
1432
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>` : ""}
1433
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">
1434
- <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>
1435
1693
  </section>
1436
1694
  </div></body></html>`;
1437
1695
  }
1438
1696
  export function renderBackendTestFacts(input) {
1439
1697
  const passRate = input.parsed.tests > 0 ? input.parsed.passed / input.parsed.tests : 0;
1440
1698
  const caseValidation = parseAdvisorySummary(input.caseValidationSummary);
1699
+ const caseCoverage = parseAdvisorySummary(input.caseCoverageSummary);
1441
1700
  const traceability = parseAdvisorySummary(input.traceabilitySummary);
1701
+ const correspondence = parseAdvisorySummary(input.correspondenceSummary);
1442
1702
  const catalog = new Map((input.cases ?? []).map((entry) => [entry.id, entry]));
1443
1703
  return ([
1444
1704
  "# 后端自动化测试执行报告",
@@ -1460,7 +1720,9 @@ export function renderBackendTestFacts(input) {
1460
1720
  "| 校验项 | 状态 | Findings | 首要发现 |",
1461
1721
  "|---|---|---:|---|",
1462
1722
  `| Markdown 用例校验 | ${caseValidation.status} | ${caseValidation.findings ?? "不可用"} | ${caseValidation.firstFinding.replaceAll("|", "\\|")} |`,
1723
+ `| 用例场景覆盖分析 | ${caseCoverage.status} | ${caseCoverage.findings ?? "不可用"} | ${caseCoverage.firstFinding.replaceAll("|", "\\|")} |`,
1463
1724
  `| Markdown → pytest 追溯 | ${traceability.status} | ${traceability.findings ?? "不可用"} | ${traceability.firstFinding.replaceAll("|", "\\|")} |`,
1725
+ `| Markdown → pytest 一一对应 | ${correspondence.status} | ${correspondence.findings ?? "不可用"} | ${correspondence.firstFinding.replaceAll("|", "\\|")} |`,
1464
1726
  "",
1465
1727
  "## 失败概览",
1466
1728
  "",
@@ -1492,12 +1754,26 @@ export function renderBackendTestFacts(input) {
1492
1754
  "</details>",
1493
1755
  "",
1494
1756
  "<details>",
1757
+ "<summary>用例场景覆盖分析原文</summary>",
1758
+ "",
1759
+ input.caseCoverageSummary?.trim() ?? "Unavailable",
1760
+ "",
1761
+ "</details>",
1762
+ "",
1763
+ "<details>",
1495
1764
  "<summary>Markdown → pytest 追溯原文</summary>",
1496
1765
  "",
1497
1766
  input.traceabilitySummary?.trim() ?? "Unavailable",
1498
1767
  "",
1499
1768
  "</details>",
1500
1769
  "",
1770
+ "<details>",
1771
+ "<summary>Markdown → pytest 一一对应原文</summary>",
1772
+ "",
1773
+ input.correspondenceSummary?.trim() ?? "Unavailable",
1774
+ "",
1775
+ "</details>",
1776
+ "",
1501
1777
  "## 覆盖率证据",
1502
1778
  "",
1503
1779
  "- Code coverage: unavailable unless a separate validated coverage artifact exists.",