@tea-agent/loop-agent 0.38.1 → 0.38.3

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 (180) hide show
  1. package/CHANGELOG.md +126 -12
  2. package/README.md +2 -0
  3. package/bin/loop-agent.js +7 -3
  4. package/dist/application/dag/generate-task-dag.js +9 -0
  5. package/dist/application/task-lifecycle/advance.js +4 -0
  6. package/dist/build-stamp.json +3 -3
  7. package/dist/commands/init.js +2 -14
  8. package/dist/commands/task-advance.js +31 -0
  9. package/dist/executors/dag-pi-executor.js +99 -5
  10. package/dist/executors/pi-event-serializer.js +42 -1
  11. package/dist/executors/pi-executor.js +51 -0
  12. package/dist/executors/pi-extension-resolver.js +233 -0
  13. package/dist/executors/pi-sdk-executor.js +187 -59
  14. package/dist/executors/shell-executor.js +324 -16
  15. package/dist/executors/shell-write-guard.js +7 -0
  16. package/dist/governance/manifest-types.js +0 -2
  17. package/dist/shared/operator/capabilities.js +87 -22
  18. package/dist/task/config-types.js +22 -0
  19. package/dist/task/contract/constants.js +1 -0
  20. package/dist/task/contract/diff.js +26 -0
  21. package/dist/task/contract/project.js +5 -0
  22. package/dist/task/contract/schema.js +6 -1
  23. package/dist/task/source-prepare/build-draft.js +15 -0
  24. package/dist/worker/console/chat/deferred-turn.js +91 -0
  25. package/dist/worker/console/chat/pi-runtime.js +10 -5
  26. package/dist/worker/console/chat/routes.js +458 -47
  27. package/dist/worker/console/chat/semantic-activity.js +13 -13
  28. package/dist/worker/console/chat/session-store.js +146 -2
  29. package/dist/worker/console/harness-pi-model-matrix.js +2 -11
  30. package/dist/worker/console/operator-actions.js +5 -4
  31. package/dist/worker/console/pi-readiness.js +5 -4
  32. package/dist/worker/console/recovery-cta.js +4 -4
  33. package/dist/worker/console/recovery-error-copy.js +4 -4
  34. package/dist/worker/console/server.js +6 -1
  35. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-Hfsot6rP.js → abnfDiagram-N423BO3Z-C4kVYweA.js} +1 -1
  36. package/dist/worker/console/static/assets/{arc-YGwEASin.js → arc-Bj2M8iO5.js} +1 -1
  37. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-CmOM0F92.js → architectureDiagram-T3A2C74G-CLlXe4-6.js} +1 -1
  38. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-DDLuaHC5.js → blockDiagram-VBNYF7ZC-psEp5xH0.js} +1 -1
  39. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-B0Um43W4.js → c4Diagram-5PPSVZJV-DeKcOCGJ.js} +1 -1
  40. package/dist/worker/console/static/assets/channel-CPF4N7pf.js +1 -0
  41. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-DvBN4JZc.js → chunk-2GRJ4B5K-K0C744p1.js} +1 -1
  42. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-B1UVgW4E.js → chunk-2Q5K7J3B-xbNw4J9-.js} +1 -1
  43. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-Cf0GgXzX.js → chunk-5RXB4S5H-DjLaSDUO.js} +1 -1
  44. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-DbNA24sj.js → chunk-5VM5RSS4-CtiPlKel.js} +1 -1
  45. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-Bi_0Be06.js → chunk-6Q2QTUOP-DZtolir7.js} +1 -1
  46. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-C8pPGvRd.js → chunk-GF5L2VYU-CPcdNeew.js} +1 -1
  47. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-BitQY8Z-.js → chunk-JWPE2WC7-NgHc6V37.js} +1 -1
  48. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-U67GCiEo.js → chunk-KBJHAD2P-DQ_T-hg8.js} +1 -1
  49. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-IoJwXPBA.js → chunk-RYQCIY6F-DgLsxcVP.js} +1 -1
  50. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-YDZ_UHgE.js → chunk-XXDRQBXY-UrVoM9z1.js} +1 -1
  51. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-C2DwA_Y1.js +1 -0
  52. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-C2DwA_Y1.js +1 -0
  53. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-CUZgsiRC.js → cose-bilkent-JH36ORCC-CLkYvTb3.js} +1 -1
  54. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-COmCh4pT.js → cynefin-VYW2F7L2-BwT_xKrE.js} +1 -1
  55. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-Ta6FSm68.js → cynefinDiagram-MW4NZA55-Bi9tuzif.js} +1 -1
  56. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-UdcR-VW9.js → dagre-VZM6K2ZE-BCIqkWBV.js} +1 -1
  57. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-DybcO3Et.js → diagram-7IWD3JNH-Dip6l9_Z.js} +1 -1
  58. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-wErDFWLw.js → diagram-B4RE2ZJO-B-xkh_wK.js} +1 -1
  59. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-DkVHZWqz.js → diagram-LBJQPF4R-DZO0kTt_.js} +1 -1
  60. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-DdqWs7QN.js → diagram-Q27KOJAE-D6ZplNbZ.js} +1 -1
  61. package/dist/worker/console/static/assets/{diagram-UB23O5K3-C9dMVmDo.js → diagram-UB23O5K3-BPekbhoS.js} +1 -1
  62. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-B2T2WcBK.js → ebnfDiagram-BXEA7PRR-BtmaJpK5.js} +1 -1
  63. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-Bt9rjDFq.js → erDiagram-JOGREHBK-6DS0Xc44.js} +1 -1
  64. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-U8ic_tvi.js → flowDiagram-UKHOOZJN-CihnROSm.js} +1 -1
  65. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-BfQJdQoO.js → ganttDiagram-PKOTCBZU-CZC4zOE2.js} +1 -1
  66. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-CpMftH4O.js → gitGraphDiagram-DS77QQ5N-Dq1fhzGN.js} +1 -1
  67. package/dist/worker/console/static/assets/index-DTZOKgAn.js +404 -0
  68. package/dist/worker/console/static/assets/index-Ya5FE7cD.css +1 -0
  69. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-DzqWXNO_.js → infoDiagram-6WML65LV-DlSl4BGx.js} +1 -1
  70. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-CJmGQ5Yp.js → ishikawaDiagram-WSZJBQD7-snnzFl_U.js} +1 -1
  71. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-ChPtKFLz.js → journeyDiagram-NVQOT4AX-BDE7qpMx.js} +1 -1
  72. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-PRe7wB5u.js → kanban-definition-27J2QSJJ-CD2Ci04G.js} +1 -1
  73. package/dist/worker/console/static/assets/{linear-DpKX6QNp.js → linear-DdnapKIH.js} +1 -1
  74. package/dist/worker/console/static/assets/{mermaid.core-Dnl76b7Y.js → mermaid.core-BVjAT9b8.js} +5 -5
  75. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-S9PCXc3J.js → mindmap-definition-FAOFIHXS-D0yJk-zA.js} +1 -1
  76. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-Du8e-FSX.js → pegDiagram-VL7TDLO6-BVo9tFP-.js} +1 -1
  77. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-CY6OmHTc.js → pieDiagram-7S7Q4E2Y-DnOV-mZ_.js} +1 -1
  78. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-C65GPhpj.js → quadrantDiagram-CIZ2JOQS-CzUJo58i.js} +1 -1
  79. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-U6IUiTCL.js → railroadDiagram-AXF67PYL-Bvikrolh.js} +1 -1
  80. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-D-I_9Qhi.js → requirementDiagram-LRYGKXZP-DtaSVCap.js} +1 -1
  81. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-B57Fhed0.js → sankeyDiagram-W5VNT64P-C2c9A0wW.js} +1 -1
  82. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-CJOtiw21.js → sequenceDiagram-SI44F4Z6-DmXJcU7r.js} +1 -1
  83. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-FyX8zxSh.js → sizeCapture-X5ZJPWSS-B4GUFW92.js} +1 -1
  84. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-eF-pbs9t.js → stateDiagram-OKZ733FA-Dsad2MXf.js} +1 -1
  85. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-B8FB_cNk.js +1 -0
  86. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-CcfkA-Dq.js → swimlanes-SLNWSIFB-DGq48Fbi.js} +2 -2
  87. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-CrZYaWfx.js +8 -0
  88. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-DH8FjnOK.js → timeline-definition-Z64GVDOM-CSSi6OFf.js} +1 -1
  89. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-beoTagJg.js → vennDiagram-T6HMQDX7-BqyzPrWv.js} +1 -1
  90. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CQSfw4cK.js → wardleyDiagram-T6FBY63Y-DlznVb6S.js} +1 -1
  91. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-CQgzQ6jn.js → xychartDiagram-ELKLHX3M-CfBcgJ_K.js} +1 -1
  92. package/dist/worker/console/static/index.html +2 -2
  93. package/dist/worker/console/static-src/app/console-types.js +0 -12
  94. package/dist/worker/console/static-src/app/useOperatorActions.js +1 -1
  95. package/dist/worker/console/static-src/app/useRecoveryActions.js +2 -2
  96. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +38 -1
  97. package/dist/worker/console/static-src/operator-chat/open-preview-in-browser.js +328 -0
  98. package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +8 -1
  99. package/dist/worker/console/static-src/operator-chat/turn-submission.js +42 -0
  100. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +3 -0
  101. package/dist/worker/console/static-src/operator-chat/useChatStream.js +204 -11
  102. package/dist/worker/console/static-src/operator-chat/useChatThread.js +23 -1
  103. package/dist/worker/observe/node-input.js +72 -3
  104. package/dist/worker/observe/static/dag-history-labels.js +4 -0
  105. package/dist/worker/observe/static/state.js +2 -2
  106. package/dist/worker/observe/static/views/dag-inspector.js +51 -0
  107. package/dist/worker/observe/static/views/session-timeline.js +18 -5
  108. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +809 -53
  109. package/dist/workflows/dag/backend-test-case-manifest.js +4 -0
  110. package/dist/workflows/dag/backend-test-execution-contract.js +18 -9
  111. package/dist/workflows/dag/backend-test-gap-fill.js +205 -0
  112. package/dist/workflows/dag/backend-test-intake-context.js +32 -0
  113. package/dist/workflows/dag/backend-test-layout.js +133 -0
  114. package/dist/workflows/dag/backend-test-markdown-workflow.js +167 -32
  115. package/dist/workflows/dag/backend-test-module-stem.js +5 -0
  116. package/dist/workflows/dag/backend-test-pytest-collection.js +368 -33
  117. package/dist/workflows/dag/backend-test-result-contract.js +1 -1
  118. package/dist/workflows/dag/backend-test-scenario-param.js +884 -140
  119. package/dist/workflows/dag/backend-test-scenario-partitions.js +321 -0
  120. package/dist/workflows/dag/backend-test-writer-completeness.js +100 -56
  121. package/dist/workflows/dag/contract-output-registry.js +15 -0
  122. package/dist/workflows/dag/contract-validator-registrations.js +2 -1
  123. package/dist/workflows/dag/convergence/controller.js +141 -0
  124. package/dist/workflows/dag/dynamic-runtime/map.js +24 -8
  125. package/dist/workflows/dag/dynamic-runtime/shared.js +1 -1
  126. package/dist/workflows/dag/failure-category.js +4 -0
  127. package/dist/workflows/dag/frontend-implementation-contract.js +355 -26
  128. package/dist/workflows/dag/frontend-prewrite-gate.js +96 -84
  129. package/dist/workflows/dag/frontend-test-case-quality.js +5 -13
  130. package/dist/workflows/dag/frontend-test-environment-probe.js +227 -0
  131. package/dist/workflows/dag/frontend-test-markdown.js +61 -0
  132. package/dist/workflows/dag/frontend-test-result-contract.js +10 -18
  133. package/dist/workflows/dag/frontend-test-standard-scenarios.js +68 -0
  134. package/dist/workflows/dag/init-hybrid.js +720 -177
  135. package/dist/workflows/dag/node-execution.js +164 -26
  136. package/dist/workflows/dag/prompt.js +60 -1
  137. package/dist/workflows/dag/rerun-feedback.js +212 -0
  138. package/dist/workflows/dag/rerun-plan.js +112 -27
  139. package/dist/workflows/dag/rerun-run.js +3 -0
  140. package/dist/workflows/dag/rerun-task.js +61 -2
  141. package/dist/workflows/dag/retry-policy.js +18 -0
  142. package/dist/workflows/dag/structured-output-repair.js +712 -0
  143. package/dist/workflows/dag/types.js +121 -0
  144. package/dist/workflows/dag/validate.js +41 -1
  145. package/docs/operations/local-development-environment.md +1 -5
  146. package/docs/skills/vetted-skill-registry.md +14 -0
  147. package/docs/templates/README.md +1 -1
  148. package/docs/templates/agent-dag.schema.json +44 -0
  149. package/docs/templates/backend-test-dag.json +60 -35
  150. package/docs/templates/frontend-test-dag.json +8 -10
  151. package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +1 -1
  152. package/docs/templates/harness.schema.json +0 -8
  153. package/harness.json +1 -8
  154. package/package.json +8 -4
  155. package/skills/analyze-product-requirements/SKILL.md +8 -5
  156. package/skills/analyze-product-requirements/references/acceptance-criteria.md +1 -1
  157. package/skills/analyze-product-requirements/references/clarification-and-knowledge.md +10 -6
  158. package/skills/analyze-product-requirements/references/example.md +50 -0
  159. package/skills/analyze-product-requirements/references/forward-test-cases.md +11 -7
  160. package/skills/analyze-product-requirements/references/kb-integration.md +5 -5
  161. package/skills/analyze-product-requirements/references/product-analysis-schema.md +8 -4
  162. package/skills/analyze-product-requirements/references/product-requirement-schema.md +2 -2
  163. package/skills/analyze-product-requirements/references/requirement-clarification-schema.md +1 -1
  164. package/skills/analyze-product-requirements/scripts/test-validators.mjs +11 -1
  165. package/skills/analyze-product-requirements/scripts/validate-product-analysis.mjs +20 -1
  166. package/skills/analyze-product-requirements/scripts/validate-requirement-clarification.mjs +9 -0
  167. package/skills/codebase-scout/SKILL.md +1 -1
  168. package/skills/frontend-implementation/references/node-contracts.md +2 -2
  169. package/skills/improve-codebase-architecture/SKILL.md +81 -0
  170. package/skills/improve-codebase-architecture/deepening.md +37 -0
  171. package/skills/improve-codebase-architecture/html-report.md +123 -0
  172. package/skills/improve-codebase-architecture/interface-design.md +44 -0
  173. package/skills/improve-codebase-architecture/language.md +53 -0
  174. package/dist/worker/console/static/assets/channel-Dp_2Aqf1.js +0 -1
  175. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL--CODy37j.js +0 -1
  176. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE--CODy37j.js +0 -1
  177. package/dist/worker/console/static/assets/index-BA5-N5ER.css +0 -1
  178. package/dist/worker/console/static/assets/index-DKPXBrdc.js +0 -325
  179. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-B-mvnLEc.js +0 -1
  180. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-JtpBmIv4.js +0 -8
