@tea-agent/loop-agent 0.38.3 → 0.38.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -1
- package/CHANGELOG.md +153 -96
- package/README.md +9 -5
- package/dist/build-stamp.json +3 -3
- package/dist/cli/command-definitions.js +16 -5
- package/dist/cli/program.js +16 -2
- package/dist/cli/update/notifier.js +2 -2
- package/dist/cli.js +2 -1
- package/dist/commands/dag-rerun.js +55 -1
- package/dist/commands/init-model-catalog.js +464 -0
- package/dist/commands/init-upgrade.js +265 -97
- package/dist/commands/init.js +428 -27
- package/dist/commands/inspect-next.js +8 -0
- package/dist/executors/dag-pi-executor.js +18 -9
- package/dist/executors/pi-playwright-cli-tool.js +51 -12
- package/dist/executors/shell-executor.js +209 -13
- package/dist/shared/dag-prompt-override.js +28 -0
- package/dist/shared/operator/capabilities.js +32 -0
- package/dist/shared/playwright-cli-command-policy.js +15 -0
- package/dist/shared/update/console-notifier.js +100 -0
- package/dist/{cli → shared}/update/npm-client.js +41 -8
- package/dist/{cli → shared}/update/state.js +41 -13
- package/dist/task/source-prepare/parse-intent.js +24 -4
- package/dist/worker/cli.js +31 -1
- package/dist/worker/console/chat/routes.js +1 -0
- package/dist/worker/console/dag-execution-receipt.js +33 -0
- package/dist/worker/console/operator-actions.js +116 -0
- package/dist/worker/console/operator-user-error.js +2 -2
- package/dist/worker/console/routes.js +43 -2
- package/dist/worker/console/security.js +63 -9
- package/dist/worker/console/server.js +36 -5
- package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-C4kVYweA.js → abnfDiagram-N423BO3Z-gauhD9kD.js} +1 -1
- package/dist/worker/console/static/assets/{arc-Bj2M8iO5.js → arc-pm3c2Rsl.js} +1 -1
- package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-CLlXe4-6.js → architectureDiagram-T3A2C74G-D_JAg3wH.js} +1 -1
- package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-psEp5xH0.js → blockDiagram-VBNYF7ZC-Dk7-ex52.js} +1 -1
- package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DeKcOCGJ.js → c4Diagram-5PPSVZJV-Uval9oRI.js} +1 -1
- package/dist/worker/console/static/assets/channel-CVrnUdZl.js +1 -0
- package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-K0C744p1.js → chunk-2GRJ4B5K-BSYjHs-v.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-xbNw4J9-.js → chunk-2Q5K7J3B-KGUuBXI8.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5RXB4S5H-DjLaSDUO.js → chunk-5RXB4S5H-JqAtxD9N.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5VM5RSS4-CtiPlKel.js → chunk-5VM5RSS4-D6EJOVQK.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-DZtolir7.js → chunk-6Q2QTUOP-B1JriSjm.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-GF5L2VYU-CPcdNeew.js → chunk-GF5L2VYU-D62b96Jd.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-JWPE2WC7-NgHc6V37.js → chunk-JWPE2WC7-BsNKEC_r.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-KBJHAD2P-DQ_T-hg8.js → chunk-KBJHAD2P-B56v6-ZP.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-RYQCIY6F-DgLsxcVP.js → chunk-RYQCIY6F-D2KPnd8K.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-XXDRQBXY-UrVoM9z1.js → chunk-XXDRQBXY-kzKSCaaL.js} +1 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-aX91WTya.js +1 -0
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-aX91WTya.js +1 -0
- package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-CLkYvTb3.js → cose-bilkent-JH36ORCC-C6N6KNJd.js} +1 -1
- package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-BwT_xKrE.js → cynefin-VYW2F7L2-DiyymT2D.js} +1 -1
- package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-Bi9tuzif.js → cynefinDiagram-MW4NZA55-BeKAXvf3.js} +1 -1
- package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BCIqkWBV.js → dagre-VZM6K2ZE-TkIdXfPF.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-7IWD3JNH-Dip6l9_Z.js → diagram-7IWD3JNH-BFmSG3ks.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-B-xkh_wK.js → diagram-B4RE2ZJO-DqpJT2Fs.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-LBJQPF4R-DZO0kTt_.js → diagram-LBJQPF4R-r5ATN43t.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-Q27KOJAE-D6ZplNbZ.js → diagram-Q27KOJAE-DEcSCMuR.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-UB23O5K3-BPekbhoS.js → diagram-UB23O5K3-VOvYRLVc.js} +1 -1
- package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-BtmaJpK5.js → ebnfDiagram-BXEA7PRR-D7f4Cb1u.js} +1 -1
- package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-6DS0Xc44.js → erDiagram-JOGREHBK-ziUf3U0G.js} +1 -1
- package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-CihnROSm.js → flowDiagram-UKHOOZJN-BoRdQ-1y.js} +1 -1
- package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-CZC4zOE2.js → ganttDiagram-PKOTCBZU-B1-GncNm.js} +1 -1
- package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Dq1fhzGN.js → gitGraphDiagram-DS77QQ5N-CTW-ph8W.js} +1 -1
- package/dist/worker/console/static/assets/{index-Ya5FE7cD.css → index-C8rOIAJr.css} +1 -1
- package/dist/worker/console/static/assets/{index-DTZOKgAn.js → index-DEbyrEfW.js} +71 -71
- package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-DlSl4BGx.js → infoDiagram-6WML65LV-BFA337tZ.js} +1 -1
- package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-snnzFl_U.js → ishikawaDiagram-WSZJBQD7-BtEujgRR.js} +1 -1
- package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-BDE7qpMx.js → journeyDiagram-NVQOT4AX-C8ZwD2Jp.js} +1 -1
- package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-CD2Ci04G.js → kanban-definition-27J2QSJJ-Ck-z8B11.js} +1 -1
- package/dist/worker/console/static/assets/{linear-DdnapKIH.js → linear-CAyXjQjN.js} +1 -1
- package/dist/worker/console/static/assets/{mermaid.core-BVjAT9b8.js → mermaid.core-CcWKn6tS.js} +5 -5
- package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-D0yJk-zA.js → mindmap-definition-FAOFIHXS-C2NuDO7E.js} +1 -1
- package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-BVo9tFP-.js → pegDiagram-VL7TDLO6-BFub3PDz.js} +1 -1
- package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-DnOV-mZ_.js → pieDiagram-7S7Q4E2Y-BJx5THZ5.js} +1 -1
- package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-CzUJo58i.js → quadrantDiagram-CIZ2JOQS-DhalkDWV.js} +1 -1
- package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-Bvikrolh.js → railroadDiagram-AXF67PYL-CVcBM36o.js} +1 -1
- package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-DtaSVCap.js → requirementDiagram-LRYGKXZP-D_vy4n0w.js} +1 -1
- package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-C2c9A0wW.js → sankeyDiagram-W5VNT64P-DGYdJL1D.js} +1 -1
- package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-DmXJcU7r.js → sequenceDiagram-SI44F4Z6-C1xJFg3k.js} +1 -1
- package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-B4GUFW92.js → sizeCapture-X5ZJPWSS-D_Fc43df.js} +1 -1
- package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-Dsad2MXf.js → stateDiagram-OKZ733FA-BhGbIjbu.js} +1 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DUt1lyFL.js +1 -0
- package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-DGq48Fbi.js → swimlanes-SLNWSIFB-DVkNcO-V.js} +2 -2
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-BM6SGr7j.js +8 -0
- package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-CSSi6OFf.js → timeline-definition-Z64GVDOM-BQU9ufV0.js} +1 -1
- package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-BqyzPrWv.js → vennDiagram-T6HMQDX7-BCQhRmYB.js} +1 -1
- package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-DlznVb6S.js → wardleyDiagram-T6FBY63Y-DWerOyHO.js} +1 -1
- package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-CfBcgJ_K.js → xychartDiagram-ELKLHX3M-DxLeebQU.js} +1 -1
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/operator-chat/mutation-gate.js +1 -1
- package/dist/worker/loop-agent/loop-agent-client.js +7 -3
- package/dist/worker/observe/node-input.js +2 -0
- package/dist/worker/observe/node-process.js +377 -0
- package/dist/worker/observe/routes.js +36 -3
- package/dist/worker/observe/shell-handler-keys.js +34 -0
- package/dist/worker/observe/static/api.js +66 -0
- package/dist/worker/observe/static/constants.js +8 -1
- package/dist/worker/observe/static/format.js +23 -0
- package/dist/worker/observe/static/markdown-render.js +20 -1
- package/dist/worker/observe/static/operator-chrome.js +4 -0
- package/dist/worker/observe/static/state.js +11 -0
- package/dist/worker/observe/static/styles.css +73 -0
- package/dist/worker/observe/static/views/dag-inspector.js +561 -8
- package/dist/worker/observe/static/views/dag.js +8 -1
- package/dist/worker/observe/static/views/failures.js +5 -2
- package/dist/worker/observe/static/views/session-timeline.js +60 -4
- package/dist/workflows/dag/backend-test-case-coverage-analysis.js +20 -2
- package/dist/workflows/dag/failure-category.js +8 -0
- package/dist/workflows/dag/frontend-implementation-contract.js +5 -1
- package/dist/workflows/dag/frontend-repair.js +14 -0
- package/dist/workflows/dag/frontend-risk.js +15 -2
- package/dist/workflows/dag/frontend-test-case-checklist.js +26 -2
- package/dist/workflows/dag/frontend-test-case-manifest.js +5 -0
- package/dist/workflows/dag/frontend-test-result-contract.js +151 -11
- package/dist/workflows/dag/frontend-verification-trace.js +29 -4
- package/dist/workflows/dag/init-hybrid.js +125 -24
- package/dist/workflows/dag/node-execution.js +20 -2
- package/dist/workflows/dag/rerun-plan.js +112 -0
- package/dist/workflows/dag/rerun-run.js +58 -6
- package/dist/workflows/dag/retry-policy.js +91 -0
- package/dist/workflows/dag/types.js +9 -1
- package/docs/init-surface.manifest.json +5 -0
- package/docs/operations/README.md +1 -1
- package/docs/templates/init-managed-agents.md +13 -3
- package/harness.json +2 -2
- package/package.json +5 -1
- package/scripts/next-info.mjs +356 -0
- package/scripts/next-publish-gate.mjs +238 -0
- package/scripts/release-source-binding.mjs +251 -0
- package/skills/fe-test-ui-scout/SKILL.md +65 -0
- package/skills/fe-test-ui-scout/references/ledger-schema.md +62 -0
- package/skills/fe-test-ui-scout/references/recon-protocol.md +54 -0
- package/skills/loop-agent/references/command-reference.md +11 -4
- package/skills/playwright-cli/SKILL.md +22 -0
- package/skills/playwright-cli-case-generator/SKILL.md +20 -1
- package/dist/worker/console/static/assets/channel-CPF4N7pf.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-C2DwA_Y1.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-C2DwA_Y1.js +0 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-B8FB_cNk.js +0 -1
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-CrZYaWfx.js +0 -8
|
@@ -250,6 +250,54 @@ export function buildSessionTimelineItems(events) {
|
|
|
250
250
|
return items;
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
+
/**
|
|
254
|
+
* Split a node's shared session-events log into per-attempt segments. Every
|
|
255
|
+
* attempt appends one agent run beginning with agent_start; events recorded
|
|
256
|
+
* before the first agent_start (defensive) stay in an implicit first segment.
|
|
257
|
+
*/
|
|
258
|
+
export function splitSessionEventsByAttempt(events) {
|
|
259
|
+
const segments = [];
|
|
260
|
+
let current = null;
|
|
261
|
+
for (const event of events) {
|
|
262
|
+
if (typeof event?.type === "string" && event.type === "agent_start") {
|
|
263
|
+
current = [];
|
|
264
|
+
segments.push(current);
|
|
265
|
+
}
|
|
266
|
+
if (current === null) {
|
|
267
|
+
current = [];
|
|
268
|
+
segments.push(current);
|
|
269
|
+
}
|
|
270
|
+
current.push(event);
|
|
271
|
+
}
|
|
272
|
+
return segments;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Resolve the events visible in the「执行过程」tab for the selected execution
|
|
277
|
+
* key. attempt-N shows only that attempt's segment when the shared log carries
|
|
278
|
+
* multiple attempts; other keys (pass-N, null) keep the full stream.
|
|
279
|
+
*/
|
|
280
|
+
export function resolveTimelineVisibleEvents(events, executionKey) {
|
|
281
|
+
const match =
|
|
282
|
+
typeof executionKey === "string"
|
|
283
|
+
? /^attempt-(\d+)$/.exec(executionKey)
|
|
284
|
+
: null;
|
|
285
|
+
if (!match) return { events, note: null };
|
|
286
|
+
const segments = splitSessionEventsByAttempt(events);
|
|
287
|
+
if (segments.length <= 1) return { events, note: null };
|
|
288
|
+
const requested = Number(match[1]);
|
|
289
|
+
if (requested >= 1 && requested <= segments.length) {
|
|
290
|
+
return {
|
|
291
|
+
events: segments[requested - 1],
|
|
292
|
+
note: `已按第 ${requested} 次执行过滤 · 共 ${segments.length} 次执行`,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
return {
|
|
296
|
+
events,
|
|
297
|
+
note: `未检测到第 ${requested} 次执行的边界(共 ${segments.length} 次执行),暂显示全部事件`,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
253
301
|
/**
|
|
254
302
|
* Build rich timeline items: merge tool start/end by toolCallId, collapse
|
|
255
303
|
* protocol noise, surface turn thinking/text without dumping agent_end.messages.
|
|
@@ -657,7 +705,7 @@ export async function mergeSessionEvents(dagRunId, nodeId) {
|
|
|
657
705
|
}
|
|
658
706
|
}
|
|
659
707
|
|
|
660
|
-
export function renderSessionTimeline(content, nodeId) {
|
|
708
|
+
export function renderSessionTimeline(content, nodeId, options = {}) {
|
|
661
709
|
content.dataset.dagRunId = uiState.currentDagRunId ?? "";
|
|
662
710
|
content.dataset.nodeId = nodeId ?? "";
|
|
663
711
|
content.classList.add("process-timeline-scroll");
|
|
@@ -666,6 +714,14 @@ export function renderSessionTimeline(content, nodeId) {
|
|
|
666
714
|
return;
|
|
667
715
|
}
|
|
668
716
|
|
|
717
|
+
const visible = resolveTimelineVisibleEvents(
|
|
718
|
+
uiState.sessionEvents,
|
|
719
|
+
options.executionKey,
|
|
720
|
+
);
|
|
721
|
+
if (visible.note) {
|
|
722
|
+
content.appendChild(el("p", "process-timeline-note", visible.note));
|
|
723
|
+
}
|
|
724
|
+
|
|
669
725
|
const showProtocol = Boolean(uiState.sessionTimelineShowProtocol);
|
|
670
726
|
const toolbar = el("div", "process-timeline-toolbar");
|
|
671
727
|
const protocolToggle = document.createElement("button");
|
|
@@ -681,17 +737,17 @@ export function renderSessionTimeline(content, nodeId) {
|
|
|
681
737
|
const scrollTop = parent.scrollTop;
|
|
682
738
|
const scrollLeft = parent.scrollLeft;
|
|
683
739
|
while (parent.firstChild) parent.removeChild(parent.firstChild);
|
|
684
|
-
renderSessionTimeline(parent, nodeId);
|
|
740
|
+
renderSessionTimeline(parent, nodeId, options);
|
|
685
741
|
parent.scrollTop = scrollTop;
|
|
686
742
|
parent.scrollLeft = scrollLeft;
|
|
687
743
|
});
|
|
688
744
|
toolbar.appendChild(protocolToggle);
|
|
689
745
|
content.appendChild(toolbar);
|
|
690
746
|
|
|
691
|
-
const timelineItems = buildRichTimelineItems(
|
|
747
|
+
const timelineItems = buildRichTimelineItems(visible.events, {
|
|
692
748
|
showProtocol,
|
|
693
749
|
});
|
|
694
|
-
const hasEventTimes =
|
|
750
|
+
const hasEventTimes = visible.events.some((event) =>
|
|
695
751
|
Boolean(sessionEventTimestamp(event)),
|
|
696
752
|
);
|
|
697
753
|
if (!hasEventTimes) {
|
|
@@ -1500,8 +1500,26 @@ print(json.dumps(out,ensure_ascii=True))
|
|
|
1500
1500
|
// Windows Python may expose stdin as GBK; decoding generated Chinese/emoji
|
|
1501
1501
|
// source through that locale can create surrogateescape code points and make
|
|
1502
1502
|
// ast.parse fail with "surrogates not allowed".
|
|
1503
|
-
|
|
1504
|
-
|
|
1503
|
+
// POSIX images usually expose only `python3` (modern macOS has no `python`);
|
|
1504
|
+
// Windows often has a real `python` plus a Microsoft Store `python3` stub.
|
|
1505
|
+
// A missing interpreter must not silently degrade to empty payload shapes.
|
|
1506
|
+
const interpreterCandidates = process.platform === "win32" ? ["python", "python3"] : ["python3", "python"];
|
|
1507
|
+
let result;
|
|
1508
|
+
for (const interpreter of interpreterCandidates) {
|
|
1509
|
+
const attempt = spawnSync(interpreter, ["-c", program], {
|
|
1510
|
+
input: Buffer.from(source, "utf8"),
|
|
1511
|
+
encoding: "utf8",
|
|
1512
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
1513
|
+
windowsHide: true,
|
|
1514
|
+
});
|
|
1515
|
+
// Interpreter missing/unspawnable → try the next candidate. A spawned
|
|
1516
|
+
// interpreter that exits nonzero analyzed real input; do not retry then.
|
|
1517
|
+
if (attempt.error)
|
|
1518
|
+
continue;
|
|
1519
|
+
result = attempt;
|
|
1520
|
+
break;
|
|
1521
|
+
}
|
|
1522
|
+
if (!result || result.status !== 0 || !result.stdout.trim())
|
|
1505
1523
|
return new Map();
|
|
1506
1524
|
try {
|
|
1507
1525
|
const parsed = JSON.parse(result.stdout);
|
|
@@ -29,6 +29,7 @@ const RAW_TO_NORMALIZED = {
|
|
|
29
29
|
"decision-envelope-invalid": "decision-envelope",
|
|
30
30
|
"nonzero-exit": "shell-command",
|
|
31
31
|
"spawn-error": "shell-command",
|
|
32
|
+
"test-timeout": "timeout",
|
|
32
33
|
"static-error": "static-error",
|
|
33
34
|
"invalid-output": "validation",
|
|
34
35
|
"structured-output-truncated": "validation",
|
|
@@ -44,6 +45,13 @@ const RAW_TO_NORMALIZED = {
|
|
|
44
45
|
quota: "executor",
|
|
45
46
|
"rate-limit": "executor",
|
|
46
47
|
unavailable: "executor",
|
|
48
|
+
// Deterministic capability/policy refusal: missing verified launcher JS
|
|
49
|
+
// entry, capability allowlist rejection, or missing bounded write tool
|
|
50
|
+
// profile. These are environment/config defects the operator repairs
|
|
51
|
+
// before rerunning; classify as executor so recovery recommends
|
|
52
|
+
// rerun-after-fix WITHOUT forcing a human-decision gate on
|
|
53
|
+
// standaloneTaskRerun (run-failed same-task rerun).
|
|
54
|
+
"tool-policy": "executor",
|
|
47
55
|
unknown: "unknown",
|
|
48
56
|
};
|
|
49
57
|
function isFinishedStatus(status) {
|
|
@@ -1723,7 +1723,11 @@ async function dropUnresolvedVerificationSymbols(input) {
|
|
|
1723
1723
|
return changed;
|
|
1724
1724
|
}
|
|
1725
1725
|
function extractSingleOpenspecCitationsBlock(text) {
|
|
1726
|
-
|
|
1726
|
+
// Line-anchored so a directly closed empty fence (no candidate rows) still
|
|
1727
|
+
// counts as the one required block instead of "found 0" retry loops.
|
|
1728
|
+
const blocks = [
|
|
1729
|
+
...text.matchAll(/^```openspec-citations[ \t]*\r?\n([\s\S]*?)^```[ \t]*$/gim),
|
|
1730
|
+
].map((match) => match[0]);
|
|
1727
1731
|
if (blocks.length !== 1) {
|
|
1728
1732
|
throw new Error(`frontend plan patch output must include exactly one fenced openspec-citations block (found ${blocks.length})`);
|
|
1729
1733
|
}
|
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { pathMatchesPattern } from "../../shared/git-progress.js";
|
|
5
5
|
import { FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID, frontendImplementationContractSchema, } from "./frontend-implementation-contract.js";
|
|
6
|
+
import { FRONTEND_NO_VERIFICATION_MARKER_TEXT, FRONTEND_VERIFICATION_UNAVAILABLE_MESSAGE, } from "./frontend-verification-trace.js";
|
|
6
7
|
export const FRONTEND_REPAIR_ASSESSMENT_SCHEMA_ID = "frontend-repair-assessment-v1";
|
|
7
8
|
export const frontendRepairFailureClassSchema = z.enum([
|
|
8
9
|
"typecheck",
|
|
@@ -82,6 +83,12 @@ export function classifyFrontendFailure(input) {
|
|
|
82
83
|
if (input.failureCategory === "write-guard") {
|
|
83
84
|
return "path";
|
|
84
85
|
}
|
|
86
|
+
if (`${input.stderr ?? ""}\n${input.stdout ?? ""}`.includes(FRONTEND_NO_VERIFICATION_MARKER_TEXT)) {
|
|
87
|
+
// Verification unavailability is a generation-time contract gap: no
|
|
88
|
+
// authorized verification command exists. Bounded repair cannot fix it;
|
|
89
|
+
// the operator must declare commands and regenerate the DAG.
|
|
90
|
+
return "contract";
|
|
91
|
+
}
|
|
85
92
|
if (input.traceTarget || input.commandCategory === "trace") {
|
|
86
93
|
return "trace";
|
|
87
94
|
}
|
|
@@ -424,6 +431,13 @@ export async function runFrontendFailureAssessGate(input) {
|
|
|
424
431
|
: !baseEligible
|
|
425
432
|
? `non-repairable or ineligible: failureClass=${failureClass} attempt=${attempt}`
|
|
426
433
|
: `missing structured evidence for failureClass=${failureClass} on ${primary.nodeId}`;
|
|
434
|
+
if (!eligible &&
|
|
435
|
+
`${primary.record.stderr ?? ""}`.includes(FRONTEND_NO_VERIFICATION_MARKER_TEXT)) {
|
|
436
|
+
// Surface the actionable unavailable-verification reason in the
|
|
437
|
+
// assessment (and the non-repairable gate error) instead of a
|
|
438
|
+
// generic class label.
|
|
439
|
+
reason = FRONTEND_VERIFICATION_UNAVAILABLE_MESSAGE;
|
|
440
|
+
}
|
|
427
441
|
}
|
|
428
442
|
// fixScope and changedPaths must stay inside the implement writeSet.
|
|
429
443
|
if (evidence.fixScope?.length) {
|
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
* Deterministic frontend-implementation risk classifier (M4).
|
|
3
3
|
* Pure function: no I/O, no model, no side effects.
|
|
4
4
|
*/
|
|
5
|
+
/**
|
|
6
|
+
* Dependency-installation language in task text. Mentioning package.json or
|
|
7
|
+
* allowing its path is NOT dependency introduction by itself; the manifest
|
|
8
|
+
* path check in classifyFrontendRisk requires this language to escalate.
|
|
9
|
+
*/
|
|
10
|
+
const DEPENDENCY_INSTALL_LANGUAGE = /new\s+dependency|add\s+dependencies?|install\s+dependencies?|npm\s+(?:install|i)\b|yarn\s+(?:add|install)\b|pnpm\s+(?:add|install)\b|bun\s+(?:add|install)\b|新增依赖|添加依赖|安装依赖|引入依赖/i;
|
|
5
11
|
const HIGH_RISK_PATTERNS = [
|
|
6
12
|
{
|
|
7
13
|
id: "new-route",
|
|
@@ -25,7 +31,7 @@ const HIGH_RISK_PATTERNS = [
|
|
|
25
31
|
},
|
|
26
32
|
{
|
|
27
33
|
id: "new-dependency",
|
|
28
|
-
re:
|
|
34
|
+
re: DEPENDENCY_INSTALL_LANGUAGE,
|
|
29
35
|
},
|
|
30
36
|
{
|
|
31
37
|
id: "schema-api-change",
|
|
@@ -88,7 +94,14 @@ export function classifyFrontendRisk(input) {
|
|
|
88
94
|
const paths = input.allowedPaths ?? [];
|
|
89
95
|
const spread = pathSpreadScore(paths);
|
|
90
96
|
if (paths.some((p) => /package\.json|pnpm-lock|yarn\.lock|package-lock/.test(p))) {
|
|
91
|
-
|
|
97
|
+
if (DEPENDENCY_INSTALL_LANGUAGE.test(blob)) {
|
|
98
|
+
signals.push("new-dependency");
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
// Editing manifest scripts/config is not dependency introduction;
|
|
102
|
+
// record the rejected escalation instead of forcing full gates.
|
|
103
|
+
rejectedSignals.push("new-dependency:manifest-path-without-install-language");
|
|
104
|
+
}
|
|
92
105
|
}
|
|
93
106
|
// Explicit multi-domain language only — path count alone is not high-risk
|
|
94
107
|
if (spread >= 3 &&
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import { readFile, stat } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { isPlaywrightCliCommand } from "../../shared/playwright-cli-command-policy.js";
|
|
4
|
+
import { isTruncatedFrontendAcId, isTruncatedWhenUndeclaredFrontendAcId, } from "./frontend-test-result-contract.js";
|
|
4
5
|
const COMMAND_FENCE_LANGUAGE = /^(?:|bash|sh|shell|zsh|fish|console|terminal|command|cmd|powershell|pwsh|javascript|js|typescript|ts|python|py)$/i;
|
|
5
6
|
const LIST_ITEM = /^\s*(?:[-+*]|\d+[.)])\s+(.+)$/;
|
|
6
7
|
const INDENTED_CODE = /^(?: {4,}|\t+)(\S.*)$/;
|
|
7
8
|
const EXPLICIT_COMMAND = /^(?:(?:shell|terminal)(?:\s+command)?|command|run command|execute command)\s*:\s*(.+)$/i;
|
|
8
9
|
const BLOCKED_REASON_PREFIX = /^(?:(?:blocked|forbidden|reject(?:ed)?|disallow(?:ed)?|prohibited)(?:\s+reason)?\s*(?::|\bbecause\b)|(?:do not|must not|never)\s+(?:run|execute|use)\b)/i;
|
|
9
10
|
/** Standalone blockedReason / environment tokens (not executable commands). */
|
|
10
|
-
const BLOCKED_REASON_TOKEN = /^(?:playwright-cli-unavailable|frontend-base-url-unreachable|curl-unavailable|browser-command-capability-unavailable|browser-command-evidence-missing|token-budget-exhausted|current-user-data-unavailable|data-ownership-unverifiable|safe-test-data-setup-unavailable|invalid-evidence-shape)(?:\s|$|[.,;:)`'"\]])/i;
|
|
11
|
+
const BLOCKED_REASON_TOKEN = /^(?:playwright-cli-unavailable|frontend-base-url-unreachable|curl-unavailable|browser-command-capability-unavailable|browser-command-evidence-missing|token-budget-exhausted|current-user-data-unavailable|data-ownership-unverifiable|safe-test-data-setup-unavailable|unique-control-unavailable|invalid-evidence-shape)(?:\s|$|[.,;:)`'"\]])/i;
|
|
11
12
|
const BLOCKED_PROSE_HINT = /(?:blockedReason|blocked\s*reason|environmentProbe|evidenceDir|\bunavailable\b|不可用|写\s*blocked|写入\s*blocked|伪命令|自然语言|元数据|fail-closed|探测失败)/i;
|
|
12
13
|
const IMPERATIVE_COMMAND = /^(?:(?:run|execute|use)(?:\s+(?:the\s+)?(?:(?:shell|terminal)\s+)?command)?|in\s+(?:the\s+)?(?:shell|terminal|console)\s*,?\s*(?:run|execute|use))\s*:?\s+(.+)$/i;
|
|
13
14
|
const INLINE_CODE_STEP = /^`([^`\r\n]+)`[.!?]?$/;
|
|
15
|
+
/** Assertion-count annotations appended to a playwright-cli literal (e.g. `find "锚点">=1`, `>=N`, `≥N`, `(≥1)`) document an expected match count; they are documentation, not shell arguments. */
|
|
16
|
+
const ASSERTION_COUNT_SUFFIX = /\s*[((]?\s*(?:>=|≥|>|超过|至少)\s*\d+\s*[))]?\s*$/;
|
|
14
17
|
const AUTOMATION_EXECUTABLE = /^(?:playwright-cli\b|playwright\b|@playwright\/test\b|npx\b|npm\b|pnpm\b|yarn\b|bunx?\b|node(?:js)?\b|python(?:3)?\b|bash\b|sh\b|zsh\b|fish\b|powershell\b|pwsh\b|cmd(?:\.exe)?\b|cypress\b|selenium\b|webdriverio\b|chromedriver\b|google-chrome\b|chrome\b|firefox\b|curl\b|wget\b)/i;
|
|
15
18
|
const SHELL_WRAPPED_EXECUTABLE = /^(?:sudo\s+|env(?:\s+[A-Za-z_][A-Za-z0-9_]*=[^\s]+)*\s+|(?:\.{1,2}[\\/]|~[\\/]|[A-Za-z]:[\\/]|\/)[^\s]+)/i;
|
|
16
19
|
const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s]*)/;
|
|
@@ -44,6 +47,11 @@ function isBlockedReason(value) {
|
|
|
44
47
|
return true;
|
|
45
48
|
if (/^blockedReason\s*[:=]/i.test(trimmed))
|
|
46
49
|
return true;
|
|
50
|
+
// Expected-outcome field lines ("status=blocked" / "status: failed") document
|
|
51
|
+
// the intended terminal state of a designed-negative case; they are
|
|
52
|
+
// case-result metadata, never executable commands.
|
|
53
|
+
if (/^["'`]?status["'`]?\s*[:=]\s*["'`]?(passed|failed|blocked)["'`]?\s*[.。]?$/i.test(trimmed))
|
|
54
|
+
return true;
|
|
47
55
|
return false;
|
|
48
56
|
}
|
|
49
57
|
/**
|
|
@@ -238,6 +246,12 @@ function commandGateIssue(input) {
|
|
|
238
246
|
if (input.instruction.fromFence && isBlockedReason(input.instruction.command)) {
|
|
239
247
|
return null;
|
|
240
248
|
}
|
|
249
|
+
// An expected-match-count annotation (`find "锚点">=1`, with or without a
|
|
250
|
+
// playwright-cli prefix) documents the assertion; it is not part of the
|
|
251
|
+
// command and never executable input.
|
|
252
|
+
if (/^(?:playwright-cli\s+)?find\s+"[^"]*"/i.test(input.instruction.command) && ASSERTION_COUNT_SUFFIX.test(input.instruction.command)) {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
241
255
|
const commandMatch = input.instruction.command.match(/^playwright-cli\s+([^\s`]+)/i);
|
|
242
256
|
if (commandMatch) {
|
|
243
257
|
const command = commandMatch[1].toLowerCase().replace(/^[`'"]+|[`'":,,。→]+$/g, "");
|
|
@@ -282,7 +296,13 @@ export async function validateFrontendCaseChecklist(input) {
|
|
|
282
296
|
const manifestPath = await exists(draft) ? draft : await exists(final) ? final : "";
|
|
283
297
|
if (!manifestPath)
|
|
284
298
|
throw new Error("checklist: missing manifest.draft.json or manifest.json");
|
|
285
|
-
|
|
299
|
+
let manifest;
|
|
300
|
+
try {
|
|
301
|
+
manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
throw new Error("checklist: manifest is malformed JSON");
|
|
305
|
+
}
|
|
286
306
|
if (!Array.isArray(manifest.cases) || manifest.cases.length === 0)
|
|
287
307
|
throw new Error("checklist: empty cases");
|
|
288
308
|
const declaredAc = new Set(input.declaredAcIds ?? []);
|
|
@@ -334,6 +354,10 @@ export async function validateFrontendCaseChecklist(input) {
|
|
|
334
354
|
for (const ac of item.acIds) {
|
|
335
355
|
if (typeof ac !== "string" || !acIdRe.test(ac))
|
|
336
356
|
issues.push({ ruleId: "ac-id-shape", caseId: id, casePath, detail: String(ac) });
|
|
357
|
+
else if (declaredAc.size > 0 && isTruncatedFrontendAcId(ac))
|
|
358
|
+
issues.push({ ruleId: "ac-id-truncated", caseId: id, casePath, detail: `${ac} is a truncated acceptance id (missing feature prefix or sequence number)` });
|
|
359
|
+
else if (declaredAc.size === 0 && isTruncatedWhenUndeclaredFrontendAcId(ac))
|
|
360
|
+
issues.push({ ruleId: "ac-id-truncated", caseId: id, casePath, detail: `${ac} is a truncated acceptance id (missing feature prefix or sequence number)` });
|
|
337
361
|
else if (declaredAc.size > 0 && !declaredAc.has(ac))
|
|
338
362
|
issues.push({ ruleId: "unknown-ac", caseId: id, casePath, detail: `${ac} not in sourceBinding` });
|
|
339
363
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile, rename, unlink, writeFile, mkdir } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { isTruncatedFrontendAcId, isTruncatedWhenUndeclaredFrontendAcId, } from "./frontend-test-result-contract.js";
|
|
3
4
|
const DIMENSIONS = new Set(["core", "boundary", "flow", "backend"]);
|
|
4
5
|
function fail(ruleId, detail) {
|
|
5
6
|
const msg = JSON.stringify({ ruleId, detail: String(detail ?? "") });
|
|
@@ -62,6 +63,10 @@ export async function materializeFrontendTestCaseManifest(input) {
|
|
|
62
63
|
for (const ac of c.acIds) {
|
|
63
64
|
if (typeof ac !== "string" || !acIdRe.test(ac))
|
|
64
65
|
fail("ac-id-shape", `acIds entry must be AC-* acceptance id, not caseId: ${ac}`);
|
|
66
|
+
if ((declaredAc.size > 0
|
|
67
|
+
? isTruncatedFrontendAcId(ac)
|
|
68
|
+
: isTruncatedWhenUndeclaredFrontendAcId(ac)))
|
|
69
|
+
fail("ac-id-truncated", `acIds entry is a truncated acceptance id (missing feature prefix or sequence number): ${ac}`);
|
|
65
70
|
if (declaredAc.size > 0 && !declaredAc.has(ac))
|
|
66
71
|
fail("unknown-ac", `${ac} not in sourceBinding; repair generator input or AC list`);
|
|
67
72
|
acIds.push(ac);
|
|
@@ -7,6 +7,25 @@ import { readPlaywrightCliReceipts, summarizePlaywrightCliReceipts, } from "../.
|
|
|
7
7
|
import { validateFrontendCaseContent } from "./frontend-test-case-quality.js";
|
|
8
8
|
import { markdownH1Title, markdownList, markdownSection, } from "./frontend-test-markdown.js";
|
|
9
9
|
export const FRONTEND_TEST_RESULT_SCHEMA_ID = "frontend-test-result-v1";
|
|
10
|
+
/**
|
|
11
|
+
* Truncated acceptance-id shapes observed in generator output: a bare
|
|
12
|
+
* numeric segment ("AC-002" dropped its AC-FE-<FEATURE> prefix) or an
|
|
13
|
+
* AC-FE-<FEATURE> literal without the trailing -NNN sequence number
|
|
14
|
+
* ("AC-FE-CHAT"). Generic short ids like "AC-1"/"AC-FE-001" stay valid;
|
|
15
|
+
* declared-source cross-checks (unknown-ac) remain the primary guard when a
|
|
16
|
+
* sourceBinding exists.
|
|
17
|
+
*/
|
|
18
|
+
export function isTruncatedFrontendAcId(acId) {
|
|
19
|
+
return /^AC-FE-[A-Z]+$/i.test(acId.trim());
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Bare numeric ids ("AC-002") only indicate truncation when no declared AC
|
|
23
|
+
* list exists to adjudicate them: with a sourceBinding they are ordinary
|
|
24
|
+
* unknown-ac findings ("AC-999" may be a legitimately declared id style).
|
|
25
|
+
*/
|
|
26
|
+
export function isTruncatedWhenUndeclaredFrontendAcId(acId) {
|
|
27
|
+
return /^AC-\d{3}$/i.test(acId.trim()) || isTruncatedFrontendAcId(acId);
|
|
28
|
+
}
|
|
10
29
|
const safeRelativePathSchema = z.string().min(1).refine((value) => !path.posix.isAbsolute(value) &&
|
|
11
30
|
!path.win32.isAbsolute(value) &&
|
|
12
31
|
!value.includes("\\") &&
|
|
@@ -57,6 +76,8 @@ export const frontendTestResultContractSchema = z.object({
|
|
|
57
76
|
passed: z.number().int().min(0),
|
|
58
77
|
failed: z.number().int().min(0),
|
|
59
78
|
blocked: z.number().int().min(0),
|
|
79
|
+
/** Stability metric: passed on attempt 0 (no rerun/self-heal retry). */
|
|
80
|
+
firstTryPassed: z.number().int().min(0).optional(),
|
|
60
81
|
}).strict(),
|
|
61
82
|
acceptanceCoverage: z.object({
|
|
62
83
|
covered: z.array(z.string().min(1)),
|
|
@@ -110,7 +131,13 @@ export async function validateFrontendCaseEvidence(input) {
|
|
|
110
131
|
catch {
|
|
111
132
|
throw new Error("missing testcase/frontend/cases/manifest.json");
|
|
112
133
|
}
|
|
113
|
-
|
|
134
|
+
let manifest;
|
|
135
|
+
try {
|
|
136
|
+
manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
throw new Error("invalid frontend case manifest: malformed JSON");
|
|
140
|
+
}
|
|
114
141
|
if (!Array.isArray(manifest.cases))
|
|
115
142
|
throw new Error("invalid frontend case manifest");
|
|
116
143
|
const statuses = new Set(["passed", "failed", "blocked"]);
|
|
@@ -195,11 +222,17 @@ export function evaluatePassedCaseBrowserReceipts(summary, caseId) {
|
|
|
195
222
|
}
|
|
196
223
|
return { ok: true };
|
|
197
224
|
}
|
|
198
|
-
async function
|
|
225
|
+
async function loadCaseBrowserReceiptsWithSummary(input) {
|
|
199
226
|
try {
|
|
200
227
|
const entries = await readdir(input.runDir, { withFileTypes: true });
|
|
201
228
|
// A passed chain must belong to exactly one browser child stream. Never
|
|
202
|
-
// stitch receipts across DAG nodes or accept ambiguous duplicate
|
|
229
|
+
// stitch receipts across DAG nodes or accept ambiguous duplicate
|
|
230
|
+
// authority — with one exception: bounded rerun creates a second,
|
|
231
|
+
// later stream for the same case (the rerun overwrites the disk
|
|
232
|
+
// case-result, so the final attempt is the authoritative one). When
|
|
233
|
+
// multiple streams authorize, prefer the highest rerun round
|
|
234
|
+
// (rerun-frontend-case-rN-<idx> directories); ties within the same
|
|
235
|
+
// round remain ambiguous and fail closed.
|
|
203
236
|
const authorizingStreams = [];
|
|
204
237
|
for (const entry of entries) {
|
|
205
238
|
if (!entry.isDirectory())
|
|
@@ -207,17 +240,99 @@ async function loadCaseBrowserReceiptSummary(input) {
|
|
|
207
240
|
const nodeReceipts = (await readPlaywrightCliReceipts(input.runDir, entry.name))
|
|
208
241
|
.filter((item) => item.caseId === input.caseId);
|
|
209
242
|
const summary = summarizePlaywrightCliReceipts(nodeReceipts);
|
|
210
|
-
if (summary.hasOrderedPassedReceiptChain)
|
|
211
|
-
|
|
243
|
+
if (summary.hasOrderedPassedReceiptChain) {
|
|
244
|
+
const roundMatch = /rerun-frontend-case-r(\d+)-/i.exec(entry.name);
|
|
245
|
+
authorizingStreams.push({
|
|
246
|
+
receipts: nodeReceipts,
|
|
247
|
+
summary,
|
|
248
|
+
round: roundMatch ? Number(roundMatch[1]) : 0,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (authorizingStreams.length === 0) {
|
|
253
|
+
return { receipts: [], summary: summarizePlaywrightCliReceipts([]) };
|
|
212
254
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
255
|
+
const maxRound = Math.max(...authorizingStreams.map((s) => s.round));
|
|
256
|
+
const finalists = authorizingStreams.filter((s) => s.round === maxRound);
|
|
257
|
+
return finalists.length === 1
|
|
258
|
+
? finalists[0]
|
|
259
|
+
: { receipts: [], summary: summarizePlaywrightCliReceipts([]) };
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
return { receipts: [], summary: summarizePlaywrightCliReceipts([]) };
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Extract `find "literal" >=N` / `find "literal"` declarations from a case
|
|
267
|
+
* document. Only quoted literals are binding assertions — the count suffix
|
|
268
|
+
* (`>=N`, `==N`) is the expected minimum/exact match count from the ledger.
|
|
269
|
+
* Placeholder literals ("...", "…", "<字面>", "eX") appear in prose discussing
|
|
270
|
+
* the syntax itself; they are documentation, never declarations.
|
|
271
|
+
*/
|
|
272
|
+
export function extractDeclaredFindAssertions(caseMarkdown) {
|
|
273
|
+
const source = stripFencedCodeBlocks(caseMarkdown);
|
|
274
|
+
const out = [];
|
|
275
|
+
const seen = new Set();
|
|
276
|
+
const pattern = /find\s+"((?:[^"\\]|\\.)+)"\s*(>=|==)\s*(\d+)/g;
|
|
277
|
+
const isPlaceholder = (literal) => /^(?:\.{2,}|…+|<[^>]*>|e\d+)$/.test(literal.trim());
|
|
278
|
+
let match;
|
|
279
|
+
while ((match = pattern.exec(source)) !== null) {
|
|
280
|
+
const literal = match[1].replace(/\\(.)/g, "$1");
|
|
281
|
+
if (isPlaceholder(literal))
|
|
282
|
+
continue;
|
|
283
|
+
const operator = match[2];
|
|
284
|
+
const expected = Number(match[3]);
|
|
285
|
+
const key = `${operator}:${literal}`;
|
|
286
|
+
if (seen.has(key))
|
|
287
|
+
continue;
|
|
288
|
+
seen.add(key);
|
|
289
|
+
out.push({ literal, expected, operator });
|
|
290
|
+
}
|
|
291
|
+
return out;
|
|
292
|
+
}
|
|
293
|
+
function stripFencedCodeBlocks(markdown) {
|
|
294
|
+
return markdown.replace(/```[\s\S]*?```/g, "");
|
|
295
|
+
}
|
|
296
|
+
async function readCaseMarkdownForAudit(workspaceRoot, casePath) {
|
|
297
|
+
try {
|
|
298
|
+
return await readFile(path.join(workspaceRoot, casePath), "utf8");
|
|
216
299
|
}
|
|
217
300
|
catch {
|
|
218
|
-
return
|
|
301
|
+
return "";
|
|
219
302
|
}
|
|
220
303
|
}
|
|
304
|
+
/**
|
|
305
|
+
* Controller-side assertion audit: every find-family declaration in the case
|
|
306
|
+
* document must be backed by a successful find receipt whose parsed match
|
|
307
|
+
* count satisfies the declared operator. This moves pass authority from
|
|
308
|
+
* "the model says it asserted" to "receipts prove it asserted".
|
|
309
|
+
*/
|
|
310
|
+
export function auditDeclaredFindAssertions(declared, receipts) {
|
|
311
|
+
if (declared.length === 0)
|
|
312
|
+
return { ok: true };
|
|
313
|
+
const successfulFinds = receipts.filter((receipt) => (receipt.command === "find" || receipt.command === "find-unique") &&
|
|
314
|
+
receipt.exitCode === 0 &&
|
|
315
|
+
!receipt.timedOut);
|
|
316
|
+
const missing = [];
|
|
317
|
+
for (const decl of declared) {
|
|
318
|
+
const backing = successfulFinds.some((receipt) => receipt.argsRedacted.some((arg) => arg === decl.literal || arg === `"${decl.literal}"`) &&
|
|
319
|
+
typeof receipt.matchCount === "number" &&
|
|
320
|
+
(decl.operator === ">="
|
|
321
|
+
? receipt.matchCount >= decl.expected
|
|
322
|
+
: receipt.matchCount === decl.expected));
|
|
323
|
+
if (!backing) {
|
|
324
|
+
missing.push(`${decl.operator}${decl.expected} "${decl.literal.slice(0, 40)}"`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (missing.length > 0) {
|
|
328
|
+
return {
|
|
329
|
+
ok: false,
|
|
330
|
+
reason: "assertion-not-receipt-backed",
|
|
331
|
+
missing,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
return { ok: true };
|
|
335
|
+
}
|
|
221
336
|
function sha256(content) {
|
|
222
337
|
return createHash("sha256").update(content).digest("hex");
|
|
223
338
|
}
|
|
@@ -324,7 +439,13 @@ export async function materializeFrontendTestResult(input) {
|
|
|
324
439
|
}
|
|
325
440
|
const manifestPath = "testcase/frontend/cases/manifest.json";
|
|
326
441
|
const manifestAbsolute = path.resolve(input.workspaceRoot, manifestPath);
|
|
327
|
-
|
|
442
|
+
let manifest;
|
|
443
|
+
try {
|
|
444
|
+
manifest = JSON.parse(await readFile(manifestAbsolute, "utf-8"));
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
throw new Error("invalid or empty frontend case manifest: malformed JSON");
|
|
448
|
+
}
|
|
328
449
|
if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.cases) || manifest.cases.length === 0) {
|
|
329
450
|
throw new Error("invalid or empty frontend case manifest");
|
|
330
451
|
}
|
|
@@ -387,7 +508,7 @@ export async function materializeFrontendTestResult(input) {
|
|
|
387
508
|
advisoryFindings.push({ ruleId: "passed-without-browser-evidence", caseId: item.caseId, detail: "passed case has no browser evidence" });
|
|
388
509
|
let blockedReason = status === "blocked" && typeof resultRaw.blockedReason === "string" && resultRaw.blockedReason.trim() ? resultRaw.blockedReason.trim() : undefined;
|
|
389
510
|
if (status === "passed") {
|
|
390
|
-
const receiptSummary = await
|
|
511
|
+
const { receipts: caseReceipts, summary: receiptSummary } = await loadCaseBrowserReceiptsWithSummary({
|
|
391
512
|
runDir: input.runDir,
|
|
392
513
|
caseId: item.caseId,
|
|
393
514
|
});
|
|
@@ -401,6 +522,24 @@ export async function materializeFrontendTestResult(input) {
|
|
|
401
522
|
detail: "passed case lacks controller-owned open + interaction/assertion + cleanup receipts",
|
|
402
523
|
});
|
|
403
524
|
}
|
|
525
|
+
else {
|
|
526
|
+
// Controller-side assertion audit: every `find "literal" >=N` declared
|
|
527
|
+
// in the case document must be backed by a successful find receipt
|
|
528
|
+
// whose parsed matchCount satisfies the operator. Pass authority moves
|
|
529
|
+
// from model prose to receipt evidence.
|
|
530
|
+
const caseMarkdown = await readCaseMarkdownForAudit(input.workspaceRoot, item.casePath);
|
|
531
|
+
const declared = extractDeclaredFindAssertions(caseMarkdown);
|
|
532
|
+
const audit = auditDeclaredFindAssertions(declared, caseReceipts);
|
|
533
|
+
if (!audit.ok) {
|
|
534
|
+
status = "blocked";
|
|
535
|
+
blockedReason = audit.reason;
|
|
536
|
+
advisoryFindings.push({
|
|
537
|
+
ruleId: "assertion-not-receipt-backed",
|
|
538
|
+
caseId: item.caseId,
|
|
539
|
+
detail: `declared find assertions without receipt backing: ${audit.missing.join("; ")}`,
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
}
|
|
404
543
|
}
|
|
405
544
|
const { caseContent, testPoints } = await readFrontendCaseContent(input.workspaceRoot, item.casePath);
|
|
406
545
|
// Parse bounded fixed sections from execution.md. Missing optional
|
|
@@ -451,6 +590,7 @@ export async function materializeFrontendTestResult(input) {
|
|
|
451
590
|
passed: cases.filter((item) => item.status === "passed").length,
|
|
452
591
|
failed: cases.filter((item) => item.status === "failed").length,
|
|
453
592
|
blocked: cases.filter((item) => item.status === "blocked").length,
|
|
593
|
+
firstTryPassed: cases.filter((item) => item.status === "passed" && !item.rerunAttempt).length,
|
|
454
594
|
};
|
|
455
595
|
const outcome = totals.failed > 0 ? "failed" : totals.blocked > 0 || missing.length > 0 ? "incomplete" : "passed";
|
|
456
596
|
const result = frontendTestResultContractSchema.parse({
|
|
@@ -2,6 +2,18 @@ import { readFile, stat, writeFile, mkdir } from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID, frontendImplementationContractSchema, } from "./frontend-implementation-contract.js";
|
|
4
4
|
export const FRONTEND_VERIFICATION_TRACE_SCHEMA_ID = "frontend-verification-trace-v1";
|
|
5
|
+
/**
|
|
6
|
+
* Shared marker for a project that exposes no usable verification command at
|
|
7
|
+
* generation time. The marker command runs (and logs not-run honestly), but it
|
|
8
|
+
* must never count as successful verification evidence: labels carrying this
|
|
9
|
+
* text are excluded from successful evidence and targets bound to it fail the
|
|
10
|
+
* trace gate instead of passing it.
|
|
11
|
+
*/
|
|
12
|
+
export const FRONTEND_NO_VERIFICATION_MARKER_TEXT = "No frontend verification command configured";
|
|
13
|
+
export function isFrontendNoVerificationMarkerLabel(label) {
|
|
14
|
+
return label.includes(FRONTEND_NO_VERIFICATION_MARKER_TEXT);
|
|
15
|
+
}
|
|
16
|
+
export const FRONTEND_VERIFICATION_UNAVAILABLE_MESSAGE = `frontend verification unavailable: the only frozen static command is the no-op not-run marker (${FRONTEND_NO_VERIFICATION_MARKER_TEXT}); declare verification commands via task.json --verify or 执行约束.md and regenerate the DAG`;
|
|
5
17
|
async function readJsonFile(filePath) {
|
|
6
18
|
return JSON.parse(await readFile(filePath, "utf8"));
|
|
7
19
|
}
|
|
@@ -52,7 +64,14 @@ export async function evaluateVerificationTargets(input) {
|
|
|
52
64
|
const hardIssues = [];
|
|
53
65
|
for (const target of input.contract.verificationTargets) {
|
|
54
66
|
const issues = [];
|
|
55
|
-
|
|
67
|
+
if (isFrontendNoVerificationMarkerLabel(target.commandLabel)) {
|
|
68
|
+
// A target bound to the not-run marker has no verification behind it;
|
|
69
|
+
// passing it would let the run finish green without any check.
|
|
70
|
+
issues.push(`${target.id}: ${FRONTEND_VERIFICATION_UNAVAILABLE_MESSAGE}`);
|
|
71
|
+
}
|
|
72
|
+
const owners = isFrontendNoVerificationMarkerLabel(target.commandLabel)
|
|
73
|
+
? []
|
|
74
|
+
: (input.labelOwners.get(target.commandLabel) ?? []);
|
|
56
75
|
if (!owners?.length) {
|
|
57
76
|
issues.push(`commandLabel not executed in current-run frozen evidence: ${target.commandLabel}`);
|
|
58
77
|
}
|
|
@@ -189,11 +208,17 @@ export async function runFrontendVerificationTraceGate(input) {
|
|
|
189
208
|
throw new Error("trace: selected Mock strategy requires successful Mock verification commands");
|
|
190
209
|
}
|
|
191
210
|
if (input.evidence) {
|
|
192
|
-
|
|
193
|
-
|
|
211
|
+
// The no-op not-run marker never counts as successful verification
|
|
212
|
+
// evidence, even if it executed with exit code 0.
|
|
213
|
+
const effectiveStaticLabels = input.evidence.static.commandLabels.filter((label) => !isFrontendNoVerificationMarkerLabel(label));
|
|
214
|
+
const effectiveBehaviorLabels = input.evidence.behavior.commandLabels.filter((label) => !isFrontendNoVerificationMarkerLabel(label));
|
|
215
|
+
if (effectiveStaticLabels.length === 0) {
|
|
216
|
+
throw new Error(input.evidence.static.commandLabels.length > 0
|
|
217
|
+
? `trace: ${FRONTEND_VERIFICATION_UNAVAILABLE_MESSAGE}`
|
|
218
|
+
: "trace: no successful static verification commands in current run");
|
|
194
219
|
}
|
|
195
220
|
if (requiresBehaviorVerification &&
|
|
196
|
-
|
|
221
|
+
effectiveBehaviorLabels.length === 0) {
|
|
197
222
|
throw new Error("trace: no successful behavior verification commands in current run");
|
|
198
223
|
}
|
|
199
224
|
}
|