@tea-agent/loop-agent 0.39.0-beta.5 → 0.39.0-beta.7

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 (130) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/dist/build-stamp.json +3 -3
  3. package/dist/commands/client-recovery.js +3 -0
  4. package/dist/commands/task-advance.js +19 -0
  5. package/dist/executors/dag-pi-executor.js +138 -36
  6. package/dist/executors/pi-executor.js +7 -0
  7. package/dist/executors/pi-playwright-cli-tool.js +23 -16
  8. package/dist/executors/shell-executor.js +26 -9
  9. package/dist/shared/dag-failure-category.js +150 -0
  10. package/dist/task/config-types.js +6 -0
  11. package/dist/task/frontend-project-capability.js +19 -0
  12. package/dist/task/source-references.js +22 -1
  13. package/dist/worker/console/chat/operation-card.js +8 -1
  14. package/dist/worker/console/chat/routes.js +5 -3
  15. package/dist/worker/console/inspect-split.js +18 -0
  16. package/dist/worker/console/operation-run-facts.js +19 -3
  17. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-zsNyvGaH.js → abnfDiagram-N423BO3Z-bPer1waP.js} +1 -1
  18. package/dist/worker/console/static/assets/{arc-BDjZ5kE1.js → arc-BLdkwucW.js} +1 -1
  19. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-BkgaujnN.js → architectureDiagram-T3A2C74G-Bw4wo4I1.js} +1 -1
  20. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-CqxOJi0j.js → blockDiagram-VBNYF7ZC-DUUkc0gr.js} +1 -1
  21. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-C2JHLdRi.js → c4Diagram-5PPSVZJV-BC3O0nob.js} +1 -1
  22. package/dist/worker/console/static/assets/channel-AbFtVnFm.js +1 -0
  23. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-DF_YSvOS.js → chunk-2GRJ4B5K-Dd4g2nti.js} +1 -1
  24. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-D3Ort5Vo.js → chunk-2Q5K7J3B-s2EDm7-n.js} +1 -1
  25. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-XfAfZeUC.js → chunk-5RXB4S5H-GqtHM2yQ.js} +1 -1
  26. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-SLBibhf5.js → chunk-5VM5RSS4-BzRFrN8j.js} +1 -1
  27. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-Dz29Ye_f.js → chunk-6Q2QTUOP-Bw9nFjCJ.js} +1 -1
  28. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-DB2G4H8V.js → chunk-GF5L2VYU-B4n-03Qm.js} +1 -1
  29. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-CIoGkxdu.js → chunk-JWPE2WC7-Dt75ZdBD.js} +1 -1
  30. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-CG78FGkn.js → chunk-KBJHAD2P-B_afMhHB.js} +1 -1
  31. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-ZDHAvXPd.js → chunk-RYQCIY6F-Bd8Rqqg9.js} +1 -1
  32. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-BKkkC4VA.js → chunk-XXDRQBXY-9boOMji4.js} +1 -1
  33. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-n1yf2Jcv.js +1 -0
  34. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-n1yf2Jcv.js +1 -0
  35. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-SposUZsO.js → cose-bilkent-JH36ORCC-BtCycfCc.js} +1 -1
  36. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-BSCtQpD7.js → cynefin-VYW2F7L2-eTOts9L1.js} +1 -1
  37. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-DgFHrw9l.js → cynefinDiagram-MW4NZA55-Cibd2mjg.js} +1 -1
  38. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BVBasYbj.js → dagre-VZM6K2ZE-CDjDZtDV.js} +1 -1
  39. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-6r_4rN_c.js → diagram-7IWD3JNH-Btexn-JJ.js} +1 -1
  40. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-JmuaB41P.js → diagram-B4RE2ZJO-Cyl8sdpM.js} +1 -1
  41. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-xXvAY3sk.js → diagram-LBJQPF4R-Ev-83E1t.js} +1 -1
  42. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-D2B3lY2o.js → diagram-Q27KOJAE-41LqE0Mc.js} +1 -1
  43. package/dist/worker/console/static/assets/{diagram-UB23O5K3-tK54tMEX.js → diagram-UB23O5K3-BC39T5R3.js} +1 -1
  44. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-CNtELzV-.js → ebnfDiagram-BXEA7PRR-DzcNRuri.js} +1 -1
  45. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-C1xKMKcW.js → erDiagram-JOGREHBK-B5Frvvnz.js} +1 -1
  46. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-CZ7dlPMz.js → flowDiagram-UKHOOZJN-BapxROIM.js} +1 -1
  47. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-BqGb42Xe.js → ganttDiagram-PKOTCBZU-Cd4d4wQ0.js} +1 -1
  48. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-D_BlXEtt.js → gitGraphDiagram-DS77QQ5N-CrL6buFW.js} +1 -1
  49. package/dist/worker/console/static/assets/index-DBh_x_Ek.js +406 -0
  50. package/dist/worker/console/static/assets/index-DwOGauxU.css +1 -0
  51. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-Vce2vmIe.js → infoDiagram-6WML65LV-BrSiNxyd.js} +1 -1
  52. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-DDwVBAvI.js → ishikawaDiagram-WSZJBQD7-gvlvY-n9.js} +1 -1
  53. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-y_Ew-wv4.js → journeyDiagram-NVQOT4AX-D-YaPzRQ.js} +1 -1
  54. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-CjXJFp1K.js → kanban-definition-27J2QSJJ-BsmDv9E_.js} +1 -1
  55. package/dist/worker/console/static/assets/{linear-1itMst1Q.js → linear-UWaoDN3h.js} +1 -1
  56. package/dist/worker/console/static/assets/{mermaid.core-C-D3ZRUa.js → mermaid.core-BlvpGVG8.js} +5 -5
  57. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-BiYywmZc.js → mindmap-definition-FAOFIHXS-CUCIP5CP.js} +1 -1
  58. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-l2fydNWA.js → pegDiagram-VL7TDLO6-CB1aQUAK.js} +1 -1
  59. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-Dz0hNbYW.js → pieDiagram-7S7Q4E2Y-BwehHxhf.js} +1 -1
  60. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-BujI_mLZ.js → quadrantDiagram-CIZ2JOQS-CFz30APB.js} +1 -1
  61. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-pWe84beb.js → railroadDiagram-AXF67PYL-BwNmw1i6.js} +1 -1
  62. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-ByYu2frg.js → requirementDiagram-LRYGKXZP-jg-tPXlI.js} +1 -1
  63. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-CoPnilFP.js → sankeyDiagram-W5VNT64P-BlyReDKl.js} +1 -1
  64. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-1Md2LnS0.js → sequenceDiagram-SI44F4Z6-DBUzW1f3.js} +1 -1
  65. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-DqqqAoSH.js → sizeCapture-X5ZJPWSS-BYcI2RAr.js} +1 -1
  66. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-C4sGkf--.js → stateDiagram-OKZ733FA-D7AiyJdo.js} +1 -1
  67. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BBSH5c8m.js +1 -0
  68. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-ByxJrUzW.js → swimlanes-SLNWSIFB-BHGt7Kkv.js} +2 -2
  69. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-D2F7-U4H.js +8 -0
  70. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-D8MLgM0O.js → timeline-definition-Z64GVDOM-OPjooWcX.js} +1 -1
  71. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-DnBis-wZ.js → vennDiagram-T6HMQDX7-ggsWDvrO.js} +1 -1
  72. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-uuyYT7UO.js → wardleyDiagram-T6FBY63Y-DjNe-BjZ.js} +1 -1
  73. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-BjgabhwK.js → xychartDiagram-ELKLHX3M-Byp63Hei.js} +1 -1
  74. package/dist/worker/console/static/index.html +2 -2
  75. package/dist/worker/console/static-src/app/useRecoveryConsole.js +38 -2
  76. package/dist/worker/console/static-src/app/useRunProgress.js +46 -0
  77. package/dist/worker/console/static-src/operator-chat/cards/failure-category-advice.js +25 -0
  78. package/dist/worker/console/static-src/operator-chat/input-history.js +76 -0
  79. package/dist/worker/console/static-src/operator-chat/turn-submission.js +13 -1
  80. package/dist/worker/console/static-src/operator-chat/useChatStream.js +29 -9
  81. package/dist/worker/console/static-src/operator-chat/useComposer.js +32 -0
  82. package/dist/worker/console/static-src/pages/tasks/run-id-resolution.js +79 -0
  83. package/dist/worker/console/static-src/pages/tasks/run-ownership-verify.js +23 -0
  84. package/dist/worker/console/static-src/pages/tasks/run-panel-progress.js +110 -0
  85. package/dist/worker/materialize/harness-task-lineage.js +5 -2
  86. package/dist/worker/observability/read-model.js +22 -0
  87. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +29 -1
  88. package/dist/workflows/dag/backend-test-markdown-workflow.js +176 -1
  89. package/dist/workflows/dag/backend-test-pytest-collection.js +87 -4
  90. package/dist/workflows/dag/backend-test-writer-completeness.js +10 -5
  91. package/dist/workflows/dag/dynamic-runtime/loop-until.js +1 -1
  92. package/dist/workflows/dag/dynamic-runtime/map.js +1 -1
  93. package/dist/workflows/dag/failure-category.js +7 -128
  94. package/dist/workflows/dag/frontend-design-policy.js +31 -7
  95. package/dist/workflows/dag/frontend-implementation-contract.js +78 -3
  96. package/dist/workflows/dag/frontend-review-findings.js +238 -0
  97. package/dist/workflows/dag/frontend-shadow-dual-write.js +24 -10
  98. package/dist/workflows/dag/frontend-shape.js +5 -3
  99. package/dist/workflows/dag/frontend-test-case-checklist.js +4 -2
  100. package/dist/workflows/dag/frontend-test-case-manifest.js +6 -4
  101. package/dist/workflows/dag/frontend-test-case-quality.js +6 -3
  102. package/dist/workflows/dag/frontend-test-environment-probe.js +10 -7
  103. package/dist/workflows/dag/frontend-test-html-report.js +3 -1
  104. package/dist/workflows/dag/frontend-test-l5-report.js +3 -1
  105. package/dist/workflows/dag/frontend-test-layout.js +159 -0
  106. package/dist/workflows/dag/frontend-test-result-contract.js +28 -14
  107. package/dist/workflows/dag/frontend-test-standard-scenarios.js +3 -1
  108. package/dist/workflows/dag/init-hybrid.js +149 -15
  109. package/dist/workflows/dag/node-execution.js +18 -1
  110. package/dist/workflows/dag/report.js +37 -1
  111. package/dist/workflows/dag/rerun-feedback.js +101 -1
  112. package/dist/workflows/dag/retry-policy.js +31 -3
  113. package/dist/workflows/dag/runner.js +46 -1
  114. package/dist/workflows/dag/types.js +12 -0
  115. package/dist/workflows/dag/validate.js +32 -22
  116. package/docs/templates/agent-dag-report.schema.json +8 -2
  117. package/docs/templates/agent-dag.schema.json +1 -1
  118. package/docs/templates/frontend-test-case-checklist.md +2 -2
  119. package/package.json +1 -1
  120. package/skills/fe-test-ui-scout/SKILL.md +4 -4
  121. package/skills/fe-test-ui-scout/references/ledger-schema.md +1 -1
  122. package/skills/playwright-cli/SKILL.md +1 -1
  123. package/skills/playwright-cli-case-generator/SKILL.md +10 -8
  124. package/dist/worker/console/static/assets/channel-BuqaGKAT.js +0 -1
  125. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DNdSHjH9.js +0 -1
  126. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DNdSHjH9.js +0 -1
  127. package/dist/worker/console/static/assets/index-D5zGX6fG.js +0 -404
  128. package/dist/worker/console/static/assets/index-DBbhESQ_.css +0 -1
  129. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-B8rmop_9.js +0 -1
  130. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-B0ky-kBM.js +0 -8
