@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
@@ -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/** files
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 <repoRoot>/openspec/**. */
53
+ /** A repo-relative path references a canonical openspec spec directory. */
51
54
  function isOpenspecPath(filePath) {
52
- const normalized = filePath.replaceAll(path.sep, "/");
53
- return normalized === "openspec" || normalized.startsWith("openspec/");
55
+ return isOpenspecSpecFilePath(filePath.replaceAll(path.sep, "/"));
54
56
  }
55
- /** A search query targets the openspec/ directory. */
57
+ /** A search query targets a canonical openspec spec directory. */
56
58
  function isOpenspecSearch(query, searchPath) {
57
- const lower = query.toLowerCase();
58
- const normalizedPath = searchPath?.replaceAll(path.sep, "/").toLowerCase();
59
- return (normalizedPath === "openspec" ||
60
- normalizedPath?.startsWith("openspec/") === true ||
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/** reads and searches for Dashboard display.
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
- if (looksLikeStrictFrontendContract(value))
318
- return value;
319
- const record = asRecord(value);
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
- targetFiles.push("apps/web/src/welcome/WelcomeBanner.js");
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
- verificationTargets.push({
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
- requirements.push({
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 strategy = [
617
+ const allowedStrategies = [
515
618
  "native",
516
619
  "browser-intercept",
517
620
  "request-adapter",
518
621
  "not-needed",
519
- ].includes(strategyRaw)
520
- ? strategyRaw
521
- : "not-needed";
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"