@tea-agent/loop-agent 0.38.1 → 0.38.2

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 (127) hide show
  1. package/CHANGELOG.md +109 -13
  2. package/dist/application/dag/generate-task-dag.js +9 -0
  3. package/dist/build-stamp.json +3 -3
  4. package/dist/commands/init.js +2 -14
  5. package/dist/executors/dag-pi-executor.js +41 -4
  6. package/dist/executors/shell-executor.js +269 -15
  7. package/dist/governance/manifest-types.js +0 -2
  8. package/dist/shared/operator/capabilities.js +87 -22
  9. package/dist/task/config-types.js +22 -0
  10. package/dist/worker/console/chat/deferred-turn.js +91 -0
  11. package/dist/worker/console/chat/pi-runtime.js +10 -5
  12. package/dist/worker/console/chat/routes.js +458 -47
  13. package/dist/worker/console/chat/semantic-activity.js +13 -13
  14. package/dist/worker/console/chat/session-store.js +146 -2
  15. package/dist/worker/console/harness-pi-model-matrix.js +2 -11
  16. package/dist/worker/console/operator-actions.js +5 -4
  17. package/dist/worker/console/pi-readiness.js +1 -0
  18. package/dist/worker/console/recovery-cta.js +4 -4
  19. package/dist/worker/console/recovery-error-copy.js +4 -4
  20. package/dist/worker/console/server.js +6 -1
  21. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-Hfsot6rP.js → abnfDiagram-N423BO3Z-C4bmo3iY.js} +1 -1
  22. package/dist/worker/console/static/assets/{arc-YGwEASin.js → arc-SAD7McOS.js} +1 -1
  23. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-CmOM0F92.js → architectureDiagram-T3A2C74G-DAVLUdK1.js} +1 -1
  24. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-DDLuaHC5.js → blockDiagram-VBNYF7ZC-CPPdv4vj.js} +1 -1
  25. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-B0Um43W4.js → c4Diagram-5PPSVZJV-D1GRGZax.js} +1 -1
  26. package/dist/worker/console/static/assets/channel-R6oQIDkw.js +1 -0
  27. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-DvBN4JZc.js → chunk-2GRJ4B5K-D_Bq2ZqK.js} +1 -1
  28. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-B1UVgW4E.js → chunk-2Q5K7J3B-CPLVtamK.js} +1 -1
  29. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-Cf0GgXzX.js → chunk-5RXB4S5H-DnaZWcL3.js} +1 -1
  30. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-DbNA24sj.js → chunk-5VM5RSS4-BRTmFgnq.js} +1 -1
  31. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-Bi_0Be06.js → chunk-6Q2QTUOP-BRlfYZoH.js} +1 -1
  32. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-C8pPGvRd.js → chunk-GF5L2VYU-CjNybD4u.js} +1 -1
  33. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-BitQY8Z-.js → chunk-JWPE2WC7-D3e-74fG.js} +1 -1
  34. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-U67GCiEo.js → chunk-KBJHAD2P-Br3ir6f2.js} +1 -1
  35. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-IoJwXPBA.js → chunk-RYQCIY6F-1NgVuRbh.js} +1 -1
  36. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-YDZ_UHgE.js → chunk-XXDRQBXY-BTYuvuWN.js} +1 -1
  37. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BPXlmPWI.js +1 -0
  38. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BPXlmPWI.js +1 -0
  39. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-CUZgsiRC.js → cose-bilkent-JH36ORCC-BC6Bgosf.js} +1 -1
  40. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-COmCh4pT.js → cynefin-VYW2F7L2-DV-HypfL.js} +1 -1
  41. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-Ta6FSm68.js → cynefinDiagram-MW4NZA55-DhTaddO-.js} +1 -1
  42. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-UdcR-VW9.js → dagre-VZM6K2ZE-BnEcOmiI.js} +1 -1
  43. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-DybcO3Et.js → diagram-7IWD3JNH-xphGRVKp.js} +1 -1
  44. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-wErDFWLw.js → diagram-B4RE2ZJO-CrdD1mRG.js} +1 -1
  45. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-DkVHZWqz.js → diagram-LBJQPF4R-D4YdLsaF.js} +1 -1
  46. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-DdqWs7QN.js → diagram-Q27KOJAE-LCKyGZ5U.js} +1 -1
  47. package/dist/worker/console/static/assets/{diagram-UB23O5K3-C9dMVmDo.js → diagram-UB23O5K3-BeiTe7t0.js} +1 -1
  48. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-B2T2WcBK.js → ebnfDiagram-BXEA7PRR-BO2cq8GS.js} +1 -1
  49. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-Bt9rjDFq.js → erDiagram-JOGREHBK-CmWww3Nt.js} +1 -1
  50. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-U8ic_tvi.js → flowDiagram-UKHOOZJN-C2kkUYg5.js} +1 -1
  51. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-BfQJdQoO.js → ganttDiagram-PKOTCBZU-DY_JZ2iQ.js} +1 -1
  52. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-CpMftH4O.js → gitGraphDiagram-DS77QQ5N-BizhCSw9.js} +1 -1
  53. package/dist/worker/console/static/assets/index-BXN89VPX.js +325 -0
  54. package/dist/worker/console/static/assets/index-CuH5CNFu.css +1 -0
  55. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-DzqWXNO_.js → infoDiagram-6WML65LV-Dlo2eK_d.js} +1 -1
  56. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-CJmGQ5Yp.js → ishikawaDiagram-WSZJBQD7-3EIlIIuZ.js} +1 -1
  57. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-ChPtKFLz.js → journeyDiagram-NVQOT4AX-DiMDe5Yh.js} +1 -1
  58. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-PRe7wB5u.js → kanban-definition-27J2QSJJ-sN553oIN.js} +1 -1
  59. package/dist/worker/console/static/assets/{linear-DpKX6QNp.js → linear-D9Py31Ld.js} +1 -1
  60. package/dist/worker/console/static/assets/{mermaid.core-Dnl76b7Y.js → mermaid.core-D7Occzk_.js} +5 -5
  61. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-S9PCXc3J.js → mindmap-definition-FAOFIHXS-DjDRboiR.js} +1 -1
  62. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-Du8e-FSX.js → pegDiagram-VL7TDLO6-Cgc4qlzp.js} +1 -1
  63. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-CY6OmHTc.js → pieDiagram-7S7Q4E2Y-DxZj8pKd.js} +1 -1
  64. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-C65GPhpj.js → quadrantDiagram-CIZ2JOQS-CJ50MngR.js} +1 -1
  65. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-U6IUiTCL.js → railroadDiagram-AXF67PYL-BY-OD8Tr.js} +1 -1
  66. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-D-I_9Qhi.js → requirementDiagram-LRYGKXZP-CQFButfK.js} +1 -1
  67. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-B57Fhed0.js → sankeyDiagram-W5VNT64P-BB_QUK_b.js} +1 -1
  68. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-CJOtiw21.js → sequenceDiagram-SI44F4Z6-CqhXZjyx.js} +1 -1
  69. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-FyX8zxSh.js → sizeCapture-X5ZJPWSS-2l1U4rgJ.js} +1 -1
  70. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-eF-pbs9t.js → stateDiagram-OKZ733FA-BX1FbrhJ.js} +1 -1
  71. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DJKi0Qel.js +1 -0
  72. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-CcfkA-Dq.js → swimlanes-SLNWSIFB-DOSnT7hb.js} +2 -2
  73. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-YYe7EEm5.js +8 -0
  74. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-DH8FjnOK.js → timeline-definition-Z64GVDOM-DVJQA7AN.js} +1 -1
  75. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-beoTagJg.js → vennDiagram-T6HMQDX7-74uxl9gx.js} +1 -1
  76. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CQSfw4cK.js → wardleyDiagram-T6FBY63Y-DsBxlcg_.js} +1 -1
  77. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-CQgzQ6jn.js → xychartDiagram-ELKLHX3M-snuW9Mjw.js} +1 -1
  78. package/dist/worker/console/static/index.html +2 -2
  79. package/dist/worker/console/static-src/app/console-types.js +0 -12
  80. package/dist/worker/console/static-src/app/useOperatorActions.js +1 -1
  81. package/dist/worker/console/static-src/app/useRecoveryActions.js +2 -2
  82. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +38 -1
  83. package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +8 -1
  84. package/dist/worker/console/static-src/operator-chat/turn-submission.js +42 -0
  85. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +3 -0
  86. package/dist/worker/console/static-src/operator-chat/useChatStream.js +204 -11
  87. package/dist/worker/console/static-src/operator-chat/useChatThread.js +23 -1
  88. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +755 -51
  89. package/dist/workflows/dag/backend-test-case-manifest.js +4 -0
  90. package/dist/workflows/dag/backend-test-execution-contract.js +18 -9
  91. package/dist/workflows/dag/backend-test-gap-fill.js +205 -0
  92. package/dist/workflows/dag/backend-test-intake-context.js +32 -0
  93. package/dist/workflows/dag/backend-test-layout.js +133 -0
  94. package/dist/workflows/dag/backend-test-markdown-workflow.js +167 -32
  95. package/dist/workflows/dag/backend-test-module-stem.js +5 -0
  96. package/dist/workflows/dag/backend-test-pytest-collection.js +368 -33
  97. package/dist/workflows/dag/backend-test-result-contract.js +1 -1
  98. package/dist/workflows/dag/backend-test-scenario-param.js +884 -140
  99. package/dist/workflows/dag/backend-test-scenario-partitions.js +249 -0
  100. package/dist/workflows/dag/backend-test-writer-completeness.js +100 -56
  101. package/dist/workflows/dag/contract-validator-registrations.js +2 -1
  102. package/dist/workflows/dag/convergence/controller.js +141 -0
  103. package/dist/workflows/dag/dynamic-runtime/map.js +24 -8
  104. package/dist/workflows/dag/dynamic-runtime/shared.js +1 -1
  105. package/dist/workflows/dag/frontend-implementation-contract.js +236 -8
  106. package/dist/workflows/dag/frontend-prewrite-gate.js +95 -35
  107. package/dist/workflows/dag/init-hybrid.js +652 -87
  108. package/dist/workflows/dag/node-execution.js +45 -18
  109. package/dist/workflows/dag/prompt.js +45 -0
  110. package/dist/workflows/dag/rerun-feedback.js +212 -0
  111. package/dist/workflows/dag/rerun-plan.js +93 -27
  112. package/dist/workflows/dag/rerun-run.js +3 -0
  113. package/dist/workflows/dag/rerun-task.js +61 -2
  114. package/dist/workflows/dag/retry-policy.js +18 -0
  115. package/dist/workflows/dag/types.js +98 -0
  116. package/docs/templates/backend-test-dag.json +60 -35
  117. package/docs/templates/harness.schema.json +0 -8
  118. package/harness.json +1 -8
  119. package/package.json +1 -1
  120. package/skills/frontend-implementation/references/node-contracts.md +2 -2
  121. package/dist/worker/console/static/assets/channel-Dp_2Aqf1.js +0 -1
  122. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL--CODy37j.js +0 -1
  123. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE--CODy37j.js +0 -1
  124. package/dist/worker/console/static/assets/index-BA5-N5ER.css +0 -1
  125. package/dist/worker/console/static/assets/index-DKPXBrdc.js +0 -325
  126. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-B-mvnLEc.js +0 -1
  127. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-JtpBmIv4.js +0 -8
