@tea-agent/loop-agent 0.24.6 → 0.24.8
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/CHANGELOG.md +28 -0
- package/dist/executors/shell-executor.js +36 -1
- package/dist/worker/delivery/git-transaction.js +32 -7
- package/dist/worker/delivery/verification-bundle.js +26 -5
- package/dist/worker/outcomes/projector.js +5 -1
- package/dist/worker/runner/run-ready.js +41 -1
- package/dist/workflows/dag/frontend-test-result-contract.js +106 -41
- package/dist/workflows/dag/init-hybrid.js +4 -6
- package/dist/workflows/dag/types.js +2 -0
- package/dist/workflows/dag/validate.js +3 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,34 @@
|
|
|
6
6
|
|
|
7
7
|
- Observe / Inspect 节点检查器在「节点输出」左侧新增「节点输入」页签:只读投影冻结 `run.json` 顶层节点定义(任务正文、依赖、执行器摘要、边界)与可选 assembled prompt 指纹;完整 assembled prompt 与动态展开子节点仍不在本轮范围
|
|
8
8
|
|
|
9
|
+
### 修复
|
|
10
|
+
|
|
11
|
+
- 修复前端证据校验在 Windows 下通过长 `node -e` 命令执行时的终端转义崩溃:改为由 runtime 内部直接校验,兼容 CMD、Git Bash、PowerShell 与 Unix shell
|
|
12
|
+
|
|
13
|
+
## [0.24.8] - 2026-07-29
|
|
14
|
+
|
|
15
|
+
### 重点更新
|
|
16
|
+
|
|
17
|
+
- 修复 FINAL-VERIFY 改写已存在的 allowed_paths 证据(gitignore)时 Git checkpoint 误失败,以及 record-error 后 Task Pool 卡在 Running 无法 mark-failed/retry 的问题
|
|
18
|
+
|
|
19
|
+
### 修复
|
|
20
|
+
|
|
21
|
+
- `finalizeGitTask` 对 allowed_paths 内的 pre-existing ignored evidence 允许 rewrite 并 force-add
|
|
22
|
+
- `runReadyTasks` 在 record-error 时 best-effort 投影 `runs.jsonl` + Failed 状态,便于官方 retry
|
|
23
|
+
|
|
24
|
+
## [0.24.7] - 2026-07-29
|
|
25
|
+
|
|
26
|
+
### 重点更新
|
|
27
|
+
|
|
28
|
+
- 修复 Feature Verification Bundle 在 Delivery 重验时因绝对路径 artifact、`qa-testcode` 类型与 `run_record` 后写 hash 漂移而失败的问题
|
|
29
|
+
|
|
30
|
+
### 修复
|
|
31
|
+
|
|
32
|
+
- Outcome 投影强制写出 repo-relative artifact 路径
|
|
33
|
+
- Delivery `verifyArtifactRefs` 接受位于仓库内的绝对路径(兼容旧 envelope)
|
|
34
|
+
- `verifyBundleTaskSpecBindings` 接受 `qa-testcode`(backend-test)与 `qa-execute`(frontend-test)
|
|
35
|
+
- Bundle 重验仅 rehash typed evidence(backend/frontend-test-result),忽略可能后写的 run_record
|
|
36
|
+
|
|
9
37
|
## [0.24.6] - 2026-07-29
|
|
10
38
|
|
|
11
39
|
### 重点更新
|
|
@@ -8,7 +8,7 @@ import { buildRequirementCoverageGateShellCommand, expandShellPreset, buildVerdi
|
|
|
8
8
|
import { materializeBackendTestAnalysisContract } from "../workflows/dag/backend-test-analysis-contract.js";
|
|
9
9
|
import { extractBackendTestContractEnvelope } from "../workflows/dag/backend-test-contract-envelope.js";
|
|
10
10
|
import { materializeFrontendImplementationContract } from "../workflows/dag/frontend-implementation-contract.js";
|
|
11
|
-
import { materializeFrontendTestResult, } from "../workflows/dag/frontend-test-result-contract.js";
|
|
11
|
+
import { materializeFrontendTestResult, validateFrontendCaseEvidence, } from "../workflows/dag/frontend-test-result-contract.js";
|
|
12
12
|
import { formatFrontendVerificationTraceStdout, runFrontendVerificationTraceGate, } from "../workflows/dag/frontend-verification-trace.js";
|
|
13
13
|
import { formatFrontendWorktreeDiffStdout, runFrontendWorktreeDiffGate, } from "../workflows/dag/frontend-worktree-diff.js";
|
|
14
14
|
import { formatFrontendFailureAssessStdout, formatFrontendRepairContractStdout, runFrontendFailureAssessGate, runFrontendRepairContractGate, } from "../workflows/dag/frontend-repair.js";
|
|
@@ -897,6 +897,38 @@ async function executeFrontendVerificationBundle(input, meta) {
|
|
|
897
897
|
};
|
|
898
898
|
}
|
|
899
899
|
}
|
|
900
|
+
async function executeFrontendTestEvidenceValidation(input) {
|
|
901
|
+
const started = Date.now();
|
|
902
|
+
try {
|
|
903
|
+
const result = await validateFrontendCaseEvidence({ workspaceRoot: input.cwd });
|
|
904
|
+
const output = `frontend case evidence validation cases=${result.cases} findings=${result.issues.length}${result.issues.length ? ` issues=${JSON.stringify(result.issues)}` : ""}`;
|
|
905
|
+
if (result.hardFail) {
|
|
906
|
+
return {
|
|
907
|
+
ok: false,
|
|
908
|
+
stdout: "",
|
|
909
|
+
stderr: `frontend-test evidence hard-fail: ${JSON.stringify(result.issues)}`,
|
|
910
|
+
failureCategory: "nonzero-exit",
|
|
911
|
+
durationMs: Date.now() - started,
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
return {
|
|
915
|
+
ok: true,
|
|
916
|
+
stdout: output,
|
|
917
|
+
stderr: "",
|
|
918
|
+
failureCategory: "success",
|
|
919
|
+
durationMs: Date.now() - started,
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
catch (error) {
|
|
923
|
+
return {
|
|
924
|
+
ok: false,
|
|
925
|
+
stdout: "",
|
|
926
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
927
|
+
failureCategory: "invalid-output",
|
|
928
|
+
durationMs: Date.now() - started,
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
}
|
|
900
932
|
async function executeFrontendLintBaseline(input, meta) {
|
|
901
933
|
const started = Date.now();
|
|
902
934
|
const shell = input.task.shell;
|
|
@@ -997,6 +1029,9 @@ export async function executeDagShellNode(input, meta) {
|
|
|
997
1029
|
return { ok: false, stdout: "", stderr: error instanceof Error ? error.message : String(error), failureCategory: "invalid-output", durationMs: Date.now() - started };
|
|
998
1030
|
}
|
|
999
1031
|
}
|
|
1032
|
+
if (shell?.frontendTestEvidenceValidation) {
|
|
1033
|
+
return executeFrontendTestEvidenceValidation(input);
|
|
1034
|
+
}
|
|
1000
1035
|
if (shell?.backendTestPipeline) {
|
|
1001
1036
|
return executeBackendTestPipelineWithWriteGuard(input, meta);
|
|
1002
1037
|
}
|
|
@@ -101,11 +101,12 @@ export async function finalizeGitTask(transaction, outcome) {
|
|
|
101
101
|
await assertTransactionPosition(transaction.repoRoot, current);
|
|
102
102
|
const currentIgnored = await readIgnoredBaseline(transaction.repoRoot);
|
|
103
103
|
const ignoredBaselineByPath = new Map(current.ignoredBaseline.map((entry) => [entry.path, entry.sha256]));
|
|
104
|
-
const changedBaselineIgnored = currentIgnored
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
104
|
+
const changedBaselineIgnored = currentIgnored
|
|
105
|
+
.filter((entry) => ignoredBaselineByPath.has(entry.path) && ignoredBaselineByPath.get(entry.path) !== entry.sha256)
|
|
106
|
+
.map((entry) => entry.path);
|
|
107
|
+
const missingBaselineIgnored = current.ignoredBaseline
|
|
108
|
+
.filter((entry) => !currentIgnored.some((candidate) => candidate.path === entry.path))
|
|
109
|
+
.map((entry) => entry.path);
|
|
109
110
|
const existing = current.checkpoints.find((entry) => entry.workerRunId === outcome.workerRunId);
|
|
110
111
|
if (outcome.status === "reused" || existing) {
|
|
111
112
|
if (!existing)
|
|
@@ -123,12 +124,28 @@ export async function finalizeGitTask(transaction, outcome) {
|
|
|
123
124
|
const newIgnored = currentIgnored.map((entry) => entry.path).filter((entry) => !ignoredBaselineByPath.has(entry));
|
|
124
125
|
if (outcome.status === "succeeded") {
|
|
125
126
|
const allowedPaths = expandAllowedPathsForWorkflow(resolveWorkflow(outcome.taskSpec).workflow, outcome.taskSpec.constraints.allowed_paths);
|
|
127
|
+
const isAllowedEvidencePath = (entry) => !isSensitivePath(entry) &&
|
|
128
|
+
!isEphemeralToolCachePath(entry) &&
|
|
129
|
+
allowedPaths.some((glob) => matchesGlob(entry, glob));
|
|
130
|
+
// Pre-existing ignored evidence under allowed_paths may be rewritten by
|
|
131
|
+
// FINAL-VERIFY smoke / verify-final and must force-add into the checkpoint.
|
|
132
|
+
// Other baseline ignored mutations remain fail-closed (e.g. dist/, secrets).
|
|
133
|
+
const blockedBaselineChanged = changedBaselineIgnored.filter((entry) => !isAllowedEvidencePath(entry));
|
|
134
|
+
const blockedBaselineMissing = missingBaselineIgnored.filter((entry) => !isEphemeralToolCachePath(entry) && !isAllowedEvidencePath(entry));
|
|
135
|
+
if (blockedBaselineChanged.length > 0 || blockedBaselineMissing.length > 0) {
|
|
136
|
+
throw new Error(`pre-existing ignored file changed during Git transaction: ${[...blockedBaselineChanged, ...blockedBaselineMissing].join(", ")}`);
|
|
137
|
+
}
|
|
126
138
|
// Evidence under allowed_paths may be gitignored for local convenience (e.g.
|
|
127
139
|
// reports/welcome/** smoke JSON) but must still checkpoint for Task Pool Done.
|
|
128
140
|
// Sensitive / out-of-scope ignored files remain fail-closed; ephemeral tool
|
|
129
141
|
// caches neither block nor force-add.
|
|
130
142
|
const forceAddIgnored = [];
|
|
131
143
|
const blockedIgnored = [];
|
|
144
|
+
const rewrittenBaselineEvidence = changedBaselineIgnored.filter((entry) => isAllowedEvidencePath(entry));
|
|
145
|
+
for (const entry of rewrittenBaselineEvidence) {
|
|
146
|
+
if (!forceAddIgnored.includes(entry))
|
|
147
|
+
forceAddIgnored.push(entry);
|
|
148
|
+
}
|
|
132
149
|
for (const entry of newIgnored) {
|
|
133
150
|
if (isEphemeralToolCachePath(entry))
|
|
134
151
|
continue;
|
|
@@ -137,7 +154,8 @@ export async function finalizeGitTask(transaction, outcome) {
|
|
|
137
154
|
continue;
|
|
138
155
|
}
|
|
139
156
|
if (allowedPaths.some((glob) => matchesGlob(entry, glob))) {
|
|
140
|
-
forceAddIgnored.
|
|
157
|
+
if (!forceAddIgnored.includes(entry))
|
|
158
|
+
forceAddIgnored.push(entry);
|
|
141
159
|
}
|
|
142
160
|
else {
|
|
143
161
|
blockedIgnored.push(entry);
|
|
@@ -166,7 +184,10 @@ export async function finalizeGitTask(transaction, outcome) {
|
|
|
166
184
|
await assertTransactionPosition(transaction.repoRoot, { ...current, lastCheckpoint: commit });
|
|
167
185
|
await assertCheckpointMetadata(transaction.repoRoot, commit, outcome.taskSpec, outcome.workerRunId);
|
|
168
186
|
const afterCommitIgnored = await readIgnoredBaseline(transaction.repoRoot);
|
|
169
|
-
|
|
187
|
+
// force-add may promote allowed evidence from ignored → tracked; drop those
|
|
188
|
+
// paths from the post-commit baseline equality check.
|
|
189
|
+
const forceAdded = new Set(forceAddIgnored);
|
|
190
|
+
assertIgnoredBaselineUnchanged(current.ignoredBaseline.filter((entry) => !isEphemeralToolCachePath(entry.path) && !forceAdded.has(entry.path)), afterCommitIgnored.filter((entry) => !isEphemeralToolCachePath(entry.path) && !forceAdded.has(entry.path)));
|
|
170
191
|
}
|
|
171
192
|
catch (error) {
|
|
172
193
|
const branch = await git(transaction.repoRoot, ["branch", "--show-current"]).catch(() => "");
|
|
@@ -199,6 +220,10 @@ export async function finalizeGitTask(transaction, outcome) {
|
|
|
199
220
|
changedFiles: checkpointFiles,
|
|
200
221
|
};
|
|
201
222
|
}
|
|
223
|
+
// Failed / keep-diff paths still fail closed on unexpected baseline ignored mutations.
|
|
224
|
+
if (changedBaselineIgnored.length > 0 || missingBaselineIgnored.length > 0) {
|
|
225
|
+
throw new Error(`pre-existing ignored file changed during Git transaction: ${[...changedBaselineIgnored, ...missingBaselineIgnored].join(", ")}`);
|
|
226
|
+
}
|
|
202
227
|
const artifactDir = path.join(path.dirname(transaction.recordPath), "failures", outcome.workerRunId);
|
|
203
228
|
await captureFailureArtifacts(transaction.repoRoot, artifactDir, changes, newIgnored, outcome);
|
|
204
229
|
if (outcome.keepFailedDiff) {
|
|
@@ -404,7 +404,23 @@ export async function verifyBundleOutcomes(repoRoot, bundle) {
|
|
|
404
404
|
return false;
|
|
405
405
|
if (JSON.stringify(verified.artifacts) !== JSON.stringify(outcome.artifacts))
|
|
406
406
|
return false;
|
|
407
|
-
|
|
407
|
+
// Rehash only durable typed evidence. run_record/dag_json may be rewritten
|
|
408
|
+
// after outcome projection (worker-run-record appends report commands).
|
|
409
|
+
const evidenceArtifacts = outcome.artifacts.filter((artifact) => {
|
|
410
|
+
const kind = artifact.kind ?? "";
|
|
411
|
+
if (kind === "run_record" || kind === "dag_json")
|
|
412
|
+
return false;
|
|
413
|
+
if (kind === "backend-test-result" ||
|
|
414
|
+
kind === "frontend-test-result" ||
|
|
415
|
+
kind.includes("backend-test-result") ||
|
|
416
|
+
kind.includes("frontend-test-result")) {
|
|
417
|
+
return true;
|
|
418
|
+
}
|
|
419
|
+
// Unknown kinds: still rehash unless clearly infrastructure paths.
|
|
420
|
+
return (!artifact.path.includes("worker-run-record") &&
|
|
421
|
+
!artifact.path.includes("-dag.json"));
|
|
422
|
+
});
|
|
423
|
+
if (!await verifyArtifactRefs(repoRoot, evidenceArtifacts))
|
|
408
424
|
return false;
|
|
409
425
|
}
|
|
410
426
|
return true;
|
|
@@ -417,9 +433,12 @@ export function verifyBundleTaskSpecBindings(bundle, specs) {
|
|
|
417
433
|
return false;
|
|
418
434
|
taskIds.add(outcome.taskId);
|
|
419
435
|
const spec = specs.get(outcome.taskId);
|
|
436
|
+
// Typed verification tasks may be qa-execute (frontend-test) or qa-testcode
|
|
437
|
+
// (backend-test Markdown-first). Delivery binds on feature_id + workflow.
|
|
438
|
+
const typedVerificationTypes = new Set(["qa-execute", "qa-testcode"]);
|
|
420
439
|
if (!spec ||
|
|
421
440
|
spec.feature_id !== bundle.featureId ||
|
|
422
|
-
spec.type
|
|
441
|
+
!typedVerificationTypes.has(spec.type) ||
|
|
423
442
|
spec.execution?.workflow !== outcome.workflow)
|
|
424
443
|
return false;
|
|
425
444
|
}
|
|
@@ -460,9 +479,11 @@ async function verifyArtifactRefs(repoRoot, artifacts) {
|
|
|
460
479
|
return false;
|
|
461
480
|
}
|
|
462
481
|
for (const artifact of artifacts) {
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
const lexical = path.
|
|
482
|
+
// Accept absolute paths only when they resolve inside the repo (legacy
|
|
483
|
+
// envelopes may store abs paths). Prefer relative for new projections.
|
|
484
|
+
const lexical = path.isAbsolute(artifact.path)
|
|
485
|
+
? path.resolve(artifact.path)
|
|
486
|
+
: path.resolve(canonicalRepoRoot, artifact.path);
|
|
466
487
|
let resolved;
|
|
467
488
|
let content;
|
|
468
489
|
try {
|
|
@@ -111,8 +111,12 @@ export async function projectOutcome(input) {
|
|
|
111
111
|
if (artifact.sha256 && artifact.sha256 !== recomputed) {
|
|
112
112
|
return contractError(`artifact sha256 mismatch for ${artifact.path}: declared=${artifact.sha256} actual=${recomputed}`);
|
|
113
113
|
}
|
|
114
|
+
// Persist portable repo-relative paths only (Delivery verifyArtifactRefs rejects abs paths).
|
|
115
|
+
const relativePath = path
|
|
116
|
+
.relative(path.resolve(input.repoRoot), resolved)
|
|
117
|
+
.replace(/\\/g, "/");
|
|
114
118
|
validatedArtifacts.push({
|
|
115
|
-
path:
|
|
119
|
+
path: relativePath,
|
|
116
120
|
sha256: recomputed,
|
|
117
121
|
...(artifact.kind ? { kind: artifact.kind } : {}),
|
|
118
122
|
...(artifact.schemaId ? { schemaId: artifact.schemaId } : {}),
|
|
@@ -304,11 +304,51 @@ export async function runReadyTasks(options) {
|
|
|
304
304
|
if (result.status !== "succeeded") {
|
|
305
305
|
await options.onTaskFinalized?.({ status: result.status, taskSpec, workerRunId: result.workerRunId, runRecordPath: result.runRecordPath });
|
|
306
306
|
}
|
|
307
|
+
const recordErrorMessage = errorMessage(error);
|
|
308
|
+
// Always project a Pool run fact for record-error so operators can
|
|
309
|
+
// mark-failed / retry without hand-editing Running state.
|
|
310
|
+
try {
|
|
311
|
+
await recordTaskPoolRun({
|
|
312
|
+
repoRoot: options.repoRoot,
|
|
313
|
+
run: {
|
|
314
|
+
schemaVersion: 1,
|
|
315
|
+
batchRunId,
|
|
316
|
+
workerRunId: result.workerRunId,
|
|
317
|
+
taskId: result.businessId,
|
|
318
|
+
featureId: taskSpec.feature_id,
|
|
319
|
+
status: "run-error",
|
|
320
|
+
recordedAt: new Date().toISOString(),
|
|
321
|
+
error: recordErrorMessage,
|
|
322
|
+
runRecordPath: result.runRecordPath,
|
|
323
|
+
...(result.workflow ? { workflow: result.workflow } : {}),
|
|
324
|
+
...(controllerIdentity ? { controllerIdentity } : {}),
|
|
325
|
+
...(retryOfWorkerRunId ? { retryOfWorkerRunId } : {}),
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
await writeTaskPoolState(options.repoRoot, {
|
|
329
|
+
schemaVersion: 2,
|
|
330
|
+
featureId: taskSpec.feature_id,
|
|
331
|
+
taskId,
|
|
332
|
+
status: "Failed",
|
|
333
|
+
updatedAt: new Date().toISOString(),
|
|
334
|
+
workerRunId: result.workerRunId,
|
|
335
|
+
lastRunRecordPath: result.runRecordPath,
|
|
336
|
+
failure: {
|
|
337
|
+
category: "EnvFailure",
|
|
338
|
+
recommendedFollowUpKind: "manual-review",
|
|
339
|
+
derivedFollowUpTaskId: `${taskId}-record-error-review`,
|
|
340
|
+
source: "report-decision",
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
// Best-effort; batch still reports record-error.
|
|
346
|
+
}
|
|
307
347
|
tasks.push({
|
|
308
348
|
taskId,
|
|
309
349
|
workerRunId: result.workerRunId,
|
|
310
350
|
status: "record-error",
|
|
311
|
-
error:
|
|
351
|
+
error: recordErrorMessage,
|
|
312
352
|
runRecordPath: result.runRecordPath,
|
|
313
353
|
});
|
|
314
354
|
break;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { lstat, readFile, realpath } from "node:fs/promises";
|
|
2
|
+
import { lstat, readFile, realpath, stat } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { writeDagRunJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
|
|
@@ -68,6 +68,111 @@ export const frontendTestResultContractSchema = z.object({
|
|
|
68
68
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["integrationMode"], message: "real integration requires passed outcome" });
|
|
69
69
|
}
|
|
70
70
|
});
|
|
71
|
+
async function isNonEmptyFile(filePath) {
|
|
72
|
+
try {
|
|
73
|
+
const info = await stat(filePath);
|
|
74
|
+
return info.isFile() && info.size > 0;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function isSafeEvidenceShellPath(value) {
|
|
81
|
+
return (typeof value === "string" &&
|
|
82
|
+
value.length > 0 &&
|
|
83
|
+
!path.isAbsolute(value) &&
|
|
84
|
+
!path.win32.isAbsolute(value) &&
|
|
85
|
+
!value.includes(".."));
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Validate frontend browser evidence without going through a shell command.
|
|
89
|
+
* Keeping this in the Node executor avoids CMD/Git Bash/PowerShell quoting and
|
|
90
|
+
* backslash interpretation for the former long `node -e` command.
|
|
91
|
+
*/
|
|
92
|
+
export async function validateFrontendCaseEvidence(input) {
|
|
93
|
+
const manifestPath = path.join(input.workspaceRoot, "testcase/frontend/cases/manifest.json");
|
|
94
|
+
try {
|
|
95
|
+
await stat(manifestPath);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
throw new Error("missing testcase/frontend/cases/manifest.json");
|
|
99
|
+
}
|
|
100
|
+
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
101
|
+
if (!Array.isArray(manifest.cases))
|
|
102
|
+
throw new Error("invalid frontend case manifest");
|
|
103
|
+
const statuses = new Set(["passed", "failed", "blocked"]);
|
|
104
|
+
const issues = [];
|
|
105
|
+
let hardFail = false;
|
|
106
|
+
for (const rawCase of manifest.cases) {
|
|
107
|
+
const item = rawCase;
|
|
108
|
+
const id = typeof item?.caseId === "string" ? item.caseId : "?";
|
|
109
|
+
const dir = item?.evidenceDir;
|
|
110
|
+
const prefix = `testcase/frontend/evidence/${id}`;
|
|
111
|
+
if (!item ||
|
|
112
|
+
typeof item.caseId !== "string" ||
|
|
113
|
+
typeof dir !== "string" ||
|
|
114
|
+
!isSafeEvidenceShellPath(dir) ||
|
|
115
|
+
!(dir === prefix || dir.startsWith(`${prefix}/`))) {
|
|
116
|
+
hardFail = true;
|
|
117
|
+
issues.push({ ruleId: "unsafe-evidence-dir", caseId: id, detail: String(dir) });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const execution = path.join(input.workspaceRoot, dir, "execution.md");
|
|
121
|
+
const resultPath = path.join(input.workspaceRoot, dir, "case-result.json");
|
|
122
|
+
if (!(await isNonEmptyFile(execution))) {
|
|
123
|
+
issues.push({ ruleId: "missing-execution", caseId: id, detail: "execution.md is missing or empty" });
|
|
124
|
+
}
|
|
125
|
+
let result = null;
|
|
126
|
+
if (!(await isNonEmptyFile(resultPath))) {
|
|
127
|
+
issues.push({ ruleId: "missing-case-result", caseId: id, detail: "case-result.json is missing" });
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
result = JSON.parse(await readFile(resultPath, "utf8"));
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
issues.push({ ruleId: "invalid-case-result", caseId: id, detail: "case-result.json is malformed" });
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (!result || result.caseId !== id) {
|
|
138
|
+
issues.push({ ruleId: "case-result-identity", caseId: id, detail: "case-result caseId does not match manifest" });
|
|
139
|
+
}
|
|
140
|
+
if (!statuses.has(String(result?.status))) {
|
|
141
|
+
issues.push({ ruleId: "invalid-case-status", caseId: id, detail: "status must be passed, failed, or blocked" });
|
|
142
|
+
}
|
|
143
|
+
if (!Array.isArray(result?.evidencePaths)) {
|
|
144
|
+
issues.push({ ruleId: "invalid-evidence-paths", caseId: id, detail: "evidencePaths must be an array" });
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
for (const evidencePath of result.evidencePaths) {
|
|
148
|
+
if (!isSafeEvidenceShellPath(evidencePath)) {
|
|
149
|
+
hardFail = true;
|
|
150
|
+
issues.push({ ruleId: "unsafe-evidence-path", caseId: id, detail: String(evidencePath) });
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const currentPrefix = `${prefix}/`;
|
|
154
|
+
if (evidencePath.startsWith("testcase/frontend/evidence/") && !evidencePath.startsWith(currentPrefix)) {
|
|
155
|
+
hardFail = true;
|
|
156
|
+
issues.push({ ruleId: "cross-case-evidence-path", caseId: id, detail: evidencePath });
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const target = evidencePath.startsWith("testcase/")
|
|
160
|
+
? path.join(input.workspaceRoot, evidencePath)
|
|
161
|
+
: path.join(input.workspaceRoot, dir, evidencePath);
|
|
162
|
+
if (!(await isNonEmptyFile(target))) {
|
|
163
|
+
issues.push({ ruleId: "missing-evidence", caseId: id, detail: `${evidencePath} is missing or empty` });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (result?.status === "passed" && (!Array.isArray(result.evidencePaths) || !result.evidencePaths.some((entry) => /\.(png|jpg|jpeg|webp|zip|har|webm|mp4)$/i.test(String(entry))))) {
|
|
168
|
+
issues.push({ ruleId: "passed-without-browser-evidence", caseId: id, detail: "passed case has no screenshot, HAR, video, or equivalent browser artifact" });
|
|
169
|
+
}
|
|
170
|
+
if (result?.status === "blocked" && (typeof result.blockedReason !== "string" || !result.blockedReason.trim())) {
|
|
171
|
+
issues.push({ ruleId: "blocked-reason", caseId: id, detail: "blocked result has no usable reason" });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return { cases: manifest.cases.length, issues, hardFail };
|
|
175
|
+
}
|
|
71
176
|
function sha256(content) {
|
|
72
177
|
return createHash("sha256").update(content).digest("hex");
|
|
73
178
|
}
|
|
@@ -214,43 +319,3 @@ export function buildFrontendTestOutcomeGateShellSnippet(options) {
|
|
|
214
319
|
`node -e 'const fs=require("fs");const r=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));const ok=r.outcome==="passed"&&r.integrationMode==="real"&&Number(r.totals?.failed||0)===0&&Number(r.totals?.blocked||0)===0&&Array.isArray(r.acceptanceCoverage?.missing)&&r.acceptanceCoverage.missing.length===0;console.log("frontend-test outcome="+r.outcome+" integrationMode="+r.integrationMode);if(!ok)process.exit(1);' "\${RESULT}"`,
|
|
215
320
|
].join("; ");
|
|
216
321
|
}
|
|
217
|
-
/**
|
|
218
|
-
* Shared frontend-test evidence advisory check for map children + node 7.
|
|
219
|
-
* Hard-fails only for unsafe evidence directories or evidence paths. Missing,
|
|
220
|
-
* malformed, or empty evidence is reported without mutating case outputs.
|
|
221
|
-
*/
|
|
222
|
-
export function buildFrontendCaseEvidenceValidateShellSnippet() {
|
|
223
|
-
const body = [
|
|
224
|
-
"const fs=require('fs'),path=require('path');",
|
|
225
|
-
"const manifestPath='testcase/frontend/cases/manifest.json';",
|
|
226
|
-
"if(!fs.existsSync(manifestPath))throw new Error('missing '+manifestPath);",
|
|
227
|
-
"const manifest=JSON.parse(fs.readFileSync(manifestPath,'utf8'));",
|
|
228
|
-
"if(!Array.isArray(manifest.cases))throw new Error('invalid frontend case manifest');",
|
|
229
|
-
"const statuses=new Set(['passed','failed','blocked']);",
|
|
230
|
-
"const issues=[];",
|
|
231
|
-
"let hardFail=false;",
|
|
232
|
-
"function isSafeRel(p){return typeof p==='string'&&p.length>0&&!path.isAbsolute(p)&&!path.win32.isAbsolute(p)&&!p.includes('..');}",
|
|
233
|
-
"for(const c of manifest.cases){",
|
|
234
|
-
" const id=c&&typeof c.caseId==='string'?c.caseId:'?';",
|
|
235
|
-
" const dir=c&&c.evidenceDir;",
|
|
236
|
-
" const prefix='testcase/frontend/evidence/'+id;",
|
|
237
|
-
" if(!c||typeof c.caseId!=='string'||typeof dir!=='string'||!isSafeRel(dir)||!(dir===prefix||dir.startsWith(prefix+'/'))){",
|
|
238
|
-
" hardFail=true; issues.push({ruleId:'unsafe-evidence-dir',caseId:id,detail:String(dir)}); continue;",
|
|
239
|
-
" }",
|
|
240
|
-
" const execution=path.join(dir,'execution.md');",
|
|
241
|
-
" const resultPath=path.join(dir,'case-result.json');",
|
|
242
|
-
" if(!fs.existsSync(execution)||!fs.statSync(execution).isFile()||fs.statSync(execution).size===0)issues.push({ruleId:'missing-execution',caseId:id,detail:'execution.md is missing or empty'});",
|
|
243
|
-
" let result=null;",
|
|
244
|
-
" if(!fs.existsSync(resultPath)){issues.push({ruleId:'missing-case-result',caseId:id,detail:'case-result.json is missing'});continue;}",
|
|
245
|
-
" try{result=JSON.parse(fs.readFileSync(resultPath,'utf8'));}catch(e){issues.push({ruleId:'invalid-case-result',caseId:id,detail:'case-result.json is malformed'});continue;}",
|
|
246
|
-
" if(!result||result.caseId!==id)issues.push({ruleId:'case-result-identity',caseId:id,detail:'case-result caseId does not match manifest'});",
|
|
247
|
-
" if(!statuses.has(result&&result.status))issues.push({ruleId:'invalid-case-status',caseId:id,detail:'status must be passed, failed, or blocked'});",
|
|
248
|
-
" if(!Array.isArray(result&&result.evidencePaths)){issues.push({ruleId:'invalid-evidence-paths',caseId:id,detail:'evidencePaths must be an array'});}else{for(const p of result.evidencePaths){if(!isSafeRel(p)){hardFail=true;issues.push({ruleId:'unsafe-evidence-path',caseId:id,detail:String(p)});continue;}const currentPrefix=prefix+'/';if(p.startsWith('testcase/frontend/evidence/')&&!p.startsWith(currentPrefix)){hardFail=true;issues.push({ruleId:'cross-case-evidence-path',caseId:id,detail:p});continue;}const target=p.startsWith('testcase/')?p:path.join(dir,p);if(!fs.existsSync(target)||!fs.statSync(target).isFile()||fs.statSync(target).size===0)issues.push({ruleId:'missing-evidence',caseId:id,detail:p+' is missing or empty'});}}",
|
|
249
|
-
" if(result&&result.status==='passed'&&(!Array.isArray(result.evidencePaths)||!result.evidencePaths.some(p=>/\\.(png|jpg|jpeg|webp|zip|har|webm|mp4)$/i.test(p))))issues.push({ruleId:'passed-without-browser-evidence',caseId:id,detail:'passed case has no screenshot, HAR, video, or equivalent browser artifact'});",
|
|
250
|
-
" if(result&&result.status==='blocked'&&(typeof result.blockedReason!=='string'||!result.blockedReason.trim()))issues.push({ruleId:'blocked-reason',caseId:id,detail:'blocked result has no usable reason'});",
|
|
251
|
-
"}",
|
|
252
|
-
"if(hardFail){console.error('frontend-test evidence hard-fail: '+JSON.stringify(issues)); process.exit(1);}",
|
|
253
|
-
"console.log('frontend case evidence advisory validation ok cases='+manifest.cases.length+' findings='+issues.length+(issues.length?(' issues='+JSON.stringify(issues)):''));",
|
|
254
|
-
].join("");
|
|
255
|
-
return ["node -e", JSON.stringify(body)].join(" ");
|
|
256
|
-
}
|
|
@@ -25,7 +25,7 @@ import { normalizeTaskRequirementText, resolveTaskDagTemplateSelection, } from "
|
|
|
25
25
|
import { BACKEND_TEST_EXECUTION_DEFAULT_TEST_ROOT, buildBackendTestExecutionPreflightShellSnippet, } from "./backend-test-execution-contract.js";
|
|
26
26
|
import { buildBackendTestOutcomeGateShellSnippet } from "./backend-test-result-contract.js";
|
|
27
27
|
import { buildBackendTestIntakeContext } from "./backend-test-intake-context.js";
|
|
28
|
-
import {
|
|
28
|
+
import { buildFrontendTestOutcomeGateShellSnippet } from "./frontend-test-result-contract.js";
|
|
29
29
|
import { classifyFrontendRisk, } from "./frontend-risk.js";
|
|
30
30
|
import { discoverFrontendProjectCapability, } from "./frontend-project-capability.js";
|
|
31
31
|
import { FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID, loadFrontendImplementationContractJsonSchema, } from "./frontend-implementation-contract.js";
|
|
@@ -3504,8 +3504,6 @@ function buildFrontendTestHybridDag(sources) {
|
|
|
3504
3504
|
"process.stdout.write(JSON.stringify({cases:manifest.cases}));",
|
|
3505
3505
|
].join("")),
|
|
3506
3506
|
].join(" ");
|
|
3507
|
-
// Heal malformed/missing case evidence to blocked; only unsafe evidenceDir hard-fails.
|
|
3508
|
-
const evidenceValidation = buildFrontendCaseEvidenceValidateShellSnippet();
|
|
3509
3507
|
const frontendTestOutcomeGate = buildFrontendTestOutcomeGateShellSnippet();
|
|
3510
3508
|
const frontendCaseQualityAdvisory = [
|
|
3511
3509
|
"node -e",
|
|
@@ -3753,9 +3751,9 @@ function buildFrontendTestHybridDag(sources) {
|
|
|
3753
3751
|
writeSet: [`${evidenceRoot}/**`],
|
|
3754
3752
|
allowedPaths: ["testcase/frontend/cases/**", `${evidenceRoot}/**`],
|
|
3755
3753
|
forbiddenPaths: forbidden,
|
|
3756
|
-
outputContract: "Deterministic evidence gate:
|
|
3757
|
-
subtask_prompt: "Validate frontend case evidence before result materialization.
|
|
3758
|
-
shell: { commands: [
|
|
3754
|
+
outputContract: "Deterministic evidence gate: missing/malformed evidence is advisory; only unsafe evidenceDir or evidence paths hard-fail. Does not block retrospect.",
|
|
3755
|
+
subtask_prompt: "Validate frontend case evidence before result materialization. Keep missing or malformed evidence as advisory findings; only path-escape failures abort the node.",
|
|
3756
|
+
shell: { commands: [], frontendTestEvidenceValidation: {}, cwd: ".", timeoutMs: 120000 },
|
|
3759
3757
|
}, {
|
|
3760
3758
|
id: "materialize-frontend-test-result-shell",
|
|
3761
3759
|
depends_on: ["validate-frontend-case-evidence-shell"],
|
|
@@ -280,6 +280,7 @@ export const dagBackendTestPipelineSchema = z.enum([
|
|
|
280
280
|
"markdown-traceability",
|
|
281
281
|
"markdown-execute-html",
|
|
282
282
|
]);
|
|
283
|
+
export const dagFrontendTestEvidenceValidationSchema = z.object({}).strict();
|
|
283
284
|
export const dagShellConfigSchema = z.object({
|
|
284
285
|
commands: z.array(z.string()).default([]),
|
|
285
286
|
preset: dagShellPresetSchema.optional(),
|
|
@@ -293,6 +294,7 @@ export const dagShellConfigSchema = z.object({
|
|
|
293
294
|
frontendLintBaseline: dagFrontendLintBaselineSchema.optional(),
|
|
294
295
|
frontendVerificationBundle: dagFrontendVerificationBundleSchema.optional(),
|
|
295
296
|
frontendReviewContext: dagFrontendReviewContextSchema.optional(),
|
|
297
|
+
frontendTestEvidenceValidation: dagFrontendTestEvidenceValidationSchema.optional(),
|
|
296
298
|
backendTestPipeline: dagBackendTestPipelineSchema.optional(),
|
|
297
299
|
verifyEvidence: dagShellVerifyEvidenceSchema.optional(),
|
|
298
300
|
repairArtifactGate: dagRepairArtifactGateSchema.optional(),
|
|
@@ -461,10 +461,11 @@ function validateShellTaskConfig(task, spec, issues) {
|
|
|
461
461
|
!shell.backendTestPipeline &&
|
|
462
462
|
!shell.frontendPrewriteGate &&
|
|
463
463
|
!shell.frontendVerificationBundle &&
|
|
464
|
-
!shell.frontendReviewContext
|
|
464
|
+
!shell.frontendReviewContext &&
|
|
465
|
+
!shell.frontendTestEvidenceValidation) {
|
|
465
466
|
issues.push({
|
|
466
467
|
type: "missing-shell-commands",
|
|
467
|
-
message: `shell task ${task.id} requires
|
|
468
|
+
message: `shell task ${task.id} requires a supported shell operation or non-empty shell.commands`,
|
|
468
469
|
});
|
|
469
470
|
}
|
|
470
471
|
if (commands.some((command) => command.trim().length === 0)) {
|