@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.
Files changed (108) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +22 -0
  3. package/dist/build-stamp.json +3 -3
  4. package/dist/executors/dag-pi-executor.js +75 -13
  5. package/dist/task/source-prepare/fragment-inventory.js +49 -16
  6. package/dist/task/source-prepare/prepare.js +37 -20
  7. package/dist/worker/console/chat/model-resolver.js +7 -4
  8. package/dist/worker/console/chat/pi-runtime.js +40 -1
  9. package/dist/worker/console/chat/routes.js +25 -24
  10. package/dist/worker/console/chat/sdd-data-alignment.js +6 -3
  11. package/dist/worker/console/pi-readiness.js +3 -2
  12. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-BJpsGgAv.js → abnfDiagram-N423BO3Z-BQBHX31n.js} +1 -1
  13. package/dist/worker/console/static/assets/{arc-DjMFPsEd.js → arc-MSJ199eR.js} +1 -1
  14. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-DqdZlr1O.js → architectureDiagram-T3A2C74G-DiRq2qMX.js} +1 -1
  15. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-BmZnfFEO.js → blockDiagram-VBNYF7ZC-rlaxFOvi.js} +1 -1
  16. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-3cKberpL.js → c4Diagram-5PPSVZJV-BSpi9ump.js} +1 -1
  17. package/dist/worker/console/static/assets/channel-BdTaMnYP.js +1 -0
  18. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-q-0yjQ1h.js → chunk-2GRJ4B5K-BN8VNeh5.js} +1 -1
  19. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-DnONBaDW.js → chunk-2Q5K7J3B-CDa1OVRA.js} +1 -1
  20. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-v3a6PCOS.js → chunk-5RXB4S5H-zxMQ5b8E.js} +1 -1
  21. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-D1kLrlU_.js → chunk-5VM5RSS4-XTqNlzH4.js} +1 -1
  22. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-BPelzihi.js → chunk-6Q2QTUOP-DpZQfFTq.js} +1 -1
  23. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-DauNE69H.js → chunk-GF5L2VYU-DPv743zu.js} +1 -1
  24. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-RIg5_bFJ.js → chunk-JWPE2WC7-CFsCFoF4.js} +1 -1
  25. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-BuILuoxs.js → chunk-KBJHAD2P-CV__aJTt.js} +1 -1
  26. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-VHORV5oW.js → chunk-RYQCIY6F-BshKlNir.js} +1 -1
  27. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-BJYr4xHC.js → chunk-XXDRQBXY-B-A3ErBP.js} +1 -1
  28. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-Cid7CSRK.js +1 -0
  29. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-Cid7CSRK.js +1 -0
  30. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-BS6nuqQn.js → cose-bilkent-JH36ORCC-25xj8928.js} +1 -1
  31. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-BBhF0NAT.js → cynefin-VYW2F7L2-DFmv38cl.js} +1 -1
  32. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-D1135Ifg.js → cynefinDiagram-MW4NZA55-hReuWDQP.js} +1 -1
  33. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-vAOYPUWJ.js → dagre-VZM6K2ZE-C9X3YPUI.js} +1 -1
  34. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-DuaSPkfa.js → diagram-7IWD3JNH-CAVmqjg9.js} +1 -1
  35. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-mMvXvGU6.js → diagram-B4RE2ZJO-DYNlLsIm.js} +1 -1
  36. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-CDIEJtlL.js → diagram-LBJQPF4R-BP5mlbfb.js} +1 -1
  37. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-Bi_6JvCW.js → diagram-Q27KOJAE-KWOHK5ZM.js} +1 -1
  38. package/dist/worker/console/static/assets/{diagram-UB23O5K3-DAqj57yN.js → diagram-UB23O5K3-D25Ro5y-.js} +1 -1
  39. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-DfWaxNEo.js → ebnfDiagram-BXEA7PRR-kLaahtbh.js} +1 -1
  40. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-Bt7QqkNG.js → erDiagram-JOGREHBK-1tgKuLez.js} +1 -1
  41. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-CRRhQwT2.js → flowDiagram-UKHOOZJN-DARr-Cvn.js} +1 -1
  42. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-DAV1O5B6.js → ganttDiagram-PKOTCBZU-BgwjBv_V.js} +1 -1
  43. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-0X2Qo4Lp.js → gitGraphDiagram-DS77QQ5N-DneIlYSI.js} +1 -1
  44. package/dist/worker/console/static/assets/{index-CVdddQsA.js → index-Dkv0ezuE.js} +75 -77
  45. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-C5W5eILC.js → infoDiagram-6WML65LV-DA7ATqY6.js} +1 -1
  46. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-5SfqU-Tb.js → ishikawaDiagram-WSZJBQD7-DnOS-E1Z.js} +1 -1
  47. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-CKDRcaKf.js → journeyDiagram-NVQOT4AX-C8JnwJ_s.js} +1 -1
  48. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-Cc7_TuSb.js → kanban-definition-27J2QSJJ-Cw0_OjEC.js} +1 -1
  49. package/dist/worker/console/static/assets/{linear-BcQYYmeT.js → linear-mrxq3fyn.js} +1 -1
  50. package/dist/worker/console/static/assets/{mermaid.core-CwLZPtQo.js → mermaid.core-Bagus6zF.js} +5 -5
  51. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-D29M025z.js → mindmap-definition-FAOFIHXS-Bf95bLuW.js} +1 -1
  52. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-CV-ZAWYe.js → pegDiagram-VL7TDLO6-BvtUW6w2.js} +1 -1
  53. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-C6iPS_o5.js → pieDiagram-7S7Q4E2Y-CbWtT-Ry.js} +1 -1
  54. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-BFCD1sTg.js → quadrantDiagram-CIZ2JOQS-ctnxxIdD.js} +1 -1
  55. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-CztNj4O9.js → railroadDiagram-AXF67PYL-BZ7Z3X0-.js} +1 -1
  56. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-BWIELSNd.js → requirementDiagram-LRYGKXZP-BMX1xnIy.js} +1 -1
  57. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-CPtxrjJQ.js → sankeyDiagram-W5VNT64P-MShOQznz.js} +1 -1
  58. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-BEWVSFsu.js → sequenceDiagram-SI44F4Z6-CTGOazaB.js} +1 -1
  59. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-DyhzoX_J.js → sizeCapture-X5ZJPWSS-zGbfM4nV.js} +1 -1
  60. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-xLox0nu7.js → stateDiagram-OKZ733FA-Brf8Wwzr.js} +1 -1
  61. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-CIkoculx.js +1 -0
  62. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-CLC_ow9A.js → swimlanes-SLNWSIFB-CvNw3ZqF.js} +2 -2
  63. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-pJ-V5EdT.js +8 -0
  64. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-DmJZ4uyY.js → timeline-definition-Z64GVDOM-z2LyZpDt.js} +1 -1
  65. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-DD7GGi9Z.js → vennDiagram-T6HMQDX7-Bc_coprH.js} +1 -1
  66. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-Bi8PQoGt.js → wardleyDiagram-T6FBY63Y-M1NSmWq-.js} +1 -1
  67. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-uKdAY8Dp.js → xychartDiagram-ELKLHX3M-Ct0rjGXW.js} +1 -1
  68. package/dist/worker/console/static/index.html +1 -1
  69. package/dist/worker/console/static-src/app/useRecoveryConsole.js +5 -5
  70. package/dist/worker/console/static-src/operator-chat/input-history.js +8 -6
  71. package/dist/worker/console/static-src/shell/console-update-reload.js +36 -9
  72. package/dist/worker/console/static-src/shell/workspace-route.js +11 -0
  73. package/dist/worker/observe/static/dag-helpers.js +4 -2
  74. package/dist/worker/observe/static/inspect-workspace.js +23 -0
  75. package/dist/worker/observe/static/kpi.js +2 -2
  76. package/dist/worker/observe/static/operator-chrome.d.ts +10 -2
  77. package/dist/worker/observe/static/operator-chrome.js +37 -23
  78. package/dist/worker/observe/static/relations.js +2 -2
  79. package/dist/worker/observe/static/router.d.ts +12 -1
  80. package/dist/worker/observe/static/router.js +48 -6
  81. package/dist/worker/observe/static/run-processing.js +4 -2
  82. package/dist/worker/observe/static/shell-chrome.js +2 -2
  83. package/dist/worker/observe/static/state.js +5 -0
  84. package/dist/worker/observe/static/styles.css +95 -0
  85. package/dist/worker/observe/static/views/dag-inspector.js +14 -7
  86. package/dist/worker/observe/static/views/dag.js +10 -2
  87. package/dist/worker/observe/static/views/dags.js +3 -1
  88. package/dist/worker/observe/static/views/dashboard.js +14 -6
  89. package/dist/worker/observe/static/views/pool.js +7 -2
  90. package/dist/worker/observe/static/views/run.js +12 -3
  91. package/dist/worker/observe/static/views/session-timeline.js +69 -10
  92. package/dist/worker/observe/static/views/task.js +7 -2
  93. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +58 -15
  94. package/dist/workflows/dag/backend-test-plan-protocol.js +106 -0
  95. package/dist/workflows/dag/backend-test-scenario-param.js +16 -5
  96. package/dist/workflows/dag/backend-test-writer-completeness.js +42 -6
  97. package/dist/workflows/dag/frontend-shadow-dual-write.js +33 -6
  98. package/dist/workflows/dag/init-hybrid.js +193 -32
  99. package/dist/workflows/dag/node-execution.js +1 -1
  100. package/dist/workflows/dag/prompt.js +36 -2
  101. package/dist/workflows/dag/types.js +1 -1
  102. package/docs/templates/backend-test-dag.json +5 -5
  103. package/package.json +3 -2
  104. package/dist/worker/console/static/assets/channel-DWYwciiv.js +0 -1
  105. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-D6jOf_l4.js +0 -1
  106. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-D6jOf_l4.js +0 -1
  107. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DS5ZRvJG.js +0 -1
  108. 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
