@tea-agent/loop-agent 0.38.1 → 0.38.3

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 (180) hide show
  1. package/CHANGELOG.md +126 -12
  2. package/README.md +2 -0
  3. package/bin/loop-agent.js +7 -3
  4. package/dist/application/dag/generate-task-dag.js +9 -0
  5. package/dist/application/task-lifecycle/advance.js +4 -0
  6. package/dist/build-stamp.json +3 -3
  7. package/dist/commands/init.js +2 -14
  8. package/dist/commands/task-advance.js +31 -0
  9. package/dist/executors/dag-pi-executor.js +99 -5
  10. package/dist/executors/pi-event-serializer.js +42 -1
  11. package/dist/executors/pi-executor.js +51 -0
  12. package/dist/executors/pi-extension-resolver.js +233 -0
  13. package/dist/executors/pi-sdk-executor.js +187 -59
  14. package/dist/executors/shell-executor.js +324 -16
  15. package/dist/executors/shell-write-guard.js +7 -0
  16. package/dist/governance/manifest-types.js +0 -2
  17. package/dist/shared/operator/capabilities.js +87 -22
  18. package/dist/task/config-types.js +22 -0
  19. package/dist/task/contract/constants.js +1 -0
  20. package/dist/task/contract/diff.js +26 -0
  21. package/dist/task/contract/project.js +5 -0
  22. package/dist/task/contract/schema.js +6 -1
  23. package/dist/task/source-prepare/build-draft.js +15 -0
  24. package/dist/worker/console/chat/deferred-turn.js +91 -0
  25. package/dist/worker/console/chat/pi-runtime.js +10 -5
  26. package/dist/worker/console/chat/routes.js +458 -47
  27. package/dist/worker/console/chat/semantic-activity.js +13 -13
  28. package/dist/worker/console/chat/session-store.js +146 -2
  29. package/dist/worker/console/harness-pi-model-matrix.js +2 -11
  30. package/dist/worker/console/operator-actions.js +5 -4
  31. package/dist/worker/console/pi-readiness.js +5 -4
  32. package/dist/worker/console/recovery-cta.js +4 -4
  33. package/dist/worker/console/recovery-error-copy.js +4 -4
  34. package/dist/worker/console/server.js +6 -1
  35. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-Hfsot6rP.js → abnfDiagram-N423BO3Z-C4kVYweA.js} +1 -1
  36. package/dist/worker/console/static/assets/{arc-YGwEASin.js → arc-Bj2M8iO5.js} +1 -1
  37. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-CmOM0F92.js → architectureDiagram-T3A2C74G-CLlXe4-6.js} +1 -1
  38. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-DDLuaHC5.js → blockDiagram-VBNYF7ZC-psEp5xH0.js} +1 -1
  39. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-B0Um43W4.js → c4Diagram-5PPSVZJV-DeKcOCGJ.js} +1 -1
  40. package/dist/worker/console/static/assets/channel-CPF4N7pf.js +1 -0
  41. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-DvBN4JZc.js → chunk-2GRJ4B5K-K0C744p1.js} +1 -1
  42. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-B1UVgW4E.js → chunk-2Q5K7J3B-xbNw4J9-.js} +1 -1
  43. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-Cf0GgXzX.js → chunk-5RXB4S5H-DjLaSDUO.js} +1 -1
  44. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-DbNA24sj.js → chunk-5VM5RSS4-CtiPlKel.js} +1 -1
  45. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-Bi_0Be06.js → chunk-6Q2QTUOP-DZtolir7.js} +1 -1
  46. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-C8pPGvRd.js → chunk-GF5L2VYU-CPcdNeew.js} +1 -1
  47. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-BitQY8Z-.js → chunk-JWPE2WC7-NgHc6V37.js} +1 -1
  48. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-U67GCiEo.js → chunk-KBJHAD2P-DQ_T-hg8.js} +1 -1
  49. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-IoJwXPBA.js → chunk-RYQCIY6F-DgLsxcVP.js} +1 -1
  50. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-YDZ_UHgE.js → chunk-XXDRQBXY-UrVoM9z1.js} +1 -1
  51. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-C2DwA_Y1.js +1 -0
  52. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-C2DwA_Y1.js +1 -0
  53. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-CUZgsiRC.js → cose-bilkent-JH36ORCC-CLkYvTb3.js} +1 -1
  54. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-COmCh4pT.js → cynefin-VYW2F7L2-BwT_xKrE.js} +1 -1
  55. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-Ta6FSm68.js → cynefinDiagram-MW4NZA55-Bi9tuzif.js} +1 -1
  56. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-UdcR-VW9.js → dagre-VZM6K2ZE-BCIqkWBV.js} +1 -1
  57. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-DybcO3Et.js → diagram-7IWD3JNH-Dip6l9_Z.js} +1 -1
  58. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-wErDFWLw.js → diagram-B4RE2ZJO-B-xkh_wK.js} +1 -1
  59. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-DkVHZWqz.js → diagram-LBJQPF4R-DZO0kTt_.js} +1 -1
  60. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-DdqWs7QN.js → diagram-Q27KOJAE-D6ZplNbZ.js} +1 -1
  61. package/dist/worker/console/static/assets/{diagram-UB23O5K3-C9dMVmDo.js → diagram-UB23O5K3-BPekbhoS.js} +1 -1
  62. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-B2T2WcBK.js → ebnfDiagram-BXEA7PRR-BtmaJpK5.js} +1 -1
  63. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-Bt9rjDFq.js → erDiagram-JOGREHBK-6DS0Xc44.js} +1 -1
  64. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-U8ic_tvi.js → flowDiagram-UKHOOZJN-CihnROSm.js} +1 -1
  65. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-BfQJdQoO.js → ganttDiagram-PKOTCBZU-CZC4zOE2.js} +1 -1
  66. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-CpMftH4O.js → gitGraphDiagram-DS77QQ5N-Dq1fhzGN.js} +1 -1
  67. package/dist/worker/console/static/assets/index-DTZOKgAn.js +404 -0
  68. package/dist/worker/console/static/assets/index-Ya5FE7cD.css +1 -0
  69. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-DzqWXNO_.js → infoDiagram-6WML65LV-DlSl4BGx.js} +1 -1
  70. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-CJmGQ5Yp.js → ishikawaDiagram-WSZJBQD7-snnzFl_U.js} +1 -1
  71. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-ChPtKFLz.js → journeyDiagram-NVQOT4AX-BDE7qpMx.js} +1 -1
  72. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-PRe7wB5u.js → kanban-definition-27J2QSJJ-CD2Ci04G.js} +1 -1
  73. package/dist/worker/console/static/assets/{linear-DpKX6QNp.js → linear-DdnapKIH.js} +1 -1
  74. package/dist/worker/console/static/assets/{mermaid.core-Dnl76b7Y.js → mermaid.core-BVjAT9b8.js} +5 -5
  75. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-S9PCXc3J.js → mindmap-definition-FAOFIHXS-D0yJk-zA.js} +1 -1
  76. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-Du8e-FSX.js → pegDiagram-VL7TDLO6-BVo9tFP-.js} +1 -1
  77. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-CY6OmHTc.js → pieDiagram-7S7Q4E2Y-DnOV-mZ_.js} +1 -1
  78. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-C65GPhpj.js → quadrantDiagram-CIZ2JOQS-CzUJo58i.js} +1 -1
  79. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-U6IUiTCL.js → railroadDiagram-AXF67PYL-Bvikrolh.js} +1 -1
  80. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-D-I_9Qhi.js → requirementDiagram-LRYGKXZP-DtaSVCap.js} +1 -1
  81. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-B57Fhed0.js → sankeyDiagram-W5VNT64P-C2c9A0wW.js} +1 -1
  82. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-CJOtiw21.js → sequenceDiagram-SI44F4Z6-DmXJcU7r.js} +1 -1
  83. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-FyX8zxSh.js → sizeCapture-X5ZJPWSS-B4GUFW92.js} +1 -1
  84. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-eF-pbs9t.js → stateDiagram-OKZ733FA-Dsad2MXf.js} +1 -1
  85. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-B8FB_cNk.js +1 -0
  86. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-CcfkA-Dq.js → swimlanes-SLNWSIFB-DGq48Fbi.js} +2 -2
  87. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-CrZYaWfx.js +8 -0
  88. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-DH8FjnOK.js → timeline-definition-Z64GVDOM-CSSi6OFf.js} +1 -1
  89. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-beoTagJg.js → vennDiagram-T6HMQDX7-BqyzPrWv.js} +1 -1
  90. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CQSfw4cK.js → wardleyDiagram-T6FBY63Y-DlznVb6S.js} +1 -1
  91. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-CQgzQ6jn.js → xychartDiagram-ELKLHX3M-CfBcgJ_K.js} +1 -1
  92. package/dist/worker/console/static/index.html +2 -2
  93. package/dist/worker/console/static-src/app/console-types.js +0 -12
  94. package/dist/worker/console/static-src/app/useOperatorActions.js +1 -1
  95. package/dist/worker/console/static-src/app/useRecoveryActions.js +2 -2
  96. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +38 -1
  97. package/dist/worker/console/static-src/operator-chat/open-preview-in-browser.js +328 -0
  98. package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +8 -1
  99. package/dist/worker/console/static-src/operator-chat/turn-submission.js +42 -0
  100. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +3 -0
  101. package/dist/worker/console/static-src/operator-chat/useChatStream.js +204 -11
  102. package/dist/worker/console/static-src/operator-chat/useChatThread.js +23 -1
  103. package/dist/worker/observe/node-input.js +72 -3
  104. package/dist/worker/observe/static/dag-history-labels.js +4 -0
  105. package/dist/worker/observe/static/state.js +2 -2
  106. package/dist/worker/observe/static/views/dag-inspector.js +51 -0
  107. package/dist/worker/observe/static/views/session-timeline.js +18 -5
  108. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +809 -53
  109. package/dist/workflows/dag/backend-test-case-manifest.js +4 -0
  110. package/dist/workflows/dag/backend-test-execution-contract.js +18 -9
  111. package/dist/workflows/dag/backend-test-gap-fill.js +205 -0
  112. package/dist/workflows/dag/backend-test-intake-context.js +32 -0
  113. package/dist/workflows/dag/backend-test-layout.js +133 -0
  114. package/dist/workflows/dag/backend-test-markdown-workflow.js +167 -32
  115. package/dist/workflows/dag/backend-test-module-stem.js +5 -0
  116. package/dist/workflows/dag/backend-test-pytest-collection.js +368 -33
  117. package/dist/workflows/dag/backend-test-result-contract.js +1 -1
  118. package/dist/workflows/dag/backend-test-scenario-param.js +884 -140
  119. package/dist/workflows/dag/backend-test-scenario-partitions.js +321 -0
  120. package/dist/workflows/dag/backend-test-writer-completeness.js +100 -56
  121. package/dist/workflows/dag/contract-output-registry.js +15 -0
  122. package/dist/workflows/dag/contract-validator-registrations.js +2 -1
  123. package/dist/workflows/dag/convergence/controller.js +141 -0
  124. package/dist/workflows/dag/dynamic-runtime/map.js +24 -8
  125. package/dist/workflows/dag/dynamic-runtime/shared.js +1 -1
  126. package/dist/workflows/dag/failure-category.js +4 -0
  127. package/dist/workflows/dag/frontend-implementation-contract.js +355 -26
  128. package/dist/workflows/dag/frontend-prewrite-gate.js +96 -84
  129. package/dist/workflows/dag/frontend-test-case-quality.js +5 -13
  130. package/dist/workflows/dag/frontend-test-environment-probe.js +227 -0
  131. package/dist/workflows/dag/frontend-test-markdown.js +61 -0
  132. package/dist/workflows/dag/frontend-test-result-contract.js +10 -18
  133. package/dist/workflows/dag/frontend-test-standard-scenarios.js +68 -0
  134. package/dist/workflows/dag/init-hybrid.js +720 -177
  135. package/dist/workflows/dag/node-execution.js +164 -26
  136. package/dist/workflows/dag/prompt.js +60 -1
  137. package/dist/workflows/dag/rerun-feedback.js +212 -0
  138. package/dist/workflows/dag/rerun-plan.js +112 -27
  139. package/dist/workflows/dag/rerun-run.js +3 -0
  140. package/dist/workflows/dag/rerun-task.js +61 -2
  141. package/dist/workflows/dag/retry-policy.js +18 -0
  142. package/dist/workflows/dag/structured-output-repair.js +712 -0
  143. package/dist/workflows/dag/types.js +121 -0
  144. package/dist/workflows/dag/validate.js +41 -1
  145. package/docs/operations/local-development-environment.md +1 -5
  146. package/docs/skills/vetted-skill-registry.md +14 -0
  147. package/docs/templates/README.md +1 -1
  148. package/docs/templates/agent-dag.schema.json +44 -0
  149. package/docs/templates/backend-test-dag.json +60 -35
  150. package/docs/templates/frontend-test-dag.json +8 -10
  151. package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +1 -1
  152. package/docs/templates/harness.schema.json +0 -8
  153. package/harness.json +1 -8
  154. package/package.json +8 -4
  155. package/skills/analyze-product-requirements/SKILL.md +8 -5
  156. package/skills/analyze-product-requirements/references/acceptance-criteria.md +1 -1
  157. package/skills/analyze-product-requirements/references/clarification-and-knowledge.md +10 -6
  158. package/skills/analyze-product-requirements/references/example.md +50 -0
  159. package/skills/analyze-product-requirements/references/forward-test-cases.md +11 -7
  160. package/skills/analyze-product-requirements/references/kb-integration.md +5 -5
  161. package/skills/analyze-product-requirements/references/product-analysis-schema.md +8 -4
  162. package/skills/analyze-product-requirements/references/product-requirement-schema.md +2 -2
  163. package/skills/analyze-product-requirements/references/requirement-clarification-schema.md +1 -1
  164. package/skills/analyze-product-requirements/scripts/test-validators.mjs +11 -1
  165. package/skills/analyze-product-requirements/scripts/validate-product-analysis.mjs +20 -1
  166. package/skills/analyze-product-requirements/scripts/validate-requirement-clarification.mjs +9 -0
  167. package/skills/codebase-scout/SKILL.md +1 -1
  168. package/skills/frontend-implementation/references/node-contracts.md +2 -2
  169. package/skills/improve-codebase-architecture/SKILL.md +81 -0
  170. package/skills/improve-codebase-architecture/deepening.md +37 -0
  171. package/skills/improve-codebase-architecture/html-report.md +123 -0
  172. package/skills/improve-codebase-architecture/interface-design.md +44 -0
  173. package/skills/improve-codebase-architecture/language.md +53 -0
  174. package/dist/worker/console/static/assets/channel-Dp_2Aqf1.js +0 -1
  175. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL--CODy37j.js +0 -1
  176. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE--CODy37j.js +0 -1
  177. package/dist/worker/console/static/assets/index-BA5-N5ER.css +0 -1
  178. package/dist/worker/console/static/assets/index-DKPXBrdc.js +0 -325
  179. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-B-mvnLEc.js +0 -1
  180. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-JtpBmIv4.js +0 -8
