@tea-agent/loop-agent 0.25.4 → 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 (35) hide show
  1. package/AGENTS.md +6 -0
  2. package/CHANGELOG.md +40 -0
  3. package/dist/commands/client-recovery.js +209 -62
  4. package/dist/executors/dag-pi-executor.js +80 -15
  5. package/dist/executors/model-routing.js +1 -1
  6. package/dist/executors/shell-executor.js +127 -0
  7. package/dist/executors/shell-write-guard.js +21 -7
  8. package/dist/worker/console/repo-fingerprint.js +7 -1
  9. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +964 -0
  10. package/dist/workflows/dag/backend-test-case-manifest.js +39 -1
  11. package/dist/workflows/dag/backend-test-markdown-workflow.js +219 -16
  12. package/dist/workflows/dag/convergence/controller.js +134 -9
  13. package/dist/workflows/dag/frontend-test-l5-report.js +138 -0
  14. package/dist/workflows/dag/init-hybrid.js +262 -75
  15. package/dist/workflows/dag/node-execution.js +64 -11
  16. package/dist/workflows/dag/prompt.js +118 -4
  17. package/dist/workflows/dag/retry-policy.js +5 -4
  18. package/dist/workflows/dag/scheduler.js +32 -5
  19. package/dist/workflows/dag/types.js +7 -4
  20. package/dist/workflows/dag/validate.js +3 -2
  21. package/docs/architecture/dag-execution.md +7 -4
  22. package/docs/architecture/runtime-boundaries.md +1 -1
  23. package/docs/templates/agent-dag.base.json +1 -1
  24. package/docs/templates/agent-dag.final-verification.json +1 -1
  25. package/docs/templates/agent-dag.supervised-implementation.json +1 -1
  26. package/docs/templates/backend-test-dag.json +40 -13
  27. package/docs/templates/frontend-test-dag.json +32 -2
  28. package/docs/templates/hybrid-dag.json +1 -1
  29. package/examples/decision-gate-agent-dag.json +1 -1
  30. package/examples/example-dag.json +1 -1
  31. package/examples/hybrid-loop-agent-dag.json +1 -1
  32. package/harness.json +1 -1
  33. package/package.json +1 -1
  34. package/skills/loop-agent/references/hybrid-dag.md +2 -2
  35. package/skills/loop-agent/references/model-routing.md +1 -1
