@tea-agent/loop-agent 0.40.0-next.10 → 0.40.0-next.11
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 +8 -0
- package/dist/application/dag/generate-task-dag.js +6 -2
- package/dist/build-stamp.json +3 -3
- package/dist/executors/shell-executor.js +64 -17
- package/dist/governance/checks.js +1 -0
- package/dist/worker/console/dag-execution-receipt.js +20 -2
- package/dist/worker/console/operator-actions.js +4 -3
- package/dist/workflows/dag/backend-test-case-coverage-analysis.js +157 -7
- package/dist/workflows/dag/backend-test-pytest-collection.js +70 -2
- package/dist/workflows/dag/backend-test-result-contract.js +4 -0
- package/dist/workflows/dag/backend-test-scenario-param.js +339 -53
- package/dist/workflows/dag/backend-test-writer-completeness.js +11 -0
- package/dist/workflows/dag/init-hybrid.js +1 -1
- package/docs/templates/backend-test-dag.json +3 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -28,6 +28,14 @@
|
|
|
28
28
|
|
|
29
29
|
### 修复
|
|
30
30
|
|
|
31
|
+
- 修复文档审计扫描本地 `.scratch/` 临时证据与打包目录、导致仓库 preflight 和发布前验证被非治理文件阻断的问题。
|
|
32
|
+
- 修复 backend-test 把 Markdown 明确声明的 enum-invalid、wrong-type、null、empty 和精确非法 custom literal 当作 payload enum 契约漂移、错误消耗唯一一次 pytest repair 的问题;豁免现在按 pytest parameter ID、目标字段和值逐 TP 绑定,positive/nominal drift 与 path 缺陷仍严格阻断。
|
|
33
|
+
- 修复 backend-test 无法从同一生成模块的安全 payload helper 中绑定 field-target positional/keyword/default 参数,以及无法识别精确 `_without(payload, field)` 删除语义的问题;computed、动态、二层、跨模块和危险 helper 仍保持 `UNDETERMINED`。
|
|
34
|
+
- 修复 backend-test 无法静态证明同一生成模块内安全 payload helper、导致 request-level nominal 场景长期停留在 `UNDETERMINED` 的问题;超长边界 observed 现在持久化为有界摘要并保留真实长度,空白修复建议也明确使用真实空格而非字面 escape 文本。
|
|
35
|
+
- 修复 backend-test 把 Markdown 中 `\\u0020`/`\\x20` 空白表示与 pytest 真实空白字符串误判为 scenario-param 不一致的问题;readiness 的逐项排除现在携带有界的 TP、字段、expected/observed raw/normalized 和源码位置诊断,同时不对普通业务字符做 trim、大小写折叠或宽松解码。
|
|
36
|
+
- 修复 Console 把 backend-test writer 的局部保护文件误判为整个 writeSet 越界的问题;任务级禁止路径仍保持硬阻断,合法 DAG 可继续取得 autonomous execution receipt。
|
|
37
|
+
- 修复 backend-test 对 `resource_client.call(..., payload=...)` 一类本地 transport 的 payload AST 假阴性;readiness 新增固定分类/Case 排除汇总和 pytest 启动事实,payload repair finding 同时携带 expected/observed/missing path 清单。
|
|
38
|
+
- 修复后端测试在 payload、fixture 与 Markdown→pytest 对应关系同时异常时只修复其中一类问题的问题;pytest 侧缺陷现在通过结构化 `repairFindings` 合并进入唯一一次有界 repair,Markdown Test Point 未分类或重复分类则在生成阶段定向重试并禁止下游猜修。
|
|
31
39
|
- 修复重命名会话时输入框没有回显当前标题的问题。
|
|
32
40
|
- 修复停止回复后输入区恢复迟缓、排队消息偶发丢失,以及切换会话后旧状态残留的问题。
|
|
33
41
|
- 修复宽表格、文件预览、滚动条和窄屏布局中的多处显示异常。
|
|
@@ -109,8 +109,12 @@ function findForbiddenOverlapsForPacket(task) {
|
|
|
109
109
|
const overlaps = [];
|
|
110
110
|
for (const writeSetEntry of task.writeSet ?? []) {
|
|
111
111
|
for (const forbiddenPath of task.forbiddenPaths ?? []) {
|
|
112
|
-
|
|
113
|
-
|
|
112
|
+
// A narrower forbidden child may intentionally carve a protected
|
|
113
|
+
// file out of a broader node writeSet. The runtime write guard gives
|
|
114
|
+
// forbiddenPaths precedence; report a packet conflict only when the
|
|
115
|
+
// writeSet entry itself is inside the forbidden boundary. Console G2
|
|
116
|
+
// separately rechecks task-global forbidden paths with live scope.
|
|
117
|
+
if (pathMatchesPattern(writeSetEntry, forbiddenPath)) {
|
|
114
118
|
overlaps.push({ writeSetEntry, forbiddenPath });
|
|
115
119
|
}
|
|
116
120
|
}
|
package/dist/build-stamp.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"version": "0.40.0-next.
|
|
4
|
-
"gitSha": "
|
|
5
|
-
"builtAt": "2026-08-
|
|
3
|
+
"version": "0.40.0-next.11",
|
|
4
|
+
"gitSha": "a23cf8d31616835f9fa670d7135ee4a5bd84feba",
|
|
5
|
+
"builtAt": "2026-08-28T16:52:35.690Z"
|
|
6
6
|
}
|
|
@@ -32,11 +32,11 @@ import { materializeBackendTestExecutionContract } from "../workflows/dag/backen
|
|
|
32
32
|
import { resolveBackendTestLayout } from "../workflows/dag/backend-test-layout.js";
|
|
33
33
|
import { frontendTestLayoutFromSpec, } from "../workflows/dag/frontend-test-layout.js";
|
|
34
34
|
import { backendTestGapDocSchema, ingestBackendTestGap, } from "../workflows/dag/backend-test-gap-fill.js";
|
|
35
|
-
import { analyzeBackendTestCaseCoverage, analyzeBackendTestMarkdownPytestCorrespondence, materializeBackendTestCaseManifestFromFacts, } from "../workflows/dag/backend-test-case-coverage-analysis.js";
|
|
35
|
+
import { analyzeBackendTestCaseCoverage, analyzeBackendTestMarkdownPytestCorrespondence, classifyBackendTestCorrespondenceRepair, materializeBackendTestCaseManifestFromFacts, } from "../workflows/dag/backend-test-case-coverage-analysis.js";
|
|
36
36
|
import { materializeBackendTestResultFromPytestHtml, materializeBackendTestResultFromRunDir, parsePytestHtmlReport, } from "../workflows/dag/backend-test-result-contract.js";
|
|
37
37
|
import { collectBackendTestHumanCaseCatalog, collectBackendTestMappedPytestScripts, resolveBackendTestMappedPytestScripts, collectJacocoCoverage, hasBlockingBackendMarkdownSafetyFindings, hasBlockingBackendMarkdownModuleStemFindings, inspectBackendTestEnvironment, markdownFiles, requiredBackendMarkdownCaseAcIds, renderBackendTestFacts, renderBackendTestHtml, renderBackendTestL5Dashboard, redactBackendTestOutput, validateBackendMarkdownCases, validateBackendMarkdownTraceability, writeRunReport, } from "../workflows/dag/backend-test-markdown-workflow.js";
|
|
38
38
|
import { applyDeterministicScenarioParamRepairs, assessBackendScenarioParamConsistency, classifyBackendTestFailureWithScenarioParam, readBackendScenarioParamFacts, renderBackendTestFailureAnalysis, writeBackendScenarioParamArtifacts, writeScenarioParamRepairAudit, } from "../workflows/dag/backend-test-scenario-param.js";
|
|
39
|
-
import { assessBackendPytestCollection, assessMissingBackendPytestScripts, assessPriorityOnlyBackendPytestModules, assertBackendTestExecutionReadinessFresh, buildBackendPytestAssetInventory, buildBackendTestItemEligibility, materializeBackendTestExecutionReadiness, materializeEffectiveBackendPytestCollection, readBackendPytestCollectionFacts, readBackendTestExecutionReadiness, writeBackendPytestCollectionArtifacts, } from "../workflows/dag/backend-test-pytest-collection.js";
|
|
39
|
+
import { assessBackendPytestCollection, assessMissingBackendPytestScripts, assessPriorityOnlyBackendPytestModules, assertBackendTestExecutionReadinessFresh, buildBackendPytestAssetInventory, buildBackendTestItemEligibility, materializeBackendTestExecutionReadiness, materializeEffectiveBackendPytestCollection, readBackendPytestCollectionFacts, readBackendTestExecutionReadiness, summarizeBackendTestExclusions, writeBackendPytestCollectionArtifacts, } from "../workflows/dag/backend-test-pytest-collection.js";
|
|
40
40
|
import { computeL5ReportMetrics } from "../workflows/dag/l5-report-metrics.js";
|
|
41
41
|
import { buildBackendTestCanonicalResultFromInitialShellSnippet, materializeBackendTestClassification, } from "../workflows/dag/backend-test-classification-contract.js";
|
|
42
42
|
import { backendTestSemanticReviewSchema, materializeBackendTestSemanticReview, } from "../workflows/dag/backend-test-semantic-review-contract.js";
|
|
@@ -870,26 +870,49 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
870
870
|
const correspondenceFactsPath = path.join(contractsDir, "backend-test-markdown-pytest-correspondence-initial.json");
|
|
871
871
|
await writeFile(correspondenceFactsPath, JSON.stringify(correspondence.facts, null, 2), "utf8");
|
|
872
872
|
outputs.push(`initialCorrespondence=${correspondenceReportPath}`, `initialCorrespondenceFacts=${correspondenceFactsPath}`);
|
|
873
|
-
if (correspondence.facts.status === "FAIL"
|
|
874
|
-
const
|
|
875
|
-
|
|
876
|
-
.
|
|
877
|
-
.
|
|
878
|
-
|
|
879
|
-
facts.status = "REPAIRABLE";
|
|
880
|
-
facts.repairEligible = true;
|
|
881
|
-
facts.repairPaths = Array.from(new Set([...facts.repairPaths, ...correspondenceRepairPaths])).sort();
|
|
873
|
+
if (correspondence.facts.status === "FAIL") {
|
|
874
|
+
const repair = classifyBackendTestCorrespondenceRepair(correspondence.facts, backendLayout);
|
|
875
|
+
if (repair.markdownBlockingEntries.length > 0) {
|
|
876
|
+
facts.status = "BLOCKED";
|
|
877
|
+
facts.repairEligible = false;
|
|
878
|
+
facts.repairPaths = [];
|
|
882
879
|
facts.findings.push({
|
|
883
|
-
kind: "markdown-
|
|
880
|
+
kind: "markdown-correspondence-contract",
|
|
884
881
|
classification: "test-asset-defect",
|
|
885
|
-
repairability: "
|
|
886
|
-
detail:
|
|
887
|
-
.
|
|
888
|
-
.flatMap((entry) => entry.findings)
|
|
882
|
+
repairability: "blocked",
|
|
883
|
+
detail: repair.markdownBlockingEntries
|
|
884
|
+
.map((entry) => `${entry.caseId ?? "unknown Case"}: ${entry.reasonCodes.join(", ")}`)
|
|
889
885
|
.join("; ")
|
|
890
|
-
.slice(0, 12_000)
|
|
886
|
+
.slice(0, 12_000),
|
|
891
887
|
});
|
|
892
888
|
}
|
|
889
|
+
else if (repair.pytestRepairPaths.length > 0 &&
|
|
890
|
+
(facts.status === "PASS" || facts.status === "REPAIRABLE")) {
|
|
891
|
+
facts.status = "REPAIRABLE";
|
|
892
|
+
facts.repairEligible = true;
|
|
893
|
+
facts.repairPaths = Array.from(new Set([...facts.repairPaths, ...repair.pytestRepairPaths])).sort();
|
|
894
|
+
const repairEntries = correspondence.facts.entries.filter((entry) => repair.pytestRepairEntries.some((candidate) => candidate.caseId === entry.caseId));
|
|
895
|
+
for (const entry of repairEntries.slice(0, 40)) {
|
|
896
|
+
const classified = repair.pytestRepairEntries.find((candidate) => candidate.caseId === entry.caseId);
|
|
897
|
+
facts.findings.push({
|
|
898
|
+
kind: "markdown-pytest-correspondence",
|
|
899
|
+
classification: "test-asset-defect",
|
|
900
|
+
repairability: "repairable",
|
|
901
|
+
detail: entry.findings.join("; ").slice(0, 2_000) || "Markdown-to-pytest correspondence is incomplete",
|
|
902
|
+
...(entry.caseId ? { caseId: entry.caseId } : {}),
|
|
903
|
+
reasonCodes: classified?.reasonCodes ?? [],
|
|
904
|
+
repairPaths: classified?.paths ?? [],
|
|
905
|
+
payloadExpectedPaths: Array.from(new Set([
|
|
906
|
+
...entry.payloadAssessment.requiredPaths,
|
|
907
|
+
...entry.payloadAssessment.allowedPaths,
|
|
908
|
+
])).sort(),
|
|
909
|
+
payloadObservedPaths: entry.payloadAssessment.observedPaths,
|
|
910
|
+
payloadMissingPaths: entry.payloadAssessment.missingPaths,
|
|
911
|
+
payloadUnexpectedPaths: entry.payloadAssessment.unexpectedPaths,
|
|
912
|
+
payloadEnumMismatches: entry.payloadAssessment.enumMismatches,
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
}
|
|
893
916
|
}
|
|
894
917
|
}
|
|
895
918
|
catch (error) {
|
|
@@ -912,6 +935,19 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
912
935
|
collectedItemCount: facts.collectedItemCount,
|
|
913
936
|
missingMappedScripts: facts.missingMappedScripts,
|
|
914
937
|
repairPaths: facts.repairPaths,
|
|
938
|
+
repairFindings: facts.findings.map((finding) => ({
|
|
939
|
+
kind: finding.kind,
|
|
940
|
+
repairability: finding.repairability,
|
|
941
|
+
detail: finding.detail,
|
|
942
|
+
...(finding.caseId ? { caseId: finding.caseId } : {}),
|
|
943
|
+
...(finding.reasonCodes ? { reasonCodes: finding.reasonCodes } : {}),
|
|
944
|
+
...(finding.repairPaths ? { repairPaths: finding.repairPaths } : {}),
|
|
945
|
+
...(finding.payloadExpectedPaths ? { payloadExpectedPaths: finding.payloadExpectedPaths } : {}),
|
|
946
|
+
...(finding.payloadObservedPaths ? { payloadObservedPaths: finding.payloadObservedPaths } : {}),
|
|
947
|
+
...(finding.payloadMissingPaths ? { payloadMissingPaths: finding.payloadMissingPaths } : {}),
|
|
948
|
+
...(finding.payloadUnexpectedPaths ? { payloadUnexpectedPaths: finding.payloadUnexpectedPaths } : {}),
|
|
949
|
+
...(finding.payloadEnumMismatches ? { payloadEnumMismatches: finding.payloadEnumMismatches } : {}),
|
|
950
|
+
})),
|
|
915
951
|
factsPath: "contracts/backend-test-pytest-collection-initial.json",
|
|
916
952
|
}),
|
|
917
953
|
stderr: "",
|
|
@@ -1011,6 +1047,7 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
1011
1047
|
})
|
|
1012
1048
|
: { eligibleItemIds: effective.collectedItemIds, excludedItems: [] };
|
|
1013
1049
|
const eligibilityInputHashes = Object.fromEntries((correspondence?.facts.inputFiles ?? []).map((item) => [item.path, item.sha256]));
|
|
1050
|
+
const exclusionSummary = summarizeBackendTestExclusions(eligibility.excludedItems);
|
|
1014
1051
|
const eligibilityFactsPath = path.join(meta.runDir, "contracts", "backend-test-execution-eligibility.json");
|
|
1015
1052
|
await mkdir(path.dirname(eligibilityFactsPath), { recursive: true });
|
|
1016
1053
|
await writeFile(eligibilityFactsPath, `${JSON.stringify({
|
|
@@ -1021,6 +1058,9 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
1021
1058
|
excludedItemCount: eligibility.excludedItems.length,
|
|
1022
1059
|
eligibleItemIds: eligibility.eligibleItemIds,
|
|
1023
1060
|
excludedItems: eligibility.excludedItems,
|
|
1061
|
+
exclusionSummary,
|
|
1062
|
+
pytestSpawned: false,
|
|
1063
|
+
businessTestBodyExecutedCount: 0,
|
|
1024
1064
|
inputHashes: eligibilityInputHashes,
|
|
1025
1065
|
}, null, 2)}\n`, "utf8");
|
|
1026
1066
|
const eligibilityReportPath = await writeRunReport(meta.runDir, "backend-test-execution-eligibility.md", [
|
|
@@ -1033,6 +1073,10 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
1033
1073
|
`- Collected items: ${effective.collectedItemIds.length}`,
|
|
1034
1074
|
`- Eligible items: ${eligibility.eligibleItemIds.length}`,
|
|
1035
1075
|
`- Excluded items: ${eligibility.excludedItems.length}`,
|
|
1076
|
+
`- Excluded Cases: ${exclusionSummary.excludedCaseCount}`,
|
|
1077
|
+
`- Exclusion categories: ${Object.entries(exclusionSummary.excludedByReason).map(([category, count]) => `${category}=${count}`).join("; ") || "none"}`,
|
|
1078
|
+
"- pytest spawned: false",
|
|
1079
|
+
"- Business test bodies executed: 0",
|
|
1036
1080
|
"",
|
|
1037
1081
|
"## Excluded Items",
|
|
1038
1082
|
"",
|
|
@@ -1061,6 +1105,9 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
1061
1105
|
executionReadinessStatus: readiness.status,
|
|
1062
1106
|
eligibleItemCount: readiness.eligibleItemIds.length,
|
|
1063
1107
|
excludedItemCount: readiness.excludedItems.length,
|
|
1108
|
+
exclusionSummary: readiness.exclusionSummary,
|
|
1109
|
+
pytestSpawned: readiness.pytestSpawned,
|
|
1110
|
+
businessTestBodyExecutedCount: readiness.businessTestBodyExecutedCount,
|
|
1064
1111
|
factsPath: "contracts/backend-test-pytest-collection-effective.json",
|
|
1065
1112
|
readinessPath: "contracts/backend-test-execution-readiness.json",
|
|
1066
1113
|
reportPath: artifacts.reportPath,
|
|
@@ -176,8 +176,26 @@ export function deriveEligibilityFactsFromDagSpec(input) {
|
|
|
176
176
|
}));
|
|
177
177
|
const allWriteSetEntries = writers.flatMap((writer) => writer.writeSet);
|
|
178
178
|
const broadWriteSetRisk = allWriteSetEntries.some(isBroadWriteSetEntry);
|
|
179
|
-
const
|
|
180
|
-
|
|
179
|
+
const taskForbidden = input.forbiddenPaths.map(normalizePathEntry);
|
|
180
|
+
const forbiddenOverlapRisk = tasks.some((task) => stringList(task.writeSet).some((entry) => {
|
|
181
|
+
const normalizedEntry = normalizePathEntry(entry);
|
|
182
|
+
return stringList(task.forbiddenPaths).some((forbidden) => {
|
|
183
|
+
const normalizedForbidden = normalizePathEntry(forbidden);
|
|
184
|
+
const isTaskGlobal = taskForbidden.includes(normalizedForbidden);
|
|
185
|
+
if (isTaskGlobal) {
|
|
186
|
+
// Task-level forbidden paths are hard global boundaries. A broad
|
|
187
|
+
// writer that could include one is not autonomously eligible.
|
|
188
|
+
return (pathMatchesPattern(normalizedEntry, normalizedForbidden) ||
|
|
189
|
+
pathMatchesPattern(normalizedForbidden, normalizedEntry));
|
|
190
|
+
}
|
|
191
|
+
// Node-local forbidden paths may intentionally carve a narrower
|
|
192
|
+
// child (for example md/README.md) out of a broader writer
|
|
193
|
+
// writeSet. That exclusion is enforced by the runtime write guard.
|
|
194
|
+
// It is a conflict only when the writer entry itself is wholly
|
|
195
|
+
// inside the forbidden boundary.
|
|
196
|
+
return pathMatchesPattern(normalizedEntry, normalizedForbidden);
|
|
197
|
+
});
|
|
198
|
+
}));
|
|
181
199
|
const hasStructuredVerification = tasks.some((task) => {
|
|
182
200
|
if (task.executor !== "shell" || !task.shell)
|
|
183
201
|
return false;
|
|
@@ -1641,9 +1641,10 @@ export async function dispatchOperatorAction(ctx, req) {
|
|
|
1641
1641
|
broadWriteSetRisk: typeof reviewPacket.broadWriteSetRisk === "boolean"
|
|
1642
1642
|
? reviewPacket.broadWriteSetRisk
|
|
1643
1643
|
: derivedFacts.broadWriteSetRisk,
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1644
|
+
// Always use the staged DAG + live task boundary derivation here.
|
|
1645
|
+
// The generation review packet lacks task-boundary scope and can
|
|
1646
|
+
// mistake a narrower node-local exclusion for a global overlap.
|
|
1647
|
+
forbiddenOverlapRisk: derivedFacts.forbiddenOverlapRisk,
|
|
1647
1648
|
hasStructuredVerification: packetShellVerification,
|
|
1648
1649
|
};
|
|
1649
1650
|
const assessment = assessAutonomousExecutionEligibility(eligibility);
|
|
@@ -8,6 +8,7 @@ import { resolveBackendTestLayout, } from "./backend-test-layout.js";
|
|
|
8
8
|
import { resolveBackendTestManifestModules, backendManifestStatusFinding } from "./backend-test-markdown-workflow.js";
|
|
9
9
|
import { applyOpenApiPartitionDomainPolicy, assessScenarioPartitionCoverage, parseScenarioPartitions, } from "./backend-test-scenario-partitions.js";
|
|
10
10
|
import { backendTestCaseManifestSchema, computeCaseManifestCoverageSummary, } from "./backend-test-case-manifest.js";
|
|
11
|
+
import { inferScenarioParamIntent } from "./backend-test-scenario-param.js";
|
|
11
12
|
const CASE_HEADING = /^##\s+(BE-[A-Z0-9_-]+-\d{3})\b.*$/gm;
|
|
12
13
|
const CASE_ID_IN_TEXT = /\bBE-[A-Z0-9_-]+-\d{3}\b/g;
|
|
13
14
|
const NON_CANONICAL_CASE_HEADING = /^##\s+(BE-[A-Z0-9_-]+-(?:\d{2}|\d{2,3}[A-Z]+))\b.*$/gm;
|
|
@@ -281,6 +282,80 @@ export const backendTestMarkdownPytestCorrespondenceFactsSchema = z.object({
|
|
|
281
282
|
entries: z.array(correspondenceEntrySchema),
|
|
282
283
|
findings: z.array(z.string()),
|
|
283
284
|
}).strict();
|
|
285
|
+
function bindingCounts(entry) {
|
|
286
|
+
const counts = new Map();
|
|
287
|
+
for (const point of [...entry.variantTestPoints, ...entry.assertionTestPoints, ...entry.crossCuttingTestPoints]) {
|
|
288
|
+
counts.set(point, (counts.get(point) ?? 0) + 1);
|
|
289
|
+
}
|
|
290
|
+
return counts;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Classify correspondence defects by the only writer that can safely repair
|
|
294
|
+
* them. Markdown-owned contract defects must be fixed before pytest generation;
|
|
295
|
+
* pytest-owned generated-asset defects may enter the single bounded N11 repair.
|
|
296
|
+
*/
|
|
297
|
+
export function classifyBackendTestCorrespondenceRepair(facts, layout) {
|
|
298
|
+
const resolved = layout ?? resolveBackendTestLayout(undefined);
|
|
299
|
+
const scriptRoot = resolved.scriptDir.replace(/\/$/, "");
|
|
300
|
+
const safePytestPath = (candidate) => {
|
|
301
|
+
const normalized = candidate.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
302
|
+
return normalized.endsWith(".py") && (normalized === scriptRoot || normalized.startsWith(`${scriptRoot}/`));
|
|
303
|
+
};
|
|
304
|
+
const markdownBlockingEntries = [];
|
|
305
|
+
const pytestRepairEntries = [];
|
|
306
|
+
for (const entry of facts.entries) {
|
|
307
|
+
const markdownReasons = [];
|
|
308
|
+
const pytestReasons = [];
|
|
309
|
+
const counts = bindingCounts(entry);
|
|
310
|
+
if (entry.testPoints.some((point) => (counts.get(point) ?? 0) !== 1) ||
|
|
311
|
+
[...counts.keys()].some((point) => !entry.testPoints.includes(point))) {
|
|
312
|
+
markdownReasons.push("markdown-test-point-binding-invalid");
|
|
313
|
+
}
|
|
314
|
+
if (entry.declaredScript && entry.declaredScript !== entry.expectedScript) {
|
|
315
|
+
markdownReasons.push("markdown-script-declaration-invalid");
|
|
316
|
+
}
|
|
317
|
+
if (entry.cardinality === "1:0")
|
|
318
|
+
pytestReasons.push("missing-pytest-symbol");
|
|
319
|
+
if (entry.cardinality === "1:N")
|
|
320
|
+
pytestReasons.push("multiple-pytest-symbols");
|
|
321
|
+
if (entry.cardinality === "0:1")
|
|
322
|
+
pytestReasons.push("extra-pytest-symbol");
|
|
323
|
+
if (entry.actualScripts.some((script) => script !== entry.expectedScript))
|
|
324
|
+
pytestReasons.push("pytest-script-mismatch");
|
|
325
|
+
if (entry.declaredPrimarySymbol && entry.pytestSymbols.some((symbol) => symbol !== entry.declaredPrimarySymbol)) {
|
|
326
|
+
pytestReasons.push("pytest-primary-symbol-mismatch");
|
|
327
|
+
}
|
|
328
|
+
if (entry.variantTestPoints.some((point) => !entry.parameterIds.includes(point)))
|
|
329
|
+
pytestReasons.push("variant-parameter-missing");
|
|
330
|
+
if (entry.assertionTestPoints.some((point) => !entry.mappedTestPoints.includes(point)))
|
|
331
|
+
pytestReasons.push("assertion-binding-missing");
|
|
332
|
+
if (entry.crossCuttingTestPoints.some((point) => !entry.mappedTestPoints.includes(point)))
|
|
333
|
+
pytestReasons.push("cross-cutting-binding-missing");
|
|
334
|
+
if (entry.parameterIds.some((point) => !entry.testPoints.includes(point)))
|
|
335
|
+
pytestReasons.push("pytest-test-point-binding-extra");
|
|
336
|
+
if (entry.payloadAssessment.status === "UNSAFE")
|
|
337
|
+
pytestReasons.push("pytest-payload-shape-mismatch");
|
|
338
|
+
if (entry.status !== "EXACT_1_TO_1" &&
|
|
339
|
+
!["TEST_POINT_BINDING_DUPLICATE", "PAYLOAD_CONTRACT_UNAVAILABLE"].includes(entry.status)) {
|
|
340
|
+
pytestReasons.push(`correspondence-${entry.status.toLowerCase().replaceAll("_", "-")}`);
|
|
341
|
+
}
|
|
342
|
+
if (entry.status === "PAYLOAD_CONTRACT_UNAVAILABLE") {
|
|
343
|
+
markdownReasons.push("markdown-payload-contract-unavailable");
|
|
344
|
+
}
|
|
345
|
+
const paths = orderedUnique([entry.expectedScript, ...entry.actualScripts].filter(safePytestPath));
|
|
346
|
+
if (markdownReasons.length > 0) {
|
|
347
|
+
markdownBlockingEntries.push({ ...(entry.caseId ? { caseId: entry.caseId } : {}), reasonCodes: orderedUnique(markdownReasons), paths });
|
|
348
|
+
}
|
|
349
|
+
if (pytestReasons.length > 0 && paths.length > 0) {
|
|
350
|
+
pytestRepairEntries.push({ ...(entry.caseId ? { caseId: entry.caseId } : {}), reasonCodes: orderedUnique(pytestReasons), paths });
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return {
|
|
354
|
+
markdownBlockingEntries,
|
|
355
|
+
pytestRepairEntries,
|
|
356
|
+
pytestRepairPaths: orderedUnique(pytestRepairEntries.flatMap((entry) => entry.paths)).sort(),
|
|
357
|
+
};
|
|
358
|
+
}
|
|
284
359
|
function isRecord(value) {
|
|
285
360
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
286
361
|
}
|
|
@@ -593,6 +668,19 @@ function automationLabelBody(body, labels) {
|
|
|
593
668
|
const match = new RegExp(`^\\s*[-*+]\\s*(?:${escaped})\\s*[::]\\s*(.*)$`, "mi").exec(automation);
|
|
594
669
|
return match?.[1]?.replaceAll("`", "").trim() ?? "";
|
|
595
670
|
}
|
|
671
|
+
export function inspectBackendTestMarkdownTestPointBindings(markdown) {
|
|
672
|
+
const headings = [...markdown.matchAll(CASE_HEADING)];
|
|
673
|
+
return headings.map((heading, index) => {
|
|
674
|
+
const body = markdown.slice(heading.index, headings[index + 1]?.index ?? markdown.length);
|
|
675
|
+
const testPoints = listTokens(body, CASE_SECTION_ALIASES.testPoints, TEST_POINT);
|
|
676
|
+
const bindings = testPointBindingFacts(body, testPoints);
|
|
677
|
+
return {
|
|
678
|
+
caseId: canonicalCaseId(heading[1]),
|
|
679
|
+
unclassifiedTestPoints: bindings.unclassifiedTestPoints,
|
|
680
|
+
duplicateBindingTestPoints: bindings.duplicateBindingTestPoints,
|
|
681
|
+
};
|
|
682
|
+
});
|
|
683
|
+
}
|
|
596
684
|
function testPointBindingFacts(body, testPoints) {
|
|
597
685
|
const bindings = TEST_POINT_BINDING_MODES.flatMap((mode) => {
|
|
598
686
|
const value = automationLabelBody(body, AUTOMATION_BINDING_LABELS[mode]);
|
|
@@ -1404,6 +1492,11 @@ def payload_expr_nodes(expr, assigned, parameters, seen=None):
|
|
|
1404
1492
|
if isinstance(key,ast.Constant) and key.value == expr.slice.value:
|
|
1405
1493
|
result.extend(shape_only(item) for item in payload_expr_nodes(value,assigned,parameters,set(seen)))
|
|
1406
1494
|
return result
|
|
1495
|
+
if isinstance(expr,ast.IfExp):
|
|
1496
|
+
result=[]
|
|
1497
|
+
result.extend(payload_expr_nodes(expr.body,assigned,parameters,set(seen)))
|
|
1498
|
+
result.extend(payload_expr_nodes(expr.orelse,assigned,parameters,set(seen)))
|
|
1499
|
+
return result
|
|
1407
1500
|
if isinstance(expr,ast.Call):
|
|
1408
1501
|
result=[]
|
|
1409
1502
|
if isinstance(expr.func,ast.Name) and expr.func.id in FUNCTION_PAYLOADS:
|
|
@@ -1444,6 +1537,30 @@ def decorator_payload_nodes(parameters,assigned):
|
|
|
1444
1537
|
for value in values: result.extend(payload_expr_nodes(value,assigned,parameters))
|
|
1445
1538
|
return result
|
|
1446
1539
|
|
|
1540
|
+
def decorator_payload_nodes_by_tp(fn,assigned):
|
|
1541
|
+
result={}
|
|
1542
|
+
for dec in fn.decorator_list:
|
|
1543
|
+
if not isinstance(dec,ast.Call) or not isinstance(dec.func,ast.Attribute) or dec.func.attr != 'parametrize' or len(dec.args) < 2: continue
|
|
1544
|
+
if not isinstance(dec.args[0],ast.Constant) or not isinstance(dec.args[0].value,str): continue
|
|
1545
|
+
names=[item.strip() for item in dec.args[0].value.split(',')]
|
|
1546
|
+
rows=dec.args[1]
|
|
1547
|
+
if isinstance(rows,ast.Name) and rows.id in GLOBAL_VALUES: rows=GLOBAL_VALUES[rows.id]
|
|
1548
|
+
for node in ast.walk(rows):
|
|
1549
|
+
if not isinstance(node,ast.Call) or not isinstance(node.func,ast.Attribute) or node.func.attr != 'param': continue
|
|
1550
|
+
tp=None
|
|
1551
|
+
for keyword in node.keywords:
|
|
1552
|
+
if keyword.arg == 'id' and isinstance(keyword.value,ast.Constant) and isinstance(keyword.value.value,str) and keyword.value.value.startswith('TP-'):
|
|
1553
|
+
tp=keyword.value.value
|
|
1554
|
+
if not tp: continue
|
|
1555
|
+
parameters={}
|
|
1556
|
+
for index,name in enumerate(names):
|
|
1557
|
+
if index < len(node.args): parameters.setdefault(name,[]).append(node.args[index])
|
|
1558
|
+
payload_nodes=decorator_payload_nodes(parameters,assigned)
|
|
1559
|
+
paths=set(); values={}
|
|
1560
|
+
for payload_node in payload_nodes: collect_dict(payload_node,'',paths,values)
|
|
1561
|
+
result[tp]={key:sorted(items) for key,items in sorted(values.items())}
|
|
1562
|
+
return result
|
|
1563
|
+
|
|
1447
1564
|
def fallback_payload_nodes(fn):
|
|
1448
1565
|
result=[]
|
|
1449
1566
|
assigned={}
|
|
@@ -1473,7 +1590,7 @@ def fallback_payload_nodes(fn):
|
|
|
1473
1590
|
|
|
1474
1591
|
def is_direct_transport_call(node):
|
|
1475
1592
|
if not isinstance(node,ast.Call) or not isinstance(node.func,ast.Attribute): return False
|
|
1476
|
-
if node.func.attr.lower() not in {'get','post','put','patch','delete','request'}: return False
|
|
1593
|
+
if node.func.attr.lower() not in {'get','post','put','patch','delete','request','call'}: return False
|
|
1477
1594
|
receiver_tokens=[]
|
|
1478
1595
|
current=node.func.value
|
|
1479
1596
|
while isinstance(current,ast.Attribute):
|
|
@@ -1521,6 +1638,7 @@ for fn in ast.walk(tree):
|
|
|
1521
1638
|
'values':{key:sorted(items) for key,items in sorted(values.items())},
|
|
1522
1639
|
'fallbackPaths':sorted(fallback_paths),
|
|
1523
1640
|
'fallbackValues':{key:sorted(items) for key,items in sorted(fallback_values.items())},
|
|
1641
|
+
'valuesByTestPoint':decorator_payload_nodes_by_tp(fn,{}),
|
|
1524
1642
|
}
|
|
1525
1643
|
print(json.dumps(out,ensure_ascii=True))
|
|
1526
1644
|
`;
|
|
@@ -1651,6 +1769,7 @@ function pytestSymbols(script, source) {
|
|
|
1651
1769
|
payloadValues: payloadShape.values,
|
|
1652
1770
|
fallbackPayloadPaths: payloadShape.fallbackPaths ?? [],
|
|
1653
1771
|
fallbackPayloadValues: payloadShape.fallbackValues ?? {},
|
|
1772
|
+
payloadValuesByTestPoint: payloadShape.valuesByTestPoint ?? {},
|
|
1654
1773
|
};
|
|
1655
1774
|
});
|
|
1656
1775
|
}
|
|
@@ -1664,6 +1783,33 @@ function executionSignature(testCase) {
|
|
|
1664
1783
|
.join("\n");
|
|
1665
1784
|
return createHash("sha256").update(normalized).digest("hex");
|
|
1666
1785
|
}
|
|
1786
|
+
function normalizePythonLiteralValue(value) {
|
|
1787
|
+
const trimmed = value.trim();
|
|
1788
|
+
if ((trimmed.startsWith("'") && trimmed.endsWith("'")) || (trimmed.startsWith('"') && trimmed.endsWith('"'))) {
|
|
1789
|
+
return trimmed.slice(1, -1);
|
|
1790
|
+
}
|
|
1791
|
+
return trimmed;
|
|
1792
|
+
}
|
|
1793
|
+
function intentionalInvalidEnumVariant(input) {
|
|
1794
|
+
if (!input.testCase.testPointBindings.some((binding) => binding.mode === "variant" && binding.testPoint === input.testPoint))
|
|
1795
|
+
return false;
|
|
1796
|
+
const inferred = inferScenarioParamIntent({ tpId: input.testPoint, caseBody: input.testCase.body });
|
|
1797
|
+
if (inferred.intentSource !== "machine-line" || inferred.field?.toLowerCase() !== input.field.toLowerCase())
|
|
1798
|
+
return false;
|
|
1799
|
+
const observed = normalizePythonLiteralValue(input.observedValue);
|
|
1800
|
+
if (input.allowed.includes(observed))
|
|
1801
|
+
return false;
|
|
1802
|
+
if (inferred.intent === "enum-invalid" || inferred.intent === "wrong-type")
|
|
1803
|
+
return true;
|
|
1804
|
+
if (inferred.intent === "null")
|
|
1805
|
+
return observed === "None" || observed === "null";
|
|
1806
|
+
if (inferred.intent === "empty")
|
|
1807
|
+
return observed === "";
|
|
1808
|
+
if (!inferred.intent.startsWith("custom-literal:"))
|
|
1809
|
+
return false;
|
|
1810
|
+
const expected = normalizePythonLiteralValue(inferred.example ?? inferred.intent.slice("custom-literal:".length));
|
|
1811
|
+
return observed === expected;
|
|
1812
|
+
}
|
|
1667
1813
|
function assessPayloadContract(testCase, refs) {
|
|
1668
1814
|
const requiredPaths = testCase.payloadContract.requiredPaths;
|
|
1669
1815
|
const directObservedPaths = orderedUnique(refs.flatMap((item) => item.payloadPaths));
|
|
@@ -1685,13 +1831,17 @@ function assessPayloadContract(testCase, refs) {
|
|
|
1685
1831
|
? observedPaths.filter((item) => !allowedPaths.includes(item) && !allowedPaths.some((allowed) => item.startsWith(`${allowed}.`)))
|
|
1686
1832
|
: [];
|
|
1687
1833
|
const enumMismatches = [];
|
|
1688
|
-
const delegatesInvalidEnumToScenarioGate = testCase.testPointBindings
|
|
1689
|
-
.filter((binding) => binding.mode === "variant")
|
|
1690
|
-
.some((binding) => /(?:ENUM|CATEGORY).*(?:INVALID|UNKNOWN|CASE|WHITESPACE|EMPTY|WRONG-TYPE)|(?:INVALID|UNKNOWN|CASE|WHITESPACE|EMPTY|WRONG-TYPE).*(?:ENUM|CATEGORY)/.test(binding.testPoint));
|
|
1691
1834
|
for (const [field, allowed] of Object.entries(testCase.payloadContract.enumValues)) {
|
|
1692
|
-
const observed = orderedUnique(refs.flatMap((item) => (useFallback ? item.fallbackPayloadValues[field] : item.payloadValues[field]) ?? [])
|
|
1693
|
-
for (const
|
|
1694
|
-
|
|
1835
|
+
const observed = orderedUnique(refs.flatMap((item) => (useFallback ? item.fallbackPayloadValues[field] : item.payloadValues[field]) ?? []));
|
|
1836
|
+
for (const rawValue of observed) {
|
|
1837
|
+
const value = normalizePythonLiteralValue(rawValue);
|
|
1838
|
+
if (allowed.includes(value))
|
|
1839
|
+
continue;
|
|
1840
|
+
const associatedTestPoints = orderedUnique(refs.flatMap((item) => Object.entries(item.payloadValuesByTestPoint)
|
|
1841
|
+
.filter(([, values]) => (values[field] ?? []).includes(rawValue))
|
|
1842
|
+
.map(([testPoint]) => testPoint)));
|
|
1843
|
+
const intentionallyInvalid = associatedTestPoints.length > 0 && associatedTestPoints.every((testPoint) => intentionalInvalidEnumVariant({ testCase, testPoint, field, observedValue: rawValue, allowed }));
|
|
1844
|
+
if (!intentionallyInvalid)
|
|
1695
1845
|
enumMismatches.push(`${field}=${value}; allowed=${allowed.join("|")}`);
|
|
1696
1846
|
}
|
|
1697
1847
|
}
|
|
@@ -20,6 +20,14 @@ export const backendPytestCollectionFindingSchema = z.object({
|
|
|
20
20
|
classification: z.literal("test-asset-defect"),
|
|
21
21
|
repairability: z.enum(["repairable", "blocked"]),
|
|
22
22
|
detail: z.string().min(1),
|
|
23
|
+
caseId: z.string().min(1).optional(),
|
|
24
|
+
reasonCodes: z.array(z.string().min(1)).optional(),
|
|
25
|
+
repairPaths: z.array(z.string().min(1)).optional(),
|
|
26
|
+
payloadExpectedPaths: z.array(z.string()).optional(),
|
|
27
|
+
payloadObservedPaths: z.array(z.string()).optional(),
|
|
28
|
+
payloadMissingPaths: z.array(z.string()).optional(),
|
|
29
|
+
payloadUnexpectedPaths: z.array(z.string()).optional(),
|
|
30
|
+
payloadEnumMismatches: z.array(z.string()).optional(),
|
|
23
31
|
}).strict();
|
|
24
32
|
export const backendPytestCollectionFactsSchema = z.object({
|
|
25
33
|
schemaId: z.literal("backend-test-pytest-collection-v3"),
|
|
@@ -61,11 +69,22 @@ export const backendPytestCollectionFactsSchema = z.object({
|
|
|
61
69
|
context.addIssue({ code: z.ZodIssueCode.custom, message: "backend pytest fixture resolution without an attempt cannot have an exit code" });
|
|
62
70
|
}
|
|
63
71
|
});
|
|
72
|
+
const backendTestScenarioParamDiagnosticSchema = z.object({
|
|
73
|
+
reasonCode: z.enum(["SCENARIO_PARAM_MISMATCH", "SCENARIO_PARAM_UNDETERMINED"]),
|
|
74
|
+
tpId: z.string().min(1).max(256),
|
|
75
|
+
field: z.string().min(1).max(256).optional(),
|
|
76
|
+
expectedRaw: z.string().max(512).optional(),
|
|
77
|
+
observedRaw: z.string().max(512),
|
|
78
|
+
expectedNormalized: z.string().max(512).optional(),
|
|
79
|
+
observedNormalized: z.string().max(512).optional(),
|
|
80
|
+
sourceLocation: z.string().max(512).optional(),
|
|
81
|
+
}).strict();
|
|
64
82
|
const backendTestExcludedItemSchema = z.object({
|
|
65
83
|
itemId: z.string().min(1),
|
|
66
84
|
caseId: z.string().optional(),
|
|
67
85
|
symbol: z.string().optional(),
|
|
68
86
|
reasons: z.array(z.string().min(1)).min(1),
|
|
87
|
+
scenarioParamDiagnostics: z.array(backendTestScenarioParamDiagnosticSchema).max(16).optional(),
|
|
69
88
|
}).strict();
|
|
70
89
|
export const backendTestExecutionReadinessSchema = z.object({
|
|
71
90
|
schemaId: z.literal("backend-test-execution-readiness-v2"),
|
|
@@ -81,6 +100,14 @@ export const backendTestExecutionReadinessSchema = z.object({
|
|
|
81
100
|
collectedItemIds: z.array(z.string()),
|
|
82
101
|
eligibleItemIds: z.array(z.string()),
|
|
83
102
|
excludedItems: z.array(backendTestExcludedItemSchema),
|
|
103
|
+
exclusionSummary: z.object({
|
|
104
|
+
excludedItemCount: z.number().int().min(0),
|
|
105
|
+
excludedCaseCount: z.number().int().min(0),
|
|
106
|
+
excludedByReason: z.record(z.string(), z.number().int().min(1)),
|
|
107
|
+
excludedByCase: z.record(z.string(), z.number().int().min(1)),
|
|
108
|
+
}).strict(),
|
|
109
|
+
pytestSpawned: z.literal(false),
|
|
110
|
+
businessTestBodyExecutedCount: z.literal(0),
|
|
84
111
|
fixtureIssues: z.array(z.string()),
|
|
85
112
|
assetHashes: z.record(z.string(), z.string().regex(SHA256)),
|
|
86
113
|
eligibilityInputHashes: z.record(z.string(), z.string().regex(SHA256)),
|
|
@@ -702,6 +729,7 @@ export function buildBackendTestItemEligibility(collectedItemIds, input) {
|
|
|
702
729
|
const parameterTokens = orderedUnique((itemId.match(/TP-[A-Z0-9-]+/g) ?? []));
|
|
703
730
|
const mapping = input.correspondenceEntries.find((entry) => symbol && (entry.declaredPrimarySymbol === symbol || entry.pytestSymbols.includes(symbol)));
|
|
704
731
|
const reasons = [];
|
|
732
|
+
const scenarioParamDiagnostics = [];
|
|
705
733
|
if (!mapping)
|
|
706
734
|
reasons.push("no exact Markdown Case/primary-symbol correspondence");
|
|
707
735
|
if (mapping && mapping.status !== "EXACT_1_TO_1")
|
|
@@ -724,13 +752,30 @@ export function buildBackendTestItemEligibility(collectedItemIds, input) {
|
|
|
724
752
|
// or observed Python payload shape.
|
|
725
753
|
const normalizedField = entry.field?.toLowerCase();
|
|
726
754
|
const fieldIsPayloadBound = Boolean(normalizedField && payloadPaths.some((item) => item === normalizedField || item.endsWith(`.${normalizedField}`)));
|
|
727
|
-
if (fieldIsPayloadBound && entry.status !== "MATCH")
|
|
755
|
+
if (fieldIsPayloadBound && entry.status !== "MATCH") {
|
|
728
756
|
reasons.push(`scenario-param ${entry.tpId} field ${entry.field} is ${entry.status}`);
|
|
757
|
+
scenarioParamDiagnostics.push({
|
|
758
|
+
reasonCode: entry.status === "MISMATCH" ? "SCENARIO_PARAM_MISMATCH" : "SCENARIO_PARAM_UNDETERMINED",
|
|
759
|
+
tpId: entry.tpId,
|
|
760
|
+
...(entry.field ? { field: entry.field } : {}),
|
|
761
|
+
...(entry.expectedRaw !== undefined ? { expectedRaw: entry.expectedRaw.slice(0, 512) } : {}),
|
|
762
|
+
observedRaw: (entry.observedRaw ?? "unavailable").slice(0, 512),
|
|
763
|
+
...(entry.expectedNormalized !== undefined ? { expectedNormalized: entry.expectedNormalized.slice(0, 512) } : {}),
|
|
764
|
+
...(entry.observedNormalized !== undefined ? { observedNormalized: entry.observedNormalized.slice(0, 512) } : {}),
|
|
765
|
+
...(entry.sourceLocation ? { sourceLocation: entry.sourceLocation.slice(0, 512) } : {}),
|
|
766
|
+
});
|
|
767
|
+
}
|
|
729
768
|
}
|
|
730
769
|
if (reasons.length === 0)
|
|
731
770
|
eligibleItemIds.push(itemId);
|
|
732
771
|
else
|
|
733
|
-
excludedItems.push({
|
|
772
|
+
excludedItems.push({
|
|
773
|
+
itemId,
|
|
774
|
+
...(mapping?.caseId ? { caseId: mapping.caseId } : {}),
|
|
775
|
+
...(symbol ? { symbol } : {}),
|
|
776
|
+
reasons: orderedUnique(reasons),
|
|
777
|
+
...(scenarioParamDiagnostics.length > 0 ? { scenarioParamDiagnostics: scenarioParamDiagnostics.slice(0, 16) } : {}),
|
|
778
|
+
});
|
|
734
779
|
}
|
|
735
780
|
return { eligibleItemIds, excludedItems };
|
|
736
781
|
}
|
|
@@ -763,6 +808,9 @@ export async function materializeBackendTestExecutionReadiness(input) {
|
|
|
763
808
|
collectedItemIds: input.effective.collectedItemIds,
|
|
764
809
|
eligibleItemIds: eligibility.eligibleItemIds,
|
|
765
810
|
excludedItems: eligibility.excludedItems,
|
|
811
|
+
exclusionSummary: summarizeBackendTestExclusions(eligibility.excludedItems),
|
|
812
|
+
pytestSpawned: false,
|
|
813
|
+
businessTestBodyExecutedCount: 0,
|
|
766
814
|
fixtureIssues: input.effective.findings.filter((item) => /fixture/i.test(item.kind)).map((item) => item.detail),
|
|
767
815
|
assetHashes: input.effective.inputHashes,
|
|
768
816
|
eligibilityInputHashes: input.eligibilityInputHashes ?? {},
|
|
@@ -778,6 +826,26 @@ export async function readBackendTestExecutionReadiness(filePath) {
|
|
|
778
826
|
function formatReadinessCounts(readiness) {
|
|
779
827
|
return `mapped=${readiness.mappedScripts.length} collected=${readiness.collectedItemIds.length} eligible=${readiness.eligibleItemIds.length} excluded=${readiness.excludedItems.length}`;
|
|
780
828
|
}
|
|
829
|
+
export function summarizeBackendTestExclusions(excludedItems) {
|
|
830
|
+
const reasonCounts = new Map();
|
|
831
|
+
const caseCounts = new Map();
|
|
832
|
+
for (const item of excludedItems) {
|
|
833
|
+
if (item.caseId)
|
|
834
|
+
caseCounts.set(item.caseId, (caseCounts.get(item.caseId) ?? 0) + 1);
|
|
835
|
+
for (const reason of item.reasons) {
|
|
836
|
+
const category = classifyExclusionReason(reason);
|
|
837
|
+
reasonCounts.set(category, (reasonCounts.get(category) ?? 0) + 1);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
return {
|
|
841
|
+
excludedItemCount: excludedItems.length,
|
|
842
|
+
excludedCaseCount: caseCounts.size,
|
|
843
|
+
excludedByReason: Object.fromEntries(READINESS_EXCLUSION_CATEGORIES
|
|
844
|
+
.filter((category) => (reasonCounts.get(category) ?? 0) > 0)
|
|
845
|
+
.map((category) => [category, reasonCounts.get(category)])),
|
|
846
|
+
excludedByCase: Object.fromEntries([...caseCounts.entries()].sort(([left], [right]) => left.localeCompare(right))),
|
|
847
|
+
};
|
|
848
|
+
}
|
|
781
849
|
export function classifyExclusionReason(reason) {
|
|
782
850
|
if (reason === "no exact Markdown Case/primary-symbol correspondence")
|
|
783
851
|
return "correspondence-missing";
|
|
@@ -70,6 +70,7 @@ export const backendTestResultContractSchema = z
|
|
|
70
70
|
schemaVersion: z.literal(1),
|
|
71
71
|
/** Process-level status of the pytest invocation. Task Pool: use with outcome. */
|
|
72
72
|
executionStatus: backendTestExecutionStatusSchema,
|
|
73
|
+
pytestSpawned: z.literal(true),
|
|
73
74
|
pytestExitCode: z.number().int().min(0).max(255),
|
|
74
75
|
collectionStatus: backendTestCollectionStatusSchema,
|
|
75
76
|
/** Total tests counted from JUnit (or 0 when report unavailable). */
|
|
@@ -659,6 +660,7 @@ export function deriveBackendTestResult(input) {
|
|
|
659
660
|
const result = {
|
|
660
661
|
schemaVersion: 1,
|
|
661
662
|
executionStatus,
|
|
663
|
+
pytestSpawned: true,
|
|
662
664
|
pytestExitCode: input.pytestExitCode,
|
|
663
665
|
collectionStatus: "unknown",
|
|
664
666
|
tests: 0,
|
|
@@ -733,6 +735,7 @@ export function deriveBackendTestResult(input) {
|
|
|
733
735
|
const result = {
|
|
734
736
|
schemaVersion: 1,
|
|
735
737
|
executionStatus,
|
|
738
|
+
pytestSpawned: true,
|
|
736
739
|
pytestExitCode: exit,
|
|
737
740
|
collectionStatus,
|
|
738
741
|
tests: parsed.tests,
|
|
@@ -936,6 +939,7 @@ export async function materializeBackendTestResultFromPytestHtml(input) {
|
|
|
936
939
|
const result = backendTestResultContractSchema.parse({
|
|
937
940
|
schemaVersion: 1,
|
|
938
941
|
executionStatus,
|
|
942
|
+
pytestSpawned: true,
|
|
939
943
|
pytestExitCode: exit,
|
|
940
944
|
collectionStatus,
|
|
941
945
|
tests: parsed.tests,
|
|
@@ -20,7 +20,8 @@ const factsSchema = z
|
|
|
20
20
|
tpId: z.string(),
|
|
21
21
|
field: z.string().optional(),
|
|
22
22
|
intent: z.string(),
|
|
23
|
-
observed: z.string(),
|
|
23
|
+
observed: z.string().max(200),
|
|
24
|
+
observedLength: z.number().int().min(0).optional(),
|
|
24
25
|
status: z.enum(["MATCH", "MISMATCH", "UNDETERMINED"]),
|
|
25
26
|
repairability: z.enum([
|
|
26
27
|
"repairable",
|
|
@@ -28,6 +29,12 @@ const factsSchema = z
|
|
|
28
29
|
"none",
|
|
29
30
|
"undetermined-no-repair",
|
|
30
31
|
]),
|
|
32
|
+
reasonCode: z.enum(["SCENARIO_PARAM_MATCH", "SCENARIO_PARAM_MISMATCH", "SCENARIO_PARAM_UNDETERMINED"]),
|
|
33
|
+
expectedRaw: z.string().max(512).optional(),
|
|
34
|
+
observedRaw: z.string().max(512),
|
|
35
|
+
expectedNormalized: z.string().max(512).optional(),
|
|
36
|
+
observedNormalized: z.string().max(512).optional(),
|
|
37
|
+
sourceLocation: z.string().max(512).optional(),
|
|
31
38
|
suggestedFix: z.string().optional(),
|
|
32
39
|
scriptPath: z.string().optional(),
|
|
33
40
|
bound: z.number().optional(),
|
|
@@ -435,8 +442,16 @@ function isWhitespacePaddedString(value) {
|
|
|
435
442
|
}
|
|
436
443
|
function normalizeScenarioLiteralText(value) {
|
|
437
444
|
// Writers sometimes emit visible-space placeholders (U+2420), NBSP variants,
|
|
438
|
-
//
|
|
445
|
+
// URL-encoded spaces, or explicit Python/JSON whitespace escapes in
|
|
446
|
+
// machine-readable Markdown. Decode only escapes that resolve to whitespace;
|
|
447
|
+
// ordinary business characters such as `\\u0041` remain literal and fail closed.
|
|
448
|
+
const decodeWhitespaceEscape = (token, hex) => {
|
|
449
|
+
const decoded = String.fromCodePoint(Number.parseInt(hex, 16));
|
|
450
|
+
return /^\s$/u.test(decoded) || /[ ]/u.test(decoded) ? decoded : token;
|
|
451
|
+
};
|
|
439
452
|
const normalized = value
|
|
453
|
+
.replace(/\\u([0-9a-f]{4})/gi, decodeWhitespaceEscape)
|
|
454
|
+
.replace(/\\x([0-9a-f]{2})/gi, decodeWhitespaceEscape)
|
|
440
455
|
.replace(/␠/g, " ")
|
|
441
456
|
.replace(/ /g, " ")
|
|
442
457
|
.replace(/ /g, " ")
|
|
@@ -677,6 +692,50 @@ export function extractPytestParamBlock(source, tpId) {
|
|
|
677
692
|
}
|
|
678
693
|
return undefined;
|
|
679
694
|
}
|
|
695
|
+
function extractPytestParamTopLevelArgs(block) {
|
|
696
|
+
if (!block)
|
|
697
|
+
return [];
|
|
698
|
+
const open = block.indexOf("(");
|
|
699
|
+
const close = block.lastIndexOf(")");
|
|
700
|
+
if (open < 0 || close <= open)
|
|
701
|
+
return [];
|
|
702
|
+
const input = block.slice(open + 1, close);
|
|
703
|
+
const args = [];
|
|
704
|
+
let start = 0;
|
|
705
|
+
let depth = 0;
|
|
706
|
+
let quote = "";
|
|
707
|
+
let escaped = false;
|
|
708
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
709
|
+
const ch = input[index];
|
|
710
|
+
if (escaped) {
|
|
711
|
+
escaped = false;
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
if (ch === "\\") {
|
|
715
|
+
escaped = true;
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
if (quote) {
|
|
719
|
+
if (ch === quote)
|
|
720
|
+
quote = "";
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
if (ch === '"' || ch === "'") {
|
|
724
|
+
quote = ch;
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
if (ch === "[" || ch === "{" || ch === "(")
|
|
728
|
+
depth += 1;
|
|
729
|
+
else if (ch === "]" || ch === "}" || ch === ")")
|
|
730
|
+
depth -= 1;
|
|
731
|
+
else if (ch === "," && depth === 0) {
|
|
732
|
+
args.push(input.slice(start, index).trim());
|
|
733
|
+
start = index + 1;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
args.push(input.slice(start).trim());
|
|
737
|
+
return args.filter((arg) => arg && !/^(?:id|marks)\s*=/.test(arg));
|
|
738
|
+
}
|
|
680
739
|
function extractPytestParamParameterNames(source, tpId) {
|
|
681
740
|
const block = extractPytestParamBlock(source, tpId);
|
|
682
741
|
if (!block)
|
|
@@ -688,8 +747,11 @@ function extractPytestParamParameterNames(source, tpId) {
|
|
|
688
747
|
if (decoratorIndex < 0)
|
|
689
748
|
return [];
|
|
690
749
|
const prefix = source.slice(decoratorIndex, blockIndex);
|
|
691
|
-
const
|
|
692
|
-
|
|
750
|
+
const scalar = /@pytest\.mark\.parametrize\s*\(\s*["']([^"']+)["']/s.exec(prefix)?.[1];
|
|
751
|
+
if (scalar)
|
|
752
|
+
return scalar.split(",").map((name) => name.trim()).filter(Boolean);
|
|
753
|
+
const tuple = /@pytest\.mark\.parametrize\s*\(\s*\(\s*((?:["'][^"']+["']\s*,?\s*)+)\)/s.exec(prefix)?.[1];
|
|
754
|
+
return tuple ? [...tuple.matchAll(/["']([^"']+)["']/g)].map((match) => match[1]).filter(Boolean) : [];
|
|
693
755
|
}
|
|
694
756
|
export function observeParamFeatures(block, field, constants, parameterNames) {
|
|
695
757
|
if (!block)
|
|
@@ -749,48 +811,7 @@ export function observeParamFeatures(block, field, constants, parameterNames) {
|
|
|
749
811
|
}
|
|
750
812
|
// Parse top-level positional args before the legacy token scanner so list
|
|
751
813
|
// cardinality and field/value rows remain statically visible.
|
|
752
|
-
const topLevelArgs = (
|
|
753
|
-
const open = block.indexOf("(");
|
|
754
|
-
const close = block.lastIndexOf(")");
|
|
755
|
-
if (open < 0 || close <= open)
|
|
756
|
-
return [];
|
|
757
|
-
const input = block.slice(open + 1, close);
|
|
758
|
-
const args = [];
|
|
759
|
-
let start = 0;
|
|
760
|
-
let depth = 0;
|
|
761
|
-
let quote = "";
|
|
762
|
-
let escaped = false;
|
|
763
|
-
for (let i = 0; i < input.length; i += 1) {
|
|
764
|
-
const ch = input[i];
|
|
765
|
-
if (escaped) {
|
|
766
|
-
escaped = false;
|
|
767
|
-
continue;
|
|
768
|
-
}
|
|
769
|
-
if (ch === "\\") {
|
|
770
|
-
escaped = true;
|
|
771
|
-
continue;
|
|
772
|
-
}
|
|
773
|
-
if (quote) {
|
|
774
|
-
if (ch === quote)
|
|
775
|
-
quote = "";
|
|
776
|
-
continue;
|
|
777
|
-
}
|
|
778
|
-
if (ch === '"' || ch === "'") {
|
|
779
|
-
quote = ch;
|
|
780
|
-
continue;
|
|
781
|
-
}
|
|
782
|
-
if (ch === "[" || ch === "{" || ch === "(")
|
|
783
|
-
depth += 1;
|
|
784
|
-
else if (ch === "]" || ch === "}" || ch === ")")
|
|
785
|
-
depth -= 1;
|
|
786
|
-
else if (ch === "," && depth === 0) {
|
|
787
|
-
args.push(input.slice(start, i).trim());
|
|
788
|
-
start = i + 1;
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
|
-
args.push(input.slice(start).trim());
|
|
792
|
-
return args.filter((arg) => arg && !/^(?:id|marks)\s*=/.test(arg));
|
|
793
|
-
})();
|
|
814
|
+
const topLevelArgs = extractPytestParamTopLevelArgs(block);
|
|
794
815
|
if (topLevelArgs.length > 0) {
|
|
795
816
|
const idTp = /\bid\s*=\s*["'](TP-[A-Z0-9-]+)["']/i.exec(block)?.[1]?.toUpperCase();
|
|
796
817
|
const meaningfulArgs = topLevelArgs.filter((arg) => {
|
|
@@ -1161,11 +1182,13 @@ function suggestedFixFor(intent, field, bound, example) {
|
|
|
1161
1182
|
: undefined;
|
|
1162
1183
|
default:
|
|
1163
1184
|
if (intent.startsWith("custom-literal:")) {
|
|
1164
|
-
const
|
|
1165
|
-
|
|
1166
|
-
|
|
1185
|
+
const expectedRaw = intent.slice("custom-literal:".length);
|
|
1186
|
+
const expected = normalizeCustomLiteralExpected(expectedRaw);
|
|
1187
|
+
if (isWhitespaceSemanticLiteral(expectedRaw) || isWhitespaceOnlyString(expected)) {
|
|
1188
|
+
const length = isWhitespaceOnlyString(expected) ? expected.length : 3;
|
|
1189
|
+
return field ? `set ${field} to ${length} literal spaces` : `set value to ${length} literal spaces`;
|
|
1167
1190
|
}
|
|
1168
|
-
if (isWhitespacePaddedSemanticLiteral(
|
|
1191
|
+
if (isWhitespacePaddedSemanticLiteral(expectedRaw)) {
|
|
1169
1192
|
return field
|
|
1170
1193
|
? `set ${field}=" ${field}-value "`
|
|
1171
1194
|
: 'set value=" value "';
|
|
@@ -1177,6 +1200,200 @@ function suggestedFixFor(intent, field, bound, example) {
|
|
|
1177
1200
|
return undefined;
|
|
1178
1201
|
}
|
|
1179
1202
|
}
|
|
1203
|
+
const MAX_PERSISTED_SCENARIO_TEXT = 192;
|
|
1204
|
+
function boundedScenarioText(value, logicalLength) {
|
|
1205
|
+
if (value.length <= MAX_PERSISTED_SCENARIO_TEXT)
|
|
1206
|
+
return value;
|
|
1207
|
+
const suffix = `<truncated length=${logicalLength ?? value.length}>`;
|
|
1208
|
+
return `${value.slice(0, Math.max(0, MAX_PERSISTED_SCENARIO_TEXT - suffix.length - 3))}...${suffix}`;
|
|
1209
|
+
}
|
|
1210
|
+
function localPythonFunctionRegion(source, functionName) {
|
|
1211
|
+
const escaped = functionName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1212
|
+
const startMatch = new RegExp(`^def\\s+${escaped}\\s*\\(`, "m").exec(source);
|
|
1213
|
+
if (!startMatch || startMatch.index === undefined)
|
|
1214
|
+
return undefined;
|
|
1215
|
+
const start = startMatch.index;
|
|
1216
|
+
const remainder = source.slice(start + startMatch[0].length);
|
|
1217
|
+
const nextTopLevel = /^(?:def|class|@pytest\b|@(?:[A-Za-z_][A-Za-z0-9_.]*))\s*/m.exec(remainder);
|
|
1218
|
+
return source.slice(start, nextTopLevel?.index === undefined ? source.length : start + startMatch[0].length + nextTopLevel.index);
|
|
1219
|
+
}
|
|
1220
|
+
function splitPythonTopLevelArguments(input) {
|
|
1221
|
+
const args = [];
|
|
1222
|
+
let start = 0;
|
|
1223
|
+
let depth = 0;
|
|
1224
|
+
let quote = "";
|
|
1225
|
+
let escaped = false;
|
|
1226
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
1227
|
+
const ch = input[index];
|
|
1228
|
+
if (escaped) {
|
|
1229
|
+
escaped = false;
|
|
1230
|
+
continue;
|
|
1231
|
+
}
|
|
1232
|
+
if (ch === "\\") {
|
|
1233
|
+
escaped = true;
|
|
1234
|
+
continue;
|
|
1235
|
+
}
|
|
1236
|
+
if (quote) {
|
|
1237
|
+
if (ch === quote)
|
|
1238
|
+
quote = "";
|
|
1239
|
+
continue;
|
|
1240
|
+
}
|
|
1241
|
+
if (ch === '"' || ch === "'") {
|
|
1242
|
+
quote = ch;
|
|
1243
|
+
continue;
|
|
1244
|
+
}
|
|
1245
|
+
if (ch === "[" || ch === "{" || ch === "(")
|
|
1246
|
+
depth += 1;
|
|
1247
|
+
else if (ch === "]" || ch === "}" || ch === ")")
|
|
1248
|
+
depth -= 1;
|
|
1249
|
+
else if (ch === "," && depth === 0) {
|
|
1250
|
+
args.push(input.slice(start, index).trim());
|
|
1251
|
+
start = index + 1;
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
args.push(input.slice(start).trim());
|
|
1255
|
+
return args.filter(Boolean);
|
|
1256
|
+
}
|
|
1257
|
+
function parseSimplePythonCall(expression) {
|
|
1258
|
+
const call = /^([A-Za-z_][A-Za-z0-9_]*)\s*\(([\s\S]*)\)$/.exec(expression.trim());
|
|
1259
|
+
if (!call)
|
|
1260
|
+
return undefined;
|
|
1261
|
+
const positional = [];
|
|
1262
|
+
const keywords = new Map();
|
|
1263
|
+
for (const argument of splitPythonTopLevelArguments(call[2] ?? "")) {
|
|
1264
|
+
const keyword = /^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*([\s\S]+)$/.exec(argument);
|
|
1265
|
+
if (keyword) {
|
|
1266
|
+
if (keywords.has(keyword[1]))
|
|
1267
|
+
return undefined;
|
|
1268
|
+
keywords.set(keyword[1], keyword[2].trim());
|
|
1269
|
+
}
|
|
1270
|
+
else {
|
|
1271
|
+
if (keywords.size > 0 || /^\*{1,2}/.test(argument))
|
|
1272
|
+
return undefined;
|
|
1273
|
+
positional.push(argument);
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
return { name: call[1], positional, keywords };
|
|
1277
|
+
}
|
|
1278
|
+
function safeSameModuleDictHelper(source, helperName) {
|
|
1279
|
+
if (!helperName.startsWith("_"))
|
|
1280
|
+
return undefined;
|
|
1281
|
+
const region = localPythonFunctionRegion(source, helperName);
|
|
1282
|
+
if (!region)
|
|
1283
|
+
return undefined;
|
|
1284
|
+
const functionBody = region.slice(region.indexOf(":") + 1);
|
|
1285
|
+
const escapedName = helperName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1286
|
+
if (new RegExp(`\\b${escapedName}\\s*\\(`).test(functionBody))
|
|
1287
|
+
return undefined;
|
|
1288
|
+
if (/\b(?:Faker|fake|random|randint|uuid4|environ|getenv|requests|httpx|urlopen|subprocess|Popen|socket|open)\b/i.test(region))
|
|
1289
|
+
return undefined;
|
|
1290
|
+
const signature = new RegExp(`^def\\s+${escapedName}\\s*\\(([\\s\\S]*?)\\)\\s*(?:->[^:]+)?\\s*:`, "m").exec(region);
|
|
1291
|
+
if (!signature)
|
|
1292
|
+
return undefined;
|
|
1293
|
+
const parameters = [];
|
|
1294
|
+
for (const rawParameter of splitPythonTopLevelArguments(signature[1] ?? "")) {
|
|
1295
|
+
if (rawParameter === "*" || rawParameter === "/")
|
|
1296
|
+
continue;
|
|
1297
|
+
if (/^\*{1,2}/.test(rawParameter))
|
|
1298
|
+
return undefined;
|
|
1299
|
+
const parameter = /^([A-Za-z_][A-Za-z0-9_]*)(?:\s*:[^=]+)?(?:\s*=\s*([\s\S]+))?$/.exec(rawParameter);
|
|
1300
|
+
if (!parameter)
|
|
1301
|
+
return undefined;
|
|
1302
|
+
parameters.push({ name: parameter[1], defaultExpression: parameter[2]?.trim() });
|
|
1303
|
+
}
|
|
1304
|
+
const directReturn = /\breturn\s*(\{[\s\S]*?\})/.exec(functionBody)?.[1];
|
|
1305
|
+
const assignment = /\b([A-Za-z_][A-Za-z0-9_]*)\s*(?::[^=\n]+)?=\s*(\{[\s\S]*?\})/.exec(functionBody);
|
|
1306
|
+
const assignedReturn = assignment && new RegExp(`\\breturn\\s+${assignment[1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(functionBody)
|
|
1307
|
+
? assignment[2]
|
|
1308
|
+
: undefined;
|
|
1309
|
+
const dictExpression = directReturn ?? assignedReturn;
|
|
1310
|
+
if (!dictExpression)
|
|
1311
|
+
return undefined;
|
|
1312
|
+
const fieldBindings = new Map();
|
|
1313
|
+
for (const item of splitPythonTopLevelArguments(dictExpression.slice(1, -1))) {
|
|
1314
|
+
const binding = /^(?:["']([^"']+)["'])\s*:\s*([A-Za-z_][A-Za-z0-9_]*)$/.exec(item);
|
|
1315
|
+
if (!binding || !parameters.some((parameter) => parameter.name === binding[2]))
|
|
1316
|
+
return undefined;
|
|
1317
|
+
fieldBindings.set(binding[1], binding[2]);
|
|
1318
|
+
}
|
|
1319
|
+
return { parameters, fieldBindings };
|
|
1320
|
+
}
|
|
1321
|
+
function observeSameModulePayloadHelperField(input) {
|
|
1322
|
+
const payloadIndex = input.parameterNames.findIndex((name) => /^(?:payload|body|data|json)$/i.test(name));
|
|
1323
|
+
if (payloadIndex < 0)
|
|
1324
|
+
return undefined;
|
|
1325
|
+
let expression = extractPytestParamTopLevelArgs(input.block)[payloadIndex]?.trim() ?? "";
|
|
1326
|
+
const wrapper = parseSimplePythonCall(expression);
|
|
1327
|
+
if (wrapper?.name === "_without") {
|
|
1328
|
+
if (wrapper.positional.length !== 2 || wrapper.keywords.size > 0)
|
|
1329
|
+
return undefined;
|
|
1330
|
+
const removed = observeLiteralToken(wrapper.positional[1], input.field, input.constants);
|
|
1331
|
+
if (removed?.kind !== "string" || removed.literal !== input.field)
|
|
1332
|
+
return undefined;
|
|
1333
|
+
expression = wrapper.positional[0];
|
|
1334
|
+
const inner = parseSimplePythonCall(expression);
|
|
1335
|
+
if (!inner || !safeSameModuleDictHelper(input.source, inner.name))
|
|
1336
|
+
return undefined;
|
|
1337
|
+
return { kind: "missing-key", text: `missing:${input.field}` };
|
|
1338
|
+
}
|
|
1339
|
+
const call = parseSimplePythonCall(expression);
|
|
1340
|
+
if (!call)
|
|
1341
|
+
return undefined;
|
|
1342
|
+
const helper = safeSameModuleDictHelper(input.source, call.name);
|
|
1343
|
+
if (!helper)
|
|
1344
|
+
return undefined;
|
|
1345
|
+
const parameterName = helper.fieldBindings.get(input.field);
|
|
1346
|
+
if (!parameterName)
|
|
1347
|
+
return { kind: "missing-key", text: `missing:${input.field}` };
|
|
1348
|
+
const parameterIndex = helper.parameters.findIndex((parameter) => parameter.name === parameterName);
|
|
1349
|
+
if (parameterIndex < 0 || call.positional.length > helper.parameters.length)
|
|
1350
|
+
return undefined;
|
|
1351
|
+
const expressionForField = call.keywords.get(parameterName) ?? call.positional[parameterIndex] ?? helper.parameters[parameterIndex]?.defaultExpression;
|
|
1352
|
+
return expressionForField
|
|
1353
|
+
? observeLiteralToken(expressionForField, input.field, input.constants)
|
|
1354
|
+
: undefined;
|
|
1355
|
+
}
|
|
1356
|
+
function hasSafeSameModulePayloadHelper(source, block, parameterNames) {
|
|
1357
|
+
const payloadIndex = parameterNames.findIndex((name) => /^(?:payload|body|data|json)$/i.test(name));
|
|
1358
|
+
if (payloadIndex < 0)
|
|
1359
|
+
return false;
|
|
1360
|
+
const expression = extractPytestParamTopLevelArgs(block)[payloadIndex]?.trim() ?? "";
|
|
1361
|
+
const call = parseSimplePythonCall(expression);
|
|
1362
|
+
if (!call)
|
|
1363
|
+
return false;
|
|
1364
|
+
if (/\b(?:Faker|fake|random|randint|uuid4|environ|getenv|requests|httpx|urlopen|subprocess|Popen|socket|open)\b/i.test(expression))
|
|
1365
|
+
return false;
|
|
1366
|
+
return Boolean(safeSameModuleDictHelper(source, call.name));
|
|
1367
|
+
}
|
|
1368
|
+
function observeDeterministicFunctionFieldOverride(input) {
|
|
1369
|
+
const region = scenarioFunctionRegion(input.source, input.tpId);
|
|
1370
|
+
if (!region)
|
|
1371
|
+
return undefined;
|
|
1372
|
+
const escapedField = input.field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1373
|
+
const assignment = new RegExp(`^([ \\t]*)([A-Za-z_][A-Za-z0-9_]*)\\s*\\[\\s*["']${escapedField}["']\\s*\\]\\s*=\\s*([A-Za-z_][A-Za-z0-9_]*)\\s*$`, "m").exec(region);
|
|
1374
|
+
if (!assignment)
|
|
1375
|
+
return undefined;
|
|
1376
|
+
const indentation = assignment[1]?.length ?? 0;
|
|
1377
|
+
const valueParameter = assignment[3];
|
|
1378
|
+
const valueIndex = input.parameterNames.indexOf(valueParameter);
|
|
1379
|
+
if (valueIndex < 0)
|
|
1380
|
+
return undefined;
|
|
1381
|
+
const beforeAssignment = region.slice(0, assignment.index);
|
|
1382
|
+
const guardMatches = [...beforeAssignment.matchAll(/^([ \\t]*)if\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*$/gm)];
|
|
1383
|
+
const nearestGuard = guardMatches.at(-1);
|
|
1384
|
+
if (nearestGuard && (nearestGuard[1]?.length ?? 0) < indentation) {
|
|
1385
|
+
const guardIndex = input.parameterNames.indexOf(nearestGuard[2]);
|
|
1386
|
+
if (guardIndex < 0)
|
|
1387
|
+
return undefined;
|
|
1388
|
+
const guardExpression = extractPytestParamTopLevelArgs(input.block)[guardIndex]?.trim();
|
|
1389
|
+
if (guardExpression !== "True")
|
|
1390
|
+
return undefined;
|
|
1391
|
+
}
|
|
1392
|
+
const valueExpression = extractPytestParamTopLevelArgs(input.block)[valueIndex]?.trim();
|
|
1393
|
+
return valueExpression
|
|
1394
|
+
? observeLiteralToken(valueExpression, input.field, input.constants)
|
|
1395
|
+
: undefined;
|
|
1396
|
+
}
|
|
1180
1397
|
function scenarioFunctionRegion(source, tpId) {
|
|
1181
1398
|
const idIndex = source.indexOf(`id="${tpId}"`) >= 0
|
|
1182
1399
|
? source.indexOf(`id="${tpId}"`)
|
|
@@ -1202,6 +1419,43 @@ function hasCreateDeleteDerivedJourney(source, tpId) {
|
|
|
1202
1419
|
(/\b_create[A-Za-z0-9_]*\s*\(/.test(region) &&
|
|
1203
1420
|
(/\b_delete[A-Za-z0-9_]*\s*\(/.test(region) || /\.delete\s*\(/.test(region) || /\.request\s*\(\s*["']DELETE["']/i.test(region)));
|
|
1204
1421
|
}
|
|
1422
|
+
function hasConditionalFieldOmission(source, tpId, field, parameterNames) {
|
|
1423
|
+
if (!field)
|
|
1424
|
+
return false;
|
|
1425
|
+
const region = scenarioFunctionRegion(source, tpId);
|
|
1426
|
+
const escapedField = field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1427
|
+
const candidateNames = parameterNames.filter((name) => {
|
|
1428
|
+
const normalized = name.toLowerCase();
|
|
1429
|
+
const normalizedField = field.toLowerCase();
|
|
1430
|
+
return normalized === normalizedField || normalized.startsWith(`${normalizedField}_`) || normalized.endsWith(`_${normalizedField}`);
|
|
1431
|
+
});
|
|
1432
|
+
return candidateNames.some((name) => {
|
|
1433
|
+
const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1434
|
+
return new RegExp(`\\{\\s*\\}\\s*if\\s+${escapedName}\\s+is\\s+None\\s+else\\s+\\{[\\s\\S]{0,200}?["']${escapedField}["']\\s*:\\s*${escapedName}\\b`).test(region) ||
|
|
1435
|
+
new RegExp(`\\{[\\s\\S]{0,200}?["']${escapedField}["']\\s*:\\s*${escapedName}\\b[\\s\\S]{0,200}?\\}\\s*if\\s+${escapedName}\\s+is\\s+not\\s+None\\s+else\\s+\\{\\s*\\}`).test(region) ||
|
|
1436
|
+
new RegExp(`if\\s+${escapedName}\\s+is\\s+not\\s+None\\s*:[\\s\\S]{0,200}?["']${escapedField}["']\\s*\\]\\s*=\\s*${escapedName}\\b`).test(region);
|
|
1437
|
+
});
|
|
1438
|
+
}
|
|
1439
|
+
function scenarioParamSourceLocation(scriptPath, source, tpId) {
|
|
1440
|
+
const block = extractPytestParamBlock(source, tpId);
|
|
1441
|
+
if (!block)
|
|
1442
|
+
return undefined;
|
|
1443
|
+
const index = source.indexOf(block);
|
|
1444
|
+
if (index < 0)
|
|
1445
|
+
return undefined;
|
|
1446
|
+
return `${scriptPath}:${source.slice(0, index).split("\n").length}`;
|
|
1447
|
+
}
|
|
1448
|
+
function scenarioParamDiagnosticValues(intent, observed) {
|
|
1449
|
+
const expectedRaw = intent.startsWith("custom-literal:")
|
|
1450
|
+
? intent.slice("custom-literal:".length)
|
|
1451
|
+
: intent === "unknown" ? undefined : intent;
|
|
1452
|
+
return {
|
|
1453
|
+
expectedRaw,
|
|
1454
|
+
observedRaw: boundedScenarioText(observed.text, observed.length),
|
|
1455
|
+
expectedNormalized: expectedRaw === undefined ? undefined : normalizeCustomLiteralExpected(expectedRaw).slice(0, 512),
|
|
1456
|
+
observedNormalized: observed.literal === undefined ? undefined : boundedScenarioText(normalizeScenarioLiteralText(observed.literal), observed.length),
|
|
1457
|
+
};
|
|
1458
|
+
}
|
|
1205
1459
|
export async function assessBackendScenarioParamConsistency(input) {
|
|
1206
1460
|
const cases = await listMarkdownCases(input.workspaceRoot, input.layout);
|
|
1207
1461
|
const entries = [];
|
|
@@ -1223,7 +1477,26 @@ export async function assessBackendScenarioParamConsistency(input) {
|
|
|
1223
1477
|
const constants = source ? collectPythonStringConstants(source) : undefined;
|
|
1224
1478
|
const block = source ? extractPytestParamBlock(source, tpId) : undefined;
|
|
1225
1479
|
const parameterNames = source ? extractPytestParamParameterNames(source, tpId) : [];
|
|
1226
|
-
const
|
|
1480
|
+
const functionFieldOverride = source && inferred.field
|
|
1481
|
+
? observeDeterministicFunctionFieldOverride({
|
|
1482
|
+
source,
|
|
1483
|
+
tpId,
|
|
1484
|
+
block,
|
|
1485
|
+
parameterNames,
|
|
1486
|
+
field: inferred.field,
|
|
1487
|
+
constants,
|
|
1488
|
+
})
|
|
1489
|
+
: undefined;
|
|
1490
|
+
const helperFieldObserved = source && inferred.field
|
|
1491
|
+
? observeSameModulePayloadHelperField({
|
|
1492
|
+
source,
|
|
1493
|
+
block,
|
|
1494
|
+
parameterNames,
|
|
1495
|
+
field: inferred.field,
|
|
1496
|
+
constants,
|
|
1497
|
+
})
|
|
1498
|
+
: undefined;
|
|
1499
|
+
const observed = functionFieldOverride ?? helperFieldObserved ?? observeParamFeatures(block, inferred.field, constants, parameterNames);
|
|
1227
1500
|
// Request-level / health checks often have no pytest.param payload row,
|
|
1228
1501
|
// or only a TP-id label positional (no field value).
|
|
1229
1502
|
const requestLevelNoParam = Boolean(source) &&
|
|
@@ -1242,9 +1515,17 @@ export async function assessBackendScenarioParamConsistency(input) {
|
|
|
1242
1515
|
hasCreateDeleteDerivedJourney(source, tpId)) ||
|
|
1243
1516
|
(/^custom-literal:(?:create-derived(?:-active-id|-id)?|active-existing-derived)$/i.test(inferred.intent) &&
|
|
1244
1517
|
hasCreateDerivedJourney(source, tpId)));
|
|
1518
|
+
const conditionalOmissionMatch = Boolean(source) &&
|
|
1519
|
+
inferred.intent === "missing" &&
|
|
1520
|
+
observed.kind === "none" &&
|
|
1521
|
+
hasConditionalFieldOmission(source, tpId, inferred.field, parameterNames);
|
|
1522
|
+
const localPayloadHelperMatch = Boolean(source) &&
|
|
1523
|
+
inferred.intent === "nominal" &&
|
|
1524
|
+
!inferred.field &&
|
|
1525
|
+
hasSafeSameModulePayloadHelper(source, block, parameterNames);
|
|
1245
1526
|
const status = !source
|
|
1246
1527
|
? "UNDETERMINED"
|
|
1247
|
-
: derivedJourneyMatch
|
|
1528
|
+
: derivedJourneyMatch || conditionalOmissionMatch || localPayloadHelperMatch
|
|
1248
1529
|
? "MATCH"
|
|
1249
1530
|
: requestLevelNoParam
|
|
1250
1531
|
? "MATCH"
|
|
@@ -1252,14 +1533,19 @@ export async function assessBackendScenarioParamConsistency(input) {
|
|
|
1252
1533
|
? "UNDETERMINED"
|
|
1253
1534
|
: compareIntent(inferred.intent, observed, inferred.bound, inferred.example, inferred.field);
|
|
1254
1535
|
const repairability = repairabilityFor(status, inferred.intent, observed, inferred.example);
|
|
1536
|
+
const diagnostics = scenarioParamDiagnosticValues(inferred.intent, observed);
|
|
1255
1537
|
entries.push({
|
|
1256
1538
|
caseId: testCase.caseId,
|
|
1257
1539
|
tpId,
|
|
1258
1540
|
field: inferred.field,
|
|
1259
1541
|
intent: inferred.intent,
|
|
1260
|
-
observed: observed.text,
|
|
1542
|
+
observed: boundedScenarioText(observed.text, observed.length),
|
|
1543
|
+
observedLength: observed.length,
|
|
1261
1544
|
status,
|
|
1262
1545
|
repairability,
|
|
1546
|
+
reasonCode: status === "MATCH" ? "SCENARIO_PARAM_MATCH" : status === "MISMATCH" ? "SCENARIO_PARAM_MISMATCH" : "SCENARIO_PARAM_UNDETERMINED",
|
|
1547
|
+
...diagnostics,
|
|
1548
|
+
sourceLocation: source ? scenarioParamSourceLocation(testCase.scriptPath, source, tpId) : undefined,
|
|
1263
1549
|
suggestedFix: fallbackSourced
|
|
1264
1550
|
? undefined
|
|
1265
1551
|
: suggestedFixFor(inferred.intent, inferred.field, inferred.bound, inferred.example),
|
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { BACKEND_TEST_MAX_MODULE_COUNT, isOpaqueHashBackendTestModuleStem, isPriorityOnlyBackendTestModuleStem, } from "./backend-test-module-stem.js";
|
|
6
6
|
import { resolveBackendTestLayout, } from "./backend-test-layout.js";
|
|
7
|
+
import { inspectBackendTestMarkdownTestPointBindings } from "./backend-test-case-coverage-analysis.js";
|
|
7
8
|
/**
|
|
8
9
|
* Maps a backend-test generation writer task id to its writer-progress role.
|
|
9
10
|
* Returns undefined for nodes that are not completeness-gated generators.
|
|
@@ -276,6 +277,16 @@ function moduleStructurallyComplete(markdown) {
|
|
|
276
277
|
reasons: [`missing required sections: [${missing.join(", ")}]`],
|
|
277
278
|
};
|
|
278
279
|
}
|
|
280
|
+
const bindingReasons = inspectBackendTestMarkdownTestPointBindings(markdown).flatMap((entry) => [
|
|
281
|
+
...(entry.unclassifiedTestPoints.length > 0
|
|
282
|
+
? [`${entry.caseId} has unclassified Test Points: ${entry.unclassifiedTestPoints.join(", ")}`]
|
|
283
|
+
: []),
|
|
284
|
+
...(entry.duplicateBindingTestPoints.length > 0
|
|
285
|
+
? [`${entry.caseId} has duplicate Test Point bindings: ${entry.duplicateBindingTestPoints.join(", ")}`]
|
|
286
|
+
: []),
|
|
287
|
+
]);
|
|
288
|
+
if (bindingReasons.length > 0)
|
|
289
|
+
return { ok: false, reasons: bindingReasons };
|
|
279
290
|
return { ok: true, reasons: [] };
|
|
280
291
|
}
|
|
281
292
|
function moduleStructuralDetail(result) {
|
|
@@ -4815,7 +4815,7 @@ async function buildBackendTestHybridDag(sources) {
|
|
|
4815
4815
|
writerOutcomePolicy: { type: "implementation-outcome-v1", requireChangedFiles: true },
|
|
4816
4816
|
outputContract: "First non-empty line is IMPLEMENTATION_OUTCOME: changed|blocked, followed by a concise repair summary. This node runs only for REPAIRABLE initial facts, so already-satisfied is invalid and a successful outcome requires a non-empty bounded diff. Modify only generated pytest scripts/helpers/factories and preserve every Markdown Case, Test Point, primary symbol and assertion meaning.",
|
|
4817
4817
|
subtask_prompt: [
|
|
4818
|
-
"Repair the generated backend pytest asset as one bounded program using the direct upstream collection assessment. This is the only repair attempt and happens before any business test body execution. Treat any upstream line such as `Repair paths: testcase/test_x.py` as
|
|
4818
|
+
"Repair the generated backend pytest asset as one bounded program using the direct upstream collection assessment. This is the only repair attempt and happens before any business test body execution. The direct upstream JSON includes authoritative `repairPaths` and bounded `repairFindings`; treat both as the complete mandatory checklist without searching for a run directory or report file. Treat any upstream line such as `Repair paths: testcase/test_x.py` as equivalent authoritative repairPaths evidence. Directly read and edit that testcase path; do not search for separate root-level `contracts/**`, guess a DAG run directory, or require another report artifact. If the read tool successfully returns the testcase file, the path exists—continue the bounded repair and never later claim that file is absent.",
|
|
4819
4819
|
"Initial status REPAIRABLE means at least one listed finding remains: `already-satisfied` is forbidden, and you must produce a non-empty bounded diff on repairPaths before returning `IMPLEMENTATION_OUTCOME: changed`. Fix only readiness-proven generated testcase-local defects on initial facts repairPaths: create exact safe missing mapped test_*.py paths, repair syntax/import/symbol/decorator/parameterization, close generated fixture dependencies/plugin registration, and repair initial Markdown-to-pytest correspondence findings. Use this deterministic repair map instead of reading analyzer implementation: findings about `Case-ID`, `Assertion-Test-Points`, or `Cross-Cutting-Test-Points` are fixed by editing the declared primary symbol docstring metadata lines; variant binding findings are fixed in the literal direct `pytest.param(..., id=\"TP-...\")` row; primary-symbol cardinality/name findings are fixed in the function name or duplicate primary symbols; script mismatch is fixed only on the authoritative assessment repairPaths; payload findings are fixed in request payload construction. Do not read controller `src/**` or inspect JS/TS analyzer code. Do not search for `testcase/**/README.md`. Never invent a business pytest symbol for evidence-only Markdown Cases that declare `脚本/primary symbol=无` with empty variants. For fixture defects inspect both provider and importer listed by repairPaths; fix ScopeMismatch by aligning fixture scopes or inlining request-scoped values so module fixtures never depend on function fixtures; when a shared fixture depends on sibling fixtures, register the whole provider module through an exact pytest_plugins declaration rather than importing only the outer fixture. Do not create unrelated pytest scripts.",
|
|
4820
4820
|
"This is the single pytest incremental synchronization round. The `Findings` in `reports/backend-test-pytest-collection-initial.md` are the mandatory repair checklist: resolve every repairable listed finding on every authoritative `Repair paths` file before considering any other advisory evidence, and never substitute an unrelated scenario-param cleanup for a listed correspondence/collection defect. For every assessment-listed path, compare the effective Markdown Case/Test Points/test data and its `Payload Contract`/`Payload Required Paths`/`Payload Allowed Paths`/`Payload Enum` labels with the generated module. Incrementally add or repair only missing symbols, params, assertions and payload builders. Repair every assessment-listed missing nested path, unexpected key and enum mismatch; preserve exact DTO keys, nested shapes, enum/boundary literals, operation transport and business preconditions; remove guessed replacement keys only when the effective Markdown proves the exact contract. Keep path/query/header identifiers and scenario-control metadata separate from DTO patches and JSON bodies; an `id` used for a path target must be passed to the request path/helper, never inserted into a body patch unless `id` is explicitly listed in Payload Allowed Paths. Flatten every variant into a literal direct `pytest.param(..., id=\"TP-...\")` row; replace `_post_case`/`_put_case` or other parameter-row factories because correspondence and scenario readiness require the actual row values and IDs to be statically visible. Also repair helper call sites to match their defined return signatures; do not tuple-unpack a helper that returns one scalar value.",
|
|
4821
4821
|
"Preserve final testcase/md/** semantics, every Case ID, Rule/Test Point binding, primary symbol, parameter ID, expected status/body/schema assertion, HTTP logging, redaction and truncation behavior.",
|
|
@@ -504,7 +504,8 @@
|
|
|
504
504
|
"outputContract": "Run-owned reports/backend-test-pytest-collection-initial.md and contracts/backend-test-pytest-collection-initial.json (collection-v3) with bounded collection/fixture diagnostics, repairPaths, asset hashes, collected item IDs and deterministic repair eligibility.",
|
|
505
505
|
"allowedPaths": [
|
|
506
506
|
"testcase/**",
|
|
507
|
-
"docs/test-reports/**"
|
|
507
|
+
"docs/test-reports/**",
|
|
508
|
+
"testcase/**/test_*.py"
|
|
508
509
|
],
|
|
509
510
|
"forbiddenPaths": [
|
|
510
511
|
".harness/**",
|
|
@@ -519,7 +520,7 @@
|
|
|
519
520
|
],
|
|
520
521
|
"runIf": "$.nodes['assess-backend-pytest-collection-shell'].json.repairEligible == true",
|
|
521
522
|
"complexity": "MED",
|
|
522
|
-
"subtask_prompt": "Repair the generated backend pytest asset as one bounded program using the direct upstream collection assessment. This is the only repair attempt and happens before any business test body execution. Treat any upstream line such as `Repair paths: testcase/test_x.py` as
|
|
523
|
+
"subtask_prompt": "Repair the generated backend pytest asset as one bounded program using the direct upstream collection assessment. This is the only repair attempt and happens before any business test body execution. The direct upstream JSON includes authoritative `repairPaths` and bounded `repairFindings`; treat both as the complete mandatory checklist without searching for a run directory or report file. Treat any upstream line such as `Repair paths: testcase/test_x.py` as equivalent authoritative repairPaths evidence. Directly read and edit that testcase path; do not search for separate root-level `contracts/**`, guess a DAG run directory, or require another report artifact. If the read tool successfully returns the testcase file, the path exists—continue the bounded repair and never later claim that file is absent.\n\nInitial status REPAIRABLE means at least one listed finding remains: `already-satisfied` is forbidden, and you must produce a non-empty bounded diff on repairPaths before returning `IMPLEMENTATION_OUTCOME: changed`. Fix only readiness-proven generated testcase-local defects on initial facts repairPaths: create exact safe missing mapped test_*.py paths, repair syntax/import/symbol/decorator/parameterization, close generated fixture dependencies/plugin registration, and repair initial Markdown-to-pytest correspondence findings. Use this deterministic repair map instead of reading analyzer implementation: findings about `Case-ID`, `Assertion-Test-Points`, or `Cross-Cutting-Test-Points` are fixed by editing the declared primary symbol docstring metadata lines; variant binding findings are fixed in the literal direct `pytest.param(..., id=\"TP-...\")` row; primary-symbol cardinality/name findings are fixed in the function name or duplicate primary symbols; script mismatch is fixed only on the authoritative assessment repairPaths; payload findings are fixed in request payload construction. Do not read controller `src/**` or inspect JS/TS analyzer code. Do not search for `testcase/**/README.md`. Never invent a business pytest symbol for evidence-only Markdown Cases that declare `脚本/primary symbol=无` with empty variants. For fixture defects inspect both provider and importer listed by repairPaths; fix ScopeMismatch by aligning fixture scopes or inlining request-scoped values so module fixtures never depend on function fixtures; when a shared fixture depends on sibling fixtures, register the whole provider module through an exact pytest_plugins declaration rather than importing only the outer fixture. Do not create unrelated pytest scripts.\n\nThis is the single pytest incremental synchronization round. The `Findings` in `reports/backend-test-pytest-collection-initial.md` are the mandatory repair checklist: resolve every repairable listed finding on every authoritative `Repair paths` file before considering any other advisory evidence, and never substitute an unrelated scenario-param cleanup for a listed correspondence/collection defect. For every assessment-listed path, compare the effective Markdown Case/Test Points/test data and its `Payload Contract`/`Payload Required Paths`/`Payload Allowed Paths`/`Payload Enum` labels with the generated module. Incrementally add or repair only missing symbols, params, assertions and payload builders. Repair every assessment-listed missing nested path, unexpected key and enum mismatch; preserve exact DTO keys, nested shapes, enum/boundary literals, operation transport and business preconditions; remove guessed replacement keys only when the effective Markdown proves the exact contract. Keep path/query/header identifiers and scenario-control metadata separate from DTO patches and JSON bodies; an `id` used for a path target must be passed to the request path/helper, never inserted into a body patch unless `id` is explicitly listed in Payload Allowed Paths. Flatten every variant into a literal direct `pytest.param(..., id=\"TP-...\")` row; replace `_post_case`/`_put_case` or other parameter-row factories because correspondence and scenario readiness require the actual row values and IDs to be statically visible. Also repair helper call sites to match their defined return signatures; do not tuple-unpack a helper that returns one scalar value.\n\nPreserve final testcase/md/** semantics, every Case ID, Rule/Test Point binding, primary symbol, parameter ID, expected status/body/schema assertion, HTTP logging, redaction and truncation behavior.\n\nUse local edit only on assessment-listed paths; keep summaries short; never rewrite unrelated modules.\n\nDo not reinterpret requirements beyond the effective Markdown and bounded assessment diagnostics. Do not modify Markdown, conftest, pytest config, production code or dependencies.\n\nDo not add skip/skipif/xfail, remove tests, reduce collected items, loosen assertions, swallow exceptions, use try/except ImportError fallback, mutate sys.path/PYTHONPATH, or replace the real API with mocks.\n\nDo not execute pytest; the deterministic effective collection gate owns the final collection attempt.",
|
|
523
524
|
"executor": "pi",
|
|
524
525
|
"role": "implementer",
|
|
525
526
|
"toolProfile": "write",
|