@tea-agent/loop-agent 0.25.0 → 0.25.2

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.
@@ -1,6 +1,12 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { execFile } from "node:child_process";
3
+ import { connect } from "node:net";
2
4
  import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
5
+ import { createWriteStream } from "node:fs";
3
6
  import path from "node:path";
7
+ import { promisify } from "node:util";
8
+ import { parseJacocoXml, } from "./backend-test-coverage-contract.js";
9
+ const execFileAsync = promisify(execFile);
4
10
  const CASE_ID = /\bBE-[A-Z0-9]+(?:-[A-Z0-9]+)*-\d{3}\b/g;
5
11
  const AC_ID = /\bAC-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/g;
6
12
  const SECRET = /(?:password|passwd|secret|token|api[_-]?key|private[_-]?key|authorization)\s*[:=]\s*\S+/i;
@@ -768,15 +774,57 @@ export async function collectBackendTestHumanCaseCatalog(workspaceRoot) {
768
774
  function junitCaseId(name) {
769
775
  return symbolCaseId(name) ?? name.match(CASE_ID)?.[0];
770
776
  }
777
+ /** Prefer function-name Case ID; fall back to first BE-* in free text (docstring/log). */
778
+ function extractCaseIdFromText(value) {
779
+ if (!value?.trim())
780
+ return undefined;
781
+ return junitCaseId(value) ?? value.match(CASE_ID)?.[0];
782
+ }
783
+ function resolveReportCaseId(result, catalog) {
784
+ const fromName = extractCaseIdFromText(result.name);
785
+ if (fromName && catalog.has(fromName))
786
+ return fromName;
787
+ if (fromName)
788
+ return fromName;
789
+ // Match catalog by pytest function/method name when Case ID is only in docstring/md.
790
+ const byFunction = [...catalog.values()].find((item) => item.testFunctions.includes(result.name));
791
+ if (byFunction)
792
+ return byFunction.id;
793
+ const fromStdout = extractCaseIdFromText(result.stdout);
794
+ if (fromStdout && catalog.has(fromStdout))
795
+ return fromStdout;
796
+ if (fromStdout)
797
+ return fromStdout;
798
+ const fromDetails = extractCaseIdFromText(result.details);
799
+ if (fromDetails && catalog.has(fromDetails))
800
+ return fromDetails;
801
+ return "未关联";
802
+ }
771
803
  function humanStatus(status) {
772
804
  return status === "passed" ? "通过" : status === "failure" ? "失败" : status === "error" ? "错误" : "跳过";
773
805
  }
774
806
  function formatDuration(durationMs) {
775
807
  return durationMs === undefined ? "未记录" : `${(durationMs / 1000).toFixed(3)} 秒`;
776
808
  }
777
- function inferredScriptPath(classname) {
778
- const moduleName = classname.split(".").filter((part) => part && !/^Test/.test(part)).join("/");
779
- return `${moduleName || "unknown"}.py`;
809
+ function inferredScriptPath(classname, filePath) {
810
+ if (filePath?.trim()) {
811
+ return filePath.replaceAll("\\", "/").replace(/^\.\//, "");
812
+ }
813
+ const moduleName = classname
814
+ .split(".")
815
+ .filter((part) => part && !/^Test[A-Z_]/.test(part) && part !== "Test")
816
+ .join("/");
817
+ if (!moduleName || moduleName === "unknown")
818
+ return "unknown.py";
819
+ return moduleName.endsWith(".py") ? moduleName : `${moduleName}.py`;
820
+ }
821
+ function resolveReportScriptPath(result, item) {
822
+ if (item?.scriptPath)
823
+ return item.scriptPath;
824
+ if (result.filePath?.trim()) {
825
+ return result.filePath.replaceAll("\\", "/").replace(/^\.\//, "");
826
+ }
827
+ return inferredScriptPath(result.classname, result.filePath);
780
828
  }
781
829
  export function redactBackendTestOutput(value) {
782
830
  return value
@@ -809,8 +857,97 @@ function reportOutputLines(value, prefix) {
809
857
  return redactBackendTestOutput(trimmed.trim()).slice(0, 4000);
810
858
  });
811
859
  }
860
+ /**
861
+ * Normalize alternate HTTP log dialects into HTTP_REQUEST / HTTP_RESPONSE lines.
862
+ * Supports common project helpers that emit [REQ]/[RESP] block style instead of
863
+ * the contract prefixes (keeps original HTTP_* lines unchanged).
864
+ */
865
+ export function normalizeBackendTestHttpLogText(value) {
866
+ if (!value.trim())
867
+ return value;
868
+ const lines = value.split(/\r?\n/);
869
+ const out = [];
870
+ let i = 0;
871
+ while (i < lines.length) {
872
+ const raw = lines[i];
873
+ 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);
877
+ i += 1;
878
+ continue;
879
+ }
880
+ // [REQ] METHOD path OR [REQ] METHOD url
881
+ const reqHead = line.match(/^\[REQ\]\s+([A-Z]+)\s+(\S+)\s*$/i);
882
+ if (reqHead) {
883
+ const method = reqHead[1].toUpperCase();
884
+ const url = reqHead[2];
885
+ let body = "";
886
+ let j = i + 1;
887
+ // Optional [REQ body] then JSON/text until separator or next tag.
888
+ if (j < lines.length && /^\[REQ\s*body\]/i.test(lines[j].trim())) {
889
+ j += 1;
890
+ const bodyLines = [];
891
+ while (j < lines.length &&
892
+ !/^[=-]{3,}\s*$/.test(lines[j].trim()) &&
893
+ !/^\[(?:REQ|RESP)/i.test(lines[j].trim()) &&
894
+ !/^(HTTP_REQUEST|HTTP_RESPONSE|HTTP REQUEST|HTTP RESPONSE)\b/i.test(lines[j].trim())) {
895
+ bodyLines.push(lines[j]);
896
+ j += 1;
897
+ }
898
+ body = bodyLines.join("\n").trim();
899
+ }
900
+ const payload = { method, url };
901
+ if (body) {
902
+ try {
903
+ payload.json = JSON.parse(body);
904
+ }
905
+ catch {
906
+ payload.body = body.slice(0, 4000);
907
+ }
908
+ }
909
+ out.push(`HTTP_REQUEST ${JSON.stringify(payload)}`);
910
+ i = j;
911
+ continue;
912
+ }
913
+ // [RESP status] 200 OR [RESP] 200
914
+ const respHead = line.match(/^\[RESP(?:\s+status)?\]\s+(\d{3})\b/i);
915
+ if (respHead) {
916
+ const status_code = Number(respHead[1]);
917
+ let body = "";
918
+ let j = i + 1;
919
+ if (j < lines.length && /^\[RESP\s*body\]/i.test(lines[j].trim())) {
920
+ j += 1;
921
+ const bodyLines = [];
922
+ while (j < lines.length &&
923
+ !/^[=-]{3,}\s*$/.test(lines[j].trim()) &&
924
+ !/^\[(?:REQ|RESP)/i.test(lines[j].trim()) &&
925
+ !/^(HTTP_REQUEST|HTTP_RESPONSE|HTTP REQUEST|HTTP RESPONSE)\b/i.test(lines[j].trim())) {
926
+ bodyLines.push(lines[j]);
927
+ j += 1;
928
+ }
929
+ body = bodyLines.join("\n").trim();
930
+ }
931
+ const payload = { status_code };
932
+ if (body) {
933
+ try {
934
+ payload.result = JSON.parse(body);
935
+ }
936
+ catch {
937
+ payload.result = body.slice(0, 4000);
938
+ }
939
+ }
940
+ out.push(`HTTP_RESPONSE ${JSON.stringify(payload)}`);
941
+ i = j;
942
+ continue;
943
+ }
944
+ out.push(raw);
945
+ i += 1;
946
+ }
947
+ return out.join("\n");
948
+ }
812
949
  function requestResponseSummary(result) {
813
- const combined = [result.stdout, result.stderr].filter(Boolean).join("\n");
950
+ const combined = normalizeBackendTestHttpLogText([result.stdout, result.stderr].filter(Boolean).join("\n"));
814
951
  return {
815
952
  requests: reportOutputLines(combined, "HTTP_REQUEST"),
816
953
  responses: reportOutputLines(combined, "HTTP_RESPONSE"),
@@ -1047,8 +1184,8 @@ export function renderBackendTestHtml(input) {
1047
1184
  const failures = input.parsed.cases.filter((result) => result.status !== "passed");
1048
1185
  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: "✓" };
1049
1186
  const caseCards = input.parsed.cases.map((result) => {
1050
- const caseId = junitCaseId(result.name) ?? "未关联";
1051
- const item = catalog.get(caseId);
1187
+ const caseId = resolveReportCaseId(result, catalog);
1188
+ const item = caseId === "未关联" ? undefined : catalog.get(caseId);
1052
1189
  const io = requestResponseSummary(result);
1053
1190
  const calls = pairHttpCalls(io.requests, io.responses);
1054
1191
  const statusColors = statusColor(result.status);
@@ -1058,7 +1195,7 @@ export function renderBackendTestHtml(input) {
1058
1195
  const failureBlock = result.status === "passed"
1059
1196
  ? ""
1060
1197
  : `<div style="margin-top:12px;padding:10px 12px;border-radius:8px;background:#fef3f2;border:1px solid #fda29b;color:#b42318;font-size:0.84rem"><strong>${escapeHtml(result.message || humanStatus(result.status))}</strong>${result.details ? `<details style="margin-top:6px"><summary style="cursor:pointer;color:#b42318;font-size:0.78rem">失败详情</summary><pre style="white-space:pre-wrap;background:#f6f8fa;color:#1f2937;padding:10px 12px;border-radius:8px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:0.78rem;margin-top:6px;border:1px solid #eceef2;line-height:1.6">${escapeHtml(result.details)}</pre></details>` : ""}</div>`;
1061
- const scriptPath = item?.scriptPath ?? inferredScriptPath(result.classname);
1198
+ const scriptPath = resolveReportScriptPath(result, item);
1062
1199
  return `<details style="background:#fff;border:1px solid #e6eaf0;border-radius:12px;margin-top:10px"><summary style="display:flex;align-items:center;gap:12px;padding:12px 16px;cursor:pointer;list-style:none"><span style="background:#e8edf5;color:#2b3a55;padding:2px 9px;border-radius:6px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:0.76rem;font-weight:600;white-space:nowrap">${escapeHtml(caseId)}</span><div style="flex:1;min-width:0"><div style="font-weight:600;color:#172033;font-size:0.92rem">${escapeHtml(item?.title ?? result.name)}</div><code style="font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:0.74rem;color:#98a2b3">${escapeHtml(result.name)}</code></div><span style="color:#98a2b3;font-size:0.8rem;white-space:nowrap">${formatDuration(result.durationMs)}</span><span style="display:inline-block;padding:2px 10px;border-radius:999px;font-weight:700;font-size:0.72rem;color:${statusColors.fg};background:${statusColors.bg}">${humanStatus(result.status)}</span><span style="color:#aab2bf;font-size:0.8rem">▾</span></summary><div style="padding:4px 16px 14px 16px;border-top:1px solid #f0f3f7"><div style="margin-top:10px"><div style="color:#667085;font-size:0.74rem;font-weight:600;margin-bottom:3px">自动化脚本</div><code style="background:#eef2f7;color:#344054;padding:1px 6px;border-radius:5px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:0.8rem">${escapeHtml(scriptPath)}</code></div>${item?.scenario ? `<div style="margin-top:12px"><div style="color:#667085;font-size:0.74rem;font-weight:600;margin-bottom:3px">验收场景</div><div style="color:#475467;font-size:0.86rem;line-height:1.75">${escapeHtml(item.scenario)}</div></div>` : ""}${logSection}${failureBlock}</div></details>`;
1063
1200
  }).join("");
1064
1201
  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>`;
@@ -1067,7 +1204,7 @@ export function renderBackendTestHtml(input) {
1067
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>`;
1068
1205
  const failureOverview = failures.length > 0
1069
1206
  ? `<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) => {
1070
- const caseId = junitCaseId(result.name) ?? "未关联";
1207
+ const caseId = resolveReportCaseId(result, catalog);
1071
1208
  const c = statusColor(result.status);
1072
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>`;
1073
1210
  }).join("")}</div></div>`
@@ -1079,6 +1216,98 @@ function renderQualityCard(title, summary) {
1079
1216
  const label = summary.status === "Unavailable" ? "不可用" : summary.status;
1080
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>`;
1081
1218
  }
1219
+ /**
1220
+ * Dump JaCoCo execution data over TCP from a JaCoCo tcpserver agent, convert
1221
+ * .exec → jacoco.xml via jacococli.jar, then parse into a code-coverage-v1
1222
+ * contract. Designed for Java services started with
1223
+ * `-javaagent:jacocoagent.jar=output=tcpserver,port=6300,append=false`.
1224
+ *
1225
+ * Failure-safe: any transport/conversion/parse error returns null so the L-5
1226
+ * dashboard degrades coverage to unavailable instead of blocking the run.
1227
+ */
1228
+ export async function collectJacocoCoverage(input) {
1229
+ const [host, portStr] = input.endpoint.split(":");
1230
+ const port = Number(portStr);
1231
+ if (!host || !Number.isInteger(port) || port <= 0 || port > 65535) {
1232
+ return null;
1233
+ }
1234
+ const execPath = path.join(input.workDir, "jacoco.exec");
1235
+ const xmlPath = path.join(input.workDir, "jacoco.xml");
1236
+ // Step 1: TCP dump of JaCoCo execution data. The protocol is JaCoCo's
1237
+ // binary "dump" command: a magic header + command byte. We send the dump
1238
+ // request and stream the response into jacoco.exec.
1239
+ try {
1240
+ await dumpJacocoExec(host, port, execPath, input.connectTimeoutMs);
1241
+ }
1242
+ catch {
1243
+ return null;
1244
+ }
1245
+ // Step 2: jacococli.jar report .exec → jacoco.xml
1246
+ try {
1247
+ await execFileAsync("java", [
1248
+ "-jar", input.cliJarPath,
1249
+ "report", execPath,
1250
+ "--xml", xmlPath,
1251
+ "--includes", input.includes,
1252
+ ], { timeout: 60000, windowsHide: true });
1253
+ }
1254
+ catch {
1255
+ return null;
1256
+ }
1257
+ // Step 3: parse jacoco.xml into a code-coverage-v1 contract.
1258
+ try {
1259
+ const xml = await readFile(xmlPath, "utf8");
1260
+ const artifactSha256 = createHash("sha256").update(xml).digest("hex");
1261
+ return parseJacocoXml(xml, {
1262
+ sourceScope: input.sourceScope,
1263
+ commitSha: input.commitSha,
1264
+ artifactPath: "reports/jacoco.xml",
1265
+ artifactSha256,
1266
+ });
1267
+ }
1268
+ catch {
1269
+ return null;
1270
+ }
1271
+ }
1272
+ /**
1273
+ * Send a JaCoCo TCP dump command and stream the response into destPath.
1274
+ * JaCoCo tcpserver protocol: connect, send dump command header, read until close.
1275
+ */
1276
+ function dumpJacocoExec(host, port, destPath, connectTimeoutMs) {
1277
+ return new Promise((resolve, reject) => {
1278
+ const socket = connect({ host, port });
1279
+ const writeStream = createWriteStream(destPath);
1280
+ const timer = setTimeout(() => {
1281
+ socket.destroy();
1282
+ reject(new Error("jacoco-tcp-connect-timeout"));
1283
+ }, connectTimeoutMs);
1284
+ socket.on("connect", () => {
1285
+ // JaCoCo TCP "dump" command: magic 0xC0FFEE + cmd=0x40 (dump)
1286
+ // Format: <magic 3 bytes><cmd id 1 byte>
1287
+ // The server responds with the exec data and closes.
1288
+ const magic = Buffer.from([0xC0, 0xFF, 0xEE]);
1289
+ const dumpCmd = Buffer.from([0x40]);
1290
+ socket.end(Buffer.concat([magic, dumpCmd]));
1291
+ });
1292
+ socket.on("error", (error) => {
1293
+ clearTimeout(timer);
1294
+ reject(error);
1295
+ });
1296
+ socket.on("close", () => {
1297
+ clearTimeout(timer);
1298
+ writeStream.end(() => {
1299
+ // Verify we got non-empty exec data.
1300
+ readFile(destPath).then((buf) => {
1301
+ if (buf.length === 0)
1302
+ reject(new Error("jacoco-dump-empty"));
1303
+ else
1304
+ resolve();
1305
+ }).catch(reject);
1306
+ });
1307
+ });
1308
+ socket.pipe(writeStream);
1309
+ });
1310
+ }
1082
1311
  function inferModuleScript(classname) {
1083
1312
  // classname like "test_process_definition_list.py::TestX::test_a" → take the file stem.
1084
1313
  const first = classname.split("::")[0] ?? classname;
@@ -1244,9 +1473,9 @@ export function renderBackendTestFacts(input) {
1244
1473
  "| 用例编号 | 用例名称 | 自动化脚本 | 测试函数 | 结果 | 耗时 | 失败原因 |",
1245
1474
  "|---|---|---|---|---|---:|---|",
1246
1475
  ...input.parsed.cases.map((result) => {
1247
- const caseId = junitCaseId(result.name) ?? "未关联";
1248
- const item = catalog.get(caseId);
1249
- const script = item?.scriptPath ?? inferredScriptPath(result.classname);
1476
+ const caseId = resolveReportCaseId(result, catalog);
1477
+ const item = caseId === "未关联" ? undefined : catalog.get(caseId);
1478
+ const script = resolveReportScriptPath(result, item);
1250
1479
  return `| ${caseId} | ${item?.title ?? result.name} | \`${script}\` | \`${result.name}\` | ${humanStatus(result.status)} | ${formatDuration(result.durationMs)} | ${(result.message ?? "—").replaceAll("|", "\\|")} |`;
1251
1480
  }),
1252
1481
  "",
@@ -428,7 +428,8 @@ export function parsePytestHtmlReport(html) {
428
428
  continue;
429
429
  const rawResult = (record.result ?? "").toLowerCase();
430
430
  const testId = record.testId ?? nodeId;
431
- const { classname, name } = splitPytestNodeId(testId);
431
+ const { classname, name, filePath } = splitPytestNodeId(testId);
432
+ const filePathFields = filePath ? { filePath } : {};
432
433
  const durationMs = parseDurationLabelMs(record.duration);
433
434
  const capturedLog = record.log ?? "";
434
435
  // pytest-html collapses captured stdout/stderr into a single `log` field
@@ -441,6 +442,8 @@ export function parsePytestHtmlReport(html) {
441
442
  cases.push({
442
443
  classname,
443
444
  name,
445
+ ...filePathFields,
446
+ ...filePathFields,
444
447
  durationMs,
445
448
  status: "passed",
446
449
  ...(stdout ? { stdout } : {}),
@@ -455,6 +458,7 @@ export function parsePytestHtmlReport(html) {
455
458
  cases.push({
456
459
  classname,
457
460
  name,
461
+ ...filePathFields,
458
462
  durationMs,
459
463
  status: "failure",
460
464
  message: summary,
@@ -471,6 +475,7 @@ export function parsePytestHtmlReport(html) {
471
475
  cases.push({
472
476
  classname,
473
477
  name,
478
+ ...filePathFields,
474
479
  durationMs,
475
480
  status: "error",
476
481
  message: summary,
@@ -484,6 +489,7 @@ export function parsePytestHtmlReport(html) {
484
489
  cases.push({
485
490
  classname,
486
491
  name,
492
+ ...filePathFields,
487
493
  durationMs,
488
494
  status: "skipped",
489
495
  ...(stdout ? { stdout } : {}),
@@ -499,6 +505,7 @@ export function parsePytestHtmlReport(html) {
499
505
  cases.push({
500
506
  classname,
501
507
  name,
508
+ ...filePathFields,
502
509
  durationMs,
503
510
  status: "error",
504
511
  message: summary,
@@ -520,20 +527,44 @@ export function parsePytestHtmlReport(html) {
520
527
  failures: failures.slice(0, MAX_FAILURES),
521
528
  };
522
529
  }
523
- function splitPytestNodeId(testId) {
524
- // pytest-html uses node ids like "path/to/test_x.py::TestClass::test_name".
525
- const sepIndex = testId.lastIndexOf("::");
526
- if (sepIndex < 0) {
527
- return { classname: "unknown", name: testId };
528
- }
529
- const prefix = testId.slice(0, sepIndex);
530
- const name = testId.slice(sepIndex + 2);
531
- const lastParen = name.indexOf("(");
532
- const cleanName = lastParen >= 0 ? name.slice(0, lastParen) : name;
533
- const moduleSep = prefix.lastIndexOf("::");
534
- const filePart = moduleSep >= 0 ? prefix.slice(moduleSep + 2) : prefix;
535
- const moduleWithoutExt = filePart.replace(/\.py$/i, "");
536
- return { classname: moduleWithoutExt.replace(/\//g, ".") || "unknown", name: cleanName };
530
+ /**
531
+ * Split a pytest node id into module classname, test name, and optional file path.
532
+ *
533
+ * Handles:
534
+ * - `testcase/test_x.py::test_name`
535
+ * - `testcase/test_x.py::TestClass::test_name` (class-based; previous code
536
+ * treated `TestClass` as the file segment and later filtered it to unknown.py)
537
+ * - Windows separators and parametrized names `test_name[param]`
538
+ */
539
+ export function splitPytestNodeId(testId) {
540
+ const normalized = testId.replaceAll("\\", "/").trim();
541
+ if (!normalized) {
542
+ return { classname: "unknown", name: "unknown" };
543
+ }
544
+ const parts = normalized.split("::").filter(Boolean);
545
+ if (parts.length < 2) {
546
+ // Bare id without "::" — keep as name; path unknown.
547
+ return { classname: "unknown", name: normalized };
548
+ }
549
+ let rawName = parts[parts.length - 1];
550
+ // Drop call args if present; keep parametrize brackets in display name strip for id match.
551
+ const paren = rawName.indexOf("(");
552
+ if (paren >= 0)
553
+ rawName = rawName.slice(0, paren);
554
+ const cleanName = rawName.trim() || "unknown";
555
+ // Prefer the first segment that looks like a .py file path.
556
+ const filePart = parts.find((part) => /\.py$/i.test(part)) ??
557
+ (parts[0].includes("/") || parts[0].includes(".") ? parts[0] : undefined);
558
+ const filePath = filePart
559
+ ? filePart.replace(/\\/g, "/")
560
+ : undefined;
561
+ const moduleWithoutExt = (filePath ?? "unknown").replace(/\.py$/i, "");
562
+ const classname = moduleWithoutExt.replaceAll("/", ".") || "unknown";
563
+ return {
564
+ classname,
565
+ name: cleanName,
566
+ ...(filePath ? { filePath } : {}),
567
+ };
537
568
  }
538
569
  function splitPytestHtmlLogSections(log) {
539
570
  if (!log)
@@ -1,8 +1,8 @@
1
1
  import { createHash } from "node:crypto";
2
- import { access, readdir, readFile, realpath, writeFile, } from "node:fs/promises";
2
+ import { access, readdir, readFile, realpath, } from "node:fs/promises";
3
3
  import { existsSync, readFileSync } from "node:fs";
4
- import os from "node:os";
5
4
  import path from "node:path";
5
+ import { writeJsonAtomic } from "../../infrastructure/harness/atomic-write.js";
6
6
  import { assertValidDagSpec } from "./validate.js";
7
7
  import { DAG_AGENT_RUNTIME_PI_ONLY, DAG_REPAIR_WRITER_PROTOCOL_EXPLICIT_NODE_V1, DAG_RUNTIME_CONTRACT_SCHEMA_VERSION, DEFAULT_DAG_OUTPUT_LANGUAGE, DEFAULT_DAG_EXECUTOR_MODELS, parseDagSpec, } from "./types.js";
8
8
  import { planMavenVerification, } from "../../verification/maven/index.js";
@@ -3515,7 +3515,7 @@ async function buildBackendTestHybridDag(sources) {
3515
3515
  'mkdir -p "${HARNESS_DAG_RUN_DIR}/reports"',
3516
3516
  'echo "pytest targets are resolved at runtime from final Markdown 自动化映射"',
3517
3517
  ].join("; ");
3518
- const execute = shellNode("execute-backend-pytest-and-html-report-shell", [traceability.id], "markdown-execute-html", "Resolve the final Markdown Automation Notes/自动化映射 to a unique, safe set of testcase/**/test_*.py targets and execute only those scripts exactly once. Prefer the deterministic module one-to-one path when a mapped script is missing but the module stem file exists. Generate a native pytest-html self-contained report, then render the primary self-contained Chinese HTML report from the same pytest-html plus final Markdown case metadata without rerun. Keep 测试结论, quality status, failure overview, and a polished per-case result card with concise scenario, automation test name, result, duration, and redacted bounded HTTP request parameters/response results for both passed and failed cases. Do not render a technical/execution evidence section in HTML; retain auditable paths and hashes in facts.", "One scoped pytest execution over Markdown-mapped scripts producing a valid pytest-html report with per-case captured output, self-contained reports/backend-test.html, reports/backend-test.md, reports/backend-test-facts.md, and a deterministic self-contained reports/backend-test-l5-dashboard.html (machine-computed L-5 metrics, no JSON); exit 0/1 with valid evidence continues.", [pytestCommand], 300000);
3518
+ const execute = shellNode("execute-backend-pytest-and-html-report-shell", [traceability.id], "markdown-execute-html", "Resolve the final Markdown Automation Notes/自动化映射 to a unique, safe set of testcase/**/test_*.py targets and execute only those scripts exactly once. Prefer the deterministic module one-to-one path when a mapped script is missing but the module stem file exists. Generate a native pytest-html self-contained report, then render the primary self-contained Chinese HTML report from the same pytest-html plus final Markdown case metadata without rerun. Keep 测试结论, quality status, failure overview, and a polished per-case result card with concise scenario, automation test name, result, duration, and redacted bounded HTTP request parameters/response results for both passed and failed cases. Do not render a technical/execution evidence section in HTML; retain auditable paths and hashes in facts.", "One scoped pytest execution over Markdown-mapped scripts producing a valid pytest-html report with per-case captured output, self-contained reports/backend-test.html, reports/backend-test.md, reports/backend-test-facts.md, a deterministic self-contained reports/backend-test-l5-dashboard.html (machine-computed L-5 metrics, no JSON), and an optional contracts/code-coverage-v1.json when jacocoCoverage is configured (JaCoCo TCP dump → jacoco.xml → parsed; failure-safe); exit 0/1 with valid evidence continues.", [pytestCommand], 300000);
3519
3519
  if (execute.shell) {
3520
3520
  execute.shell.envAllowlist = collectBackendTestShellEnvAllowlist(sources);
3521
3521
  }
@@ -5741,9 +5741,6 @@ function buildSupervisedHybridDag(standard, sources) {
5741
5741
  assertValidDagSpec(spec);
5742
5742
  return spec;
5743
5743
  }
5744
- export function defaultHybridDagOutputPath(taskId) {
5745
- return path.join(os.tmpdir(), `${taskId}-hybrid-dag.json`);
5746
- }
5747
5744
  export async function writeHybridDagDraft(sources, outputPath, options = {}) {
5748
5745
  const routingProjectCapability = sources.taskConfig.taskKind === "standard" &&
5749
5746
  (options.template === undefined || options.template === "standard-dag")
@@ -5774,7 +5771,7 @@ export async function writeHybridDagDraft(sources, outputPath, options = {}) {
5774
5771
  taskId: preparedSources.taskId,
5775
5772
  });
5776
5773
  }
5777
- await writeFile(outputPath, `${JSON.stringify(spec, null, 2)}\n`, "utf-8");
5774
+ await writeJsonAtomic(outputPath, spec, { repoRoot: sources.repoRoot });
5778
5775
  return {
5779
5776
  taskId: sources.taskId,
5780
5777
  outputPath,
@@ -5787,7 +5784,7 @@ export async function writeHybridDagDraft(sources, outputPath, options = {}) {
5787
5784
  export async function initHybridDagFromTask(repoRoot, taskId, options = {}) {
5788
5785
  const sources = await loadTaskHybridSources(repoRoot, taskId);
5789
5786
  assertTaskAllowedPathsPreflight(sources.taskConfig);
5790
- const outputPath = options.outputPath ?? defaultHybridDagOutputPath(taskId);
5787
+ const outputPath = options.outputPath ?? getTaskPaths(repoRoot, taskId).dagDraftPath;
5791
5788
  return writeHybridDagDraft(sources, outputPath, {
5792
5789
  template: options.template,
5793
5790
  });
@@ -326,6 +326,17 @@ export const dagShellConfigSchema = z.object({
326
326
  frontendReviewContext: dagFrontendReviewContextSchema.optional(),
327
327
  frontendTestEvidenceValidation: dagFrontendTestEvidenceValidationSchema.optional(),
328
328
  backendTestPipeline: dagBackendTestPipelineSchema.optional(),
329
+ /** JaCoCo coverage collection for backend-test (Java services). When set, node 7 dumps coverage over TCP from a JaCoCo tcpserver agent and feeds it to the L-5 dashboard. */
330
+ jacocoCoverage: z.object({
331
+ /** JaCoCo tcpserver endpoint, e.g. "host:6300". */
332
+ endpoint: z.string().min(1),
333
+ /** Absolute path to jacococli.jar on this machine, used to convert .exec → jacoco.xml. */
334
+ cliJarPath: z.string().min(1),
335
+ /** Business package filter passed to JaCoCo includes, e.g. "com.example.*". Defaults to "*". */
336
+ includes: z.string().min(1).optional().default("*"),
337
+ /** TCP connect timeout in ms. Defaults to 5000. */
338
+ connectTimeoutMs: z.number().int().positive().optional().default(5000),
339
+ }).strict().optional(),
329
340
  verifyEvidence: dagShellVerifyEvidenceSchema.optional(),
330
341
  repairArtifactGate: dagRepairArtifactGateSchema.optional(),
331
342
  /** fail (default): any nonzero command fails the node. record: finish node FINISHED with failure facts for downstream assess/repair. */
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { mkdir } from "node:fs/promises";
3
3
  import { writeLoopDagRecord } from "../../../infrastructure/harness/loop-action-store.js";
4
+ import { getTaskPaths } from "../../../task/runtime.js";
4
5
  import { getLoopPaths } from "../paths.js";
5
6
  import { appendLoopRound } from "../rounds.js";
6
7
  import { rewriteLoopContext } from "../context.js";
@@ -26,7 +27,7 @@ export async function runLoopDagAction(repoRoot, taskId, options = {}) {
26
27
  await mkdir(dagEvidenceDir, { recursive: true });
27
28
  const dagPath = options.dagPath
28
29
  ? path.resolve(repoRoot, options.dagPath)
29
- : path.join(dagEvidenceDir, `round-${round}.dag.json`);
30
+ : getTaskPaths(repoRoot, taskId).dagDraftPath;
30
31
  const runner = options.runner ?? resolveDefaultRunner(repoRoot);
31
32
  let generated = {};
32
33
  if (!options.dagPath) {
@@ -55,6 +55,7 @@
55
55
  "skills/agent-worker/references/agent-worker-operator.md",
56
56
  "skills/loop-agent/SKILL.md",
57
57
  "skills/loop-agent/references/command-reference.md",
58
+ "skills/loop-agent/references/source-and-plan-practice.md",
58
59
  "skills/loop-agent/references/docs-converge.md",
59
60
  "skills/ai-engineering-context/SKILL.md",
60
61
  "skills/verification-before-completion/SKILL.md",
@@ -133,6 +134,7 @@
133
134
  ".agents/skills/agent-worker/references/agent-worker-operator.md",
134
135
  ".agents/skills/loop-agent/SKILL.md",
135
136
  ".agents/skills/loop-agent/references/command-reference.md",
137
+ ".agents/skills/loop-agent/references/source-and-plan-practice.md",
136
138
  ".agents/skills/loop-agent/references/docs-converge.md",
137
139
  ".agents/skills/ai-engineering-context/SKILL.md",
138
140
  ".agents/skills/verification-before-completion/SKILL.md",
@@ -212,6 +214,7 @@
212
214
  ".agents/skills/agent-worker/references/agent-worker-operator.md": "copied",
213
215
  ".agents/skills/loop-agent/SKILL.md": "copied",
214
216
  ".agents/skills/loop-agent/references/command-reference.md": "copied",
217
+ ".agents/skills/loop-agent/references/source-and-plan-practice.md": "copied",
215
218
  ".agents/skills/loop-agent/references/docs-converge.md": "copied",
216
219
  ".agents/skills/ai-engineering-context/SKILL.md": "copied",
217
220
  ".agents/skills/verification-before-completion/SKILL.md": "copied",
@@ -250,7 +250,7 @@
250
250
  ".harness/dag-runs/**",
251
251
  "artifacts/**"
252
252
  ],
253
- "outputContract": "One scoped pytest execution over Markdown-mapped scripts producing a valid pytest-html report with per-case captured output, self-contained reports/backend-test.html, reports/backend-test.md, reports/backend-test-facts.md, and a deterministic self-contained reports/backend-test-l5-dashboard.html (machine-computed L-5 metrics, no JSON); exit 0/1 with valid evidence continues.",
253
+ "outputContract": "One scoped pytest execution over Markdown-mapped scripts producing a valid pytest-html report with per-case captured output, self-contained reports/backend-test.html, reports/backend-test.md, reports/backend-test-facts.md, a deterministic self-contained reports/backend-test-l5-dashboard.html (machine-computed L-5 metrics, no JSON), and an optional contracts/code-coverage-v1.json when jacocoCoverage is configured (JaCoCo TCP dump → jacoco.xml → parsed; failure-safe); exit 0/1 with valid evidence continues.",
254
254
  "subtask_prompt": "Resolve the final Markdown Automation Notes/自动化映射 to a unique, safe set of testcase/**/test_*.py targets and execute only those scripts exactly once. Prefer the deterministic module one-to-one path when a mapped script is missing but the module stem file exists. Generate a native pytest-html self-contained report, then render the primary self-contained Chinese HTML report from the same pytest-html plus final Markdown case metadata without rerun. Keep 测试结论, quality status, failure overview, and a polished per-case result card with concise scenario, automation test name, result, duration, and redacted bounded HTTP request parameters/response results for both passed and failed cases. Do not render a technical/execution evidence section in HTML; retain auditable paths and hashes in facts.",
255
255
  "shell": {
256
256
  "commands": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tea-agent/loop-agent",
3
- "version": "0.25.0",
3
+ "version": "0.25.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "loop-agent": "bin/loop-agent.js",
@@ -27,10 +27,12 @@ Entry: routing and hard rules. Required details come from frontmatter references
27
27
 
28
28
  ```bash
29
29
  loop-agent new-task <task-id> "Title"
30
- # Prefer immutable PRD: loop-agent import-prd <task-id> --file <path>
31
- loop-agent dag run-task <task-id> --profile auto --strict-models --output <dag.json>
32
- loop-agent dag validate --dag <dag.json> --strict-models --strict-governance
33
- loop-agent run-dag --dag <dag.json> --cwd <repo-root>
30
+ # Prefer import-prd when a PRD exists; plan create for non-trivial work
31
+ # (see references/source-and-plan-practice.md)
32
+ loop-agent dag run-task <task-id> --profile auto --strict-models
33
+ # default draft: .harness/tasks/<task-id>/dag.json
34
+ loop-agent dag validate --dag .harness/tasks/<task-id>/dag.json --strict-models --strict-governance
35
+ loop-agent run-dag --dag .harness/tasks/<task-id>/dag.json --cwd <repo-root>
34
36
  ```
35
37
 
36
38
  执行前审阅 `profileRouting`、`governanceProfile`、writer `writeSet`、`forbiddenPaths`、decision gate mode。
@@ -44,10 +46,6 @@ loop-agent run-dag --dag <dag.json> --cwd <repo-root>
44
46
  | Operator commands、`agent-worker` | `references/command-reference.md` |
45
47
  | 独立验证、failure handling、closeout | `references/verification-and-failure-handling.md` |
46
48
 
47
- ## Source Layout
48
-
49
- CLI `src/cli/`;DAG `src/workflows/dag/`;loop `src/workflows/loop/`;executors `src/executors/`;worker `src/worker/`;governance `src/governance/`。不新增平行兼容入口。
50
-
51
49
  ## Hard Rules
52
50
 
53
51
  1. Use vertical tracer bullets across real integration layers;each needs independent acceptance and verification.