@@ -1,13 +1,15 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { readFile, stat } from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import { z } from "zod";
4
5
  import { isOpenspecSpecFilePath } from "../../shared/openspec-spec.js";
5
6
  import { writeTextArtifactFile } from "../../infrastructure/harness/artifact-store.js";
6
7
  import { countExcludedGovernanceSpecFiles, resolveOpenspecGovernanceRoot, } from "../../task/frontend-project-capability.js";
7
- import { analyzeFrontendImplementationContract, writeFrontendImplementationContractArtifact, writeDeterministicJsonArtifact, assertFrontendSourceBindingFresh, deterministicSha256, sha256Hex, serializeDeterministicJson, applyFrontendImplementationContractPatch, extractFrontendImplementationJson, FrontendContractFailure, frontendNormalizationActionSchema, } from "./frontend-implementation-contract.js";
8
+ import { analyzeFrontendImplementationContract, writeFrontendImplementationContractArtifact, writeDeterministicJsonArtifact, assertFrontendSourceBindingFresh, deterministicSha256, sha256Hex, serializeDeterministicJson, FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID, FrontendContractFailure, frontendNormalizationActionSchema, } from "./frontend-implementation-contract.js";
8
9
  import { renderFrontendPlanMarkdown } from "./frontend-plan-render.js";
