@loucompanion/forge-bridge 0.1.3-dev.f66395c3ac21 → 0.1.4-dev.04a1e5b9e484
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/README.md +165 -1
- package/dist/bridge/bridge-chat.types.d.ts +111 -0
- package/dist/bridge/bridge-chat.types.js +401 -0
- package/dist/bridge/bridge.base.d.ts +9 -1
- package/dist/bridge/bridge.service.d.ts +129 -3
- package/dist/bridge/bridge.service.js +2427 -70
- package/dist/bridge/bridge.types.d.ts +218 -2
- package/dist/bridge/dispatch-result-outbox.d.ts +25 -0
- package/dist/bridge/dispatch-result-outbox.js +62 -0
- package/dist/bridge/dispatch-result-worker.d.ts +2 -0
- package/dist/bridge/dispatch-result-worker.js +122 -0
- package/dist/bridge/internals.d.ts +2 -0
- package/dist/bridge/internals.js +1 -0
- package/dist/bridge/readiness-result-outbox.d.ts +21 -0
- package/dist/bridge/readiness-result-outbox.js +54 -0
- package/dist/bridge/readiness-result-worker.d.ts +2 -0
- package/dist/bridge/readiness-result-worker.js +121 -0
- package/dist/bridge/runner-host-capabilities.d.ts +15 -0
- package/dist/bridge/runner-host-capabilities.js +93 -0
- package/dist/cleanup/cleanup.service.d.ts +14 -2
- package/dist/cleanup/cleanup.service.js +496 -3
- package/dist/cleanup/cleanup.types.d.ts +10 -0
- package/dist/cleanup/index.d.ts +1 -1
- package/dist/cleanup/index.js +1 -1
- package/dist/cleanup/internals.d.ts +2 -2
- package/dist/cleanup/internals.js +1 -1
- package/dist/cli/cli.help.d.ts +2 -0
- package/dist/cli/cli.help.js +217 -0
- package/dist/cli/cli.service.d.ts +55 -7
- package/dist/cli/cli.service.js +429 -219
- package/dist/cli/local-config.d.ts +7 -0
- package/dist/cli/local-config.js +51 -3
- package/dist/runtime/aspnet-runtime-prebuild.d.ts +36 -0
- package/dist/runtime/aspnet-runtime-prebuild.js +341 -0
- package/dist/runtime/attempt-database-broker.d.ts +89 -0
- package/dist/runtime/attempt-database-broker.js +390 -0
- package/dist/runtime/attempt-node-runtime-launcher.d.ts +18 -0
- package/dist/runtime/attempt-node-runtime-launcher.js +140 -0
- package/dist/runtime/attempt-path-operation.d.ts +21 -0
- package/dist/runtime/attempt-path-operation.js +94 -0
- package/dist/runtime/attempt-private-directory.d.ts +6 -0
- package/dist/runtime/attempt-private-directory.js +54 -0
- package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
- package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
- package/dist/runtime/attempt-process-supervisor.d.ts +102 -0
- package/dist/runtime/attempt-process-supervisor.js +421 -0
- package/dist/runtime/canonical-source-file.d.ts +3 -0
- package/dist/runtime/canonical-source-file.js +54 -0
- package/dist/runtime/docker-engine-probe.d.ts +90 -0
- package/dist/runtime/docker-engine-probe.js +333 -0
- package/dist/runtime/generated-output-reset.d.ts +20 -0
- package/dist/runtime/generated-output-reset.js +157 -0
- package/dist/runtime/host-database-capability-boundary.d.ts +16 -0
- package/dist/runtime/host-database-capability-boundary.js +62 -0
- package/dist/runtime/host-diagnostics.d.ts +11 -0
- package/dist/runtime/host-diagnostics.js +192 -0
- package/dist/runtime/host-execution-root.d.ts +4 -0
- package/dist/runtime/host-execution-root.js +39 -0
- package/dist/runtime/host-listener-inspector.d.ts +51 -0
- package/dist/runtime/host-listener-inspector.js +385 -0
- package/dist/runtime/host-port-broker.d.ts +76 -0
- package/dist/runtime/host-port-broker.js +333 -0
- package/dist/runtime/host-runtime-contract.d.ts +58 -0
- package/dist/runtime/host-runtime-contract.js +813 -0
- package/dist/runtime/host-runtime-lease.d.ts +217 -0
- package/dist/runtime/host-runtime-lease.js +2487 -0
- package/dist/runtime/host-runtime-promotion.d.ts +32 -0
- package/dist/runtime/host-runtime-promotion.js +206 -0
- package/dist/runtime/memory-capacity.d.ts +70 -0
- package/dist/runtime/memory-capacity.js +123 -0
- package/dist/runtime/native-test-postgres-fallback.d.ts +25 -0
- package/dist/runtime/native-test-postgres-fallback.js +91 -0
- package/dist/runtime/node-host-runtime-adapter.d.ts +54 -0
- package/dist/runtime/node-host-runtime-adapter.js +286 -0
- package/dist/runtime/pinned-directory-process.d.ts +9 -0
- package/dist/runtime/pinned-directory-process.js +61 -0
- package/dist/runtime/pinned-source-directory.d.ts +17 -0
- package/dist/runtime/pinned-source-directory.js +94 -0
- package/dist/runtime/playwright-host-runtime-adapter.d.ts +21 -0
- package/dist/runtime/playwright-host-runtime-adapter.js +268 -0
- package/dist/runtime/prepared-runtime-identity.d.ts +6 -0
- package/dist/runtime/prepared-runtime-identity.js +218 -0
- package/dist/runtime/prepared-runtime-readiness.d.ts +30 -0
- package/dist/runtime/prepared-runtime-readiness.js +285 -0
- package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
- package/dist/runtime/runtime-isolation-lease.js +161 -0
- package/dist/runtime/workspace-resource-estimator.d.ts +17 -0
- package/dist/runtime/workspace-resource-estimator.js +174 -0
- package/dist/sandbox/command-runner.d.ts +4 -0
- package/dist/sandbox/command-runner.js +47 -0
- package/dist/sandbox/contract.d.ts +5 -0
- package/dist/sandbox/contract.js +276 -0
- package/dist/sandbox/event-budget.d.ts +11 -0
- package/dist/sandbox/event-budget.js +27 -0
- package/dist/sandbox/image-policy.d.ts +35 -0
- package/dist/sandbox/image-policy.js +247 -0
- package/dist/sandbox/index.d.ts +5 -0
- package/dist/sandbox/index.js +5 -0
- package/dist/sandbox/lease-watchdog.d.ts +18 -0
- package/dist/sandbox/lease-watchdog.js +67 -0
- package/dist/sandbox/preflight.d.ts +84 -0
- package/dist/sandbox/preflight.js +521 -0
- package/dist/sandbox/process.d.ts +20 -0
- package/dist/sandbox/process.js +209 -0
- package/dist/sandbox/provider-auth/codex.d.ts +26 -0
- package/dist/sandbox/provider-auth/codex.js +212 -0
- package/dist/sandbox/repository-lock.d.ts +6 -0
- package/dist/sandbox/repository-lock.js +129 -0
- package/dist/sandbox/runtime.d.ts +302 -0
- package/dist/sandbox/runtime.js +2368 -0
- package/dist/sandbox/sandbox.types.d.ts +93 -0
- package/dist/sandbox/sandbox.types.js +1 -0
- package/dist/sandbox/smoke.d.ts +76 -0
- package/dist/sandbox/smoke.js +380 -0
- package/dist/sandbox/worker-supervisor.d.ts +13 -0
- package/dist/sandbox/worker-supervisor.js +36 -0
- package/dist/sandbox/worker.d.ts +1 -0
- package/dist/sandbox/worker.js +225 -0
- package/dist/sandbox/workspace.service.d.ts +13 -0
- package/dist/sandbox/workspace.service.js +74 -0
- package/dist/session/managed-attempt-tools.d.ts +28 -0
- package/dist/session/managed-attempt-tools.js +1016 -0
- package/dist/session/managed-build-coordinator.d.ts +5 -0
- package/dist/session/managed-build-coordinator.js +106 -0
- package/dist/session/managed-build-receipt.d.ts +21 -0
- package/dist/session/managed-build-receipt.js +337 -0
- package/dist/session/managed-verification-timeouts.d.ts +8 -0
- package/dist/session/managed-verification-timeouts.js +17 -0
- package/dist/session/phase-completion.artifacts.d.ts +27 -0
- package/dist/session/phase-completion.artifacts.js +829 -0
- package/dist/session/phase-completion.d.ts +53 -42
- package/dist/session/phase-completion.helpers.d.ts +117 -0
- package/dist/session/phase-completion.helpers.js +707 -0
- package/dist/session/phase-completion.js +125 -3007
- package/dist/session/phase-completion.prompts.d.ts +14 -0
- package/dist/session/phase-completion.prompts.js +233 -0
- package/dist/session/phase-completion.runtime.d.ts +49 -0
- package/dist/session/phase-completion.runtime.js +840 -0
- package/dist/session/phase-completion.schemas.d.ts +3 -0
- package/dist/session/phase-completion.schemas.js +859 -0
- package/dist/session/phase-completion.validation.d.ts +38 -0
- package/dist/session/phase-completion.validation.js +918 -0
- package/dist/session/pre-publication-validation.d.ts +75 -0
- package/dist/session/pre-publication-validation.js +661 -0
- package/dist/session/provider-cli/bridge-cli.conformance-fixtures.d.ts +72 -0
- package/dist/session/provider-cli/bridge-cli.conformance-fixtures.js +462 -0
- package/dist/session/provider-cli/ci-repair-readiness.d.ts +16 -0
- package/dist/session/provider-cli/ci-repair-readiness.js +110 -0
- package/dist/session/provider-cli/provider-cli.artifacts.d.ts +95 -0
- package/dist/session/provider-cli/provider-cli.artifacts.js +812 -0
- package/dist/session/provider-cli/provider-cli.base.d.ts +1 -0
- package/dist/session/provider-cli/provider-cli.deterministic-payloads.d.ts +12 -0
- package/dist/session/provider-cli/provider-cli.deterministic-payloads.js +279 -0
- package/dist/session/provider-cli/provider-cli.deterministic.d.ts +14 -0
- package/dist/session/provider-cli/provider-cli.deterministic.js +407 -0
- package/dist/session/provider-cli/provider-cli.process.d.ts +99 -0
- package/dist/session/provider-cli/provider-cli.process.js +874 -0
- package/dist/session/provider-cli/provider-cli.prompts.d.ts +52 -0
- package/dist/session/provider-cli/provider-cli.prompts.js +715 -0
- package/dist/session/provider-cli/provider-cli.qa-sidecars.d.ts +11 -0
- package/dist/session/provider-cli/provider-cli.qa-sidecars.js +66 -0
- package/dist/session/provider-cli/provider-cli.repair-epoch.d.ts +6 -0
- package/dist/session/provider-cli/provider-cli.repair-epoch.js +11 -0
- package/dist/session/provider-cli/provider-cli.runtime.d.ts +22 -0
- package/dist/session/provider-cli/provider-cli.runtime.js +92 -0
- package/dist/session/provider-cli/provider-cli.service.d.ts +47 -53
- package/dist/session/provider-cli/provider-cli.service.js +1258 -2497
- package/dist/session/provider-cli/provider-cli.types.d.ts +8 -0
- package/dist/session/provider-cli/provider-output-state.d.ts +32 -0
- package/dist/session/provider-cli/provider-output-state.js +817 -0
- package/dist/session/provider-cli/provider-usage.d.ts +7 -0
- package/dist/session/provider-cli/provider-usage.js +28 -5
- package/dist/session/session-redaction.d.ts +3 -0
- package/dist/session/session-redaction.js +448 -0
- package/dist/session/session.base.d.ts +6 -1
- package/dist/session/session.service.d.ts +8 -2
- package/dist/session/session.service.js +586 -59
- package/dist/session/session.types.d.ts +324 -3
- package/dist/session/session.types.js +939 -10
- package/dist/session/unreferenced-exports.d.ts +12 -0
- package/dist/session/unreferenced-exports.js +59 -0
- package/dist/shared/format/format-bytes.d.ts +1 -0
- package/dist/shared/format/format-bytes.js +9 -0
- package/dist/shared/git/git.js +2 -1
- package/dist/shared/path/path.d.ts +1 -0
- package/dist/shared/path/path.js +21 -1
- package/dist/shared/process/dotnet-toolchain.d.ts +17 -0
- package/dist/shared/process/dotnet-toolchain.js +307 -0
- package/dist/shared/process/host-process-recovery.d.ts +12 -0
- package/dist/shared/process/host-process-recovery.js +79 -0
- package/dist/shared/process/host-process-supervisor.d.ts +59 -0
- package/dist/shared/process/host-process-supervisor.js +331 -0
- package/dist/shared/process/process.d.ts +3 -0
- package/dist/shared/process/process.js +13 -7
- package/dist/shared/process/windows-launch.d.ts +8 -0
- package/dist/shared/process/windows-launch.js +41 -0
- package/dist/shared/provider-capabilities.d.ts +4 -0
- package/dist/shared/provider-capabilities.js +88 -0
- package/dist/shared/redaction/redaction.d.ts +1 -0
- package/dist/shared/redaction/redaction.js +47 -4
- package/dist/shared/time/monotonic.d.ts +1 -0
- package/dist/shared/time/monotonic.js +8 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worktree/artifact-injector.d.ts +3 -2
- package/dist/worktree/artifact-injector.js +82 -10
- package/dist/worktree/dependency-warmer.d.ts +53 -1
- package/dist/worktree/dependency-warmer.js +619 -11
- package/dist/worktree/index.d.ts +2 -1
- package/dist/worktree/index.js +1 -0
- package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
- package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
- package/dist/worktree/story-qa-reference-hydrator.d.ts +4 -0
- package/dist/worktree/story-qa-reference-hydrator.js +253 -0
- package/dist/worktree/worktree-paths.d.ts +4 -0
- package/dist/worktree/worktree-paths.js +30 -0
- package/dist/worktree/worktree.base.d.ts +3 -0
- package/dist/worktree/worktree.service.d.ts +16 -3
- package/dist/worktree/worktree.service.js +334 -54
- package/dist/worktree/worktree.types.d.ts +9 -1
- package/package.json +12 -7
- package/runtime/windows-job-supervisor.ps1 +722 -0
- package/runtime/windows-listener-owner.ps1 +33 -0
|
@@ -0,0 +1,918 @@
|
|
|
1
|
+
import { qaBlockerReasons, qaCandidateBugActions, qaFindingScopes, qaFindingSeverities, qaGateToolNames, qaScopeAuthorities, qaScopeCapabilities, qaScopeConfidence, qaScopeEvidence, qaScopeFields, qaScopeSources, qaScopeSurfaces } from "./phase-completion.js";
|
|
2
|
+
export function normalizeCompletionStatus(value, phase) {
|
|
3
|
+
const normalized = value.trim().toLowerCase().replace(/-/g, "_");
|
|
4
|
+
if (phase === "qa") {
|
|
5
|
+
const canonical = normalized === "needsrework" ? "needs_rework" : normalized;
|
|
6
|
+
if (canonical !== "pass" && canonical !== "needs_rework" && canonical !== "blocked") {
|
|
7
|
+
throw new Error("QA status must be pass, needs_rework, or blocked.");
|
|
8
|
+
}
|
|
9
|
+
return canonical;
|
|
10
|
+
}
|
|
11
|
+
if (phase === "architect") {
|
|
12
|
+
const canonical = normalized === "needsrevision" ? "needs_revision" : normalized;
|
|
13
|
+
if (canonical !== "pass" && canonical !== "needs_revision" && canonical !== "blocked") {
|
|
14
|
+
throw new Error("Architect status must be pass, needs_revision, or blocked.");
|
|
15
|
+
}
|
|
16
|
+
return canonical;
|
|
17
|
+
}
|
|
18
|
+
const canonical = normalized === "blocked" || normalized === "needsrework"
|
|
19
|
+
? "needs_rework"
|
|
20
|
+
: normalized === "failed"
|
|
21
|
+
? "fail"
|
|
22
|
+
: normalized;
|
|
23
|
+
if (canonical !== "pass" && canonical !== "fail" && canonical !== "needs_rework") {
|
|
24
|
+
throw new Error("status must be pass, fail, or needs_rework.");
|
|
25
|
+
}
|
|
26
|
+
return canonical;
|
|
27
|
+
}
|
|
28
|
+
export function normalizeQaGateStatus(value) {
|
|
29
|
+
const normalized = value.trim().toLowerCase().replace(/-/g, "_");
|
|
30
|
+
if (normalized !== "pass" && normalized !== "skipped" && normalized !== "blocked") {
|
|
31
|
+
throw new Error("QA gate status must be pass, skipped, or blocked.");
|
|
32
|
+
}
|
|
33
|
+
return normalized;
|
|
34
|
+
}
|
|
35
|
+
export function validatePayloadShape(phase, payload) {
|
|
36
|
+
const status = normalizeCompletionStatus(requiredString(payload, "status"), phase);
|
|
37
|
+
if (!status) {
|
|
38
|
+
throw new Error("status is required.");
|
|
39
|
+
}
|
|
40
|
+
requireField(payload, "summary");
|
|
41
|
+
if (phase === "qa") {
|
|
42
|
+
validateQaPayloadShape(payload, status);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (phase === "architect") {
|
|
46
|
+
validateArchitectPayloadShape(payload, status);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const pass = status === "pass";
|
|
50
|
+
if (!pass) {
|
|
51
|
+
requireNonPassEvidence(payload);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (phase === "ci_repair") {
|
|
55
|
+
validateCiRepairPayloadShape(payload);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (phase === "comment_repair") {
|
|
59
|
+
validateCommentRepairPayloadShape(payload);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (isDebugCompletionPhase(phase)) {
|
|
63
|
+
validateDebugPayloadShape(phase, payload);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (phase === "implementation") {
|
|
67
|
+
requireArray(payload, "plan");
|
|
68
|
+
requireArray(payload, "changed_files");
|
|
69
|
+
requireArray(payload, "tests_added_or_updated");
|
|
70
|
+
requireArray(payload, "docs_added_or_updated");
|
|
71
|
+
requireArray(payload, "acceptance_criteria");
|
|
72
|
+
requireArray(payload, "known_gaps");
|
|
73
|
+
requireField(payload, "next_phase_context");
|
|
74
|
+
requireImplementationEvidence(payload);
|
|
75
|
+
requireSatisfiedAcceptanceCriteria(payload);
|
|
76
|
+
validateResolvedFindings(payload);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (phase === "self_review") {
|
|
80
|
+
requireBoolean(payload, "reviewed_diff");
|
|
81
|
+
requireArray(payload, "findings");
|
|
82
|
+
requireObject(payload, "security_review");
|
|
83
|
+
requireObject(payload, "edge_case_review");
|
|
84
|
+
requireObject(payload, "design_review");
|
|
85
|
+
requireObject(payload, "deterministic_comparison");
|
|
86
|
+
requireArray(payload, "fixes_applied");
|
|
87
|
+
requireArray(payload, "remaining_risks");
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
requireArray(payload, "commands");
|
|
91
|
+
requireArray(payload, "acceptance_criteria");
|
|
92
|
+
requireField(payload, "build_status");
|
|
93
|
+
requireField(payload, "test_status");
|
|
94
|
+
requireField(payload, "lint_status");
|
|
95
|
+
requireField(payload, "design_status");
|
|
96
|
+
requireAllowedStatus(payload, "build_status", ["pass"]);
|
|
97
|
+
requireAllowedStatus(payload, "test_status", ["pass"]);
|
|
98
|
+
requireAllowedStatus(payload, "lint_status", ["pass", "not_applicable"]);
|
|
99
|
+
requireAllowedStatus(payload, "design_status", ["pass", "not_applicable"]);
|
|
100
|
+
requireArray(payload, "drift_checks");
|
|
101
|
+
requireBoolean(payload, "ready_for_dev_test");
|
|
102
|
+
if (payload.ready_for_dev_test !== true) {
|
|
103
|
+
throw new Error("validation pass requires ready_for_dev_test: true.");
|
|
104
|
+
}
|
|
105
|
+
requirePassingCommands(payload);
|
|
106
|
+
requireSatisfiedAcceptanceCriteria(payload);
|
|
107
|
+
}
|
|
108
|
+
export function validateCommentRepairPayloadShape(payload) {
|
|
109
|
+
requireNonEmptyStringRecord(payload, "repair_head_sha", "");
|
|
110
|
+
requireNonEmptyStringRecord(payload, "repair_issue_key", "");
|
|
111
|
+
requireArray(payload, "dispositions");
|
|
112
|
+
requireNonEmptyArray(payload, "dispositions");
|
|
113
|
+
const seen = new Set();
|
|
114
|
+
let hasFixed = false;
|
|
115
|
+
for (const [index, entry] of payload.dispositions.entries()) {
|
|
116
|
+
requireEvidenceRecord(entry, `dispositions[${index}]`);
|
|
117
|
+
const record = entry;
|
|
118
|
+
for (const key of ["claim_id", "kind", "body_sha256", "disposition", "evidence"]) {
|
|
119
|
+
requireNonEmptyStringRecord(record, key, `dispositions[${index}].`);
|
|
120
|
+
}
|
|
121
|
+
requireKnownStringRecord(record, "kind", new Set(["review_thread", "review_body", "issue_comment"]), `dispositions[${index}].`);
|
|
122
|
+
requireKnownStringRecord(record, "disposition", new Set(["fixed", "rejected", "already_addressed", "needs_decision", "accepted_transfer", "owned_elsewhere"]), `dispositions[${index}].`);
|
|
123
|
+
if (record.disposition === "owned_elsewhere") {
|
|
124
|
+
const receipt = record.owned_elsewhere;
|
|
125
|
+
if (!receipt || typeof receipt !== "object" || Array.isArray(receipt))
|
|
126
|
+
throw new Error("owned_elsewhere requires the owning Story and its quoted obligation; server verification remains mandatory.");
|
|
127
|
+
const owner = receipt;
|
|
128
|
+
if (typeof owner.target_work_item_id !== "string" || !owner.target_work_item_id.trim())
|
|
129
|
+
throw new Error("owned_elsewhere.target_work_item_id is required.");
|
|
130
|
+
if (typeof owner.quoted_obligation !== "string" || owner.quoted_obligation.trim().length < 40)
|
|
131
|
+
throw new Error("owned_elsewhere.quoted_obligation must quote at least 40 characters of the owning Story's acceptance criteria.");
|
|
132
|
+
}
|
|
133
|
+
if (record.disposition === "accepted_transfer") {
|
|
134
|
+
const receipt = record.accepted_transfer;
|
|
135
|
+
if (!receipt || typeof receipt !== "object" || Array.isArray(receipt))
|
|
136
|
+
throw new Error("accepted_transfer requires an approved amendment receipt; server verification remains mandatory.");
|
|
137
|
+
const transfer = receipt;
|
|
138
|
+
for (const key of ["amendment_id", "content_hash", "target_work_item_id", "obligation_reference"]) {
|
|
139
|
+
if (typeof transfer[key] !== "string" || !transfer[key].trim())
|
|
140
|
+
throw new Error(`accepted_transfer.${key} is required.`);
|
|
141
|
+
}
|
|
142
|
+
if (!Number.isInteger(transfer.version) || Number(transfer.version) < 1)
|
|
143
|
+
throw new Error("accepted_transfer.version must be positive.");
|
|
144
|
+
}
|
|
145
|
+
const identity = `${normalizedString(optionalString(record, "kind"))}:${optionalString(record, "claim_id")}:${normalizedString(optionalString(record, "body_sha256"))}`;
|
|
146
|
+
if (seen.has(identity))
|
|
147
|
+
throw new Error(`dispositions[${index}] duplicates finding ${identity}.`);
|
|
148
|
+
seen.add(identity);
|
|
149
|
+
hasFixed ||= normalizedString(optionalString(record, "disposition")) === "fixed";
|
|
150
|
+
}
|
|
151
|
+
requireArray(payload, "changed_files");
|
|
152
|
+
requireArray(payload, "validation");
|
|
153
|
+
for (const [index, changedFile] of payload.changed_files.entries()) {
|
|
154
|
+
requireEvidenceRecord(changedFile, `changed_files[${index}]`);
|
|
155
|
+
const record = changedFile;
|
|
156
|
+
requireNonEmptyStringRecord(record, "path", `changed_files[${index}].`);
|
|
157
|
+
requireNonEmptyStringRecord(record, "summary", `changed_files[${index}].`);
|
|
158
|
+
}
|
|
159
|
+
if (hasFixed && arrayLength(payload, "changed_files") === 0) {
|
|
160
|
+
throw new Error("fixed comment dispositions require changed_files evidence.");
|
|
161
|
+
}
|
|
162
|
+
if (arrayLength(payload, "changed_files") > 0) {
|
|
163
|
+
requireNonEmptyArray(payload, "validation");
|
|
164
|
+
validateQaGatePassingCommands(payload.validation);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
export function validateCiRepairPayloadShape(payload) {
|
|
168
|
+
requireArray(payload, "failed_checks");
|
|
169
|
+
requireNonEmptyArray(payload, "failed_checks");
|
|
170
|
+
for (const [index, failedCheck] of payload.failed_checks.entries()) {
|
|
171
|
+
requireEvidenceRecord(failedCheck, `failed_checks[${index}]`);
|
|
172
|
+
const record = failedCheck;
|
|
173
|
+
requireNonEmptyStringRecord(record, "name", `failed_checks[${index}].`);
|
|
174
|
+
requireNonEmptyStringRecord(record, "provider_state", `failed_checks[${index}].`);
|
|
175
|
+
}
|
|
176
|
+
requireObject(payload, "root_cause");
|
|
177
|
+
const rootCause = requiredRecord(payload, "root_cause");
|
|
178
|
+
requireNonEmptyStringRecord(rootCause, "classification", "root_cause.");
|
|
179
|
+
requireNonEmptyStringRecord(rootCause, "summary", "root_cause.");
|
|
180
|
+
requireNonEmptyStringArrayRecord(rootCause, "evidence", "root_cause.");
|
|
181
|
+
requireArray(payload, "changed_files");
|
|
182
|
+
if (arrayLength(payload, "changed_files") === 0) {
|
|
183
|
+
requireObject(payload, "no_code_action");
|
|
184
|
+
const noCodeAction = requiredRecord(payload, "no_code_action");
|
|
185
|
+
requireNonEmptyStringRecord(noCodeAction, "action", "no_code_action.");
|
|
186
|
+
requireNonEmptyStringRecord(noCodeAction, "reason", "no_code_action.");
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
for (const [index, changedFile] of payload.changed_files.entries()) {
|
|
190
|
+
requireEvidenceRecord(changedFile, `changed_files[${index}]`);
|
|
191
|
+
const record = changedFile;
|
|
192
|
+
requireNonEmptyStringRecord(record, "path", `changed_files[${index}].`);
|
|
193
|
+
requireNonEmptyStringRecord(record, "summary", `changed_files[${index}].`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
for (const key of ["narrow_validation", "broader_validation"]) {
|
|
197
|
+
requireArray(payload, key);
|
|
198
|
+
requireNonEmptyArray(payload, key);
|
|
199
|
+
validateQaGatePassingCommands(payload[key]);
|
|
200
|
+
}
|
|
201
|
+
requireArray(payload, "workflow_validation");
|
|
202
|
+
requireNonEmptyArray(payload, "workflow_validation");
|
|
203
|
+
for (const [index, entry] of payload.workflow_validation.entries()) {
|
|
204
|
+
requireEvidenceRecord(entry, `workflow_validation[${index}]`);
|
|
205
|
+
const record = entry;
|
|
206
|
+
requireNonEmptyStringRecord(record, "step", `workflow_validation[${index}].`);
|
|
207
|
+
requireKnownStringRecord(record, "equivalence", new Set(["supported", "substitute", "provider_only"]), `workflow_validation[${index}].`);
|
|
208
|
+
requireNonEmptyStringRecord(record, "status", `workflow_validation[${index}].`);
|
|
209
|
+
const equivalence = normalizedString(optionalString(record, "equivalence"));
|
|
210
|
+
if (equivalence === "provider_only") {
|
|
211
|
+
requireNonEmptyStringRecord(record, "reason", `workflow_validation[${index}].`);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
requireNonEmptyStringRecord(record, "command", `workflow_validation[${index}].`);
|
|
215
|
+
requireNonEmptyStringRecord(record, "summary", `workflow_validation[${index}].`);
|
|
216
|
+
if (typeof record.exit_code !== "number" || !Number.isFinite(record.exit_code)) {
|
|
217
|
+
throw new Error(`workflow_validation[${index}].exit_code is required for ${equivalence} evidence.`);
|
|
218
|
+
}
|
|
219
|
+
if (record.exit_code !== 0 || normalizedString(optionalString(record, "status")) !== "pass") {
|
|
220
|
+
throw new Error(`workflow_validation[${index}] must pass locally when equivalence is ${equivalence}.`);
|
|
221
|
+
}
|
|
222
|
+
if (equivalence === "substitute") {
|
|
223
|
+
requireNonEmptyStringRecord(record, "substitute", `workflow_validation[${index}].`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
requireArray(payload, "remaining_risks");
|
|
227
|
+
requireAllowedStatus(payload, "provider_state", ["ready_for_rerun", "rerun_requested", "checks_pending"]);
|
|
228
|
+
requireBoolean(payload, "ready_for_push");
|
|
229
|
+
if (payload.ready_for_push !== true) {
|
|
230
|
+
throw new Error("ci_repair pass requires ready_for_push: true.");
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
export function validateDebugPayloadShape(phase, payload) {
|
|
234
|
+
if (phase === "debug_repro") {
|
|
235
|
+
requireArray(payload, "repro_steps");
|
|
236
|
+
requireField(payload, "observed");
|
|
237
|
+
requireField(payload, "expected");
|
|
238
|
+
requireArray(payload, "evidence");
|
|
239
|
+
requireNonEmptyArray(payload, "repro_steps");
|
|
240
|
+
requireNonEmptyArray(payload, "evidence");
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (phase === "debug_root_cause") {
|
|
244
|
+
requireField(payload, "root_cause");
|
|
245
|
+
requireArray(payload, "evidence");
|
|
246
|
+
requireArray(payload, "eliminated_alternatives");
|
|
247
|
+
requireField(payload, "risk_area");
|
|
248
|
+
requireNonEmptyArray(payload, "evidence");
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (phase === "debug_fix") {
|
|
252
|
+
requireArray(payload, "changed_files");
|
|
253
|
+
requireArray(payload, "tests_added_or_updated");
|
|
254
|
+
requireArray(payload, "remaining_risks");
|
|
255
|
+
requireNonEmptyArray(payload, "changed_files");
|
|
256
|
+
if (arrayLength(payload, "tests_added_or_updated") === 0 && arrayLength(payload, "remaining_risks") === 0) {
|
|
257
|
+
throw new Error("tests_added_or_updated is empty; remaining_risks must explain why no regression coverage changed.");
|
|
258
|
+
}
|
|
259
|
+
validateResolvedFindings(payload);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
requireArray(payload, "commands");
|
|
263
|
+
requireArray(payload, "required_regressions");
|
|
264
|
+
requireField(payload, "build_status");
|
|
265
|
+
requireField(payload, "test_status");
|
|
266
|
+
requireBoolean(payload, "repro_negative");
|
|
267
|
+
requireBoolean(payload, "qa_ready");
|
|
268
|
+
if (payload.repro_negative !== true) {
|
|
269
|
+
throw new Error("debug_regression pass requires repro_negative: true.");
|
|
270
|
+
}
|
|
271
|
+
if (payload.qa_ready !== true) {
|
|
272
|
+
throw new Error("debug_regression pass requires qa_ready: true.");
|
|
273
|
+
}
|
|
274
|
+
requireAllowedStatus(payload, "build_status", ["pass", "not_applicable"]);
|
|
275
|
+
requireAllowedStatus(payload, "test_status", ["pass", "not_applicable"]);
|
|
276
|
+
requireNonEmptyArray(payload, "required_regressions");
|
|
277
|
+
requirePassingCommands(payload);
|
|
278
|
+
}
|
|
279
|
+
export function validateQaGatePayloadShape(gate, payload, status) {
|
|
280
|
+
requireField(payload, "summary");
|
|
281
|
+
if (status === "skipped") {
|
|
282
|
+
requireField(payload, "reason");
|
|
283
|
+
}
|
|
284
|
+
if (status === "blocked") {
|
|
285
|
+
const reason = normalizedString(optionalString(payload, "blocker_reason"));
|
|
286
|
+
if (!qaBlockerReasons.has(reason)) {
|
|
287
|
+
throw new Error("QA gate blocked requires blocker_reason to be one of missing_app_launch_target, inconclusive, environment_failure, missing_validation_evidence, missing_worktree, or runner_failure.");
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (gate === "qa_plan" && status === "pass") {
|
|
291
|
+
const checklist = qaStringArray(payload, "checklist");
|
|
292
|
+
if (checklist.length === 0) {
|
|
293
|
+
throw new Error("complete_qa_plan pass requires checklist with at least one entry.");
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (gate === "qa_findings" && status !== "blocked") {
|
|
297
|
+
qaObjectArray(payload, "findings");
|
|
298
|
+
qaObjectArray(payload, "candidate_bugs");
|
|
299
|
+
}
|
|
300
|
+
if ((gate === "qa_regression" || gate === "qa_api") && status === "pass") {
|
|
301
|
+
const commands = qaArray(payload, "commands");
|
|
302
|
+
if (commands.length === 0) {
|
|
303
|
+
throw new Error(`${qaGateToolNames[gate]} pass requires commands with at least one entry.`);
|
|
304
|
+
}
|
|
305
|
+
validateQaGatePassingCommands(commands, {
|
|
306
|
+
allowClassifiedNonBlockingFailures: gate === "qa_regression"
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
if (gate === "qa_manual_web" && status === "pass") {
|
|
310
|
+
const browserChecks = qaArray(payload, "browser_checks");
|
|
311
|
+
const notes = optionalString(payload, "notes");
|
|
312
|
+
if (browserChecks.length === 0 && !notes) {
|
|
313
|
+
throw new Error(`${qaGateToolNames[gate]} pass requires browser_checks or notes.`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
export function validateQaGatePassingCommands(commands, options = {}) {
|
|
318
|
+
const issues = [];
|
|
319
|
+
for (const [index, command] of commands.entries()) {
|
|
320
|
+
if (!command || typeof command !== "object" || Array.isArray(command)) {
|
|
321
|
+
issues.push(`commands[${index}] must be an object.`);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
const record = command;
|
|
325
|
+
if (!optionalString(record, "command"))
|
|
326
|
+
issues.push(`commands[${index}].command is required.`);
|
|
327
|
+
if (!optionalString(record, "status"))
|
|
328
|
+
issues.push(`commands[${index}].status is required.`);
|
|
329
|
+
if (!optionalString(record, "summary"))
|
|
330
|
+
issues.push(`commands[${index}].summary is required.`);
|
|
331
|
+
if (typeof record.exit_code !== "number" || !Number.isFinite(record.exit_code)) {
|
|
332
|
+
issues.push(`commands[${index}].exit_code is required.`);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
const passed = record.exit_code === 0 && normalizedString(record.status) === "pass";
|
|
336
|
+
if (!passed
|
|
337
|
+
&& options.allowClassifiedNonBlockingFailures
|
|
338
|
+
&& optionalString(record, "classification")) {
|
|
339
|
+
try {
|
|
340
|
+
validateClassifiedNonBlockingQaFailure(record, index);
|
|
341
|
+
}
|
|
342
|
+
catch (error) {
|
|
343
|
+
issues.push(error instanceof Error ? error.message : String(error));
|
|
344
|
+
}
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (record.exit_code !== 0) {
|
|
348
|
+
issues.push(`commands[${index}].exit_code must be 0 when QA gate status is pass.`);
|
|
349
|
+
}
|
|
350
|
+
if (normalizedString(record.status) !== "pass") {
|
|
351
|
+
issues.push(`commands[${index}].status must be pass when QA gate status is pass.`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
throwCompletionPayloadIssues(issues);
|
|
355
|
+
}
|
|
356
|
+
function validateClassifiedNonBlockingQaFailure(record, index) {
|
|
357
|
+
const classification = normalizedString(optionalString(record, "classification"));
|
|
358
|
+
if (classification !== "out_of_scope_failure" && classification !== "baseline_failure") {
|
|
359
|
+
throw new Error(`commands[${index}] is non-passing; QA regression pass requires classification out_of_scope_failure or baseline_failure with corroborating evidence.`);
|
|
360
|
+
}
|
|
361
|
+
requireNonEmptyStringRecord(record, "candidate_bug_id", `commands[${index}].`);
|
|
362
|
+
requireNonEmptyStringRecord(record, "scope_evidence", `commands[${index}].`);
|
|
363
|
+
if (record.acceptance_criteria_independent !== true) {
|
|
364
|
+
throw new Error(`commands[${index}].acceptance_criteria_independent must be true for a non-blocking QA regression failure.`);
|
|
365
|
+
}
|
|
366
|
+
if (classification === "out_of_scope_failure") {
|
|
367
|
+
requireNonEmptyStringRecord(record, "repro_command", `commands[${index}].`);
|
|
368
|
+
if (typeof record.repro_exit_code !== "number" || !Number.isFinite(record.repro_exit_code) || record.repro_exit_code === 0) {
|
|
369
|
+
throw new Error(`commands[${index}].repro_exit_code must be a non-zero number for an out-of-scope failure.`);
|
|
370
|
+
}
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
requireNonEmptyStringRecord(record, "baseline_ref", `commands[${index}].`);
|
|
374
|
+
if (typeof record.baseline_exit_code !== "number" || !Number.isFinite(record.baseline_exit_code) || record.baseline_exit_code === 0) {
|
|
375
|
+
throw new Error(`commands[${index}].baseline_exit_code must be a non-zero number for a baseline failure.`);
|
|
376
|
+
}
|
|
377
|
+
if (record.failure_signature_match !== true) {
|
|
378
|
+
throw new Error(`commands[${index}].failure_signature_match must be true for a baseline failure.`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
export function validateArchitectPayloadShape(payload, status) {
|
|
382
|
+
if (status !== "pass") {
|
|
383
|
+
requireArchitectNonPassEvidence(payload);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
const implementationPlan = requiredRecord(payload, "implementation_plan");
|
|
387
|
+
const qaPlan = requiredRecord(payload, "qa_plan");
|
|
388
|
+
requireNumericOne(implementationPlan, "schema_version", "implementation_plan.schema_version");
|
|
389
|
+
requireNonEmptyStringRecord(implementationPlan, "plan_id", "implementation_plan.");
|
|
390
|
+
requireArchitectArray(implementationPlan, "phases", payload);
|
|
391
|
+
requireArchitectArray(implementationPlan, "tasks", payload);
|
|
392
|
+
requireArchitectArray(implementationPlan, "dependencies", payload, { allowEmpty: true });
|
|
393
|
+
requireArchitectArray(implementationPlan, "phase_gates", payload);
|
|
394
|
+
if (arrayLength(implementationPlan, "tasks") === 0) {
|
|
395
|
+
throw new Error("implementation_plan.tasks: expected non-empty array; correction: include at least one task with id and tests.");
|
|
396
|
+
}
|
|
397
|
+
const taskIds = new Set();
|
|
398
|
+
const taskIndexById = new Map();
|
|
399
|
+
for (const [taskIndex, task] of implementationPlan.tasks.entries()) {
|
|
400
|
+
if (!task || typeof task !== "object" || Array.isArray(task)) {
|
|
401
|
+
throw new Error("implementation_plan.tasks[]: expected object entries; correction: each task must be an object with id and tests.");
|
|
402
|
+
}
|
|
403
|
+
const taskRecord = task;
|
|
404
|
+
requireNonEmptyStringRecord(taskRecord, "id", "implementation_plan.tasks[].");
|
|
405
|
+
const taskId = optionalString(taskRecord, "id") ?? "";
|
|
406
|
+
const firstTaskIndex = taskIndexById.get(taskId);
|
|
407
|
+
if (firstTaskIndex !== undefined) {
|
|
408
|
+
throw new Error(`implementation_plan.tasks[${taskIndex}].id duplicates implementation_plan.tasks[${firstTaskIndex}].id '${taskId}'; correction: assign the duplicate a unique stable task id, update every phase/dependency reference to that task, and resubmit forge.complete_architect_plan.`);
|
|
409
|
+
}
|
|
410
|
+
taskIds.add(taskId);
|
|
411
|
+
taskIndexById.set(taskId, taskIndex);
|
|
412
|
+
validateArchitectTaskDevelopScope(taskRecord, taskId);
|
|
413
|
+
requireNonEmptyStringArrayRecord(taskRecord, "acceptance_criteria", `implementation_plan.tasks[${taskId}].`);
|
|
414
|
+
validateArchitectVerificationScope(taskRecord, taskId);
|
|
415
|
+
requireArchitectArray(taskRecord, "tests", payload, { path: `implementation_plan.tasks[${optionalString(taskRecord, "id") ?? "?"}].tests` });
|
|
416
|
+
if (arrayLength(taskRecord, "tests") === 0) {
|
|
417
|
+
throw new Error(`implementation_plan.tasks[${optionalString(taskRecord, "id") ?? "?"}].tests: expected non-empty array; correction: include task-specific verification.`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
validateAndNormalizeArchitectDependencies(implementationPlan, taskIds);
|
|
421
|
+
requireNumericOne(qaPlan, "schema_version", "qa_plan.schema_version");
|
|
422
|
+
requireArchitectArray(qaPlan, "final_qa", payload, { path: "qa_plan.final_qa" });
|
|
423
|
+
if (arrayLength(qaPlan, "final_qa") === 0) {
|
|
424
|
+
throw new Error("qa_plan.final_qa: expected non-empty array; correction: include final QA coverage for the completed plan.");
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
export function validateArchitectTaskDevelopScope(task, taskId) {
|
|
428
|
+
const explicitScope = firstOptionalString(task, ["execution_scope", "scope", "task_scope", "task_type", "lane", "kind", "type"]);
|
|
429
|
+
if (isStoryQaScope(explicitScope)) {
|
|
430
|
+
throw new Error(`implementation_plan.tasks[${taskId}]: story QA/sign-off work must not be materialized as a Develop child task; correction: move this coverage to qa_plan.final_qa and keep implementation_plan.tasks limited to repo/product changes.`);
|
|
431
|
+
}
|
|
432
|
+
if (looksLikeStoryLevelVerificationTask(architectTaskScopeText(task))) {
|
|
433
|
+
throw new Error(`implementation_plan.tasks[${taskId}]: parent-story materialization or sign-off verification belongs in qa_plan.final_qa, not implementation_plan.tasks; correction: keep child tasks focused on implementation work and put child-count/parent-plan artifact checks in final QA coverage.`);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
export function validateArchitectVerificationScope(task, taskId) {
|
|
437
|
+
const value = task.verification_scope;
|
|
438
|
+
if (value === undefined || value === null) {
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
442
|
+
throw new Error(`implementation_plan.tasks[${taskId}].verification_scope: expected object when present.`);
|
|
443
|
+
}
|
|
444
|
+
validateQaScopeRecord(value, `implementation_plan.tasks[${taskId}].verification_scope`);
|
|
445
|
+
}
|
|
446
|
+
export function validateQaScopeRecord(scope, pathLabel) {
|
|
447
|
+
for (const key of Object.keys(scope)) {
|
|
448
|
+
if (!qaScopeFields.has(key)) {
|
|
449
|
+
throw new Error(`${pathLabel}.${key}: unsupported QA scope field.`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (scope.schema_version !== 1) {
|
|
453
|
+
throw new Error(`${pathLabel}.schema_version must be numeric 1.`);
|
|
454
|
+
}
|
|
455
|
+
const surface = qaScopeStringArray(scope, "surface", pathLabel, qaScopeSurfaces);
|
|
456
|
+
const requiredEvidence = qaScopeStringArray(scope, "required_evidence", pathLabel, qaScopeEvidence);
|
|
457
|
+
qaScopeStringArray(scope, "required_capabilities", pathLabel, qaScopeCapabilities);
|
|
458
|
+
const notApplicable = qaScopeStringArray(scope, "not_applicable", pathLabel, qaScopeEvidence);
|
|
459
|
+
const requiredSet = new Set(requiredEvidence);
|
|
460
|
+
for (const evidence of notApplicable) {
|
|
461
|
+
if (requiredSet.has(evidence)) {
|
|
462
|
+
throw new Error(`${pathLabel}: '${evidence}' cannot be both required and not applicable.`);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
const browserRequired = requiredSet.has("browser_manual");
|
|
466
|
+
const apiRequired = requiredSet.has("api_http");
|
|
467
|
+
validateOptionalBoolean(scope, "browser_required", pathLabel, browserRequired);
|
|
468
|
+
validateOptionalBoolean(scope, "api_required", pathLabel, apiRequired);
|
|
469
|
+
validateOptionalBoolean(scope, "confirmed_by_user", pathLabel);
|
|
470
|
+
validateOptionalBoolean(scope, "locked", pathLabel);
|
|
471
|
+
if (surface.includes("ui_browser") && !browserRequired) {
|
|
472
|
+
throw new Error(`${pathLabel}.surface ui_browser requires browser_manual evidence.`);
|
|
473
|
+
}
|
|
474
|
+
if (surface.includes("api_route") && !apiRequired) {
|
|
475
|
+
throw new Error(`${pathLabel}.surface api_route requires api_http evidence.`);
|
|
476
|
+
}
|
|
477
|
+
validateOptionalKnownString(scope, "source", pathLabel, qaScopeSources);
|
|
478
|
+
validateOptionalKnownString(scope, "authority", pathLabel, qaScopeAuthorities);
|
|
479
|
+
validateOptionalKnownString(scope, "confidence", pathLabel, qaScopeConfidence);
|
|
480
|
+
if (scope.rationale !== undefined && scope.rationale !== null && typeof scope.rationale !== "string") {
|
|
481
|
+
throw new Error(`${pathLabel}.rationale must be a string.`);
|
|
482
|
+
}
|
|
483
|
+
validateOptionalQaScopeDerivedFrom(scope, pathLabel);
|
|
484
|
+
}
|
|
485
|
+
export function qaScopeStringArray(scope, key, pathLabel, allowed) {
|
|
486
|
+
const value = scope[key];
|
|
487
|
+
if (value === undefined || value === null) {
|
|
488
|
+
return [];
|
|
489
|
+
}
|
|
490
|
+
if (!Array.isArray(value)) {
|
|
491
|
+
throw new Error(`${pathLabel}.${key} must be an array.`);
|
|
492
|
+
}
|
|
493
|
+
const normalized = [];
|
|
494
|
+
for (const [index, entry] of value.entries()) {
|
|
495
|
+
if (typeof entry !== "string" || entry.trim().length === 0) {
|
|
496
|
+
throw new Error(`${pathLabel}.${key}[${index}] must be a non-empty string.`);
|
|
497
|
+
}
|
|
498
|
+
const candidate = entry.trim().toLowerCase();
|
|
499
|
+
if (!allowed.has(candidate)) {
|
|
500
|
+
throw new Error(`${pathLabel}.${key}[${index}] value '${candidate}' is unsupported.`);
|
|
501
|
+
}
|
|
502
|
+
if (!normalized.includes(candidate)) {
|
|
503
|
+
normalized.push(candidate);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return normalized;
|
|
507
|
+
}
|
|
508
|
+
export function validateOptionalBoolean(scope, key, pathLabel, expected) {
|
|
509
|
+
const value = scope[key];
|
|
510
|
+
if (value === undefined || value === null) {
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
if (typeof value !== "boolean") {
|
|
514
|
+
throw new Error(`${pathLabel}.${key} must be boolean.`);
|
|
515
|
+
}
|
|
516
|
+
if (expected !== undefined && value !== expected) {
|
|
517
|
+
throw new Error(`${pathLabel}.${key} must match required_evidence.`);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
export function validateOptionalKnownString(scope, key, pathLabel, allowed) {
|
|
521
|
+
const value = scope[key];
|
|
522
|
+
if (value === undefined || value === null) {
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
526
|
+
throw new Error(`${pathLabel}.${key} must be a non-empty string.`);
|
|
527
|
+
}
|
|
528
|
+
const normalized = value.trim().toLowerCase();
|
|
529
|
+
if (!allowed.has(normalized)) {
|
|
530
|
+
throw new Error(`${pathLabel}.${key} value '${normalized}' is unsupported.`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
export function validateOptionalQaScopeDerivedFrom(scope, pathLabel) {
|
|
534
|
+
const value = scope.derived_from;
|
|
535
|
+
if (value === undefined || value === null) {
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
539
|
+
throw new Error(`${pathLabel}.derived_from must be an object.`);
|
|
540
|
+
}
|
|
541
|
+
const derivedFrom = value;
|
|
542
|
+
for (const key of Object.keys(derivedFrom)) {
|
|
543
|
+
if (key !== "work_item_id" && key !== "mode") {
|
|
544
|
+
throw new Error(`${pathLabel}.derived_from.${key}: unsupported QA scope provenance field.`);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
if (derivedFrom.work_item_id !== undefined && derivedFrom.work_item_id !== null && typeof derivedFrom.work_item_id !== "string") {
|
|
548
|
+
throw new Error(`${pathLabel}.derived_from.work_item_id must be a string.`);
|
|
549
|
+
}
|
|
550
|
+
if (derivedFrom.mode !== undefined && derivedFrom.mode !== null && typeof derivedFrom.mode !== "string") {
|
|
551
|
+
throw new Error(`${pathLabel}.derived_from.mode must be a string.`);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
export function firstOptionalString(record, keys) {
|
|
555
|
+
for (const key of keys) {
|
|
556
|
+
const value = optionalString(record, key);
|
|
557
|
+
if (value) {
|
|
558
|
+
return value;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
export function isStoryQaScope(value) {
|
|
564
|
+
if (!value) {
|
|
565
|
+
return false;
|
|
566
|
+
}
|
|
567
|
+
const normalized = value.trim().toLowerCase().replace(/[\s-]+/g, "_");
|
|
568
|
+
return [
|
|
569
|
+
"qa",
|
|
570
|
+
"story_qa",
|
|
571
|
+
"final_qa",
|
|
572
|
+
"sign_off",
|
|
573
|
+
"signoff",
|
|
574
|
+
"story_sign_off",
|
|
575
|
+
"story_verification",
|
|
576
|
+
"story_validation"
|
|
577
|
+
].includes(normalized);
|
|
578
|
+
}
|
|
579
|
+
export function architectTaskScopeText(task) {
|
|
580
|
+
const parts = [];
|
|
581
|
+
for (const key of ["id", "title", "summary", "phase", "phase_id"]) {
|
|
582
|
+
appendOptionalString(parts, task, key);
|
|
583
|
+
}
|
|
584
|
+
for (const key of ["acceptance_criteria", "expected_paths", "touched_surfaces", "implementation_notes"]) {
|
|
585
|
+
appendOptionalStringArray(parts, task, key);
|
|
586
|
+
}
|
|
587
|
+
const tests = task.tests;
|
|
588
|
+
if (Array.isArray(tests)) {
|
|
589
|
+
for (const test of tests) {
|
|
590
|
+
if (!test || typeof test !== "object" || Array.isArray(test)) {
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
const record = test;
|
|
594
|
+
for (const key of ["id", "command", "purpose", "target", "expected"]) {
|
|
595
|
+
appendOptionalString(parts, record, key);
|
|
596
|
+
}
|
|
597
|
+
appendOptionalStringArray(parts, record, "steps");
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
return parts.join(" ");
|
|
601
|
+
}
|
|
602
|
+
export function appendOptionalString(parts, record, key) {
|
|
603
|
+
const value = optionalString(record, key);
|
|
604
|
+
if (value) {
|
|
605
|
+
parts.push(value);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
export function appendOptionalStringArray(parts, record, key) {
|
|
609
|
+
const value = record[key];
|
|
610
|
+
if (!Array.isArray(value)) {
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
for (const entry of value) {
|
|
614
|
+
if (typeof entry === "string" && entry.trim().length > 0) {
|
|
615
|
+
parts.push(entry.trim());
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
export function looksLikeStoryLevelVerificationTask(text) {
|
|
620
|
+
if (!text.trim()) {
|
|
621
|
+
return false;
|
|
622
|
+
}
|
|
623
|
+
const normalized = text.toLowerCase();
|
|
624
|
+
const verificationWords = [
|
|
625
|
+
"verify",
|
|
626
|
+
"verification",
|
|
627
|
+
"validate",
|
|
628
|
+
"validation",
|
|
629
|
+
"inspect",
|
|
630
|
+
"audit",
|
|
631
|
+
"check",
|
|
632
|
+
"confirm",
|
|
633
|
+
"prove",
|
|
634
|
+
"evidence",
|
|
635
|
+
"sign-off",
|
|
636
|
+
"sign off"
|
|
637
|
+
];
|
|
638
|
+
if (!verificationWords.some((word) => normalized.includes(word))) {
|
|
639
|
+
return false;
|
|
640
|
+
}
|
|
641
|
+
return [
|
|
642
|
+
"materialization",
|
|
643
|
+
"materialized child",
|
|
644
|
+
"child inventory",
|
|
645
|
+
"child work-item",
|
|
646
|
+
"child work item",
|
|
647
|
+
"created task count",
|
|
648
|
+
"created_task_count",
|
|
649
|
+
"parent plan",
|
|
650
|
+
"parent story",
|
|
651
|
+
"story sign-off",
|
|
652
|
+
"story sign off",
|
|
653
|
+
".forge/work-items",
|
|
654
|
+
".forge\\work-items",
|
|
655
|
+
".forge/stories",
|
|
656
|
+
".forge\\stories",
|
|
657
|
+
"implementation.plan.json",
|
|
658
|
+
"qa.plan.json",
|
|
659
|
+
"architect plan artifact",
|
|
660
|
+
"child spec"
|
|
661
|
+
].some((needle) => normalized.includes(needle));
|
|
662
|
+
}
|
|
663
|
+
export function validateAndNormalizeArchitectDependencies(implementationPlan, taskIds) {
|
|
664
|
+
const dependencies = implementationPlan.dependencies;
|
|
665
|
+
const edges = new Map();
|
|
666
|
+
for (const taskId of taskIds) {
|
|
667
|
+
edges.set(taskId, []);
|
|
668
|
+
}
|
|
669
|
+
const normalizedDependencies = [];
|
|
670
|
+
for (const [index, dependency] of dependencies.entries()) {
|
|
671
|
+
if (!dependency || typeof dependency !== "object" || Array.isArray(dependency)) {
|
|
672
|
+
throw new Error("implementation_plan.dependencies[]: expected object entries; correction: each dependency must be an object with before and after task ids.");
|
|
673
|
+
}
|
|
674
|
+
const record = dependency;
|
|
675
|
+
const before = architectDependencyEndpoint(record, index, "before", "from");
|
|
676
|
+
const after = architectDependencyEndpoint(record, index, "after", "to");
|
|
677
|
+
if (!taskIds.has(before) || !taskIds.has(after)) {
|
|
678
|
+
throw new Error("implementation_plan.dependencies: dependency references an unknown task id.");
|
|
679
|
+
}
|
|
680
|
+
const normalized = stripUndefined({
|
|
681
|
+
...record,
|
|
682
|
+
before,
|
|
683
|
+
after,
|
|
684
|
+
from: undefined,
|
|
685
|
+
to: undefined
|
|
686
|
+
});
|
|
687
|
+
normalizedDependencies.push(normalized);
|
|
688
|
+
edges.get(before)?.push(after);
|
|
689
|
+
}
|
|
690
|
+
implementationPlan.dependencies = normalizedDependencies;
|
|
691
|
+
if (hasArchitectDependencyCycle(edges)) {
|
|
692
|
+
throw new Error("implementation_plan.dependencies: expected acyclic graph; correction: remove or revise cyclic task dependencies.");
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
export function architectDependencyEndpoint(dependency, index, canonicalKey, aliasKey) {
|
|
696
|
+
const canonical = optionalString(dependency, canonicalKey);
|
|
697
|
+
const alias = optionalString(dependency, aliasKey);
|
|
698
|
+
if (canonical && alias && canonical !== alias) {
|
|
699
|
+
throw new Error(`implementation_plan.dependencies[${index}].${canonicalKey}: conflicts with ${aliasKey}; correction: use one task id for ${canonicalKey}.`);
|
|
700
|
+
}
|
|
701
|
+
const value = canonical ?? alias;
|
|
702
|
+
if (!value) {
|
|
703
|
+
throw new Error(`implementation_plan.dependencies[${index}].${canonicalKey}: expected string; correction: use before and after task ids for each dependency.`);
|
|
704
|
+
}
|
|
705
|
+
return value;
|
|
706
|
+
}
|
|
707
|
+
export function hasArchitectDependencyCycle(edges) {
|
|
708
|
+
const visiting = new Set();
|
|
709
|
+
const visited = new Set();
|
|
710
|
+
const visit = (node) => {
|
|
711
|
+
if (visited.has(node)) {
|
|
712
|
+
return false;
|
|
713
|
+
}
|
|
714
|
+
if (visiting.has(node)) {
|
|
715
|
+
return true;
|
|
716
|
+
}
|
|
717
|
+
visiting.add(node);
|
|
718
|
+
for (const next of edges.get(node) ?? []) {
|
|
719
|
+
if (visit(next)) {
|
|
720
|
+
return true;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
visiting.delete(node);
|
|
724
|
+
visited.add(node);
|
|
725
|
+
return false;
|
|
726
|
+
};
|
|
727
|
+
for (const node of edges.keys()) {
|
|
728
|
+
if (visit(node)) {
|
|
729
|
+
return true;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
return false;
|
|
733
|
+
}
|
|
734
|
+
export function requireArchitectNonPassEvidence(payload) {
|
|
735
|
+
const evidenceKeys = ["blockers", "revision_questions", "revision_reasons", "known_gaps", "findings", "remaining_risks", "failures"];
|
|
736
|
+
const hasEvidence = evidenceKeys.some((key) => {
|
|
737
|
+
const value = payload[key];
|
|
738
|
+
if (typeof value === "string") {
|
|
739
|
+
return value.trim().length > 0;
|
|
740
|
+
}
|
|
741
|
+
if (Array.isArray(value)) {
|
|
742
|
+
return value.length > 0;
|
|
743
|
+
}
|
|
744
|
+
return Boolean(value && typeof value === "object");
|
|
745
|
+
});
|
|
746
|
+
if (!hasEvidence) {
|
|
747
|
+
throw new Error("status: non-pass Architect completion requires blockers, revision_questions, revision_reasons, findings, failures, or known_gaps; correction: explain why the plan cannot pass.");
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
export function requireArchitectArray(source, key, rootPayload, options = {}) {
|
|
751
|
+
const pathLabel = options.path ?? `implementation_plan.${key}`;
|
|
752
|
+
if (!Array.isArray(source[key])) {
|
|
753
|
+
const nestedHint = architectNestedArrayHint(rootPayload, key);
|
|
754
|
+
throw new Error(`${pathLabel}: expected array${key === "phases" ? " as a top-level field inside implementation_plan" : ""}; correction: ${nestedHint ?? `set ${pathLabel} to an array${options.allowEmpty ? " (use [] when none exist)" : " with at least one entry"}.`}`);
|
|
755
|
+
}
|
|
756
|
+
if (!options.allowEmpty && arrayLength(source, key) === 0) {
|
|
757
|
+
throw new Error(`${pathLabel}: expected non-empty array; correction: include at least one entry.`);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
export function architectNestedArrayHint(rootPayload, key) {
|
|
761
|
+
if (Array.isArray(rootPayload[key])) {
|
|
762
|
+
return `move top-level ${key} into implementation_plan.${key}.`;
|
|
763
|
+
}
|
|
764
|
+
const implementationPlan = rootPayload.implementation_plan;
|
|
765
|
+
if (!implementationPlan || typeof implementationPlan !== "object" || Array.isArray(implementationPlan)) {
|
|
766
|
+
return null;
|
|
767
|
+
}
|
|
768
|
+
const plan = implementationPlan;
|
|
769
|
+
for (const container of ["plan", "dag", "implementation_dag", "implementationPlan", "implementation_plan"]) {
|
|
770
|
+
const nested = plan[container];
|
|
771
|
+
if (nested && typeof nested === "object" && !Array.isArray(nested) && Array.isArray(nested[key])) {
|
|
772
|
+
return `move implementation_plan.${container}.${key} to implementation_plan.${key}.`;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
return null;
|
|
776
|
+
}
|
|
777
|
+
export function validateQaPayloadShape(payload, status) {
|
|
778
|
+
const evidenceSource = qaEvidenceSource(payload);
|
|
779
|
+
const commands = qaArray(payload, "commands", evidenceSource);
|
|
780
|
+
const browserChecks = qaArray(payload, "browser_checks", evidenceSource);
|
|
781
|
+
const sidecars = qaArray(payload, "sidecars", evidenceSource);
|
|
782
|
+
const notes = optionalString(payload, "notes") ?? optionalString(evidenceSource, "notes") ?? "";
|
|
783
|
+
const testedAcceptanceCriteria = qaStringArray(payload, "tested_acceptance_criteria");
|
|
784
|
+
const findings = qaObjectArray(payload, "findings");
|
|
785
|
+
const candidateBugs = qaObjectArray(payload, "candidate_bugs");
|
|
786
|
+
const explicitFindingIds = qaStringArray(payload, "finding_ids");
|
|
787
|
+
const explicitCandidateBugIds = qaStringArray(payload, "candidate_bug_ids");
|
|
788
|
+
qaPolicyWarnings(payload);
|
|
789
|
+
if (payload.schema_version !== undefined && payload.schema_version !== 1) {
|
|
790
|
+
throw new Error("QA schema_version must be numeric 1 when provided.");
|
|
791
|
+
}
|
|
792
|
+
if (status === "pass" || status === "needs_rework") {
|
|
793
|
+
if (testedAcceptanceCriteria.length === 0) {
|
|
794
|
+
throw new Error("QA pass/needs_rework requires tested_acceptance_criteria with at least one criterion.");
|
|
795
|
+
}
|
|
796
|
+
if (commands.length === 0 && browserChecks.length === 0 && notes.length === 0) {
|
|
797
|
+
throw new Error("QA pass/needs_rework requires at least one command, browser check, or evidence note.");
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
const commandIssues = [];
|
|
801
|
+
for (const [index, command] of commands.entries()) {
|
|
802
|
+
if (!command || typeof command !== "object" || Array.isArray(command)) {
|
|
803
|
+
commandIssues.push(`commands[${index}] must be an object.`);
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
const record = command;
|
|
807
|
+
if (!optionalString(record, "command"))
|
|
808
|
+
commandIssues.push(`commands[${index}].command is required.`);
|
|
809
|
+
if (!optionalString(record, "status"))
|
|
810
|
+
commandIssues.push(`commands[${index}].status is required.`);
|
|
811
|
+
if (!optionalString(record, "summary"))
|
|
812
|
+
commandIssues.push(`commands[${index}].summary is required.`);
|
|
813
|
+
if (typeof record.exit_code !== "number" || !Number.isFinite(record.exit_code)) {
|
|
814
|
+
commandIssues.push(`commands[${index}].exit_code is required.`);
|
|
815
|
+
}
|
|
816
|
+
else if (status === "pass" && record.exit_code !== 0) {
|
|
817
|
+
commandIssues.push(`commands[${index}].exit_code must be 0 when QA status is pass.`);
|
|
818
|
+
}
|
|
819
|
+
if (status === "pass" && normalizedString(record.status) !== "pass") {
|
|
820
|
+
commandIssues.push(`commands[${index}].status must be pass when QA status is pass.`);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
throwCompletionPayloadIssues(commandIssues);
|
|
824
|
+
for (const [index, browserCheck] of browserChecks.entries()) {
|
|
825
|
+
requireEvidenceRecord(browserCheck, `browser_checks[${index}]`);
|
|
826
|
+
const record = browserCheck;
|
|
827
|
+
requireNonEmptyStringRecord(record, "summary", `browser_checks[${index}].`);
|
|
828
|
+
requireNonEmptyStringRecord(record, "status", `browser_checks[${index}].`);
|
|
829
|
+
const checkStatus = optionalString(record, "status");
|
|
830
|
+
if (status === "pass" && checkStatus && normalizedString(checkStatus) !== "pass") {
|
|
831
|
+
throw new Error(`browser_checks[${index}].status must be pass when QA status is pass.`);
|
|
832
|
+
}
|
|
833
|
+
if (!optionalString(record, "url") && !optionalString(record, "route")) {
|
|
834
|
+
throw new Error(`browser_checks[${index}] must include url or route.`);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
for (const [index, sidecar] of sidecars.entries()) {
|
|
838
|
+
requireEvidenceRecord(sidecar, `sidecars[${index}]`);
|
|
839
|
+
const record = sidecar;
|
|
840
|
+
requireNonEmptyStringRecord(record, "path", `sidecars[${index}].`);
|
|
841
|
+
requireNonEmptyStringRecord(record, "content_hash", `sidecars[${index}].`);
|
|
842
|
+
if (typeof record.bytes !== "number" || !Number.isFinite(record.bytes) || record.bytes <= 0) {
|
|
843
|
+
throw new Error(`sidecars[${index}].bytes must be greater than 0.`);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
for (const [index, finding] of findings.entries()) {
|
|
847
|
+
requireNonEmptyStringRecord(finding, "id", `findings[${index}].`);
|
|
848
|
+
requireNonEmptyStringRecord(finding, "title", `findings[${index}].`);
|
|
849
|
+
requireKnownStringRecord(finding, "severity", qaFindingSeverities, `findings[${index}].`);
|
|
850
|
+
requireKnownStringRecord(finding, "scope", qaFindingScopes, `findings[${index}].`);
|
|
851
|
+
if (normalizedString(finding.scope) === "in_scope") {
|
|
852
|
+
validateActionableQaFinding(finding, index);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
for (const [index, candidateBug] of candidateBugs.entries()) {
|
|
856
|
+
requireNonEmptyStringRecord(candidateBug, "id", `candidate_bugs[${index}].`);
|
|
857
|
+
requireNonEmptyStringRecord(candidateBug, "title", `candidate_bugs[${index}].`);
|
|
858
|
+
requireKnownStringRecord(candidateBug, "suggested_action", qaCandidateBugActions, `candidate_bugs[${index}].`);
|
|
859
|
+
}
|
|
860
|
+
const inScopeFindingIds = findings
|
|
861
|
+
.filter((finding) => normalizedString(finding.scope) === "in_scope")
|
|
862
|
+
.map((finding) => optionalString(finding, "id"))
|
|
863
|
+
.filter((id) => Boolean(id));
|
|
864
|
+
for (const findingId of explicitFindingIds) {
|
|
865
|
+
if (!inScopeFindingIds.includes(findingId)) {
|
|
866
|
+
throw new Error(`QA finding_ids contains '${findingId}', but findings has no matching in-scope finding.`);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
const candidateBugIds = candidateBugs
|
|
870
|
+
.map((candidateBug) => optionalString(candidateBug, "id"))
|
|
871
|
+
.filter((id) => Boolean(id));
|
|
872
|
+
for (const candidateBugId of explicitCandidateBugIds) {
|
|
873
|
+
if (!candidateBugIds.includes(candidateBugId)) {
|
|
874
|
+
throw new Error(`QA candidate_bug_ids contains '${candidateBugId}', but candidate_bugs has no matching candidate bug.`);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
if (status === "pass") {
|
|
878
|
+
if (explicitFindingIds.length > 0 || inScopeFindingIds.length > 0) {
|
|
879
|
+
throw new Error("QA pass must not include in-scope finding_ids or in-scope findings.");
|
|
880
|
+
}
|
|
881
|
+
if (optionalString(payload, "blocker_reason")) {
|
|
882
|
+
throw new Error("QA pass must not include blocker_reason.");
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
if (status === "needs_rework") {
|
|
886
|
+
if (inScopeFindingIds.length === 0) {
|
|
887
|
+
throw new Error("QA needs_rework requires at least one actionable in-scope finding.");
|
|
888
|
+
}
|
|
889
|
+
if (optionalString(payload, "blocker_reason")) {
|
|
890
|
+
throw new Error("QA needs_rework must not include blocker_reason.");
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
if (status === "blocked") {
|
|
894
|
+
const reason = normalizedString(optionalString(payload, "blocker_reason"));
|
|
895
|
+
if (!qaBlockerReasons.has(reason)) {
|
|
896
|
+
throw new Error("QA blocked requires blocker_reason to be one of missing_app_launch_target, inconclusive, environment_failure, missing_validation_evidence, missing_worktree, or runner_failure.");
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
export function validateActionableQaFinding(finding, index) {
|
|
901
|
+
const prefix = `findings[${index}].`;
|
|
902
|
+
requireNonEmptyStringArrayRecord(finding, "acceptance_criteria", prefix);
|
|
903
|
+
requireNonEmptyStringArrayRecord(finding, "repro_steps", prefix);
|
|
904
|
+
requireNonEmptyStringRecord(finding, "expected", prefix);
|
|
905
|
+
requireNonEmptyStringRecord(finding, "actual", prefix);
|
|
906
|
+
requireSubstantiveEvidenceRecord(finding, prefix);
|
|
907
|
+
requireNonEmptyStringArrayRecord(finding, "likely_area", prefix);
|
|
908
|
+
requireNonEmptyStringRecord(finding, "required_fix", prefix);
|
|
909
|
+
requireNonEmptyStringRecord(finding, "required_regression", prefix);
|
|
910
|
+
}
|
|
911
|
+
export function validateOptionalWorkItemId(payload, expectedWorkItemId) {
|
|
912
|
+
const actual = optionalString(payload, "work_item_id") ?? optionalString(payload, "workItemId");
|
|
913
|
+
if (actual && actual !== expectedWorkItemId) {
|
|
914
|
+
throw new Error(`completion payload work item ${actual} does not match dispatch work item ${expectedWorkItemId}.`);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
import { requireField, requireNonPassEvidence, requireImplementationEvidence, requirePassingCommands, requireArray, requireNonEmptyArray, arrayLength, requireAllowedStatus, requireObject, requiredRecord, requireBoolean, requireNumericOne, requireSatisfiedAcceptanceCriteria, validateResolvedFindings, isDebugCompletionPhase, qaEvidenceSource, qaArray, qaObjectArray, qaPolicyWarnings, qaStringArray, requireEvidenceRecord, requireNonEmptyStringRecord, requireKnownStringRecord, requireNonEmptyStringArrayRecord, requireSubstantiveEvidenceRecord, normalizedString, requiredString, optionalString, stripUndefined, throwCompletionPayloadIssues, } from "./phase-completion.helpers.js";
|
|
918
|
+
export * from "./phase-completion.helpers.js";
|