@tea-agent/loop-agent 0.38.0 → 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.
- package/CHANGELOG.md +191 -0
- package/dist/application/dag/generate-task-dag.js +9 -0
- package/dist/build-stamp.json +3 -3
- package/dist/commands/init-upgrade.js +97 -19
- package/dist/executors/dag-pi-executor.js +42 -4
- package/dist/executors/shell-executor.js +269 -15
- package/dist/infrastructure/harness/atomic-write.js +2 -1
- package/dist/shared/openspec-spec.js +76 -0
- package/dist/shared/operator/capabilities.js +149 -27
- package/dist/task/config-types.js +43 -0
- package/dist/task/frontend-project-capability.js +292 -38
- package/dist/worker/console/chat/deferred-turn.js +91 -0
- package/dist/worker/console/chat/mcp-inventory.js +221 -0
- package/dist/worker/console/chat/pi-runtime.js +165 -83
- package/dist/worker/console/chat/repo-walk.js +10 -1
- package/dist/worker/console/chat/routes.js +477 -55
- package/dist/worker/console/chat/semantic-activity.js +20 -8
- package/dist/worker/console/chat/session-store.js +146 -2
- package/dist/worker/console/chat/turn-process.js +17 -30
- package/dist/worker/console/harness-pi-model-matrix.js +137 -0
- package/dist/worker/console/operator-actions.js +128 -4
- package/dist/worker/console/operator-user-error.js +19 -0
- package/dist/worker/console/pi-readiness.js +8 -1
- package/dist/worker/console/recovery-cta.js +4 -4
- package/dist/worker/console/recovery-error-copy.js +4 -4
- package/dist/worker/console/security.js +20 -1
- package/dist/worker/console/server.js +6 -1
- package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-DSvF6RXQ.js → abnfDiagram-N423BO3Z-C4bmo3iY.js} +1 -1
- package/dist/worker/console/static/assets/{arc-DuI4ogtJ.js → arc-SAD7McOS.js} +1 -1
- package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-DdBbgD4y.js → architectureDiagram-T3A2C74G-DAVLUdK1.js} +1 -1
- package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-xQC2HELm.js → blockDiagram-VBNYF7ZC-CPPdv4vj.js} +1 -1
- package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DPMSgwIZ.js → c4Diagram-5PPSVZJV-D1GRGZax.js} +1 -1
- package/dist/worker/console/static/assets/channel-R6oQIDkw.js +1 -0
- package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-WTEKTxin.js → chunk-2GRJ4B5K-D_Bq2ZqK.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-B6vZ0Pv_.js → chunk-2Q5K7J3B-CPLVtamK.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5RXB4S5H-BrerNAix.js → chunk-5RXB4S5H-DnaZWcL3.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5VM5RSS4-D_ch2aaI.js → chunk-5VM5RSS4-BRTmFgnq.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-VvfacNrq.js → chunk-6Q2QTUOP-BRlfYZoH.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-GF5L2VYU-B-uGFIg2.js → chunk-GF5L2VYU-CjNybD4u.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-JWPE2WC7-BlysCtod.js → chunk-JWPE2WC7-D3e-74fG.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-KBJHAD2P-BjAUAfcT.js → chunk-KBJHAD2P-Br3ir6f2.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-RYQCIY6F-BeZnBmb-.js → chunk-RYQCIY6F-1NgVuRbh.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-XXDRQBXY-Bbfe3UJG.js → chunk-XXDRQBXY-BTYuvuWN.js} +1 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BPXlmPWI.js +1 -0
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BPXlmPWI.js +1 -0
- package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-BKJQnYVM.js → cose-bilkent-JH36ORCC-BC6Bgosf.js} +1 -1
- package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-B1wtI3hU.js → cynefin-VYW2F7L2-DV-HypfL.js} +1 -1
- package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-DCF6DBSu.js → cynefinDiagram-MW4NZA55-DhTaddO-.js} +1 -1
- package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BJyNGRlP.js → dagre-VZM6K2ZE-BnEcOmiI.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-7IWD3JNH-B7j1-VsZ.js → diagram-7IWD3JNH-xphGRVKp.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-BREJ140a.js → diagram-B4RE2ZJO-CrdD1mRG.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-LBJQPF4R-CDPHmHGH.js → diagram-LBJQPF4R-D4YdLsaF.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-Q27KOJAE-BtZLrez1.js → diagram-Q27KOJAE-LCKyGZ5U.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-UB23O5K3-mzybu8oU.js → diagram-UB23O5K3-BeiTe7t0.js} +1 -1
- package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-Dk3I2uO6.js → ebnfDiagram-BXEA7PRR-BO2cq8GS.js} +1 -1
- package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-BT97u_tQ.js → erDiagram-JOGREHBK-CmWww3Nt.js} +1 -1
- package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-CMOtYzui.js → flowDiagram-UKHOOZJN-C2kkUYg5.js} +1 -1
- package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-DiNydkuY.js → ganttDiagram-PKOTCBZU-DY_JZ2iQ.js} +1 -1
- package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Cwt6fLkl.js → gitGraphDiagram-DS77QQ5N-BizhCSw9.js} +1 -1
- package/dist/worker/console/static/assets/index-BXN89VPX.js +325 -0
- package/dist/worker/console/static/assets/index-CuH5CNFu.css +1 -0
- package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-AsGIGKKB.js → infoDiagram-6WML65LV-Dlo2eK_d.js} +1 -1
- package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-BL8ITZh3.js → ishikawaDiagram-WSZJBQD7-3EIlIIuZ.js} +1 -1
- package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-VPKAUX8M.js → journeyDiagram-NVQOT4AX-DiMDe5Yh.js} +1 -1
- package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-BL56hfGp.js → kanban-definition-27J2QSJJ-sN553oIN.js} +1 -1
- package/dist/worker/console/static/assets/{linear-BmSG3nIZ.js → linear-D9Py31Ld.js} +1 -1
- package/dist/worker/console/static/assets/{mermaid.core-DWGK9kwP.js → mermaid.core-D7Occzk_.js} +5 -5
- package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS--vvKLjo0.js → mindmap-definition-FAOFIHXS-DjDRboiR.js} +1 -1
- package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-B41mxb2-.js → pegDiagram-VL7TDLO6-Cgc4qlzp.js} +1 -1
- package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-DTlu77sw.js → pieDiagram-7S7Q4E2Y-DxZj8pKd.js} +1 -1
- package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-Bk5A7alv.js → quadrantDiagram-CIZ2JOQS-CJ50MngR.js} +1 -1
- package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-Csf9jzQy.js → railroadDiagram-AXF67PYL-BY-OD8Tr.js} +1 -1
- package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-CeptoSNx.js → requirementDiagram-LRYGKXZP-CQFButfK.js} +1 -1
- package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-A-DLCLer.js → sankeyDiagram-W5VNT64P-BB_QUK_b.js} +1 -1
- package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-22VbPdOI.js → sequenceDiagram-SI44F4Z6-CqhXZjyx.js} +1 -1
- package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-DWvAyBC9.js → sizeCapture-X5ZJPWSS-2l1U4rgJ.js} +1 -1
- package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-qICZ75yT.js → stateDiagram-OKZ733FA-BX1FbrhJ.js} +1 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DJKi0Qel.js +1 -0
- package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-xi7lcMyp.js → swimlanes-SLNWSIFB-DOSnT7hb.js} +2 -2
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-YYe7EEm5.js +8 -0
- package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-Bu6Nl6O2.js → timeline-definition-Z64GVDOM-DVJQA7AN.js} +1 -1
- package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-BzMz4Bn9.js → vennDiagram-T6HMQDX7-74uxl9gx.js} +1 -1
- package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CxaJnzc5.js → wardleyDiagram-T6FBY63Y-DsBxlcg_.js} +1 -1
- package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-5JK-omQK.js → xychartDiagram-ELKLHX3M-snuW9Mjw.js} +1 -1
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/app/console-types.js +39 -0
- package/dist/worker/console/static-src/app/useOperatorActions.js +1 -1
- package/dist/worker/console/static-src/app/useRecoveryActions.js +2 -2
- package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +82 -22
- package/dist/worker/console/static-src/operator-chat/mutation-gate.js +45 -0
- package/dist/worker/console/static-src/operator-chat/resource-diagnostics.js +204 -0
- package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +8 -1
- package/dist/worker/console/static-src/operator-chat/turn-submission.js +42 -0
- package/dist/worker/console/static-src/operator-chat/useChatSessions.js +18 -0
- package/dist/worker/console/static-src/operator-chat/useChatStream.js +216 -10
- package/dist/worker/console/static-src/operator-chat/useChatThread.js +50 -22
- package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +26 -1
- package/dist/workflows/dag/backend-test-case-coverage-analysis.js +755 -51
- package/dist/workflows/dag/backend-test-case-manifest.js +4 -0
- package/dist/workflows/dag/backend-test-execution-contract.js +18 -9
- package/dist/workflows/dag/backend-test-gap-fill.js +205 -0
- package/dist/workflows/dag/backend-test-intake-context.js +32 -0
- package/dist/workflows/dag/backend-test-layout.js +133 -0
- package/dist/workflows/dag/backend-test-markdown-workflow.js +167 -32
- package/dist/workflows/dag/backend-test-module-stem.js +5 -0
- package/dist/workflows/dag/backend-test-pytest-collection.js +368 -33
- package/dist/workflows/dag/backend-test-result-contract.js +1 -1
- package/dist/workflows/dag/backend-test-scenario-param.js +884 -140
- package/dist/workflows/dag/backend-test-scenario-partitions.js +249 -0
- package/dist/workflows/dag/backend-test-writer-completeness.js +100 -56
- package/dist/workflows/dag/contract-validator-registrations.js +3 -1
- package/dist/workflows/dag/convergence/controller.js +141 -0
- package/dist/workflows/dag/dynamic-runtime/map.js +24 -8
- package/dist/workflows/dag/dynamic-runtime/shared.js +1 -1
- package/dist/workflows/dag/failure-routing.js +9 -4
- package/dist/workflows/dag/frontend-implementation-contract.js +386 -0
- package/dist/workflows/dag/frontend-plan-render.js +90 -0
- package/dist/workflows/dag/frontend-prewrite-gate.js +479 -28
- package/dist/workflows/dag/init-hybrid.js +920 -110
- package/dist/workflows/dag/lifecycle.js +4 -0
- package/dist/workflows/dag/node-execution.js +77 -8
- package/dist/workflows/dag/prompt.js +45 -0
- package/dist/workflows/dag/report.js +6 -0
- package/dist/workflows/dag/rerun-feedback.js +212 -0
- package/dist/workflows/dag/rerun-plan.js +93 -27
- package/dist/workflows/dag/rerun-run.js +3 -0
- package/dist/workflows/dag/rerun-task.js +61 -2
- package/dist/workflows/dag/retry-policy.js +33 -0
- package/dist/workflows/dag/types.js +148 -4
- package/dist/workflows/dag/validate.js +7 -2
- package/docs/templates/backend-test-dag.json +60 -35
- package/docs/templates/frontend-design-contract.md +40 -14
- package/docs/templates/frontend-implementation-contract.schema.json +9 -2
- package/docs/templates/frontend-task-constraints.md +40 -13
- package/docs/templates/frontend-task-requirement.md +54 -20
- package/harness.json +3 -1
- package/package.json +1 -1
- package/skills/frontend-design-review/references/review-checklist.md +2 -0
- package/skills/frontend-implementation/references/design-spec.md +16 -19
- package/skills/frontend-implementation/references/node-contracts.md +15 -11
- package/dist/worker/console/static/assets/channel-DLVz9TYW.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BeAuGcO9.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BeAuGcO9.js +0 -1
- package/dist/worker/console/static/assets/index-DQxiso1w.js +0 -325
- package/dist/worker/console/static/assets/index-_j0Hzo69.css +0 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DzKvfHTX.js +0 -1
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Cxqr_qM1.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-
|
|
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 (
|
|
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,
|
|
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
|
|
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,
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
135
|
-
return
|
|
249
|
+
function escapeRegExp(value) {
|
|
250
|
+
return value.replace(/[.*+?^[\]{}()|$]/g, "\\$&");
|
|
136
251
|
}
|
|
137
|
-
function
|
|
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) =>
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|