9
10
  import { captureFrontendWorktreeBaseline } from "./frontend-worktree-diff.js";
10
11
  import { pathMatchesPattern } from "../../shared/git-progress.js";
12
+ import { normalizeVerdictCandidateLine } from "./dynamic-runtime/shared.js";
11
13
  export const FRONTEND_PREWRITE_RESULT_SCHEMA_ID = "frontend-prewrite-result-v1";
12
14
  export const FRONTEND_PREWRITE_RESULT_ARTIFACT_NAME = "frontend-prewrite-result.json";
13
15
  /** Canonical contract serialization must stay at or below this byte size. */
@@ -39,6 +41,7 @@ export const frontendPrewriteFailureCodeSchema = z.enum([
39
41
  "component-spec-not-cited",
40
42
  "component-choices-missing",
41
43
  "contract-too-large",
44
+ "revision-patch-unmergeable",
42
45
  ]);
43
46
  export const frontendPrewriteResultV1Schema = z
44
47
  .object({
@@ -155,32 +158,80 @@ async function selectNode(runDir, primary, fallbacks) {
155
158
  }
156
159
  throw new Error(`frontend prewrite gate no FINISHED candidate: ${rejections.join("; ")}`);
157
160
  }
158
- async function readNodeText(runDir, nodeId) {
159
- const record = JSON.parse(await readFile(path.join(runDir, `${nodeId}.json`), "utf8"));
161
+ async function readFinishedNodeRecord(runDir, nodeId) {
162
+ let record;
163
+ try {
164
+ record = JSON.parse(await readFile(path.join(runDir, `${nodeId}.json`), "utf8"));
165
+ }
166
+ catch (error) {
167
+ throw new Error(`frontend prewrite gate cannot parse node record ${nodeId}: ${error instanceof Error ? error.message : String(error)}`);
168
+ }
160
169
  if (record.status !== "FINISHED") {
161
170
  throw new Error(`frontend prewrite gate requires FINISHED node ${nodeId} (got ${String(record.status)})`);
162
171
  }
163
- const text = [record.assistantText, record.stdout]
164
- .filter((value) => Boolean(value?.trim()))
165
- .join("\n");
172
+ return record;
173
+ }
174
+ async function readNodeText(runDir, nodeId) {
175
+ const record = await readFinishedNodeRecord(runDir, nodeId);
176
+ const text = record.assistantText?.trim() || record.stdout?.trim() || "";
166
177
  if (!text)
167
178
  throw new Error(`frontend prewrite gate empty output from ${nodeId}`);
168
179
  return text;
169
180
  }
170
- async function readNodeRawText(runDir, nodeId) {
171
- const record = JSON.parse(await readFile(path.join(runDir, `${nodeId}.json`), "utf8"));
172
- return record.assistantText ?? record.stdout ?? "";
181
+ async function readCanonicalContractFromNode(runDir, nodeId) {
182
+ const record = await readFinishedNodeRecord(runDir, nodeId);
183
+ if (!record.structuredArtifactPath) {
184
+ throw new Error(`frontend prewrite gate missing structured artifact for ${nodeId}`);
185
+ }
186
+ const artifactPath = path.isAbsolute(record.structuredArtifactPath)
187
+ ? record.structuredArtifactPath
188
+ : path.join(runDir, record.structuredArtifactPath);
189
+ const resolvedRun = path.resolve(runDir);
190
+ const resolvedArtifact = path.resolve(artifactPath);
191
+ const relative = path.relative(resolvedRun, resolvedArtifact);
192
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
193
+ throw new Error(`frontend prewrite gate structured artifact escapes runDir: ${record.structuredArtifactPath}`);
194
+ }
195
+ const bytes = await readFile(resolvedArtifact);
196
+ const digest = createHash("sha256").update(bytes).digest("hex");
197
+ if (record.structuredArtifactSha256 &&
198
+ record.structuredArtifactSha256 !== digest) {
199
+ throw new Error(`frontend prewrite gate structured artifact hash mismatch for ${nodeId}`);
200
+ }
201
+ if (record.structuredArtifactSchemaId &&
202
+ record.structuredArtifactSchemaId !== FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID) {
203
+ throw new Error(`frontend prewrite gate structured artifact schema mismatch for ${nodeId}: ${record.structuredArtifactSchemaId}`);
204
+ }
205
+ const contractText = bytes.toString("utf8");
206
+ let citationsText = "";
207
+ try {
208
+ const citationsRaw = await readFile(path.join(path.dirname(resolvedArtifact), "openspec-citations.json"), "utf8");
209
+ try {
210
+ const parsed = JSON.parse(citationsRaw);
211
+ citationsText =
212
+ typeof parsed.block === "string" ? parsed.block : citationsRaw;
213
+ }
214
+ catch {
215
+ citationsText = citationsRaw;
216
+ }
217
+ }
218
+ catch (error) {
219
+ if (error.code !== "ENOENT")
220
+ throw error;
221
+ }
222
+ return { contractText, citationsText, rawSha256: digest };
173
223
  }
174
224
  function firstNonEmptyVerdictLine(text) {
175
225
  const lines = text
176
226
  .split(/\r?\n/)
177
227
  .map((line) => line.trim())
178
228
  .filter(Boolean);
179
- const normalize = (line) => {
180
- const emphasized = line.match(/^(?:`{1,3}|\*{1,3})\s*(VERDICT:[^`*]+?)\s*(?:`{1,3}|\*{1,3})$/);
181
- return (emphasized?.[1] ?? line).trim();
182
- };
183
- const normalizedLines = lines.map(normalize);
229
+ // Emphasis stripping (backtick + asterisk) is shared with the runIf
230
+ // selector and node-level output-protocol validation via
231
+ // normalizeVerdictCandidateLine. The gate keeps its own loose
232
+ // pass/request-revision normalization below (prefix match + trailing
233
+ // prose tolerance) for deterministic fail-closed behavior.
234
+ const normalizedLines = lines.map(normalizeVerdictCandidateLine);
184
235
  const first = normalizedLines[0] ?? "";
185
236
  // The prompt requires VERDICT to be the first non-empty line, but model
186
237
  // output can still prepend a summary. Keep the protocol strict in the
@@ -397,7 +448,30 @@ export async function runFrontendPrewriteGate(input) {
397
448
  // remains an ERROR and is intentionally not classified into prewrite-result.
398
449
  const planNodeId = await selectNode(input.runDir, input.config.planFromNodeId, input.config.planFallbackFromNodeIds);
399
450
  const reviewNodeId = await selectNode(input.runDir, input.config.reviewFromNodeId, input.config.reviewFallbackFromNodeIds);
400
- const candidateRawSha256 = sha256Hex(await readNodeRawText(input.runDir, planNodeId));
451
+ let planArtifact;
452
+ try {
453
+ planArtifact = await readCanonicalContractFromNode(input.runDir, planNodeId);
454
+ }
455
+ catch (error) {
456
+ return finalizePrewrite(input, {
457
+ planNodeId,
458
+ reviewNodeId,
459
+ verdict: "",
460
+ candidateRawSha256: sha256Hex(""),
461
+ candidateJsonSha256: null,
462
+ canonicalSha256: null,
463
+ normalizationActions: [],
464
+ mockStrategy: undefined,
465
+ artifact: undefined,
466
+ failureReason: error instanceof Error ? error.message : String(error),
467
+ failureCode: "plan-text-unreadable",
468
+ classification: "retryable-invalid",
469
+ openspecReadPaths: [],
470
+ openspecCandidatePaths: input.config.openspecCandidatePaths ?? [],
471
+ openspecPolicy: input.config.openspecPolicy ?? "scan-strict",
472
+ });
473
+ }
474
+ const candidateRawSha256 = planArtifact.rawSha256;
401
475
  const openspecPolicy = input.config.openspecPolicy ?? "scan-strict";
402
476
  const basePending = {
403
477
  planNodeId,
@@ -416,18 +490,9 @@ export async function runFrontendPrewriteGate(input) {
416
490
  openspecCandidatePaths: input.config.openspecCandidatePaths ?? [],
417
491
  openspecPolicy,
418
492
  };
419
- let planText;
420
- try {
421
- planText = await readNodeText(input.runDir, planNodeId);
422
- }
423
- catch (error) {
424
- return finalizePrewrite(input, {
425
- ...basePending,
426
- classification: "retryable-invalid",
427
- failureReason: error instanceof Error ? error.message : String(error),
428
- failureCode: "plan-text-unreadable",
429
- });
430
- }
493
+ const planText = [planArtifact.contractText, planArtifact.citationsText]
494
+ .filter(Boolean)
495
+ .join("\n");
431
496
  let reviewText;
432
497
  try {
433
498
  reviewText = await readNodeText(input.runDir, reviewNodeId);
@@ -450,59 +515,7 @@ export async function runFrontendPrewriteGate(input) {
450
515
  failureCode: "verdict-not-pass",
451
516
  });
452
517
  }
453
- // revisionPatch mode: the effective plan node emits an RFC 7386 merge-patch
454
- // delta against the original contract node (planFallbackFromNodeIds[0]).
455
- // Legacy DAGs without revisionPatch (undefined) keep full-contract semantics.
456
- const revisionPatchMode = input.config.revisionPatch === true &&
457
- planNodeId === input.config.planFromNodeId &&
458
- input.config.planFallbackFromNodeIds.length > 0;
459
- let effectiveContractText = null;
460
- if (revisionPatchMode) {
461
- const originalNodeId = input.config.planFallbackFromNodeIds[0];
462
- let originalText;
463
- try {
464
- originalText = await readNodeText(input.runDir, originalNodeId);
465
- }
466
- catch (error) {
467
- return finalizePrewrite(input, {
468
- ...basePending,
469
- verdict,
470
- classification: "retryable-invalid",
471
- failureReason: `frontend prewrite gate revisionPatch mode requires original contract node ${originalNodeId}: ${error instanceof Error ? error.message : String(error)}`,
472
- failureCode: "contract-invalid",
473
- });
474
- }
475
- let originalValue;
476
- let patchValue;
477
- try {
478
- originalValue = extractFrontendImplementationJson(originalText);
479
- patchValue = extractFrontendImplementationJson(planText);
480
- }
481
- catch (error) {
482
- return finalizePrewrite(input, {
483
- ...basePending,
484
- verdict,
485
- classification: "retryable-invalid",
486
- failureReason: `frontend prewrite gate revisionPatch mode failed to parse contract/patch: ${error instanceof Error ? error.message : String(error)}`,
487
- failureCode: "contract-invalid",
488
- });
489
- }
490
- let merged;
491
- try {
492
- merged = applyFrontendImplementationContractPatch(originalValue, patchValue);
493
- }
494
- catch (error) {
495
- return finalizePrewrite(input, {
496
- ...basePending,
497
- verdict,
498
- classification: "retryable-invalid",
499
- failureReason: `frontend prewrite gate revisionPatch apply failed: ${error instanceof Error ? error.message : String(error)}`,
500
- failureCode: "contract-invalid",
501
- });
502
- }
503
- effectiveContractText = serializeDeterministicJson(merged);
504
- }
505
- const requirementIdSearchText = effectiveContractText ?? planText;
518
+ const requirementIdSearchText = planArtifact.contractText;
506
519
  const missingIds = input.config.requiredRequirementIds.filter((id) => !requirementIdSearchText.includes(id));
507
520
  if (missingIds.length > 0) {
508
521
  return finalizePrewrite(input, {
@@ -550,14 +563,13 @@ export async function runFrontendPrewriteGate(input) {
550
563
  workspaceRoot,
551
564
  });
552
565
  }
553
- // Candidate -> canonical conversion. The gate consumes the selected
554
- // plan/revision assistant output directly and is the only conversion point.
566
+ // Candidate -> canonical conversion. The gate consumes the producing node's
567
+ // hash-bound canonical artifact and never re-merges plan/revision stdout.
555
568
  let analysis;
556
569
  try {
557
570
  analysis = await analyzeFrontendImplementationContract({
558
571
  runDir: input.runDir,
559
- fromNodeId: effectiveContractText ? undefined : planNodeId,
560
- rawContractText: effectiveContractText ?? undefined,
572
+ rawContractText: planArtifact.contractText,
561
573
  sourceBinding: input.sourceBinding,
562
574
  });
563
575
  }
@@ -1,25 +1,17 @@
1
1
  import { readFile, writeFile, mkdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { markdownSection } from "./frontend-test-markdown.js";
3
4
  const CASE_ID = /^FE-[A-Za-z0-9]+-\d{3}-(?:core|boundary|flow|backend)$/i;
4
5
  const AC_ID = /^AC(?:-[A-Z0-9]+)+$/i;
5
6
  const PLACEHOLDER = /\b(?:TODO|TBD|FIXME)\b|结果正确|页面正常|按实际情况处理|验证成功/i;
6
7
  const SECTION_ALIASES = {
7
8
  purpose: ["Test Purpose", "测试目的", "测试场景"],
8
9
  source: ["Source References", "需求依据"],
9
- preconditions: ["Preconditions", "前置条件"],
10
- steps: ["Steps", "操作步骤"],
10
+ preconditions: ["Preconditions", "前置条件", "前置条件与重置"],
11
+ steps: ["Test Steps", "测试步骤", "Steps", "操作步骤"],
11
12
  expected: ["Expected Results", "预期结果"],
12
13
  automation: ["Automation Notes", "自动化映射", "自动化说明"],
13
14
  };
14
- function section(body, names) {
15
- const marker = new RegExp(`^###\\s+(?:${names.map((v) => v.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")).join("|")})\\s*$`, "mi");
16
- const hit = marker.exec(body);
17
- if (!hit)
18
- return "";
19
- const rest = body.slice(hit.index + hit[0].length);
20
- const next = /^###\s+/m.exec(rest);
21
- return rest.slice(0, next?.index ?? rest.length).trim();
22
- }
23
15
  function hasList(value) { return /^\s*(?:\d+[.)]|[-*+])\s+\S+/m.test(value); }
24
16
  function hasAssertion(value) { return /(?:status|状态|包含|显示|等于|为|可见|不可见|跳转|错误|成功|失败|should|expect|assert|must)/i.test(value); }
25
17
  export async function validateFrontendCaseContent(input) {
@@ -69,9 +61,9 @@ export async function validateFrontendCaseContent(input) {
69
61
  if (PLACEHOLDER.test(body))
70
62
  findings.push({ ruleId: "placeholder-wording", caseId: id, detail: "case contains placeholder or non-assertable wording" });
71
63
  for (const [key, names] of Object.entries(SECTION_ALIASES))
72
- if (!section(body, names))
64
+ if (!markdownSection(body, names))
73
65
  findings.push({ ruleId: `missing-${key}`, caseId: id, detail: `missing section: ${names.join(" or ")}` });
74
- const steps = section(body, SECTION_ALIASES.steps), expected = section(body, SECTION_ALIASES.expected);
66
+ const steps = markdownSection(body, SECTION_ALIASES.steps), expected = markdownSection(body, SECTION_ALIASES.expected);
75
67
  if (!hasList(steps))
76
68
  findings.push({ ruleId: "unstructured-steps", caseId: id, detail: "steps should contain numbered or bulleted executable actions" });
77
69
  if (!hasList(expected) || !hasAssertion(expected))
@@ -0,0 +1,227 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ export const FRONTEND_TEST_CONTEXT_REL = "testcase/frontend/rag/context.md";
5
+ export const FRONTEND_TEST_PROBE_REL = "testcase/frontend/rag/environment-probe.json";
6
+ const CONNECTION_REFUSED_HINT = "被测服务未在 baseUrl 监听(connection refused)。请先启动本地前端(例如 scripts/serve.sh 或项目约定的 npm start),确认可访问后再从 materialize-frontend-test-execution-shell 续跑。";
7
+ export function classifyFrontendBaseUrlProbeFailure(input) {
8
+ if (input.spawnError)
9
+ return { errorClass: "spawn-error" };
10
+ const exit = input.curlExit ?? 0;
11
+ if (exit === 7) {
12
+ return { errorClass: "connection-refused", hint: CONNECTION_REFUSED_HINT };
13
+ }
14
+ if (exit === 6) {
15
+ return {
16
+ errorClass: "dns-unresolved",
17
+ hint: "无法解析 baseUrl 主机名。确认 context.md 中的地址可在本机解析,或改用 127.0.0.1。",
18
+ };
19
+ }
20
+ if (exit === 28) {
21
+ return {
22
+ errorClass: "connect-timeout",
23
+ hint: "连接 baseUrl 超时。确认被测服务已启动且端口可访问。",
24
+ };
25
+ }
26
+ if (exit !== 0)
27
+ return { errorClass: `curl-exit-${exit}` };
28
+ if (input.httpStatus && Number.isFinite(input.httpStatus)) {
29
+ return { errorClass: `http-${input.httpStatus}` };
30
+ }
31
+ return { errorClass: "unreachable" };
32
+ }
33
+ function defaultCurlRunner(args) {
34
+ const result = spawnSync("curl", [...args], { encoding: "utf8" });
35
+ return {
36
+ status: result.status,
37
+ stdout: result.stdout ?? "",
38
+ stderr: result.stderr ?? "",
39
+ error: result.error,
40
+ };
41
+ }
42
+ function redactUrl(value) {
43
+ try {
44
+ const parsed = new URL(value);
45
+ parsed.username = "";
46
+ parsed.password = "";
47
+ parsed.search = "";
48
+ return parsed.toString();
49
+ }
50
+ catch {
51
+ return value.replace(/\/\/[^@\s]+@/g, "//");
52
+ }
53
+ }
54
+ function resolveBaseUrl(context) {
55
+ const patterns = [
56
+ /baseUrl\s*[:=]\s*["'`]?((?:https?):\/\/[^\s"'`<>]+)/i,
57
+ /base[-_ ]url\s*[:=]\s*["'`]?((?:https?):\/\/[^\s"'`<>]+)/i,
58
+ /playwright-cli open --browser=chrome\s+((?:https?):\/\/[^\s"'`<>]+)/i,
59
+ /(https?:\/\/(?:localhost|127\.0\.0\.1)[^\s)}\],"']*)/i,
60
+ ];
61
+ for (const pattern of patterns) {
62
+ const match = context.match(pattern);
63
+ if (match?.[1])
64
+ return match[1].replace(/[)}\],"'.`]+$/g, "");
65
+ }
66
+ return null;
67
+ }
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);
71
+ await mkdir(path.dirname(probePath), { recursive: true });
72
+ await writeFile(probePath, `${JSON.stringify(probe, null, 2)}\n`, "utf8");
73
+ let context = "";
74
+ try {
75
+ context = await readFile(contextPath, "utf8");
76
+ }
77
+ catch {
78
+ context = "";
79
+ }
80
+ const line = `environmentProbe: ${probe.status}${probe.blockedReason ? ` (${probe.blockedReason})` : ""}`;
81
+ const next = /environmentProbe\s*[:=]/i.test(context)
82
+ ? context.replace(/environmentProbe\s*[:=]\s*.*/i, line)
83
+ : `${context.trimEnd()}\n\n${line}\n`;
84
+ await writeFile(contextPath, next, "utf8");
85
+ }
86
+ export async function probeFrontendTestEnvironment(input) {
87
+ const curl = input.curl ?? defaultCurlRunner;
88
+ const contextPath = path.join(input.workspaceRoot, FRONTEND_TEST_CONTEXT_REL);
89
+ let context;
90
+ try {
91
+ context = await readFile(contextPath, "utf8");
92
+ }
93
+ catch {
94
+ return { ok: false, stderr: `missing ${FRONTEND_TEST_CONTEXT_REL}` };
95
+ }
96
+ const rawUrl = resolveBaseUrl(context);
97
+ if (!rawUrl) {
98
+ return {
99
+ ok: false,
100
+ stderr: "frontend-test preflight missing absolute baseUrl from context.md",
101
+ };
102
+ }
103
+ let parsed;
104
+ try {
105
+ parsed = new URL(rawUrl);
106
+ }
107
+ catch {
108
+ return { ok: false, stderr: `baseUrl must be absolute http(s): ${rawUrl}` };
109
+ }
110
+ if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
111
+ parsed.username ||
112
+ parsed.password ||
113
+ parsed.search ||
114
+ parsed.hash) {
115
+ return {
116
+ ok: false,
117
+ stderr: `unsafe baseUrl from context.md: ${redactUrl(rawUrl)}`,
118
+ };
119
+ }
120
+ if (/(?:^|\.)(?:www\.)?[^.]*?(?:prod|production)/i.test(parsed.hostname)) {
121
+ return {
122
+ ok: false,
123
+ stderr: `production URL forbidden: ${redactUrl(rawUrl)}`,
124
+ };
125
+ }
126
+ const baseUrl = parsed.toString();
127
+ const safe = redactUrl(baseUrl);
128
+ const sourceMatch = context.match(/baseUrlSource\s*[:=]\s*([^\r\n]+)/i);
129
+ const baseUrlSource = sourceMatch ? sourceMatch[1].trim() : "context.md";
130
+ const failBlocked = async (reason, extra) => {
131
+ const probe = {
132
+ status: "unreachable",
133
+ blockedReason: reason,
134
+ baseUrlRedacted: extra.baseUrlRedacted ?? safe,
135
+ httpStatus: extra.httpStatus ?? null,
136
+ method: extra.method ?? null,
137
+ curlExit: extra.curlExit ?? null,
138
+ errorClass: extra.errorClass ?? null,
139
+ ...(extra.hint ? { hint: extra.hint } : {}),
140
+ ...(extra.baseUrlSource ? { baseUrlSource: extra.baseUrlSource } : {}),
141
+ };
142
+ await writeProbe(input.workspaceRoot, probe);
143
+ const payload = {
144
+ blockedReason: reason,
145
+ baseUrl: probe.baseUrlRedacted,
146
+ httpStatus: probe.httpStatus,
147
+ errorClass: probe.errorClass,
148
+ ...(probe.hint ? { hint: probe.hint } : {}),
149
+ };
150
+ return {
151
+ ok: false,
152
+ stderr: `frontend-test preflight blocked: ${JSON.stringify(payload)}`,
153
+ probe,
154
+ };
155
+ };
156
+ const curlCheck = curl(["--version"]);
157
+ if (curlCheck.error || curlCheck.status !== 0) {
158
+ return failBlocked("curl-unavailable", {
159
+ baseUrlRedacted: safe,
160
+ errorClass: "curl-missing",
161
+ });
162
+ }
163
+ const probeMethod = (method) => curl([
164
+ "-sS",
165
+ "-o",
166
+ "/dev/null",
167
+ "-w",
168
+ "%{http_code}",
169
+ "--connect-timeout",
170
+ "3",
171
+ "--max-time",
172
+ "8",
173
+ "-X",
174
+ method,
175
+ "-L",
176
+ "--max-redirs",
177
+ "3",
178
+ "--http1.1",
179
+ "--proto-redir",
180
+ "=http,https",
181
+ safe,
182
+ ]);
183
+ let used = "HEAD";
184
+ let result = probeMethod("HEAD");
185
+ let code = String(result.stdout || "").trim();
186
+ let statusNum = Number.parseInt(code, 10);
187
+ const headRejected = result.status !== 0 || !statusNum || statusNum === 405 || statusNum === 501;
188
+ if (headRejected) {
189
+ used = "GET";
190
+ result = probeMethod("GET");
191
+ code = String(result.stdout || "").trim();
192
+ statusNum = Number.parseInt(code, 10);
193
+ }
194
+ const ok = statusNum >= 200 && statusNum < 400;
195
+ if (!ok) {
196
+ const classified = classifyFrontendBaseUrlProbeFailure({
197
+ spawnError: Boolean(result.error),
198
+ curlExit: result.status,
199
+ httpStatus: Number.isFinite(statusNum) ? statusNum : null,
200
+ });
201
+ return failBlocked("frontend-base-url-unreachable", {
202
+ baseUrlRedacted: safe,
203
+ httpStatus: Number.isFinite(statusNum) ? statusNum : null,
204
+ method: used,
205
+ curlExit: result.status,
206
+ errorClass: classified.errorClass,
207
+ hint: classified.hint,
208
+ });
209
+ }
210
+ const probe = {
211
+ status: "reachable",
212
+ blockedReason: null,
213
+ baseUrl: safe,
214
+ baseUrlRedacted: safe,
215
+ baseUrlSource,
216
+ httpStatus: statusNum,
217
+ method: used,
218
+ curlExit: result.status,
219
+ errorClass: null,
220
+ };
221
+ await writeProbe(input.workspaceRoot, probe);
222
+ return {
223
+ ok: true,
224
+ stdout: `frontend-test-execution-v1 validated contextBaseUrl=${safe} source=${baseUrlSource} probe=reachable method=${used} httpStatus=${statusNum}`,
225
+ probe,
226
+ };
227
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Bounded ATX section/list parsing for frontend-test case Markdown.
3
+ * Generator cases use `##` headings; older fixtures use `###`.
4
+ */
5
+ const ATX_HEADING = /^(#{1,3})\s+(\S.*)$/;
6
+ function headingMatches(title, names) {
7
+ const normalized = title.trim();
8
+ return names.some((name) => {
9
+ if (normalized === name)
10
+ return true;
11
+ return (normalized.startsWith(name) &&
12
+ (normalized.length === name.length ||
13
+ /[\s::与((-]/.test(normalized.charAt(name.length))));
14
+ });
15
+ }
16
+ export function markdownSection(body, names) {
17
+ const lines = body.replace(/\r\n/g, "\n").split("\n");
18
+ let start = -1;
19
+ let startLevel = 0;
20
+ for (let index = 0; index < lines.length; index += 1) {
21
+ const match = ATX_HEADING.exec(lines[index]);
22
+ if (!match)
23
+ continue;
24
+ if (!headingMatches(match[2], names))
25
+ continue;
26
+ start = index;
27
+ startLevel = match[1].length;
28
+ break;
29
+ }
30
+ if (start < 0)
31
+ return "";
32
+ const collected = [];
33
+ for (let index = start + 1; index < lines.length; index += 1) {
34
+ const match = ATX_HEADING.exec(lines[index]);
35
+ if (match && match[1].length <= startLevel)
36
+ break;
37
+ collected.push(lines[index]);
38
+ }
39
+ return collected.join("\n").trim();
40
+ }
41
+ export function markdownList(value) {
42
+ const items = [];
43
+ for (const raw of value.replace(/\r\n/g, "\n").split("\n")) {
44
+ const listed = raw.match(/^\s*(?:\d+[.)]|[-*+])\s+(.*)$/);
45
+ if (listed) {
46
+ const text = listed[1].trim();
47
+ if (text)
48
+ items.push(text);
49
+ continue;
50
+ }
51
+ const continuation = raw.match(/^\s{2,}(\S.*)$/);
52
+ if (continuation && items.length > 0) {
53
+ items[items.length - 1] = `${items[items.length - 1]} ${continuation[1].trim()}`;
54
+ }
55
+ }
56
+ return items;
57
+ }
58
+ export function markdownH1Title(body) {
59
+ const match = /^#\s+(\S.*)$/m.exec(body.replace(/\r\n/g, "\n"));
60
+ return match?.[1]?.trim() ?? "";
61
+ }
@@ -5,6 +5,7 @@ import { z } from "zod";
5
5
  import { writeDagRunJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
6
6
  import { readPlaywrightCliReceipts, summarizePlaywrightCliReceipts, } from "../../executors/pi-playwright-cli-tool.js";
7
7
  import { validateFrontendCaseContent } from "./frontend-test-case-quality.js";
8
+ import { markdownH1Title, markdownList, markdownSection, } from "./frontend-test-markdown.js";
8
9
  export const FRONTEND_TEST_RESULT_SCHEMA_ID = "frontend-test-result-v1";
9
10
  const safeRelativePathSchema = z.string().min(1).refine((value) => !path.posix.isAbsolute(value) &&
10
11
  !path.win32.isAbsolute(value) &&
@@ -220,34 +221,25 @@ async function loadCaseBrowserReceiptSummary(input) {
220
221
  function sha256(content) {
221
222
  return createHash("sha256").update(content).digest("hex");
222
223
  }
223
- function markdownSection(body, names) {
224
- const escaped = names.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
225
- const marker = new RegExp(`^###\\s+(?:${escaped})\\s*$`, "mi");
226
- const hit = marker.exec(body);
227
- if (!hit)
228
- return "";
229
- const rest = body.slice(hit.index + hit[0].length);
230
- const next = /^###\s+/m.exec(rest);
231
- return rest.slice(0, next?.index ?? rest.length).trim();
232
- }
233
- function markdownList(value) {
234
- const items = value.split(/\r?\n/).map((line) => line.replace(/^\s*(?:\d+[.)]|[-*+])\s+/, "").trim()).filter(Boolean);
235
- return items;
236
- }
237
224
  /**
238
225
  * Read planned case facts from the case Markdown. Recognizes both English and
239
- * Chinese headings, including `测试点` (test points) and `测试步骤` (planned
240
- * test steps), alongside the legacy `操作步骤`/`Steps` headings.
226
+ * Chinese headings at `##` or `###`, including `测试点` (test points) and
227
+ * `测试步骤` (planned test steps), alongside the legacy `操作步骤`/`Steps`
228
+ * headings. Numbered and bulleted lists are extracted; a missing 测试目的
229
+ * falls back to the case H1 title.
241
230
  */
242
231
  async function readFrontendCaseContent(workspaceRoot, casePath) {
243
232
  if (!safeRelativePathSchema.safeParse(casePath).success || !casePath.startsWith("testcase/frontend/cases/")) {
244
233
  throw new Error(`unsafe frontend case path: ${casePath}`);
245
234
  }
246
235
  const body = await readFile(path.resolve(workspaceRoot, casePath), "utf8");
236
+ const purpose = markdownSection(body, ["Test Purpose", "测试目的", "测试场景"]) ||
237
+ markdownH1Title(body).replace(/^FE-[^\s::]+[::]\s*/, "").trim() ||
238
+ "未提供测试目的";
247
239
  return {
248
240
  caseContent: {
249
- purpose: markdownSection(body, ["Test Purpose", "测试目的", "测试场景"]) || "未提供测试目的",
250
- preconditions: markdownList(markdownSection(body, ["Preconditions", "前置条件"])),
241
+ purpose,
242
+ preconditions: markdownList(markdownSection(body, ["Preconditions", "前置条件", "前置条件与重置"])),
251
243
  steps: markdownList(markdownSection(body, ["Test Steps", "测试步骤", "Steps", "操作步骤"])),
252
244
  expectedResults: markdownList(markdownSection(body, ["Expected Results", "预期结果"])),
253
245
  },