@tea-agent/loop-agent 0.42.0-next.5 → 0.42.0-next.7
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 +1 -1
- package/CHANGELOG.md +22 -0
- package/dist/build-stamp.json +3 -3
- package/dist/executors/dag-pi-executor.js +75 -13
- package/dist/task/source-prepare/fragment-inventory.js +49 -16
- package/dist/task/source-prepare/prepare.js +37 -20
- package/dist/worker/console/chat/model-resolver.js +7 -4
- package/dist/worker/console/chat/pi-runtime.js +40 -1
- package/dist/worker/console/chat/routes.js +25 -24
- package/dist/worker/console/chat/sdd-data-alignment.js +6 -3
- package/dist/worker/console/pi-readiness.js +3 -2
- package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-BJpsGgAv.js → abnfDiagram-N423BO3Z-BQBHX31n.js} +1 -1
- package/dist/worker/console/static/assets/{arc-DjMFPsEd.js → arc-MSJ199eR.js} +1 -1
- package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-DqdZlr1O.js → architectureDiagram-T3A2C74G-DiRq2qMX.js} +1 -1
- package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-BmZnfFEO.js → blockDiagram-VBNYF7ZC-rlaxFOvi.js} +1 -1
- package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-3cKberpL.js → c4Diagram-5PPSVZJV-BSpi9ump.js} +1 -1
- package/dist/worker/console/static/assets/channel-BdTaMnYP.js +1 -0
- package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-q-0yjQ1h.js → chunk-2GRJ4B5K-BN8VNeh5.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-DnONBaDW.js → chunk-2Q5K7J3B-CDa1OVRA.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5RXB4S5H-v3a6PCOS.js → chunk-5RXB4S5H-zxMQ5b8E.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5VM5RSS4-D1kLrlU_.js → chunk-5VM5RSS4-XTqNlzH4.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-BPelzihi.js → chunk-6Q2QTUOP-DpZQfFTq.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-GF5L2VYU-DauNE69H.js → chunk-GF5L2VYU-DPv743zu.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-JWPE2WC7-RIg5_bFJ.js → chunk-JWPE2WC7-CFsCFoF4.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-KBJHAD2P-BuILuoxs.js → chunk-KBJHAD2P-CV__aJTt.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-RYQCIY6F-VHORV5oW.js → chunk-RYQCIY6F-BshKlNir.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-XXDRQBXY-BJYr4xHC.js → chunk-XXDRQBXY-B-A3ErBP.js} +1 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-Cid7CSRK.js +1 -0
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-Cid7CSRK.js +1 -0
- package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-BS6nuqQn.js → cose-bilkent-JH36ORCC-25xj8928.js} +1 -1
- package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-BBhF0NAT.js → cynefin-VYW2F7L2-DFmv38cl.js} +1 -1
- package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-D1135Ifg.js → cynefinDiagram-MW4NZA55-hReuWDQP.js} +1 -1
- package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-vAOYPUWJ.js → dagre-VZM6K2ZE-C9X3YPUI.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-7IWD3JNH-DuaSPkfa.js → diagram-7IWD3JNH-CAVmqjg9.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-mMvXvGU6.js → diagram-B4RE2ZJO-DYNlLsIm.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-LBJQPF4R-CDIEJtlL.js → diagram-LBJQPF4R-BP5mlbfb.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-Q27KOJAE-Bi_6JvCW.js → diagram-Q27KOJAE-KWOHK5ZM.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-UB23O5K3-DAqj57yN.js → diagram-UB23O5K3-D25Ro5y-.js} +1 -1
- package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-DfWaxNEo.js → ebnfDiagram-BXEA7PRR-kLaahtbh.js} +1 -1
- package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-Bt7QqkNG.js → erDiagram-JOGREHBK-1tgKuLez.js} +1 -1
- package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-CRRhQwT2.js → flowDiagram-UKHOOZJN-DARr-Cvn.js} +1 -1
- package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-DAV1O5B6.js → ganttDiagram-PKOTCBZU-BgwjBv_V.js} +1 -1
- package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-0X2Qo4Lp.js → gitGraphDiagram-DS77QQ5N-DneIlYSI.js} +1 -1
- package/dist/worker/console/static/assets/{index-CVdddQsA.js → index-Dkv0ezuE.js} +75 -77
- package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-C5W5eILC.js → infoDiagram-6WML65LV-DA7ATqY6.js} +1 -1
- package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-5SfqU-Tb.js → ishikawaDiagram-WSZJBQD7-DnOS-E1Z.js} +1 -1
- package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-CKDRcaKf.js → journeyDiagram-NVQOT4AX-C8JnwJ_s.js} +1 -1
- package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-Cc7_TuSb.js → kanban-definition-27J2QSJJ-Cw0_OjEC.js} +1 -1
- package/dist/worker/console/static/assets/{linear-BcQYYmeT.js → linear-mrxq3fyn.js} +1 -1
- package/dist/worker/console/static/assets/{mermaid.core-CwLZPtQo.js → mermaid.core-Bagus6zF.js} +5 -5
- package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-D29M025z.js → mindmap-definition-FAOFIHXS-Bf95bLuW.js} +1 -1
- package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-CV-ZAWYe.js → pegDiagram-VL7TDLO6-BvtUW6w2.js} +1 -1
- package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-C6iPS_o5.js → pieDiagram-7S7Q4E2Y-CbWtT-Ry.js} +1 -1
- package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-BFCD1sTg.js → quadrantDiagram-CIZ2JOQS-ctnxxIdD.js} +1 -1
- package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-CztNj4O9.js → railroadDiagram-AXF67PYL-BZ7Z3X0-.js} +1 -1
- package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-BWIELSNd.js → requirementDiagram-LRYGKXZP-BMX1xnIy.js} +1 -1
- package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-CPtxrjJQ.js → sankeyDiagram-W5VNT64P-MShOQznz.js} +1 -1
- package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-BEWVSFsu.js → sequenceDiagram-SI44F4Z6-CTGOazaB.js} +1 -1
- package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-DyhzoX_J.js → sizeCapture-X5ZJPWSS-zGbfM4nV.js} +1 -1
- package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-xLox0nu7.js → stateDiagram-OKZ733FA-Brf8Wwzr.js} +1 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-CIkoculx.js +1 -0
- package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-CLC_ow9A.js → swimlanes-SLNWSIFB-CvNw3ZqF.js} +2 -2
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-pJ-V5EdT.js +8 -0
- package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-DmJZ4uyY.js → timeline-definition-Z64GVDOM-z2LyZpDt.js} +1 -1
- package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-DD7GGi9Z.js → vennDiagram-T6HMQDX7-Bc_coprH.js} +1 -1
- package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-Bi8PQoGt.js → wardleyDiagram-T6FBY63Y-M1NSmWq-.js} +1 -1
- package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-uKdAY8Dp.js → xychartDiagram-ELKLHX3M-Ct0rjGXW.js} +1 -1
- package/dist/worker/console/static/index.html +1 -1
- package/dist/worker/console/static-src/app/useRecoveryConsole.js +5 -5
- package/dist/worker/console/static-src/operator-chat/input-history.js +8 -6
- package/dist/worker/console/static-src/shell/console-update-reload.js +36 -9
- package/dist/worker/console/static-src/shell/workspace-route.js +11 -0
- package/dist/worker/observe/static/dag-helpers.js +4 -2
- package/dist/worker/observe/static/inspect-workspace.js +23 -0
- package/dist/worker/observe/static/kpi.js +2 -2
- package/dist/worker/observe/static/operator-chrome.d.ts +10 -2
- package/dist/worker/observe/static/operator-chrome.js +37 -23
- package/dist/worker/observe/static/relations.js +2 -2
- package/dist/worker/observe/static/router.d.ts +12 -1
- package/dist/worker/observe/static/router.js +48 -6
- package/dist/worker/observe/static/run-processing.js +4 -2
- package/dist/worker/observe/static/shell-chrome.js +2 -2
- package/dist/worker/observe/static/state.js +5 -0
- package/dist/worker/observe/static/styles.css +95 -0
- package/dist/worker/observe/static/views/dag-inspector.js +14 -7
- package/dist/worker/observe/static/views/dag.js +10 -2
- package/dist/worker/observe/static/views/dags.js +3 -1
- package/dist/worker/observe/static/views/dashboard.js +14 -6
- package/dist/worker/observe/static/views/pool.js +7 -2
- package/dist/worker/observe/static/views/run.js +12 -3
- package/dist/worker/observe/static/views/session-timeline.js +69 -10
- package/dist/worker/observe/static/views/task.js +7 -2
- package/dist/workflows/dag/backend-test-case-coverage-analysis.js +58 -15
- package/dist/workflows/dag/backend-test-plan-protocol.js +106 -0
- package/dist/workflows/dag/backend-test-scenario-param.js +16 -5
- package/dist/workflows/dag/backend-test-writer-completeness.js +42 -6
- package/dist/workflows/dag/frontend-shadow-dual-write.js +33 -6
- package/dist/workflows/dag/init-hybrid.js +193 -32
- package/dist/workflows/dag/node-execution.js +1 -1
- package/dist/workflows/dag/prompt.js +36 -2
- package/dist/workflows/dag/types.js +1 -1
- package/docs/templates/backend-test-dag.json +5 -5
- package/package.json +3 -2
- package/dist/worker/console/static/assets/channel-DWYwciiv.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-D6jOf_l4.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-D6jOf_l4.js +0 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DS5ZRvJG.js +0 -1
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Dvpxn-bf.js +0 -8
|
@@ -7,12 +7,72 @@ import { fetchJson } from "../api.js";
|
|
|
7
7
|
import {
|
|
8
8
|
uiState,
|
|
9
9
|
updateDagTimelineViewportState,
|
|
10
|
-
computeFollowLatest,
|
|
11
10
|
} from "../state.js";
|
|
12
11
|
|
|
13
12
|
/** Default visible lines for tool result preview before expand. */
|
|
14
13
|
export const TOOL_RESULT_PREVIEW_LINES = 20;
|
|
15
14
|
|
|
15
|
+
function isTimelineAutoScrollEnabled() {
|
|
16
|
+
return uiState.dagTimelineAutoScrollEnabled !== false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function scrollTimelineToLatest(content) {
|
|
20
|
+
content.scrollLeft = 0;
|
|
21
|
+
content.scrollTop = content.scrollHeight;
|
|
22
|
+
uiState.dagTimelineViewportState = updateDagTimelineViewportState(
|
|
23
|
+
uiState.dagTimelineViewportState,
|
|
24
|
+
uiState.currentDagRunId,
|
|
25
|
+
content.dataset.nodeId,
|
|
26
|
+
content.scrollLeft,
|
|
27
|
+
content.scrollTop,
|
|
28
|
+
true,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Floating, explicit live-follow control for the execution-record viewport. */
|
|
33
|
+
export function createTimelineAutoScrollControl(content) {
|
|
34
|
+
const dock = el("div", "process-timeline-autoscroll-dock");
|
|
35
|
+
const button = document.createElement("button");
|
|
36
|
+
button.type = "button";
|
|
37
|
+
const signal = el("span", "process-timeline-autoscroll-signal");
|
|
38
|
+
signal.setAttribute("aria-hidden", "true");
|
|
39
|
+
const icon = el("i", "ri-arrow-down-line");
|
|
40
|
+
icon.setAttribute("aria-hidden", "true");
|
|
41
|
+
button.appendChild(signal);
|
|
42
|
+
button.appendChild(icon);
|
|
43
|
+
|
|
44
|
+
const sync = () => {
|
|
45
|
+
const enabled = isTimelineAutoScrollEnabled();
|
|
46
|
+
const actionLabel = enabled ? "关闭自动滚动" : "自动滚动";
|
|
47
|
+
button.className = `process-timeline-autoscroll${enabled ? " is-active" : ""}`;
|
|
48
|
+
button.setAttribute("aria-pressed", String(enabled));
|
|
49
|
+
button.setAttribute("aria-label", actionLabel);
|
|
50
|
+
button.setAttribute("data-tooltip", actionLabel);
|
|
51
|
+
icon.className = enabled ? "ri-arrow-down-line" : "ri-pause-mini-fill";
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
button.addEventListener("click", () => {
|
|
55
|
+
const enabled = !isTimelineAutoScrollEnabled();
|
|
56
|
+
uiState.dagTimelineAutoScrollEnabled = enabled;
|
|
57
|
+
if (enabled) scrollTimelineToLatest(content);
|
|
58
|
+
else {
|
|
59
|
+
uiState.dagTimelineViewportState = updateDagTimelineViewportState(
|
|
60
|
+
uiState.dagTimelineViewportState,
|
|
61
|
+
uiState.currentDagRunId,
|
|
62
|
+
content.dataset.nodeId,
|
|
63
|
+
content.scrollLeft,
|
|
64
|
+
content.scrollTop,
|
|
65
|
+
false,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
sync();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
sync();
|
|
72
|
+
dock.appendChild(button);
|
|
73
|
+
return dock;
|
|
74
|
+
}
|
|
75
|
+
|
|
16
76
|
export function normalizeSessionEventFields(event) {
|
|
17
77
|
if (!event || typeof event !== "object") return {};
|
|
18
78
|
const args = event.args ?? event.input ?? event.toolInput;
|
|
@@ -951,6 +1011,7 @@ export function renderSessionTimeline(content, nodeId, options = {}) {
|
|
|
951
1011
|
});
|
|
952
1012
|
toolbar.appendChild(exportBtn);
|
|
953
1013
|
content.appendChild(toolbar);
|
|
1014
|
+
content.appendChild(createTimelineAutoScrollControl(content));
|
|
954
1015
|
|
|
955
1016
|
const timelineItems = filterExecutionRecordItems(
|
|
956
1017
|
buildRichTimelineItems(visible.events, { showProtocol }),
|
|
@@ -1030,23 +1091,18 @@ export function renderSessionTimeline(content, nodeId, options = {}) {
|
|
|
1030
1091
|
content.addEventListener(
|
|
1031
1092
|
"scroll",
|
|
1032
1093
|
() => {
|
|
1033
|
-
const followLatest = computeFollowLatest(
|
|
1034
|
-
content.scrollTop,
|
|
1035
|
-
content.scrollHeight,
|
|
1036
|
-
content.clientHeight,
|
|
1037
|
-
);
|
|
1038
1094
|
uiState.dagTimelineViewportState = updateDagTimelineViewportState(
|
|
1039
1095
|
uiState.dagTimelineViewportState,
|
|
1040
1096
|
uiState.currentDagRunId,
|
|
1041
1097
|
nodeId,
|
|
1042
1098
|
content.scrollLeft,
|
|
1043
1099
|
content.scrollTop,
|
|
1044
|
-
|
|
1100
|
+
isTimelineAutoScrollEnabled(),
|
|
1045
1101
|
);
|
|
1046
1102
|
},
|
|
1047
1103
|
{ passive: true },
|
|
1048
1104
|
);
|
|
1049
|
-
if (
|
|
1105
|
+
if (isTimelineAutoScrollEnabled()) {
|
|
1050
1106
|
content.scrollLeft = timelineState?.scrollLeft ?? 0;
|
|
1051
1107
|
content.scrollTop = content.scrollHeight;
|
|
1052
1108
|
} else {
|
|
@@ -1058,6 +1114,7 @@ export function renderSessionTimeline(content, nodeId, options = {}) {
|
|
|
1058
1114
|
function rerenderSessionTimeline(content, nodeId, options) {
|
|
1059
1115
|
const scrollTop = content.scrollTop;
|
|
1060
1116
|
const scrollLeft = content.scrollLeft;
|
|
1117
|
+
const autoScrollEnabled = isTimelineAutoScrollEnabled();
|
|
1061
1118
|
const preserved = [...content.children].filter((child) =>
|
|
1062
1119
|
String(child.className ?? "")
|
|
1063
1120
|
.split(/\s+/)
|
|
@@ -1066,6 +1123,8 @@ function rerenderSessionTimeline(content, nodeId, options) {
|
|
|
1066
1123
|
while (content.firstChild) content.removeChild(content.firstChild);
|
|
1067
1124
|
for (const child of preserved) content.appendChild(child);
|
|
1068
1125
|
renderSessionTimeline(content, nodeId, options);
|
|
1069
|
-
|
|
1070
|
-
|
|
1126
|
+
if (!autoScrollEnabled) {
|
|
1127
|
+
content.scrollTop = scrollTop;
|
|
1128
|
+
content.scrollLeft = scrollLeft;
|
|
1129
|
+
}
|
|
1071
1130
|
}
|
|
@@ -36,7 +36,12 @@ import {
|
|
|
36
36
|
markdownLinkHref,
|
|
37
37
|
formatLedgerSummary,
|
|
38
38
|
} from "../format.js";
|
|
39
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
parseRoute,
|
|
41
|
+
navigate,
|
|
42
|
+
buildHashPath,
|
|
43
|
+
inspectPathToCanonicalHref,
|
|
44
|
+
} from "../router.js";
|
|
40
45
|
import {
|
|
41
46
|
fetchJson,
|
|
42
47
|
fetchJsonResult,
|
|
@@ -133,7 +138,7 @@ export async function renderTaskDetail(featureId, taskId) {
|
|
|
133
138
|
if (!c?.featureId || !c?.taskId) continue;
|
|
134
139
|
const path = `/feature/${encodeURIComponent(c.featureId)}/task/${encodeURIComponent(c.taskId)}`;
|
|
135
140
|
const a = el("a", "object-link", `${c.featureId}::${c.taskId}`);
|
|
136
|
-
a.href =
|
|
141
|
+
a.href = inspectPathToCanonicalHref(path);
|
|
137
142
|
a.addEventListener("click", (e) => {
|
|
138
143
|
e.preventDefault();
|
|
139
144
|
navigate(path);
|
|
@@ -1463,7 +1463,7 @@ for top in ast.walk(tree):
|
|
|
1463
1463
|
returned_names.add(node.value.id)
|
|
1464
1464
|
if isinstance(node,ast.Call):
|
|
1465
1465
|
for keyword in node.keywords:
|
|
1466
|
-
if keyword.arg in {'payload','body','request_body','request_json','dto','json','data','json_body'} and isinstance(keyword.value,ast.Name) and keyword.value.id in positional:
|
|
1466
|
+
if keyword.arg in {'payload','body','request_body','request_json','dto','json','data','json_body','params','query','query_params'} and isinstance(keyword.value,ast.Name) and keyword.value.id in positional:
|
|
1467
1467
|
transport.append((keyword.value.id,positional.index(keyword.value.id)-call_offset))
|
|
1468
1468
|
if isinstance(node.func,ast.Attribute) and node.func.attr.lower() in {'post','put','patch'} and len(node.args) >= 2:
|
|
1469
1469
|
candidate=node.args[1]
|
|
@@ -1484,7 +1484,7 @@ for top in ast.walk(tree):
|
|
|
1484
1484
|
for node in ast.walk(top):
|
|
1485
1485
|
if not isinstance(node,ast.Call): continue
|
|
1486
1486
|
for keyword in node.keywords:
|
|
1487
|
-
if keyword.arg in {'payload','body','request_body','request_json','dto','json','data','json_body'} and isinstance(keyword.value,ast.Name) and keyword.value.id in assigned:
|
|
1487
|
+
if keyword.arg in {'payload','body','request_body','request_json','dto','json','data','json_body','params','query','query_params'} and isinstance(keyword.value,ast.Name) and keyword.value.id in assigned:
|
|
1488
1488
|
transport_shapes.append(assigned[keyword.value.id])
|
|
1489
1489
|
if candidates: FUNCTION_PAYLOADS[top.name]=candidates
|
|
1490
1490
|
if transport_shapes: FUNCTION_TRANSPORT_SHAPES[top.name]=transport_shapes
|
|
@@ -1540,8 +1540,9 @@ def literal(node):
|
|
|
1540
1540
|
return repr(node.value)
|
|
1541
1541
|
return None
|
|
1542
1542
|
|
|
1543
|
-
def collect_dict(node, prefix, paths, values):
|
|
1543
|
+
def collect_dict(node, prefix, paths, values, parameters=None):
|
|
1544
1544
|
if not isinstance(node, ast.Dict): return
|
|
1545
|
+
parameters = parameters or {}
|
|
1545
1546
|
for key,value in zip(node.keys,node.values):
|
|
1546
1547
|
if not isinstance(key, ast.Constant) or not isinstance(key.value,str): continue
|
|
1547
1548
|
# Ignore writer helper/meta keys that are not DTO request fields.
|
|
@@ -1551,12 +1552,16 @@ def collect_dict(node, prefix, paths, values):
|
|
|
1551
1552
|
paths.add(current)
|
|
1552
1553
|
lv=literal(value)
|
|
1553
1554
|
if lv is not None: values.setdefault(current,set()).add(lv)
|
|
1554
|
-
|
|
1555
|
+
elif isinstance(value, ast.Name) and value.id in parameters:
|
|
1556
|
+
for bound in parameters[value.id]:
|
|
1557
|
+
bound_lv=literal(bound)
|
|
1558
|
+
if bound_lv is not None: values.setdefault(current,set()).add(bound_lv)
|
|
1559
|
+
if isinstance(value, ast.Dict): collect_dict(value,current,paths,values,parameters)
|
|
1555
1560
|
elif isinstance(value,(ast.List,ast.Tuple)):
|
|
1556
1561
|
for item in value.elts:
|
|
1557
|
-
if isinstance(item,ast.Dict): collect_dict(item,current,paths,values)
|
|
1562
|
+
if isinstance(item,ast.Dict): collect_dict(item,current,paths,values,parameters)
|
|
1558
1563
|
|
|
1559
|
-
PAYLOAD_NAMES={'payload','body','request_body','request_json','dto','json','data','json_body'}
|
|
1564
|
+
PAYLOAD_NAMES={'payload','body','request_body','request_json','dto','json','data','json_body','params','query','query_params'}
|
|
1560
1565
|
|
|
1561
1566
|
def shape_only(node):
|
|
1562
1567
|
if isinstance(node,ast.Dict):
|
|
@@ -1637,6 +1642,7 @@ def decorator_payload_nodes(parameters,assigned):
|
|
|
1637
1642
|
|
|
1638
1643
|
def decorator_payload_nodes_by_tp(fn,assigned):
|
|
1639
1644
|
result={}
|
|
1645
|
+
function_payload_nodes=payload_nodes(fn)
|
|
1640
1646
|
for dec in fn.decorator_list:
|
|
1641
1647
|
if not isinstance(dec,ast.Call) or not isinstance(dec.func,ast.Attribute) or dec.func.attr != 'parametrize' or len(dec.args) < 2: continue
|
|
1642
1648
|
if not isinstance(dec.args[0],ast.Constant) or not isinstance(dec.args[0].value,str): continue
|
|
@@ -1653,9 +1659,11 @@ def decorator_payload_nodes_by_tp(fn,assigned):
|
|
|
1653
1659
|
parameters={}
|
|
1654
1660
|
for index,name in enumerate(names):
|
|
1655
1661
|
if index < len(node.args): parameters.setdefault(name,[]).append(node.args[index])
|
|
1656
|
-
|
|
1662
|
+
payload_nodes_for_tp=decorator_payload_nodes(parameters,assigned)
|
|
1663
|
+
if not payload_nodes_for_tp:
|
|
1664
|
+
payload_nodes_for_tp=function_payload_nodes
|
|
1657
1665
|
paths=set(); values={}
|
|
1658
|
-
for payload_node in
|
|
1666
|
+
for payload_node in payload_nodes_for_tp: collect_dict(payload_node,'',paths,values,parameters)
|
|
1659
1667
|
result[tp]={key:sorted(items) for key,items in sorted(values.items())}
|
|
1660
1668
|
return result
|
|
1661
1669
|
|
|
@@ -1729,8 +1737,8 @@ for fn in ast.walk(tree):
|
|
|
1729
1737
|
if isinstance(fn,(ast.FunctionDef,ast.AsyncFunctionDef)) and fn.name.startswith('test_'):
|
|
1730
1738
|
paths=set(); values={}
|
|
1731
1739
|
fallback_paths=set(); fallback_values={}
|
|
1732
|
-
for node in payload_nodes(fn): collect_dict(node,'',paths,values)
|
|
1733
|
-
for node in fallback_payload_nodes(fn): collect_dict(node,'',fallback_paths,fallback_values)
|
|
1740
|
+
for node in payload_nodes(fn): collect_dict(node,'',paths,values,{})
|
|
1741
|
+
for node in fallback_payload_nodes(fn): collect_dict(node,'',fallback_paths,fallback_values,{})
|
|
1734
1742
|
out[fn.name]={
|
|
1735
1743
|
'paths':sorted(paths),
|
|
1736
1744
|
'values':{key:sorted(items) for key,items in sorted(values.items())},
|
|
@@ -1782,14 +1790,39 @@ function metadataTestPoints(region, label) {
|
|
|
1782
1790
|
const match = new RegExp(`^\\s*${escaped}\\s*:\\s*(.*)$`, "mi").exec(region);
|
|
1783
1791
|
return orderedUnique(match?.[1]?.match(/\bTP-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/g) ?? []);
|
|
1784
1792
|
}
|
|
1793
|
+
function isPytestDecoratorOrContinuationLine(line) {
|
|
1794
|
+
const trimmed = line.trim();
|
|
1795
|
+
if (!trimmed)
|
|
1796
|
+
return false;
|
|
1797
|
+
if (trimmed.startsWith("#"))
|
|
1798
|
+
return true;
|
|
1799
|
+
if (trimmed.startsWith("@"))
|
|
1800
|
+
return true;
|
|
1801
|
+
// Multi-line decorator argument lists: @pytest.mark.parametrize(\n "x", [\n pytest.param(..., id="TP-...")\n ]\n)
|
|
1802
|
+
return /^[\]\),]/.test(trimmed) || /[\(\[{,]$/.test(trimmed) || /\bpytest\.param\s*\(/.test(trimmed);
|
|
1803
|
+
}
|
|
1785
1804
|
function pytestFunctionRegionStart(source, functionIndex) {
|
|
1786
1805
|
let regionStart = source.lastIndexOf("\n", functionIndex - 1) + 1;
|
|
1787
1806
|
let cursor = regionStart;
|
|
1807
|
+
let blankRun = 0;
|
|
1788
1808
|
while (cursor > 0) {
|
|
1789
1809
|
const previousEnd = cursor - 1;
|
|
1790
1810
|
const previousStart = source.lastIndexOf("\n", previousEnd - 1) + 1;
|
|
1791
1811
|
const previous = source.slice(previousStart, previousEnd).replace(/\r$/, "");
|
|
1792
|
-
|
|
1812
|
+
const trimmed = previous.trim();
|
|
1813
|
+
if (!trimmed) {
|
|
1814
|
+
blankRun += 1;
|
|
1815
|
+
// Keep walking across a small blank gap so a decorator is still bound to
|
|
1816
|
+
// the following test when writers insert one empty line before `def`.
|
|
1817
|
+
if (blankRun > 2)
|
|
1818
|
+
break;
|
|
1819
|
+
cursor = previousStart;
|
|
1820
|
+
continue;
|
|
1821
|
+
}
|
|
1822
|
+
blankRun = 0;
|
|
1823
|
+
if (/^(?:async\s+)?def\s+/.test(trimmed) || /^(?:async\s+)?class\s+/.test(trimmed))
|
|
1824
|
+
break;
|
|
1825
|
+
if (!isPytestDecoratorOrContinuationLine(previous))
|
|
1793
1826
|
break;
|
|
1794
1827
|
regionStart = previousStart;
|
|
1795
1828
|
cursor = previousStart;
|
|
@@ -1819,12 +1852,14 @@ function pytestFunctionBodyEnd(source, signatureEnd, functionIndent) {
|
|
|
1819
1852
|
}
|
|
1820
1853
|
function pytestParameterCollections(source) {
|
|
1821
1854
|
const collections = new Map();
|
|
1822
|
-
for (const match of source.matchAll(/^(
|
|
1855
|
+
for (const match of source.matchAll(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*\[(?![^\r\n]*\])([\s\S]*?)^\]/gm)) {
|
|
1823
1856
|
const explicitIds = [...match[2].matchAll(/\bid\s*=\s*["'](TP-[A-Z0-9-]+)["']/g)].map((item) => item[1]);
|
|
1824
1857
|
const factoryIds = explicitIds.length === 0
|
|
1825
1858
|
? [...match[2].matchAll(/["'](TP-[A-Z0-9-]+)["']/g)].map((item) => item[1])
|
|
1826
1859
|
: [];
|
|
1827
|
-
|
|
1860
|
+
const ids = orderedUnique([...explicitIds, ...factoryIds]);
|
|
1861
|
+
if (ids.length > 0)
|
|
1862
|
+
collections.set(match[1], ids);
|
|
1828
1863
|
}
|
|
1829
1864
|
return collections;
|
|
1830
1865
|
}
|
|
@@ -1886,6 +1921,12 @@ function normalizePythonLiteralValue(value) {
|
|
|
1886
1921
|
if ((trimmed.startsWith("'") && trimmed.endsWith("'")) || (trimmed.startsWith('"') && trimmed.endsWith('"'))) {
|
|
1887
1922
|
return trimmed.slice(1, -1);
|
|
1888
1923
|
}
|
|
1924
|
+
if (trimmed === "True")
|
|
1925
|
+
return "true";
|
|
1926
|
+
if (trimmed === "False")
|
|
1927
|
+
return "false";
|
|
1928
|
+
if (trimmed === "None")
|
|
1929
|
+
return "null";
|
|
1889
1930
|
return trimmed;
|
|
1890
1931
|
}
|
|
1891
1932
|
function intentionalInvalidEnumVariant(input) {
|
|
@@ -1930,13 +1971,15 @@ function assessPayloadContract(testCase, refs) {
|
|
|
1930
1971
|
: [];
|
|
1931
1972
|
const enumMismatches = [];
|
|
1932
1973
|
for (const [field, allowed] of Object.entries(testCase.payloadContract.enumValues)) {
|
|
1933
|
-
const
|
|
1974
|
+
const aggregated = orderedUnique(refs.flatMap((item) => (useFallback ? item.fallbackPayloadValues[field] : item.payloadValues[field]) ?? []));
|
|
1975
|
+
const byTestPoint = orderedUnique(refs.flatMap((item) => Object.values(item.payloadValuesByTestPoint).flatMap((values) => values[field] ?? [])));
|
|
1976
|
+
const observed = orderedUnique([...aggregated, ...byTestPoint]);
|
|
1934
1977
|
for (const rawValue of observed) {
|
|
1935
1978
|
const value = normalizePythonLiteralValue(rawValue);
|
|
1936
1979
|
if (allowed.includes(value))
|
|
1937
1980
|
continue;
|
|
1938
1981
|
const associatedTestPoints = orderedUnique(refs.flatMap((item) => Object.entries(item.payloadValuesByTestPoint)
|
|
1939
|
-
.filter(([, values]) => (values[field] ?? []).
|
|
1982
|
+
.filter(([, values]) => (values[field] ?? []).some((itemValue) => itemValue === rawValue || normalizePythonLiteralValue(itemValue) === value))
|
|
1940
1983
|
.map(([testPoint]) => testPoint)));
|
|
1941
1984
|
const intentionallyInvalid = associatedTestPoints.length > 0 && associatedTestPoints.every((testPoint) => intentionalInvalidEnumVariant({ testCase, testPoint, field, observedValue: rawValue, allowed }));
|
|
1942
1985
|
if (!intentionallyInvalid)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic backend-test plan protocol helpers.
|
|
3
|
+
*
|
|
4
|
+
* English canonical `##` headings remain the parse authority. Known Chinese
|
|
5
|
+
* aliases are rewritten only as an exact whole-line `##` heading, never as
|
|
6
|
+
* prose. Partition slot coverage follows Scheme A: Case count may be smaller
|
|
7
|
+
* than the enum count, but every exact `TP-SP-...` slot must appear as an
|
|
8
|
+
* independent variant Test Point.
|
|
9
|
+
*/
|
|
10
|
+
export const BACKEND_TEST_PLAN_HEADING_ALIASES = {
|
|
11
|
+
"模块索引": "Module Index",
|
|
12
|
+
"覆盖范围": "Coverage Scope",
|
|
13
|
+
"覆盖矩阵": "Coverage Matrix",
|
|
14
|
+
"场景分区": "Scenario Partitions",
|
|
15
|
+
};
|
|
16
|
+
export const BACKEND_TEST_SCENARIO_PARTITION_SLOTS_SCHEMA_ID = "backend-test-scenario-partition-slots-v1";
|
|
17
|
+
const CANONICAL_HEADING_LINE = /^##\s+(Module Index|Coverage Scope|Coverage Matrix|Scenario Partitions)\s*$/;
|
|
18
|
+
const ALIAS_HEADING_LINE = /^##\s+(模块索引|覆盖范围|覆盖矩阵|场景分区)\s*$/;
|
|
19
|
+
const CASE_HEADING = /^##\s+(BE-[A-Z0-9_-]+-\d{2,3})\b/;
|
|
20
|
+
const SECTION_HEADING = /^###\s+/;
|
|
21
|
+
const TEST_POINT_RE = /\bTP-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/g;
|
|
22
|
+
const VARIANT_LABEL = /变体测试点|Variant Test Points/i;
|
|
23
|
+
const FORBIDDEN_SLOT_ALIASES = new Set(["SINGLE", "MULTIPLE"]);
|
|
24
|
+
export function canonicalizeBackendTestPlanProtocolHeadings(markdown) {
|
|
25
|
+
const lines = markdown.replaceAll("\r\n", "\n").replaceAll("\r", "\n").split("\n");
|
|
26
|
+
const rewrittenHeadings = [];
|
|
27
|
+
const next = lines.map((line) => {
|
|
28
|
+
const trimmed = line.trim();
|
|
29
|
+
const alias = ALIAS_HEADING_LINE.exec(trimmed);
|
|
30
|
+
if (!alias)
|
|
31
|
+
return line;
|
|
32
|
+
const canonical = BACKEND_TEST_PLAN_HEADING_ALIASES[alias[1]];
|
|
33
|
+
rewrittenHeadings.push(canonical);
|
|
34
|
+
return `## ${canonical}`;
|
|
35
|
+
});
|
|
36
|
+
return {
|
|
37
|
+
markdown: next.join("\n"),
|
|
38
|
+
changed: rewrittenHeadings.length > 0,
|
|
39
|
+
rewrittenHeadings,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export function countCanonicalPlanHeading(markdown, heading) {
|
|
43
|
+
const canonical = canonicalizeBackendTestPlanProtocolHeadings(markdown).markdown;
|
|
44
|
+
return canonical
|
|
45
|
+
.split("\n")
|
|
46
|
+
.filter((line) => line.trim() === `## ${heading}`).length;
|
|
47
|
+
}
|
|
48
|
+
function orderedUnique(values) {
|
|
49
|
+
return [...new Set(values.filter(Boolean))];
|
|
50
|
+
}
|
|
51
|
+
function collectSectionTokens(body, headingNeedle) {
|
|
52
|
+
const lines = body.split("\n");
|
|
53
|
+
const start = lines.findIndex((line) => line.trim() === `### ${headingNeedle}`);
|
|
54
|
+
if (start < 0)
|
|
55
|
+
return [];
|
|
56
|
+
const tokens = [];
|
|
57
|
+
for (const line of lines.slice(start + 1)) {
|
|
58
|
+
if (SECTION_HEADING.test(line) || CASE_HEADING.test(line.trim()))
|
|
59
|
+
break;
|
|
60
|
+
tokens.push(...(line.match(TEST_POINT_RE) ?? []));
|
|
61
|
+
}
|
|
62
|
+
return tokens;
|
|
63
|
+
}
|
|
64
|
+
function collectLabeledTokens(body, label) {
|
|
65
|
+
return body
|
|
66
|
+
.split("\n")
|
|
67
|
+
.filter((line) => label.test(line))
|
|
68
|
+
.flatMap((line) => line.match(TEST_POINT_RE) ?? []);
|
|
69
|
+
}
|
|
70
|
+
export function collectMarkdownPartitionSlotCoverage(markdown, requiredVariantSlots) {
|
|
71
|
+
const declaredTestPoints = [];
|
|
72
|
+
const variantTestPoints = [];
|
|
73
|
+
const lines = markdown.replaceAll("\r\n", "\n").replaceAll("\r", "\n").split("\n");
|
|
74
|
+
const headingIndexes = lines.flatMap((line, index) => CASE_HEADING.test(line.trim()) ? [index] : []);
|
|
75
|
+
for (let i = 0; i < headingIndexes.length; i += 1) {
|
|
76
|
+
const start = headingIndexes[i];
|
|
77
|
+
const end = headingIndexes[i + 1] ?? lines.length;
|
|
78
|
+
const body = lines.slice(start, end).join("\n");
|
|
79
|
+
declaredTestPoints.push(...collectSectionTokens(body, "测试点"));
|
|
80
|
+
variantTestPoints.push(...collectLabeledTokens(body, VARIANT_LABEL));
|
|
81
|
+
}
|
|
82
|
+
const declaredSet = new Set(orderedUnique(declaredTestPoints));
|
|
83
|
+
const variantSet = new Set(orderedUnique(variantTestPoints));
|
|
84
|
+
const required = requiredVariantSlots.filter((slotId) => !FORBIDDEN_SLOT_ALIASES.has(slotId));
|
|
85
|
+
return {
|
|
86
|
+
declaredTestPoints: orderedUnique(declaredTestPoints),
|
|
87
|
+
variantTestPoints: orderedUnique(variantTestPoints),
|
|
88
|
+
missingFromDeclared: required.filter((slotId) => !declaredSet.has(slotId)),
|
|
89
|
+
missingFromVariant: required.filter((slotId) => !variantSet.has(slotId)),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
export function isForbiddenPartitionSlotAlias(token) {
|
|
93
|
+
return FORBIDDEN_SLOT_ALIASES.has(token.trim().toUpperCase());
|
|
94
|
+
}
|
|
95
|
+
export function lookupRequiredVariantSlotsForMarkdownPath(inventory, markdownPath) {
|
|
96
|
+
if (!inventory)
|
|
97
|
+
return [];
|
|
98
|
+
const stem = markdownPath
|
|
99
|
+
.replace(/\\/g, "/")
|
|
100
|
+
.replace(/^.*\//, "")
|
|
101
|
+
.replace(/\.md$/i, "");
|
|
102
|
+
return inventory.modules.find((item) => item.stem === stem)?.requiredVariantSlots ?? [];
|
|
103
|
+
}
|
|
104
|
+
export function isCanonicalPlanHeadingLine(line) {
|
|
105
|
+
return CANONICAL_HEADING_LINE.test(line.trim());
|
|
106
|
+
}
|
|
@@ -1116,7 +1116,11 @@ function compareIntent(intent, observed, bound, example, field) {
|
|
|
1116
1116
|
? "UNDETERMINED"
|
|
1117
1117
|
: "MISMATCH";
|
|
1118
1118
|
}
|
|
1119
|
-
|
|
1119
|
+
const comparableObserved = observed.kind === "boolean"
|
|
1120
|
+
? (observed.literal === "True" ? "true" : observed.literal === "False" ? "false" : literal)
|
|
1121
|
+
: literal;
|
|
1122
|
+
const comparableExpected = expected === "True" ? "true" : expected === "False" ? "false" : expected;
|
|
1123
|
+
return comparableObserved === comparableExpected
|
|
1120
1124
|
? "MATCH"
|
|
1121
1125
|
: observed.kind === "unknown"
|
|
1122
1126
|
? "UNDETERMINED"
|
|
@@ -1207,6 +1211,12 @@ function boundedScenarioText(value, logicalLength) {
|
|
|
1207
1211
|
const suffix = `<truncated length=${logicalLength ?? value.length}>`;
|
|
1208
1212
|
return `${value.slice(0, Math.max(0, MAX_PERSISTED_SCENARIO_TEXT - suffix.length - 3))}...${suffix}`;
|
|
1209
1213
|
}
|
|
1214
|
+
/** Facts schema forbids negative lengths. Internal helper sentinels stay in-memory only. */
|
|
1215
|
+
function persistedObservedLength(length) {
|
|
1216
|
+
return typeof length === "number" && Number.isFinite(length) && length >= 0
|
|
1217
|
+
? Math.trunc(length)
|
|
1218
|
+
: undefined;
|
|
1219
|
+
}
|
|
1210
1220
|
function localPythonFunctionRegion(source, functionName) {
|
|
1211
1221
|
const escaped = functionName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1212
1222
|
const startMatch = new RegExp(`^def\\s+${escaped}\\s*\\(`, "m").exec(source);
|
|
@@ -1449,11 +1459,12 @@ function scenarioParamDiagnosticValues(intent, observed) {
|
|
|
1449
1459
|
const expectedRaw = intent.startsWith("custom-literal:")
|
|
1450
1460
|
? intent.slice("custom-literal:".length)
|
|
1451
1461
|
: intent === "unknown" ? undefined : intent;
|
|
1462
|
+
const persistedLength = persistedObservedLength(observed.length);
|
|
1452
1463
|
return {
|
|
1453
1464
|
expectedRaw,
|
|
1454
|
-
observedRaw: boundedScenarioText(observed.text,
|
|
1465
|
+
observedRaw: boundedScenarioText(observed.text, persistedLength),
|
|
1455
1466
|
expectedNormalized: expectedRaw === undefined ? undefined : normalizeCustomLiteralExpected(expectedRaw).slice(0, 512),
|
|
1456
|
-
observedNormalized: observed.literal === undefined ? undefined : boundedScenarioText(normalizeScenarioLiteralText(observed.literal),
|
|
1467
|
+
observedNormalized: observed.literal === undefined ? undefined : boundedScenarioText(normalizeScenarioLiteralText(observed.literal), persistedLength),
|
|
1457
1468
|
};
|
|
1458
1469
|
}
|
|
1459
1470
|
export async function assessBackendScenarioParamConsistency(input) {
|
|
@@ -1539,8 +1550,8 @@ export async function assessBackendScenarioParamConsistency(input) {
|
|
|
1539
1550
|
tpId,
|
|
1540
1551
|
field: inferred.field,
|
|
1541
1552
|
intent: inferred.intent,
|
|
1542
|
-
observed: boundedScenarioText(observed.text, observed.length),
|
|
1543
|
-
observedLength: observed.length,
|
|
1553
|
+
observed: boundedScenarioText(observed.text, persistedObservedLength(observed.length)),
|
|
1554
|
+
observedLength: persistedObservedLength(observed.length),
|
|
1544
1555
|
status,
|
|
1545
1556
|
repairability,
|
|
1546
1557
|
reasonCode: status === "MATCH" ? "SCENARIO_PARAM_MATCH" : status === "MISMATCH" ? "SCENARIO_PARAM_MISMATCH" : "SCENARIO_PARAM_UNDETERMINED",
|
|
@@ -5,6 +5,7 @@ import { z } from "zod";
|
|
|
5
5
|
import { BACKEND_TEST_MAX_MODULE_COUNT, isOpaqueHashBackendTestModuleStem, isPriorityOnlyBackendTestModuleStem, } from "./backend-test-module-stem.js";
|
|
6
6
|
import { resolveBackendTestLayout, } from "./backend-test-layout.js";
|
|
7
7
|
import { canonicalizeBackendTestMarkdownTestPointBindings, inspectBackendTestMarkdownTestPointBindings, } from "./backend-test-case-coverage-analysis.js";
|
|
8
|
+
import { BACKEND_TEST_SCENARIO_PARTITION_SLOTS_SCHEMA_ID, canonicalizeBackendTestPlanProtocolHeadings, collectMarkdownPartitionSlotCoverage, lookupRequiredVariantSlotsForMarkdownPath, } from "./backend-test-plan-protocol.js";
|
|
8
9
|
/**
|
|
9
10
|
* Maps a backend-test generation writer task id to its writer-progress role.
|
|
10
11
|
* Returns undefined for nodes that are not completeness-gated generators.
|
|
@@ -127,10 +128,8 @@ function looksLikeValidModuleStem(raw) {
|
|
|
127
128
|
return moduleStemInvalidReason(raw) === undefined;
|
|
128
129
|
}
|
|
129
130
|
function extractCanonicalModuleIndexSection(readme) {
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
.replaceAll("\r", "\n")
|
|
133
|
-
.split("\n");
|
|
131
|
+
const canonical = canonicalizeBackendTestPlanProtocolHeadings(readme).markdown;
|
|
132
|
+
const lines = canonical.split("\n");
|
|
134
133
|
const headings = lines.flatMap((line, index) => line.trim() === "## Module Index" ? [index] : []);
|
|
135
134
|
if (headings.length === 0) {
|
|
136
135
|
return { section: "", structureIssues: ["missing-module-index"] };
|
|
@@ -519,6 +518,19 @@ export async function loadBackendTestWriterProgressForRetry(runDir, taskId) {
|
|
|
519
518
|
* child's own target file(s): md children are checked for structural
|
|
520
519
|
* completeness, pytest children for parseability. Sibling modules are ignored.
|
|
521
520
|
*/
|
|
521
|
+
async function loadScenarioPartitionSlotInventory(runDir) {
|
|
522
|
+
if (!runDir)
|
|
523
|
+
return undefined;
|
|
524
|
+
try {
|
|
525
|
+
const raw = JSON.parse(await readFile(path.join(runDir, "contracts", "backend-test-scenario-partition-slots.json"), "utf8"));
|
|
526
|
+
if (raw?.schemaId !== BACKEND_TEST_SCENARIO_PARTITION_SLOTS_SCHEMA_ID)
|
|
527
|
+
return undefined;
|
|
528
|
+
return raw;
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
531
|
+
return undefined;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
522
534
|
export async function assessBackendTestShardChildCompleteness(input) {
|
|
523
535
|
const role = input.role;
|
|
524
536
|
const targetPaths = input.writeSet
|
|
@@ -531,6 +543,9 @@ export async function assessBackendTestShardChildCompleteness(input) {
|
|
|
531
543
|
let canonicalizationOutcome = role === "md-generate" ? "already-canonical" : undefined;
|
|
532
544
|
let canonicalizedTestPointCount = 0;
|
|
533
545
|
let lightweightRepairEligible = false;
|
|
546
|
+
const slotInventory = role === "md-generate"
|
|
547
|
+
? await loadScenarioPartitionSlotInventory(input.runDir)
|
|
548
|
+
: undefined;
|
|
534
549
|
for (const target of targetPaths) {
|
|
535
550
|
// Rendered map-child writeSet entries are concrete (e.g.
|
|
536
551
|
// testcase/md/health.md / testcase/test_orders.py); glob-only entries are
|
|
@@ -565,6 +580,27 @@ export async function assessBackendTestShardChildCompleteness(input) {
|
|
|
565
580
|
.filter((entry) => entry.unclassifiedTestPoints.length > 0 || entry.duplicateBindingTestPoints.length > 0)
|
|
566
581
|
.map((entry) => entry.caseId);
|
|
567
582
|
const structural = moduleStructurallyComplete(body);
|
|
583
|
+
const requiredSlots = lookupRequiredVariantSlotsForMarkdownPath(slotInventory, target);
|
|
584
|
+
const slotCoverage = requiredSlots.length > 0
|
|
585
|
+
? collectMarkdownPartitionSlotCoverage(body, requiredSlots)
|
|
586
|
+
: undefined;
|
|
587
|
+
const missingSlots = orderedUnique([
|
|
588
|
+
...(slotCoverage?.missingFromDeclared ?? []),
|
|
589
|
+
...(slotCoverage?.missingFromVariant ?? []),
|
|
590
|
+
]);
|
|
591
|
+
if (missingSlots.length > 0) {
|
|
592
|
+
lightweightRepairEligible = true;
|
|
593
|
+
if (!brokenPaths.includes(target))
|
|
594
|
+
brokenPaths.push(target);
|
|
595
|
+
issues.push({
|
|
596
|
+
code: "T5",
|
|
597
|
+
path: target,
|
|
598
|
+
detail: `incomplete-partition-slot-set: missing ${missingSlots.join(", ")}; Scheme A requires each exact slot as an independent variant Test Point, not Case count or SINGLE/MULTIPLE aliases`,
|
|
599
|
+
recoverable: true,
|
|
600
|
+
affectedCases: afterBindings.map((entry) => entry.caseId),
|
|
601
|
+
testPointIds: missingSlots,
|
|
602
|
+
});
|
|
603
|
+
}
|
|
568
604
|
if (!structural.ok) {
|
|
569
605
|
lightweightRepairEligible = bindingCases.length > 0;
|
|
570
606
|
if (bindingCases.length > 0)
|
|
@@ -651,7 +687,7 @@ export async function assessBackendTestMdWriterCompleteness(workspaceRoot, layou
|
|
|
651
687
|
}
|
|
652
688
|
else {
|
|
653
689
|
actualPaths.push(readmeRel);
|
|
654
|
-
readme = await readFile(readmePath, "utf8");
|
|
690
|
+
readme = canonicalizeBackendTestPlanProtocolHeadings(await readFile(readmePath, "utf8")).markdown;
|
|
655
691
|
if (!readme.includes("## Coverage Scope")) {
|
|
656
692
|
brokenPaths.push(readmeRel);
|
|
657
693
|
issues.push({
|
|
@@ -985,7 +1021,7 @@ export async function assessBackendTestMdPlanCompleteness(workspaceRoot, layout)
|
|
|
985
1021
|
}
|
|
986
1022
|
else {
|
|
987
1023
|
actualPaths.push(planReadmeRel);
|
|
988
|
-
const readme = await readFile(readmePath, "utf8");
|
|
1024
|
+
const readme = canonicalizeBackendTestPlanProtocolHeadings(await readFile(readmePath, "utf8")).markdown;
|
|
989
1025
|
if (!readme.includes("## Coverage Scope")) {
|
|
990
1026
|
brokenPaths.push(planReadmeRel);
|
|
991
1027
|
issues.push({
|
|
@@ -659,10 +659,13 @@ function latestCommittedScoutTargetSurface(records) {
|
|
|
659
659
|
}
|
|
660
660
|
/**
|
|
661
661
|
* Scout owns discovery. A complete fact must explicitly say so, name at least
|
|
662
|
-
* one target candidate
|
|
663
|
-
* evidence for a named target
|
|
664
|
-
*
|
|
665
|
-
*
|
|
662
|
+
* one target candidate and leave no unresolved paths. Existing surfaces must
|
|
663
|
+
* include fresh runtime evidence for a named target path. Greenfield
|
|
664
|
+
* surfaces are the one explicit exception: when runtime-injected source
|
|
665
|
+
* declarations prove that every named path was specified by the task source,
|
|
666
|
+
* all paths may be future (not yet on disk). This keeps create-new work
|
|
667
|
+
* possible without allowing Scout to invent an arbitrary path under the
|
|
668
|
+
* writer boundary. An empty test path list remains valid.
|
|
666
669
|
*/
|
|
667
670
|
export function readCompleteScoutTargetSurface(records) {
|
|
668
671
|
const fact = latestCommittedScoutTargetSurface(records);
|
|
@@ -704,11 +707,35 @@ export function readCompleteScoutTargetSurface(records) {
|
|
|
704
707
|
.map((item) => asString(item.path))
|
|
705
708
|
.filter(Boolean)
|
|
706
709
|
: []);
|
|
707
|
-
|
|
710
|
+
const pathEvidence = Array.isArray(fact.pathEvidence)
|
|
711
|
+
? fact.pathEvidence.filter(isRecord)
|
|
712
|
+
: [];
|
|
713
|
+
const hasSourceDeclarationLedger = Array.isArray(fact.sourceDeclaredPaths);
|
|
714
|
+
const undeclaredFuturePaths = hasSourceDeclarationLedger
|
|
715
|
+
? namedPaths.filter((candidate) => {
|
|
716
|
+
const evidence = pathEvidence.find((item) => asString(item.path) === candidate);
|
|
717
|
+
return evidence?.fresh !== true && evidence?.sourceDeclared !== true;
|
|
718
|
+
})
|
|
719
|
+
: [];
|
|
720
|
+
if (undeclaredFuturePaths.length > 0)
|
|
708
721
|
return {
|
|
709
722
|
ok: false,
|
|
710
|
-
reason:
|
|
723
|
+
reason: `frontend scout target surface has future paths not declared by the task source: ${undeclaredFuturePaths.join(", ")}; test path is not declared by the task source, so do not guess test/ vs tests/ or other target directories`,
|
|
711
724
|
};
|
|
725
|
+
if (!namedPaths.some((candidate) => freshPaths.has(candidate))) {
|
|
726
|
+
const allPathsAreSourceDeclaredFuturePaths = hasSourceDeclarationLedger &&
|
|
727
|
+
namedPaths.length > 0 &&
|
|
728
|
+
namedPaths.every((candidate) => {
|
|
729
|
+
const evidence = pathEvidence.find((item) => asString(item.path) === candidate);
|
|
730
|
+
return evidence?.fresh !== true && evidence?.sourceDeclared === true;
|
|
731
|
+
});
|
|
732
|
+
if (allPathsAreSourceDeclaredFuturePaths)
|
|
733
|
+
return { ok: true, surface };
|
|
734
|
+
return {
|
|
735
|
+
ok: false,
|
|
736
|
+
reason: "frontend scout target surface lacks fresh runtime evidence for a named target path; greenfield exceptions require runtime-injected source declarations for every future path",
|
|
737
|
+
};
|
|
738
|
+
}
|
|
712
739
|
return { ok: true, surface };
|
|
713
740
|
}
|
|
714
741
|
/** Paths with Scout ownership that Plan may read for a specific decision. */
|