@@ -12,7 +12,7 @@ import { readTypedEventStoreFromJsonl } from "./frontend-typed-event-store.js";
12
12
  // Cycle-safe value imports: the check functions are hoisted declarations used
13
13
  // only at call time, so the contract module and the policy module can depend
14
14
  // on each other without module-init ordering issues.
15
- import { checkDependencies, checkUiDesignCoverage, checkUiStateAttribution, } from "./frontend-design-policy.js";
15
+ import { checkDependencies, checkTargetPaths, checkUiDesignCoverage, checkUiStateAttribution, } from "./frontend-design-policy.js";
16
16
  import { canonicalContractRel, classifyStructuredContractFailure, formatStructuredArtifactPointer, persistRuntimeSkeleton, } from "./structured-output-repair.js";
17
17
  import { isConfigurationVerificationFile, verificationSymbolMatchesContent, } from "./frontend-verification-trace.js";
18
18
  export const frontendNormalizationActionSchema = z.enum([
@@ -142,6 +142,35 @@ async function deriveAllowedDependenciesFromRun(runDir) {
142
142
  }
143
143
  return [...deps];
144
144
  }
145
+ /** Read the frozen implementation writeSet patterns from run.json so the plan
146
+ * validator can front-load checkTargetPaths (write-set-too-large) before the
147
+ * design-policy shell terminates the run. */
148
+ async function deriveWriteSetFromRun(runDir) {
149
+ const specPath = path.join(runDir, "run.json");
150
+ let raw;
151
+ try {
152
+ raw = await readFile(specPath, "utf8");
153
+ }
154
+ catch {
155
+ return [];
156
+ }
157
+ let spec;
158
+ try {
159
+ spec = JSON.parse(raw);
160
+ }
161
+ catch {
162
+ return [];
163
+ }
164
+ const writeSet = new Set();
165
+ for (const task of spec.tasks ?? []) {
166
+ const patterns = task.shell?.frontendDesignPolicy?.implementationWriteSet;
167
+ if (patterns) {
168
+ for (const pattern of patterns)
169
+ writeSet.add(pattern);
170
+ }
171
+ }
172
+ return [...writeSet];
173
+ }
145
174
  export const FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID = "frontend-implementation-contract-v1";
146
175
  export const FRONTEND_IMPLEMENTATION_CONTRACT_PLAN_PATCH_SCHEMA_ID = "frontend-implementation-contract-plan-patch-v1";
147
176
  /**
@@ -278,7 +307,10 @@ const uiComponentChoiceSchema = z
278
307
  component: z.string().min(1),
279
308
  decision: z.enum(["specified", "reuse-existing", "new"]),
280
309
  specReference: uiComponentSpecReferenceSchema.nullable().optional(),
281
- rationale: z.string().min(1),
310
+ // Optional: the decision enum already carries the semantic (specified →
311
+ // specReference, new → deviation, reuse-existing → no rationale needed).
312
+ // Omit for reuse-existing to keep plan output small.
313
+ rationale: z.string().min(1).optional(),
282
314
  })
283
315
  .strict();
284
316
  const uiComponentChoicesSchema = z.array(uiComponentChoiceSchema);
@@ -306,8 +338,50 @@ function stripNullValuesDeep(value) {
306
338
  }
307
339
  return value;
308
340
  }
341
+ /**
342
+ * Normalize a model-emitted `uiComponentChoices` where a `new` choice carries a
343
+ * degenerate empty `specReference` object (`{ path: "", section: "", line:
344
+ * null }`) instead of the required `null`. The model intent is unambiguous
345
+ * (new components must not carry a specReference; the empty object is a format
346
+ * slip, not a real spec hit), so dropping the empty object before validation
347
+ * avoids a full plan retry on an otherwise-correct emission. A `specReference`
348
+ * with a non-empty `path` is preserved (it is a genuine `specified` hit).
349
+ */
350
+ export function normalizeNewComponentSpecReference(value) {
351
+ if (!Array.isArray(value))
352
+ return value;
353
+ return value.map((item) => {
354
+ if (!item || typeof item !== "object")
355
+ return item;
356
+ const record = item;
357
+ if (record.decision !== "new")
358
+ return item;
359
+ const ref = record.specReference;
360
+ if (!ref || typeof ref !== "object")
361
+ return item;
362
+ const refRecord = ref;
363
+ const path = typeof refRecord.path === "string" ? refRecord.path.trim() : "";
364
+ if (path.length > 0)
365
+ return item;
366
+ // Empty/degenerate specReference on a new choice → absent (null intent).
367
+ const { specReference: _dropped, ...rest } = record;
368
+ return rest;
369
+ });
370
+ }
309
371
  export const frontendImplementationContractSchema = z
310
- .preprocess(stripNullValuesDeep, z
372
+ .preprocess((value) => {
373
+ const record = asRecord(value);
374
+ if (!record)
375
+ return stripNullValuesDeep(value);
376
+ return stripNullValuesDeep({
377
+ ...record,
378
+ ...(record.uiComponentChoices !== undefined
379
+ ? {
380
+ uiComponentChoices: normalizeNewComponentSpecReference(record.uiComponentChoices),
381
+ }
382
+ : {}),
383
+ });
384
+ }, z
311
385
  .object({
312
386
  schemaVersion: z.literal(1),
313
387
  sourceBinding: z
@@ -1971,6 +2045,7 @@ export async function validateFrontendPlanPatchNodeOutput(input) {
1971
2045
  ...checkUiDesignCoverage(analysis.canonical),
1972
2046
  ...checkUiStateAttribution(analysis.canonical),
1973
2047
  ...checkDependencies(analysis.canonical, await deriveAllowedDependenciesFromRun(input.runDir)),
2048
+ ...checkTargetPaths(analysis.canonical, await deriveWriteSetFromRun(input.runDir)),
1974
2049
  ];
1975
2050
  if (policyPreFindings.length > 0)
1976
2051
  throw new Error(`frontend plan policy pre-check failed (fix these plan facts, then re-commit and finalize): ${policyPreFindings
@@ -0,0 +1,238 @@
1
+ import path from "node:path";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { readTypedEventStoreFromJsonl, } from "./frontend-typed-event-store.js";
4
+ /**
5
+ * Frontend review findings → downstream work (direction A).
6
+ *
7
+ * When `frontend-review-pi` commits `request_review_changes`, the implementation
8
+ * must not silently finish: the run becomes "not passed" (partial_failed +
9
+ * terminalReason=review-request-changes), and the committed findings are
10
+ * rendered into a downstream-task PRD draft that a later DAG round can consume
11
+ * instead of repeating the previous flow. Rendering is deterministic (pure);
12
+ * only the JSONL read and PRD write are IO.
13
+ */
14
+ export const FRONTEND_REVIEW_FINDINGS_PRD_REL = "contracts/frontend-review-findings-prd.md";
15
+ /** Review node id whose committed typed facts drive the findings PRD. */
16
+ export const FRONTEND_REVIEW_NODE_ID = "frontend-review-pi";
17
+ /** Deterministic issueCategory → failure owner mapping (A+B AC-009 §4.10). */
18
+ export function classifyReviewIssueCategoryFailureOwner(issueCategory) {
19
+ switch (issueCategory) {
20
+ case "implementation-mismatch":
21
+ return "implementation";
22
+ case "approved-design-defect":
23
+ return "plan";
24
+ case "target-surface-defect":
25
+ return "scout";
26
+ case "contract-requirement-gap":
27
+ return "contract";
28
+ case "unknown":
29
+ default:
30
+ return "unknown";
31
+ }
32
+ }
33
+ const API_SIGNAL_RE = /\b(api|fetch|axios|graphql|endpoint|远程|接口)\b/i;
34
+ /** Negations for the API signal. CJK "无 API/无接口" has no \b boundary, so
35
+ * it is matched separately from the ASCII no api/not-needed forms. */
36
+ const API_NEGATION_RE = /(?:^|[^\w])(?:no\s+api|not-needed)(?:$|[^\w])|无\s*api|无\s*接口/i;
37
+ const SMALL_SIGNAL_WORDS = [
38
+ "单文件局部修复",
39
+ "局部样式",
40
+ "文案调整",
41
+ "无远程接口",
42
+ ];
43
+ function topLevelDir(p) {
44
+ const normalized = p.replace(/\\/g, "/");
45
+ const first = normalized.split("/").filter(Boolean)[0] ?? "";
46
+ return first;
47
+ }
48
+ /** Collect unique non-empty finding `file` values (sorted, de-duplicated). */
49
+ function findingTargetFiles(fact) {
50
+ const files = new Set();
51
+ for (const finding of fact.findings) {
52
+ if (typeof finding.file === "string" && finding.file.trim()) {
53
+ files.add(finding.file.trim());
54
+ }
55
+ }
56
+ return [...files].sort();
57
+ }
58
+ /**
59
+ * Deterministically derive the fix scope for the downstream task. Same fact
60
+ * always yields the same scope. The shape mirrors `resolveFrontendTaskShape`
61
+ * signals (concentrated paths, no remote API → micro/small) so that when the
62
+ * PRD draft is used as the next task's requirement, the frontend DAG generator
63
+ * routes a small fix to micro/small instead of the full 11-node standard
64
+ * topology.
65
+ */
66
+ export function deriveFrontendReviewFixScope(fact) {
67
+ const targetFiles = findingTargetFiles(fact);
68
+ const blob = fact.findings
69
+ .map((f) => `${f.issue} ${f.requiredChange ?? ""} ${f.file ?? ""}`)
70
+ .join("\n");
71
+ const hasApiSignal = API_SIGNAL_RE.test(blob) && !API_NEGATION_RE.test(blob);
72
+ const topDirs = new Set(targetFiles.map(topLevelDir));
73
+ const concentrated = targetFiles.length > 0 && topDirs.size <= 1 && targetFiles.length <= 4;
74
+ let shape;
75
+ let complexity;
76
+ let reason;
77
+ if (targetFiles.length === 0) {
78
+ shape = "standard";
79
+ complexity = "medium";
80
+ reason =
81
+ "findings carry no file references; scope unknown, keep standard topology";
82
+ }
83
+ else if (hasApiSignal) {
84
+ shape = "standard";
85
+ complexity = "medium";
86
+ reason = `findings reference a remote API signal; standard topology required (files=${targetFiles.length})`;
87
+ }
88
+ else if (concentrated && targetFiles.length <= 1) {
89
+ shape = "micro";
90
+ complexity = "trivial";
91
+ reason = "single-file concentrated fix without remote API";
92
+ }
93
+ else if (concentrated) {
94
+ shape = "small";
95
+ complexity = "low";
96
+ reason = `concentrated fix across ${targetFiles.length} file(s) in one top-level directory`;
97
+ }
98
+ else {
99
+ shape = "standard";
100
+ complexity = "medium";
101
+ reason = `fix spans ${targetFiles.length} file(s) across ${topDirs.size} top-level directories; standard topology`;
102
+ }
103
+ const allowedPaths = [
104
+ ...new Set([
105
+ ...targetFiles,
106
+ ...targetFiles.map((f) => `${topLevelDir(f)}/**`),
107
+ ]),
108
+ ].sort();
109
+ return {
110
+ shape,
111
+ complexity,
112
+ targetFiles,
113
+ allowedPaths,
114
+ smallSignals: [...SMALL_SIGNAL_WORDS],
115
+ hasApiSignal,
116
+ reason,
117
+ };
118
+ }
119
+ /**
120
+ * Read the committed `request_review_changes` fact from the review node's typed
121
+ * event store. Returns undefined when no committed request fact exists (e.g.
122
+ * approve_review, missing store, or an unreadable store).
123
+ */
124
+ export async function readCommittedReviewRequestFact(input) {
125
+ const reviewNodeId = input.reviewNodeId ?? FRONTEND_REVIEW_NODE_ID;
126
+ let records;
127
+ try {
128
+ records = await readTypedEventStoreFromJsonl(path.join(input.runDir, reviewNodeId, "review-typed-facts.jsonl"));
129
+ }
130
+ catch {
131
+ return undefined;
132
+ }
133
+ const committed = records.filter((record) => record.phase === "committed");
134
+ if (committed.length === 0)
135
+ return undefined;
136
+ // A committed approve_review means the review passed; only a committed
137
+ // request_review_changes carries a "not passed" verdict.
138
+ const request = committed.find((record) => record.fact.kind === "request_review_changes");
139
+ if (!request)
140
+ return undefined;
141
+ const fact = request.fact;
142
+ if (typeof fact !== "object" || fact === null)
143
+ return undefined;
144
+ return fact;
145
+ }
146
+ function bulletList(items) {
147
+ if (!items || items.length === 0)
148
+ return "_(none)_";
149
+ return items.map((item) => `- ${item}`).join("\n");
150
+ }
151
+ function findingLines(findings) {
152
+ if (findings.length === 0)
153
+ return "_(none)_";
154
+ return findings
155
+ .map((finding) => {
156
+ const location = [finding.file, finding.line]
157
+ .filter((value) => value !== undefined)
158
+ .join(":");
159
+ const prefix = location ? `[${location}] ` : "";
160
+ const requiredChange = finding.requiredChange
161
+ ? `\n - requiredChange: ${finding.requiredChange}`
162
+ : "";
163
+ return `- ${prefix}${finding.severity}: ${finding.issue}${requiredChange}`;
164
+ })
165
+ .join("\n");
166
+ }
167
+ /**
168
+ * Deterministically render a downstream-task PRD draft from a committed
169
+ * `request_review_changes` fact. Same fact always renders byte-identical
170
+ * output; the parent run id and review node id are the only context.
171
+ */
172
+ export function renderFrontendReviewFindingsPrd(input) {
173
+ const { parentRunId, fact } = input;
174
+ const reviewNodeId = input.reviewNodeId ?? FRONTEND_REVIEW_NODE_ID;
175
+ const restartPhase = classifyReviewIssueCategoryFailureOwner(fact.issueCategory);
176
+ const scope = deriveFrontendReviewFixScope(fact);
177
+ const targetSummary = scope.targetFiles.length > 0
178
+ ? scope.targetFiles.join(", ")
179
+ : fact.issueCategory;
180
+ return [
181
+ `# 修复 review 发现(${scope.shape}):${targetSummary}`,
182
+ "",
183
+ "## 背景",
184
+ "",
185
+ `- parentRunId: \`${parentRunId}\``,
186
+ `- reviewNodeId: \`${reviewNodeId}\``,
187
+ `- typedVerdict: \`request_review_changes\``,
188
+ `- issueCategory: \`${fact.issueCategory}\``,
189
+ `- restartPhase/failureOwner: \`${restartPhase}\``,
190
+ "",
191
+ "## 修复范围(供下一轮任务生成路由)",
192
+ "",
193
+ `- 建议 shape:\`${scope.shape}\`(micro=7 / small=10 / standard=11 节点)`,
194
+ `- 建议 complexity:\`${scope.complexity}\``,
195
+ `- 推导依据:${scope.reason}`,
196
+ ...(scope.hasApiSignal ? ["- ⚠️ findings 含远程 API/接口信号,走 standard 拓扑"] : []),
197
+ scope.targetFiles.length > 0
198
+ ? [`- 目标文件:${scope.targetFiles.map((f) => `\`${f}\``).join(", ")}`]
199
+ : ["- 目标文件:_(findings 未提供 file 引用,需人工补全)_"],
200
+ scope.allowedPaths.length > 0
201
+ ? [`- 建议 allowedPaths:${scope.allowedPaths.map((p) => `\`${p}\``).join(", ")}`]
202
+ : [],
203
+ scope.shape === "micro" || scope.shape === "small"
204
+ ? [`- 范围信号:${scope.smallSignals.join("、")}(供 shape 分类器识别,避免退化为全节点流程)`]
205
+ : [],
206
+ "",
207
+ "## 问题清单",
208
+ "",
209
+ findingLines(fact.findings),
210
+ "",
211
+ "## 证据引用",
212
+ "",
213
+ bulletList(fact.evidenceRefs),
214
+ "",
215
+ "## 修复目标",
216
+ "",
217
+ `- 按 issueCategory 映射的重启阶段(${restartPhase})定点修复,不复用上一轮未通过的实现。`,
218
+ "- 逐条关闭上面「问题清单」中的 findings,并保留上一轮已通过的验证目标继续有效。",
219
+ "- 完成修复后重新走 frontend DAG 的 implement → verify → review 链,直到 review 提交 approve_review。",
220
+ "",
221
+ ].join("\n");
222
+ }
223
+ /** Write the findings PRD draft under the run dir (run-owned artifact). */
224
+ export async function writeFrontendReviewFindingsPrd(input) {
225
+ const markdown = renderFrontendReviewFindingsPrd(input);
226
+ const abs = path.join(input.runDir, FRONTEND_REVIEW_FINDINGS_PRD_REL);
227
+ await mkdir(path.dirname(abs), { recursive: true });
228
+ await writeFile(abs, markdown, "utf8");
229
+ return FRONTEND_REVIEW_FINDINGS_PRD_REL;
230
+ }
231
+ export async function readFrontendReviewFindingsPrd(runDir) {
232
+ try {
233
+ return await readFile(path.join(runDir, FRONTEND_REVIEW_FINDINGS_PRD_REL), "utf8");
234
+ }
235
+ catch {
236
+ return undefined;
237
+ }
238
+ }
@@ -291,15 +291,17 @@ export function assemblePlanPatchFromCommittedFacts(records) {
291
291
  if (routes.length > 0)
292
292
  patch.targets = { routes };
293
293
  }
294
- const componentChoice = facts.find((fact) => fact.kind === "component-choice");
295
- if (componentChoice) {
296
- if (Array.isArray(componentChoice.uiComponentChoices)) {
297
- patch.uiComponentChoices = componentChoice.uiComponentChoices.filter(isRecord);
298
- }
299
- const stylingStrategy = asString(componentChoice.stylingStrategy);
300
- if (stylingStrategy)
301
- patch.stylingStrategy = stylingStrategy;
302
- }
294
+ const componentChoices = facts.filter((fact) => fact.kind === "component-choice");
295
+ const collectedChoices = componentChoices.flatMap((fact) => Array.isArray(fact.uiComponentChoices)
296
+ ? fact.uiComponentChoices.filter(isRecord)
297
+ : []);
298
+ const stylingStrategy = componentChoices
299
+ .map((fact) => asString(fact.stylingStrategy))
300
+ .find((value) => value.length > 0);
301
+ if (collectedChoices.length > 0)
302
+ patch.uiComponentChoices = collectedChoices;
303
+ if (stylingStrategy)
304
+ patch.stylingStrategy = stylingStrategy;
303
305
  const stateFlow = facts.find((fact) => fact.kind === "state-flow");
304
306
  if (stateFlow) {
305
307
  if (Array.isArray(stateFlow.uiStates)) {
@@ -330,7 +332,19 @@ export function assemblePlanPatchFromCommittedFacts(records) {
330
332
  const requirements = facts
331
333
  .filter((fact) => fact.kind === "plan-requirement")
332
334
  .map((fact) => fact.entry)
333
- .filter(isRecord);
335
+ .filter(isRecord)
336
+ .map((entry) => {
337
+ // The canonical contract schema requires a non-empty
338
+ // expectedOutcome, but the plan should not be forced to re-write
339
+ // full outcome prose for every requirement (the contract node
340
+ // already captured requirement semantics). Derive a deterministic
341
+ // placeholder from the requirement id when omitted.
342
+ if (isRecord(entry) && !asString(entry.expectedOutcome)) {
343
+ const id = asString(entry.id) || "requirement";
344
+ return { ...entry, expectedOutcome: `implied by contract: ${id}` };
345
+ }
346
+ return entry;
347
+ });
334
348
  if (requirements.length > 0)
335
349
  patch.requirements = requirements;
336
350
  const verificationTargets = facts
@@ -29,7 +29,7 @@ const SHAPE_ESCALATION_PATTERNS = [
29
29
  ];
30
30
  const SHAPE_SMALL_PATTERNS = [
31
31
  { id: "single-component", re: /single\s+component|单一组件|局部样式|style\s+tweak|css\s+only|文案|copy\s+change/i },
32
- { id: "no-remote", re: /no\s+api|无接口|本地状态|local\s+state\s+only|pure\s+ui/i },
32
+ { id: "no-remote", re: /no\s+api|无\s*api|无\s*接口|本地状态|local\s+state\s+only|pure\s+ui/i },
33
33
  ];
34
34
  const SHAPE_SPLIT_REQUIRED_PATTERN = /拆分|split\s+(?:requirement|task|需求)|large\s+requirement|大型需求|多步骤需求/i;
35
35
  const EXECUTABLE_SHAPE_RANK = {
@@ -77,7 +77,8 @@ function resolveBaselineFrontendTaskShape(input) {
77
77
  const spread = pathSpreadScore(paths);
78
78
  const concentrated = spread <= 1 && paths.length <= 4;
79
79
  const hasApiSignal = /\b(api|fetch|axios|graphql|endpoint|远程|接口)\b/i.test(blob) &&
80
- !/\b(no\s+api|无接口|not-needed)\b/i.test(blob);
80
+ !(/(?:^|[^\w])(?:no\s+api|not-needed)(?:$|[^\w])/i.test(blob) ||
81
+ /无\s*api|无\s*接口/i.test(blob));
81
82
  const complexity = (input.complexity ?? "").toLowerCase();
82
83
  // split-required wins over everything: it is a termination/escalation
83
84
  // signal, not an executable topology.
@@ -258,7 +259,8 @@ export function deriveFrontendRuntimeShapeFacts(input) {
258
259
  const escalationSignals = collectEscalationSignals(blob, supervised);
259
260
  const paths = input.allowedPaths ?? [];
260
261
  const hasApiSignal = /\b(api|fetch|axios|graphql|endpoint|远程|接口)\b/i.test(blob) &&
261
- !/\b(no\s+api|无接口|not-needed)\b/i.test(blob);
262
+ !(/(?:^|[^\w])(?:no\s+api|not-needed)(?:$|[^\w])/i.test(blob) ||
263
+ /无\s*api|无\s*接口/i.test(blob));
262
264
  const requiresFull = escalationSignals.length > 0 || pathSpreadScore(paths) > 1 || hasApiSignal;
263
265
  return {
264
266
  splitRequired: input.splitRequired,
@@ -2,6 +2,7 @@ import { readFile, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { isPlaywrightCliCommand } from "../../shared/playwright-cli-command-policy.js";
4
4
  import { isTruncatedFrontendAcId, isTruncatedWhenUndeclaredFrontendAcId, } from "./frontend-test-result-contract.js";
5
+ import { defaultFrontendTestLayout, frontendTestCasePath, } from "./frontend-test-layout.js";
5
6
  const COMMAND_FENCE_LANGUAGE = /^(?:|bash|sh|shell|zsh|fish|console|terminal|command|cmd|powershell|pwsh|javascript|js|typescript|ts|python|py)$/i;
6
7
  const LIST_ITEM = /^\s*(?:[-+*]|\d+[.)])\s+(.+)$/;
7
8
  const INDENTED_CODE = /^(?: {4,}|\t+)(\S.*)$/;
@@ -290,7 +291,8 @@ function commandGateIssue(input) {
290
291
  };
291
292
  }
292
293
  export async function validateFrontendCaseChecklist(input) {
293
- const root = path.join(input.workspaceRoot, "testcase/frontend/cases");
294
+ const layout = input.layout ?? defaultFrontendTestLayout();
295
+ const root = path.join(input.workspaceRoot, layout.casesDir);
294
296
  const draft = path.join(root, "manifest.draft.json");
295
297
  const final = path.join(root, "manifest.json");
296
298
  const manifestPath = await exists(draft) ? draft : await exists(final) ? final : "";
@@ -319,7 +321,7 @@ export async function validateFrontendCaseChecklist(input) {
319
321
  if (typeof item?.caseId === "string" && /^AC-/i.test(item.caseId))
320
322
  issues.push({ ruleId: "case-id-is-ac", caseId: id, detail: "caseId must not be an acceptance id" });
321
323
  const casePath = typeof item?.casePath === "string" ? item.casePath : "";
322
- const expectedPath = typeof item?.caseId === "string" ? `testcase/frontend/cases/${item.caseId}.md` : "";
324
+ const expectedPath = typeof item?.caseId === "string" ? frontendTestCasePath(layout, item.caseId) : "";
323
325
  const absolute = path.resolve(input.workspaceRoot, casePath);
324
326
  const relative = path.relative(input.workspaceRoot, absolute);
325
327
  if (!casePath || relative.startsWith("..") || path.isAbsolute(relative) || !(await exists(absolute))) {
@@ -1,6 +1,7 @@
1
1
  import { readFile, rename, unlink, writeFile, mkdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { isTruncatedFrontendAcId, isTruncatedWhenUndeclaredFrontendAcId, } from "./frontend-test-result-contract.js";
4
+ import { defaultFrontendTestLayout, frontendTestCasePath, frontendTestEvidenceDir, frontendTestManifestDraftPath, frontendTestManifestPath, } from "./frontend-test-layout.js";
4
5
  const DIMENSIONS = new Set(["core", "boundary", "flow", "backend"]);
5
6
  function fail(ruleId, detail) {
6
7
  const msg = JSON.stringify({ ruleId, detail: String(detail ?? "") });
@@ -19,8 +20,9 @@ function fail(ruleId, detail) {
19
20
  * present). When empty, ac-id-shape still applies.
20
21
  */
21
22
  export async function materializeFrontendTestCaseManifest(input) {
22
- const draft = path.join(input.workspaceRoot, "testcase/frontend/cases/manifest.draft.json");
23
- const file = path.join(input.workspaceRoot, "testcase/frontend/cases/manifest.json");
23
+ const layout = input.layout ?? defaultFrontendTestLayout();
24
+ const draft = path.join(input.workspaceRoot, frontendTestManifestDraftPath(layout));
25
+ const file = path.join(input.workspaceRoot, frontendTestManifestPath(layout));
24
26
  const tmp = `${file}.tmp`;
25
27
  const maxCases = input.maxCases ?? 32;
26
28
  const declaredAc = new Set(input.declaredAcIds ?? []);
@@ -71,8 +73,8 @@ export async function materializeFrontendTestCaseManifest(input) {
71
73
  fail("unknown-ac", `${ac} not in sourceBinding; repair generator input or AC list`);
72
74
  acIds.push(ac);
73
75
  }
74
- const casePath = `testcase/frontend/cases/${c.caseId}.md`;
75
- const evidenceDir = `testcase/frontend/evidence/${c.caseId}/`;
76
+ const casePath = frontendTestCasePath(layout, c.caseId);
77
+ const evidenceDir = frontendTestEvidenceDir(layout, c.caseId);
76
78
  for (const [k, v] of [
77
79
  ["casePath", casePath],
78
80
  ["evidenceDir", evidenceDir],
@@ -1,6 +1,7 @@
1
1
  import { readFile, writeFile, mkdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { markdownSection } from "./frontend-test-markdown.js";
4
+ import { defaultFrontendTestLayout, frontendTestCasePath, } from "./frontend-test-layout.js";
4
5
  const CASE_ID = /^FE-[A-Za-z0-9]+-\d{3}-(?:core|boundary|flow|backend)$/i;
5
6
  const AC_ID = /^AC(?:-[A-Z0-9]+)+$/i;
6
7
  const PLACEHOLDER = /\b(?:TODO|TBD|FIXME)\b|结果正确|页面正常|按实际情况处理|验证成功/i;
@@ -15,7 +16,8 @@ const SECTION_ALIASES = {
15
16
  function hasList(value) { return /^\s*(?:\d+[.)]|[-*+])\s+\S+/m.test(value); }
16
17
  function hasAssertion(value) { return /(?:status|状态|包含|显示|等于|为|可见|不可见|跳转|错误|成功|失败|should|expect|assert|must)/i.test(value); }
17
18
  export async function validateFrontendCaseContent(input) {
18
- const root = path.join(input.workspaceRoot, "testcase/frontend/cases");
19
+ const layout = input.layout ?? defaultFrontendTestLayout();
20
+ const root = path.join(input.workspaceRoot, layout.casesDir);
19
21
  const manifestPath = path.join(root, "manifest.json");
20
22
  const findings = [];
21
23
  let manifest;
@@ -49,7 +51,7 @@ export async function validateFrontendCaseContent(input) {
49
51
  findings.push({ ruleId: "unknown-ac", caseId: id, detail: `${ac} is not declared by the task` });
50
52
  }
51
53
  }
52
- const file = path.join(input.workspaceRoot, item?.casePath || `testcase/frontend/cases/${id}.md`);
54
+ const file = path.join(input.workspaceRoot, item?.casePath || frontendTestCasePath(layout, id));
53
55
  let body = "";
54
56
  try {
55
57
  body = await readFile(file, "utf8");
@@ -89,7 +91,8 @@ function render(findings) {
89
91
  }
90
92
  export async function writeFrontendCaseQualityReports(input) {
91
93
  const result = await validateFrontendCaseContent(input);
92
- const dir = path.join(input.workspaceRoot, input.outputDir ?? "testcase/frontend/reports");
94
+ const layout = input.layout ?? defaultFrontendTestLayout();
95
+ const dir = path.join(input.workspaceRoot, input.outputDir ?? layout.reportsDir);
93
96
  await mkdir(dir, { recursive: true });
94
97
  await writeFile(path.join(dir, "frontend-test-case-quality-advisory.md"), result.markdown, "utf8");
95
98
  await writeFile(path.join(dir, "frontend-test-case-quality-advisory.html"), result.html, "utf8");
@@ -1,6 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import { defaultFrontendTestLayout, frontendTestContextRel, frontendTestProbeRel, } from "./frontend-test-layout.js";
4
5
  export const FRONTEND_TEST_CONTEXT_REL = "testcase/frontend/rag/context.md";
5
6
  export const FRONTEND_TEST_PROBE_REL = "testcase/frontend/rag/environment-probe.json";
6
7
  const CONNECTION_REFUSED_HINT = "被测服务未在 baseUrl 监听(connection refused)。请先启动本地前端(例如 scripts/serve.sh 或项目约定的 npm start),确认可访问后再从 materialize-frontend-test-execution-shell 续跑。";
@@ -65,9 +66,9 @@ function resolveBaseUrl(context) {
65
66
  }
66
67
  return null;
67
68
  }
68
- async function writeProbe(workspaceRoot, probe) {
69
- const probePath = path.join(workspaceRoot, FRONTEND_TEST_PROBE_REL);
70
- const contextPath = path.join(workspaceRoot, FRONTEND_TEST_CONTEXT_REL);
69
+ async function writeProbe(workspaceRoot, probe, layout) {
70
+ const probePath = path.join(workspaceRoot, frontendTestProbeRel(layout));
71
+ const contextPath = path.join(workspaceRoot, frontendTestContextRel(layout));
71
72
  await mkdir(path.dirname(probePath), { recursive: true });
72
73
  await writeFile(probePath, `${JSON.stringify(probe, null, 2)}\n`, "utf8");
73
74
  let context = "";
@@ -84,14 +85,16 @@ async function writeProbe(workspaceRoot, probe) {
84
85
  await writeFile(contextPath, next, "utf8");
85
86
  }
86
87
  export async function probeFrontendTestEnvironment(input) {
88
+ const layout = input.layout ?? defaultFrontendTestLayout();
87
89
  const curl = input.curl ?? defaultCurlRunner;
88
- const contextPath = path.join(input.workspaceRoot, FRONTEND_TEST_CONTEXT_REL);
90
+ const contextRel = frontendTestContextRel(layout);
91
+ const contextPath = path.join(input.workspaceRoot, contextRel);
89
92
  let context;
90
93
  try {
91
94
  context = await readFile(contextPath, "utf8");
92
95
  }
93
96
  catch {
94
- return { ok: false, stderr: `missing ${FRONTEND_TEST_CONTEXT_REL}` };
97
+ return { ok: false, stderr: `missing ${contextRel}` };
95
98
  }
96
99
  const rawUrl = resolveBaseUrl(context);
97
100
  if (!rawUrl) {
@@ -139,7 +142,7 @@ export async function probeFrontendTestEnvironment(input) {
139
142
  ...(extra.hint ? { hint: extra.hint } : {}),
140
143
  ...(extra.baseUrlSource ? { baseUrlSource: extra.baseUrlSource } : {}),
141
144
  };
142
- await writeProbe(input.workspaceRoot, probe);
145
+ await writeProbe(input.workspaceRoot, probe, layout);
143
146
  const payload = {
144
147
  blockedReason: reason,
145
148
  baseUrl: probe.baseUrlRedacted,
@@ -218,7 +221,7 @@ export async function probeFrontendTestEnvironment(input) {
218
221
  curlExit: result.status,
219
222
  errorClass: null,
220
223
  };
221
- await writeProbe(input.workspaceRoot, probe);
224
+ await writeProbe(input.workspaceRoot, probe, layout);
222
225
  return {
223
226
  ok: true,
224
227
  stdout: `frontend-test-execution-v1 validated contextBaseUrl=${safe} source=${baseUrlSource} probe=reachable method=${used} httpStatus=${statusNum}`,
@@ -1,6 +1,7 @@
1
1
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { frontendTestResultContractSchema } from "./frontend-test-result-contract.js";
4
+ import { defaultFrontendTestLayout, } from "./frontend-test-layout.js";
4
5
  function escapeHtml(value) {
5
6
  return String(value ?? "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
6
7
  }
@@ -80,7 +81,8 @@ async function writePairAtomic(markdownPath, markdown, htmlPath, html) {
80
81
  export async function renderFrontendTestHtmlReport(input) {
81
82
  const resultPath = path.join(input.runDir, "contracts", "frontend-test-result.json");
82
83
  const result = frontendTestResultContractSchema.parse(JSON.parse(await readFile(resultPath, "utf8")));
83
- const outputDir = path.join(input.workspaceRoot, "testcase", "frontend", "reports");
84
+ const layout = input.layout ?? defaultFrontendTestLayout();
85
+ const outputDir = path.join(input.workspaceRoot, layout.reportsDir);
84
86
  const markdownPath = path.join(outputDir, "frontend-test-report.md");
85
87
  const htmlPath = path.join(outputDir, "frontend-test-report.html");
86
88
  const outcomeLabel = result.outcome === "passed" ? "测试通过" : result.outcome === "failed" ? "测试失败" : "测试未完成";
@@ -1,6 +1,7 @@
1
1
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { frontendTestResultContractSchema, } from "./frontend-test-result-contract.js";
4
+ import { defaultFrontendTestLayout, } from "./frontend-test-layout.js";
4
5
  function ratioMetric(numerator, denominator, threshold, label) {
5
6
  if (denominator === 0) {
6
7
  return { numerator, denominator, ratio: null, threshold, status: "unavailable", reason: `${label}-denominator-is-zero` };
@@ -169,7 +170,8 @@ export async function renderFrontendTestL5Report(input) {
169
170
  const resultPath = path.join(input.runDir, "contracts", "frontend-test-result.json");
170
171
  const result = frontendTestResultContractSchema.parse(JSON.parse(await readFile(resultPath, "utf8")));
171
172
  const metrics = computeFrontendL5ReportMetrics(result);
172
- const outputDir = path.join(input.workspaceRoot, "testcase", "frontend", "reports");
173
+ const layout = input.layout ?? defaultFrontendTestLayout();
174
+ const outputDir = path.join(input.workspaceRoot, layout.reportsDir);
173
175
  const markdownPath = path.join(outputDir, "frontend-test-l5-dashboard.md");
174
176
  const htmlPath = path.join(outputDir, "frontend-test-l5-dashboard.html");
175
177
  await writePairAtomic(markdownPath, renderMarkdown(result, metrics), htmlPath, renderHtml(result, metrics));