@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.
- package/CHANGELOG.md +46 -0
- package/bin/agent-worker.js +0 -0
- package/dist/adapters/loop-agent.js +52 -0
- package/dist/commands/init.js +97 -0
- 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/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 +344 -64
- package/dist/workflows/dag/types.js +62 -1
- package/docs/templates/agent-dag.schema.json +15 -5
- package/docs/templates/backend-test-dag.json +1 -1
- 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/package.json +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
|
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { isSafeObservabilityIdentifier } from "../observability/event-store.js";
|
|
5
|
+
import { isOpenspecSpecFilePath, isOpenspecSpecPath, isOpenspecSpecSearchTarget, } from "../../shared/openspec-spec.js";
|
|
5
6
|
const DAG_RUN_LIFECYCLE_DIRS = ["active", "completed", "paused"];
|
|
6
7
|
/**
|
|
7
8
|
* Known knowledge-base connector tool names.
|
|
@@ -18,7 +19,8 @@ const KB_CONNECTOR_TOOLS = new Set([
|
|
|
18
19
|
]);
|
|
19
20
|
/**
|
|
20
21
|
* Pattern for detecting spec-related files:
|
|
21
|
-
* - openspec
|
|
22
|
+
* - openspec files (generic evidence, including non-normative paths)
|
|
23
|
+
* - ai_workspace files
|
|
22
24
|
* - *.spec.md / *.spec.ts / *.spec.tsx
|
|
23
25
|
* - project-specs/**
|
|
24
26
|
* - design-spec.md, code-standards.md, review-checklist.md, etc.
|
|
@@ -27,6 +29,7 @@ const KB_CONNECTOR_TOOLS = new Set([
|
|
|
27
29
|
*/
|
|
28
30
|
const SPEC_FILE_PATTERNS = [
|
|
29
31
|
/openspec\//i,
|
|
32
|
+
/ai_workspace\//i,
|
|
30
33
|
/\/project-specs\//i,
|
|
31
34
|
/\/spec\//i,
|
|
32
35
|
/\.spec\.(md|tsx?|jsx?)$/i,
|
|
@@ -47,22 +50,16 @@ function isSpecFilePath(filePath) {
|
|
|
47
50
|
function isKnowledgeBaseTool(toolName) {
|
|
48
51
|
return KB_CONNECTOR_TOOLS.has(toolName);
|
|
49
52
|
}
|
|
50
|
-
/** A repo-relative path references
|
|
53
|
+
/** A repo-relative path references a canonical openspec spec directory. */
|
|
51
54
|
function isOpenspecPath(filePath) {
|
|
52
|
-
|
|
53
|
-
return normalized === "openspec" || normalized.startsWith("openspec/");
|
|
55
|
+
return isOpenspecSpecFilePath(filePath.replaceAll(path.sep, "/"));
|
|
54
56
|
}
|
|
55
|
-
/** A search query targets
|
|
57
|
+
/** A search query targets a canonical openspec spec directory. */
|
|
56
58
|
function isOpenspecSearch(query, searchPath) {
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
lower === "openspec" ||
|
|
62
|
-
lower === "openspec/" ||
|
|
63
|
-
lower.startsWith("openspec/") ||
|
|
64
|
-
lower.includes("openspec/**") ||
|
|
65
|
-
lower.includes("openspec/*"));
|
|
59
|
+
const normalizedPath = searchPath?.replaceAll(path.sep, "/");
|
|
60
|
+
if (normalizedPath && isOpenspecSpecPath(normalizedPath))
|
|
61
|
+
return true;
|
|
62
|
+
return isOpenspecSpecSearchTarget(query);
|
|
66
63
|
}
|
|
67
64
|
function resolveSessionEventsPath(repoRoot, dagRunId, nodeId) {
|
|
68
65
|
if (!isSafeObservabilityIdentifier(dagRunId) || !isSafeObservabilityIdentifier(nodeId)) {
|
|
@@ -362,7 +359,7 @@ export async function extractSpecEvidence(repoRoot, dagRunId, nodeId) {
|
|
|
362
359
|
if (status === "no-evidence") {
|
|
363
360
|
summaryLines.push("未观察到任何规范证据:无 skill 注入、无文件读取、无检索操作。");
|
|
364
361
|
}
|
|
365
|
-
// Separate openspec
|
|
362
|
+
// Separate openspec spec directory reads and searches for Dashboard display.
|
|
366
363
|
// Knowledge base and openspec are parallel sources.
|
|
367
364
|
const openspecReads = specReads.filter((r) => isOpenspecPath(r.path));
|
|
368
365
|
const openspecSearches = searches.filter((s) => isOpenspecSearch(s.query, s.path));
|
|
@@ -46,6 +46,28 @@ export function getActiveNodes(dag) {
|
|
|
46
46
|
return (dag.nodes ?? []).filter((n) => isNodeActive(n.status));
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
export function frontendLintSummary(frontendLint) {
|
|
50
|
+
if (!frontendLint) return null;
|
|
51
|
+
const statusLabels = {
|
|
52
|
+
passed: "passed",
|
|
53
|
+
"baseline-debt": "baseline-debt",
|
|
54
|
+
failed: "failed",
|
|
55
|
+
unavailable: "unavailable",
|
|
56
|
+
};
|
|
57
|
+
const status = statusLabels[frontendLint.status] ?? "unavailable";
|
|
58
|
+
const changed = frontendLint.writerChangedFiles?.length ?? 0;
|
|
59
|
+
const tolerated = frontendLint.toleratedDiagnosticCount ?? 0;
|
|
60
|
+
const blocked = frontendLint.blockingDiagnosticCount ?? 0;
|
|
61
|
+
const reason = (frontendLint.blockingReasons ?? []).join(";");
|
|
62
|
+
return [
|
|
63
|
+
status,
|
|
64
|
+
`修改文件 ${changed}`,
|
|
65
|
+
`容忍存量诊断 ${tolerated}`,
|
|
66
|
+
`阻断诊断 ${blocked}`,
|
|
67
|
+
...(reason ? [`原因:${reason}`] : []),
|
|
68
|
+
].join(" · ");
|
|
69
|
+
}
|
|
70
|
+
|
|
49
71
|
export function dagSortTime(dag) {
|
|
50
72
|
return dag.startedAt ?? dag.finishedAt ?? "";
|
|
51
73
|
}
|
|
@@ -79,6 +79,7 @@ import {
|
|
|
79
79
|
} from "../shell-chrome.js";
|
|
80
80
|
import {
|
|
81
81
|
dagProgress,
|
|
82
|
+
frontendLintSummary,
|
|
82
83
|
isNodeFailed,
|
|
83
84
|
isNodeFinished,
|
|
84
85
|
isNodeActive,
|
|
@@ -223,6 +224,10 @@ export async function renderDagDetail(dagRunId, initial = true) {
|
|
|
223
224
|
["成功/失败/跳过", `${succeededCount}/${failedCount}/${skippedCount}`],
|
|
224
225
|
["DAG 路径", dag.dagPath ?? "—"],
|
|
225
226
|
);
|
|
227
|
+
const lintSummary = frontendLintSummary(dag.frontendLint);
|
|
228
|
+
if (lintSummary) {
|
|
229
|
+
primaryEntries.push(["前端 lint 判定", lintSummary]);
|
|
230
|
+
}
|
|
226
231
|
const primaryGrid = metaGrid(primaryEntries);
|
|
227
232
|
const diagnosticGrid = metaGrid([
|
|
228
233
|
["当前判定", dagStatusBadge(dag.effectiveStatus ?? dag.status)],
|
|
@@ -763,7 +763,44 @@ function advisoryBadge(status) {
|
|
|
763
763
|
const className = status === "PASS" ? "passed" : status === "FAIL" ? "failure" : "skipped";
|
|
764
764
|
return `<span class="status ${className}">${status === "Unavailable" ? "不可用" : status}</span>`;
|
|
765
765
|
}
|
|
766
|
+
function conclusionIcon(failed) {
|
|
767
|
+
return failed
|
|
768
|
+
? `<svg class="conclusion-icon" viewBox="0 0 48 48" role="img" aria-label="测试未通过"><circle cx="24" cy="24" r="20"/><path d="M24 14v13"/><circle cx="24" cy="34" r="1.5" class="icon-dot"/></svg>`
|
|
769
|
+
: `<svg class="conclusion-icon" viewBox="0 0 48 48" role="img" aria-label="测试通过"><circle cx="24" cy="24" r="20"/><path d="m14 24 7 7 14-15"/></svg>`;
|
|
770
|
+
}
|
|
771
|
+
function reportDetailSection(title, content, open = false) {
|
|
772
|
+
return `<details class="report-detail"${open ? " open" : ""}><summary>${escapeHtml(title)}</summary><div class="report-detail-body">${content}</div></details>`;
|
|
773
|
+
}
|
|
766
774
|
export function renderBackendTestHtml(input) {
|
|
775
|
+
const failed = input.parsed.failed + input.parsed.errors > 0;
|
|
776
|
+
const passRate = input.parsed.tests > 0 ? input.parsed.passed / input.parsed.tests : 0;
|
|
777
|
+
const catalog = new Map((input.cases ?? []).map((item) => [item.id, item]));
|
|
778
|
+
const caseValidation = parseAdvisorySummary(input.caseValidationSummary);
|
|
779
|
+
const traceability = parseAdvisorySummary(input.traceabilitySummary);
|
|
780
|
+
const failures = input.parsed.cases.filter((result) => result.status !== "passed");
|
|
781
|
+
const rows = input.parsed.cases.map((result) => {
|
|
782
|
+
const caseId = junitCaseId(result.name) ?? "未关联";
|
|
783
|
+
const item = catalog.get(caseId);
|
|
784
|
+
const io = requestResponseSummary(result);
|
|
785
|
+
const failureDetails = result.status === "passed"
|
|
786
|
+
? ""
|
|
787
|
+
: `<div class="failure-detail"><strong>${escapeHtml(result.message || humanStatus(result.status))}</strong>${result.details ? reportDetailSection("失败详情", `<pre>${escapeHtml(result.details)}</pre>`) : ""}</div>`;
|
|
788
|
+
return `<article class="case-card result-${result.status}"><div class="case-head"><div><span class="case-id">${escapeHtml(caseId)}</span><h3>${escapeHtml(item?.title ?? result.name)}</h3><p>${escapeHtml(item?.scenario ?? "未从 Markdown 用例提取场景说明。")}</p></div><div class="case-outcome"><span class="status ${result.status}">${humanStatus(result.status)}</span><span class="duration">${formatDuration(result.durationMs)}</span></div></div><div class="case-meta"><span>自动化用例</span><code>${escapeHtml(result.name)}</code></div><div class="io-grid">${reportDetails("接口请求参数", io.requests, "本次 JUnit 未捕获请求日志。")}${reportDetails("接口响应结果", io.responses, "本次 JUnit 未捕获响应日志。")}</div>${failureDetails}</article>`;
|
|
789
|
+
}).join("");
|
|
790
|
+
const failureOverview = failures.length > 0
|
|
791
|
+
? `<div class="failure-list">${failures.map((result) => {
|
|
792
|
+
const caseId = junitCaseId(result.name) ?? "未关联";
|
|
793
|
+
return `<article><div>${advisoryBadge(result.status === "skipped" ? "Unavailable" : "FAIL")}<strong>${escapeHtml(caseId)} · ${escapeHtml(catalog.get(caseId)?.title ?? result.name)}</strong></div><p>${escapeHtml(result.message || humanStatus(result.status))}</p></article>`;
|
|
794
|
+
}).join("")}</div>`
|
|
795
|
+
: '<div class="empty-state">本轮没有失败、错误或跳过用例。</div>';
|
|
796
|
+
const conclusion = failed
|
|
797
|
+
? `本轮测试未通过:${input.parsed.failed + input.parsed.errors} 条用例失败或错误。`
|
|
798
|
+
: `本轮测试通过:${input.parsed.passed} 条用例执行成功。`;
|
|
799
|
+
const executionOverview = `<div class="metric-grid"><div><span>用例总数</span><strong>${input.parsed.tests}</strong></div><div><span>通过</span><strong>${input.parsed.passed}</strong></div><div><span>失败 / 错误</span><strong>${input.parsed.failed} / ${input.parsed.errors}</strong></div><div><span>跳过</span><strong>${input.parsed.skipped}</strong></div><div><span>通过率</span><strong>${(passRate * 100).toFixed(2)}%</strong></div><div><span>总耗时</span><strong>${formatDuration(input.parsed.durationMs)}</strong></div></div>`;
|
|
800
|
+
const qualityDetails = `<div class="quality-grid"><div class="quality-card"><header><strong>Markdown 用例校验</strong>${advisoryBadge(caseValidation.status)}</header><p>Findings:${caseValidation.findings ?? "不可用"}</p><p>${escapeHtml(caseValidation.firstFinding)}</p></div><div class="quality-card"><header><strong>Markdown → pytest 追溯</strong>${advisoryBadge(traceability.status)}</header><p>Findings:${traceability.findings ?? "不可用"}</p><p>${escapeHtml(traceability.firstFinding)}</p></div></div><div class="evidence-line"><span>执行环境</span>${escapeHtml(input.environmentSummary || "不可用")}</div>`;
|
|
801
|
+
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:#182230;--muted:#667085;--line:#dce3ec;--panel:#f6f8fb;--brand:#163a63;--accent:#2e6da4;--pass:#067647;--pass-bg:#ecfdf3;--fail:#b42318;--fail-bg:#fef3f2;--skip:#9a6700;--skip-bg:#fffaeb;--shadow:0 18px 45px rgba(28,55,90,.09)}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at top left,#dcecff 0,transparent 32rem),linear-gradient(180deg,#edf3fa 0,#f8fafc 34rem);color:var(--ink);font-family:Inter,system-ui,"Microsoft YaHei","PingFang SC",sans-serif;line-height:1.65}header,main{max-width:1280px;margin:auto}header{padding:3.5rem 2rem 1.75rem}main{padding:0 2rem 4rem}h1{margin:.3rem 0;color:var(--brand);font-size:clamp(2rem,4vw,3rem);letter-spacing:-.03em}h2{margin:0 0 1rem;color:var(--brand)}h3{margin:.35rem 0 .15rem;font-size:1.12rem}.eyebrow{color:var(--accent);font-weight:850;letter-spacing:.12em}.subtitle,.muted,.case-head p{color:var(--muted)}section,.report-detail{background:rgba(255,255,255,.94);border:1px solid rgba(220,227,236,.95);border-radius:1.15rem;padding:1.5rem;margin-top:1rem;box-shadow:var(--shadow)}.conclusion{display:flex;align-items:center;gap:1rem;padding:1rem 1.25rem;border-left:5px solid ${failed ? "var(--fail)" : "var(--pass)"};border-radius:.8rem;background:${failed ? "var(--fail-bg)" : "var(--pass-bg)"};color:${failed ? "var(--fail)" : "var(--pass)"}}.conclusion-icon{width:4rem;height:4rem;flex:0 0 4rem;display:block;overflow:visible}.conclusion-icon circle:first-child{fill:currentColor;fill-opacity:.12;stroke:currentColor;stroke-width:2}.conclusion-icon path{fill:none;stroke:currentColor;stroke-width:3.2;stroke-linecap:round;stroke-linejoin:round}.conclusion-icon .icon-dot{fill:currentColor;stroke:none}.conclusion-copy{min-width:0}.conclusion-copy strong{display:block;font-size:1.05rem}.conclusion-copy span{display:block;font-size:.9rem;font-weight:400}.metric-grid{display:grid;grid-template-columns:repeat(6,minmax(7rem,1fr));gap:.75rem;margin-top:1rem}.metric-grid>div{padding:1rem;border:1px solid var(--line);border-radius:.85rem;background:white}.metric-grid span,.metric-grid small{display:block;color:var(--muted);font-size:.88rem}.metric-grid strong{display:block;font-size:1.45rem;font-weight:700}.quality-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem}.quality-card{padding:1rem;border:1px solid var(--line);border-radius:.85rem;background:white}.quality-card header{display:flex;align-items:center;justify-content:space-between;padding:0;margin:0 0 .5rem}.quality-card p{margin:.25rem 0;color:var(--muted)}.status{display:inline-flex;align-items:center;padding:.18rem .62rem;border-radius:999px;font-weight:800;font-size:.86rem}.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)}.failure-list{display:grid;gap:.75rem}.failure-list article{border-left:4px solid var(--fail);background:var(--fail-bg);padding:.85rem 1rem;border-radius:.65rem}.empty-state{padding:1rem;border:1px dashed var(--line);border-radius:.75rem;color:var(--muted);background:var(--panel)}.case-list{display:grid;gap:1rem}.case-card{border:1px solid var(--line);border-radius:1rem;padding:1.15rem;background:linear-gradient(145deg,#fff,#fbfcfe);box-shadow:0 8px 24px rgba(28,55,90,.06)}.case-card.result-failure,.case-card.result-error{border-left:5px solid var(--fail)}.case-card.result-passed{border-left:5px solid var(--pass)}.case-head{display:flex;justify-content:space-between;gap:1rem;align-items:flex-start}.case-head p{margin:.2rem 0}.case-id{display:inline-block;color:var(--accent);font-weight:850;letter-spacing:.04em}.case-outcome{display:flex;align-items:center;gap:.75rem;white-space:nowrap}.duration{color:var(--muted);font-variant-numeric:tabular-nums}.case-meta{display:flex;gap:.75rem;align-items:center;margin:.85rem 0;padding:.65rem .8rem;border-radius:.7rem;background:var(--panel);color:var(--muted)}code{color:var(--brand);font-weight:700}.io-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.8rem}.io-details{border:1px solid var(--line);border-radius:.75rem;padding:.7rem;background:white}.io-details summary,.report-detail summary{cursor:pointer;color:var(--brand);font-weight:800}.io-details summary span{float:right;color:var(--muted)}pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:18rem;overflow:auto;background:#101828;color:#eef4ff;padding:.85rem;border-radius:.6rem;font-size:.82rem}.failure-detail{margin-top:.8rem;padding:.8rem;border-radius:.7rem;background:var(--fail-bg);color:var(--fail)}.report-detail{padding:0;overflow:hidden}.report-detail summary{list-style:none;padding:1rem 1.25rem}.report-detail summary::-webkit-details-marker{display:none}.report-detail summary::after{content:"+";float:right;font-size:1.2rem;font-weight:400;color:var(--muted)}.report-detail[open] summary::after{content:"−"}.report-detail-body{border-top:1px solid var(--line);padding:1.25rem}.evidence-line{display:flex;gap:.75rem;align-items:baseline;padding:.7rem .8rem;background:var(--panel);border-radius:.65rem;font-size:.9rem}.evidence-line span{color:var(--muted);min-width:8rem}.subtitle{margin:.2rem 0 0}@media(max-width:900px){.metric-grid{grid-template-columns:repeat(2,1fr)}.quality-grid,.io-grid{grid-template-columns:1fr}.case-head{display:block}.case-outcome{margin-top:.65rem}header,main{padding-left:1rem;padding-right:1rem}}@media(max-width:420px){.conclusion{align-items:flex-start}.conclusion-icon{width:3rem;height:3rem;flex-basis:3rem}.metric-grid{grid-template-columns:1fr 1fr}}</style></head><body><header><div class="eyebrow">后端自动化测试报告</div><h1>${escapeHtml(input.title)}</h1><p class="subtitle">测试结论、质量状态与每条接口用例的请求/响应事实均来自同一次 pytest 执行。</p></header><main><section><h2>当前结论</h2><div class="conclusion">${conclusionIcon(failed)}<div class="conclusion-copy"><strong>${escapeHtml(failed ? "测试未通过" : "测试通过")}</strong><span>${escapeHtml(conclusion)}</span></div></div>${executionOverview}</section>${reportDetailSection("质量校验", qualityDetails, true)}${reportDetailSection("失败概览", failureOverview, failures.length > 0)}${reportDetailSection("用例执行结果", `<div class="case-list">${rows || '<div class="empty-state">未发现可展示的 JUnit testcase。</div>'}</div>`, true)}</main></body></html>`;
|
|
802
|
+
}
|
|
803
|
+
function renderBackendTestHtmlLegacy(input) {
|
|
767
804
|
const passRate = input.parsed.tests > 0 ? input.parsed.passed / input.parsed.tests : 0;
|
|
768
805
|
const catalog = new Map((input.cases ?? []).map((item) => [item.id, item]));
|
|
769
806
|
const rows = input.parsed.cases.map((result) => {
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
-
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { readFile, stat } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { writeDagRunJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
|
|
8
8
|
import { findPackageRoot } from "../../shared/package-metadata.js";
|
|
9
|
+
import { pathMatchesPattern } from "../../shared/git-progress.js";
|
|
9
10
|
export const FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID = "frontend-implementation-contract-v1";
|
|
10
11
|
/**
|
|
11
12
|
* Load the canonical frontend-implementation-contract-v1 JSON Schema from the
|
|
@@ -122,7 +123,7 @@ export const frontendImplementationContractSchema = z
|
|
|
122
123
|
verificationTargetIds: z.array(z.string().min(1)),
|
|
123
124
|
evidenceGap: gap.optional(),
|
|
124
125
|
})
|
|
125
|
-
.strict()),
|
|
126
|
+
.strict()).min(1),
|
|
126
127
|
uiStates: z.array(z
|
|
127
128
|
.object({
|
|
128
129
|
name: z.string().min(1),
|
|
@@ -132,7 +133,7 @@ export const frontendImplementationContractSchema = z
|
|
|
132
133
|
verificationTargetIds: z.array(z.string().min(1)).optional(),
|
|
133
134
|
notApplicableReason: z.string().min(1).optional(),
|
|
134
135
|
})
|
|
135
|
-
.strict()),
|
|
136
|
+
.strict()).min(1),
|
|
136
137
|
interactions: z.array(z
|
|
137
138
|
.object({
|
|
138
139
|
name: z.string().min(1),
|
|
@@ -185,7 +186,7 @@ export const frontendImplementationContractSchema = z
|
|
|
185
186
|
requirementIds: z.array(id),
|
|
186
187
|
uiStates: z.array(z.string().min(1)),
|
|
187
188
|
})
|
|
188
|
-
.strict()),
|
|
189
|
+
.strict()).min(1),
|
|
189
190
|
evidenceGaps: z.array(gap),
|
|
190
191
|
})
|
|
191
192
|
.strict()
|
|
@@ -198,6 +199,30 @@ export const frontendImplementationContractSchema = z
|
|
|
198
199
|
path: ["verificationTargets"],
|
|
199
200
|
});
|
|
200
201
|
const known = new Set(value.sourceBinding.requirementIds);
|
|
202
|
+
const stateNames = new Set(value.uiStates.map((state) => state.name));
|
|
203
|
+
for (const target of value.verificationTargets) {
|
|
204
|
+
// Verification evidence may point to read-only project files such as
|
|
205
|
+
// tsconfig.json or a test entrypoint; implementation targets remain
|
|
206
|
+
// governed by targets.files and the writer writeSet.
|
|
207
|
+
for (const requirementId of target.requirementIds) {
|
|
208
|
+
if (!known.has(requirementId)) {
|
|
209
|
+
ctx.addIssue({
|
|
210
|
+
code: "custom",
|
|
211
|
+
message: `verification target references unknown requirement ${requirementId}`,
|
|
212
|
+
path: ["verificationTargets"],
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
for (const stateName of target.uiStates) {
|
|
217
|
+
if (!stateNames.has(stateName)) {
|
|
218
|
+
ctx.addIssue({
|
|
219
|
+
code: "custom",
|
|
220
|
+
message: `verification target references unknown UI state ${stateName}`,
|
|
221
|
+
path: ["verificationTargets"],
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
201
226
|
for (const requirement of value.requirements) {
|
|
202
227
|
if (!known.has(requirement.id))
|
|
203
228
|
ctx.addIssue({
|
|
@@ -238,7 +263,70 @@ export const frontendImplementationContractSchema = z
|
|
|
238
263
|
path: ["uiStates"],
|
|
239
264
|
});
|
|
240
265
|
}
|
|
266
|
+
if (value.mockApi.strategy !== "not-needed") {
|
|
267
|
+
if (value.mockApi.endpoints.length === 0) {
|
|
268
|
+
ctx.addIssue({
|
|
269
|
+
code: "custom",
|
|
270
|
+
message: "Mock strategy requires at least one endpoint",
|
|
271
|
+
path: ["mockApi", "endpoints"],
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
value.mockApi.endpoints.forEach((endpoint, index) => {
|
|
275
|
+
if (!endpoint.fixture) {
|
|
276
|
+
ctx.addIssue({
|
|
277
|
+
code: "custom",
|
|
278
|
+
message: "Mock endpoint requires a fixture path",
|
|
279
|
+
path: ["mockApi", "endpoints", index, "fixture"],
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
if (!endpoint.consumer) {
|
|
283
|
+
ctx.addIssue({
|
|
284
|
+
code: "custom",
|
|
285
|
+
message: "Mock endpoint requires a consumer path",
|
|
286
|
+
path: ["mockApi", "endpoints", index, "consumer"],
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
for (const [field, file] of [["fixture", endpoint.fixture], ["consumer", endpoint.consumer]]) {
|
|
290
|
+
if (file && !value.targets.files.some((pattern) => pathMatchesPattern(file, pattern))) {
|
|
291
|
+
ctx.addIssue({
|
|
292
|
+
code: "custom",
|
|
293
|
+
message: `Mock ${field} is outside contract targets: ${file}`,
|
|
294
|
+
path: ["mockApi", "endpoints", index, field],
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
}
|
|
241
300
|
});
|
|
301
|
+
export async function assertFrontendSourceBindingFresh(input) {
|
|
302
|
+
for (const source of input.binding.sources) {
|
|
303
|
+
// DAG source bindings are task-relative (for example source/需求.md),
|
|
304
|
+
// while older bindings may already contain the repository-relative
|
|
305
|
+
// .harness/tasks/<taskId>/ prefix. Resolve both forms before hashing.
|
|
306
|
+
const taskDir = path.resolve(input.workspaceRoot, ".harness", "tasks", input.binding.taskId);
|
|
307
|
+
const absolute = source.path.startsWith(".harness/")
|
|
308
|
+
? path.resolve(input.workspaceRoot, source.path)
|
|
309
|
+
: path.resolve(taskDir, source.path);
|
|
310
|
+
const relative = path.relative(input.workspaceRoot, absolute);
|
|
311
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
312
|
+
throw new Error(`frontend source binding escapes workspace: ${source.path}`);
|
|
313
|
+
}
|
|
314
|
+
let content;
|
|
315
|
+
try {
|
|
316
|
+
content = await readFile(absolute);
|
|
317
|
+
const info = await stat(absolute);
|
|
318
|
+
if (!info.isFile())
|
|
319
|
+
throw new Error("not a regular file");
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
throw new Error(`frontend source binding file unavailable: ${source.path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
323
|
+
}
|
|
324
|
+
const actual = createHash("sha256").update(content).digest("hex");
|
|
325
|
+
if (actual !== source.sha256) {
|
|
326
|
+
throw new Error(`frontend source binding is stale: ${source.path}`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
242
330
|
const SECRET_KEY = /(?:password|passwd|secret|token|api[_-]?key|private[_-]?key|credential|authorization)/i;
|
|
243
331
|
const SECRET_VALUE = /(?:-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:sk|ghp|github_pat|xox[baprs]|AKIA)[-_A-Za-z0-9]{12,}\b)/;
|
|
244
332
|
function secretIssues(value, at = "$", issues = []) {
|
|
@@ -314,9 +402,42 @@ function looksLikeStrictFrontendContract(value) {
|
|
|
314
402
|
* Near-schema payloads are left untouched so unknown-key fail-closed still holds.
|
|
315
403
|
*/
|
|
316
404
|
export function coerceFrontendImplementationContractInput(value, canonicalBinding) {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
405
|
+
const rawRecord = asRecord(value);
|
|
406
|
+
const verificationTargetIds = rawRecord && Array.isArray(rawRecord.verificationTargets)
|
|
407
|
+
? new Set(rawRecord.verificationTargets
|
|
408
|
+
.map((item) => asString(asRecord(item)?.id))
|
|
409
|
+
.filter(Boolean))
|
|
410
|
+
: undefined;
|
|
411
|
+
const normalizedValue = rawRecord
|
|
412
|
+
? {
|
|
413
|
+
...rawRecord,
|
|
414
|
+
requirements: verificationTargetIds && Array.isArray(rawRecord.requirements)
|
|
415
|
+
? rawRecord.requirements.map((item) => {
|
|
416
|
+
const requirement = asRecord(item);
|
|
417
|
+
if (!requirement || !Array.isArray(requirement.verificationTargetIds))
|
|
418
|
+
return item;
|
|
419
|
+
return {
|
|
420
|
+
...requirement,
|
|
421
|
+
verificationTargetIds: requirement.verificationTargetIds.filter((id) => typeof id === "string" && verificationTargetIds.has(id)),
|
|
422
|
+
};
|
|
423
|
+
})
|
|
424
|
+
: rawRecord.requirements,
|
|
425
|
+
uiStates: Array.isArray(rawRecord.uiStates)
|
|
426
|
+
? rawRecord.uiStates.map((item) => {
|
|
427
|
+
const state = asRecord(item);
|
|
428
|
+
if (!state ||
|
|
429
|
+
state.applicable === false ||
|
|
430
|
+
(state.notApplicableReason !== "" && state.notApplicableReason !== null))
|
|
431
|
+
return item;
|
|
432
|
+
const { notApplicableReason: _emptyReason, ...withoutEmptyReason } = state;
|
|
433
|
+
return withoutEmptyReason;
|
|
434
|
+
})
|
|
435
|
+
: rawRecord.uiStates,
|
|
436
|
+
}
|
|
437
|
+
: value;
|
|
438
|
+
if (looksLikeStrictFrontendContract(normalizedValue))
|
|
439
|
+
return normalizedValue;
|
|
440
|
+
const record = asRecord(normalizedValue);
|
|
320
441
|
if (!record)
|
|
321
442
|
return value;
|
|
322
443
|
const implementation = asRecord(record.implementation);
|
|
@@ -333,7 +454,7 @@ export function coerceFrontendImplementationContractInput(value, canonicalBindin
|
|
|
333
454
|
...asStringArray(asRecord(component?.paths)?.domHelper ? [asRecord(component?.paths)?.domHelper] : []),
|
|
334
455
|
].filter((item, index, arr) => arr.indexOf(item) === index);
|
|
335
456
|
if (targetFiles.length === 0) {
|
|
336
|
-
|
|
457
|
+
throw new Error("frontend contract compatibility input must declare target files");
|
|
337
458
|
}
|
|
338
459
|
const riskRaw = asString(record.riskLevel) ||
|
|
339
460
|
asString(record.risk) ||
|
|
@@ -383,21 +504,7 @@ export function coerceFrontendImplementationContractInput(value, canonicalBindin
|
|
|
383
504
|
});
|
|
384
505
|
}
|
|
385
506
|
if (verificationTargets.length === 0) {
|
|
386
|
-
|
|
387
|
-
id: "VT-STATIC",
|
|
388
|
-
type: "static",
|
|
389
|
-
commandLabel: "npm run typecheck",
|
|
390
|
-
file: targetFiles[0],
|
|
391
|
-
requirementIds: [...canonicalBinding.requirementIds],
|
|
392
|
-
uiStates: ["success"],
|
|
393
|
-
}, {
|
|
394
|
-
id: "VT-UNIT",
|
|
395
|
-
type: "unit",
|
|
396
|
-
commandLabel: "npm run test:unit:fe",
|
|
397
|
-
file: targetFiles.find((p) => p.includes("__tests__")) || targetFiles[0],
|
|
398
|
-
requirementIds: [...canonicalBinding.requirementIds],
|
|
399
|
-
uiStates: ["success", "error"],
|
|
400
|
-
});
|
|
507
|
+
throw new Error("frontend contract compatibility input must declare verification targets");
|
|
401
508
|
}
|
|
402
509
|
const defaultVerificationIds = verificationTargets.map((item) => String(item.id));
|
|
403
510
|
const requirements = [];
|
|
@@ -440,11 +547,7 @@ export function coerceFrontendImplementationContractInput(value, canonicalBindin
|
|
|
440
547
|
}
|
|
441
548
|
for (const id of canonicalBinding.requirementIds) {
|
|
442
549
|
if (!requirements.some((item) => item.id === id)) {
|
|
443
|
-
|
|
444
|
-
id,
|
|
445
|
-
implementationTargets: targetFiles,
|
|
446
|
-
verificationTargetIds: defaultVerificationIds,
|
|
447
|
-
});
|
|
550
|
+
throw new Error(`frontend contract compatibility input does not cover ${id}`);
|
|
448
551
|
}
|
|
449
552
|
}
|
|
450
553
|
const uiStates = [];
|
|
@@ -511,14 +614,20 @@ export function coerceFrontendImplementationContractInput(value, canonicalBindin
|
|
|
511
614
|
});
|
|
512
615
|
}
|
|
513
616
|
const strategyRaw = asString(mockApiIn.strategy) || "not-needed";
|
|
514
|
-
const
|
|
617
|
+
const allowedStrategies = [
|
|
515
618
|
"native",
|
|
516
619
|
"browser-intercept",
|
|
517
620
|
"request-adapter",
|
|
518
621
|
"not-needed",
|
|
519
|
-
]
|
|
520
|
-
|
|
521
|
-
:
|
|
622
|
+
];
|
|
623
|
+
if (!allowedStrategies.includes(strategyRaw)) {
|
|
624
|
+
throw new Error(`frontend contract compatibility input has unsupported Mock strategy: ${strategyRaw}`);
|
|
625
|
+
}
|
|
626
|
+
const strategy = strategyRaw;
|
|
627
|
+
const productionDefaultOff = mockApiIn.productionDefaultOff === true || mockApiIn.productionMockOff === true;
|
|
628
|
+
if (!productionDefaultOff) {
|
|
629
|
+
throw new Error("frontend contract compatibility input must prove production Mock is off");
|
|
630
|
+
}
|
|
522
631
|
const activation = asString(mockApiIn.activation) ||
|
|
523
632
|
(strategy === "not-needed"
|
|
524
633
|
? "production remains real fetch; unit tests may inject fetchImpl only"
|