@tea-agent/loop-agent 0.21.0 → 0.23.1
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/AGENTS.md +42 -108
- package/CHANGELOG.md +114 -0
- package/README.md +7 -4
- package/bin/agent-worker.js +0 -0
- package/dist/adapters/loop-agent.js +52 -0
- package/dist/application/context-usage/skill-resolution-stats.js +263 -0
- package/dist/application/dag/generate-task-dag.js +17 -3
- package/dist/cli/command-definitions.js +8 -7
- package/dist/cli/program.js +17 -15
- package/dist/commands/doctor.js +269 -18
- package/dist/commands/init.js +198 -86
- package/dist/commands/stats.js +40 -11
- package/dist/executors/dag-pi-executor.js +2 -0
- package/dist/executors/shell-executor.js +162 -19
- package/dist/shared/openspec-spec.js +49 -0
- package/dist/shared/operator/capabilities.js +11 -1
- package/dist/worker/console/app-data.js +4 -0
- package/dist/worker/console/chat/instruction-skills.js +217 -0
- package/dist/worker/console/chat/model-resolver.js +106 -0
- package/dist/worker/console/chat/pi-runtime.js +605 -0
- package/dist/worker/console/chat/resource-loader.js +66 -0
- package/dist/worker/console/chat/routes.js +357 -0
- package/dist/worker/console/chat/session-store.js +238 -0
- package/dist/worker/console/chat/tool-adapter.js +238 -0
- package/dist/worker/console/chat/tools.js +171 -0
- package/dist/worker/console/server.js +55 -0
- package/dist/worker/console/static/assets/index-DVl7Jxt5.js +25 -0
- package/dist/worker/console/static/assets/index-lVcIr9Ju.css +1 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/feature/profile-schema.js +1 -1
- package/dist/worker/observability/read-model.js +21 -1
- package/dist/worker/observe/spec-evidence.js +12 -15
- package/dist/worker/observe/static/dag-helpers.js +22 -0
- package/dist/worker/observe/static/views/dag.js +5 -0
- package/dist/workflows/dag/backend-test-markdown-workflow.js +37 -0
- package/dist/workflows/dag/frontend-implementation-contract.js +141 -32
- package/dist/workflows/dag/frontend-lint-baseline.js +471 -0
- package/dist/workflows/dag/frontend-prewrite-gate.js +79 -16
- package/dist/workflows/dag/frontend-project-capability.js +11 -8
- package/dist/workflows/dag/frontend-repair.js +6 -4
- package/dist/workflows/dag/frontend-review-context.js +67 -0
- package/dist/workflows/dag/frontend-test-case-quality.js +105 -0
- package/dist/workflows/dag/frontend-test-result-contract.js +71 -66
- package/dist/workflows/dag/frontend-verification-trace.js +31 -1
- package/dist/workflows/dag/frontend-worktree-diff.js +81 -6
- package/dist/workflows/dag/init-hybrid.js +412 -84
- package/dist/workflows/dag/node-execution.js +38 -1
- package/dist/workflows/dag/output-protocol.js +89 -0
- package/dist/workflows/dag/prompt.js +35 -1
- package/dist/workflows/dag/recovery-recommendation.js +45 -0
- package/dist/workflows/dag/report.js +28 -1
- package/dist/workflows/dag/rerun-task.js +1 -1
- package/dist/workflows/dag/scheduler.js +9 -0
- package/dist/workflows/dag/types.js +74 -1
- package/dist/workflows/dag/validate.js +55 -0
- package/docs/README.md +73 -156
- package/docs/architecture/README.md +3 -2
- package/docs/architecture/dag-execution.md +2 -2
- package/docs/architecture/evolution.md +14 -12
- package/docs/architecture/system-overview.md +1 -1
- package/docs/architecture/worker-and-feature.md +3 -3
- package/docs/governance/README.md +15 -0
- package/docs/{harness-methodology-debugging.md → governance/harness-methodology-debugging.md} +27 -3
- package/docs/init-surface.manifest.json +22 -4
- package/docs/operations/README.md +12 -0
- package/docs/{local-development-environment.md → operations/local-development-environment.md} +1 -1
- package/docs/skills/vetted-skill-registry.md +23 -3
- package/docs/templates/README.md +55 -0
- package/docs/templates/agent-dag.schema.json +15 -5
- package/docs/templates/backend-test-dag.json +1 -1
- package/docs/templates/evaluation/agents-map-slim-v1.candidate.json +9 -0
- package/docs/templates/evaluation/agents-map-slim-v1.md +87 -0
- package/docs/templates/evaluation/agents-map-verbose-v0.candidate.json +9 -0
- package/docs/templates/evaluation/agents-map-verbose-v0.md +153 -0
- package/docs/templates/frontend-implementation-contract.schema.json +4 -3
- package/docs/templates/frontend-test-case-checklist.md +6 -2
- package/docs/templates/frontend-test-dag.json +2 -2
- package/docs/templates/hybrid-dag.json +1 -1
- package/docs/templates/progress-log.md +9 -2
- package/harness.json +5 -5
- package/package.json +5 -5
- package/scripts/kb-bootstrap-init-skeleton.sh +2 -2
- package/skills/agent-worker/SKILL.md +1 -1
- package/skills/frontend-design-review/SKILL.md +12 -10
- package/skills/frontend-design-review/references/review-checklist.md +4 -4
- package/skills/frontend-implementation/SKILL.md +2 -2
- package/skills/frontend-implementation/references/code-standards.md +4 -3
- package/skills/frontend-implementation/references/design-spec.md +19 -14
- package/skills/frontend-implementation/references/node-contracts.md +2 -2
- package/skills/frontend-review/SKILL.md +15 -28
- package/skills/frontend-review/references/review-findings.md +16 -18
- package/skills/frontend-verification/SKILL.md +16 -13
- package/skills/frontend-verification/references/verification-checklist.md +18 -30
- package/skills/grill-with-docs/SKILL.md +44 -52
- package/skills/grill-with-docs/adr-format.md +37 -26
- package/skills/grill-with-docs/context-format.md +18 -26
- package/skills/loop-agent/SKILL.md +28 -112
- package/skills/loop-agent/references/command-reference.md +9 -3
- package/skills/loop-agent/references/harness-policy.md +3 -3
- package/skills/loop-agent/references/hybrid-dag.md +1 -1
- package/skills/loop-agent/references/orchestrator-and-interventions.md +1 -1
- package/skills/loop-agent/references/task-workflow.md +2 -0
- package/skills/systematic-debugging/SKILL.md +20 -4
- package/skills/test-driven-development/SKILL.md +10 -3
- package/dist/worker/console/static/assets/index-CUDke82y.js +0 -18
- package/dist/worker/console/static/assets/index-wSEksVSO.css +0 -1
- /package/docs/{harness-methodology-tdd.md → governance/harness-methodology-tdd.md} +0 -0
- /package/docs/{harness-methodology-verification.md → governance/harness-methodology-verification.md} +0 -0
|
@@ -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 (
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
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
|
-
//
|
|
83
|
-
const normalized = relative
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
96
|
-
|
|
100
|
+
if (!isRepoRelative)
|
|
101
|
+
assertInside(evidenceRootResolved, absolute, "evidence path");
|
|
97
102
|
const resolved = await realpath(absolute);
|
|
98
103
|
assertInside(repoRootResolved, resolved, "evidence realpath");
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
|
190
|
-
* -
|
|
191
|
-
*
|
|
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
|
-
"
|
|
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(
|
|
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(!
|
|
229
|
-
"
|
|
230
|
-
" }",
|
|
231
|
-
" if(!
|
|
232
|
-
"
|
|
233
|
-
"
|
|
234
|
-
"
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
: [
|