- followLatest,
1100
+ isTimelineAutoScrollEnabled(),
1045
1101
  );
1046
1102
  },
1047
1103
  { passive: true },
1048
1104
  );
1049
- if (timelineState?.followLatest ?? true) {
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
- content.scrollTop = scrollTop;
1070
- content.scrollLeft = scrollLeft;
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 { parseRoute, navigate, buildHashPath } from "../router.js";
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 = `#${path}`;
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
- if isinstance(value, ast.Dict): collect_dict(value,current,paths,values)
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
- payload_nodes=decorator_payload_nodes(parameters,assigned)
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 payload_nodes: collect_dict(payload_node,'',paths,values)
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
- if (!previous.trim())
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(/^(_?[A-Z][A-Z0-9_]*)\s*=\s*\[(?![^\r\n]*\])([\s\S]*?)^\]/gm)) {
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
- collections.set(match[1], orderedUnique([...explicitIds, ...factoryIds]));
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 observed = orderedUnique(refs.flatMap((item) => (useFallback ? item.fallbackPayloadValues[field] : item.payloadValues[field]) ?? []));
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] ?? []).includes(rawValue))
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
- return literal === expected
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, observed.length),
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), observed.length),
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 lines = readme
131
- .replaceAll("\r\n", "\n")
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, leave no unresolved paths, and include fresh runtime
663
- * evidence for a named target file. This deliberately accepts an empty test
664
- * path list: some small changes have no existing focused test file, but they
665
- * still need a proven implementation entrypoint.
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
- if (!namedPaths.some((candidate) => freshPaths.has(candidate)))
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: "frontend scout target surface lacks fresh runtime evidence for a named target path; record_target_surface enriches every declared entrypoint/implementation/test path against the real workspace, so at least one named path must exist on disk (an existing directory counts; a file to be created later cannot count)",
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. */