@@ -6,6 +6,7 @@ import { createWriteStream } from "node:fs";
6
6
  import path from "node:path";
7
7
  import { promisify } from "node:util";
8
8
  import { isPriorityOnlyBackendTestModuleStem } from "./backend-test-module-stem.js";
9
+ import { resolveBackendTestLayout, } from "./backend-test-layout.js";
9
10
  import { parseJacocoXml, } from "./backend-test-coverage-contract.js";
10
11
  import { backendTestCaseManifestSchema, computeCaseManifestCoverageSummary, } from "./backend-test-case-manifest.js";
11
12
  const execFileAsync = promisify(execFile);
@@ -14,10 +15,22 @@ const execFileAsync = promisify(execFile);
14
15
  // heading width cannot collapse the whole manifest/report to zero cases.
15
16
  const CASE_ID = /\bBE-[A-Z0-9_-]+-\d{2,3}\b/g;
16
17
  const CANONICAL_CASE_ID = /\bBE-[A-Z0-9]+(?:-[A-Z0-9]+)*-\d{3}\b/;
18
+ const SHARED_SETUP_REF_LINE = /^\s*引用前置\s*:\s*(\S+)\s*#\s*([A-Za-z0-9_.\-\u4e00-\u9fa5]+)\s*$/;
19
+ const sharedSetupAnchorRe = /^SS-[A-Z0-9][A-Z0-9._-]*$/i;
20
+ export function extractBackendSharedSetupRefs(markdown) {
21
+ const refs = [];
22
+ for (const line of markdown.split("\n")) {
23
+ const match = SHARED_SETUP_REF_LINE.exec(line);
24
+ if (match) {
25
+ refs.push({ raw: match[0].trim(), path: match[1], anchor: match[2] });
26
+ }
27
+ }
28
+ return refs;
29
+ }
17
30
  const AC_ID = /\bAC-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/g;
