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