@@ -3,8 +3,18 @@ import { readdir, readFile, writeFile, mkdir } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { z } from "zod";
5
5
  import { priorityOnlyBackendPytestScripts } from "./backend-test-module-stem.js";
6
+ import { resolveBackendTestLayout, } from "./backend-test-layout.js";
6
7
  const SHA256 = /^[a-f0-9]{64}$/;
7
8
  const MAX_DIAGNOSTIC_CHARS = 12_000;
9
+ /** Upper bound for assertBackendTestExecutionReadinessFresh Error.message (not collection excerpts). */
10
+ export const MAX_READINESS_ERROR_CHARS = 2_048;
11
+ const READINESS_EXCLUSION_CATEGORIES = [
12
+ "correspondence-missing",
13
+ "correspondence-status",
14
+ "payload-contract-status",
15
+ "scenario-param-field",
16
+ "other",
17
+ ];
8
18
  export const backendPytestCollectionFindingSchema = z.object({
9
19
  kind: z.string().min(1),
10
20
  classification: z.literal("test-asset-defect"),
@@ -27,6 +37,8 @@ export const backendPytestCollectionFactsSchema = z.object({
27
37
  missingMappedScripts: z.array(z.string()),
28
38
  assetFiles: z.array(z.string()),
29
39
  inputHashes: z.record(z.string(), z.string().regex(SHA256)),
40
+ hazardOccurrenceCounts: z.record(z.string(), z.record(z.string(), z.number().int().min(0))),
41
+ hazardOccurrences: z.record(z.string(), z.record(z.string(), z.array(z.string()))).optional(),
30
42
  pytestExitCode: z.number().int().nullable(),
31
43
  collectedItemCount: z.number().int().min(0),
32
44
  collectedItemIds: z.array(z.string()),
@@ -49,8 +61,14 @@ export const backendPytestCollectionFactsSchema = z.object({
49
61
  context.addIssue({ code: z.ZodIssueCode.custom, message: "backend pytest fixture resolution without an attempt cannot have an exit code" });
50
62
  }
51
63
  });
64
+ const backendTestExcludedItemSchema = z.object({
65
+ itemId: z.string().min(1),
66
+ caseId: z.string().optional(),
67
+ symbol: z.string().optional(),
68
+ reasons: z.array(z.string().min(1)).min(1),
69
+ }).strict();
52
70
  export const backendTestExecutionReadinessSchema = z.object({
53
- schemaId: z.literal("backend-test-execution-readiness-v1"),
71
+ schemaId: z.literal("backend-test-execution-readiness-v2"),
54
72
  status: z.enum(["PASS", "PARTIAL", "BLOCKED"]),
55
73
  collectionStatus: z.literal("PASS"),
56
74
  fixtureResolutionStatus: z.literal("PASS"),
@@ -61,9 +79,94 @@ export const backendTestExecutionReadinessSchema = z.object({
61
79
  }).strict(),
62
80
  mappedScripts: z.array(z.string()).min(1),
63
81
  collectedItemIds: z.array(z.string()),
82
+ eligibleItemIds: z.array(z.string()),
83
+ excludedItems: z.array(backendTestExcludedItemSchema),
64
84
  fixtureIssues: z.array(z.string()),
65
85
  assetHashes: z.record(z.string(), z.string().regex(SHA256)),
86
+ eligibilityInputHashes: z.record(z.string(), z.string().regex(SHA256)),
66
87
  }).strict();
88
+ const REPAIR_HAZARD_PATTERNS = [
89
+ { label: "skip/xfail", source: "pytest\\.mark\\.(?:skip|skipif|xfail)\\b|pytest\\.(?:skip|xfail)\\s*\\(" },
90
+ { label: "sys.path/PYTHONPATH mutation", source: "sys\\.path\\s*\\.|PYTHONPATH" },
91
+ { label: "ImportError fallback", source: "except\\s+ImportError\\b" },
92
+ { label: "broad exception swallowing", source: "except\\s+(?:Exception|BaseException)\\b[\\s\\S]{0,160}?\\bpass\\b" },
93
+ ];
94
+ function normalizeSourceNewlines(source) {
95
+ return source.replaceAll("\r\n", "\n").replaceAll("\r", "\n");
96
+ }
97
+ /**
98
+ * Normalize a hazard occurrence identity by stripping trailing Python `#` comments
99
+ * while preserving `#` characters inside string literals. Fail closed: if quote
100
+ * state is ambiguous, keep the original trimmed line rather than over-stripping.
101
+ */
102
+ function normalizeHazardOccurrenceIdentity(line) {
103
+ const trimmed = line.trimEnd().trim();
104
+ if (!trimmed.includes("#"))
105
+ return trimmed;
106
+ let inSingle = false;
107
+ let inDouble = false;
108
+ let escaped = false;
109
+ for (let i = 0; i < trimmed.length; i += 1) {
110
+ const ch = trimmed[i];
111
+ if (escaped) {
112
+ escaped = false;
113
+ continue;
114
+ }
115
+ if ((inSingle || inDouble) && ch === "\\") {
116
+ escaped = true;
117
+ continue;
118
+ }
119
+ if (!inDouble && ch === "'") {
120
+ inSingle = !inSingle;
121
+ continue;
122
+ }
123
+ if (!inSingle && ch === '"') {
124
+ inDouble = !inDouble;
125
+ continue;
126
+ }
127
+ if (!inSingle && !inDouble && ch === "#") {
128
+ return trimmed.slice(0, i).trimEnd();
129
+ }
130
+ }
131
+ // Unclosed quote or no out-of-string `#`: do not invent a comment boundary.
132
+ return trimmed;
133
+ }
134
+ function listPatternOccurrences(source, patternSource, flags = "") {
135
+ const normalized = normalizeSourceNewlines(source);
136
+ const pattern = new RegExp(patternSource, flags.includes("g") ? flags : `${flags}g`);
137
+ const identities = [];
138
+ for (const match of normalized.matchAll(pattern)) {
139
+ const index = match.index ?? 0;
140
+ const lineStart = normalized.lastIndexOf("\n", Math.max(0, index - 1)) + 1;
141
+ const lineEnd = normalized.indexOf("\n", index);
142
+ const line = normalized.slice(lineStart, lineEnd === -1 ? normalized.length : lineEnd).trimEnd();
143
+ identities.push(normalizeHazardOccurrenceIdentity(line));
144
+ }
145
+ return identities;
146
+ }
147
+ function scanRepairHazardOccurrenceLists(source) {
148
+ const lists = {};
149
+ for (const rule of REPAIR_HAZARD_PATTERNS) {
150
+ lists[rule.label] = listPatternOccurrences(source, rule.source, rule.flags);
151
+ }
152
+ return lists;
153
+ }
154
+ function multisetCounts(identities) {
155
+ const counts = new Map();
156
+ for (const identity of identities) {
157
+ counts.set(identity, (counts.get(identity) ?? 0) + 1);
158
+ }
159
+ return counts;
160
+ }
161
+ function hasMultisetIncrement(initialIdentities, finalIdentities) {
162
+ const initial = multisetCounts(initialIdentities);
163
+ const final = multisetCounts(finalIdentities);
164
+ for (const [identity, finalCount] of final) {
165
+ if (finalCount > (initial.get(identity) ?? 0))
166
+ return true;
167
+ }
168
+ return false;
169
+ }
67
170
  function repoRef(workspaceRoot, absolutePath) {
68
171
  return path.relative(workspaceRoot, absolutePath).replaceAll(path.sep, "/");
69
172
  }
@@ -94,27 +197,39 @@ async function walkPythonFiles(root) {
94
197
  return results;
95
198
  }
96
199
  export async function buildBackendPytestAssetInventory(workspaceRoot, mappedScripts, options = {}) {
200
+ const layout = options.layout ?? resolveBackendTestLayout(undefined);
201
+ const scriptRoot = layout.scriptDir;
97
202
  const normalizedScripts = [...new Set(mappedScripts.map((value) => value.replaceAll("\\", "/")))].sort();
98
203
  if ((options.requireMappedScripts ?? true) && normalizedScripts.length === 0) {
99
204
  throw new Error("backend pytest collection requires mapped scripts");
100
205
  }
206
+ const scriptPattern = new RegExp(`^${scriptRoot.replaceAll(/[.*+?^[\]{}()|$]/g, "\\$&")}(?:/[A-Za-z0-9_.-]+)*/test_[A-Za-z0-9_.-]+\\.py$`);
207
+ const rootScriptPattern = new RegExp(`^${scriptRoot.replaceAll(/[.*+?^[\]{}()|$]/g, "\\$&")}/test_[A-Za-z0-9_.-]+\\.py$`);
101
208
  for (const script of normalizedScripts) {
102
- if (!/^testcase(?:\/[A-Za-z0-9_.-]+)*\/test_[A-Za-z0-9_.-]+\.py$/.test(script) && !/^testcase\/test_[A-Za-z0-9_.-]+\.py$/.test(script)) {
209
+ if (!scriptPattern.test(script) && !rootScriptPattern.test(script)) {
103
210
  throw new Error(`unsafe backend pytest mapped script: ${script}`);
104
211
  }
105
212
  const absolute = path.resolve(workspaceRoot, script);
106
- const testcaseRoot = path.resolve(workspaceRoot, "testcase");
213
+ const testcaseRoot = path.resolve(workspaceRoot, layout.testRoot);
107
214
  if (absolute !== testcaseRoot && !absolute.startsWith(`${testcaseRoot}${path.sep}`)) {
108
- throw new Error(`backend pytest mapped script escapes testcase: ${script}`);
215
+ throw new Error(`backend pytest mapped script escapes test root ${layout.testRoot}: ${script}`);
109
216
  }
110
217
  await readFile(absolute);
111
218
  }
112
- const pythonFiles = await walkPythonFiles(path.resolve(workspaceRoot, "testcase"));
219
+ const pythonFiles = await walkPythonFiles(path.resolve(workspaceRoot, layout.testRoot));
113
220
  const assetFiles = [...new Set(pythonFiles.map((file) => repoRef(workspaceRoot, file)))].sort();
114
221
  const inputHashes = {};
115
- for (const file of assetFiles)
116
- inputHashes[file] = sha256(await readFile(path.resolve(workspaceRoot, file)));
117
- return { mappedScripts: normalizedScripts, assetFiles, inputHashes };
222
+ const hazardOccurrenceCounts = {};
223
+ const hazardOccurrences = {};
224
+ for (const file of assetFiles) {
225
+ const bytes = await readFile(path.resolve(workspaceRoot, file));
226
+ const source = bytes.toString("utf8");
227
+ inputHashes[file] = sha256(bytes);
228
+ const lists = scanRepairHazardOccurrenceLists(source);
229
+ hazardOccurrences[file] = lists;
230
+ hazardOccurrenceCounts[file] = Object.fromEntries(Object.entries(lists).map(([label, identities]) => [label, identities.length]));
231
+ }
232
+ return { mappedScripts: normalizedScripts, assetFiles, inputHashes, hazardOccurrenceCounts, hazardOccurrences };
118
233
  }
119
234
  function bounded(value) {
120
235
  const normalized = value.replaceAll(/(Bearer|Basic)\s+[A-Za-z0-9._~+\/-]+/gi, "$1 [REDACTED]");
@@ -131,13 +246,26 @@ function collectionItems(stdout) {
131
246
  return /^testcase\//.test(line.replaceAll("\\", "/"));
132
247
  }).map((line) => line.replaceAll("\\", "/")))];
133
248
  }
134
- function testcasePythonPaths(output) {
135
- return [...new Set((output.replaceAll("\\", "/").match(/testcase\/[A-Za-z0-9_./-]+\.py/gi) ?? []).map((item) => item.replace(/:\d+$/, "")))].sort();
249
+ function escapeRegExp(value) {
250
+ return value.replace(/[.*+?^[\]{}()|$]/g, "\\$&");
136
251
  }
137
- function classifyFixtureResolutionFailure(output) {
252
+ function testcasePythonPaths(output, layout) {
253
+ const root = (layout ?? resolveBackendTestLayout(undefined)).testRoot;
254
+ const pattern = new RegExp(`(?:${escapeRegExp(root)})/[A-Za-z0-9_./-]+\\.py`, "gi");
255
+ return [...new Set((output.replaceAll("\\", "/").match(pattern) ?? []).map((item) => item.replace(/:\d+$/, "")))].sort();
256
+ }
257
+ function classifyFixtureResolutionFailure(output, layout) {
258
+ const resolvedLayout = layout ?? resolveBackendTestLayout(undefined);
259
+ const rootPrefix = `${resolvedLayout.testRoot}/`;
138
260
  const normalized = output.replaceAll("\\", "/");
139
- const paths = testcasePythonPaths(normalized);
140
- const generatedProviderPaths = paths.filter((item) => /testcase\/(?:helpers|factories)\//.test(item));
261
+ const paths = testcasePythonPaths(normalized, resolvedLayout);
262
+ const generatedProviderPaths = paths.filter((item) => new RegExp(`^${escapeRegExp(rootPrefix)}(?:helpers|factories)/`).test(item) ||
263
+ new RegExp(`^${escapeRegExp(resolvedLayout.scriptDir)}/(?:helpers|factories)/`).test(item));
264
+ const generatedTestModulePaths = paths.filter((item) => new RegExp(`^${escapeRegExp(resolvedLayout.scriptDir)}(?:/[A-Za-z0-9_./-]+)?/test_[A-Za-z0-9_.-]+\\.py$`, "i").test(item) ||
265
+ new RegExp(`^${escapeRegExp(rootPrefix)}test_[A-Za-z0-9_.-]+\\.py$`, "i").test(item));
266
+ const onlyGeneratedTestcasePaths = paths.length > 0 && paths.every((item) => item.startsWith(rootPrefix));
267
+ const mentionsProduction = /(?:apps|src|server)\/[A-Za-z0-9_./-]+\.py/i.test(normalized) ||
268
+ new RegExp(`No module named ['\"](?!${escapeRegExp(resolvedLayout.testRoot)}(?:\\.|['\"]))`, "i").test(normalized);
141
269
  if (/fixture ['"][^'"]+['"] not found/i.test(normalized) && generatedProviderPaths.length > 0) {
142
270
  return {
143
271
  status: "REPAIRABLE",
@@ -146,6 +274,33 @@ function classifyFixtureResolutionFailure(output) {
146
274
  repairPaths: generatedProviderPaths,
147
275
  };
148
276
  }
277
+ // ScopeMismatch and other setup failures fully attributed to generated test modules are
278
+ // writer-local and may be fixed by one bounded repair (align scopes / define fixtures).
279
+ if (!mentionsProduction &&
280
+ onlyGeneratedTestcasePaths &&
281
+ generatedTestModulePaths.length > 0 &&
282
+ /ScopeMismatch|scope mismatch|function scoped fixture|module scoped request object|module scoped fixture|ERROR at setup of/i.test(normalized)) {
283
+ const isScope = /ScopeMismatch|scope mismatch|function scoped fixture|module scoped request object|module scoped fixture/i.test(normalized);
284
+ return {
285
+ status: "REPAIRABLE",
286
+ kind: isScope ? "generated-fixture-scope-mismatch" : "generated-fixture-setup-defect",
287
+ detail: isScope
288
+ ? "generated pytest fixture scopes are inconsistent inside mapped test modules"
289
+ : "generated pytest fixture setup failed inside mapped test modules",
290
+ repairPaths: [...new Set([...generatedTestModulePaths, ...generatedProviderPaths])].sort(),
291
+ };
292
+ }
293
+ if (!mentionsProduction &&
294
+ onlyGeneratedTestcasePaths &&
295
+ generatedTestModulePaths.length > 0 &&
296
+ /fixture ['"][^'"]+['"] not found/i.test(normalized)) {
297
+ return {
298
+ status: "REPAIRABLE",
299
+ kind: "missing-generated-fixture",
300
+ detail: "mapped pytest module requests a fixture not defined in generated testcase assets",
301
+ repairPaths: [...new Set([...generatedTestModulePaths, ...generatedProviderPaths])].sort(),
302
+ };
303
+ }
149
304
  return {
150
305
  status: "BLOCKED",
151
306
  kind: "unresolved-fixture-dependency",
@@ -199,6 +354,8 @@ export function assessBackendPytestCollection(input) {
199
354
  missingMappedScripts: [],
200
355
  assetFiles: input.inventory.assetFiles,
201
356
  inputHashes: input.inventory.inputHashes,
357
+ hazardOccurrenceCounts: input.inventory.hazardOccurrenceCounts,
358
+ hazardOccurrences: input.inventory.hazardOccurrences,
202
359
  pytestExitCode: input.exitCode,
203
360
  collectedItemCount: items.length,
204
361
  collectedItemIds: items,
@@ -227,6 +384,8 @@ export function assessBackendPytestCollection(input) {
227
384
  missingMappedScripts: [],
228
385
  assetFiles: input.inventory.assetFiles,
229
386
  inputHashes: input.inventory.inputHashes,
387
+ hazardOccurrenceCounts: input.inventory.hazardOccurrenceCounts,
388
+ hazardOccurrences: input.inventory.hazardOccurrences,
230
389
  pytestExitCode: input.exitCode,
231
390
  collectedItemCount: items.length,
232
391
  collectedItemIds: items,
@@ -259,6 +418,8 @@ export function assessBackendPytestCollection(input) {
259
418
  missingMappedScripts: [],
260
419
  assetFiles: input.inventory.assetFiles,
261
420
  inputHashes: input.inventory.inputHashes,
421
+ hazardOccurrenceCounts: input.inventory.hazardOccurrenceCounts,
422
+ hazardOccurrences: input.inventory.hazardOccurrences,
262
423
  pytestExitCode: input.exitCode,
263
424
  collectedItemCount: items.length,
264
425
  collectedItemIds: items,
@@ -294,6 +455,8 @@ export function assessPriorityOnlyBackendPytestModules(input) {
294
455
  missingMappedScripts: [],
295
456
  assetFiles: input.inventory.assetFiles,
296
457
  inputHashes: input.inventory.inputHashes,
458
+ hazardOccurrenceCounts: input.inventory.hazardOccurrenceCounts,
459
+ hazardOccurrences: input.inventory.hazardOccurrences,
297
460
  pytestExitCode: null,
298
461
  collectedItemCount: 0,
299
462
  collectedItemIds: [],
@@ -326,6 +489,8 @@ export function assessMissingBackendPytestScripts(input) {
326
489
  missingMappedScripts: [...input.missingMappedScripts],
327
490
  assetFiles: [...input.assetFiles],
328
491
  inputHashes: input.inputHashes,
492
+ hazardOccurrenceCounts: input.hazardOccurrenceCounts ?? Object.fromEntries(input.assetFiles.map((file) => [file, {}])),
493
+ hazardOccurrences: input.hazardOccurrences ?? Object.fromEntries(input.assetFiles.map((file) => [file, {}])),
329
494
  pytestExitCode: null,
330
495
  collectedItemCount: 0,
331
496
  collectedItemIds: [],
@@ -415,17 +580,21 @@ async function assertBackendPytestRepairSafety(workspaceRoot, initial, final) {
415
580
  if (JSON.stringify(initial.mappedScripts) !== JSON.stringify(final.mappedScripts)) {
416
581
  throw new Error("backend pytest repair changed mapped script scope");
417
582
  }
418
- const forbidden = [
419
- [/pytest\.mark\.(?:skip|skipif|xfail)\b|pytest\.(?:skip|xfail)\s*\(/, "skip/xfail"],
420
- [/sys\.path\s*\.|PYTHONPATH/, "sys.path/PYTHONPATH mutation"],
421
- [/except\s+ImportError\b/, "ImportError fallback"],
422
- [/except\s+(?:Exception|BaseException)\b[\s\S]{0,160}?\bpass\b/, "broad exception swallowing"],
423
- ];
424
583
  for (const file of final.assetFiles) {
425
584
  const source = await readFile(path.resolve(workspaceRoot, file), "utf8");
426
- for (const [pattern, label] of forbidden) {
427
- if (pattern.test(source))
428
- throw new Error(`backend pytest repair safety blocked ${label}: ${file}`);
585
+ const liveHash = sha256(source);
586
+ if (final.inputHashes[file] && final.inputHashes[file] !== liveHash) {
587
+ throw new Error(`backend pytest collection hash drift: ${file}`);
588
+ }
589
+ if (initial.inputHashes[file] === final.inputHashes[file])
590
+ continue;
591
+ const finalLists = scanRepairHazardOccurrenceLists(source);
592
+ for (const rule of REPAIR_HAZARD_PATTERNS) {
593
+ const baseline = initial.hazardOccurrences?.[file]?.[rule.label] ?? [];
594
+ const finalIdentities = finalLists[rule.label] ?? [];
595
+ if (hasMultisetIncrement(baseline, finalIdentities)) {
596
+ throw new Error(`backend pytest repair safety blocked ${rule.label}: ${file}`);
597
+ }
429
598
  }
430
599
  }
431
600
  }
@@ -439,23 +608,65 @@ function assertSameInventory(expected, actual) {
439
608
  throw new Error(`backend pytest collection hash drift: ${file}`);
440
609
  }
441
610
  }
611
+ export function buildBackendTestItemEligibility(collectedItemIds, input) {
612
+ const eligibleItemIds = [];
613
+ const excludedItems = [];
614
+ for (const itemId of collectedItemIds) {
615
+ const segments = itemId.split("::");
616
+ const symbolSegment = [...segments].reverse().find((segment) => /^test_/.test(segment));
617
+ const symbol = symbolSegment?.replace(/\[.*$/, "");
618
+ const parameterTokens = orderedUnique((itemId.match(/TP-[A-Z0-9-]+/g) ?? []));
619
+ const mapping = input.correspondenceEntries.find((entry) => symbol && (entry.declaredPrimarySymbol === symbol || entry.pytestSymbols.includes(symbol)));
620
+ const reasons = [];
621
+ if (!mapping)
622
+ reasons.push("no exact Markdown Case/primary-symbol correspondence");
623
+ if (mapping && mapping.status !== "EXACT_1_TO_1")
624
+ reasons.push(`correspondence status is ${mapping.status}`);
625
+ if (mapping && mapping.payloadAssessment.status !== "SAFE")
626
+ reasons.push(`payload contract status is ${mapping.payloadAssessment.status}`);
627
+ const scenarioEntries = mapping?.caseId ? input.scenarioEntries.filter((entry) => entry.caseId === mapping.caseId) : [];
628
+ const relevantScenarioEntries = parameterTokens.length > 0
629
+ ? scenarioEntries.filter((entry) => parameterTokens.includes(entry.tpId))
630
+ : scenarioEntries;
631
+ const payloadPaths = orderedUnique([
632
+ ...(mapping?.payloadAssessment.requiredPaths ?? []),
633
+ ...(mapping?.payloadAssessment.allowedPaths ?? []),
634
+ ...(mapping?.payloadAssessment.observedPaths ?? []),
635
+ ]).map((item) => item.toLowerCase());
636
+ for (const entry of relevantScenarioEntries) {
637
+ // Field names heuristically inferred only from a TP token (for example
638
+ // TP-HEALTH-HTTP-200 -> hEALTH) are not execution-safety evidence. Exclude
639
+ // only when the field is also present in the deterministic payload contract
640
+ // or observed Python payload shape.
641
+ const normalizedField = entry.field?.toLowerCase();
642
+ const fieldIsPayloadBound = Boolean(normalizedField && payloadPaths.some((item) => item === normalizedField || item.endsWith(`.${normalizedField}`)));
643
+ if (fieldIsPayloadBound && entry.status !== "MATCH")
644
+ reasons.push(`scenario-param ${entry.tpId} field ${entry.field} is ${entry.status}`);
645
+ }
646
+ if (reasons.length === 0)
647
+ eligibleItemIds.push(itemId);
648
+ else
649
+ excludedItems.push({ itemId, ...(mapping?.caseId ? { caseId: mapping.caseId } : {}), ...(symbol ? { symbol } : {}), reasons: orderedUnique(reasons) });
650
+ }
651
+ return { eligibleItemIds, excludedItems };
652
+ }
653
+ function orderedUnique(values) {
654
+ return [...new Set(values)];
655
+ }
442
656
  export async function materializeBackendTestExecutionReadiness(input) {
443
657
  if (input.effective.phase !== "effective" || input.effective.status !== "PASS" || input.effective.fixtureResolutionStatus !== "PASS") {
444
658
  throw new Error("backend-test execution readiness requires effective collection and fixture-resolution PASS");
445
659
  }
446
660
  const current = await buildBackendPytestAssetInventory(input.workspaceRoot, input.effective.mappedScripts);
447
- const priorityOnly = priorityOnlyBackendPytestScripts(current.assetFiles);
448
- if (priorityOnly.length > 0) {
449
- throw new Error(`priority-only-pytest-module: ${priorityOnly.join(", ")}`);
450
- }
451
661
  assertSameInventory(input.effective, current);
452
- const status = input.scenarioParamStatus === "FAIL"
662
+ const eligibility = input.eligibility ?? { eligibleItemIds: input.effective.collectedItemIds, excludedItems: [] };
663
+ const status = eligibility.eligibleItemIds.length === 0
453
664
  ? "BLOCKED"
454
- : input.scenarioParamStatus === "PASS"
665
+ : input.scenarioParamStatus === "PASS" && eligibility.excludedItems.length === 0
455
666
  ? "PASS"
456
667
  : "PARTIAL";
457
668
  const readiness = backendTestExecutionReadinessSchema.parse({
458
- schemaId: "backend-test-execution-readiness-v1",
669
+ schemaId: "backend-test-execution-readiness-v2",
459
670
  status,
460
671
  collectionStatus: "PASS",
461
672
  fixtureResolutionStatus: "PASS",
@@ -466,8 +677,11 @@ export async function materializeBackendTestExecutionReadiness(input) {
466
677
  },
467
678
  mappedScripts: input.effective.mappedScripts,
468
679
  collectedItemIds: input.effective.collectedItemIds,
680
+ eligibleItemIds: eligibility.eligibleItemIds,
681
+ excludedItems: eligibility.excludedItems,
469
682
  fixtureIssues: input.effective.findings.filter((item) => /fixture/i.test(item.kind)).map((item) => item.detail),
470
683
  assetHashes: input.effective.inputHashes,
684
+ eligibilityInputHashes: input.eligibilityInputHashes ?? {},
471
685
  });
472
686
  const contractsDir = path.join(input.runDir, "contracts");
473
687
  await mkdir(contractsDir, { recursive: true });
@@ -477,27 +691,145 @@ export async function materializeBackendTestExecutionReadiness(input) {
477
691
  export async function readBackendTestExecutionReadiness(filePath) {
478
692
  return backendTestExecutionReadinessSchema.parse(JSON.parse(await readFile(filePath, "utf8")));
479
693
  }
694
+ function formatReadinessCounts(readiness) {
695
+ return `mapped=${readiness.mappedScripts.length} collected=${readiness.collectedItemIds.length} eligible=${readiness.eligibleItemIds.length} excluded=${readiness.excludedItems.length}`;
696
+ }
697
+ export function classifyExclusionReason(reason) {
698
+ if (reason === "no exact Markdown Case/primary-symbol correspondence")
699
+ return "correspondence-missing";
700
+ if (/^correspondence status is\b/.test(reason))
701
+ return "correspondence-status";
702
+ if (/^payload contract status is\b/.test(reason))
703
+ return "payload-contract-status";
704
+ if (/^scenario-param\b.*\bfield\b/.test(reason))
705
+ return "scenario-param-field";
706
+ return "other";
707
+ }
708
+ /** Fixed-category tally for readiness Error messages — never echoes raw reason text. */
709
+ function tallyExclusionReasonCategories(excludedItems) {
710
+ const counts = new Map();
711
+ for (const item of excludedItems) {
712
+ for (const reason of item.reasons) {
713
+ const category = classifyExclusionReason(reason);
714
+ counts.set(category, (counts.get(category) ?? 0) + 1);
715
+ }
716
+ }
717
+ const parts = READINESS_EXCLUSION_CATEGORIES
718
+ .filter((category) => (counts.get(category) ?? 0) > 0)
719
+ .map((category) => `${category}=${counts.get(category)}`);
720
+ return parts.length > 0 ? parts.join("; ") : "none";
721
+ }
722
+ const READINESS_ERROR_TRUNCATION_SUFFIX = "...[truncated]";
723
+ export function boundReadinessErrorMessage(message) {
724
+ if (message.length <= MAX_READINESS_ERROR_CHARS)
725
+ return message;
726
+ // Truncate to leave room for the suffix so the final message never exceeds
727
+ // MAX_READINESS_ERROR_CHARS. Never append beyond the declared limit.
728
+ const limit = MAX_READINESS_ERROR_CHARS - READINESS_ERROR_TRUNCATION_SUFFIX.length;
729
+ return `${message.slice(0, Math.max(0, limit))}${READINESS_ERROR_TRUNCATION_SUFFIX}`;
730
+ }
731
+ function throwReadinessError(message) {
732
+ throw new Error(boundReadinessErrorMessage(message));
733
+ }
734
+ function firstInventoryDeltaPath(expectedHashes, actualHashes) {
735
+ const expectedFiles = Object.keys(expectedHashes).sort();
736
+ const actualFiles = Object.keys(actualHashes).sort();
737
+ const actualSet = new Set(actualFiles);
738
+ const expectedSet = new Set(expectedFiles);
739
+ const onlyExpected = expectedFiles.find((file) => !actualSet.has(file));
740
+ if (onlyExpected)
741
+ return onlyExpected;
742
+ return actualFiles.find((file) => !expectedSet.has(file));
743
+ }
744
+ async function firstUnreadableAssetPath(workspaceRoot, readiness) {
745
+ const candidatePaths = [...new Set([
746
+ ...Object.keys(readiness.assetHashes),
747
+ ...readiness.mappedScripts,
748
+ ])].sort();
749
+ for (const relativePath of candidatePaths) {
750
+ try {
751
+ await readFile(path.resolve(workspaceRoot, relativePath));
752
+ }
753
+ catch {
754
+ return relativePath;
755
+ }
756
+ }
757
+ return undefined;
758
+ }
480
759
  export async function assertBackendTestExecutionReadinessFresh(workspaceRoot, readiness) {
481
760
  if (!["PASS", "PARTIAL"].includes(readiness.status)) {
482
- throw new Error(`backend pytest execution readiness is ${readiness.status}`);
761
+ const counts = formatReadinessCounts(readiness);
762
+ const reasonTally = readiness.eligibleItemIds.length === 0 && readiness.excludedItems.length > 0
763
+ ? `; exclusionReasons: ${tallyExclusionReasonCategories(readiness.excludedItems)}`
764
+ : "";
765
+ throwReadinessError(`backend pytest execution readiness is ${readiness.status}: ${counts}${reasonTally}`);
766
+ }
767
+ if (readiness.eligibleItemIds.length === 0) {
768
+ throwReadinessError(`backend pytest execution readiness authorizes zero eligible items: ${tallyExclusionReasonCategories(readiness.excludedItems)}`);
769
+ }
770
+ const missingEligibleCount = readiness.eligibleItemIds.filter((itemId) => !readiness.collectedItemIds.includes(itemId)).length;
771
+ if (missingEligibleCount > 0) {
772
+ // Do not replay raw pytest item IDs: they may contain TP-*, field, payload,
773
+ // secret-like or parameter markers. Report only the fixed category + count.
774
+ throwReadinessError(`backend pytest readiness has ${missingEligibleCount} eligible item(s) absent from collected items: ${formatReadinessCounts(readiness)}`);
775
+ }
776
+ const unreadableAsset = await firstUnreadableAssetPath(workspaceRoot, readiness);
777
+ if (unreadableAsset) {
778
+ throwReadinessError(`backend pytest collection asset inventory drift: firstPath=${unreadableAsset}`);
779
+ }
780
+ let current;
781
+ try {
782
+ current = await buildBackendPytestAssetInventory(workspaceRoot, readiness.mappedScripts);
783
+ }
784
+ catch {
785
+ const fallbackPath = [...readiness.mappedScripts].sort()[0] ?? Object.keys(readiness.assetHashes).sort()[0] ?? "unknown";
786
+ throwReadinessError(`backend pytest collection asset inventory drift: firstPath=${fallbackPath}`);
483
787
  }
484
- const current = await buildBackendPytestAssetInventory(workspaceRoot, readiness.mappedScripts);
485
788
  const priorityOnly = priorityOnlyBackendPytestScripts(current.assetFiles);
486
789
  if (priorityOnly.length > 0) {
487
- throw new Error(`priority-only-pytest-module: ${priorityOnly.join(", ")}`);
790
+ throwReadinessError(`priority-only-pytest-module: ${priorityOnly.join(", ")}`);
791
+ }
792
+ const expectedHashes = readiness.assetHashes;
793
+ const inventoryDelta = firstInventoryDeltaPath(expectedHashes, current.inputHashes);
794
+ if (inventoryDelta) {
795
+ throwReadinessError(`backend pytest collection asset inventory drift: firstPath=${inventoryDelta}`);
796
+ }
797
+ for (const file of Object.keys(expectedHashes).sort()) {
798
+ if (expectedHashes[file] !== current.inputHashes[file]) {
799
+ throwReadinessError(`backend pytest collection hash drift: ${file}`);
800
+ }
801
+ }
802
+ for (const relativePath of Object.keys(readiness.eligibilityInputHashes).sort()) {
803
+ const expectedHash = readiness.eligibilityInputHashes[relativePath];
804
+ const absolute = path.resolve(workspaceRoot, relativePath);
805
+ let actualHash;
806
+ try {
807
+ actualHash = sha256(await readFile(absolute));
808
+ }
809
+ catch {
810
+ throwReadinessError(`backend pytest eligibility input hash drift: firstPath=${relativePath}`);
811
+ }
812
+ if (actualHash !== expectedHash) {
813
+ throwReadinessError(`backend pytest eligibility input hash drift: ${relativePath}`);
814
+ }
488
815
  }
489
- assertSameInventory({ mappedScripts: readiness.mappedScripts, assetFiles: Object.keys(readiness.assetHashes), inputHashes: readiness.assetHashes }, current);
490
816
  }
491
817
  export async function assertBackendPytestCollectionFresh(workspaceRoot, effective) {
492
818
  if (effective.phase !== "effective" || effective.status !== "PASS") {
493
819
  throw new Error("backend pytest execution requires effective collection PASS facts");
494
820
  }
495
821
  const current = await buildBackendPytestAssetInventory(workspaceRoot, effective.mappedScripts);
822
+ const priorityOnly = priorityOnlyBackendPytestScripts(current.assetFiles);
823
+ if (priorityOnly.length > 0)
824
+ throw new Error(`priority-only-pytest-module: ${priorityOnly.join(", ")}`);
496
825
  assertSameInventory(effective, current);
497
826
  }
498
827
  export async function materializeEffectiveBackendPytestCollection(input) {
499
828
  if (input.initial.status === "PASS") {
500
829
  const current = await buildBackendPytestAssetInventory(input.workspaceRoot, input.initial.mappedScripts);
830
+ const priorityOnly = priorityOnlyBackendPytestScripts(current.assetFiles);
831
+ if (priorityOnly.length > 0)
832
+ throw new Error(`priority-only-pytest-module: ${priorityOnly.join(", ")}`);
501
833
  assertSameInventory(input.initial, current);
502
834
  return backendPytestCollectionFactsSchema.parse({
503
835
  ...input.initial,
@@ -513,6 +845,9 @@ export async function materializeEffectiveBackendPytestCollection(input) {
513
845
  throw new Error("backend pytest collection still fails after bounded repair");
514
846
  await assertBackendPytestRepairSafety(input.workspaceRoot, input.initial, input.final);
515
847
  const current = await buildBackendPytestAssetInventory(input.workspaceRoot, input.final.mappedScripts);
848
+ const priorityOnly = priorityOnlyBackendPytestScripts(current.assetFiles);
849
+ if (priorityOnly.length > 0)
850
+ throw new Error(`priority-only-pytest-module: ${priorityOnly.join(", ")}`);
516
851
  assertSameInventory(input.final, current);
517
852
  return backendPytestCollectionFactsSchema.parse({
518
853
  ...input.final,
@@ -994,7 +994,7 @@ export async function materializeBackendTestResultFromRunDir(input) {
994
994
  const pytestExitCode = await readPytestExitCode(input.runDir, input.fromNodeId, input.junitRelativePath?.endsWith("-junit.xml")
995
995
  ? input.junitRelativePath.replace(/-junit\.xml$/, "-pytest-exit.txt")
996
996
  : "reports/backend-test-pytest-exit.txt");
997
- const commandSummary = `PYTHONUTF8=1 PYTHONIOENCODING=utf-8 PYTHONDONTWRITEBYTECODE=1 python -m pytest testcase/ -v -p no:cacheprovider --junitxml=${junitRelativePath}`;
997
+ const commandSummary = `PYTHONUTF8=1 PYTHONIOENCODING=utf-8 PYTHONDONTWRITEBYTECODE=1 python -m pytest ${input.testRoot ?? "testcase"}/ -v -p no:cacheprovider --junitxml=${junitRelativePath}`;
998
998
  let result;
999
999
  try {
1000
1000
  result = deriveBackendTestResult({