18
31
  const SECRET = /(?:password|passwd|secret|token|api[_-]?key|private[_-]?key|authorization)\s*[:=]\s*\S+/i;
19
32
  const SECRET_ASSIGNMENT = /\b(password|passwd|secret|token|api[_-]?key|private[_-]?key|authorization|cookie|set-cookie|credential)\b\s*[:=]\s*([^\r\n,;]+)/gi;
20
- const SECRET_VALUE = /-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:ghp|github_pat|xox[baprs]|AKIA|sk)[-_A-Za-z0-9]{12,}\b/i;
33
+ const SECRET_VALUE = /-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:ghp|github_pat|xox[baprs]|AKIA)[-_A-Za-z0-9]{12,}\b|\bsk[-_][-_A-Za-z0-9]{10,}\b/i;
21
34
  const PLACEHOLDER_SECRET_VALUE = /^(?:<[^>]+>|\$\{[^}]+\}|\*+|x+|redacted|masked|example(?:[-_].*)?|sample(?:[-_].*)?|dummy(?:[-_].*)?|test(?:[-_].*)?|your[-_].*)$/i;
22
35
  const PLACEHOLDER = /\b(?:TODO|TBD|FIXME)\b|后续补充|暂不实现|接口正常|结果正确|按实际情况处理/i;
23
36
  const CASE_SECTION_ALIASES = {
@@ -187,8 +200,9 @@ export async function inspectBackendTestEnvironment(input) {
187
200
  serverEntries,
188
201
  };
189
202
  }
190
- async function markdownFiles(root) {
191
- const directory = path.join(root, "testcase", "md");
203
+ export async function markdownFiles(root, layout) {
204
+ const resolved = layout ?? resolveBackendTestLayout(undefined);
205
+ const directory = path.join(root, ...resolved.markdownDir.split("/"));
192
206
  const entries = await readdir(directory, { withFileTypes: true });
193
207
  return entries
194
208
  .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".md"))
