@tea-agent/loop-agent 0.21.0 → 0.22.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 (40) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/bin/agent-worker.js +0 -0
  3. package/dist/adapters/loop-agent.js +52 -0
  4. package/dist/commands/init.js +97 -0
  5. package/dist/executors/dag-pi-executor.js +2 -0
  6. package/dist/executors/shell-executor.js +162 -19
  7. package/dist/shared/openspec-spec.js +49 -0
  8. package/dist/worker/observability/read-model.js +21 -1
  9. package/dist/worker/observe/spec-evidence.js +12 -15
  10. package/dist/worker/observe/static/dag-helpers.js +22 -0
  11. package/dist/worker/observe/static/views/dag.js +5 -0
  12. package/dist/workflows/dag/backend-test-markdown-workflow.js +37 -0
  13. package/dist/workflows/dag/frontend-implementation-contract.js +141 -32
  14. package/dist/workflows/dag/frontend-lint-baseline.js +471 -0
  15. package/dist/workflows/dag/frontend-prewrite-gate.js +79 -16
  16. package/dist/workflows/dag/frontend-project-capability.js +11 -8
  17. package/dist/workflows/dag/frontend-repair.js +6 -4
  18. package/dist/workflows/dag/frontend-review-context.js +67 -0
  19. package/dist/workflows/dag/frontend-test-case-quality.js +105 -0
  20. package/dist/workflows/dag/frontend-test-result-contract.js +71 -66
  21. package/dist/workflows/dag/frontend-verification-trace.js +31 -1
  22. package/dist/workflows/dag/frontend-worktree-diff.js +81 -6
  23. package/dist/workflows/dag/init-hybrid.js +344 -64
  24. package/dist/workflows/dag/types.js +62 -1
  25. package/docs/templates/agent-dag.schema.json +15 -5
  26. package/docs/templates/backend-test-dag.json +1 -1
  27. package/docs/templates/frontend-implementation-contract.schema.json +4 -3
  28. package/docs/templates/frontend-test-case-checklist.md +6 -2
  29. package/docs/templates/frontend-test-dag.json +2 -2
  30. package/package.json +1 -1
  31. package/skills/frontend-design-review/SKILL.md +12 -10
  32. package/skills/frontend-design-review/references/review-checklist.md +4 -4
  33. package/skills/frontend-implementation/SKILL.md +2 -2
  34. package/skills/frontend-implementation/references/code-standards.md +4 -3
  35. package/skills/frontend-implementation/references/design-spec.md +19 -14
  36. package/skills/frontend-implementation/references/node-contracts.md +2 -2
  37. package/skills/frontend-review/SKILL.md +15 -28
  38. package/skills/frontend-review/references/review-findings.md +16 -18
  39. package/skills/frontend-verification/SKILL.md +16 -13
  40. package/skills/frontend-verification/references/verification-checklist.md +18 -30
@@ -34,15 +34,17 @@ export function isFrontendRepairable(failureClass) {
34
34
  }
