@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.
- package/CHANGELOG.md +19 -0
- package/dist/application/dag/generate-task-dag.js +12 -1
- package/dist/executors/shell-executor.js +189 -2
- package/dist/worker/observe/spec-evidence.js +33 -0
- package/dist/worker/observe/static/views/dag-inspector.js +67 -4
- package/dist/workflows/dag/backend-test-markdown-workflow.js +163 -41
- package/dist/workflows/dag/backend-test-result-contract.js +30 -7
- package/dist/workflows/dag/frontend-prewrite-gate.js +172 -0
- package/dist/workflows/dag/frontend-project-capability.js +6 -2
- package/dist/workflows/dag/frontend-repair.js +7 -1
- package/dist/workflows/dag/frontend-review-context.js +43 -0
- package/dist/workflows/dag/frontend-verification-trace.js +34 -15
- package/dist/workflows/dag/governance-profile.js +14 -6
- package/dist/workflows/dag/init-hybrid.js +144 -399
- package/dist/workflows/dag/types.js +33 -0
- package/dist/workflows/dag/validate.js +22 -1
- package/docs/README.md +1 -0
- package/docs/templates/agent-dag.schema.json +40 -0
- package/docs/templates/backend-test-dag.generate-pytest.prompt.md +23 -192
- package/docs/templates/backend-test-dag.json +8 -8
- package/docs/templates/backend-test-dag.review-cases.prompt.md +22 -75
- package/package.json +1 -1
- package/skills/frontend-design-review/SKILL.md +5 -3
- package/skills/frontend-design-review/references/review-checklist.md +3 -2
- package/skills/frontend-implementation/references/design-spec.md +16 -8
- package/skills/frontend-implementation/references/node-contracts.md +6 -8
- package/skills/frontend-review/SKILL.md +12 -9
- package/skills/frontend-review/references/review-findings.md +5 -1
- package/skills/frontend-verification/SKILL.md +5 -3
- package/skills/frontend-verification/references/verification-checklist.md +3 -2
- 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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
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,
|
|
169
|
-
const
|
|
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
|
|
324
|
-
|
|
325
|
-
|
|
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,
|
|
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,
|
|
352
|
-
const expected = sectionBody(testCase.body,
|
|
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
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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[
|
|
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
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
"#
|
|
620
|
+
"# 后端测试执行事实",
|
|
510
621
|
"",
|
|
511
|
-
"##
|
|
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
|
-
"##
|
|
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
|
-
"##
|
|
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
|
-
"##
|
|
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
|
-
"##
|
|
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:
|
|
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:
|
|
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"));
|