@tea-agent/loop-agent 0.39.0-next.3 → 0.39.0-next.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -1
- package/CHANGELOG.md +267 -65
- package/README.md +11 -5
- package/bin/loop-agent.js +7 -3
- package/dist/application/dag/generate-task-dag.js +9 -0
- package/dist/application/task-lifecycle/advance.js +4 -0
- package/dist/build-stamp.json +3 -3
- package/dist/cli/command-definitions.js +16 -5
- package/dist/cli/program.js +16 -2
- package/dist/cli/update/notifier.js +2 -2
- package/dist/cli.js +2 -1
- package/dist/commands/dag-rerun.js +55 -1
- package/dist/commands/init-model-catalog.js +464 -0
- package/dist/commands/init-upgrade.js +265 -97
- package/dist/commands/init.js +428 -27
- package/dist/commands/inspect-next.js +8 -0
- package/dist/commands/task-advance.js +31 -0
- package/dist/executors/dag-pi-executor.js +118 -14
- package/dist/executors/pi-event-serializer.js +42 -1
- package/dist/executors/pi-executor.js +51 -0
- package/dist/executors/pi-extension-resolver.js +233 -0
- package/dist/executors/pi-playwright-cli-tool.js +8 -1
- package/dist/executors/pi-sdk-executor.js +187 -59
- package/dist/executors/shell-executor.js +533 -29
- package/dist/executors/shell-write-guard.js +7 -0
- package/dist/shared/dag-prompt-override.js +28 -0
- package/dist/shared/operator/capabilities.js +181 -27
- package/dist/shared/update/console-notifier.js +100 -0
- package/dist/{cli → shared}/update/npm-client.js +41 -8
- package/dist/{cli → shared}/update/state.js +41 -13
- package/dist/task/config-types.js +22 -0
- package/dist/task/contract/constants.js +1 -0
- package/dist/task/contract/diff.js +26 -0
- package/dist/task/contract/project.js +5 -0
- package/dist/task/contract/schema.js +6 -1
- package/dist/task/source-prepare/build-draft.js +15 -0
- package/dist/worker/cli.js +31 -1
- package/dist/worker/console/chat/deferred-turn.js +91 -0
- package/dist/worker/console/chat/mcp-inventory.js +221 -0
- package/dist/worker/console/chat/pi-runtime.js +165 -83
- package/dist/worker/console/chat/repo-walk.js +10 -1
- package/dist/worker/console/chat/routes.js +478 -55
- package/dist/worker/console/chat/semantic-activity.js +20 -8
- package/dist/worker/console/chat/session-store.js +146 -2
- package/dist/worker/console/chat/turn-process.js +17 -30
- package/dist/worker/console/harness-pi-model-matrix.js +137 -0
- package/dist/worker/console/operator-actions.js +244 -4
- package/dist/worker/console/operator-user-error.js +19 -0
- package/dist/worker/console/pi-readiness.js +12 -5
- package/dist/worker/console/recovery-cta.js +4 -4
- package/dist/worker/console/recovery-error-copy.js +4 -4
- package/dist/worker/console/routes.js +43 -2
- package/dist/worker/console/security.js +83 -10
- package/dist/worker/console/server.js +42 -6
- package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-DSvF6RXQ.js → abnfDiagram-N423BO3Z-CiGOV5eG.js} +1 -1
- package/dist/worker/console/static/assets/{arc-DuI4ogtJ.js → arc-Ds9Gyfd8.js} +1 -1
- package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-DdBbgD4y.js → architectureDiagram-T3A2C74G-C6V6uC98.js} +1 -1
- package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-xQC2HELm.js → blockDiagram-VBNYF7ZC-DezF7Ol9.js} +1 -1
- package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DPMSgwIZ.js → c4Diagram-5PPSVZJV-BCWk3gCT.js} +1 -1
- package/dist/worker/console/static/assets/channel-DMvu1nmi.js +1 -0
- package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-WTEKTxin.js → chunk-2GRJ4B5K-DbYr-Cxy.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-B6vZ0Pv_.js → chunk-2Q5K7J3B-XSlusW4G.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5RXB4S5H-BrerNAix.js → chunk-5RXB4S5H-BtcKAj9i.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5VM5RSS4-D_ch2aaI.js → chunk-5VM5RSS4-BF57lhXj.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-VvfacNrq.js → chunk-6Q2QTUOP-DJIlR25o.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-GF5L2VYU-B-uGFIg2.js → chunk-GF5L2VYU-DklNc7dm.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-JWPE2WC7-BlysCtod.js → chunk-JWPE2WC7-fZNtEdQZ.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-KBJHAD2P-BjAUAfcT.js → chunk-KBJHAD2P-BBgLqV4h.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-RYQCIY6F-BeZnBmb-.js → chunk-RYQCIY6F-Dl9e5n_J.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-XXDRQBXY-Bbfe3UJG.js → chunk-XXDRQBXY-B0JK51NT.js} +1 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DVzM-02m.js +1 -0
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DVzM-02m.js +1 -0
- package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-BKJQnYVM.js → cose-bilkent-JH36ORCC-Ch-ajv4O.js} +1 -1
- package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-B1wtI3hU.js → cynefin-VYW2F7L2-xn_NxcfB.js} +1 -1
- package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-DCF6DBSu.js → cynefinDiagram-MW4NZA55-DRD0fyga.js} +1 -1
- package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BJyNGRlP.js → dagre-VZM6K2ZE-COtnKp4B.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-7IWD3JNH-B7j1-VsZ.js → diagram-7IWD3JNH-BQp_CAqG.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-BREJ140a.js → diagram-B4RE2ZJO-x0y-vNgq.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-LBJQPF4R-CDPHmHGH.js → diagram-LBJQPF4R-N2ezwL31.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-Q27KOJAE-BtZLrez1.js → diagram-Q27KOJAE-CSaxRH6Q.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-UB23O5K3-mzybu8oU.js → diagram-UB23O5K3-6Ro7Kpbd.js} +1 -1
- package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-Dk3I2uO6.js → ebnfDiagram-BXEA7PRR-CrIIQEAI.js} +1 -1
- package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-BT97u_tQ.js → erDiagram-JOGREHBK-DivKcq8g.js} +1 -1
- package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-CMOtYzui.js → flowDiagram-UKHOOZJN-BXrfDo8y.js} +1 -1
- package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-DiNydkuY.js → ganttDiagram-PKOTCBZU-LTTQmMRJ.js} +1 -1
- package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Cwt6fLkl.js → gitGraphDiagram-DS77QQ5N-DP3o_5qd.js} +1 -1
- package/dist/worker/console/static/assets/index-C2jmYBfu.js +404 -0
- package/dist/worker/console/static/assets/index-C8rOIAJr.css +1 -0
- package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-AsGIGKKB.js → infoDiagram-6WML65LV-wVfSY1WH.js} +1 -1
- package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-BL8ITZh3.js → ishikawaDiagram-WSZJBQD7-BRKfFL0I.js} +1 -1
- package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-VPKAUX8M.js → journeyDiagram-NVQOT4AX-DEml3jLb.js} +1 -1
- package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-BL56hfGp.js → kanban-definition-27J2QSJJ-DTzBmOqN.js} +1 -1
- package/dist/worker/console/static/assets/{linear-BmSG3nIZ.js → linear-Cpy1d873.js} +1 -1
- package/dist/worker/console/static/assets/{mermaid.core-DWGK9kwP.js → mermaid.core-C1cSavOy.js} +5 -5
- package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS--vvKLjo0.js → mindmap-definition-FAOFIHXS-BEBSKvYM.js} +1 -1
- package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-B41mxb2-.js → pegDiagram-VL7TDLO6-BrLs4Oac.js} +1 -1
- package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-DTlu77sw.js → pieDiagram-7S7Q4E2Y-B47AZcLa.js} +1 -1
- package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-Bk5A7alv.js → quadrantDiagram-CIZ2JOQS-s0dVeYR2.js} +1 -1
- package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-Csf9jzQy.js → railroadDiagram-AXF67PYL-lW8aJ7Q6.js} +1 -1
- package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-CeptoSNx.js → requirementDiagram-LRYGKXZP-BcHJPf6Q.js} +1 -1
- package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-A-DLCLer.js → sankeyDiagram-W5VNT64P-DdegpUUf.js} +1 -1
- package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-22VbPdOI.js → sequenceDiagram-SI44F4Z6-BxFWqNv8.js} +1 -1
- package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-DWvAyBC9.js → sizeCapture-X5ZJPWSS-cOdUDPbR.js} +1 -1
- package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-qICZ75yT.js → stateDiagram-OKZ733FA-DpxlYl8P.js} +1 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BwecGAkM.js +1 -0
- package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-xi7lcMyp.js → swimlanes-SLNWSIFB-DQOfurE0.js} +2 -2
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-D5WW_LbN.js +8 -0
- package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-Bu6Nl6O2.js → timeline-definition-Z64GVDOM-CIp3tC6V.js} +1 -1
- package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-BzMz4Bn9.js → vennDiagram-T6HMQDX7-C-adIPKn.js} +1 -1
- package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CxaJnzc5.js → wardleyDiagram-T6FBY63Y-CiYrar9M.js} +1 -1
- package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-5JK-omQK.js → xychartDiagram-ELKLHX3M-WHrdTV0-.js} +1 -1
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/app/console-types.js +39 -0
- package/dist/worker/console/static-src/app/useOperatorActions.js +1 -1
- package/dist/worker/console/static-src/app/useRecoveryActions.js +2 -2
- package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +82 -22
- package/dist/worker/console/static-src/operator-chat/mutation-gate.js +45 -0
- package/dist/worker/console/static-src/operator-chat/open-preview-in-browser.js +328 -0
- package/dist/worker/console/static-src/operator-chat/resource-diagnostics.js +204 -0
- package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +8 -1
- package/dist/worker/console/static-src/operator-chat/turn-submission.js +42 -0
- package/dist/worker/console/static-src/operator-chat/useChatSessions.js +18 -0
- package/dist/worker/console/static-src/operator-chat/useChatStream.js +216 -10
- package/dist/worker/console/static-src/operator-chat/useChatThread.js +50 -22
- package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +26 -1
- package/dist/worker/observe/node-input.js +74 -3
- package/dist/worker/observe/node-process.js +377 -0
- package/dist/worker/observe/routes.js +36 -3
- package/dist/worker/observe/shell-handler-keys.js +34 -0
- package/dist/worker/observe/static/api.js +66 -0
- package/dist/worker/observe/static/constants.js +8 -1
- package/dist/worker/observe/static/dag-history-labels.js +4 -0
- package/dist/worker/observe/static/format.js +23 -0
- package/dist/worker/observe/static/markdown-render.js +20 -1
- package/dist/worker/observe/static/operator-chrome.js +4 -0
- package/dist/worker/observe/static/state.js +13 -2
- package/dist/worker/observe/static/styles.css +73 -0
- package/dist/worker/observe/static/views/dag-inspector.js +612 -8
- package/dist/worker/observe/static/views/dag.js +8 -1
- package/dist/worker/observe/static/views/session-timeline.js +78 -9
- package/dist/workflows/dag/backend-test-case-coverage-analysis.js +827 -53
- package/dist/workflows/dag/backend-test-case-manifest.js +4 -0
- package/dist/workflows/dag/backend-test-execution-contract.js +18 -9
- package/dist/workflows/dag/backend-test-gap-fill.js +205 -0
- package/dist/workflows/dag/backend-test-intake-context.js +32 -0
- package/dist/workflows/dag/backend-test-layout.js +133 -0
- package/dist/workflows/dag/backend-test-markdown-workflow.js +167 -32
- package/dist/workflows/dag/backend-test-module-stem.js +5 -0
- package/dist/workflows/dag/backend-test-pytest-collection.js +368 -33
- package/dist/workflows/dag/backend-test-result-contract.js +1 -1
- package/dist/workflows/dag/backend-test-scenario-param.js +884 -140
- package/dist/workflows/dag/backend-test-scenario-partitions.js +321 -0
- package/dist/workflows/dag/backend-test-writer-completeness.js +100 -56
- package/dist/workflows/dag/contract-output-registry.js +15 -0
- package/dist/workflows/dag/contract-validator-registrations.js +3 -1
- package/dist/workflows/dag/convergence/controller.js +141 -0
- package/dist/workflows/dag/dynamic-runtime/map.js +24 -8
- package/dist/workflows/dag/dynamic-runtime/shared.js +1 -1
- package/dist/workflows/dag/failure-category.js +12 -0
- package/dist/workflows/dag/frontend-implementation-contract.js +493 -2
- package/dist/workflows/dag/frontend-plan-render.js +90 -0
- package/dist/workflows/dag/frontend-prewrite-gate.js +221 -31
- package/dist/workflows/dag/frontend-test-case-quality.js +5 -13
- package/dist/workflows/dag/frontend-test-environment-probe.js +227 -0
- package/dist/workflows/dag/frontend-test-markdown.js +61 -0
- package/dist/workflows/dag/frontend-test-result-contract.js +10 -18
- package/dist/workflows/dag/frontend-test-standard-scenarios.js +68 -0
- package/dist/workflows/dag/init-hybrid.js +805 -113
- package/dist/workflows/dag/node-execution.js +194 -6
- package/dist/workflows/dag/prompt.js +60 -1
- package/dist/workflows/dag/rerun-feedback.js +212 -0
- package/dist/workflows/dag/rerun-plan.js +224 -27
- package/dist/workflows/dag/rerun-run.js +61 -6
- package/dist/workflows/dag/rerun-task.js +61 -2
- package/dist/workflows/dag/retry-policy.js +113 -0
- package/dist/workflows/dag/structured-output-repair.js +712 -0
- package/dist/workflows/dag/types.js +157 -1
- package/dist/workflows/dag/validate.js +41 -1
- package/docs/init-surface.manifest.json +5 -0
- package/docs/operations/README.md +1 -1
- package/docs/operations/local-development-environment.md +1 -5
- package/docs/skills/vetted-skill-registry.md +14 -0
- package/docs/templates/README.md +1 -1
- package/docs/templates/agent-dag.schema.json +44 -0
- package/docs/templates/backend-test-dag.json +60 -35
- package/docs/templates/frontend-implementation-contract.schema.json +9 -2
- package/docs/templates/frontend-test-dag.json +8 -10
- package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +1 -1
- package/docs/templates/init-managed-agents.md +13 -3
- package/harness.json +3 -1
- package/package.json +12 -4
- package/scripts/next-info.mjs +356 -0
- package/scripts/next-publish-gate.mjs +238 -0
- package/scripts/release-source-binding.mjs +251 -0
- package/skills/analyze-product-requirements/SKILL.md +8 -5
- package/skills/analyze-product-requirements/references/acceptance-criteria.md +1 -1
- package/skills/analyze-product-requirements/references/clarification-and-knowledge.md +10 -6
- package/skills/analyze-product-requirements/references/example.md +50 -0
- package/skills/analyze-product-requirements/references/forward-test-cases.md +11 -7
- package/skills/analyze-product-requirements/references/kb-integration.md +5 -5
- package/skills/analyze-product-requirements/references/product-analysis-schema.md +8 -4
- package/skills/analyze-product-requirements/references/product-requirement-schema.md +2 -2
- package/skills/analyze-product-requirements/references/requirement-clarification-schema.md +1 -1
- package/skills/analyze-product-requirements/scripts/test-validators.mjs +11 -1
- package/skills/analyze-product-requirements/scripts/validate-product-analysis.mjs +20 -1
- package/skills/analyze-product-requirements/scripts/validate-requirement-clarification.mjs +9 -0
- package/skills/codebase-scout/SKILL.md +1 -1
- package/skills/frontend-design-review/references/review-checklist.md +2 -0
- package/skills/frontend-implementation/references/design-spec.md +16 -19
- package/skills/frontend-implementation/references/node-contracts.md +9 -6
- package/skills/improve-codebase-architecture/SKILL.md +81 -0
- package/skills/improve-codebase-architecture/deepening.md +37 -0
- package/skills/improve-codebase-architecture/html-report.md +123 -0
- package/skills/improve-codebase-architecture/interface-design.md +44 -0
- package/skills/improve-codebase-architecture/language.md +53 -0
- package/skills/loop-agent/references/command-reference.md +11 -4
- package/dist/worker/console/static/assets/channel-DLVz9TYW.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BeAuGcO9.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BeAuGcO9.js +0 -1
- package/dist/worker/console/static/assets/index-DQxiso1w.js +0 -325
- package/dist/worker/console/static/assets/index-_j0Hzo69.css +0 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DzKvfHTX.js +0 -1
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Cxqr_qM1.js +0 -8
|
@@ -1,13 +1,19 @@
|
|
|
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";
|
|
6
|
+
import { writeTextArtifactFile } from "../../infrastructure/harness/artifact-store.js";
|
|
5
7
|
import { countExcludedGovernanceSpecFiles, resolveOpenspecGovernanceRoot, } from "../../task/frontend-project-capability.js";
|
|
6
|
-
import { analyzeFrontendImplementationContract, writeFrontendImplementationContractArtifact, writeDeterministicJsonArtifact, assertFrontendSourceBindingFresh, deterministicSha256, sha256Hex, 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";
|
|
9
|
+
import { renderFrontendPlanMarkdown } from "./frontend-plan-render.js";
|
|
7
10
|
import { captureFrontendWorktreeBaseline } from "./frontend-worktree-diff.js";
|
|
8
11
|
import { pathMatchesPattern } from "../../shared/git-progress.js";
|
|
12
|
+
import { normalizeVerdictCandidateLine } from "./dynamic-runtime/shared.js";
|
|
9
13
|
export const FRONTEND_PREWRITE_RESULT_SCHEMA_ID = "frontend-prewrite-result-v1";
|
|
10
14
|
export const FRONTEND_PREWRITE_RESULT_ARTIFACT_NAME = "frontend-prewrite-result.json";
|
|
15
|
+
/** Canonical contract serialization must stay at or below this byte size. */
|
|
16
|
+
export const FRONTEND_CONTRACT_MAX_BYTES = 65536;
|
|
11
17
|
/**
|
|
12
18
|
* Machine-readable failure classification for blocked/retryable-invalid
|
|
13
19
|
* prewrite outcomes. Kept alongside the human-readable failureReason so
|
|
@@ -31,6 +37,11 @@ export const frontendPrewriteFailureCodeSchema = z.enum([
|
|
|
31
37
|
"openspec-citation-block-unparseable",
|
|
32
38
|
"openspec-not-cited",
|
|
33
39
|
"openspec-citation-not-read",
|
|
40
|
+
"component-spec-reference-invalid",
|
|
41
|
+
"component-spec-not-cited",
|
|
42
|
+
"component-choices-missing",
|
|
43
|
+
"contract-too-large",
|
|
44
|
+
"revision-patch-unmergeable",
|
|
34
45
|
]);
|
|
35
46
|
export const frontendPrewriteResultV1Schema = z
|
|
36
47
|
.object({
|
|
@@ -147,32 +158,80 @@ async function selectNode(runDir, primary, fallbacks) {
|
|
|
147
158
|
}
|
|
148
159
|
throw new Error(`frontend prewrite gate no FINISHED candidate: ${rejections.join("; ")}`);
|
|
149
160
|
}
|
|
150
|
-
async function
|
|
151
|
-
|
|
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
|
+
}
|
|
152
169
|
if (record.status !== "FINISHED") {
|
|
153
170
|
throw new Error(`frontend prewrite gate requires FINISHED node ${nodeId} (got ${String(record.status)})`);
|
|
154
171
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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() || "";
|
|
158
177
|
if (!text)
|
|
159
178
|
throw new Error(`frontend prewrite gate empty output from ${nodeId}`);
|
|
160
179
|
return text;
|
|
161
180
|
}
|
|
162
|
-
async function
|
|
163
|
-
const record =
|
|
164
|
-
|
|
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 };
|
|
165
223
|
}
|
|
166
224
|
function firstNonEmptyVerdictLine(text) {
|
|
167
225
|
const lines = text
|
|
168
226
|
.split(/\r?\n/)
|
|
169
227
|
.map((line) => line.trim())
|
|
170
228
|
.filter(Boolean);
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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);
|
|
176
235
|
const first = normalizedLines[0] ?? "";
|
|
177
236
|
// The prompt requires VERDICT to be the first non-empty line, but model
|
|
178
237
|
// output can still prepend a summary. Keep the protocol strict in the
|
|
@@ -389,7 +448,30 @@ export async function runFrontendPrewriteGate(input) {
|
|
|
389
448
|
// remains an ERROR and is intentionally not classified into prewrite-result.
|
|
390
449
|
const planNodeId = await selectNode(input.runDir, input.config.planFromNodeId, input.config.planFallbackFromNodeIds);
|
|
391
450
|
const reviewNodeId = await selectNode(input.runDir, input.config.reviewFromNodeId, input.config.reviewFallbackFromNodeIds);
|
|
392
|
-
|
|
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;
|
|
393
475
|
const openspecPolicy = input.config.openspecPolicy ?? "scan-strict";
|
|
394
476
|
const basePending = {
|
|
395
477
|
planNodeId,
|
|
@@ -408,18 +490,9 @@ export async function runFrontendPrewriteGate(input) {
|
|
|
408
490
|
openspecCandidatePaths: input.config.openspecCandidatePaths ?? [],
|
|
409
491
|
openspecPolicy,
|
|
410
492
|
};
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
}
|
|
415
|
-
catch (error) {
|
|
416
|
-
return finalizePrewrite(input, {
|
|
417
|
-
...basePending,
|
|
418
|
-
classification: "retryable-invalid",
|
|
419
|
-
failureReason: error instanceof Error ? error.message : String(error),
|
|
420
|
-
failureCode: "plan-text-unreadable",
|
|
421
|
-
});
|
|
422
|
-
}
|
|
493
|
+
const planText = [planArtifact.contractText, planArtifact.citationsText]
|
|
494
|
+
.filter(Boolean)
|
|
495
|
+
.join("\n");
|
|
423
496
|
let reviewText;
|
|
424
497
|
try {
|
|
425
498
|
reviewText = await readNodeText(input.runDir, reviewNodeId);
|
|
@@ -442,7 +515,8 @@ export async function runFrontendPrewriteGate(input) {
|
|
|
442
515
|
failureCode: "verdict-not-pass",
|
|
443
516
|
});
|
|
444
517
|
}
|
|
445
|
-
const
|
|
518
|
+
const requirementIdSearchText = planArtifact.contractText;
|
|
519
|
+
const missingIds = input.config.requiredRequirementIds.filter((id) => !requirementIdSearchText.includes(id));
|
|
446
520
|
if (missingIds.length > 0) {
|
|
447
521
|
return finalizePrewrite(input, {
|
|
448
522
|
...basePending,
|
|
@@ -489,13 +563,13 @@ export async function runFrontendPrewriteGate(input) {
|
|
|
489
563
|
workspaceRoot,
|
|
490
564
|
});
|
|
491
565
|
}
|
|
492
|
-
// Candidate -> canonical conversion. The gate consumes the
|
|
493
|
-
//
|
|
566
|
+
// Candidate -> canonical conversion. The gate consumes the producing node's
|
|
567
|
+
// hash-bound canonical artifact and never re-merges plan/revision stdout.
|
|
494
568
|
let analysis;
|
|
495
569
|
try {
|
|
496
570
|
analysis = await analyzeFrontendImplementationContract({
|
|
497
571
|
runDir: input.runDir,
|
|
498
|
-
|
|
572
|
+
rawContractText: planArtifact.contractText,
|
|
499
573
|
sourceBinding: input.sourceBinding,
|
|
500
574
|
});
|
|
501
575
|
}
|
|
@@ -515,6 +589,21 @@ export async function runFrontendPrewriteGate(input) {
|
|
|
515
589
|
}
|
|
516
590
|
throw error;
|
|
517
591
|
}
|
|
592
|
+
// Size guard: a canonical contract above 64KB is a retryable-invalid defect
|
|
593
|
+
// (compress the contract) rather than a writer authorization failure.
|
|
594
|
+
const contractSizeBytes = Buffer.byteLength(serializeDeterministicJson(analysis.canonical), "utf8");
|
|
595
|
+
if (contractSizeBytes > FRONTEND_CONTRACT_MAX_BYTES) {
|
|
596
|
+
return finalizePrewrite(input, {
|
|
597
|
+
...basePending,
|
|
598
|
+
verdict,
|
|
599
|
+
candidateJsonSha256: analysis.candidateJsonSha256,
|
|
600
|
+
normalizationActions: [],
|
|
601
|
+
mockStrategy: analysis.canonical.mockApi.strategy,
|
|
602
|
+
classification: "retryable-invalid",
|
|
603
|
+
failureReason: `frontend prewrite gate: canonical contract serialization is ${contractSizeBytes} bytes, exceeding the ${FRONTEND_CONTRACT_MAX_BYTES}-byte limit. 压缩契约(移除冗余 prose、重复目标或过长描述)后重试。`,
|
|
604
|
+
failureCode: "contract-too-large",
|
|
605
|
+
});
|
|
606
|
+
}
|
|
518
607
|
const mockStrategy = analysis.canonical.mockApi.strategy;
|
|
519
608
|
if (!input.config.allowedMockStrategies.includes(mockStrategy)) {
|
|
520
609
|
const quotedAllowed = input.config.allowedMockStrategies
|
|
@@ -711,6 +800,105 @@ export async function runFrontendPrewriteGate(input) {
|
|
|
711
800
|
}
|
|
712
801
|
}
|
|
713
802
|
}
|
|
803
|
+
// Component selection spec conformance (ADR 0016). Deterministic cross-check
|
|
804
|
+
// that declared component/token selections trace to the frontend spec
|
|
805
|
+
// (openspec/ai_workspace component + theme) instead of a model's ad-hoc
|
|
806
|
+
// "better" choice. Runs after the openspec read gate so citation + read
|
|
807
|
+
// evidence are already resolved.
|
|
808
|
+
const componentChoices = analysis.canonical.uiComponentChoices ?? [];
|
|
809
|
+
const componentSpecCandidates = input.config.componentSpecCandidatePaths ?? [];
|
|
810
|
+
const hasUiVisibleWork = analysis.canonical.uiStates.some((state) => state.applicable) ||
|
|
811
|
+
analysis.canonical.interactions.length > 0;
|
|
812
|
+
if (componentSpecCandidates.length > 0 &&
|
|
813
|
+
hasUiVisibleWork &&
|
|
814
|
+
componentChoices.length === 0) {
|
|
815
|
+
return finalizePrewrite(input, {
|
|
816
|
+
...basePending,
|
|
817
|
+
verdict,
|
|
818
|
+
candidateJsonSha256: analysis.candidateJsonSha256,
|
|
819
|
+
normalizationActions: [],
|
|
820
|
+
mockStrategy,
|
|
821
|
+
classification: "retryable-invalid",
|
|
822
|
+
failureReason: "frontend prewrite gate: the canonical contract has UI-visible work but uiComponentChoices is missing or empty while the generation-frozen component/theme specification bucket is non-empty. 为每个 UI 用途声明组件选型(decision: specified | reuse-existing | new),specified 必须给 specReference.path/section/line,new 必须给 rationale 声明偏差。",
|
|
823
|
+
failureCode: "component-choices-missing",
|
|
824
|
+
openspecReadPaths,
|
|
825
|
+
openspecCandidatePaths: candidatePaths,
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
const componentSpecReferences = componentChoices
|
|
829
|
+
.map((choice) => ({
|
|
830
|
+
raw: choice.specReference?.path ?? "",
|
|
831
|
+
normalized: choice.specReference?.path
|
|
832
|
+
? normalizeCitationPath(choice.specReference.path, candidateRoot)
|
|
833
|
+
: null,
|
|
834
|
+
}))
|
|
835
|
+
.filter((ref) => ref.raw.length > 0);
|
|
836
|
+
if (componentSpecReferences.length > 0) {
|
|
837
|
+
const invalidRefs = [
|
|
838
|
+
...new Set(componentSpecReferences
|
|
839
|
+
.filter((ref) => !ref.normalized ||
|
|
840
|
+
!isOpenspecSpecFilePath(ref.normalized))
|
|
841
|
+
.map((ref) => ref.raw)),
|
|
842
|
+
];
|
|
843
|
+
if (invalidRefs.length > 0) {
|
|
844
|
+
return finalizePrewrite(input, {
|
|
845
|
+
...basePending,
|
|
846
|
+
verdict,
|
|
847
|
+
candidateJsonSha256: analysis.candidateJsonSha256,
|
|
848
|
+
normalizationActions: [],
|
|
849
|
+
mockStrategy,
|
|
850
|
+
classification: "retryable-invalid",
|
|
851
|
+
failureReason: `frontend prewrite gate: these uiComponentChoices specReference paths are not supported openspec/ai_workspace spec files: ${invalidRefs.join(", ")}`,
|
|
852
|
+
failureCode: "component-spec-reference-invalid",
|
|
853
|
+
openspecReadPaths,
|
|
854
|
+
openspecCandidatePaths: candidatePaths,
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
if (openspecPolicy === "cited") {
|
|
858
|
+
const parsedCitations = parseOpenspecCitations(planText, candidateRoot);
|
|
859
|
+
const citedPaths = new Set(parsedCitations.citations.map((citation) => citation.path));
|
|
860
|
+
const readSet = new Set(openspecReadPaths);
|
|
861
|
+
const uncorroborated = [
|
|
862
|
+
...new Set(componentSpecReferences
|
|
863
|
+
.filter((ref) => !ref.normalized ||
|
|
864
|
+
!citedPaths.has(ref.normalized) ||
|
|
865
|
+
!readSet.has(ref.normalized))
|
|
866
|
+
.map((ref) => ref.raw)),
|
|
867
|
+
];
|
|
868
|
+
if (uncorroborated.length > 0) {
|
|
869
|
+
return finalizePrewrite(input, {
|
|
870
|
+
...basePending,
|
|
871
|
+
verdict,
|
|
872
|
+
candidateJsonSha256: analysis.candidateJsonSha256,
|
|
873
|
+
normalizationActions: [],
|
|
874
|
+
mockStrategy,
|
|
875
|
+
classification: "retryable-invalid",
|
|
876
|
+
failureReason: `frontend prewrite gate: these uiComponentChoices specReference paths were not cited in the plan openspec-citations block or lack a successful read event: ${uncorroborated.join(", ")}`,
|
|
877
|
+
failureCode: "component-spec-not-cited",
|
|
878
|
+
openspecReadPaths,
|
|
879
|
+
openspecCandidatePaths: candidatePaths,
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
// Render the deterministic plan.md from the canonical contract (fail-closed
|
|
885
|
+
// before any artifact is written: no contract, no writer authorization).
|
|
886
|
+
let planMarkdown;
|
|
887
|
+
try {
|
|
888
|
+
planMarkdown = renderFrontendPlanMarkdown(analysis.canonical);
|
|
889
|
+
}
|
|
890
|
+
catch (error) {
|
|
891
|
+
return finalizePrewrite(input, {
|
|
892
|
+
...basePending,
|
|
893
|
+
verdict,
|
|
894
|
+
candidateJsonSha256: analysis.candidateJsonSha256,
|
|
895
|
+
normalizationActions: [],
|
|
896
|
+
mockStrategy,
|
|
897
|
+
classification: "retryable-invalid",
|
|
898
|
+
failureReason: `frontend prewrite gate: failed to render ${input.config.planMdArtifactName ?? "frontend-plan.md"}: ${error instanceof Error ? error.message : String(error)}`,
|
|
899
|
+
failureCode: "contract-invalid",
|
|
900
|
+
});
|
|
901
|
+
}
|
|
714
902
|
// Materialize the canonical contract only after every governance check passed.
|
|
715
903
|
const artifact = await writeFrontendImplementationContractArtifact({
|
|
716
904
|
runDir: input.runDir,
|
|
@@ -718,6 +906,8 @@ export async function runFrontendPrewriteGate(input) {
|
|
|
718
906
|
artifactName: input.config.artifactName,
|
|
719
907
|
canonical: analysis.canonical,
|
|
720
908
|
});
|
|
909
|
+
// Materialize the rendered plan.md in the same outputDir as the contract.
|
|
910
|
+
await writeTextArtifactFile(path.join(input.runDir, path.posix.join(input.config.outputDir, input.config.planMdArtifactName ?? "frontend-plan.md")), planMarkdown);
|
|
721
911
|
const classification = analysis.normalizationActions.length > 0 ? "accepted-normalized" : "accepted";
|
|
722
912
|
return finalizePrewrite(input, {
|
|
723
913
|
...basePending,
|
|
@@ -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 (!
|
|
64
|
+
if (!markdownSection(body, names))
|
|
73
65
|
findings.push({ ruleId: `missing-${key}`, caseId: id, detail: `missing section: ${names.join(" or ")}` });
|
|
74
|
-
const steps =
|
|
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
|
+
}
|