35
35
  export function classifyFrontendFailure(input) {
36
36
  const blob = `${input.nodeId}\n${input.failureCategory ?? ""}\n${input.stdout ?? ""}\n${input.stderr ?? ""}`.toLowerCase();
37
+ const failureDiagnosticBlob = `${input.nodeId}\n${input.failureCategory ?? ""}\n${input.stderr ?? ""}`.toLowerCase();
37
38
  if (input.nodeId.includes("contract") ||
38
39
  blob.includes("contract mismatch") ||
39
40
  blob.includes("source binding")) {
40
41
  return "contract";
41
42
  }
42
- if (blob.includes("forbidden") ||
43
- blob.includes("write guard") ||
44
- blob.includes("write-set") ||
45
- blob.includes("writeset")) {
43
+ if (input.failureCategory === "write-guard" ||
44
+ failureDiagnosticBlob.includes("forbidden") ||
45
+ failureDiagnosticBlob.includes("write guard") ||
46
+ failureDiagnosticBlob.includes("write-set") ||
47
+ failureDiagnosticBlob.includes("writeset")) {
46
48
  return "path";
47
49
  }
48
50
  if (blob.includes("package.json") ||
@@ -2,6 +2,9 @@ import { readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { writeDagRunJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
4
4
  import { formatFrontendWorktreeDiffStdout, runFrontendWorktreeDiffGate, } from "./frontend-worktree-diff.js";
5
+ import { frontendLintAssessmentArtifactSchema } from "./frontend-lint-baseline.js";
6
+ import { frontendImplementationContractSchema } from "./frontend-implementation-contract.js";
7
+ import { frontendRepairAssessmentSchema } from "./frontend-repair.js";
5
8
  export const FRONTEND_REVIEW_CONTEXT_SCHEMA_ID = "frontend-review-context-v1";
6
9
  async function readRequiredJson(runDir, relativePath) {
7
10
  try {
@@ -11,17 +14,81 @@ async function readRequiredJson(runDir, relativePath) {
11
14
  throw new Error(`frontend review context missing or invalid ${relativePath}`);
12
15
  }
13
16
  }
17
+ async function readOptionalLintAssessment(runDir) {
18
+ const relativePath = "contracts/frontend-lint-assessment.json";
19
+ try {
20
+ const decoded = JSON.parse(await readFile(path.join(runDir, relativePath), "utf8"));
21
+ return frontendLintAssessmentArtifactSchema.parse(decoded);
22
+ }
23
+ catch (error) {
24
+ if (error &&
25
+ typeof error === "object" &&
26
+ "code" in error &&
27
+ error.code === "ENOENT") {
28
+ return undefined;
29
+ }
30
+ throw new Error(`frontend review context invalid ${relativePath}`);
31
+ }
32
+ }
33
+ function assertReviewEvidence(input) {
34
+ const contract = frontendImplementationContractSchema.safeParse(input.contract);
35
+ if (!contract.success)
36
+ throw new Error("frontend review context invalid implementation contract");
37
+ const trace = input.verificationTrace;
38
+ const expectedTargets = new Map(contract.data.verificationTargets.map((target) => [target.id, target]));
39
+ const actualTargets = Array.isArray(trace?.targets) ? trace.targets : [];
40
+ const mockEvidenceBound = contract.data.mockApi.strategy === "not-needed" ||
41
+ (typeof trace?.mockCommandNodeId === "string" && trace.mockCommandNodeId.length > 0 &&
42
+ Array.isArray(trace?.mockCommandLabels) && trace.mockCommandLabels.length > 0 &&
43
+ Array.isArray(trace?.mockCommandTexts) && trace.mockCommandTexts.length === trace.mockCommandLabels.length &&
44
+ trace.mockCommandTexts.every((command) => typeof command === "string" && command.length > 0));
45
+ const actualIds = new Set();
46
+ const targetsMatch = actualTargets.every((rawTarget) => {
47
+ if (!rawTarget || typeof rawTarget !== "object")
48
+ return false;
49
+ const target = rawTarget;
50
+ const id = typeof target.id === "string" ? target.id : "";
51
+ const expected = expectedTargets.get(id);
52
+ if (!expected || actualIds.has(id))
53
+ return false;
54
+ actualIds.add(id);
55
+ return target.commandLabel === expected.commandLabel &&
56
+ target.file === expected.file &&
57
+ target.symbol === expected.symbol &&
58
+ Array.isArray(target.matchedNodeIds) && target.matchedNodeIds.length > 0;
59
+ });
60
+ if (!trace ||
61
+ trace.schemaId !== "frontend-verification-trace-v1" ||
62
+ trace.contractSchemaId !== "frontend-implementation-contract-v1" ||
63
+ trace.browserStatus !== "not-run" ||
64
+ trace.visualStatus !== "not-run" ||
65
+ !Array.isArray(trace.targets) ||
66
+ trace.targets.length === 0 ||
67
+ trace.targets.some((target) => target?.status !== "ok") ||
68
+ !targetsMatch ||
69
+ actualIds.size !== expectedTargets.size ||
70
+ !mockEvidenceBound ||
71
+ trace.mockStrategy !== contract.data.mockApi.strategy) {
72
+ throw new Error("frontend review context verification trace is not bound to a passing contract");
73
+ }
74
+ const assessment = frontendRepairAssessmentSchema.safeParse(input.repairAssessment);
75
+ if (!assessment.success)
76
+ throw new Error("frontend review context invalid repair assessment");
77
+ }
14
78
  export async function runFrontendReviewContextGate(input) {
15
79
  const diff = await runFrontendWorktreeDiffGate(input);
16
80
  const contract = await readRequiredJson(input.runDir, "contracts/frontend-implementation-contract.json");
17
81
  const verificationTrace = await readRequiredJson(input.runDir, "contracts/frontend-verification-trace.json");
18
82
  const repairAssessment = await readRequiredJson(input.runDir, "contracts/frontend-repair-assessment.json");
83
+ const lintAssessment = await readOptionalLintAssessment(input.runDir);
84
+ assertReviewEvidence({ contract, verificationTrace, repairAssessment });
19
85
  const payload = {
20
86
  schemaVersion: 1,
21
87
  schemaId: FRONTEND_REVIEW_CONTEXT_SCHEMA_ID,
22
88
  contract,
23
89
  verificationTrace,
24
90
  repairAssessment,
91
+ ...(lintAssessment ? { lintAssessment } : {}),
25
92
  diff: {
26
93
  schemaId: diff.schemaId,
27
94
  patchPath: diff.patchPath,
@@ -0,0 +1,105 @@
1
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ const CASE_ID = /^FE-[A-Za-z0-9]+-\d{3}-(?:core|boundary|flow|backend)$/i;
4
+ const AC_ID = /^AC(?:-[A-Z0-9]+)+$/i;
5
+ const PLACEHOLDER = /\b(?:TODO|TBD|FIXME)\b|结果正确|页面正常|按实际情况处理|验证成功/i;
6
+ const SECTION_ALIASES = {
7
+ purpose: ["Test Purpose", "测试目的", "测试场景"],
8
+ source: ["Source References", "需求依据"],
9
+ preconditions: ["Preconditions", "前置条件"],
10
+ steps: ["Steps", "操作步骤"],
11
+ expected: ["Expected Results", "预期结果"],
12
+ automation: ["Automation Notes", "自动化映射", "自动化说明"],
13
+ };
14
+ function section(body, names) {
15
+ const marker = new RegExp(`^###\\s+(?:${names.map((v) => v.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")).join("|")})\\s*$`, "mi");
16
+ const hit = marker.exec(body);
17
+ if (!hit)
18
+ return "";
19
+ const rest = body.slice(hit.index + hit[0].length);
20
+ const next = /^###\s+/m.exec(rest);
21
+ return rest.slice(0, next?.index ?? rest.length).trim();
22
+ }
23
+ function hasList(value) { return /^\s*(?:\d+[.)]|[-*+])\s+\S+/m.test(value); }
24
+ function hasAssertion(value) { return /(?:status|状态|包含|显示|等于|为|可见|不可见|跳转|错误|成功|失败|should|expect|assert|must)/i.test(value); }
25
+ export async function validateFrontendCaseContent(input) {
26
+ const root = path.join(input.workspaceRoot, "testcase/frontend/cases");
27
+ const manifestPath = path.join(root, "manifest.json");
28
+ const findings = [];
29
+ let manifest;
30
+ try {
31
+ manifest = JSON.parse(await readFile(manifestPath, "utf8"));
32
+ }
33
+ catch {
34
+ findings.push({ ruleId: "manifest-unavailable", detail: "manifest.json is missing or invalid" });
35
+ return render(findings);
36
+ }
37
+ if (!Array.isArray(manifest.cases) || manifest.cases.length === 0) {
38
+ findings.push({ ruleId: "empty-cases", detail: "cases must be a non-empty array" });
39
+ return render(findings);
40
+ }
41
+ const seen = new Set();
42
+ const covered = new Set();
43
+ for (const item of manifest.cases) {
44
+ const id = typeof item?.caseId === "string" ? item.caseId : "?";
45
+ if (seen.has(id))
46
+ findings.push({ ruleId: "duplicate-case-id", caseId: id, detail: "caseId is duplicated" });
47
+ seen.add(id);
48
+ if (!CASE_ID.test(id))
49
+ findings.push({ ruleId: "case-id-shape", caseId: id, detail: "caseId must match FE-<FEATURE>-<NNN>-<dimension>" });
50
+ const acIds = Array.isArray(item?.acIds) ? item.acIds : [];
51
+ for (const ac of acIds) {
52
+ if (typeof ac !== "string" || !AC_ID.test(ac))
53
+ findings.push({ ruleId: "ac-id-shape", caseId: id, detail: `invalid AC id: ${String(ac)}` });
54
+ else {
55
+ covered.add(ac);
56
+ if (input.requiredAcIds?.length && !input.requiredAcIds.includes(ac))
57
+ findings.push({ ruleId: "unknown-ac", caseId: id, detail: `${ac} is not declared by the task` });
58
+ }
59
+ }
60
+ const file = path.join(input.workspaceRoot, item?.casePath || `testcase/frontend/cases/${id}.md`);
61
+ let body = "";
62
+ try {
63
+ body = await readFile(file, "utf8");
64
+ }
65
+ catch {
66
+ findings.push({ ruleId: "case-file-missing", caseId: id, detail: `missing case file: ${item?.casePath || file}` });
67
+ continue;
68
+ }
69
+ if (PLACEHOLDER.test(body))
70
+ findings.push({ ruleId: "placeholder-wording", caseId: id, detail: "case contains placeholder or non-assertable wording" });
71
+ for (const [key, names] of Object.entries(SECTION_ALIASES))
72
+ if (!section(body, names))
73
+ findings.push({ ruleId: `missing-${key}`, caseId: id, detail: `missing section: ${names.join(" or ")}` });
74
+ const steps = section(body, SECTION_ALIASES.steps), expected = section(body, SECTION_ALIASES.expected);
75
+ if (!hasList(steps))
76
+ findings.push({ ruleId: "unstructured-steps", caseId: id, detail: "steps should contain numbered or bulleted executable actions" });
77
+ if (!hasList(expected) || !hasAssertion(expected))
78
+ findings.push({ ruleId: "unassertable-expected", caseId: id, detail: "expected results should contain structured, assertable outcomes" });
79
+ for (const ac of acIds)
80
+ if (typeof ac === "string" && !body.includes(ac))
81
+ findings.push({ ruleId: "ac-not-in-case", caseId: id, detail: `${ac} is not explicitly referenced by the case body` });
82
+ if (acIds.length && !acIds.some((ac) => body.includes(ac) && (steps.includes(ac) || expected.includes(ac))))
83
+ findings.push({ ruleId: "ac-not-linked-to-verification", caseId: id, detail: "AC is not linked to steps or expected results" });
84
+ }
85
+ for (const ac of input.requiredAcIds ?? [])
86
+ if (!covered.has(ac))
87
+ findings.push({ ruleId: "missing-ac-coverage", detail: `${ac} is not covered by any frontend case` });
88
+ return render(findings);
89
+ }
90
+ function render(findings) {
91
+ const status = findings.length ? "FAIL" : "PASS";
92
+ const lines = ["# Frontend Test Case Quality Advisory", "", "## Status", "", status, "", `- Findings: ${findings.length}`, "- Enforcement: advisory; findings do not block execution", "", "## Findings", "", ...(findings.length ? findings.map((f) => `- [${f.ruleId}]${f.caseId ? ` ${f.caseId}` : ""}: ${f.detail}`) : ["- None"]), ""];
93
+ const markdown = lines.join("\n");
94
+ const esc = (v) => v.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
95
+ const html = `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>前端测试用例质量建议报告</title><style>body{font:16px system-ui,"Microsoft YaHei","PingFang SC",sans-serif;margin:0;padding:2rem;color:#172033;background:#f6f8fb}main{max-width:1100px;margin:auto;background:white;padding:2rem;border-radius:16px;box-shadow:0 8px 28px rgba(23,32,51,.08)}h1{color:#17365d;margin-top:0}.badge{display:inline-block;padding:.35rem .7rem;border-radius:999px;background:${findings.length ? "#fff3cd" : "#ecfdf3"};color:${findings.length ? "#946200" : "#067647"}}li{margin:.7rem 0}code{color:#667085}p{line-height:1.7}</style></head><body><main><h1>前端测试用例质量建议报告</h1><p><span class="badge">${findings.length ? "发现问题" : "检查通过"}</span> <strong>仅供改进</strong>:以下问题属于建议性检查结果,不会阻塞后续测试执行。</p><p>问题数量:${findings.length}</p><h2>建议项明细</h2><ul>${findings.length ? findings.map((f) => `<li><code>${esc(f.ruleId)}</code> ${f.caseId ? `<strong>${esc(f.caseId)}</strong> ` : ""}${esc(f.detail)}</li>`).join("") : "<li>未发现问题。</li>"}</ul></main></body></html>`;
96
+ return { findings, markdown, html };
97
+ }
98
+ export async function writeFrontendCaseQualityReports(input) {
99
+ const result = await validateFrontendCaseContent(input);
100
+ const dir = path.join(input.workspaceRoot, input.outputDir ?? "testcase/frontend/reports");
101
+ await mkdir(dir, { recursive: true });
102
+ await writeFile(path.join(dir, "frontend-test-case-quality-advisory.md"), result.markdown, "utf8");
103
+ await writeFile(path.join(dir, "frontend-test-case-quality-advisory.html"), result.html, "utf8");
104
+ return result;
105
+ }
@@ -3,6 +3,7 @@ import { lstat, readFile, realpath } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { z } from "zod";
5
5
  import { writeDagRunJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
6
+ import { validateFrontendCaseContent } from "./frontend-test-case-quality.js";
6
7
  export const FRONTEND_TEST_RESULT_SCHEMA_ID = "frontend-test-result-v1";
7
8
  const safeRelativePathSchema = z.string().min(1).refine((value) => !path.posix.isAbsolute(value) &&
8
9
  !path.win32.isAbsolute(value) &&
@@ -33,6 +34,11 @@ export const frontendTestResultContractSchema = z.object({
33
34
  evidence: z.array(z.object({ path: safeRelativePathSchema, sha256: sha256Schema }).strict()),
34
35
  blockedReason: z.string().min(1).optional(),
35
36
  }).strict()).min(1),
37
+ advisoryFindings: z.array(z.object({
38
+ ruleId: z.string().min(1),
39
+ caseId: z.string().min(1).optional(),
40
+ detail: z.string().min(1),
41
+ }).strict()),
36
42
  totals: z.object({
37
43
  cases: z.number().int().positive(),
38
44
  passed: z.number().int().min(0),
@@ -79,24 +85,24 @@ async function readEvidenceFile(repoRoot, evidenceRoot, relative) {
79
85
  const repoRootResolved = await realpath(repoRoot);
80
86
  const evidenceRootResolved = await realpath(evidenceRoot);
81
87
  assertInside(repoRootResolved, evidenceRootResolved, "evidenceDir");
82
- // Accept either evidence-root-relative paths or repo-relative paths under the case evidence root.
83
- const normalized = relative.replaceAll("\\", "/");
88
+ // Paths rooted at testcase/ are repository-relative; all other safe paths are case-local.
89
+ const normalized = relative;
84
90
  const evidenceRootRel = path.relative(repoRootResolved, evidenceRootResolved).replaceAll("\\", "/");
85
- let candidate = normalized;
86
- if (normalized === evidenceRootRel || normalized.startsWith(`${evidenceRootRel}/`)) {
87
- candidate = path.relative(evidenceRootResolved, path.resolve(repoRootResolved, normalized)).replaceAll("\\", "/");
88
- }
89
- else if (normalized.startsWith("testcase/frontend/evidence/")) {
90
- candidate = path.relative(evidenceRootResolved, path.resolve(repoRootResolved, normalized)).replaceAll("\\", "/");
91
- }
92
- if (!candidate || candidate.startsWith("..") || path.isAbsolute(candidate)) {
91
+ const isCurrentCaseRepoPath = normalized === evidenceRootRel || normalized.startsWith(`${evidenceRootRel}/`);
92
+ const isRepoRelative = normalized.startsWith("testcase/") && !isCurrentCaseRepoPath;
93
+ const absolute = isRepoRelative
94
+ ? path.resolve(repoRootResolved, normalized)
95
+ : path.resolve(evidenceRootResolved, normalized);
96
+ assertInside(repoRootResolved, absolute, "evidence path");
97
+ if (isRepoRelative && normalized.startsWith("testcase/frontend/evidence/")) {
93
98
  throw new Error(`evidence path escapes evidence root: ${relative}`);
94
99
  }
95
- const absolute = path.resolve(evidenceRootResolved, candidate);
96
- assertInside(evidenceRootResolved, absolute, "evidence path");
100
+ if (!isRepoRelative)
101
+ assertInside(evidenceRootResolved, absolute, "evidence path");
97
102
  const resolved = await realpath(absolute);
98
103
  assertInside(repoRootResolved, resolved, "evidence realpath");
99
- assertInside(evidenceRootResolved, resolved, "evidence realpath under case root");
104
+ if (!isRepoRelative)
105
+ assertInside(evidenceRootResolved, resolved, "evidence realpath under case root");
100
106
  const info = await lstat(resolved);
101
107
  if (!info.isFile())
102
108
  throw new Error(`evidence is not a file: ${relative}`);
@@ -117,13 +123,20 @@ export async function materializeFrontendTestResult(input) {
117
123
  if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.cases) || manifest.cases.length === 0) {
118
124
  throw new Error("invalid or empty frontend case manifest");
119
125
  }
126
+ const advisoryFindings = [];
127
+ const contentQuality = await validateFrontendCaseContent({
128
+ workspaceRoot: input.workspaceRoot,
129
+ requiredAcIds: sourceBinding.requirementIds ?? [],
130
+ });
131
+ advisoryFindings.push(...contentQuality.findings);
120
132
  const requiredIds = new Set(sourceBinding.requirementIds ?? []);
121
133
  const covered = new Set();
122
134
  const cases = [];
123
135
  for (const raw of manifest.cases) {
124
136
  const item = raw;
125
137
  if (!item || typeof item.caseId !== "string" || !Array.isArray(item.acIds) || typeof item.evidenceDir !== "string") {
126
- throw new Error("invalid frontend manifest case");
138
+ advisoryFindings.push({ ruleId: "invalid-manifest-case", detail: "manifest case is missing caseId, acIds, or evidenceDir" });
139
+ continue;
127
140
  }
128
141
  const evidencePrefix = `testcase/frontend/evidence/${item.caseId}`;
129
142
  if (!(item.evidenceDir === evidencePrefix || item.evidenceDir.startsWith(`${evidencePrefix}/`))) {
@@ -131,28 +144,43 @@ export async function materializeFrontendTestResult(input) {
131
144
  }
132
145
  for (const acId of item.acIds) {
133
146
  if (requiredIds.size > 0 && !requiredIds.has(acId))
134
- throw new Error(`unknown AC in manifest: ${acId}`);
147
+ advisoryFindings.push({ ruleId: "unknown-ac", caseId: item.caseId, detail: `unknown AC: ${acId}` });
135
148
  covered.add(acId);
136
149
  }
137
150
  const workspaceRootResolved = await realpath(input.workspaceRoot);
138
151
  const evidenceRoot = path.resolve(workspaceRootResolved, item.evidenceDir);
139
152
  assertInside(workspaceRootResolved, evidenceRoot, "evidenceDir");
140
- const resultRaw = JSON.parse(await readFile(path.join(evidenceRoot, "case-result.json"), "utf-8"));
141
- const status = caseStatusSchema.parse(resultRaw.status);
142
- if (resultRaw.caseId !== item.caseId || !Array.isArray(resultRaw.evidencePaths))
143
- throw new Error(`invalid result identity for ${item.caseId}`);
144
- if (status === "blocked" && (typeof resultRaw.blockedReason !== "string" || !resultRaw.blockedReason.trim()))
145
- throw new Error(`blocked result requires reason for ${item.caseId}`);
146
- const evidence = [await readEvidenceFile(input.workspaceRoot, evidenceRoot, "execution.md")];
147
- for (const evidencePath of resultRaw.evidencePaths) {
148
- if (typeof evidencePath !== "string" || evidencePath === "execution.md")
149
- continue;
150
- evidence.push(await readEvidenceFile(input.workspaceRoot, evidenceRoot, evidencePath));
153
+ let resultRaw = {};
154
+ try {
155
+ resultRaw = JSON.parse(await readFile(path.join(evidenceRoot, "case-result.json"), "utf-8"));
156
+ }
157
+ catch {
158
+ advisoryFindings.push({ ruleId: "missing-or-invalid-case-result", caseId: item.caseId, detail: "case-result.json is missing or invalid" });
151
159
  }
152
- if (status === "passed" && !evidence.some((entry) => /\.(png|jpg|jpeg|webp|zip|har|webm|mp4|ya?ml|md)$/i.test(entry.path))) {
153
- throw new Error(`passed case requires browser evidence: ${item.caseId}`);
160
+ const parsedStatus = caseStatusSchema.safeParse(resultRaw.status);
161
+ const status = parsedStatus.success ? parsedStatus.data : "blocked";
162
+ if (resultRaw.caseId !== undefined && resultRaw.caseId !== item.caseId)
163
+ advisoryFindings.push({ ruleId: "case-result-identity", caseId: item.caseId, detail: "case-result caseId does not match manifest" });
164
+ if (status === "blocked" && resultRaw.blockedReason !== undefined && (typeof resultRaw.blockedReason !== "string" || !resultRaw.blockedReason.trim()))
165
+ advisoryFindings.push({ ruleId: "blocked-reason", caseId: item.caseId, detail: "blocked result has no usable reason" });
166
+ const evidence = [];
167
+ const evidencePaths = Array.isArray(resultRaw.evidencePaths) ? resultRaw.evidencePaths : [];
168
+ for (const evidencePath of ["execution.md", ...evidencePaths]) {
169
+ if (typeof evidencePath !== "string" || evidencePath === "execution.md" && evidence.some((entry) => entry.path.endsWith("/execution.md")))
170
+ continue;
171
+ try {
172
+ evidence.push(await readEvidenceFile(input.workspaceRoot, evidenceRoot, evidencePath));
173
+ }
174
+ catch (error) {
175
+ const message = error instanceof Error ? error.message : String(error);
176
+ if (/unsafe evidence path|escapes (?:evidence root|its allowed root)|evidence realpath/.test(message))
177
+ throw error;
178
+ advisoryFindings.push({ ruleId: "missing-evidence", caseId: item.caseId, detail: `evidence is unavailable: ${evidencePath}` });
179
+ }
154
180
  }
155
- cases.push({ caseId: item.caseId, acIds: item.acIds, status, evidence, ...(status === "blocked" ? { blockedReason: resultRaw.blockedReason } : {}) });
181
+ if (status === "passed" && !evidence.some((entry) => /\.(png|jpg|jpeg|webp|zip|har|webm|mp4|ya?ml|md)$/i.test(entry.path)))
182
+ advisoryFindings.push({ ruleId: "passed-without-browser-evidence", caseId: item.caseId, detail: "passed case has no browser evidence" });
183
+ cases.push({ caseId: item.caseId, acIds: item.acIds, status, evidence, ...(status === "blocked" && typeof resultRaw.blockedReason === "string" && resultRaw.blockedReason.trim() ? { blockedReason: resultRaw.blockedReason } : {}) });
156
184
  }
157
185
  const missing = [...requiredIds].filter((id) => !covered.has(id));
158
186
  const totals = {
@@ -167,6 +195,7 @@ export async function materializeFrontendTestResult(input) {
167
195
  sourceBinding,
168
196
  manifest: { path: manifestPath, sha256: sha256(await readFile(manifestAbsolute)) },
169
197
  cases,
198
+ advisoryFindings,
170
199
  totals,
171
200
  acceptanceCoverage: { covered: [...covered].sort(), missing: missing.sort() },
172
201
  integrationMode: outcome === "passed" ? "real" : "none",
@@ -186,10 +215,9 @@ export function buildFrontendTestOutcomeGateShellSnippet(options) {
186
215
  ].join("; ");
187
216
  }
188
217
  /**
189
- * Shared frontend-test evidence gate for map children + node 7.
190
- * - Hard fail only on manifest/path integrity (unsafe evidenceDir / escape).
191
- * - Missing or malformed case-result/execution is healed to status=blocked
192
- * with blockedReason=invalid-evidence-shape so result materialize + retrospect can run.
218
+ * Shared frontend-test evidence advisory check for map children + node 7.
219
+ * Hard-fails only for unsafe evidence directories or evidence paths. Missing,
220
+ * malformed, or empty evidence is reported without mutating case outputs.
193
221
  */
194
222
  export function buildFrontendCaseEvidenceValidateShellSnippet() {
195
223
  const body = [
@@ -201,16 +229,7 @@ export function buildFrontendCaseEvidenceValidateShellSnippet() {
201
229
  "const statuses=new Set(['passed','failed','blocked']);",
202
230
  "const issues=[];",
203
231
  "let hardFail=false;",
204
- "let evidenceContentCount=0;",
205
- "function hasEvidenceContent(dir){if(!fs.existsSync(dir))return false;const pending=[dir];while(pending.length){const current=pending.pop();for(const entry of fs.readdirSync(current,{withFileTypes:true})){const candidate=path.join(current,entry.name);if(entry.isDirectory()){pending.push(candidate);}else if(entry.isFile()&&fs.statSync(candidate).size>0){return true;}}}return false;}",
206
- "function writeBlocked(dir,caseId,reason,detail){",
207
- " fs.mkdirSync(dir,{recursive:true});",
208
- " const execution=path.join(dir,'execution.md');",
209
- " const note='# '+caseId+'\\n\\nStatus: blocked\\n\\nReason: '+reason+(detail?('\\n\\nDetail: '+detail):'')+'\\n';",
210
- " fs.writeFileSync(execution,note);",
211
- " fs.writeFileSync(path.join(dir,'case-result.json'),JSON.stringify({caseId:caseId,status:'blocked',blockedReason:reason,evidencePaths:['execution.md']},null,2)+'\\n');",
212
- "}",
213
- "function isSafeRel(p){return typeof p==='string'&&p.length>0&&!path.isAbsolute(p)&&!p.includes('..');}",
232
+ "function isSafeRel(p){return typeof p==='string'&&p.length>0&&!path.isAbsolute(p)&&!path.win32.isAbsolute(p)&&!p.includes('..');}",
214
233
  "for(const c of manifest.cases){",
215
234
  " const id=c&&typeof c.caseId==='string'?c.caseId:'?';",
216
235
  " const dir=c&&c.evidenceDir;",
@@ -220,32 +239,18 @@ export function buildFrontendCaseEvidenceValidateShellSnippet() {
220
239
  " }",
221
240
  " const execution=path.join(dir,'execution.md');",
222
241
  " const resultPath=path.join(dir,'case-result.json');",
223
- " if(hasEvidenceContent(dir))evidenceContentCount++;",
224
- " let healReason=null; let healDetail=null;",
225
- " if(!fs.existsSync(execution)){healReason='invalid-evidence-shape';healDetail='missing execution.md';}",
226
- " if(!fs.existsSync(resultPath)){healReason='invalid-evidence-shape';healDetail=(healDetail?healDetail+'; ':'')+'missing case-result.json';}",
242
+ " if(!fs.existsSync(execution)||!fs.statSync(execution).isFile()||fs.statSync(execution).size===0)issues.push({ruleId:'missing-execution',caseId:id,detail:'execution.md is missing or empty'});",
227
243
  " let result=null;",
228
- " if(!healReason){",
229
- " try{result=JSON.parse(fs.readFileSync(resultPath,'utf8'));}catch(e){healReason='invalid-evidence-shape';healDetail='invalid JSON case-result.json';}",
230
- " }",
231
- " if(!healReason){",
232
- " if(!result||result.caseId!==id||!statuses.has(result.status)||!Array.isArray(result.evidencePaths)||result.evidencePaths.some(p=>!isSafeRel(p))){",
233
- " healReason='invalid-evidence-shape';healDetail='caseId/status/evidencePaths contract';",
234
- " } else if(result.status==='passed'&&(result.evidencePaths.length<1||!result.evidencePaths.some(p=>/\\.(png|jpg|jpeg|webp|zip|har|webm|mp4|md)$/i.test(p)))){",
235
- " healReason='invalid-evidence-shape';healDetail='passed requires browser evidence path';",
236
- " } else if(result.status==='blocked'&&(typeof result.blockedReason!=='string'||!result.blockedReason.trim())){",
237
- " healReason='invalid-evidence-shape';healDetail='blocked requires blockedReason';",
238
- " }",
239
- " }",
240
- " if(healReason){",
241
- " writeBlocked(dir,id,healReason,healDetail);",
242
- " issues.push({ruleId:healReason,caseId:id,detail:healDetail,healed:true});",
243
- " }",
244
+ " if(!fs.existsSync(resultPath)){issues.push({ruleId:'missing-case-result',caseId:id,detail:'case-result.json is missing'});continue;}",
245
+ " try{result=JSON.parse(fs.readFileSync(resultPath,'utf8'));}catch(e){issues.push({ruleId:'invalid-case-result',caseId:id,detail:'case-result.json is malformed'});continue;}",
246
+ " if(!result||result.caseId!==id)issues.push({ruleId:'case-result-identity',caseId:id,detail:'case-result caseId does not match manifest'});",
247
+ " if(!statuses.has(result&&result.status))issues.push({ruleId:'invalid-case-status',caseId:id,detail:'status must be passed, failed, or blocked'});",
248
+ " if(!Array.isArray(result&&result.evidencePaths)){issues.push({ruleId:'invalid-evidence-paths',caseId:id,detail:'evidencePaths must be an array'});}else{for(const p of result.evidencePaths){if(!isSafeRel(p)){hardFail=true;issues.push({ruleId:'unsafe-evidence-path',caseId:id,detail:String(p)});continue;}const currentPrefix=prefix+'/';if(p.startsWith('testcase/frontend/evidence/')&&!p.startsWith(currentPrefix)){hardFail=true;issues.push({ruleId:'cross-case-evidence-path',caseId:id,detail:p});continue;}const target=p.startsWith('testcase/')?p:path.join(dir,p);if(!fs.existsSync(target)||!fs.statSync(target).isFile()||fs.statSync(target).size===0)issues.push({ruleId:'missing-evidence',caseId:id,detail:p+' is missing or empty'});}}",
249
+ " if(result&&result.status==='passed'&&(!Array.isArray(result.evidencePaths)||!result.evidencePaths.some(p=>/\\.(png|jpg|jpeg|webp|zip|har|webm|mp4)$/i.test(p))))issues.push({ruleId:'passed-without-browser-evidence',caseId:id,detail:'passed case has no screenshot, HAR, video, or equivalent browser artifact'});",
250
+ " if(result&&result.status==='blocked'&&(typeof result.blockedReason!=='string'||!result.blockedReason.trim()))issues.push({ruleId:'blocked-reason',caseId:id,detail:'blocked result has no usable reason'});",
244
251
  "}",
245
252
  "if(hardFail){console.error('frontend-test evidence hard-fail: '+JSON.stringify(issues)); process.exit(1);}",
246
- "if(evidenceContentCount===0){console.error('frontend-test evidence hard-fail: no frontend case evidence content'); process.exit(1);}",
247
- "const healed=issues.filter(i=>i.healed).length;",
248
- "console.log('frontend case evidence validation ok cases='+manifest.cases.length+' healed='+healed+(issues.length?(' issues='+JSON.stringify(issues)):''));",
253
+ "console.log('frontend case evidence advisory validation ok cases='+manifest.cases.length+' findings='+issues.length+(issues.length?(' issues='+JSON.stringify(issues)):''));",
249
254
  ].join("");
250
255
  return ["node -e", JSON.stringify(body)].join(" ");
251
256
  }
@@ -26,6 +26,15 @@ function parseMockStrategyFromAssess(text) {
26
26
  const match = text.match(/^\s*MOCK_STRATEGY:\s*([a-z0-9-]+)\s*$/im);
27
27
  return match?.[1] ?? null;
28
28
  }
29
+ function symbolEvidenceCandidates(symbol) {
30
+ const normalized = symbol.trim().toLowerCase();
31
+ if (normalized === "all describe blocks")
32
+ return ["describe("];
33
+ const describeTitle = symbol.match(/^describe\((?:['"])(.+?)(?:['"])/i)?.[1];
34
+ if (describeTitle)
35
+ return [symbol, describeTitle, "describe("];
36
+ return [symbol];
37
+ }
29
38
  async function assertFileAndSymbol(input) {
30
39
  const issues = [];
31
40
  const absolute = path.resolve(input.workspaceRoot, input.file);
@@ -49,7 +58,8 @@ async function assertFileAndSymbol(input) {
49
58
  }
50
59
  if (input.symbol) {
51
60
  const content = await readFile(absolute, "utf8");
52
- if (!content.includes(input.symbol)) {
61
+ const matched = symbolEvidenceCandidates(input.symbol).some((candidate) => content.includes(candidate));
62
+ if (!matched) {
53
63
  issues.push(`symbol not found: ${input.symbol} in ${input.file}`);
54
64
  }
55
65
  }
@@ -115,6 +125,20 @@ export async function runFrontendVerificationTraceGate(input) {
115
125
  : await loadFirstNode(behaviorCandidates);
116
126
  assertNodeFinished(staticNode.nodeId, staticNode.record);
117
127
  assertNodeFinished(behaviorNode.nodeId, behaviorNode.record);
128
+ const mockCommandLabels = input.evidence?.mock?.commandLabels ?? [];
129
+ const mockCommandNodeId = input.evidence?.mock?.nodeId;
130
+ const mockCommandTexts = input.evidence?.mock?.commandTexts ?? [];
131
+ if (input.evidence && contract.mockApi.strategy !== "not-needed" && mockCommandLabels.length === 0) {
132
+ throw new Error("trace: selected Mock strategy requires successful Mock verification commands");
133
+ }
134
+ if (input.evidence) {
135
+ if (input.evidence.static.commandLabels.length === 0) {
136
+ throw new Error("trace: no successful static verification commands in current run");
137
+ }
138
+ if (input.evidence.behavior.commandLabels.length === 0) {
139
+ throw new Error("trace: no successful behavior verification commands in current run");
140
+ }
141
+ }
118
142
  const labelOwners = collectCommandLabels({ nodeId: staticNode.nodeId, record: staticNode.record }, { nodeId: behaviorNode.nodeId, record: behaviorNode.record });
119
143
  // Legacy runs may still carry a standalone Mock assessment. New slim runs
120
144
  // bind the strategy directly through the validated implementation contract.
@@ -173,6 +197,9 @@ export async function runFrontendVerificationTraceGate(input) {
173
197
  visualStatus: "not-run",
174
198
  targets,
175
199
  mockStrategy: contract.mockApi.strategy,
200
+ mockCommandLabels,
201
+ mockCommandNodeId,
202
+ mockCommandTexts,
176
203
  };
177
204
  if (input.writeArtifact !== false) {
178
205
  const artifactRel = "contracts/frontend-verification-trace.json";
@@ -185,6 +212,9 @@ export async function runFrontendVerificationTraceGate(input) {
185
212
  browserStatus: result.browserStatus,
186
213
  visualStatus: result.visualStatus,
187
214
  mockStrategy: result.mockStrategy,
215
+ mockCommandLabels: result.mockCommandLabels,
216
+ mockCommandNodeId: result.mockCommandNodeId,
217
+ mockCommandTexts: result.mockCommandTexts,
188
218
  targets: result.targets,
189
219
  }, null, 2)}\n`, "utf8");
190
220
  result.artifactPath = artifactPath;
@@ -1,9 +1,10 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { spawn } from "node:child_process";
3
- import { mkdir, writeFile } from "node:fs/promises";
3
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { writeDagRunJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
6
6
  export const FRONTEND_WORKTREE_DIFF_SCHEMA_ID = "frontend-worktree-diff-v1";
7
+ export const FRONTEND_WORKTREE_BASELINE_SCHEMA_ID = "frontend-worktree-baseline-v1";
7
8
  function runGit(cwd, args) {
8
9
  return new Promise((resolve, reject) => {
9
10
  const child = spawn("git", args, {
@@ -31,6 +32,45 @@ function splitLines(text) {
31
32
  .map((line) => line.trim())
32
33
  .filter(Boolean);
33
34
  }
35
+ function statusPaths(status) {
36
+ return splitLines(status)
37
+ .map((line) => line.slice(3).split(" -> ").at(-1) ?? "")
38
+ .filter(Boolean)
39
+ .sort();
40
+ }
41
+ async function sha256File(filePath) {
42
+ try {
43
+ const bytes = await readFile(filePath);
44
+ return createHash("sha256").update(bytes).digest("hex");
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ export async function captureFrontendWorktreeBaseline(input) {
51
+ const root = path.resolve(input.workspaceRoot);
52
+ const status = await runGit(root, [
53
+ "status",
54
+ "--porcelain=v1",
55
+ "--untracked-files=all",
56
+ ]);
57
+ if (status.code !== 0) {
58
+ throw new Error(`frontend worktree baseline failed: ${status.stderr.trim() || status.stdout.trim()}`);
59
+ }
60
+ const files = statusPaths(status.stdout);
61
+ const hashes = {};
62
+ for (const file of files)
63
+ hashes[file] = await sha256File(path.join(root, file));
64
+ const relative = "contracts/frontend-worktree-baseline.json";
65
+ await writeDagRunJsonArtifact(input.runDir, relative, {
66
+ schemaVersion: 1,
67
+ schemaId: FRONTEND_WORKTREE_BASELINE_SCHEMA_ID,
68
+ files,
69
+ hashes,
70
+ capturedAt: new Date().toISOString(),
71
+ });
72
+ return relative;
73
+ }
34
74
  /**
35
75
  * Capture the current workspace worktree as a run-owned review artifact.
36
76
  *
@@ -41,6 +81,22 @@ function splitLines(text) {
41
81
  */
42
82
  export async function runFrontendWorktreeDiffGate(input) {
43
83
  const root = path.resolve(input.workspaceRoot);
84
+ let baseline;
85
+ try {
86
+ const raw = JSON.parse(await readFile(path.join(input.runDir, "contracts/frontend-worktree-baseline.json"), "utf8"));
87
+ if (raw.schemaId !== FRONTEND_WORKTREE_BASELINE_SCHEMA_ID) {
88
+ throw new Error("invalid baseline schema");
89
+ }
90
+ baseline = { files: raw.files ?? [], hashes: raw.hashes ?? {} };
91
+ }
92
+ catch (error) {
93
+ if (input.requireBaseline) {
94
+ throw new Error(`frontend worktree baseline is missing or invalid: ${error instanceof Error ? error.message : String(error)}`);
95
+ }
96
+ }
97
+ if (input.requireBaseline && !baseline) {
98
+ throw new Error("frontend worktree baseline is required for this review context");
99
+ }
44
100
  const revParse = await runGit(root, ["rev-parse", "--is-inside-work-tree"]);
45
101
  if (revParse.code !== 0 || revParse.stdout.trim() !== "true") {
46
102
  throw new Error(`frontend worktree diff gate requires a git worktree: ${revParse.stderr.trim() || revParse.stdout.trim() || "not a git repository"}`);
@@ -67,11 +123,30 @@ export async function runFrontendWorktreeDiffGate(input) {
67
123
  if (untracked.code !== 0) {
68
124
  throw new Error(`frontend worktree diff gate ls-files failed: ${untracked.stderr.trim() || untracked.stdout.trim()}`);
69
125
  }
70
- const changedFiles = splitLines(nameOnly.stdout).sort();
71
- const untrackedFiles = splitLines(untracked.stdout).sort();
72
- const patchBody = diff.stdout.endsWith("\n") || diff.stdout.length === 0
73
- ? diff.stdout
74
- : `${diff.stdout}\n`;
126
+ const changedFilesAll = splitLines(nameOnly.stdout).sort();
127
+ const untrackedFilesAll = splitLines(untracked.stdout).sort();
128
+ if (baseline) {
129
+ for (const file of baseline.files) {
130
+ const currentHash = await sha256File(path.join(root, file));
131
+ if (currentHash !== baseline.hashes[file]) {
132
+ throw new Error(`frontend worktree diff overlaps pre-existing change: ${file}`);
133
+ }
134
+ }
135
+ }
136
+ const baselineFiles = new Set(baseline?.files ?? []);
137
+ const changedFiles = changedFilesAll.filter((file) => !baselineFiles.has(file));
138
+ const untrackedFiles = untrackedFilesAll.filter((file) => !baselineFiles.has(file));
139
+ const patchSource = baseline
140
+ ? changedFiles.length > 0
141
+ ? await runGit(root, ["diff", "--binary", "HEAD", "--", ...changedFiles])
142
+ : { code: 0, stdout: "", stderr: "" }
143
+ : diff;
144
+ if (patchSource.code !== 0) {
145
+ throw new Error(`frontend worktree diff filtering failed: ${patchSource.stderr.trim() || patchSource.stdout.trim()}`);
146
+ }
147
+ const patchBody = patchSource.stdout.endsWith("\n") || patchSource.stdout.length === 0
148
+ ? patchSource.stdout
149
+ : `${patchSource.stdout}\n`;
75
150
  const untrackedSection = untrackedFiles.length === 0
76
151
  ? ""
77
152
  : [