@tea-agent/loop-agent 0.19.0 → 0.20.1-beta.0

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 (31) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/application/dag/generate-task-dag.js +12 -1
  3. package/dist/executors/shell-executor.js +189 -2
  4. package/dist/worker/observe/spec-evidence.js +33 -0
  5. package/dist/worker/observe/static/views/dag-inspector.js +67 -4
  6. package/dist/workflows/dag/backend-test-markdown-workflow.js +163 -41
  7. package/dist/workflows/dag/backend-test-result-contract.js +30 -7
  8. package/dist/workflows/dag/frontend-prewrite-gate.js +172 -0
  9. package/dist/workflows/dag/frontend-project-capability.js +6 -2
  10. package/dist/workflows/dag/frontend-repair.js +7 -1
  11. package/dist/workflows/dag/frontend-review-context.js +43 -0
  12. package/dist/workflows/dag/frontend-verification-trace.js +34 -15
  13. package/dist/workflows/dag/governance-profile.js +14 -6
  14. package/dist/workflows/dag/init-hybrid.js +144 -399
  15. package/dist/workflows/dag/types.js +33 -0
  16. package/dist/workflows/dag/validate.js +22 -1
  17. package/docs/README.md +1 -0
  18. package/docs/templates/agent-dag.schema.json +40 -0
  19. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +23 -192
  20. package/docs/templates/backend-test-dag.json +8 -8
  21. package/docs/templates/backend-test-dag.review-cases.prompt.md +22 -75
  22. package/package.json +1 -1
  23. package/skills/frontend-design-review/SKILL.md +5 -3
  24. package/skills/frontend-design-review/references/review-checklist.md +3 -2
  25. package/skills/frontend-implementation/references/design-spec.md +16 -8
  26. package/skills/frontend-implementation/references/node-contracts.md +6 -8
  27. package/skills/frontend-review/SKILL.md +12 -9
  28. package/skills/frontend-review/references/review-findings.md +5 -1
  29. package/skills/frontend-verification/SKILL.md +5 -3
  30. package/skills/frontend-verification/references/verification-checklist.md +3 -2
  31. package/skills/loop-agent/references/hybrid-dag.md +2 -2
@@ -5,13 +5,23 @@ const CASE_ID = /\bBE-[A-Z0-9]+(?:-[A-Z0-9]+)*-\d{3}\b/g;
5
5
  const AC_ID = /\bAC-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/g;
6
6
  const SECRET = /(?:password|passwd|secret|token|api[_-]?key|private[_-]?key|authorization)\s*[:=]\s*\S+/i;
7
7
  const PLACEHOLDER = /\b(?:TODO|TBD|FIXME)\b|后续补充|暂不实现|接口正常|结果正确|按实际情况处理/i;
8
+ const CASE_SECTION_ALIASES = {
9
+ acceptanceCriteria: ["Acceptance Criteria", "验收标准"],
10
+ sourceReferences: ["Source References", "需求依据"],
11
+ preconditions: ["Preconditions", "前置条件"],
12
+ testData: ["Test Data", "测试数据"],
13
+ steps: ["Steps", "操作步骤"],
14
+ expectedResults: ["Expected Results", "预期结果"],
15
+ automationNotes: ["Automation Notes", "自动化映射", "自动化说明"],
16
+ testPurpose: ["Test Purpose", "测试目的", "测试场景"],
17
+ };
8
18
  const REQUIRED_CASE_SECTIONS = [
9
- "Acceptance Criteria",
10
- "Source References",
11
- "Preconditions",
12
- "Steps",
13
- "Expected Results",
14
- "Automation Notes",
19
+ CASE_SECTION_ALIASES.acceptanceCriteria,
20
+ CASE_SECTION_ALIASES.sourceReferences,
21
+ CASE_SECTION_ALIASES.preconditions,
22
+ CASE_SECTION_ALIASES.steps,
23
+ CASE_SECTION_ALIASES.expectedResults,
24
+ CASE_SECTION_ALIASES.automationNotes,
15
25
  ];
16
26
  async function exists(filePath) {
17
27
  try {
@@ -34,7 +44,7 @@ function unique(values) {
34
44
  return [...new Set(values)].sort((left, right) => left.localeCompare(right));
35
45
  }
36
46
  export function requiredBackendMarkdownCaseAcIds(criteria) {
37
- const downstreamEvidence = /(?:pytest|junit|html|stdout|stderr|traceability|追溯|一一映射|一一追溯|执行并产生|执行结果|测试报告|失败分类|证据)/i;
47
+ const downstreamEvidence = /(?:pytest|junit|html|stdout|stderr|traceability|markdown|readme|human-readable|skip|xfail|吞(?:断言|异常)|生产代码|生产配置|配置写入|写入边界|中文(?:用例|文档|结构|展示)?|用例(?:索引|结构|文档)|测试目的|需求依据|操作步骤|预期结果|自动化映射|追溯|一一映射|一一追溯|执行并产生|执行结果|测试报告|失败分类|证据)/i;
38
48
  return unique([...criteria]
39
49
  .filter((criterion) => !downstreamEvidence.test(criterion))
40
50
  .flatMap((criterion) => criterion.match(/\bAC-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/g) ?? []));
@@ -165,8 +175,9 @@ function splitCases(markdown) {
165
175
  body: markdown.slice(match.index, headings[index + 1]?.index ?? markdown.length),
166
176
  }));
167
177
  }