@@ -303,6 +317,8 @@ function isCleanupStatement(value) {
303
317
  return false;
304
318
  if (/\b(?:assert|pytest\.fail|raise)\b/.test(trimmed))
305
319
  return false;
320
+ if (/\b[A-Za-z_][A-Za-z0-9_.]*request\s*\(\s*["']DELETE["']/i.test(trimmed))
321
+ return true;
306
322
  return /(?:^|[._])(?:cleanup|clean_up|teardown|delete|remove|close|release|rollback|disconnect|dispose|unlink)(?:[_(.]|$)/i.test(trimmed);
307
323
  }
308
324
  function findUnsafeSwallowedException(source) {
@@ -326,7 +342,18 @@ function findUnsafeSwallowedException(source) {
326
342
  continue;
327
343
  }
328
344
  const exceptionType = exceptMatch[2]?.trim() || "bare except";
329
- if (exceptionType !== "requests.RequestException") {
345
+ const narrowDecodeTypes = exceptionType
346
+ .replace(/[()]/g, "")
347
+ .split(",")
348
+ .map((value) => value.trim())
349
+ .filter(Boolean);
350
+ const precedingDecodeRegion = lines.slice(Math.max(0, index - 8), index).join("\n");
351
+ if (narrowDecodeTypes.length > 0 &&
352
+ narrowDecodeTypes.every((value) => value === "UnicodeDecodeError" || value === "json.JSONDecodeError") &&
353
+ /\.json\s*\(\s*\)/.test(precedingDecodeRegion)) {
354
+ continue;
355
+ }
356
+ if (!new Set(["requests.RequestException", "urllib.error.URLError", "httpx.RequestError"]).has(exceptionType)) {
330
357
  return exceptionType;
331
358
  }
332
359
  let tryIndex = index - 1;
@@ -366,9 +393,18 @@ function httpLoggingFindings(relativeScript, source) {
366
393
  if (!hasHttpRequest)
367
394
  return [];
368
395
  const findings = [];
369
- const hasRequestLog = /(?:print|log(?:ger)?\.(?:debug|info|warning|error))\s*\([^\n]*(?:request|请求)[^\n]*(?:method|url|path|params|query|json|body|payload|参数)/i.test(source);
370
- const hasResponseLog = /(?:print|log(?:ger)?\.(?:debug|info|warning|error))\s*\([^\n]*(?:response|响应)[^\n]*(?:status|status_code|body|json|text|result|结果)/i.test(source);
371
- const hasSanitizer = /\b(?:sanitize|sanitise|redact|mask)[A-Za-z0-9_]*\s*\(|\b[A-Za-z0-9_]*(?:sanitize|sanitise|redact|mask)[A-Za-z0-9_]*\s*\(/i.test(source);
396
+ const hasRequestLog = /(?:print|log(?:ger)?\.(?:debug|info|warning|error))\s*\([^\n]*(?:request|请求)[^\n]*(?:method|url|path|params|query|json|body|payload|参数)/i.test(source)
397
+ || /["']record["']\s*:\s*["']request["'][\s\S]{0,600}["'](?:method|url|path|params|query|json|body|payload)["']\s*:/i.test(source)
398
+ || /\b[A-Za-z_][A-Za-z0-9_]*log[A-Za-z0-9_]*\s*\(\s*["'](?:HTTP|API)_REQUEST["'][\s\S]{0,600}["'](?:method|url|path|params|query|json|body|payload)["']\s*:/i.test(source)
399
+ || /print\s*\(\s*f?["'](?:HTTP|API)(?:_|\s+)REQUEST\b/i.test(source) && /["'](?:method|url|path|params|query|json|body|payload)["']\s*:/i.test(source)
400
+ || /\b[A-Za-z_][A-Za-z0-9_]*log\s*\(\s*["']REQUEST["'][\s\S]{0,600}["'](?:method|url|path|params|query|json|body|payload)["']\s*:/i.test(source);
401
+ const hasResponseLog = /(?:print|log(?:ger)?\.(?:debug|info|warning|error))\s*\([^\n]*(?:response|响应)[^\n]*(?:status|status_code|body|json|text|result|结果)/i.test(source)
402
+ || /["']record["']\s*:\s*["']response["'][\s\S]{0,600}["'](?:status|status_code|body|json|text|result)["']\s*:/i.test(source)
403
+ || /\b[A-Za-z_][A-Za-z0-9_]*log[A-Za-z0-9_]*\s*\(\s*["'](?:HTTP|API)_RESPONSE["'][\s\S]{0,600}["'](?:status|status_code|body|json|text|result)["']\s*:/i.test(source)
404
+ || /print\s*\(\s*f?["'](?:HTTP|API)(?:_|\s+)RESPONSE\b/i.test(source) && /["'](?:status|status_code|body|json|text|result)["']\s*:/i.test(source)
405
+ || /\b[A-Za-z_][A-Za-z0-9_]*log\s*\(\s*["']RESPONSE["'][\s\S]{0,600}["'](?:status|status_code|body|json|text|result)["']\s*:/i.test(source);
406
+ const hasSanitizer = /\b(?:sanitize|sanitise|redact|mask)[A-Za-z0-9_]*\s*\(|\b[A-Za-z0-9_]*(?:sanitize|sanitise|redact|mask)[A-Za-z0-9_]*\s*\(/i.test(source)
407
+ || /(?:SENSITIVE_KEYS|sensitive_keys)[\s\S]{0,1600}["']<redacted>["']/i.test(source);
372
408
  const hasBoundedOutput = /(?:MAX_[A-Z_]*(?:LOG|BODY|RESPONSE)|max_(?:log|body|response)|truncate|truncat|\[:\s*\d{3,}\s*\])/i.test(source);
373
409
  if (!hasRequestLog)
374
410
  findings.push(`request logging evidence is missing in mapped backend pytest: ${relativeScript}`);
@@ -403,22 +439,23 @@ export function hasBlockingBackendMarkdownModuleStemFindings(report) {
403
439
  return /^## Module Stem Status\s*\r?\n\s*BLOCKED\s*$/m.test(report);
404
440
  }
405
441
  export async function validateBackendMarkdownCases(input) {
442
+ const layout = input.layout ?? resolveBackendTestLayout(undefined);
406
443
  const findings = [];
407
444
  const safetyFindings = [];
408
445
  const moduleStemFindings = [];
409
446
  let files = [];
410
447
  try {
411
- files = await markdownFiles(input.workspaceRoot);
448
+ files = await markdownFiles(input.workspaceRoot, layout);
412
449
  }
413
450
  catch {
414
- findings.push("missing testcase/md directory");
451
+ findings.push(`missing ${layout.markdownDir} directory`);
415
452
  }
416
- const readme = path.join(input.workspaceRoot, "testcase", "md", "README.md");
453
+ const readme = path.join(input.workspaceRoot, ...layout.readmePath.split("/"));
417
454
  if (!(await exists(readme)))
418
- findings.push("missing testcase/md/README.md");
455
+ findings.push(`missing ${layout.readmePath}`);
419
456
  const moduleFiles = files.filter((file) => path.basename(file).toLowerCase() !== "readme.md");
420
457
  if (moduleFiles.length === 0)
421
- findings.push("no testcase/md module case file found");
458
+ findings.push(`no ${layout.markdownDir} module case file found`);
422
459
  const seen = new Map();
423
460
  const coveredAc = new Set();
424
461
  let caseCount = 0;
@@ -436,6 +473,18 @@ export async function validateBackendMarkdownCases(input) {
436
473
  if (PLACEHOLDER.test(markdown)) {
437
474
  findings.push(`placeholder or non-assertable wording in ${relativeFile}`);
438
475
  }
476
+ const sharedSetupRefs = extractBackendSharedSetupRefs(markdown);
477
+ for (const ref of sharedSetupRefs) {
478
+ if (!input.sharedSetup) {
479
+ findings.push(`shared setup reference without a bound document: ${ref.raw} in ${relativeFile}`);
480
+ }
481
+ else if (ref.path !== input.sharedSetup.path) {
482
+ findings.push(`shared setup reference cites unbound path ${ref.path} (bound: ${input.sharedSetup.path}) in ${relativeFile}`);
483
+ }
484
+ else if (ref.anchor !== "SS-DEFAULT" && !sharedSetupAnchorRe.test(ref.anchor)) {
485
+ findings.push(`shared setup reference anchor is not a stable SS ID and not SS-DEFAULT: ${ref.raw} in ${relativeFile}`);
486
+ }
487
+ }
439
488
  const cases = splitCases(markdown);
440
489
  if (cases.length === 0) {
441
490
  findings.push(`no BE-<MODULE>-<NNN> case heading in ${relativeFile}`);
@@ -459,6 +508,15 @@ export async function validateBackendMarkdownCases(input) {
459
508
  findings.push(`${testCase.id} missing section: ${sectionAliases.join(" or ")}`);
460
509
  }
461
510
  }
511
+ const bindingFinding = markdownTestPointBindingFinding(testCase.id, testCase.body);
512
+ if (bindingFinding)
513
+ findings.push(bindingFinding);
514
+ const payloadLabelFinding = markdownPayloadLabelFinding(testCase.id, testCase.body);
515
+ if (payloadLabelFinding)
516
+ findings.push(payloadLabelFinding);
517
+ const scenarioIntentFinding = markdownScenarioIntentFinding(testCase.id, testCase.body);
518
+ if (scenarioIntentFinding)
519
+ findings.push(scenarioIntentFinding);
462
520
  const acIds = unique(testCase.body.match(AC_ID) ?? []);
463
521
  acIds.forEach((id) => coveredAc.add(id));
464
522
  const steps = sectionBody(testCase.body, CASE_SECTION_ALIASES.steps);
@@ -469,9 +527,12 @@ export async function validateBackendMarkdownCases(input) {
469
527
  if (!hasAssertableExpectedResult(expected)) {
470
528
  findings.push(`${testCase.id} has no structured expected result`);
471
529
  }
472
- const expectedScript = expectedBackendTestPytestScriptForMarkdownModule(relativeFile);
530
+ const expectedScript = expectedBackendTestPytestScriptForMarkdownModule(relativeFile, layout);
473
531
  const mappedScripts = extractMappedPytestScripts(testCase.body);
474
- if (mappedScripts.length === 0) {
532
+ if (isEvidenceOnlyMarkdownCaseBody(testCase.body)) {
533
+ // Evidence-only meta cases intentionally declare 脚本/primary symbol as 无 and keep only assertion/cross-cutting TPs.
534
+ }
535
+ else if (mappedScripts.length === 0) {
475
536
  findings.push(`${testCase.id} has no mapped pytest script in Automation Notes/自动化映射 (expected ${expectedScript} from module ${path.basename(relativeFile)})`);
476
537
  }
477
538
  else {
@@ -549,8 +610,9 @@ export function normalizeBackendTestModuleStem(moduleFileName) {
549
610
  return stem;
550
611
  }
551
612
  /** Expected pytest script path for a Markdown module file (basename or relative path). */
552
- export function expectedBackendTestPytestScriptForMarkdownModule(moduleFileName) {
553
- return `testcase/test_${normalizeBackendTestModuleStem(moduleFileName)}.py`;
613
+ export function expectedBackendTestPytestScriptForMarkdownModule(moduleFileName, layout) {
614
+ const resolved = layout ?? resolveBackendTestLayout(undefined);
615
+ return `${resolved.scriptDir}/test_${normalizeBackendTestModuleStem(moduleFileName)}.py`;
554
616
  }
555
617
  function extractMappedPytestScripts(testCaseBody) {
556
618
  const automation = sectionBody(testCaseBody, CASE_SECTION_ALIASES.automationNotes);
@@ -560,12 +622,79 @@ function extractMappedPytestScripts(testCaseBody) {
560
622
  .map((match) => match[1].replaceAll("\\", "/"))
561
623
  .filter((value) => !value.includes("..")));
562
624
  }
563
- function isSafeBackendPytestScript(script, workspaceRoot) {
625
+ function markdownScenarioIntentFinding(testCaseId, testCaseBody) {
626
+ const automation = sectionBody(testCaseBody, CASE_SECTION_ALIASES.automationNotes);
627
+ if (!automation.trim())
628
+ return null;
629
+ const variantLine = /^(?:\s*[-*+]\s*)?(?:变体测试点|Variant Test Points)\s*[::]\s*(.*)$/im.exec(automation)?.[1] ?? "";
630
+ const variants = unique(variantLine.match(/\bTP-[A-Z0-9-]+\b/g) ?? []);
631
+ if (variants.length === 0)
632
+ return null;
633
+ const localIntentIds = unique([...testCaseBody.matchAll(/(?:场景意图|Scenario Intent)\s*[::]\s*(TP-[A-Z0-9-]+)/gi)].map((match) => match[1].toUpperCase()));
634
+ const missing = variants.filter((id) => !localIntentIds.includes(id.toUpperCase()));
635
+ return missing.length > 0
636
+ ? `${testCaseId} variant Test Points require a local Scenario Intent line inside the same Case: missing=${missing.join(", ")}`
637
+ : null;
638
+ }
639
+ function markdownPayloadLabelFinding(testCaseId, testCaseBody) {
640
+ const automation = sectionBody(testCaseBody, CASE_SECTION_ALIASES.automationNotes);
641
+ if (!automation.trim())
642
+ return null;
643
+ const payloadLabel = /Payload (?:Contract|Required Paths|Allowed Paths|Enum)\s*[::]/gi;
644
+ for (const line of automation.split(/\r?\n/)) {
645
+ const matches = line.match(payloadLabel) ?? [];
646
+ if (matches.length === 0)
647
+ continue;
648
+ if (matches.length !== 1 || !/^\s*[-*+]\s*Payload (?:Contract|Required Paths|Allowed Paths|Enum)\s*[::]/i.test(line)) {
649
+ return `${testCaseId} payload contract labels must each occupy one independent machine-readable list line`;
650
+ }
651
+ }
652
+ return null;
653
+ }
654
+ function markdownTestPointBindingFinding(testCaseId, testCaseBody) {
655
+ const declaredSection = sectionBody(testCaseBody, ["测试点", "Test Points"]);
656
+ const automation = sectionBody(testCaseBody, CASE_SECTION_ALIASES.automationNotes);
657
+ if (!declaredSection.trim() || !automation.trim())
658
+ return null;
659
+ const declared = unique(declaredSection.match(/\bTP-[A-Z0-9-]+\b/g) ?? []);
660
+ const bindingLines = [
661
+ /^(?:\s*[-*+]\s*)?(?:变体测试点|Variant Test Points)\s*[::]\s*(.*)$/gim,
662
+ /^(?:\s*[-*+]\s*)?(?:场景断言测试点|Assertion Test Points)\s*[::]\s*(.*)$/gim,
663
+ /^(?:\s*[-*+]\s*)?(?:横切证据测试点|Cross-Cutting Test Points)\s*[::]\s*(.*)$/gim,
664
+ ];
665
+ const bound = unique(bindingLines.flatMap((pattern) => [...automation.matchAll(pattern)].flatMap((match) => match[1]?.match(/\bTP-[A-Z0-9-]+\b/g) ?? [])));
666
+ const missingBindings = declared.filter((id) => !bound.includes(id));
667
+ const undeclaredBindings = bound.filter((id) => !declared.includes(id));
668
+ if (missingBindings.length === 0 && undeclaredBindings.length === 0)
669
+ return null;
670
+ return `${testCaseId} Test Point set must exactly equal variant/assertion/cross-cutting binding union: unclassified=${missingBindings.join(", ") || "none"}; undeclared-bindings=${undeclaredBindings.join(", ") || "none"}`;
671
+ }
672
+ function isEvidenceOnlyMarkdownCaseBody(testCaseBody) {
673
+ const automation = sectionBody(testCaseBody, CASE_SECTION_ALIASES.automationNotes);
674
+ if (!automation.trim())
675
+ return false;
676
+ const scriptLine = /^\s*[-*+]\s*(?:脚本|script)\s*[::]\s*(.*)$/im.exec(automation)?.[1]?.replaceAll("`", "").trim() ??
677
+ "";
678
+ const primaryLine = /^\s*[-*+]\s*(?:primary symbol|Primary Symbol|主测试符号)\s*[::]\s*(.*)$/im.exec(automation)?.[1]
679
+ ?.replaceAll("`", "")
680
+ .trim() ?? "";
681
+ const noneLike = /^(?:none|无|n\/a|not-applicable)(?:\s*[((].*[))])?$/i;
682
+ const scriptIsNone = noneLike.test(scriptLine) || scriptLine.length === 0;
683
+ const primaryIsNone = noneLike.test(primaryLine) || primaryLine.length === 0;
684
+ const hasMappedScript = extractMappedPytestScripts(testCaseBody).length > 0;
685
+ const hasPrimary = /\btest_[A-Za-z0-9_]+\b/.test(primaryLine);
686
+ const hasVariant = /(?:变体测试点|Variant Test Points)\s*[::]\s*(?!\s*(?:无|none)\b)[^\n]*\bTP-[A-Z0-9-]+/i.test(automation);
687
+ const hasAssertionOrCross = /(?:场景断言测试点|Assertion Test Points|横切证据测试点|Cross-Cutting Test Points)\s*[::]\s*(?!\s*(?:无|none)\b)[^\n]*\bTP-[A-Z0-9-]+/i.test(automation);
688
+ return scriptIsNone && primaryIsNone && !hasMappedScript && !hasPrimary && !hasVariant && hasAssertionOrCross;
689
+ }
690
+ function isSafeBackendPytestScript(script, workspaceRoot, layout) {
691
+ const resolved = layout ?? resolveBackendTestLayout(undefined);
564
692
  const absolute = path.resolve(workspaceRoot, script);
565
693
  const relative = path.relative(workspaceRoot, absolute).replaceAll(path.sep, "/");
694
+ const allowedRoots = [`${resolved.testRoot}/`];
566
695
  return (!relative.startsWith("..") &&
567
696
  !path.isAbsolute(relative) &&
568
- script.startsWith("testcase/") &&
697
+ allowedRoots.some((prefix) => script.startsWith(prefix)) &&
569
698
  !script.includes("..") &&
570
699
  /^test_.*\.py$/i.test(path.basename(script)));
571
700
  }
@@ -609,8 +738,8 @@ function symbolCaseId(symbol) {
609
738
  ? canonicalBackendCaseId(match[1].replaceAll("_", "-"))
610
739
  : undefined;
611
740
  }
612
- export async function validateBackendMarkdownTraceability(workspaceRoot) {
613
- const files = await markdownFiles(workspaceRoot);
741
+ export async function validateBackendMarkdownTraceability(workspaceRoot, layout) {
742
+ const files = await markdownFiles(workspaceRoot, layout);
614
743
  const markdownIds = new Set();
615
744
  const expectedIdsByScript = new Map();
616
745
  const findings = [];
@@ -619,8 +748,11 @@ export async function validateBackendMarkdownTraceability(workspaceRoot) {
619
748
  continue;
620
749
  const content = await readFile(file, "utf8");
621
750
  for (const testCase of splitCases(content)) {
622
- markdownIds.add(testCase.id);
623
751
  const scripts = extractMappedPytestScripts(testCase.body);
752
+ if (isEvidenceOnlyMarkdownCaseBody(testCase.body)) {
753
+ continue;
754
+ }
755
+ markdownIds.add(testCase.id);
624
756
  if (scripts.length === 0) {
625
757
  findings.push(`${testCase.id} has no mapped pytest script in Automation Notes/自动化映射`);
626
758
  continue;
@@ -632,6 +764,7 @@ export async function validateBackendMarkdownTraceability(workspaceRoot) {
632
764
  }
633
765
  }
634
766
  }
767
+ const resolvedLayout = layout ?? resolveBackendTestLayout(undefined);
635
768
  const mappings = new Map();
636
769
  const automationIds = new Set();
637
770
  let requestResponseLoggingFindingCount = 0;
@@ -640,7 +773,7 @@ export async function validateBackendMarkdownTraceability(workspaceRoot) {
640
773
  const relative = path.relative(workspaceRoot, absolute);
641
774
  if (relative.startsWith("..") ||
642
775
  path.isAbsolute(relative) ||
643
- !relativeScript.startsWith("testcase/") ||
776
+ !relativeScript.startsWith(`${resolvedLayout.testRoot}/`) ||
644
777
  !/^test_.*\.py$/i.test(path.basename(relativeScript))) {
645
778
  findings.push(`unsafe mapped pytest script: ${relativeScript}`);
646
779
  continue;
@@ -713,8 +846,9 @@ export async function validateBackendMarkdownTraceability(workspaceRoot) {
713
846
  ...(findings.length > 0 ? findings.map((finding) => `- ${finding}`) : ["- None"]),
714
847
  ].join("\n") + "\n");
715
848
  }
716
- export async function resolveBackendTestMappedPytestScripts(workspaceRoot) {
717
- const files = await markdownFiles(workspaceRoot);
849
+ export async function resolveBackendTestMappedPytestScripts(workspaceRoot, layout) {
850
+ const resolved = layout ?? resolveBackendTestLayout(undefined);
851
+ const files = await markdownFiles(workspaceRoot, resolved);
718
852
  const expectedScripts = new Set();
719
853
  const existingScripts = new Set();
720
854
  const missingScripts = new Set();
@@ -735,7 +869,7 @@ export async function resolveBackendTestMappedPytestScripts(workspaceRoot) {
735
869
  if (path.basename(file).toLowerCase() === "readme.md")
736
870
  continue;
737
871
  const relativeFile = path.relative(workspaceRoot, file).replaceAll(path.sep, "/");
738
- const expectedScript = expectedBackendTestPytestScriptForMarkdownModule(relativeFile);
872
+ const expectedScript = expectedBackendTestPytestScriptForMarkdownModule(relativeFile, resolved);
739
873
  const markdown = await readFile(file, "utf8");
740
874
  const mappedFromModule = new Set();
741
875
  for (const testCase of splitCases(markdown)) {
@@ -755,14 +889,14 @@ export async function resolveBackendTestMappedPytestScripts(workspaceRoot) {
755
889
  continue;
756
890
  }
757
891
  for (const script of mappedFromModule) {
758
- if (!isSafeBackendPytestScript(script, workspaceRoot)) {
892
+ if (!isSafeBackendPytestScript(script, workspaceRoot, resolved)) {
759
893
  throw new Error(`unsafe mapped pytest script: ${script}`);
760
894
  }
761
895
  if (await mappedScriptExists(path.resolve(workspaceRoot, script))) {
762
896
  recordScript(script, relativeFile, true);
763
897
  continue;
764
898
  }
765
- if (!isSafeBackendPytestScript(expectedScript, workspaceRoot)) {
899
+ if (!isSafeBackendPytestScript(expectedScript, workspaceRoot, resolved)) {
766
900
  throw new Error(`unsafe mapped pytest script: ${expectedScript}`);
767
901
  }
768
902
  recordScript(expectedScript, relativeFile, await mappedScriptExists(path.resolve(workspaceRoot, expectedScript)));
@@ -777,8 +911,8 @@ export async function resolveBackendTestMappedPytestScripts(workspaceRoot) {
777
911
  missingScripts: unique(missingScripts),
778
912
  };
779
913
  }
780
- export async function collectBackendTestMappedPytestScripts(workspaceRoot) {
781
- const resolution = await resolveBackendTestMappedPytestScripts(workspaceRoot);
914
+ export async function collectBackendTestMappedPytestScripts(workspaceRoot, layout) {
915
+ const resolution = await resolveBackendTestMappedPytestScripts(workspaceRoot, layout);
782
916
  if (resolution.missingScripts.length > 0) {
783
917
  throw new Error(`mapped pytest script is missing: ${resolution.missingScripts[0]}`);
784
918
  }
@@ -791,8 +925,8 @@ function cleanCaseTitle(rawHeading, id) {
791
925
  .replace(/^BE-[A-Z0-9_-]+-\d{2,3}\s*(?:[||—–-]\s*)?/i, "")
792
926
  .trim() || id;
793
927
  }
794
- export async function collectBackendTestHumanCaseCatalog(workspaceRoot) {
795
- const files = await markdownFiles(workspaceRoot);
928
+ export async function collectBackendTestHumanCaseCatalog(workspaceRoot, layout) {
929
+ const files = await markdownFiles(workspaceRoot, layout);
796
930
  const catalog = new Map();
797
931
  for (const file of files) {
798
932
  if (path.basename(file).toLowerCase() === "readme.md")
@@ -814,7 +948,8 @@ export async function collectBackendTestHumanCaseCatalog(workspaceRoot) {
814
948
  });
815
949
  }
816
950
  }
817
- const testcaseRoot = path.join(workspaceRoot, "testcase");
951
+ const resolvedLayout = layout ?? resolveBackendTestLayout(undefined);
952
+ const testcaseRoot = path.join(workspaceRoot, ...resolvedLayout.testRoot.split("/"));
818
953
  const pythonFiles = [];
819
954
  async function walk(directory) {
820
955
  for (const entry of await readdir(directory, { withFileTypes: true })) {
@@ -1,5 +1,7 @@
1
1
  import path from "node:path";
2
2
  const PRIORITY_ONLY_MODULE_STEMS = new Set(["p0", "p1", "p2"]);
3
+ const OPAQUE_HASH_MODULE_STEM = /^[a-f][a-f0-9]{6,63}$/;
4
+ export const BACKEND_TEST_MAX_MODULE_COUNT = 8;
3
5
  export function normalizeBackendTestModuleStemCandidate(raw) {
4
6
  return path.basename(raw)
5
7
  .replace(/\.(?:md|py)$/i, "")
@@ -11,6 +13,9 @@ export function normalizeBackendTestModuleStemCandidate(raw) {
11
13
  export function isPriorityOnlyBackendTestModuleStem(raw) {
12
14
  return PRIORITY_ONLY_MODULE_STEMS.has(normalizeBackendTestModuleStemCandidate(raw));
13
15
  }
16
+ export function isOpaqueHashBackendTestModuleStem(raw) {
17
+ return OPAQUE_HASH_MODULE_STEM.test(normalizeBackendTestModuleStemCandidate(raw));
18
+ }
14
19
  export function isPriorityOnlyBackendPytestScript(script) {
15
20
  const basename = path.posix.basename(script.replaceAll("\\", "/"));
16
21
  const match = /^test_(.+)\.py$/i.exec(basename);