@tea-agent/loop-agent 0.19.0 → 0.20.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 +12 -0
- package/dist/application/dag/generate-task-dag.js +12 -1
- package/dist/executors/shell-executor.js +188 -2
- 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 +77 -0
- 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 +143 -399
- package/dist/workflows/dag/types.js +30 -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-implementation/references/node-contracts.md +6 -8
- package/skills/frontend-review/SKILL.md +5 -8
- package/skills/loop-agent/references/hybrid-dag.md +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.20.0] - 2026-07-23
|
|
6
|
+
|
|
7
|
+
### 新增
|
|
8
|
+
|
|
9
|
+
- streamline implementation DAG (#61)
|
|
10
|
+
- improve human-readable artifacts
|
|
11
|
+
|
|
12
|
+
### 修复
|
|
13
|
+
|
|
14
|
+
- relax traceability mapping
|
|
15
|
+
|
|
5
16
|
## [0.19.0] - 2026-07-23
|
|
6
17
|
|
|
7
18
|
### 重点更新
|
|
@@ -17,6 +28,7 @@
|
|
|
17
28
|
|
|
18
29
|
### 改进
|
|
19
30
|
|
|
31
|
+
- `frontend-implementation` 保留独立 contract/scout,并把 Mock 策略与 implementation contract 合入 plan,把写前授权、验证/失败评估、复验和 review context 分别收敛为组合 shell 节点。standard/high-risk 固定 15 个顶层节点、small-risk 13 个;绿色路径执行 11 个节点、7 次 Pi,同时保留唯一 prewrite 写入授权、同 writeSet repair、真实 diff review 与 fail-closed closeout。
|
|
20
32
|
- 优化操作与观测统一界面的导航与布局,合并确认与运行为「开始运行」,DAG 详情跳转路径更加准确
|
|
21
33
|
|
|
22
34
|
### 修复
|
|
@@ -105,6 +105,17 @@ function isVerificationShellTask(task) {
|
|
|
105
105
|
const commands = resolveShellCommands(task.shell);
|
|
106
106
|
return commands.some((command) => /(vitest|npm run (lint|typecheck|test)|check-repo\.sh|loop-agent-standard-verify)/.test(command));
|
|
107
107
|
}
|
|
108
|
+
function resolveReviewPacketShellCommands(task) {
|
|
109
|
+
const bundle = task.shell?.frontendVerificationBundle;
|
|
110
|
+
if (bundle) {
|
|
111
|
+
return [
|
|
112
|
+
...bundle.mockCommands,
|
|
113
|
+
...bundle.staticCommands,
|
|
114
|
+
...bundle.behaviorCommands,
|
|
115
|
+
];
|
|
116
|
+
}
|
|
117
|
+
return resolveShellCommands(task.shell);
|
|
118
|
+
}
|
|
108
119
|
async function buildReviewPacket(input) {
|
|
109
120
|
const spec = parseDagSpec(JSON.parse(await readFile(input.dagPath, "utf-8")));
|
|
110
121
|
const writers = collectWriterTasksForPacket(spec).map((task) => {
|
|
@@ -123,7 +134,7 @@ async function buildReviewPacket(input) {
|
|
|
123
134
|
.filter(isVerificationShellTask)
|
|
124
135
|
.map((task) => ({
|
|
125
136
|
nodeId: task.id,
|
|
126
|
-
commands:
|
|
137
|
+
commands: resolveReviewPacketShellCommands(task),
|
|
127
138
|
}));
|
|
128
139
|
return {
|
|
129
140
|
profileRouting: {
|
|
@@ -12,10 +12,12 @@ import { materializeFrontendTestResult, } from "../workflows/dag/frontend-test-r
|
|
|
12
12
|
import { formatFrontendVerificationTraceStdout, runFrontendVerificationTraceGate, } from "../workflows/dag/frontend-verification-trace.js";
|
|
13
13
|
import { formatFrontendWorktreeDiffStdout, runFrontendWorktreeDiffGate, } from "../workflows/dag/frontend-worktree-diff.js";
|
|
14
14
|
import { formatFrontendFailureAssessStdout, formatFrontendRepairContractStdout, runFrontendFailureAssessGate, runFrontendRepairContractGate, } from "../workflows/dag/frontend-repair.js";
|
|
15
|
+
import { formatFrontendPrewriteGateStdout, runFrontendPrewriteGate, } from "../workflows/dag/frontend-prewrite-gate.js";
|
|
16
|
+
import { formatFrontendReviewContextStdout, runFrontendReviewContextGate, } from "../workflows/dag/frontend-review-context.js";
|
|
15
17
|
import { formatTraceabilityGateStdout, materializeBackendTestCaseManifest, runBackendTestTraceabilityGate, } from "../workflows/dag/backend-test-case-manifest.js";
|
|
16
18
|
import { materializeBackendTestExecutionContract } from "../workflows/dag/backend-test-execution-contract.js";
|
|
17
19
|
import { materializeBackendTestResultFromRunDir, parseJunitXml } from "../workflows/dag/backend-test-result-contract.js";
|
|
18
|
-
import { inspectBackendTestEnvironment, requiredBackendMarkdownCaseAcIds, renderBackendTestFacts, renderBackendTestHtml, redactBackendTestOutput, validateBackendMarkdownCases, validateBackendMarkdownTraceability, writeRunReport, } from "../workflows/dag/backend-test-markdown-workflow.js";
|
|
20
|
+
import { collectBackendTestHumanCaseCatalog, inspectBackendTestEnvironment, requiredBackendMarkdownCaseAcIds, renderBackendTestFacts, renderBackendTestHtml, redactBackendTestOutput, validateBackendMarkdownCases, validateBackendMarkdownTraceability, writeRunReport, } from "../workflows/dag/backend-test-markdown-workflow.js";
|
|
19
21
|
import { buildBackendTestCanonicalResultFromInitialShellSnippet, materializeBackendTestClassification, } from "../workflows/dag/backend-test-classification-contract.js";
|
|
20
22
|
import { backendTestSemanticReviewSchema, materializeBackendTestSemanticReview, } from "../workflows/dag/backend-test-semantic-review-contract.js";
|
|
21
23
|
import { pathsChangedDuringRun, readGitStatusPorcelain, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
|
|
@@ -356,14 +358,16 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
356
358
|
if (![0, 1].includes(pytestExitCode))
|
|
357
359
|
throw new Error(`pytest did not complete with a reportable exit code: ${pytestExitCode}`);
|
|
358
360
|
const parsed = parseJunitXml(junitContent);
|
|
361
|
+
const cases = await collectBackendTestHumanCaseCatalog(input.cwd);
|
|
359
362
|
const htmlContent = renderBackendTestHtml({
|
|
360
363
|
title: meta.spec.title,
|
|
361
364
|
parsed,
|
|
365
|
+
cases,
|
|
362
366
|
environmentSummary: await readFile(path.join(reportsDir, "backend-test-environment.md"), "utf8"),
|
|
363
367
|
traceabilitySummary: await readFile(path.join(reportsDir, "backend-test-traceability.md"), "utf8"),
|
|
364
368
|
});
|
|
365
369
|
const htmlPath = await writeRunReport(meta.runDir, "backend-test.html", htmlContent);
|
|
366
|
-
const facts = renderBackendTestFacts({ parsed, pytestExitCode, junitRelativePath: "reports/backend-test.junit.xml", htmlRelativePath: "reports/backend-test.html", junitContent, htmlContent });
|
|
370
|
+
const facts = renderBackendTestFacts({ parsed, cases, pytestExitCode, junitRelativePath: "reports/backend-test.junit.xml", htmlRelativePath: "reports/backend-test.html", junitContent, htmlContent });
|
|
367
371
|
const factsPath = await writeRunReport(meta.runDir, "backend-test-facts.md", facts);
|
|
368
372
|
const sanitizedOutputs = results.map((result) => redactBackendTestOutput(result.stdout));
|
|
369
373
|
outputs.push(...sanitizedOutputs, `html=${htmlPath}`, `facts=${factsPath}`, facts);
|
|
@@ -601,8 +605,190 @@ async function executePipelineCommands(input, meta, overrideCommands) {
|
|
|
601
605
|
}
|
|
602
606
|
return results;
|
|
603
607
|
}
|
|
608
|
+
async function executeFrontendVerificationBundle(input, meta) {
|
|
609
|
+
const started = Date.now();
|
|
610
|
+
const shell = input.task.shell;
|
|
611
|
+
const bundle = shell.frontendVerificationBundle;
|
|
612
|
+
const cwd = resolveShellCwd(input.cwd, shell.cwd);
|
|
613
|
+
const results = [];
|
|
614
|
+
let beforeStatus;
|
|
615
|
+
try {
|
|
616
|
+
beforeStatus = await readGitStatusPorcelain(input.cwd);
|
|
617
|
+
}
|
|
618
|
+
catch {
|
|
619
|
+
beforeStatus = undefined;
|
|
620
|
+
}
|
|
621
|
+
const groups = [
|
|
622
|
+
{ name: "mock", commands: bundle.mockCommands },
|
|
623
|
+
{ name: "static", commands: bundle.staticCommands },
|
|
624
|
+
{ name: "behavior", commands: bundle.behaviorCommands },
|
|
625
|
+
];
|
|
626
|
+
for (const group of groups) {
|
|
627
|
+
for (const command of group.commands) {
|
|
628
|
+
const commandNumber = results.length + 1;
|
|
629
|
+
const result = await executeShellCommand({
|
|
630
|
+
command,
|
|
631
|
+
cwd,
|
|
632
|
+
timeoutMs: shell.timeoutMs ?? DEFAULT_SHELL_TIMEOUT_MS,
|
|
633
|
+
envAllowlist: shell.envAllowlist,
|
|
634
|
+
dagRunMeta: { runDir: meta.runDir, runId: meta.runId },
|
|
635
|
+
outputArtifacts: {
|
|
636
|
+
stdoutPath: path.join(meta.runDir, input.task.id, "commands", `${commandNumber}.stdout.txt`),
|
|
637
|
+
stderrPath: path.join(meta.runDir, input.task.id, "commands", `${commandNumber}.stderr.txt`),
|
|
638
|
+
},
|
|
639
|
+
});
|
|
640
|
+
results.push(result);
|
|
641
|
+
if (!result.ok)
|
|
642
|
+
break;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
if (beforeStatus !== undefined) {
|
|
646
|
+
const guard = await runShellWriteGuard({
|
|
647
|
+
rootCwd: input.cwd,
|
|
648
|
+
task: input.task,
|
|
649
|
+
beforeStatus,
|
|
650
|
+
});
|
|
651
|
+
if (!guard.ok) {
|
|
652
|
+
return {
|
|
653
|
+
ok: false,
|
|
654
|
+
stdout: "",
|
|
655
|
+
stderr: `write guard failed: ${guard.violations.join(", ")}`,
|
|
656
|
+
failureCategory: "write-guard",
|
|
657
|
+
durationMs: Date.now() - started,
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
await writeDagNodeTextArtifact(meta.runDir, input.task.id, "result.summary.md", buildShellResultSummaryMarkdown({
|
|
662
|
+
nodeId: input.task.id,
|
|
663
|
+
runId: meta.runId,
|
|
664
|
+
rootCwd: input.cwd,
|
|
665
|
+
results,
|
|
666
|
+
}));
|
|
667
|
+
const commandResults = results.map((result) => ({
|
|
668
|
+
ok: result.ok,
|
|
669
|
+
exitCode: result.exitCode,
|
|
670
|
+
failureCategory: result.failureCategory,
|
|
671
|
+
command: result.command,
|
|
672
|
+
}));
|
|
673
|
+
const firstFailure = results.find((result) => !result.ok);
|
|
674
|
+
let traceError;
|
|
675
|
+
try {
|
|
676
|
+
await runFrontendVerificationTraceGate({
|
|
677
|
+
runDir: meta.runDir,
|
|
678
|
+
workspaceRoot: input.cwd,
|
|
679
|
+
evidence: {
|
|
680
|
+
static: {
|
|
681
|
+
nodeId: input.task.id,
|
|
682
|
+
commandLabels: bundle.staticEvidence.commandLabels,
|
|
683
|
+
},
|
|
684
|
+
behavior: {
|
|
685
|
+
nodeId: input.task.id,
|
|
686
|
+
commandLabels: bundle.behaviorEvidence.commandLabels,
|
|
687
|
+
},
|
|
688
|
+
},
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
catch (error) {
|
|
692
|
+
traceError = error instanceof Error ? error : new Error(String(error));
|
|
693
|
+
}
|
|
694
|
+
if (bundle.mode === "repair") {
|
|
695
|
+
if (firstFailure || traceError) {
|
|
696
|
+
return {
|
|
697
|
+
ok: false,
|
|
698
|
+
stdout: summarizeCommandResults(results).stdout,
|
|
699
|
+
stderr: firstFailure?.stderr || traceError?.message || "frontend reverify failed",
|
|
700
|
+
failureCategory: firstFailure?.failureCategory ?? "invalid-output",
|
|
701
|
+
durationMs: Date.now() - started,
|
|
702
|
+
...{ commandResults },
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
return {
|
|
706
|
+
ok: true,
|
|
707
|
+
stdout: "Frontend reverify bundle: pass",
|
|
708
|
+
stderr: "",
|
|
709
|
+
failureCategory: "success",
|
|
710
|
+
durationMs: Date.now() - started,
|
|
711
|
+
...{ commandResults },
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
const failureFacts = [];
|
|
715
|
+
if (firstFailure) {
|
|
716
|
+
failureFacts.push({
|
|
717
|
+
nodeId: input.task.id,
|
|
718
|
+
record: {
|
|
719
|
+
status: "FINISHED",
|
|
720
|
+
failureCategory: firstFailure.failureCategory,
|
|
721
|
+
stdout: summarizeCommandResults(results).stdout,
|
|
722
|
+
stderr: firstFailure.stderr,
|
|
723
|
+
commandResults,
|
|
724
|
+
},
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
if (traceError) {
|
|
728
|
+
failureFacts.push({
|
|
729
|
+
nodeId: "frontend-verification-trace-shell",
|
|
730
|
+
record: {
|
|
731
|
+
status: "FINISHED",
|
|
732
|
+
failureCategory: "invalid-output",
|
|
733
|
+
stderr: traceError.message,
|
|
734
|
+
},
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
try {
|
|
738
|
+
const assessment = await runFrontendFailureAssessGate({
|
|
739
|
+
runDir: meta.runDir,
|
|
740
|
+
failureFacts,
|
|
741
|
+
});
|
|
742
|
+
await runFrontendRepairContractGate({ runDir: meta.runDir });
|
|
743
|
+
return {
|
|
744
|
+
ok: true,
|
|
745
|
+
stdout: formatFrontendFailureAssessStdout(assessment),
|
|
746
|
+
stderr: "",
|
|
747
|
+
failureCategory: "success",
|
|
748
|
+
durationMs: Date.now() - started,
|
|
749
|
+
...{ commandResults },
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
catch (error) {
|
|
753
|
+
return {
|
|
754
|
+
ok: false,
|
|
755
|
+
stdout: "",
|
|
756
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
757
|
+
failureCategory: "invalid-output",
|
|
758
|
+
durationMs: Date.now() - started,
|
|
759
|
+
...{ commandResults },
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
}
|
|
604
763
|
export async function executeDagShellNode(input, meta) {
|
|
605
764
|
const shell = input.task.shell;
|
|
765
|
+
if (shell?.frontendPrewriteGate) {
|
|
766
|
+
const started = Date.now();
|
|
767
|
+
try {
|
|
768
|
+
const result = await runFrontendPrewriteGate({
|
|
769
|
+
runDir: meta.runDir,
|
|
770
|
+
config: shell.frontendPrewriteGate,
|
|
771
|
+
sourceBinding: meta.spec.sourceBinding,
|
|
772
|
+
});
|
|
773
|
+
return { ok: true, stdout: formatFrontendPrewriteGateStdout(result), stderr: "", failureCategory: "success", durationMs: Date.now() - started };
|
|
774
|
+
}
|
|
775
|
+
catch (error) {
|
|
776
|
+
return { ok: false, stdout: "", stderr: error instanceof Error ? error.message : String(error), failureCategory: "invalid-output", durationMs: Date.now() - started };
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
if (shell?.frontendVerificationBundle) {
|
|
780
|
+
return executeFrontendVerificationBundle(input, meta);
|
|
781
|
+
}
|
|
782
|
+
if (shell?.frontendReviewContext) {
|
|
783
|
+
const started = Date.now();
|
|
784
|
+
try {
|
|
785
|
+
const result = await runFrontendReviewContextGate({ runDir: meta.runDir, workspaceRoot: input.cwd });
|
|
786
|
+
return { ok: true, stdout: formatFrontendReviewContextStdout(result), stderr: "", failureCategory: "success", durationMs: Date.now() - started };
|
|
787
|
+
}
|
|
788
|
+
catch (error) {
|
|
789
|
+
return { ok: false, stdout: "", stderr: error instanceof Error ? error.message : String(error), failureCategory: "invalid-output", durationMs: Date.now() - started };
|
|
790
|
+
}
|
|
791
|
+
}
|
|
606
792
|
if (shell?.backendTestPipeline) {
|
|
607
793
|
return executeBackendTestPipelineWithWriteGuard(input, meta);
|
|
608
794
|
}
|
|
@@ -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.",
|