@@ -0,0 +1,964 @@
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 YAML from "yaml";
6
+ import { backendTestCaseManifestSchema, computeCaseManifestCoverageSummary, } from "./backend-test-case-manifest.js";
7
+ const CASE_HEADING = /^##\s+(BE-[A-Z0-9_-]+-\d{2,3})\b.*$/gm;
8
+ const CASE_ID_IN_TEXT = /\bBE-[A-Z0-9_-]+-\d{2,3}\b/g;
9
+ const AC_ID_IN_TEXT = /\bAC-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/g;
10
+ const RULE_KEY = /^(?:AC|REQ|BR|API)-[A-Z0-9]+(?:-[A-Z0-9]+)*$/;
11
+ const TEST_POINT = /^TP-[A-Z0-9]+(?:-[A-Z0-9]+)*$/;
12
+ const COVERAGE_HEADERS = [
13
+ "Rule Key",
14
+ "Priority",
15
+ "Source",
16
+ "Endpoint/Field",
17
+ "Dimension",
18
+ "Rule",
19
+ "Required Test Points",
20
+ "Case IDs",
21
+ "Status",
22
+ ];
23
+ const CASE_SECTION_ALIASES = {
24
+ acceptance: ["验收标准", "Acceptance Criteria"],
25
+ rules: ["覆盖规则", "Coverage Rules"],
26
+ testPoints: ["测试点", "Test Points"],
27
+ scenarioTypes: ["场景类型", "Scenario Types", "Scenario Type"],
28
+ automation: ["自动化映射", "自动化说明", "Automation Notes"],
29
+ };
30
+ const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/);
31
+ const inputFileSchema = z.object({ path: z.string().min(1), sha256: sha256Schema }).strict();
32
+ const sourceBindingSchema = z.object({
33
+ taskId: z.string().min(1),
34
+ requirementPath: z.string().min(1),
35
+ requirementSha256: sha256Schema,
36
+ referencePaths: z.array(z.string().min(1)),
37
+ requirementIds: z.array(z.string().min(1)),
38
+ }).strict();
39
+ const coverageCaseSchema = z.object({
40
+ caseId: z.string().min(1),
41
+ title: z.string().min(1),
42
+ markdownPath: z.string().min(1),
43
+ acIds: z.array(z.string()),
44
+ ruleKeys: z.array(z.string()),
45
+ testPoints: z.array(z.string()),
46
+ scenarioTypes: z.array(z.string()),
47
+ declaredScripts: z.array(z.string()),
48
+ }).strict();
49
+ const coverageRuleSchema = z.object({
50
+ ruleKey: z.string().min(1),
51
+ priority: z.enum(["P0", "P1", "P2"]),
52
+ source: z.string().min(1),
53
+ endpointField: z.string().min(1),
54
+ dimension: z.string().min(1),
55
+ rule: z.string().min(1),
56
+ requiredTestPoints: z.array(z.string()),
57
+ caseIds: z.array(z.string()),
58
+ declaredStatus: z.enum(["COVERED", "PARTIAL", "GAP", "CONFLICT"]),
59
+ coveredTestPoints: z.array(z.string()),
60
+ missingTestPoints: z.array(z.string()),
61
+ status: z.enum(["COVERED", "PARTIAL", "GAP", "CONFLICT"]),
62
+ }).strict();
63
+ export const backendTestCaseCoverageFactsSchema = z.object({
64
+ schemaId: z.literal("backend-test-case-coverage-facts-v1"),
65
+ schemaVersion: z.literal(1),
66
+ taskId: z.string().min(1),
67
+ status: z.enum(["PASS", "FAIL", "UNAVAILABLE"]),
68
+ sourceBinding: sourceBindingSchema,
69
+ inputFiles: z.array(inputFileSchema),
70
+ summary: z.object({
71
+ explicitAcCount: z.number().int().min(0),
72
+ coveredAcCount: z.number().int().min(0),
73
+ ruleCount: z.number().int().min(0),
74
+ coveredRuleCount: z.number().int().min(0),
75
+ testPointCount: z.number().int().min(0),
76
+ coveredTestPointCount: z.number().int().min(0),
77
+ enumValueCount: z.number().int().min(0),
78
+ coveredEnumValueCount: z.number().int().min(0),
79
+ invalidEquivalenceClassCount: z.number().int().min(0),
80
+ coveredInvalidEquivalenceClassCount: z.number().int().min(0),
81
+ boundaryPointCount: z.number().int().min(0),
82
+ coveredBoundaryPointCount: z.number().int().min(0),
83
+ formatClassCount: z.number().int().min(0),
84
+ coveredFormatClassCount: z.number().int().min(0),
85
+ businessStateCount: z.number().int().min(0),
86
+ coveredBusinessStateCount: z.number().int().min(0),
87
+ gapCount: z.number().int().min(0),
88
+ conflictCount: z.number().int().min(0),
89
+ }).strict(),
90
+ cases: z.array(coverageCaseSchema),
91
+ rules: z.array(coverageRuleSchema),
92
+ findings: z.array(z.string()),
93
+ evidenceGaps: z.array(z.string()),
94
+ conflicts: z.array(z.string()),
95
+ }).strict();
96
+ const correspondenceEntrySchema = z.object({
97
+ markdownModule: z.string().min(1),
98
+ caseId: z.string().optional(),
99
+ testPoints: z.array(z.string()),
100
+ declaredScript: z.string().optional(),
101
+ expectedScript: z.string().min(1),
102
+ actualScripts: z.array(z.string()),
103
+ pytestSymbols: z.array(z.string()),
104
+ parameterIds: z.array(z.string()),
105
+ cardinality: z.enum(["1:1", "1:0", "1:N", "0:1"]),
106
+ status: z.enum([
107
+ "EXACT_1_TO_1",
108
+ "MISSING_PYTEST",
109
+ "MULTIPLE_PYTEST",
110
+ "EXTRA_PYTEST",
111
+ "SCRIPT_MISMATCH",
112
+ "SYMBOL_MISSING_CASE_ID",
113
+ "PARAMETER_POINT_MISSING",
114
+ "PARAMETER_POINT_EXTRA",
115
+ ]),
116
+ findings: z.array(z.string()),
117
+ }).strict();
118
+ export const backendTestMarkdownPytestCorrespondenceFactsSchema = z.object({
119
+ schemaId: z.literal("backend-test-markdown-pytest-correspondence-facts-v1"),
120
+ schemaVersion: z.literal(1),
121
+ taskId: z.string().min(1),
122
+ status: z.enum(["PASS", "FAIL", "UNAVAILABLE"]),
123
+ inputFiles: z.array(inputFileSchema),
124
+ summary: z.object({
125
+ markdownModuleCount: z.number().int().min(0),
126
+ exactModuleCount: z.number().int().min(0),
127
+ markdownCaseCount: z.number().int().min(0),
128
+ exactCorrespondenceCount: z.number().int().min(0),
129
+ missingPytestCount: z.number().int().min(0),
130
+ multiplePytestCount: z.number().int().min(0),
131
+ extraPytestCount: z.number().int().min(0),
132
+ scriptMismatchCount: z.number().int().min(0),
133
+ testPointCount: z.number().int().min(0),
134
+ mappedTestPointCount: z.number().int().min(0),
135
+ }).strict(),
136
+ entries: z.array(correspondenceEntrySchema),
137
+ findings: z.array(z.string()),
138
+ }).strict();
139
+ function isRecord(value) {
140
+ return typeof value === "object" && value !== null && !Array.isArray(value);
141
+ }
142
+ function openApiToken(value) {
143
+ return value.replace(/[{}]/g, "").replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+|-+$/g, "").toUpperCase();
144
+ }
145
+ function resolveOpenApiSchema(document, value) {
146
+ if (!isRecord(value))
147
+ return undefined;
148
+ if (typeof value.$ref !== "string" || !value.$ref.startsWith("#/"))
149
+ return value;
150
+ let current = document;
151
+ for (const segment of value.$ref.slice(2).split("/")) {
152
+ if (!isRecord(current))
153
+ return undefined;
154
+ current = current[segment.replace(/~1/g, "/").replace(/~0/g, "~")];
155
+ }
156
+ return isRecord(current) ? current : undefined;
157
+ }
158
+ function openApiTestPoints(field, dimension, values = []) {
159
+ const prefix = `TP-${openApiToken(field)}`;
160
+ if (dimension === "enum")
161
+ return [...values.map((value) => `${prefix}-ENUM-${openApiToken(value)}`), `${prefix}-ENUM-UNKNOWN`, `${prefix}-ENUM-CASE-VARIANT`, `${prefix}-ENUM-WHITESPACE`, `${prefix}-ENUM-EMPTY`, `${prefix}-ENUM-NULL`, `${prefix}-ENUM-WRONG-TYPE`];
162
+ if (dimension === "boundary")
163
+ return [`${prefix}-MIN-1`, `${prefix}-MIN`, `${prefix}-NOMINAL`, `${prefix}-MAX`, `${prefix}-MAX-PLUS-1`];
164
+ if (dimension === "format")
165
+ return [`${prefix}-VALID-CLASS`, `${prefix}-VALID-MIXED`, `${prefix}-UPPERCASE`, `${prefix}-WHITESPACE`, `${prefix}-PUNCTUATION`, `${prefix}-SLASH`, `${prefix}-EMOJI`, `${prefix}-CONTROL`];
166
+ return [`${prefix}-PRESENT`, `${prefix}-MISSING`, `${prefix}-NULL`, `${prefix}-WRONG-TYPE`];
167
+ }
168
+ export async function extractBackendTestOpenApiRules(input) {
169
+ const rules = [];
170
+ for (const referencePath of input.sourceBinding.referencePaths) {
171
+ const physicalPath = path.join(input.workspaceRoot, ".harness", "tasks", input.sourceBinding.taskId, referencePath);
172
+ let document;
173
+ try {
174
+ const parsed = YAML.parse(await readFile(physicalPath, "utf8"));
175
+ if (!isRecord(parsed) || !isRecord(parsed.paths))
176
+ continue;
177
+ document = parsed;
178
+ }
179
+ catch {
180
+ continue;
181
+ }
182
+ for (const [apiPath, pathItem] of Object.entries(document.paths)) {
183
+ if (!isRecord(pathItem))
184
+ continue;
185
+ for (const method of ["get", "post", "put", "patch", "delete"]) {
186
+ const operation = pathItem[method];
187
+ if (!isRecord(operation))
188
+ continue;
189
+ const operationBase = `API-${method.toUpperCase()}-${openApiToken(apiPath)}`;
190
+ const responses = isRecord(operation.responses) ? operation.responses : {};
191
+ const responseStatuses = Object.keys(responses).filter((status) => /^(?:[1-5]\d\d|default)$/i.test(status)).sort();
192
+ if (responseStatuses.length > 0) {
193
+ rules.push({
194
+ ruleKey: `${operationBase}-RESPONSE-STATUS`,
195
+ priority: "P1",
196
+ source: referencePath,
197
+ endpointField: `${method.toUpperCase()} ${apiPath} / response status`,
198
+ dimension: "response-status",
199
+ rule: `documented response statuses: ${responseStatuses.join(", ")}`,
200
+ requiredTestPoints: responseStatuses.map((status) => `TP-RESPONSE-STATUS-${openApiToken(status)}`),
201
+ });
202
+ }
203
+ const parameterValues = [
204
+ ...(Array.isArray(pathItem.parameters) ? pathItem.parameters : []),
205
+ ...(Array.isArray(operation.parameters) ? operation.parameters : []),
206
+ ];
207
+ for (const rawParameter of parameterValues) {
208
+ const parameter = resolveOpenApiSchema(document, rawParameter);
209
+ if (!parameter || typeof parameter.name !== "string")
210
+ continue;
211
+ const field = parameter.name;
212
+ const property = resolveOpenApiSchema(document, parameter.schema);
213
+ if (!property)
214
+ continue;
215
+ const base = `${operationBase}-${openApiToken(field)}`;
216
+ const endpointField = `${method.toUpperCase()} ${apiPath} / ${String(parameter.in ?? "parameter")}:${field}`;
217
+ const add = (suffix, dimension, rule, points = openApiTestPoints(field, dimension)) => {
218
+ rules.push({ ruleKey: `${base}-${suffix}`, priority: "P1", source: referencePath, endpointField, dimension, rule, requiredTestPoints: points });
219
+ };
220
+ if (parameter.required === true)
221
+ add("REQUIRED", "requiredness", `${field} parameter is required`);
222
+ if (Array.isArray(property.enum)) {
223
+ const values = property.enum.map(String);
224
+ add("ENUM", "enum", `${field} enum: ${values.join(", ")}`, openApiTestPoints(field, "enum", values));
225
+ }
226
+ const boundaryPoints = openApiTestPoints(field, "boundary");
227
+ if (typeof property.minimum === "number")
228
+ add("MINIMUM", "boundary", `${field} minimum=${property.minimum}`, boundaryPoints.filter((point) => !point.includes("MAX")));
229
+ if (typeof property.maximum === "number")
230
+ add("MAXIMUM", "boundary", `${field} maximum=${property.maximum}`, boundaryPoints.filter((point) => !point.includes("MIN-")));
231
+ if (typeof property.pattern === "string")
232
+ add("PATTERN", "format", `${field} pattern=${property.pattern}`);
233
+ if (typeof property.format === "string")
234
+ add("FORMAT", "format", `${field} format=${property.format}`);
235
+ }
236
+ const requestBody = isRecord(operation.requestBody) ? operation.requestBody : undefined;
237
+ const content = requestBody && isRecord(requestBody.content) ? requestBody.content : undefined;
238
+ const media = content && (content["application/json"] ?? Object.values(content)[0]);
239
+ const schema = resolveOpenApiSchema(document, isRecord(media) ? media.schema : undefined);
240
+ if (!schema)
241
+ continue;
242
+ const required = new Set(Array.isArray(schema.required) ? schema.required.filter((value) => typeof value === "string") : []);
243
+ const properties = isRecord(schema.properties) ? schema.properties : {};
244
+ for (const [field, rawProperty] of Object.entries(properties)) {
245
+ const property = resolveOpenApiSchema(document, rawProperty);
246
+ if (!property)
247
+ continue;
248
+ const base = `${operationBase}-${openApiToken(field)}`;
249
+ const endpointField = `${method.toUpperCase()} ${apiPath} / ${field}`;
250
+ const add = (suffix, dimension, rule, points = openApiTestPoints(field, dimension)) => {
251
+ rules.push({ ruleKey: `${base}-${suffix}`, priority: "P1", source: referencePath, endpointField, dimension, rule, requiredTestPoints: points });
252
+ };
253
+ if (required.has(field))
254
+ add("REQUIRED", "requiredness", `${field} is required`);
255
+ if (Array.isArray(property.enum)) {
256
+ const values = property.enum.map(String);
257
+ add("ENUM", "enum", `${field} enum: ${values.join(", ")}`, openApiTestPoints(field, "enum", values));
258
+ }
259
+ const boundaryPoints = openApiTestPoints(field, "boundary");
260
+ if (typeof property.minLength === "number")
261
+ add("MIN-LENGTH", "boundary", `${field} minLength=${property.minLength}`, boundaryPoints.filter((point) => !point.includes("MAX")));
262
+ if (typeof property.maxLength === "number")
263
+ add("MAX-LENGTH", "boundary", `${field} maxLength=${property.maxLength}`, boundaryPoints.filter((point) => !point.includes("MIN-")));
264
+ if (typeof property.minimum === "number")
265
+ add("MINIMUM", "boundary", `${field} minimum=${property.minimum}`, boundaryPoints.filter((point) => !point.includes("MAX")));
266
+ if (typeof property.maximum === "number")
267
+ add("MAXIMUM", "boundary", `${field} maximum=${property.maximum}`, boundaryPoints.filter((point) => !point.includes("MIN-")));
268
+ if (typeof property.pattern === "string")
269
+ add("PATTERN", "format", `${field} pattern=${property.pattern}`);
270
+ if (typeof property.format === "string")
271
+ add("FORMAT", "format", `${field} format=${property.format}`);
272
+ }
273
+ }
274
+ }
275
+ }
276
+ return rules.sort((left, right) => left.ruleKey.localeCompare(right.ruleKey));
277
+ }
278
+ function orderedUnique(values) {
279
+ const result = [];
280
+ const seen = new Set();
281
+ for (const value of values) {
282
+ if (!value || seen.has(value))
283
+ continue;
284
+ seen.add(value);
285
+ result.push(value);
286
+ }
287
+ return result;
288
+ }
289
+ function canonicalCaseId(value) {
290
+ const upper = value.toUpperCase();
291
+ const match = upper.match(/^(BE-[A-Z0-9_-]+)-(\d{2,3})$/);
292
+ if (!match)
293
+ return upper;
294
+ return `${match[1].replaceAll("_", "-").replace(/-+/g, "-")}-${match[2].padStart(3, "0")}`;
295
+ }
296
+ function caseIds(value) {
297
+ return orderedUnique((value.match(CASE_ID_IN_TEXT) ?? []).map(canonicalCaseId));
298
+ }
299
+ function sectionBody(body, aliases) {
300
+ const escaped = aliases.map((value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
301
+ const marker = new RegExp(`^###\\s+(?:${escaped})\\s*$`, "mi");
302
+ const match = marker.exec(body);
303
+ if (!match)
304
+ return "";
305
+ const rest = body.slice(match.index + match[0].length);
306
+ const next = /^###\s+/m.exec(rest);
307
+ return rest.slice(0, next?.index ?? rest.length);
308
+ }
309
+ function listTokens(body, aliases, pattern) {
310
+ const section = sectionBody(body, aliases).replaceAll("`", "");
311
+ const source = pattern.source.replace(/^\^/, "").replace(/\$$/, "");
312
+ return orderedUnique(section.match(new RegExp(source, "g")) ?? []);
313
+ }
314
+ function scenarioTypes(body) {
315
+ return orderedUnique(sectionBody(body, CASE_SECTION_ALIASES.scenarioTypes)
316
+ .split(/\r?\n/)
317
+ .map((line) => line.replace(/^\s*(?:[-*+] |\d+[.)]\s*)/, "").replaceAll("`", "").trim())
318
+ .flatMap((line) => line.split(/[;,,、]/).map((item) => item.trim().toLowerCase()))
319
+ .filter(Boolean));
320
+ }
321
+ function declaredScripts(body) {
322
+ const automation = sectionBody(body, CASE_SECTION_ALIASES.automation);
323
+ return orderedUnique([...automation.matchAll(/testcase\/[A-Za-z0-9_./-]*test_[A-Za-z0-9_.-]*\.py/gi)]
324
+ .map((match) => match[0].replaceAll("\\", "/")));
325
+ }
326
+ function cleanTitle(heading, id) {
327
+ return heading.replace(/^##\s+/, "").replace(/^BE-[A-Z0-9_-]+-\d{2,3}\s*(?:[||—–-]\s*)?/i, "").trim() || id;
328
+ }
329
+ async function exists(filePath) {
330
+ try {
331
+ await access(filePath);
332
+ return true;
333
+ }
334
+ catch {
335
+ return false;
336
+ }
337
+ }
338
+ async function markdownModuleFiles(workspaceRoot) {
339
+ const dir = path.join(workspaceRoot, "testcase", "md");
340
+ const entries = await readdir(dir, { withFileTypes: true });
341
+ return entries
342
+ .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".md") && entry.name.toLowerCase() !== "readme.md")
343
+ .map((entry) => path.join(dir, entry.name))
344
+ .sort();
345
+ }
346
+ async function parseMarkdownCases(workspaceRoot) {
347
+ const result = [];
348
+ for (const file of await markdownModuleFiles(workspaceRoot)) {
349
+ const markdown = await readFile(file, "utf8");
350
+ const headings = [...markdown.matchAll(CASE_HEADING)];
351
+ for (let index = 0; index < headings.length; index += 1) {
352
+ const heading = headings[index];
353
+ const id = canonicalCaseId(heading[1]);
354
+ const body = markdown.slice(heading.index, headings[index + 1]?.index ?? markdown.length);
355
+ result.push({
356
+ caseId: id,
357
+ title: cleanTitle(heading[0], id),
358
+ markdownPath: path.relative(workspaceRoot, file).replaceAll(path.sep, "/"),
359
+ acIds: orderedUnique(body.match(AC_ID_IN_TEXT) ?? []),
360
+ ruleKeys: listTokens(body, CASE_SECTION_ALIASES.rules, RULE_KEY),
361
+ testPoints: listTokens(body, CASE_SECTION_ALIASES.testPoints, TEST_POINT),
362
+ scenarioTypes: scenarioTypes(body),
363
+ declaredScripts: declaredScripts(body),
364
+ body,
365
+ });
366
+ }
367
+ }
368
+ return result;
369
+ }
370
+ function parseTableRow(line) {
371
+ const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
372
+ const cells = [];
373
+ let current = "";
374
+ for (let index = 0; index < trimmed.length; index += 1) {
375
+ const char = trimmed[index];
376
+ if (char === "\\" && trimmed[index + 1] === "|") {
377
+ current += "|";
378
+ index += 1;
379
+ }
380
+ else if (char === "|") {
381
+ cells.push(current.trim());
382
+ current = "";
383
+ }
384
+ else {
385
+ current += char;
386
+ }
387
+ }
388
+ cells.push(current.trim());
389
+ return cells;
390
+ }
391
+ function splitCellTokens(value, pattern) {
392
+ const values = orderedUnique(value.split(/;|<br\s*\/?\s*>|,|、/i).map((item) => item.replaceAll("`", "").trim()).filter(Boolean));
393
+ return pattern ? values.filter((item) => pattern.test(item)) : values;
394
+ }
395
+ function parseCoverageMatrix(readme) {
396
+ const findings = [];
397
+ const marker = /^##\s+(?:(?:\d+(?:\.\d+)*[.)]?\s+)?(?:Coverage Matrix|覆盖矩阵))\s*$/mi.exec(readme);
398
+ if (!marker)
399
+ return { rows: [], findings: ["README is missing required ## Coverage Matrix section"] };
400
+ if (!/^##\s+Coverage Matrix\s*$/i.test(marker[0])) {
401
+ findings.push(`Coverage Matrix should use the exact canonical heading \"## Coverage Matrix\"; accepted compatibility heading: ${marker[0].trim()}`);
402
+ }
403
+ const rest = readme.slice(marker.index + marker[0].length);
404
+ const nextHeading = /^##\s+/m.exec(rest);
405
+ const section = rest.slice(0, nextHeading?.index ?? rest.length);
406
+ const lines = section.split(/\r?\n/).filter((line) => line.trim().startsWith("|"));
407
+ if (lines.length < 2)
408
+ return { rows: [], findings: ["Coverage Matrix table is missing or incomplete"] };
409
+ const headers = parseTableRow(lines[0]);
410
+ if (JSON.stringify(headers) !== JSON.stringify(COVERAGE_HEADERS)) {
411
+ findings.push(`Coverage Matrix headers must be exactly: ${COVERAGE_HEADERS.join(" | ")}`);
412
+ return { rows: [], findings };
413
+ }
414
+ const rows = [];
415
+ for (const line of lines.slice(2)) {
416
+ const cells = parseTableRow(line);
417
+ if (cells.length !== COVERAGE_HEADERS.length) {
418
+ findings.push(`Coverage Matrix row has ${cells.length} columns instead of ${COVERAGE_HEADERS.length}: ${line.slice(0, 160)}`);
419
+ continue;
420
+ }
421
+ rows.push(Object.fromEntries(COVERAGE_HEADERS.map((header, index) => [header, cells[index] ?? ""])));
422
+ }
423
+ if (rows.length === 0)
424
+ findings.push("Coverage Matrix contains no rule rows");
425
+ return { rows, findings };
426
+ }
427
+ async function fileHash(filePath) {
428
+ return createHash("sha256").update(await readFile(filePath)).digest("hex");
429
+ }
430
+ async function inputFileFacts(workspaceRoot, files) {
431
+ const result = [];
432
+ for (const file of orderedUnique(files).sort()) {
433
+ result.push({ path: path.relative(workspaceRoot, file).replaceAll(path.sep, "/"), sha256: await fileHash(file) });
434
+ }
435
+ return result;
436
+ }
437
+ function dimensionCounts(rules, matcher) {
438
+ const selected = rules.filter((rule) => matcher.test(rule.dimension));
439
+ return [
440
+ selected.reduce((sum, rule) => sum + rule.requiredTestPoints.length, 0),
441
+ selected.reduce((sum, rule) => sum + rule.coveredTestPoints.length, 0),
442
+ ];
443
+ }
444
+ function renderCoverageTable(rules) {
445
+ return [
446
+ "| Rule Key | Priority | Dimension | Required | Covered | Missing | Status | Case IDs |",
447
+ "|---|---|---|---:|---:|---|---|---|",
448
+ ...rules.map((rule) => `| ${rule.ruleKey} | ${rule.priority} | ${rule.dimension} | ${rule.requiredTestPoints.length} | ${rule.coveredTestPoints.length} | ${rule.missingTestPoints.join(", ") || "—"} | ${rule.status} | ${rule.caseIds.join(", ") || "—"} |`),
449
+ ];
450
+ }
451
+ export async function analyzeBackendTestCaseCoverage(input) {
452
+ const findings = [];
453
+ const readmePath = path.join(input.workspaceRoot, "testcase", "md", "README.md");
454
+ const readme = (await exists(readmePath)) ? await readFile(readmePath, "utf8") : "";
455
+ const matrix = parseCoverageMatrix(readme);
456
+ findings.push(...matrix.findings);
457
+ const cases = await parseMarkdownCases(input.workspaceRoot);
458
+ const caseById = new Map(cases.map((item) => [item.caseId, item]));
459
+ const rules = [];
460
+ const evidenceGaps = [];
461
+ const conflicts = [];
462
+ const seenRules = new Set();
463
+ for (const row of matrix.rows) {
464
+ const ruleKey = row["Rule Key"].replaceAll("`", "").trim();
465
+ const priority = row.Priority.trim().toUpperCase();
466
+ const declaredStatus = row.Status.trim().toUpperCase();
467
+ if (!RULE_KEY.test(ruleKey))
468
+ findings.push(`invalid Rule Key: ${ruleKey || "<empty>"}`);
469
+ if (seenRules.has(ruleKey))
470
+ findings.push(`duplicate Coverage Matrix Rule Key: ${ruleKey}`);
471
+ seenRules.add(ruleKey);
472
+ if (!/^(?:P0|P1|P2)$/.test(priority))
473
+ findings.push(`${ruleKey} has invalid priority: ${priority}`);
474
+ if (!/^(?:COVERED|PARTIAL|GAP|CONFLICT)$/.test(declaredStatus))
475
+ findings.push(`${ruleKey} has invalid status: ${declaredStatus}`);
476
+ const requiredTestPoints = splitCellTokens(row["Required Test Points"], TEST_POINT);
477
+ const rowCaseIds = splitCellTokens(row["Case IDs"]).map(canonicalCaseId);
478
+ if (requiredTestPoints.length === 0 && declaredStatus === "COVERED")
479
+ findings.push(`${ruleKey} declares COVERED without Required Test Points`);
480
+ const declaredPoints = orderedUnique(rowCaseIds.flatMap((id) => caseById.get(id)?.testPoints ?? []));
481
+ const coveredTestPoints = requiredTestPoints.filter((point) => declaredPoints.includes(point));
482
+ const missingTestPoints = requiredTestPoints.filter((point) => !declaredPoints.includes(point));
483
+ for (const id of rowCaseIds) {
484
+ const testCase = caseById.get(id);
485
+ if (!testCase)
486
+ findings.push(`${ruleKey} references missing Markdown Case: ${id}`);
487
+ else if (!testCase.ruleKeys.includes(ruleKey))
488
+ findings.push(`${id} does not reference Coverage Matrix rule ${ruleKey}`);
489
+ }
490
+ for (const testCase of cases.filter((item) => item.ruleKeys.includes(ruleKey))) {
491
+ if (!rowCaseIds.includes(testCase.caseId))
492
+ findings.push(`${testCase.caseId} references ${ruleKey} but is absent from its Coverage Matrix Case IDs`);
493
+ }
494
+ let status = declaredStatus;
495
+ if (status === "COVERED" && missingTestPoints.length > 0)
496
+ status = "PARTIAL";
497
+ if (missingTestPoints.length > 0)
498
+ findings.push(`${ruleKey} is missing required test points: ${missingTestPoints.join(", ")}`);
499
+ if (status === "GAP")
500
+ evidenceGaps.push(`${ruleKey}: ${row.Rule}`);
501
+ if (status === "CONFLICT")
502
+ conflicts.push(`${ruleKey}: ${row.Rule}`);
503
+ if (status !== "COVERED")
504
+ findings.push(`${ruleKey} coverage status is ${status}`);
505
+ rules.push({
506
+ ruleKey,
507
+ priority: /^(?:P0|P1|P2)$/.test(priority) ? priority : "P2",
508
+ source: row.Source || "unavailable",
509
+ endpointField: row["Endpoint/Field"] || "unavailable",
510
+ dimension: row.Dimension.trim().toLowerCase() || "other",
511
+ rule: row.Rule || "unavailable",
512
+ requiredTestPoints,
513
+ caseIds: rowCaseIds,
514
+ declaredStatus: /^(?:COVERED|PARTIAL|GAP|CONFLICT)$/.test(declaredStatus) ? declaredStatus : "PARTIAL",
515
+ coveredTestPoints,
516
+ missingTestPoints,
517
+ status,
518
+ });
519
+ }
520
+ const extractedOpenApiRules = await extractBackendTestOpenApiRules(input);
521
+ for (const extracted of extractedOpenApiRules) {
522
+ if (seenRules.has(extracted.ruleKey))
523
+ continue;
524
+ findings.push(`Coverage Matrix is missing documented OpenAPI rule: ${extracted.ruleKey} (${extracted.source})`);
525
+ seenRules.add(extracted.ruleKey);
526
+ rules.push({
527
+ ...extracted,
528
+ caseIds: [],
529
+ declaredStatus: "PARTIAL",
530
+ coveredTestPoints: [],
531
+ missingTestPoints: extracted.requiredTestPoints,
532
+ status: "PARTIAL",
533
+ });
534
+ }
535
+ for (const testCase of cases) {
536
+ if (testCase.ruleKeys.length === 0)
537
+ findings.push(`${testCase.caseId} missing section or entries: 覆盖规则`);
538
+ if (testCase.testPoints.length === 0)
539
+ findings.push(`${testCase.caseId} missing section or entries: 测试点`);
540
+ if (testCase.scenarioTypes.length === 0)
541
+ findings.push(`${testCase.caseId} missing section or entries: 场景类型`);
542
+ for (const ruleKey of testCase.ruleKeys)
543
+ if (!seenRules.has(ruleKey))
544
+ findings.push(`${testCase.caseId} references unknown Rule Key: ${ruleKey}`);
545
+ }
546
+ const explicitAc = input.sourceBinding.requirementIds.filter((id) => id.startsWith("AC-"));
547
+ const coveredAc = new Set(cases.flatMap((item) => item.acIds));
548
+ const [enumValueCount, coveredEnumValueCount] = dimensionCounts(rules, /enum/);
549
+ const [boundaryPointCount, coveredBoundaryPointCount] = dimensionCounts(rules, /boundary|length|numeric/);
550
+ const [formatClassCount, coveredFormatClassCount] = dimensionCounts(rules, /format|pattern|charset/);
551
+ const [businessStateCount, coveredBusinessStateCount] = dimensionCounts(rules, /business-state|state-transition|uniqueness|lifecycle/);
552
+ const invalidPointPattern = /(?:UNKNOWN|CASE-VARIANT|WHITESPACE|EMPTY|NULL|WRONG-TYPE|MIN-1|MAX-PLUS-1|UPPERCASE|PUNCTUATION|SLASH|EMOJI|CONTROL)$/;
553
+ const invalidEquivalenceClassCount = rules.reduce((sum, rule) => sum + rule.requiredTestPoints.filter((point) => invalidPointPattern.test(point)).length, 0);
554
+ const coveredInvalidEquivalenceClassCount = rules.reduce((sum, rule) => sum + rule.coveredTestPoints.filter((point) => invalidPointPattern.test(point)).length, 0);
555
+ const facts = backendTestCaseCoverageFactsSchema.parse({
556
+ schemaId: "backend-test-case-coverage-facts-v1",
557
+ schemaVersion: 1,
558
+ taskId: input.sourceBinding.taskId,
559
+ status: findings.length === 0 ? "PASS" : "FAIL",
560
+ sourceBinding: input.sourceBinding,
561
+ inputFiles: await inputFileFacts(input.workspaceRoot, [
562
+ ...((await exists(readmePath)) ? [readmePath] : []),
563
+ ...(await markdownModuleFiles(input.workspaceRoot)),
564
+ ...(await Promise.all(input.sourceBinding.referencePaths.map(async (referencePath) => {
565
+ const physicalPath = path.join(input.workspaceRoot, ".harness", "tasks", input.sourceBinding.taskId, referencePath);
566
+ return (await exists(physicalPath)) ? physicalPath : "";
567
+ }))).filter(Boolean),
568
+ ]),
569
+ summary: {
570
+ explicitAcCount: explicitAc.length,
571
+ coveredAcCount: explicitAc.filter((id) => coveredAc.has(id)).length,
572
+ ruleCount: rules.length,
573
+ coveredRuleCount: rules.filter((rule) => rule.status === "COVERED").length,
574
+ testPointCount: rules.reduce((sum, rule) => sum + rule.requiredTestPoints.length, 0),
575
+ coveredTestPointCount: rules.reduce((sum, rule) => sum + rule.coveredTestPoints.length, 0),
576
+ enumValueCount,
577
+ coveredEnumValueCount,
578
+ invalidEquivalenceClassCount,
579
+ coveredInvalidEquivalenceClassCount,
580
+ boundaryPointCount,
581
+ coveredBoundaryPointCount,
582
+ formatClassCount,
583
+ coveredFormatClassCount,
584
+ businessStateCount,
585
+ coveredBusinessStateCount,
586
+ gapCount: evidenceGaps.length,
587
+ conflictCount: conflicts.length,
588
+ },
589
+ cases: cases.map(({ body: _body, ...item }) => item),
590
+ rules,
591
+ findings: orderedUnique(findings),
592
+ evidenceGaps,
593
+ conflicts,
594
+ });
595
+ const summary = facts.summary;
596
+ const markdown = [
597
+ "# Backend Test Case Coverage Analysis",
598
+ "",
599
+ "## Status",
600
+ "",
601
+ facts.status,
602
+ "",
603
+ "## Coverage Summary",
604
+ "",
605
+ "| Dimension | Total | Covered |",
606
+ "|---|---:|---:|",
607
+ `| Product AC | ${summary.explicitAcCount} | ${summary.coveredAcCount} |`,
608
+ `| Rules | ${summary.ruleCount} | ${summary.coveredRuleCount} |`,
609
+ `| Required Test Points | ${summary.testPointCount} | ${summary.coveredTestPointCount} |`,
610
+ `| Enum | ${summary.enumValueCount} | ${summary.coveredEnumValueCount} |`,
611
+ `| Boundary | ${summary.boundaryPointCount} | ${summary.coveredBoundaryPointCount} |`,
612
+ `| Format | ${summary.formatClassCount} | ${summary.coveredFormatClassCount} |`,
613
+ `| Business State | ${summary.businessStateCount} | ${summary.coveredBusinessStateCount} |`,
614
+ "",
615
+ "## Product Requirement Coverage",
616
+ "",
617
+ ...renderCoverageTable(rules.filter((rule) => rule.priority === "P0" || rule.ruleKey.startsWith("AC-"))),
618
+ "",
619
+ "## API Operation and Field Rule Coverage",
620
+ "",
621
+ ...renderCoverageTable(rules.filter((rule) => rule.priority !== "P0")),
622
+ "",
623
+ "## Business State Coverage",
624
+ "",
625
+ ...renderCoverageTable(rules.filter((rule) => /business-state|state-transition|uniqueness|lifecycle/.test(rule.dimension))),
626
+ "",
627
+ "## Enum Coverage",
628
+ "",
629
+ ...renderCoverageTable(rules.filter((rule) => /enum/.test(rule.dimension))),
630
+ "",
631
+ "## Boundary and Format Coverage",
632
+ "",
633
+ ...renderCoverageTable(rules.filter((rule) => /boundary|length|numeric|format|pattern|charset/.test(rule.dimension))),
634
+ "",
635
+ "## Uncovered/Partial Rules",
636
+ "",
637
+ ...(rules.filter((rule) => rule.status !== "COVERED").map((rule) => `- ${rule.ruleKey}: ${rule.status}; missing=${rule.missingTestPoints.join(", ") || "none"}`)),
638
+ ...(rules.every((rule) => rule.status === "COVERED") ? ["- None"] : []),
639
+ "",
640
+ "## Conflicts and Evidence Gaps",
641
+ "",
642
+ ...([...evidenceGaps, ...conflicts].length ? [...evidenceGaps, ...conflicts].map((item) => `- ${item}`) : ["- None"]),
643
+ "",
644
+ "## Matrix/Case Consistency Findings",
645
+ "",
646
+ ...(facts.findings.length ? facts.findings.map((item) => `- ${item}`) : ["- None"]),
647
+ "",
648
+ ].join("\n");
649
+ return { facts, markdown };
650
+ }
651
+ function normalizeModuleStem(markdownPath) {
652
+ return path.posix.basename(markdownPath).replace(/\.md$/i, "").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").replace(/_+/g, "_");
653
+ }
654
+ function expectedScript(markdownPath) {
655
+ return `testcase/test_${normalizeModuleStem(markdownPath)}.py`;
656
+ }
657
+ function symbolCaseId(symbol) {
658
+ const match = symbol.match(/^test_(BE(?:_[A-Z0-9]+)+?_\d{2,3})(?:_|$)/i);
659
+ return match ? canonicalCaseId(match[1].replaceAll("_", "-")) : undefined;
660
+ }
661
+ function pytestFunctionRegionStart(source, functionIndex) {
662
+ let regionStart = source.lastIndexOf("\n", functionIndex - 1) + 1;
663
+ let cursor = regionStart;
664
+ while (cursor > 0) {
665
+ const previousEnd = cursor - 1;
666
+ const previousStart = source.lastIndexOf("\n", previousEnd - 1) + 1;
667
+ const previous = source.slice(previousStart, previousEnd).replace(/\r$/, "");
668
+ if (!previous.trim())
669
+ break;
670
+ regionStart = previousStart;
671
+ cursor = previousStart;
672
+ }
673
+ return regionStart;
674
+ }
675
+ function pytestSymbols(script, source) {
676
+ const matches = [...source.matchAll(/^([ \t]*)(?:async\s+)?def\s+(test_[A-Za-z0-9_]+)\s*\([^)]*\)\s*(?:->\s*[^:\r\n]+)?\s*:/gm)];
677
+ const regionStarts = matches.map((match) => pytestFunctionRegionStart(source, match.index));
678
+ return matches.map((match, index) => {
679
+ const regionStart = regionStarts[index];
680
+ const regionEnd = regionStarts[index + 1] ?? source.length;
681
+ const region = source.slice(regionStart, regionEnd);
682
+ const ids = caseIds(region);
683
+ const fromSymbol = symbolCaseId(match[2]);
684
+ if (fromSymbol)
685
+ ids.unshift(fromSymbol);
686
+ const parameterIds = orderedUnique([...region.matchAll(/\bid\s*=\s*["'](TP-[A-Z0-9-]+)["']/g)].map((item) => item[1]));
687
+ return {
688
+ script,
689
+ symbol: match[2],
690
+ caseIds: orderedUnique(ids),
691
+ testPoints: orderedUnique(region.match(/\bTP-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/g) ?? []),
692
+ parameterIds,
693
+ };
694
+ });
695
+ }
696
+ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
697
+ const cases = await parseMarkdownCases(input.workspaceRoot);
698
+ const modulePaths = orderedUnique(cases.map((item) => item.markdownPath));
699
+ const scripts = orderedUnique(cases.flatMap((item) => [...item.declaredScripts, expectedScript(item.markdownPath)]));
700
+ const allSymbols = [];
701
+ const scriptFiles = [];
702
+ for (const script of scripts) {
703
+ const absolute = path.resolve(input.workspaceRoot, script);
704
+ if (!(await exists(absolute)))
705
+ continue;
706
+ scriptFiles.push(absolute);
707
+ allSymbols.push(...pytestSymbols(script, await readFile(absolute, "utf8")));
708
+ }
709
+ const findings = [];
710
+ const entries = [];
711
+ for (const testCase of cases) {
712
+ const expected = expectedScript(testCase.markdownPath);
713
+ const declared = testCase.declaredScripts[0];
714
+ const refs = allSymbols.filter((item) => item.caseIds.includes(testCase.caseId));
715
+ const actualScripts = orderedUnique(refs.map((item) => item.script));
716
+ const symbols = orderedUnique(refs.map((item) => item.symbol));
717
+ const parameterIds = orderedUnique(refs.flatMap((item) => item.parameterIds));
718
+ const observedPoints = orderedUnique(refs.flatMap((item) => item.testPoints));
719
+ let cardinality = refs.length === 0 ? "1:0" : refs.length === 1 ? "1:1" : "1:N";
720
+ let status = refs.length === 0 ? "MISSING_PYTEST" : refs.length > 1 ? "MULTIPLE_PYTEST" : "EXACT_1_TO_1";
721
+ const entryFindings = [];
722
+ if (refs.length === 0)
723
+ entryFindings.push(`${testCase.caseId} has no pytest symbol`);
724
+ if (refs.length > 1)
725
+ entryFindings.push(`${testCase.caseId} maps to multiple pytest symbols: ${symbols.join(", ")}`);
726
+ if (!declared || declared !== expected || actualScripts.some((script) => script !== expected)) {
727
+ if (refs.length > 0 && status === "EXACT_1_TO_1")
728
+ status = "SCRIPT_MISMATCH";
729
+ entryFindings.push(`${testCase.caseId} script mapping mismatch: declared=${declared ?? "none"}, expected=${expected}, actual=${actualScripts.join(", ") || "none"}`);
730
+ }
731
+ const missingPoints = testCase.testPoints.filter((point) => {
732
+ if (testCase.testPoints.length > 1)
733
+ return !parameterIds.includes(point);
734
+ return !observedPoints.includes(point) && !parameterIds.includes(point);
735
+ });
736
+ const extraPoints = parameterIds.filter((point) => !testCase.testPoints.includes(point));
737
+ if (missingPoints.length > 0) {
738
+ if (refs.length > 0 && status === "EXACT_1_TO_1")
739
+ status = "PARAMETER_POINT_MISSING";
740
+ entryFindings.push(`${testCase.caseId} missing pytest Test Point IDs: ${missingPoints.join(", ")}`);
741
+ }
742
+ if (extraPoints.length > 0) {
743
+ if (refs.length > 0 && status === "EXACT_1_TO_1")
744
+ status = "PARAMETER_POINT_EXTRA";
745
+ entryFindings.push(`${testCase.caseId} has extra pytest parameter IDs: ${extraPoints.join(", ")}`);
746
+ }
747
+ findings.push(...entryFindings);
748
+ entries.push({
749
+ markdownModule: testCase.markdownPath,
750
+ caseId: testCase.caseId,
751
+ testPoints: testCase.testPoints,
752
+ ...(declared ? { declaredScript: declared } : {}),
753
+ expectedScript: expected,
754
+ actualScripts,
755
+ pytestSymbols: symbols,
756
+ parameterIds,
757
+ cardinality,
758
+ status,
759
+ findings: entryFindings,
760
+ });
761
+ }
762
+ for (const symbol of allSymbols) {
763
+ const boundKnown = symbol.caseIds.some((id) => cases.some((testCase) => testCase.caseId === id));
764
+ if (boundKnown)
765
+ continue;
766
+ const unknownId = symbol.caseIds[0];
767
+ const message = unknownId
768
+ ? `${symbol.script}#${symbol.symbol} references unknown Markdown Case ${unknownId}`
769
+ : `${symbol.script}#${symbol.symbol} has no Markdown Case ID`;
770
+ findings.push(message);
771
+ entries.push({
772
+ markdownModule: "—",
773
+ ...(unknownId ? { caseId: unknownId } : {}),
774
+ testPoints: symbol.testPoints,
775
+ expectedScript: symbol.script,
776
+ actualScripts: [symbol.script],
777
+ pytestSymbols: [symbol.symbol],
778
+ parameterIds: symbol.parameterIds,
779
+ cardinality: "0:1",
780
+ status: unknownId ? "EXTRA_PYTEST" : "SYMBOL_MISSING_CASE_ID",
781
+ findings: [message],
782
+ });
783
+ }
784
+ const exactModuleCount = modulePaths.filter((modulePath) => {
785
+ const expected = expectedScript(modulePath);
786
+ const moduleCases = cases.filter((item) => item.markdownPath === modulePath);
787
+ return moduleCases.every((item) => item.declaredScripts.length === 1 && item.declaredScripts[0] === expected) && scriptFiles.some((file) => path.relative(input.workspaceRoot, file).replaceAll(path.sep, "/") === expected);
788
+ }).length;
789
+ const caseEntries = entries.filter((entry) => entry.cardinality !== "0:1");
790
+ const facts = backendTestMarkdownPytestCorrespondenceFactsSchema.parse({
791
+ schemaId: "backend-test-markdown-pytest-correspondence-facts-v1",
792
+ schemaVersion: 1,
793
+ taskId: input.taskId,
794
+ status: findings.length === 0 ? "PASS" : "FAIL",
795
+ inputFiles: await inputFileFacts(input.workspaceRoot, [
796
+ path.join(input.workspaceRoot, "testcase", "md", "README.md"),
797
+ ...(await markdownModuleFiles(input.workspaceRoot)),
798
+ ...scriptFiles,
799
+ ].filter((file) => file && path.isAbsolute(file))),
800
+ summary: {
801
+ markdownModuleCount: modulePaths.length,
802
+ exactModuleCount,
803
+ markdownCaseCount: cases.length,
804
+ exactCorrespondenceCount: caseEntries.filter((entry) => entry.status === "EXACT_1_TO_1").length,
805
+ missingPytestCount: caseEntries.filter((entry) => entry.status === "MISSING_PYTEST").length,
806
+ multiplePytestCount: caseEntries.filter((entry) => entry.status === "MULTIPLE_PYTEST").length,
807
+ extraPytestCount: entries.filter((entry) => entry.cardinality === "0:1").length,
808
+ scriptMismatchCount: caseEntries.filter((entry) => entry.status === "SCRIPT_MISMATCH").length,
809
+ testPointCount: cases.reduce((sum, item) => sum + item.testPoints.length, 0),
810
+ mappedTestPointCount: caseEntries.reduce((sum, entry) => sum + entry.testPoints.filter((point) => entry.parameterIds.includes(point) || (entry.testPoints.length === 1 && entry.status === "EXACT_1_TO_1")).length, 0),
811
+ },
812
+ entries,
813
+ findings: orderedUnique(findings),
814
+ });
815
+ const markdown = [
816
+ "# Backend Test Markdown → pytest Correspondence",
817
+ "",
818
+ "## Status",
819
+ "",
820
+ facts.status,
821
+ "",
822
+ "## Summary",
823
+ "",
824
+ `- Markdown modules: ${facts.summary.markdownModuleCount}`,
825
+ `- Exact module mappings: ${facts.summary.exactModuleCount}`,
826
+ `- Markdown Cases: ${facts.summary.markdownCaseCount}`,
827
+ `- Exact 1:1: ${facts.summary.exactCorrespondenceCount}`,
828
+ `- Missing pytest: ${facts.summary.missingPytestCount}`,
829
+ `- Multiple pytest: ${facts.summary.multiplePytestCount}`,
830
+ `- Extra pytest: ${facts.summary.extraPytestCount}`,
831
+ `- Test Points: ${facts.summary.mappedTestPointCount}/${facts.summary.testPointCount}`,
832
+ "",
833
+ "## Correspondence Matrix",
834
+ "",
835
+ "| Markdown Module | Case ID | Test Points | Declared Script | Expected Script | Actual Script | Pytest Symbol | Parameter IDs | Cardinality | Status |",
836
+ "|---|---|---|---|---|---|---|---|---|---|",
837
+ ...facts.entries.map((entry) => `| ${entry.markdownModule} | ${entry.caseId ?? "—"} | ${entry.testPoints.join(", ") || "—"} | ${entry.declaredScript ?? "—"} | ${entry.expectedScript} | ${entry.actualScripts.join(", ") || "—"} | ${entry.pytestSymbols.join(", ") || "—"} | ${entry.parameterIds.join(", ") || "—"} | ${entry.cardinality} | ${entry.status} |`),
838
+ "",
839
+ "## Findings",
840
+ "",
841
+ ...(facts.findings.length ? facts.findings.map((item) => `- ${item}`) : ["- None"]),
842
+ "",
843
+ ].join("\n");
844
+ return { facts, markdown };
845
+ }
846
+ async function readFacts(filePath, schema) {
847
+ try {
848
+ const parsed = schema.safeParse(JSON.parse(await readFile(filePath, "utf8")));
849
+ if (!parsed.success)
850
+ return { issue: `${path.basename(filePath)} schema invalid: ${parsed.error.issues[0]?.message ?? "unknown"}` };
851
+ return { value: parsed.data };
852
+ }
853
+ catch (error) {
854
+ return { issue: `${path.basename(filePath)} unavailable: ${error instanceof Error ? error.message : String(error)}` };
855
+ }
856
+ }
857
+ async function freshnessIssues(workspaceRoot, inputFiles) {
858
+ if (!workspaceRoot)
859
+ return [];
860
+ const issues = [];
861
+ for (const item of inputFiles) {
862
+ const absolute = path.resolve(workspaceRoot, item.path);
863
+ if (!(await exists(absolute)))
864
+ issues.push(`input file missing since facts generation: ${item.path}`);
865
+ else if ((await fileHash(absolute)) !== item.sha256)
866
+ issues.push(`input file hash changed since facts generation: ${item.path}`);
867
+ }
868
+ return issues;
869
+ }
870
+ export async function materializeBackendTestCaseManifestFromFacts(input) {
871
+ const contractsDir = path.join(input.runDir, "contracts");
872
+ await mkdir(contractsDir, { recursive: true });
873
+ const coveragePath = path.join(contractsDir, "backend-test-case-coverage-facts.json");
874
+ const correspondencePath = path.join(contractsDir, "backend-test-markdown-pytest-correspondence-facts.json");
875
+ const coverageResult = await readFacts(coveragePath, backendTestCaseCoverageFactsSchema);
876
+ const correspondenceResult = await readFacts(correspondencePath, backendTestMarkdownPytestCorrespondenceFactsSchema);
877
+ const coverageCases = new Map((coverageResult.value?.cases ?? []).map((item) => [item.caseId, item]));
878
+ const correspondenceCases = new Map((correspondenceResult.value?.entries ?? []).filter((item) => item.caseId && item.cardinality !== "0:1").map((item) => [item.caseId, item]));
879
+ const keyIssues = coverageResult.value && correspondenceResult.value ? [
880
+ ...[...coverageCases.keys()].filter((caseId) => !correspondenceCases.has(caseId)).map((caseId) => `coverage Case missing from correspondence facts: ${caseId}`),
881
+ ...[...correspondenceCases.keys()].filter((caseId) => !coverageCases.has(caseId)).map((caseId) => `correspondence Case missing from coverage facts: ${caseId}`),
882
+ ...[...coverageCases.entries()].flatMap(([caseId, testCase]) => {
883
+ const mapping = correspondenceCases.get(caseId);
884
+ return mapping && JSON.stringify([...testCase.testPoints].sort()) !== JSON.stringify([...mapping.testPoints].sort())
885
+ ? [`Case Test Point keys disagree between facts: ${caseId}`]
886
+ : [];
887
+ }),
888
+ ] : [];
889
+ const sourceFactsIssues = orderedUnique([
890
+ ...(coverageResult.issue ? [coverageResult.issue] : []),
891
+ ...(correspondenceResult.issue ? [correspondenceResult.issue] : []),
892
+ ...(coverageResult.value?.taskId !== undefined && coverageResult.value.taskId !== input.sourceBinding.taskId ? ["coverage facts taskId does not match source binding"] : []),
893
+ ...(coverageResult.value && JSON.stringify(coverageResult.value.sourceBinding) !== JSON.stringify(input.sourceBinding) ? ["coverage facts source binding does not match manifest source binding"] : []),
894
+ ...(correspondenceResult.value?.taskId !== undefined && correspondenceResult.value.taskId !== input.sourceBinding.taskId ? ["correspondence facts taskId does not match source binding"] : []),
895
+ ...keyIssues,
896
+ ...(coverageResult.value ? await freshnessIssues(input.workspaceRoot, coverageResult.value.inputFiles) : []),
897
+ ...(correspondenceResult.value ? await freshnessIssues(input.workspaceRoot, correspondenceResult.value.inputFiles) : []),
898
+ ]);
899
+ const materializationStatus = !coverageResult.value && !correspondenceResult.value
900
+ ? "unavailable"
901
+ : sourceFactsIssues.length > 0 || !coverageResult.value || !correspondenceResult.value
902
+ ? "partial"
903
+ : "available";
904
+ const correspondenceByCase = new Map((correspondenceResult.value?.entries ?? [])
905
+ .filter((entry) => entry.caseId && entry.cardinality !== "0:1")
906
+ .map((entry) => [entry.caseId, entry]));
907
+ const manifestCases = (coverageResult.value?.cases ?? []).map((testCase) => {
908
+ const mapping = correspondenceByCase.get(testCase.caseId);
909
+ const generated = Boolean(mapping && mapping.pytestSymbols.length > 0 && mapping.actualScripts.length > 0);
910
+ return {
911
+ caseId: testCase.caseId,
912
+ acIds: testCase.acIds,
913
+ title: testCase.title,
914
+ category: inferCategory(testCase.scenarioTypes),
915
+ automationStatus: generated ? "generated" : "planned",
916
+ ruleRefs: testCase.ruleKeys,
917
+ ...(generated ? { file: mapping.actualScripts[0], symbol: mapping.pytestSymbols[0] } : { gapReason: "pytest correspondence is missing or unavailable" }),
918
+ };
919
+ });
920
+ const base = {
921
+ schemaVersion: 1,
922
+ sourceBinding: input.sourceBinding,
923
+ cases: manifestCases,
924
+ evidenceGaps: [],
925
+ };
926
+ const coverageSummary = materializationStatus === "available" ? computeCaseManifestCoverageSummary(base) : undefined;
927
+ const manifest = backendTestCaseManifestSchema.parse({
928
+ ...base,
929
+ materializationStatus,
930
+ sourceFactsIssues,
931
+ ruleCoverageSummary: coverageResult.value?.summary,
932
+ correspondenceSummary: correspondenceResult.value?.summary,
933
+ artifactRefs: {
934
+ caseCoverageFacts: await artifactRef(input.runDir, coveragePath),
935
+ correspondenceFacts: await artifactRef(input.runDir, correspondencePath),
936
+ },
937
+ ...(coverageSummary ? { coverageSummary } : {}),
938
+ });
939
+ await writeFile(path.join(contractsDir, "backend-test-case-manifest.json"), JSON.stringify(manifest, null, 2), "utf8");
940
+ return manifest;
941
+ }
942
+ function inferCategory(types) {
943
+ const text = types.join(" ").toLowerCase();
944
+ if (/negative|invalid|error/.test(text))
945
+ return "negative";
946
+ if (/boundary|format|enum/.test(text))
947
+ return "boundary";
948
+ if (/state|business|lifecycle|uniqueness/.test(text))
949
+ return "state-transition";
950
+ if (/auth|permission/.test(text))
951
+ return "auth";
952
+ if (/timeout/.test(text))
953
+ return "timeout";
954
+ if (/concurr/.test(text))
955
+ return "concurrency";
956
+ if (/positive|requirement/.test(text))
957
+ return "positive";
958
+ return "other";
959
+ }
960
+ async function artifactRef(runDir, absolutePath) {
961
+ if (!(await exists(absolutePath)))
962
+ return { path: path.relative(runDir, absolutePath).replaceAll(path.sep, "/") };
963
+ return { path: path.relative(runDir, absolutePath).replaceAll(path.sep, "/"), sha256: await fileHash(absolutePath) };
964
+ }