168
- function sectionBody(body, heading) {
169
- const marker = new RegExp(`^###\\s+${heading}\\s*$`, "mi");
178
+ function sectionBody(body, headings) {
179
+ const headingPattern = headings.map(escapeRegExp).join("|");
180
+ const marker = new RegExp(`^###\\s+(?:${headingPattern})\\s*$`, "mi");
170
181
  const match = marker.exec(body);
171
182
  if (!match)
172
183
  return "";
@@ -320,14 +331,15 @@ export async function validateBackendMarkdownCases(input) {
320
331
  if (previous)
321
332
  throw new Error(`duplicate case id ${testCase.id}: ${previous} and ${path.relative(input.workspaceRoot, file)}`);
322
333
  seen.set(testCase.id, path.relative(input.workspaceRoot, file).replaceAll(path.sep, "/"));
323
- for (const section of REQUIRED_CASE_SECTIONS) {
324
- if (!new RegExp(`^###\\s+${section}\\s*$`, "mi").test(testCase.body)) {
325
- throw new Error(`${testCase.id} missing section: ${section}`);
334
+ for (const sectionAliases of REQUIRED_CASE_SECTIONS) {
335
+ const sectionPattern = sectionAliases.map(escapeRegExp).join("|");
336
+ if (!new RegExp(`^###\\s+(?:${sectionPattern})\\s*$`, "mi").test(testCase.body)) {
337
+ throw new Error(`${testCase.id} missing section: ${sectionAliases.join(" or ")}`);
326
338
  }
327
339
  }
328
340
  const acIds = unique(testCase.body.match(AC_ID) ?? []);
329
341
  acIds.forEach((id) => coveredAc.add(id));
330
- const sourceSection = sectionBody(testCase.body, "Source References");
342
+ const sourceSection = sectionBody(testCase.body, CASE_SECTION_ALIASES.sourceReferences);
331
343
  const sourceRefs = extractSourceReferences({
332
344
  sourceSection,
333
345
  sourceBinding: input.sourceBinding,
@@ -348,8 +360,8 @@ export async function validateBackendMarkdownCases(input) {
348
360
  throw new Error(`${testCase.id} references a missing source path: ${sourceRef}`);
349
361
  }
350
362
  }
351
- const steps = sectionBody(testCase.body, "Steps");
352
- const expected = sectionBody(testCase.body, "Expected Results");
363
+ const steps = sectionBody(testCase.body, CASE_SECTION_ALIASES.steps);
364
+ const expected = sectionBody(testCase.body, CASE_SECTION_ALIASES.expectedResults);
353
365
  if (!hasNumberedListItem(steps))
354
366
  throw new Error(`${testCase.id} has no numbered executable step`);
355
367
  if (!hasAssertableExpectedResult(expected))
@@ -381,18 +393,32 @@ function collectMarkdownCaseIds(markdown) {
381
393
  }
382
394
  function testFunctionRegion(input) {
383
395
  const functionLineStart = input.source.lastIndexOf("\n", input.functionIndex - 1) + 1;
384
- const prefix = input.source.slice(0, functionLineStart);
385
- const previousBlankLine = Math.max(prefix.lastIndexOf("\n\n"), prefix.lastIndexOf("\r\n\r\n"));
386
- const decoratorCandidateStart = previousBlankLine >= 0
387
- ? previousBlankLine + (prefix.startsWith("\r\n", previousBlankLine) ? 4 : 2)
388
- : 0;
389
- const decoratorCandidate = input.source.slice(decoratorCandidateStart, functionLineStart);
390
- const regionStart = /^\s*@/m.test(decoratorCandidate)
391
- ? decoratorCandidateStart
392
- : functionLineStart;
393
- const nextBoundary = /^(?:(?:async\s+)?def|class)\s+[A-Za-z_][A-Za-z0-9_]*\b/gm;
394
- nextBoundary.lastIndex = input.functionHeaderEnd;
395
- const regionEnd = nextBoundary.exec(input.source)?.index ?? input.source.length;
396
+ const functionLine = input.source.slice(functionLineStart, input.functionHeaderEnd);
397
+ const indent = functionLine.match(/^\s*/)?.[0] ?? "";
398
+ let regionStart = functionLineStart;
399
+ let cursor = functionLineStart;
400
+ while (cursor > 0) {
401
+ const previousEnd = cursor - 1;
402
+ const previousStart = input.source.lastIndexOf("\n", previousEnd - 1) + 1;
403
+ const previousLine = input.source.slice(previousStart, previousEnd).replace(/\r$/, "");
404
+ if (!previousLine.trim())
405
+ break;
406
+ if (new RegExp(`^${escapeRegExp(indent)}@`).test(previousLine)) {
407
+ regionStart = previousStart;
408
+ cursor = previousStart;
409
+ continue;
410
+ }
411
+ break;
412
+ }
413
+ const lines = input.source.slice(input.functionHeaderEnd).split(/\r?\n/);
414
+ let consumed = 0;
415
+ for (const line of lines) {
416
+ const boundary = line.match(/^(\s*)(?:(?:async\s+)?def|class)\s+[A-Za-z_][A-Za-z0-9_]*\b/);
417
+ if (boundary && boundary[1].length <= indent.length)
418
+ break;
419
+ consumed += line.length + 1;
420
+ }
421
+ const regionEnd = Math.min(input.source.length, input.functionHeaderEnd + consumed);
396
422
  return input.source
397
423
  .slice(regionStart, regionEnd)
398
424
  .split(/\r?\n/)
@@ -436,8 +462,8 @@ export async function validateBackendMarkdownTraceability(workspaceRoot) {
436
462
  if (unsafeSwallowedException) {
437
463
  throw new Error(`swallowed exception is forbidden in generated backend pytest: ${path.relative(workspaceRoot, file)} (${unsafeSwallowedException})`);
438
464
  }
439
- for (const match of content.matchAll(/^(?:async\s+)?def\s+(test_[A-Za-z0-9_]+)\s*\([^)]*\)\s*(?:->\s*[^:\r\n]+)?\s*:/gm)) {
440
- const symbol = match[1];
465
+ for (const match of content.matchAll(/^(\s*)(?:async\s+)?def\s+(test_[A-Za-z0-9_]+)\s*\([^)]*\)\s*(?:->\s*[^:\r\n]+)?\s*:/gm)) {
466
+ const symbol = match[2];
441
467
  const region = testFunctionRegion({
442
468
  source: content,
443
469
  functionIndex: match.index,
@@ -482,6 +508,82 @@ export async function validateBackendMarkdownTraceability(workspaceRoot) {
482
508
  "- skip/xfail findings: 0",
483
509
  ].join("\n") + "\n");
484
510
  }
511
+ function cleanCaseTitle(rawHeading, id) {
512
+ return rawHeading
513
+ .replace(/^##\s+/, "")
514
+ .replace(new RegExp(`^${escapeRegExp(id)}\\s*(?:[||—–-]\\s*)?`), "")
515
+ .trim() || id;
516
+ }
517
+ export async function collectBackendTestHumanCaseCatalog(workspaceRoot) {
518
+ const files = await markdownFiles(workspaceRoot);
519
+ const catalog = new Map();
520
+ for (const file of files) {
521
+ if (path.basename(file).toLowerCase() === "readme.md")
522
+ continue;
523
+ const markdown = await readFile(file, "utf8");
524
+ for (const testCase of splitCases(markdown)) {
525
+ const heading = testCase.body.match(/^##\s+.*$/m)?.[0] ?? testCase.id;
526
+ const purpose = sectionBody(testCase.body, CASE_SECTION_ALIASES.testPurpose).trim();
527
+ const fallbackScenario = sectionBody(testCase.body, CASE_SECTION_ALIASES.expectedResults)
528
+ .replace(/^\s*(?:\d+[.)]|[-*+])\s+/gm, "")
529
+ .replace(/\s+/g, " ")
530
+ .trim();
531
+ catalog.set(testCase.id, {
532
+ id: testCase.id,
533
+ title: cleanCaseTitle(heading, testCase.id),
534
+ scenario: purpose.replace(/\s+/g, " ").trim() || fallbackScenario || "详见 Markdown 用例步骤与预期结果。",
535
+ markdownPath: path.relative(workspaceRoot, file).replaceAll(path.sep, "/"),
536
+ testFunctions: [],
537
+ });
538
+ }
539
+ }
540
+ const testcaseRoot = path.join(workspaceRoot, "testcase");
541
+ const pythonFiles = [];
542
+ async function walk(directory) {
543
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
544
+ const absolute = path.join(directory, entry.name);
545
+ if (entry.isDirectory())
546
+ await walk(absolute);
547
+ else if (/^test_.*\.py$/i.test(entry.name))
548
+ pythonFiles.push(absolute);
549
+ }
550
+ }
551
+ if (await exists(testcaseRoot))
552
+ await walk(testcaseRoot);
553
+ for (const file of pythonFiles) {
554
+ const content = await readFile(file, "utf8");
555
+ for (const match of content.matchAll(/^(\s*)(?:async\s+)?def\s+(test_[A-Za-z0-9_]+)\s*\([^)]*\)\s*(?:->\s*[^:\r\n]+)?\s*:/gm)) {
556
+ const symbol = match[2];
557
+ const region = testFunctionRegion({ source: content, functionIndex: match.index, functionHeaderEnd: match.index + match[0].length });
558
+ const ids = new Set(region.match(CASE_ID) ?? []);
559
+ const fromSymbol = symbolCaseId(symbol);
560
+ if (fromSymbol)
561
+ ids.add(fromSymbol);
562
+ for (const id of ids) {
563
+ const item = catalog.get(id);
564
+ if (!item)
565
+ continue;
566
+ item.scriptPath = path.relative(workspaceRoot, file).replaceAll(path.sep, "/");
567
+ if (!item.testFunctions.includes(symbol))
568
+ item.testFunctions.push(symbol);
569
+ }
570
+ }
571
+ }
572
+ return [...catalog.values()].sort((left, right) => left.id.localeCompare(right.id));
573
+ }
574
+ function junitCaseId(name) {
575
+ return symbolCaseId(name) ?? name.match(CASE_ID)?.[0];
576
+ }
577
+ function humanStatus(status) {
578
+ return status === "passed" ? "通过" : status === "failure" ? "失败" : status === "error" ? "错误" : "跳过";
579
+ }
580
+ function formatDuration(durationMs) {
581
+ return durationMs === undefined ? "未记录" : `${(durationMs / 1000).toFixed(3)} 秒`;
582
+ }
583
+ function inferredScriptPath(classname) {
584
+ const moduleName = classname.split(".").filter((part) => part && !/^Test/.test(part)).join("/");
585
+ return `${moduleName || "unknown"}.py`;
586
+ }
485
587
  export function redactBackendTestOutput(value) {
486
588
  return value
487
589
  .replace(/((?:password|passwd|secret|token|api[_-]?key|authorization)\s*[:=]\s*)\S+/gi, "$1[REDACTED]")
@@ -496,19 +598,28 @@ function escapeHtml(value) {
496
598
  }
497
599
  export function renderBackendTestHtml(input) {
498
600
  const passRate = input.parsed.tests > 0 ? input.parsed.passed / input.parsed.tests : 0;
499
- const failures = input.parsed.failures.length
500
- ? input.parsed.failures
501
- .map((failure) => `<tr><td>${escapeHtml(failure.name)}</td><td>${escapeHtml(failure.kind)}</td><td><pre>${escapeHtml(failure.message)}</pre></td></tr>`)
502
- .join("")
503
- : '<tr><td colspan="3">No failures</td></tr>';
504
- return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>${escapeHtml(input.title)}</title><style>body{font-family:system-ui,"Microsoft YaHei",sans-serif;margin:2rem;color:#172033}h1,h2{color:#17365d}.cards{display:grid;grid-template-columns:repeat(3,minmax(8rem,1fr));gap:.75rem}.card{padding:1rem;border:1px solid #ccd6e0;border-radius:.5rem;background:#f7f9fc}.bad{color:#b42318}table{width:100%;border-collapse:collapse}th,td{border:1px solid #ccd6e0;padding:.55rem;text-align:left;vertical-align:top}pre{white-space:pre-wrap;max-height:18rem;overflow:auto}</style></head><body><h1>${escapeHtml(input.title)}</h1><div class="cards"><div class="card">Total<br><strong>${input.parsed.tests}</strong></div><div class="card">Passed<br><strong>${input.parsed.passed}</strong></div><div class="card bad">Failed/Error<br><strong>${input.parsed.failed + input.parsed.errors}</strong></div><div class="card">Skipped<br><strong>${input.parsed.skipped}</strong></div><div class="card">Pass rate<br><strong>${(passRate * 100).toFixed(2)}%</strong></div><div class="card">Duration<br><strong>${input.parsed.durationMs ?? "unavailable"} ms</strong></div></div><h2>Environment</h2><pre>${escapeHtml(input.environmentSummary.slice(0, 4000))}</pre><h2>Traceability</h2><pre>${escapeHtml(input.traceabilitySummary.slice(0, 4000))}</pre><h2>Failures</h2><table><thead><tr><th>Test</th><th>Kind</th><th>Summary</th></tr></thead><tbody>${failures}</tbody></table></body></html>`;
601
+ const catalog = new Map((input.cases ?? []).map((item) => [item.id, item]));
602
+ const rows = input.parsed.cases.map((result) => {
603
+ const caseId = junitCaseId(result.name) ?? "未关联";
604
+ const item = catalog.get(caseId);
605
+ const scriptPath = item?.scriptPath ?? inferredScriptPath(result.classname);
606
+ const details = result.details || result.message || "";
607
+ const failureCell = result.status === "passed"
608
+ ? "—"
609
+ : `<strong>${escapeHtml(result.message || humanStatus(result.status))}</strong>${details ? `<details><summary>查看完整技术详情</summary><pre>${escapeHtml(details)}</pre></details>` : ""}`;
610
+ return `<tr class="result-${result.status}"><td><code>${escapeHtml(caseId)}</code></td><td><strong>${escapeHtml(item?.title ?? result.name)}</strong><small>${escapeHtml(item?.scenario ?? "未从 Markdown 用例提取场景说明。")}</small></td><td><code>${escapeHtml(scriptPath)}</code><small>${escapeHtml(result.name)}</small></td><td><span class="status ${result.status}">${humanStatus(result.status)}</span></td><td>${formatDuration(result.durationMs)}</td><td>${failureCell}</td></tr>`;
611
+ }).join("");
612
+ const conclusion = input.parsed.failed + input.parsed.errors === 0
613
+ ? `本轮测试通过:${input.parsed.passed} 条用例执行成功。`
614
+ : `本轮测试未通过:${input.parsed.failed + input.parsed.errors} 条用例失败或错误,请优先查看失败原因。`;
615
+ return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(input.title)}</title><style>:root{color-scheme:light;--ink:#172033;--muted:#667085;--line:#d9e2ec;--panel:#f7f9fc;--brand:#17365d;--pass:#067647;--pass-bg:#ecfdf3;--fail:#b42318;--fail-bg:#fef3f2;--skip:#9a6700;--skip-bg:#fffaeb}*{box-sizing:border-box}body{margin:0;background:#eef2f6;color:var(--ink);font-family:system-ui,"Microsoft YaHei","PingFang SC",sans-serif;line-height:1.6}header,main{max-width:1440px;margin:auto}header{padding:2.5rem 2rem 1.25rem}main{padding:0 2rem 3rem}h1{margin:.25rem 0;color:var(--brand)}h2{margin-top:2rem;color:var(--brand)}.eyebrow{color:var(--muted);font-weight:700}.banner{padding:1rem 1.25rem;border-radius:.75rem;background:${input.parsed.failed + input.parsed.errors === 0 ? "var(--pass-bg)" : "var(--fail-bg)"};color:${input.parsed.failed + input.parsed.errors === 0 ? "var(--pass)" : "var(--fail)"};font-weight:700}.cards{display:grid;grid-template-columns:repeat(6,minmax(7rem,1fr));gap:.75rem;margin:1rem 0}.card{padding:1rem;border:1px solid var(--line);border-radius:.75rem;background:white}.card span,small{display:block;color:var(--muted)}.card strong{font-size:1.55rem}section{background:white;border:1px solid var(--line);border-radius:.9rem;padding:1.25rem;margin-top:1rem;box-shadow:0 8px 24px rgba(23,54,93,.04)}.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse;min-width:1100px}th,td{border-bottom:1px solid var(--line);padding:.75rem;text-align:left;vertical-align:top}th{background:var(--panel);position:sticky;top:0}.status{display:inline-block;padding:.15rem .55rem;border-radius:999px;font-weight:700}.status.passed{color:var(--pass);background:var(--pass-bg)}.status.failure,.status.error{color:var(--fail);background:var(--fail-bg)}.status.skipped{color:var(--skip);background:var(--skip-bg)}code{white-space:nowrap}pre{white-space:pre-wrap;max-height:22rem;overflow:auto;background:#101828;color:#f2f4f7;padding:1rem;border-radius:.5rem}details{margin-top:.5rem}summary{cursor:pointer;color:var(--brand);font-weight:700}.secondary{display:grid;grid-template-columns:1fr 1fr;gap:1rem}.secondary pre{background:var(--panel);color:var(--ink)}@media(max-width:900px){.cards{grid-template-columns:repeat(2,1fr)}.secondary{grid-template-columns:1fr}header,main{padding-left:1rem;padding-right:1rem}}</style></head><body><header><div class="eyebrow">后端自动化测试报告</div><h1>${escapeHtml(input.title)}</h1><p>主要面向测试、研发和评审人员;执行事实来自同一次 pytest JUnit。</p></header><main><section><h2>测试结论</h2><div class="banner">${escapeHtml(conclusion)}</div><div class="cards"><div class="card"><span>用例总数</span><strong>${input.parsed.tests}</strong></div><div class="card"><span>通过</span><strong>${input.parsed.passed}</strong></div><div class="card"><span>失败</span><strong>${input.parsed.failed}</strong></div><div class="card"><span>错误</span><strong>${input.parsed.errors}</strong></div><div class="card"><span>跳过</span><strong>${input.parsed.skipped}</strong></div><div class="card"><span>通过率 / 总耗时</span><strong>${(passRate * 100).toFixed(2)}%</strong><small>${formatDuration(input.parsed.durationMs)}</small></div></div></section><section><h2>用例执行结果</h2><div class="table-wrap"><table><thead><tr><th>用例编号</th><th>用例名称及测试场景</th><th>自动化脚本</th><th>结果</th><th>耗时</th><th>失败原因</th></tr></thead><tbody>${rows || '<tr><td colspan="6">未发现可展示的 JUnit testcase。</td></tr>'}</tbody></table></div></section><section><h2>执行证据</h2><div class="secondary"><details><summary>环境信息</summary><pre>${escapeHtml(input.environmentSummary.slice(0, 8000))}</pre></details><details><summary>用例追溯信息</summary><pre>${escapeHtml(input.traceabilitySummary.slice(0, 8000))}</pre></details></div></section></main></body></html>`;
505
616
  }
506
617
  export function renderBackendTestFacts(input) {
507
618
  const passRate = input.parsed.tests > 0 ? input.parsed.passed / input.parsed.tests : 0;
508
619
  return ([
509
- "# Backend Test Execution Facts",
620
+ "# 后端测试执行事实",
510
621
  "",
511
- "## Execution",
622
+ "## 执行摘要",
512
623
  "",
513
624
  `- Status: ${input.pytestExitCode === 0 ? "passed" : "completed-with-failures"}`,
514
625
  `- Pytest exit code: ${input.pytestExitCode}`,
@@ -516,7 +627,7 @@ export function renderBackendTestFacts(input) {
516
627
  "- JUnit valid: yes",
517
628
  "- HTML valid: yes",
518
629
  "",
519
- "## Counts",
630
+ "## 计数",
520
631
  "",
521
632
  `- Total: ${input.parsed.tests}`,
522
633
  `- Passed: ${input.parsed.passed}`,
@@ -525,20 +636,31 @@ export function renderBackendTestFacts(input) {
525
636
  `- Skipped: ${input.parsed.skipped}`,
526
637
  `- Pass rate: ${(passRate * 100).toFixed(2)}%`,
527
638
  "",
528
- "## Evidence",
639
+ "## 逐条执行结果",
640
+ "",
641
+ "| 用例编号 | 用例名称 | 自动化脚本 | 测试函数 | 结果 | 耗时 | 失败原因 |",
642
+ "|---|---|---|---|---|---:|---|",
643
+ ...input.parsed.cases.map((result) => {
644
+ const caseId = junitCaseId(result.name) ?? "未关联";
645
+ const item = new Map((input.cases ?? []).map((entry) => [entry.id, entry])).get(caseId);
646
+ const script = item?.scriptPath ?? inferredScriptPath(result.classname);
647
+ return `| ${caseId} | ${item?.title ?? result.name} | \`${script}\` | \`${result.name}\` | ${humanStatus(result.status)} | ${formatDuration(result.durationMs)} | ${(result.message ?? "—").replaceAll("|", "\\|")} |`;
648
+ }),
649
+ "",
650
+ "## 证据",
529
651
  "",
530
652
  `- JUnit: ${input.junitRelativePath}`,
531
653
  `- JUnit SHA-256: ${createHash("sha256").update(input.junitContent).digest("hex")}`,
532
654
  `- HTML: ${input.htmlRelativePath}`,
533
655
  `- HTML SHA-256: ${createHash("sha256").update(input.htmlContent).digest("hex")}`,
534
656
  "",
535
- "## Failed Tests",
657
+ "## 失败用例",
536
658
  "",
537
659
  ...(input.parsed.failures.length > 0
538
660
  ? input.parsed.failures.map((failure) => `- ${failure.name} [${failure.kind}]: ${failure.message}`)
539
661
  : ["- None"]),
540
662
  "",
541
- "## Maturity Evidence",
663
+ "## 成熟度证据",
542
664
  "",
543
665
  "- Code coverage: unavailable unless a separate validated coverage artifact exists.",
544
666
  "- Stability: unavailable unless at least five independent runs are recorded.",
@@ -178,8 +178,10 @@ export function parseJunitXml(xml) {
178
178
  if (openedCases !== closedCases + selfClosingCases) {
179
179
  throw new Error("invalid junit xml: unclosed testcase element");
180
180
  }
181
- // Prefer root testsuites aggregates when present.
181
+ // Prefer root testsuites aggregates when present, then fall back to the first
182
+ // testsuite aggregate. Pytest commonly emits time only on <testsuite>.
182
183
  const suitesOpen = trimmed.match(/<testsuites\b[^>]*>/i)?.[0];
184
+ const firstSuiteOpen = trimmed.match(/<testsuite\b[^>]*>/i)?.[0];
183
185
  let tests = 0;
184
186
  let failed = 0;
185
187
  let errors = 0;
@@ -202,9 +204,15 @@ export function parseJunitXml(xml) {
202
204
  if (time !== undefined && time !== "")
203
205
  timeSec = Number(time);
204
206
  }
207
+ if (timeSec === undefined && firstSuiteOpen) {
208
+ const time = attr(firstSuiteOpen, "time");
209
+ if (time !== undefined && time !== "")
210
+ timeSec = Number(time);
211
+ }
205
212
  // Self-closing first so empty cases ending with /> are not greedily paired with a later </testcase>.
206
213
  const caseRe = /<testcase\b([^>]*?)\/>|<testcase\b([^>]*)>([\s\S]*?)<\/testcase>/gi;
207
214
  const failures = [];
215
+ const cases = [];
208
216
  let caseCount = 0;
209
217
  let caseFailed = 0;
210
218
  let caseErrors = 0;
@@ -216,35 +224,49 @@ export function parseJunitXml(xml) {
216
224
  const body = match[3] ?? "";
217
225
  const classname = attr(openAttrs, "classname") || "unknown";
218
226
  const name = attr(openAttrs, "name") || "unknown";
227
+ const caseTime = attr(openAttrs, "time");
228
+ const caseDurationMs = caseTime !== undefined && caseTime !== "" && !Number.isNaN(Number(caseTime))
229
+ ? Math.round(Number(caseTime) * 1000)
230
+ : undefined;
219
231
  const failureTag = body.match(/<failure\b([^>]*)>([\s\S]*?)<\/failure>|<failure\b([^>]*)\/>/i);
220
232
  const errorTag = body.match(/<error\b([^>]*)>([\s\S]*?)<\/error>|<error\b([^>]*)\/>/i);
221
233
  const skippedTag = /<skipped\b/i.test(body);
222
234
  if (failureTag) {
223
235
  caseFailed += 1;
224
236
  const fAttrs = failureTag[1] ?? failureTag[3] ?? "";
225
- const fBody = failureTag[2] ?? "";
226
- const message = attr(fAttrs, "message") || fBody || "failure";
237
+ const fBody = decodeXmlEntities(failureTag[2] ?? "").trim();
238
+ const message = decodeXmlEntities(attr(fAttrs, "message") || fBody || "failure");
239
+ const summary = truncate(message);
227
240
  failures.push({
228
241
  classname,
229
242
  name,
230
- message: truncate(message),
243
+ message: summary,
231
244
  kind: "failure",
232
245
  });
246
+ cases.push({ classname, name, durationMs: caseDurationMs, status: "failure", message: summary, details: fBody || summary });
233
247
  }
234
248
  else if (errorTag) {
235
249
  caseErrors += 1;
236
250
  const eAttrs = errorTag[1] ?? errorTag[3] ?? "";
237
- const eBody = errorTag[2] ?? "";
238
- const message = attr(eAttrs, "message") || eBody || "error";
251
+ const eBody = decodeXmlEntities(errorTag[2] ?? "").trim();
252
+ const message = decodeXmlEntities(attr(eAttrs, "message") || eBody || "error");
253
+ const summary = truncate(message);
239
254
  failures.push({
240
255
  classname,
241
256
  name,
242
- message: truncate(message),
257
+ message: summary,
243
258
  kind: "error",
244
259
  });
260
+ cases.push({ classname, name, durationMs: caseDurationMs, status: "error", message: summary, details: eBody || summary });
245
261
  }
246
262
  else if (skippedTag) {
247
263
  caseSkipped += 1;
264
+ const skippedAttrs = body.match(/<skipped\b([^>]*)/i)?.[1] ?? "";
265
+ const message = attr(skippedAttrs, "message");
266
+ cases.push({ classname, name, durationMs: caseDurationMs, status: "skipped", ...(message ? { message } : {}) });
267
+ }
268
+ else {
269
+ cases.push({ classname, name, durationMs: caseDurationMs, status: "passed" });
248
270
  }
249
271
  match = caseRe.exec(trimmed);
250
272
  }
@@ -322,6 +344,7 @@ export function parseJunitXml(xml) {
322
344
  durationMs: timeSec !== undefined && !Number.isNaN(timeSec)
323
345
  ? Math.round(timeSec * 1000)
324
346
  : undefined,
347
+ cases,
325
348
  failures: failures.slice(0, MAX_FAILURES),
326
349
  };
327
350
  }
@@ -0,0 +1,172 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { frontendImplementationContractSchema, materializeFrontendImplementationContract, } from "./frontend-implementation-contract.js";
4
+ async function selectNode(runDir, primary, fallbacks) {
5
+ for (const nodeId of [primary, ...fallbacks.filter((id) => id !== primary)]) {
6
+ try {
7
+ await stat(path.join(runDir, `${nodeId}.json`));
8
+ return nodeId;
9
+ }
10
+ catch (error) {
11
+ if (error.code === "ENOENT")
12
+ continue;
13
+ throw error;
14
+ }
15
+ }
16
+ throw new Error(`frontend prewrite gate missing node output: ${[primary, ...fallbacks].join(", ")}`);
17
+ }
18
+ async function readNodeText(runDir, nodeId) {
19
+ const record = JSON.parse(await readFile(path.join(runDir, `${nodeId}.json`), "utf8"));
20
+ const text = record.assistantText?.trim() || record.stdout?.trim() || "";
21
+ if (!text)
22
+ throw new Error(`frontend prewrite gate empty output from ${nodeId}`);
23
+ return text;
24
+ }
25
+ function firstVerdictLine(text) {
26
+ for (const raw of text.split(/\r?\n/)) {
27
+ const line = raw.trim().replace(/^\*{1,3}\s*(VERDICT:[^*]+?)\s*\*{1,3}$/, "$1").trim();
28
+ if (line.startsWith("VERDICT:"))
29
+ return line;
30
+ }
31
+ return "";
32
+ }
33
+ function eventArgs(event) {
34
+ return event.args ?? event.toolInput ?? event.input ?? {};
35
+ }
36
+ function toRepoRelativePath(filePath, repoRoot) {
37
+ const resolved = path.resolve(repoRoot, filePath);
38
+ const relative = path.relative(repoRoot, resolved);
39
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
40
+ return null;
41
+ }
42
+ return relative.replaceAll(path.sep, "/");
43
+ }
44
+ function isFailedToolResult(event) {
45
+ return (event.isError === true ||
46
+ event.toolResult?.error != null ||
47
+ event.toolResult?.ok === false ||
48
+ event.result?.error != null ||
49
+ event.result?.ok === false);
50
+ }
51
+ async function checkOpenspecReadEvidence(input) {
52
+ const { runDir, candidatePaths, planNodeId, reviewNodeId, repoRoot } = input;
53
+ if (candidatePaths.length === 0)
54
+ return [];
55
+ const matched = new Set();
56
+ const normalizedCandidates = new Set(candidatePaths
57
+ .map((candidate) => toRepoRelativePath(candidate, repoRoot))
58
+ .filter((candidate) => Boolean(candidate?.startsWith("openspec/"))));
59
+ for (const nodeId of [planNodeId, reviewNodeId]) {
60
+ const eventsPath = path.join(runDir, nodeId, "session-events.jsonl");
61
+ try {
62
+ const raw = await readFile(eventsPath, "utf8");
63
+ const startedReads = new Map();
64
+ for (const line of raw.split(/\r?\n/)) {
65
+ const trimmed = line.trim();
66
+ if (!trimmed)
67
+ continue;
68
+ let event;
69
+ try {
70
+ event = JSON.parse(trimmed);
71
+ }
72
+ catch {
73
+ continue;
74
+ }
75
+ if (event.type === "tool_execution_start" &&
76
+ event.toolName === "read" &&
77
+ event.toolCallId) {
78
+ const readPath = eventArgs(event).path;
79
+ if (typeof readPath === "string") {
80
+ startedReads.set(event.toolCallId, readPath);
81
+ }
82
+ }
83
+ else if (event.type === "tool_execution_end" &&
84
+ event.toolName === "read" &&
85
+ event.toolCallId !== undefined) {
86
+ const readPath = startedReads.get(event.toolCallId);
87
+ const relativePath = readPath
88
+ ? toRepoRelativePath(readPath, repoRoot)
89
+ : null;
90
+ if (relativePath &&
91
+ !isFailedToolResult(event) &&
92
+ normalizedCandidates.has(relativePath)) {
93
+ matched.add(relativePath);
94
+ }
95
+ startedReads.delete(event.toolCallId);
96
+ }
97
+ }
98
+ }
99
+ catch (error) {
100
+ if (error.code === "ENOENT")
101
+ continue;
102
+ throw error;
103
+ }
104
+ }
105
+ return [...matched];
106
+ }
107
+ export async function runFrontendPrewriteGate(input) {
108
+ const planNodeId = await selectNode(input.runDir, input.config.planFromNodeId, input.config.planFallbackFromNodeIds);
109
+ const reviewNodeId = await selectNode(input.runDir, input.config.reviewFromNodeId, input.config.reviewFallbackFromNodeIds);
110
+ const planText = await readNodeText(input.runDir, planNodeId);
111
+ const reviewText = await readNodeText(input.runDir, reviewNodeId);
112
+ const verdict = firstVerdictLine(reviewText);
113
+ if (verdict !== "VERDICT: pass") {
114
+ throw new Error(`frontend prewrite gate blocked by ${reviewNodeId}: ${verdict || "missing VERDICT"}`);
115
+ }
116
+ const missingIds = input.config.requiredRequirementIds.filter((id) => !planText.includes(id));
117
+ if (missingIds.length > 0) {
118
+ throw new Error(`frontend prewrite gate missing requirement ids: ${missingIds.join(", ")}`);
119
+ }
120
+ const artifact = await materializeFrontendImplementationContract({
121
+ runDir: input.runDir,
122
+ fromNodeId: planNodeId,
123
+ artifactName: input.config.artifactName,
124
+ outputDir: input.config.outputDir,
125
+ sourceBinding: input.sourceBinding,
126
+ });
127
+ const raw = JSON.parse(await readFile(artifact.path, "utf8"));
128
+ const contract = frontendImplementationContractSchema.parse(raw);
129
+ if (!input.config.allowedMockStrategies.includes(contract.mockApi.strategy)) {
130
+ throw new Error(`frontend prewrite gate blocked mock strategy ${contract.mockApi.strategy}; allowed=${input.config.allowedMockStrategies.join(",")}`);
131
+ }
132
+ const candidatePaths = input.config.openspecCandidatePaths ?? [];
133
+ const openspecReadPaths = await checkOpenspecReadEvidence({
134
+ runDir: input.runDir,
135
+ candidatePaths,
136
+ planNodeId,
137
+ reviewNodeId,
138
+ repoRoot: input.repoRoot ?? process.cwd(),
139
+ });
140
+ if (candidatePaths.length > 0 && openspecReadPaths.length === 0) {
141
+ const checkedNodes = [planNodeId, reviewNodeId].join(", ");
142
+ throw new Error(`openspec gate blocked: ${candidatePaths.length} candidate(s) [${candidatePaths.join(", ")}] not read by ${checkedNodes}; writer not authorized.`);
143
+ }
144
+ return {
145
+ ok: true,
146
+ planNodeId,
147
+ reviewNodeId,
148
+ verdict,
149
+ mockStrategy: contract.mockApi.strategy,
150
+ artifact,
151
+ openspecReadPaths,
152
+ openspecCandidatePaths: candidatePaths,
153
+ };
154
+ }
155
+ export function formatFrontendPrewriteGateStdout(result) {
156
+ const lines = [
157
+ "Frontend prewrite gate: pass",
158
+ `Plan: ${result.planNodeId}`,
159
+ `Review: ${result.reviewNodeId}`,
160
+ `Mock strategy: ${result.mockStrategy}`,
161
+ `Structured artifact: ${result.artifact.path}`,
162
+ `Schema: ${result.artifact.schemaId}`,
163
+ `SHA-256: ${result.artifact.sha256}`,
164
+ ];
165
+ if (result.openspecReadPaths.length > 0) {
166
+ lines.push(`openspec read: ${result.openspecReadPaths.join(", ")}`);
167
+ }
168
+ else if (result.openspecCandidatePaths.length === 0) {
169
+ lines.push("openspec: unavailable");
170
+ }
171
+ return lines.join("\n");
172
+ }
@@ -105,6 +105,7 @@ export async function discoverFrontendProjectCapability(repoRoot) {
105
105
  const pkgPath = path.join(repoRoot, "package.json");
106
106
  const pkgRaw = await readJson(pkgPath);
107
107
  if (!pkgRaw || typeof pkgRaw !== "object") {
108
+ const openspec = await listOpenspec(repoRoot);
108
109
  const base = {
109
110
  schemaVersion: 1,
110
111
  framework: "unknown",
@@ -115,14 +116,17 @@ export async function discoverFrontendProjectCapability(repoRoot) {
115
116
  testRunner: [],
116
117
  mock: [],
117
118
  a11y: { status: "unknown", tools: [], evidencePaths: [] },
118
- evidencePaths: [],
119
+ evidencePaths: openspec.slice(0, 5),
119
120
  reasons: ["package.json missing or unreadable"],
120
121
  designEvidence: {
121
- normativePaths: [],
122
+ normativePaths: openspec,
122
123
  advisoryPaths: [],
123
124
  conflicts: [],
124
125
  },
125
126
  };
127
+ if (openspec.length) {
128
+ base.reasons.push(`openspec/ files discovered: ${openspec.slice(0, 5).join(", ")}`);
129
+ }
126
130
  return { ...base, adapterGuidance: buildAdapterGuidance(base) };
127
131
  }
128
132
  evidencePaths.push("package.json");
@@ -190,6 +190,8 @@ export async function runFrontendFailureAssessGate(input) {
190
190
  throw new Error("assess: missing or invalid contracts/frontend-implementation-contract.json");
191
191
  }
192
192
  const candidateNodeIds = [
193
+ "frontend-verify-assess-shell",
194
+ "frontend-reverify-shell",
193
195
  "frontend-static-verify-shell",
194
196
  "frontend-behavior-verify-shell",
195
197
  "frontend-verification-trace-shell",
@@ -197,8 +199,12 @@ export async function runFrontendFailureAssessGate(input) {
197
199
  "frontend-behavior-reverify-shell",
198
200
  "frontend-verification-retrace-shell",
199
201
  ];
200
- const failed = [];
202
+ const failed = [
203
+ ...(input.failureFacts ?? []).filter(({ record }) => nodeHadFailure(record) || recordIndicatesCommandFailure(record)),
204
+ ];
201
205
  for (const nodeId of candidateNodeIds) {
206
+ if (failed.some((item) => item.nodeId === nodeId))
207
+ continue;
202
208
  const nodePath = path.join(input.runDir, `${nodeId}.json`);
203
209
  try {
204
210
  const record = JSON.parse(await readFile(nodePath, "utf8"));