@tea-agent/loop-agent 0.37.0 → 0.37.1-beta.0
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/AGENTS.md +2 -6
- package/CHANGELOG.md +71 -144
- package/README.md +1 -1
- package/bin/loop-agent.js +1 -37
- package/dist/application/task-lifecycle/advance.js +0 -1
- package/dist/application/task-lifecycle/observe.js +0 -15
- package/dist/application/task-lifecycle/plan-transitions.js +0 -15
- package/dist/cli/command-definitions.js +0 -7
- package/dist/cli/program.js +3 -8
- package/dist/commands/init-upgrade.js +19 -351
- package/dist/commands/init.js +67 -14
- package/dist/commands/run-dag-progress.js +0 -14
- package/dist/commands/task-advance.js +3 -33
- package/dist/executors/dag-pi-executor.js +30 -47
- package/dist/executors/pi-sdk-executor.js +0 -34
- package/dist/executors/shell-executor.js +95 -6
- package/dist/shared/operator/capabilities.js +13 -200
- package/dist/shared/package-metadata.js +0 -42
- package/dist/task/contract/project.js +0 -4
- package/dist/task/source-prepare/completeness.js +0 -53
- package/dist/task/source-prepare/parse-intent.js +12 -82
- package/dist/task/source-prepare/prepare.js +5 -102
- package/dist/task/source-prepare/semantic-intake.js +23 -144
- package/dist/worker/console/chat/artifact-card.js +1 -8
- package/dist/worker/console/chat/chat-event-store.js +0 -152
- package/dist/worker/console/chat/human-gate-card.js +1 -9
- package/dist/worker/console/chat/operation-card.js +2 -71
- package/dist/worker/console/chat/pi-runtime.js +62 -111
- package/dist/worker/console/chat/routes.js +10 -43
- package/dist/worker/console/chat/session-store.js +1 -9
- package/dist/worker/console/chat/shortcuts.js +7 -9
- package/dist/worker/console/chat/turn-process.js +23 -204
- package/dist/worker/console/chat/workspace-landing.js +1 -2
- package/dist/worker/console/operation-runner.js +6 -155
- package/dist/worker/console/operator-actions.js +14 -391
- package/dist/worker/console/operator-user-error.js +0 -4
- package/dist/worker/console/recovery-cta.js +3 -50
- package/dist/worker/console/static/assets/index-HX1pbOyl.css +1 -0
- package/dist/worker/console/static/assets/index-M0BLEBfh.js +56 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/app/console-types.js +9 -13
- package/dist/worker/console/static-src/app/useOperatorActions.js +2 -4
- package/dist/worker/console/static-src/app/useRecoveryActions.js +56 -65
- package/dist/worker/console/static-src/app/useRecoveryConsole.js +1 -73
- package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +30 -114
- package/dist/worker/console/static-src/operator-chat/refs.js +0 -3
- package/dist/worker/console/static-src/operator-chat/spatial-overlay.js +1 -2
- package/dist/worker/console/static-src/operator-chat/useChatSessions.js +13 -67
- package/dist/worker/console/static-src/operator-chat/useChatThread.js +6 -12
- package/dist/worker/console/static-src/operator-chat/useComposer.js +8 -45
- package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +3 -7
- package/dist/worker/loop-agent/loop-agent-client.js +3 -17
- package/dist/worker/observability/read-model.js +0 -20
- package/dist/worker/observe/spec-evidence.js +8 -3
- package/dist/worker/observe/static/operator-chrome.d.ts +0 -1
- package/dist/worker/observe/static/operator-chrome.js +3 -10
- package/dist/worker/observe/static/views/dag-inspector.js +71 -6
- package/dist/worker/observe/static/views/dag.js +0 -12
- package/dist/worker/preflight.js +1 -2
- package/dist/workflows/dag/backend-test-case-coverage-analysis.js +707 -37
- package/dist/workflows/dag/backend-test-case-manifest.js +4 -0
- package/dist/workflows/dag/backend-test-markdown-workflow.js +109 -7
- package/dist/workflows/dag/backend-test-module-stem.js +5 -0
- package/dist/workflows/dag/backend-test-pytest-collection.js +345 -24
- package/dist/workflows/dag/backend-test-scenario-param.js +890 -164
- package/dist/workflows/dag/backend-test-writer-completeness.js +47 -16
- package/dist/workflows/dag/dynamic-runtime/map.js +24 -8
- package/dist/workflows/dag/dynamic-runtime/shared.js +1 -9
- package/dist/workflows/dag/frontend-implementation-contract.js +39 -233
- package/dist/workflows/dag/frontend-prewrite-gate.js +61 -364
- package/dist/workflows/dag/frontend-repair.js +18 -219
- package/dist/workflows/dag/frontend-verification-trace.js +32 -47
- package/dist/workflows/dag/init-hybrid.js +82 -165
- package/dist/workflows/dag/node-execution.js +0 -89
- package/dist/workflows/dag/recovery-recommendation.js +0 -58
- package/dist/workflows/dag/runner.js +15 -333
- package/dist/workflows/dag/scheduler.js +3 -257
- package/dist/workflows/dag/types.js +9 -130
- package/docs/README.md +3 -3
- package/docs/architecture/evolution.md +67 -102
- package/docs/templates/backend-test-dag.json +50 -32
- package/docs/templates/init-managed-agents.md +2 -5
- package/harness.json +2 -2
- package/package.json +3 -9
- package/skills/loop-agent/SKILL.md +0 -1
- package/skills/loop-agent/references/command-reference.md +0 -4
- package/dist/build-stamp.json +0 -6
- package/dist/commands/dag-request-interrupt.js +0 -20
- package/dist/worker/console/chat/assistant-content.js +0 -121
- package/dist/worker/console/chat/semantic-activity.js +0 -477
- package/dist/worker/console/operation-run-facts.js +0 -190
- package/dist/worker/console/operation-wait.js +0 -355
- package/dist/worker/console/recovery-error-copy.js +0 -198
- package/dist/worker/console/static/assets/abnfDiagram-N423BO3Z-DSvF6RXQ.js +0 -1
- package/dist/worker/console/static/assets/arc-DuI4ogtJ.js +0 -1
- package/dist/worker/console/static/assets/architectureDiagram-T3A2C74G-DdBbgD4y.js +0 -36
- package/dist/worker/console/static/assets/blockDiagram-VBNYF7ZC-xQC2HELm.js +0 -132
- package/dist/worker/console/static/assets/c4Diagram-5PPSVZJV-DPMSgwIZ.js +0 -10
- package/dist/worker/console/static/assets/channel-DLVz9TYW.js +0 -1
- package/dist/worker/console/static/assets/chunk-2GRJ4B5K-WTEKTxin.js +0 -1
- package/dist/worker/console/static/assets/chunk-2Q5K7J3B-B6vZ0Pv_.js +0 -1
- package/dist/worker/console/static/assets/chunk-5RXB4S5H-BrerNAix.js +0 -231
- package/dist/worker/console/static/assets/chunk-5VM5RSS4-D_ch2aaI.js +0 -15
- package/dist/worker/console/static/assets/chunk-6Q2QTUOP-VvfacNrq.js +0 -88
- package/dist/worker/console/static/assets/chunk-GF5L2VYU-B-uGFIg2.js +0 -206
- package/dist/worker/console/static/assets/chunk-JWPE2WC7-BlysCtod.js +0 -1
- package/dist/worker/console/static/assets/chunk-KBJHAD2P-BjAUAfcT.js +0 -1
- package/dist/worker/console/static/assets/chunk-RYQCIY6F-BeZnBmb-.js +0 -1
- package/dist/worker/console/static/assets/chunk-XXDRQBXY-Bbfe3UJG.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/cose-bilkent-JH36ORCC-BKJQnYVM.js +0 -1
- package/dist/worker/console/static/assets/cynefin-VYW2F7L2-B1wtI3hU.js +0 -166
- package/dist/worker/console/static/assets/cynefinDiagram-MW4NZA55-DCF6DBSu.js +0 -62
- package/dist/worker/console/static/assets/cytoscape.esm-yzknjiTM.js +0 -321
- package/dist/worker/console/static/assets/dagre-VZM6K2ZE-BJyNGRlP.js +0 -4
- package/dist/worker/console/static/assets/defaultLocale-DX6XiGOO.js +0 -1
- package/dist/worker/console/static/assets/diagram-7IWD3JNH-B7j1-VsZ.js +0 -30
- package/dist/worker/console/static/assets/diagram-B4RE2ZJO-BREJ140a.js +0 -3
- package/dist/worker/console/static/assets/diagram-LBJQPF4R-CDPHmHGH.js +0 -24
- package/dist/worker/console/static/assets/diagram-Q27KOJAE-BtZLrez1.js +0 -24
- package/dist/worker/console/static/assets/diagram-UB23O5K3-mzybu8oU.js +0 -41
- package/dist/worker/console/static/assets/ebnfDiagram-BXEA7PRR-Dk3I2uO6.js +0 -1
- package/dist/worker/console/static/assets/erDiagram-JOGREHBK-BT97u_tQ.js +0 -85
- package/dist/worker/console/static/assets/flowDiagram-UKHOOZJN-CMOtYzui.js +0 -156
- package/dist/worker/console/static/assets/ganttDiagram-PKOTCBZU-DiNydkuY.js +0 -292
- package/dist/worker/console/static/assets/gitGraphDiagram-DS77QQ5N-Cwt6fLkl.js +0 -106
- package/dist/worker/console/static/assets/graph-DOmOIIwC.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/infoDiagram-6WML65LV-AsGIGKKB.js +0 -2
- package/dist/worker/console/static/assets/init-Gi6I4Gst.js +0 -1
- package/dist/worker/console/static/assets/ishikawaDiagram-WSZJBQD7-BL8ITZh3.js +0 -70
- package/dist/worker/console/static/assets/journeyDiagram-NVQOT4AX-VPKAUX8M.js +0 -139
- package/dist/worker/console/static/assets/kanban-definition-27J2QSJJ-BL56hfGp.js +0 -89
- package/dist/worker/console/static/assets/layout-D-LzfAck.js +0 -1
- package/dist/worker/console/static/assets/linear-BmSG3nIZ.js +0 -1
- package/dist/worker/console/static/assets/map-DxJ2ADlA.js +0 -1
- package/dist/worker/console/static/assets/mermaid.core-DWGK9kwP.js +0 -308
- package/dist/worker/console/static/assets/mindmap-definition-FAOFIHXS--vvKLjo0.js +0 -96
- package/dist/worker/console/static/assets/ordinal-Cboi1Yqb.js +0 -1
- package/dist/worker/console/static/assets/pegDiagram-VL7TDLO6-B41mxb2-.js +0 -1
- package/dist/worker/console/static/assets/pieDiagram-7S7Q4E2Y-DTlu77sw.js +0 -39
- package/dist/worker/console/static/assets/quadrantDiagram-CIZ2JOQS-Bk5A7alv.js +0 -7
- package/dist/worker/console/static/assets/railroadDiagram-AXF67PYL-Csf9jzQy.js +0 -1
- package/dist/worker/console/static/assets/requirementDiagram-LRYGKXZP-CeptoSNx.js +0 -84
- package/dist/worker/console/static/assets/sankeyDiagram-W5VNT64P-A-DLCLer.js +0 -40
- package/dist/worker/console/static/assets/sequenceDiagram-SI44F4Z6-22VbPdOI.js +0 -162
- package/dist/worker/console/static/assets/sizeCapture-X5ZJPWSS-DWvAyBC9.js +0 -1
- package/dist/worker/console/static/assets/stateDiagram-OKZ733FA-qICZ75yT.js +0 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DzKvfHTX.js +0 -1
- package/dist/worker/console/static/assets/swimlanes-SLNWSIFB-xi7lcMyp.js +0 -2
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Cxqr_qM1.js +0 -8
- package/dist/worker/console/static/assets/timeline-definition-Z64GVDOM-Bu6Nl6O2.js +0 -120
- package/dist/worker/console/static/assets/vennDiagram-T6HMQDX7-BzMz4Bn9.js +0 -34
- package/dist/worker/console/static/assets/wardleyDiagram-T6FBY63Y-CxaJnzc5.js +0 -78
- package/dist/worker/console/static/assets/xychartDiagram-ELKLHX3M-5JK-omQK.js +0 -7
- package/dist/worker/console/static/fonts/katex/KaTeX_AMS-Regular.woff2 +0 -0
- package/dist/worker/console/static/fonts/katex/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/dist/worker/console/static/fonts/katex/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/dist/worker/console/static/fonts/katex/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/dist/worker/console/static/fonts/katex/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/dist/worker/console/static/fonts/katex/KaTeX_Main-Bold.woff2 +0 -0
- package/dist/worker/console/static/fonts/katex/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/dist/worker/console/static/fonts/katex/KaTeX_Main-Italic.woff2 +0 -0
- package/dist/worker/console/static/fonts/katex/KaTeX_Main-Regular.woff2 +0 -0
- package/dist/worker/console/static/fonts/katex/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/dist/worker/console/static/fonts/katex/KaTeX_Math-Italic.woff2 +0 -0
- package/dist/worker/console/static/fonts/katex/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/dist/worker/console/static/fonts/katex/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/dist/worker/console/static/fonts/katex/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/dist/worker/console/static/fonts/katex/KaTeX_Script-Regular.woff2 +0 -0
- package/dist/worker/console/static/fonts/katex/KaTeX_Size1-Regular.woff2 +0 -0
- package/dist/worker/console/static/fonts/katex/KaTeX_Size2-Regular.woff2 +0 -0
- package/dist/worker/console/static/fonts/katex/KaTeX_Size3-Regular.woff2 +0 -0
- package/dist/worker/console/static/fonts/katex/KaTeX_Size4-Regular.woff2 +0 -0
- package/dist/worker/console/static/fonts/katex/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/dist/worker/console/static-src/operator-chat/activity-journey.js +0 -125
- package/dist/worker/console/static-src/operator-chat/activity-rail-presentation.js +0 -73
- package/dist/worker/console/static-src/operator-chat/activity-references.js +0 -20
- package/dist/worker/console/static-src/operator-chat/slash-palette-layout.js +0 -24
- package/dist/worker/console/static-src/operator-chat/slash-palette-nav.js +0 -141
- package/dist/worker/console/static-src/operator-chat/useActivityRailTransition.js +0 -59
- package/dist/worker/observability/interrupt-eligibility.js +0 -264
- package/dist/workflows/dag/contract-output-registry.js +0 -14
- package/dist/workflows/dag/contract-validator-registrations.js +0 -8
- package/dist/workflows/dag/frontend-recovery-plan.js +0 -73
- package/dist/workflows/dag/frontend-recovery-root-manifest.js +0 -123
- package/dist/workflows/dag/frontend-recovery-run.js +0 -539
- package/dist/workflows/dag/frontend-writer-recovery.js +0 -106
- package/dist/workflows/dag/frontend-writer-rollback.js +0 -821
- package/dist/workflows/dag/interrupt-request.js +0 -559
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
2
3
|
import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { z } from "zod";
|
|
@@ -8,7 +9,15 @@ const CASE_HEADING = /^##\s+(BE-[A-Z0-9_-]+-\d{3})\b.*$/gm;
|
|
|
8
9
|
const CASE_ID_IN_TEXT = /\bBE-[A-Z0-9_-]+-\d{3}\b/g;
|
|
9
10
|
const NON_CANONICAL_CASE_HEADING = /^##\s+(BE-[A-Z0-9_-]+-(?:\d{2}|\d{2,3}[A-Z]+))\b.*$/gm;
|
|
10
11
|
const AC_ID_IN_TEXT = /\bAC-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/g;
|
|
11
|
-
const
|
|
12
|
+
const SOURCE_RULE_ID_IN_TEXT = /\b(?:AC|REQ|BR)-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/g;
|
|
13
|
+
const SOURCE_ERROR_CODE_IN_TEXT = /\b[A-Z][0-9]{5,}\b/g;
|
|
14
|
+
const JSON_FIELD_IN_TEXT = /["']([A-Za-z][A-Za-z0-9_]*)["']\s*:/g;
|
|
15
|
+
const DTO_FIELD_IN_TEXT = /\b(?:private|public|protected)\s+(?:static\s+)?(?:final\s+)?[A-Za-z_$][A-Za-z0-9_$<>,.?\[\] ]*\s+([a-z][A-Za-z0-9_]*)\s*[;=]/g;
|
|
16
|
+
const NATURAL_BUSINESS_RULE_KEYWORDS = /(?:事务|原子性|回滚|幂等|唯一性|唯一|状态转换|状态流转|权限|角色|租户|跨字段|transaction|atomic|rollback|idempoten|unique|state transition|authorization|permission|tenant|cross-field)/i;
|
|
17
|
+
const NATURAL_BUSINESS_RULE_NORMATIVE = /(?:必须|不得|禁止|仅当|应当|需要|不能|保证|must|shall|required|must not|only when|guarantee)/i;
|
|
18
|
+
// Token form is used for section scanning; anchored form validates full cells.
|
|
19
|
+
const RULE_KEY_TOKEN = /(?:AC|REQ|BR|API|GET|POST|PUT|PATCH|DELETE)-[A-Z0-9]+(?:-[A-Z0-9]+)*|[A-Z][0-9]{5,}/;
|
|
20
|
+
const RULE_KEY = new RegExp(`^(?:${RULE_KEY_TOKEN.source})$`);
|
|
12
21
|
const TEST_POINT = /^TP-[A-Z0-9]+(?:-[A-Z0-9]+)*$/;
|
|
13
22
|
const COVERAGE_HEADERS = [
|
|
14
23
|
"Rule Key",
|
|
@@ -87,6 +96,13 @@ const coverageCaseSchema = z.object({
|
|
|
87
96
|
scenarioTypes: z.array(z.string()),
|
|
88
97
|
declaredScripts: z.array(z.string()),
|
|
89
98
|
declaredPrimarySymbols: z.array(z.string()),
|
|
99
|
+
payloadContract: z.object({
|
|
100
|
+
declared: z.boolean(),
|
|
101
|
+
noPayload: z.boolean(),
|
|
102
|
+
requiredPaths: z.array(z.string()),
|
|
103
|
+
allowedPaths: z.array(z.string()),
|
|
104
|
+
enumValues: z.record(z.string(), z.array(z.string())),
|
|
105
|
+
}).strict(),
|
|
90
106
|
}).strict();
|
|
91
107
|
const coverageRuleSchema = z.object({
|
|
92
108
|
ruleKey: z.string().min(1),
|
|
@@ -102,6 +118,23 @@ const coverageRuleSchema = z.object({
|
|
|
102
118
|
missingTestPoints: z.array(z.string()),
|
|
103
119
|
status: z.enum(["COVERED", "PARTIAL", "GAP", "CONFLICT"]),
|
|
104
120
|
}).strict();
|
|
121
|
+
const sourceCoverageFactSchema = z.object({
|
|
122
|
+
kind: z.enum(["requirement-id", "error-code", "dto-field", "business-rule"]),
|
|
123
|
+
key: z.string().min(1),
|
|
124
|
+
sourcePath: z.string().min(1),
|
|
125
|
+
sourceSha256: sha256Schema,
|
|
126
|
+
lineStart: z.number().int().positive(),
|
|
127
|
+
lineEnd: z.number().int().positive(),
|
|
128
|
+
quote: z.string().min(1).max(1000),
|
|
129
|
+
covered: z.boolean(),
|
|
130
|
+
}).strict();
|
|
131
|
+
const sourceCoverageSchema = z.object({
|
|
132
|
+
sourceFactCount: z.number().int().min(0),
|
|
133
|
+
coveredSourceFactCount: z.number().int().min(0),
|
|
134
|
+
residualSourceFactCount: z.number().int().min(0),
|
|
135
|
+
facts: z.array(sourceCoverageFactSchema),
|
|
136
|
+
residual: z.array(sourceCoverageFactSchema),
|
|
137
|
+
}).strict();
|
|
105
138
|
const coverageScopeSchema = z.object({
|
|
106
139
|
changeClassification: z.enum(CHANGE_CLASSIFICATIONS),
|
|
107
140
|
coveragePolicy: z.enum(COVERAGE_POLICIES),
|
|
@@ -112,13 +145,14 @@ const coverageScopeSchema = z.object({
|
|
|
112
145
|
completenessClaim: z.enum(["affected-operations-full", "affected-scope"]),
|
|
113
146
|
}).strict();
|
|
114
147
|
export const backendTestCaseCoverageFactsSchema = z.object({
|
|
115
|
-
schemaId: z.literal("backend-test-case-coverage-facts-
|
|
116
|
-
schemaVersion: z.literal(
|
|
148
|
+
schemaId: z.literal("backend-test-case-coverage-facts-v4"),
|
|
149
|
+
schemaVersion: z.literal(4),
|
|
117
150
|
taskId: z.string().min(1),
|
|
118
151
|
status: z.enum(["PASS", "FAIL", "UNAVAILABLE"]),
|
|
119
152
|
sourceBinding: sourceBindingSchema,
|
|
120
153
|
inputFiles: z.array(inputFileSchema),
|
|
121
154
|
coverageScope: coverageScopeSchema,
|
|
155
|
+
sourceCoverage: sourceCoverageSchema,
|
|
122
156
|
summary: z.object({
|
|
123
157
|
explicitAcCount: z.number().int().min(0),
|
|
124
158
|
coveredAcCount: z.number().int().min(0),
|
|
@@ -177,12 +211,24 @@ const correspondenceEntrySchema = z.object({
|
|
|
177
211
|
"CROSS_CUTTING_EVIDENCE_MISSING",
|
|
178
212
|
"TEST_POINT_BINDING_DUPLICATE",
|
|
179
213
|
"TEST_POINT_BINDING_EXTRA",
|
|
214
|
+
"PAYLOAD_CONTRACT_UNAVAILABLE",
|
|
215
|
+
"PAYLOAD_SHAPE_MISMATCH",
|
|
180
216
|
]),
|
|
217
|
+
payloadAssessment: z.object({
|
|
218
|
+
status: z.enum(["SAFE", "UNSAFE", "UNAVAILABLE"]),
|
|
219
|
+
requiredPaths: z.array(z.string()),
|
|
220
|
+
allowedPaths: z.array(z.string()),
|
|
221
|
+
observedPaths: z.array(z.string()),
|
|
222
|
+
missingPaths: z.array(z.string()),
|
|
223
|
+
unexpectedPaths: z.array(z.string()),
|
|
224
|
+
enumMismatches: z.array(z.string()),
|
|
225
|
+
}).strict(),
|
|
226
|
+
executionSignature: z.string().min(1).optional(),
|
|
181
227
|
findings: z.array(z.string()),
|
|
182
228
|
}).strict();
|
|
183
229
|
export const backendTestMarkdownPytestCorrespondenceFactsSchema = z.object({
|
|
184
|
-
schemaId: z.literal("backend-test-markdown-pytest-correspondence-facts-
|
|
185
|
-
schemaVersion: z.literal(
|
|
230
|
+
schemaId: z.literal("backend-test-markdown-pytest-correspondence-facts-v3"),
|
|
231
|
+
schemaVersion: z.literal(3),
|
|
186
232
|
taskId: z.string().min(1),
|
|
187
233
|
status: z.enum(["PASS", "FAIL", "UNAVAILABLE"]),
|
|
188
234
|
inputFiles: z.array(inputFileSchema),
|
|
@@ -203,6 +249,10 @@ export const backendTestMarkdownPytestCorrespondenceFactsSchema = z.object({
|
|
|
203
249
|
crossCuttingTestPointCount: z.number().int().min(0),
|
|
204
250
|
unclassifiedTestPointCount: z.number().int().min(0),
|
|
205
251
|
duplicateBindingTestPointCount: z.number().int().min(0),
|
|
252
|
+
payloadSafeCount: z.number().int().min(0),
|
|
253
|
+
payloadUnsafeCount: z.number().int().min(0),
|
|
254
|
+
payloadUnavailableCount: z.number().int().min(0),
|
|
255
|
+
duplicateExecutionSignatureCount: z.number().int().min(0),
|
|
206
256
|
}).strict(),
|
|
207
257
|
entries: z.array(correspondenceEntrySchema),
|
|
208
258
|
findings: z.array(z.string()),
|
|
@@ -236,6 +286,64 @@ function openApiTestPoints(field, dimension, values = []) {
|
|
|
236
286
|
return [`${prefix}-VALID-CLASS`, `${prefix}-VALID-MIXED`, `${prefix}-UPPERCASE`, `${prefix}-WHITESPACE`, `${prefix}-PUNCTUATION`, `${prefix}-SLASH`, `${prefix}-EMOJI`, `${prefix}-CONTROL`];
|
|
237
287
|
return [`${prefix}-PRESENT`, `${prefix}-MISSING`, `${prefix}-NULL`, `${prefix}-WRONG-TYPE`];
|
|
238
288
|
}
|
|
289
|
+
async function collectSourceCoverageFacts(input) {
|
|
290
|
+
const paths = orderedUnique([
|
|
291
|
+
input.sourceBinding.requirementPath,
|
|
292
|
+
...input.sourceBinding.referencePaths,
|
|
293
|
+
]);
|
|
294
|
+
const facts = [];
|
|
295
|
+
const seen = new Set();
|
|
296
|
+
for (const sourcePath of paths) {
|
|
297
|
+
const physicalPath = resolveBoundSourcePath(input.workspaceRoot, input.sourceBinding.taskId, sourcePath);
|
|
298
|
+
let text;
|
|
299
|
+
try {
|
|
300
|
+
text = await readFile(physicalPath, "utf8");
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
const sourceSha256 = createHash("sha256").update(await readFile(physicalPath)).digest("hex");
|
|
306
|
+
const lines = text.split(/\r?\n/);
|
|
307
|
+
const add = (kind, key, lineIndex, quote) => {
|
|
308
|
+
const normalizedQuote = quote.trim().slice(0, 1000);
|
|
309
|
+
if (!normalizedQuote)
|
|
310
|
+
return;
|
|
311
|
+
const identity = `${kind}:${key}:${sourcePath}:${lineIndex + 1}`;
|
|
312
|
+
if (seen.has(identity))
|
|
313
|
+
return;
|
|
314
|
+
seen.add(identity);
|
|
315
|
+
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
316
|
+
const covered = kind === "business-rule"
|
|
317
|
+
? input.generatedMarkdown.includes(normalizedQuote)
|
|
318
|
+
: new RegExp(`\\b${escaped}\\b`).test(input.generatedMarkdown);
|
|
319
|
+
facts.push({
|
|
320
|
+
kind,
|
|
321
|
+
key,
|
|
322
|
+
sourcePath,
|
|
323
|
+
sourceSha256,
|
|
324
|
+
lineStart: lineIndex + 1,
|
|
325
|
+
lineEnd: lineIndex + 1,
|
|
326
|
+
quote: normalizedQuote,
|
|
327
|
+
covered,
|
|
328
|
+
});
|
|
329
|
+
};
|
|
330
|
+
lines.forEach((line, lineIndex) => {
|
|
331
|
+
for (const key of line.match(SOURCE_RULE_ID_IN_TEXT) ?? [])
|
|
332
|
+
add("requirement-id", key, lineIndex, line);
|
|
333
|
+
for (const key of line.match(SOURCE_ERROR_CODE_IN_TEXT) ?? [])
|
|
334
|
+
add("error-code", key, lineIndex, line);
|
|
335
|
+
for (const match of line.matchAll(JSON_FIELD_IN_TEXT))
|
|
336
|
+
add("dto-field", match[1], lineIndex, line);
|
|
337
|
+
for (const match of line.matchAll(DTO_FIELD_IN_TEXT))
|
|
338
|
+
add("dto-field", match[1], lineIndex, line);
|
|
339
|
+
const trimmed = line.trim();
|
|
340
|
+
if (trimmed.length >= 8 && trimmed.length <= 1000 && !/^\s*(?:#|\||[-:]{3,})/.test(trimmed) && NATURAL_BUSINESS_RULE_KEYWORDS.test(trimmed) && NATURAL_BUSINESS_RULE_NORMATIVE.test(trimmed)) {
|
|
341
|
+
add("business-rule", `BR-SOURCE-${sourceSha256.slice(0, 8).toUpperCase()}-L${lineIndex + 1}`, lineIndex, trimmed);
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
return facts.sort((left, right) => left.kind.localeCompare(right.kind) || left.key.localeCompare(right.key) || left.sourcePath.localeCompare(right.sourcePath) || left.lineStart - right.lineStart);
|
|
346
|
+
}
|
|
239
347
|
function resolveBoundSourcePath(workspaceRoot, taskId, sourcePath) {
|
|
240
348
|
if (path.isAbsolute(sourcePath))
|
|
241
349
|
return sourcePath;
|
|
@@ -385,8 +493,14 @@ function sectionBody(body, aliases) {
|
|
|
385
493
|
}
|
|
386
494
|
function listTokens(body, aliases, pattern) {
|
|
387
495
|
const section = sectionBody(body, aliases).replaceAll("`", "");
|
|
388
|
-
|
|
389
|
-
|
|
496
|
+
// Prefer dedicated token patterns; never strip ^/$ from alternation-based anchors
|
|
497
|
+
// (that would turn `^A$|^B$` into `A$|^B` and break scanning).
|
|
498
|
+
const tokenSource = pattern === RULE_KEY
|
|
499
|
+
? RULE_KEY_TOKEN.source
|
|
500
|
+
: pattern === TEST_POINT
|
|
501
|
+
? TEST_POINT.source.replace(/^\^/, "").replace(/\$$/, "")
|
|
502
|
+
: pattern.source.replace(/^\^/, "").replace(/\$$/, "");
|
|
503
|
+
return orderedUnique(section.match(new RegExp(tokenSource, "g")) ?? []);
|
|
390
504
|
}
|
|
391
505
|
function scenarioTypes(body) {
|
|
392
506
|
return orderedUnique(sectionBody(body, CASE_SECTION_ALIASES.scenarioTypes)
|
|
@@ -423,8 +537,40 @@ function testPointBindingFacts(body, testPoints) {
|
|
|
423
537
|
}
|
|
424
538
|
function declaredPrimarySymbols(body) {
|
|
425
539
|
const value = automationLabelBody(body, ["primary symbol", "Primary Symbol", "主测试符号"]);
|
|
540
|
+
if (/^(?:none|无|n\/a|not-applicable)$/i.test(value.trim()))
|
|
541
|
+
return [];
|
|
426
542
|
return orderedUnique(value.match(/\btest_[A-Za-z0-9_]+\b/g) ?? []);
|
|
427
543
|
}
|
|
544
|
+
function isEvidenceOnlyMarkdownCase(testCase) {
|
|
545
|
+
const hasVariant = testCase.testPointBindings.some((item) => item.mode === "variant");
|
|
546
|
+
const hasNonVariant = testCase.testPointBindings.some((item) => item.mode === "assertion" || item.mode === "cross-cutting");
|
|
547
|
+
return (testCase.declaredScripts.length === 0 &&
|
|
548
|
+
testCase.declaredPrimarySymbols.length === 0 &&
|
|
549
|
+
!hasVariant &&
|
|
550
|
+
hasNonVariant);
|
|
551
|
+
}
|
|
552
|
+
function payloadContract(body) {
|
|
553
|
+
const contractValue = automationLabelBody(body, ["Payload Contract", "载荷合同"]);
|
|
554
|
+
const requiredValue = automationLabelBody(body, ["Payload Required Paths", "载荷必需路径"]);
|
|
555
|
+
const allowedValue = automationLabelBody(body, ["Payload Allowed Paths", "载荷允许路径"]);
|
|
556
|
+
const enumValue = automationLabelBody(body, ["Payload Enum", "载荷枚举"]);
|
|
557
|
+
const tokens = (value) => orderedUnique(value.split(/[,;,;]/).map((item) => item.trim()).filter((item) => /^[A-Za-z_][A-Za-z0-9_.]*$/.test(item)));
|
|
558
|
+
const enumValues = {};
|
|
559
|
+
for (const assignment of enumValue.split(/[,;,;]/).map((item) => item.trim()).filter(Boolean)) {
|
|
560
|
+
const match = /^([A-Za-z_][A-Za-z0-9_.]*)\s*(?:=|:)\s*(.+)$/.exec(assignment);
|
|
561
|
+
if (!match)
|
|
562
|
+
continue;
|
|
563
|
+
enumValues[match[1]] = orderedUnique(match[2].split(/\||\//).map((item) => item.trim().replace(/[((].*$/, "").trim().replace(/^['"]|['"]$/g, "")).filter(Boolean));
|
|
564
|
+
}
|
|
565
|
+
const noPayload = /^(?:none|无|not-applicable|n\/a)(?:\s|[((]|$)/i.test(contractValue.trim());
|
|
566
|
+
return {
|
|
567
|
+
declared: noPayload || requiredValue.length > 0 || allowedValue.length > 0 || enumValue.length > 0,
|
|
568
|
+
noPayload,
|
|
569
|
+
requiredPaths: tokens(requiredValue),
|
|
570
|
+
allowedPaths: tokens(allowedValue),
|
|
571
|
+
enumValues,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
428
574
|
function cleanTitle(heading, id) {
|
|
429
575
|
return heading.replace(/^##\s+/, "").replace(/^BE-[A-Z0-9_-]+-\d{2,3}\s*(?:[||—–-]\s*)?/i, "").trim() || id;
|
|
430
576
|
}
|
|
@@ -466,6 +612,7 @@ async function parseMarkdownCases(workspaceRoot) {
|
|
|
466
612
|
scenarioTypes: scenarioTypes(body),
|
|
467
613
|
declaredScripts: declaredScripts(body),
|
|
468
614
|
declaredPrimarySymbols: declaredPrimarySymbols(body),
|
|
615
|
+
payloadContract: payloadContract(body),
|
|
469
616
|
body,
|
|
470
617
|
});
|
|
471
618
|
}
|
|
@@ -483,8 +630,17 @@ function parseTableRow(line) {
|
|
|
483
630
|
index += 1;
|
|
484
631
|
}
|
|
485
632
|
else if (char === "|") {
|
|
486
|
-
|
|
487
|
-
|
|
633
|
+
const prev = trimmed[index - 1] ?? "";
|
|
634
|
+
const next = trimmed[index + 1] ?? "";
|
|
635
|
+
// In-cell enum/value pipes look like ACTIVE|ARCHIVED (alnum on both sides).
|
|
636
|
+
// Column separators use " | " or touch markdown separator dashes (---|---).
|
|
637
|
+
if (/[A-Za-z0-9_]/.test(prev) && /[A-Za-z0-9_]/.test(next)) {
|
|
638
|
+
current += "|";
|
|
639
|
+
}
|
|
640
|
+
else {
|
|
641
|
+
cells.push(current.trim());
|
|
642
|
+
current = "";
|
|
643
|
+
}
|
|
488
644
|
}
|
|
489
645
|
else {
|
|
490
646
|
current += char;
|
|
@@ -687,7 +843,10 @@ export async function analyzeBackendTestCaseCoverage(input) {
|
|
|
687
843
|
if (!rowCaseIds.includes(testCase.caseId))
|
|
688
844
|
findings.push(`${testCase.caseId} references ${ruleKey} but is absent from its Coverage Matrix Case IDs`);
|
|
689
845
|
}
|
|
690
|
-
|
|
846
|
+
const normalizedDeclaredStatus = /^(?:COVERED|PARTIAL|GAP|CONFLICT)$/.test(declaredStatus)
|
|
847
|
+
? declaredStatus
|
|
848
|
+
: "PARTIAL";
|
|
849
|
+
let status = normalizedDeclaredStatus;
|
|
691
850
|
if (status === "COVERED" && missingTestPoints.length > 0)
|
|
692
851
|
status = "PARTIAL";
|
|
693
852
|
if (missingTestPoints.length > 0)
|
|
@@ -707,7 +866,7 @@ export async function analyzeBackendTestCaseCoverage(input) {
|
|
|
707
866
|
rule: row.Rule || "unavailable",
|
|
708
867
|
requiredTestPoints,
|
|
709
868
|
caseIds: rowCaseIds,
|
|
710
|
-
declaredStatus:
|
|
869
|
+
declaredStatus: normalizedDeclaredStatus,
|
|
711
870
|
coveredTestPoints,
|
|
712
871
|
missingTestPoints,
|
|
713
872
|
status,
|
|
@@ -749,8 +908,12 @@ export async function analyzeBackendTestCaseCoverage(input) {
|
|
|
749
908
|
findings.push(`${testCase.caseId} missing section or entries: 测试点`);
|
|
750
909
|
if (testCase.scenarioTypes.length === 0)
|
|
751
910
|
findings.push(`${testCase.caseId} missing section or entries: 场景类型`);
|
|
752
|
-
if (testCase
|
|
911
|
+
if (isEvidenceOnlyMarkdownCase(testCase)) {
|
|
912
|
+
// Evidence-only meta cases intentionally declare 脚本/primary symbol as 无 and only keep assertion/cross-cutting TPs.
|
|
913
|
+
}
|
|
914
|
+
else if (testCase.declaredPrimarySymbols.length !== 1) {
|
|
753
915
|
findings.push(`${testCase.caseId} must declare exactly one primary symbol`);
|
|
916
|
+
}
|
|
754
917
|
if (testCase.unclassifiedTestPoints.length > 0)
|
|
755
918
|
findings.push(`${testCase.caseId} has unclassified Test Points: ${testCase.unclassifiedTestPoints.join(", ")}`);
|
|
756
919
|
if (testCase.duplicateBindingTestPoints.length > 0)
|
|
@@ -772,8 +935,28 @@ export async function analyzeBackendTestCaseCoverage(input) {
|
|
|
772
935
|
if (uniqueOwners.length > 1)
|
|
773
936
|
findings.push(`${testPoint} is bound as a non-cross-cutting Test Point by multiple Cases: ${uniqueOwners.join(", ")}`);
|
|
774
937
|
}
|
|
775
|
-
const
|
|
938
|
+
const generatedMarkdown = [readme, ...cases.map((item) => item.body)].join("\n\n");
|
|
939
|
+
const sourceCoverageFacts = await collectSourceCoverageFacts({
|
|
940
|
+
workspaceRoot: input.workspaceRoot,
|
|
941
|
+
sourceBinding: input.sourceBinding,
|
|
942
|
+
generatedMarkdown,
|
|
943
|
+
});
|
|
944
|
+
const residualSourceFacts = sourceCoverageFacts.filter((fact) => !fact.covered);
|
|
945
|
+
for (const fact of residualSourceFacts) {
|
|
946
|
+
findings.push(`source coverage is missing ${fact.kind} ${fact.key} from ${fact.sourcePath}`);
|
|
947
|
+
}
|
|
948
|
+
const sourceRequirementIds = sourceCoverageFacts
|
|
949
|
+
.filter((fact) => fact.kind === "requirement-id")
|
|
950
|
+
.map((fact) => fact.key);
|
|
951
|
+
const explicitAc = orderedUnique([
|
|
952
|
+
...input.sourceBinding.requirementIds.filter((id) => id.startsWith("AC-")),
|
|
953
|
+
...sourceRequirementIds.filter((id) => id.startsWith("AC-")),
|
|
954
|
+
]);
|
|
776
955
|
const coveredAc = new Set(cases.flatMap((item) => item.acIds));
|
|
956
|
+
for (const acId of explicitAc) {
|
|
957
|
+
if (!coveredAc.has(acId))
|
|
958
|
+
findings.push(`explicit source AC is missing from Markdown Cases: ${acId}`);
|
|
959
|
+
}
|
|
777
960
|
const [enumValueCount, coveredEnumValueCount] = dimensionCounts(rules, /enum/);
|
|
778
961
|
const [boundaryPointCount, coveredBoundaryPointCount] = dimensionCounts(rules, /boundary|length|numeric/);
|
|
779
962
|
const [formatClassCount, coveredFormatClassCount] = dimensionCounts(rules, /format|pattern|charset/);
|
|
@@ -782,8 +965,8 @@ export async function analyzeBackendTestCaseCoverage(input) {
|
|
|
782
965
|
const invalidEquivalenceClassCount = rules.reduce((sum, rule) => sum + rule.requiredTestPoints.filter((point) => invalidPointPattern.test(point)).length, 0);
|
|
783
966
|
const coveredInvalidEquivalenceClassCount = rules.reduce((sum, rule) => sum + rule.coveredTestPoints.filter((point) => invalidPointPattern.test(point)).length, 0);
|
|
784
967
|
const facts = backendTestCaseCoverageFactsSchema.parse({
|
|
785
|
-
schemaId: "backend-test-case-coverage-facts-
|
|
786
|
-
schemaVersion:
|
|
968
|
+
schemaId: "backend-test-case-coverage-facts-v4",
|
|
969
|
+
schemaVersion: 4,
|
|
787
970
|
taskId: input.sourceBinding.taskId,
|
|
788
971
|
status: findings.length === 0 ? "PASS" : "FAIL",
|
|
789
972
|
sourceBinding: input.sourceBinding,
|
|
@@ -796,6 +979,13 @@ export async function analyzeBackendTestCaseCoverage(input) {
|
|
|
796
979
|
}))).filter(Boolean),
|
|
797
980
|
]),
|
|
798
981
|
coverageScope: coverageScope.scope,
|
|
982
|
+
sourceCoverage: {
|
|
983
|
+
sourceFactCount: sourceCoverageFacts.length,
|
|
984
|
+
coveredSourceFactCount: sourceCoverageFacts.length - residualSourceFacts.length,
|
|
985
|
+
residualSourceFactCount: residualSourceFacts.length,
|
|
986
|
+
facts: sourceCoverageFacts,
|
|
987
|
+
residual: residualSourceFacts,
|
|
988
|
+
},
|
|
799
989
|
summary: {
|
|
800
990
|
explicitAcCount: explicitAc.length,
|
|
801
991
|
coveredAcCount: explicitAc.filter((id) => coveredAc.has(id)).length,
|
|
@@ -846,6 +1036,22 @@ export async function analyzeBackendTestCaseCoverage(input) {
|
|
|
846
1036
|
`- Scope Evidence: ${facts.coverageScope.scopeEvidence.join(", ") || "Unavailable"}`,
|
|
847
1037
|
...(facts.coverageScope.completenessClaim === "affected-scope" ? ["- Coverage is scoped to affected operations; it is not whole-API completeness."] : ["- Coverage fully evaluates the declared affected operations; it is not whole-API completeness unless every operation is explicitly listed."]),
|
|
848
1038
|
"",
|
|
1039
|
+
"## Source Coverage Inventory",
|
|
1040
|
+
"",
|
|
1041
|
+
`- Source facts: ${facts.sourceCoverage.sourceFactCount}`,
|
|
1042
|
+
`- Covered source facts: ${facts.sourceCoverage.coveredSourceFactCount}`,
|
|
1043
|
+
`- Residual source facts: ${facts.sourceCoverage.residualSourceFactCount}`,
|
|
1044
|
+
"",
|
|
1045
|
+
"| Kind | Key | Source Anchor | Source SHA-256 | Exact Quote | Status |",
|
|
1046
|
+
"|---|---|---|---|---|---|",
|
|
1047
|
+
...facts.sourceCoverage.facts.map((fact) => `| ${fact.kind} | ${fact.key} | ${fact.sourcePath}:L${fact.lineStart} | ${fact.sourceSha256} | ${fact.quote.replaceAll("|", "\\|")} | ${fact.covered ? "COVERED" : "MISSING_AFTER_SYNC"} |`),
|
|
1048
|
+
"",
|
|
1049
|
+
"## Residual Missing Source Scenarios",
|
|
1050
|
+
"",
|
|
1051
|
+
...(facts.sourceCoverage.residual.length > 0
|
|
1052
|
+
? facts.sourceCoverage.residual.map((fact) => `- ${fact.kind} \`${fact.key}\` from \`${fact.sourcePath}:L${fact.lineStart}\` (sha256 \`${fact.sourceSha256}\`), quote: “${fact.quote}”: missing after the bounded Markdown synchronization round; no full-coverage claim is allowed.`)
|
|
1053
|
+
: ["- None"]),
|
|
1054
|
+
"",
|
|
849
1055
|
"## Coverage Summary",
|
|
850
1056
|
"",
|
|
851
1057
|
"| Dimension | Total | Covered |",
|
|
@@ -909,6 +1115,321 @@ function symbolCaseId(symbol) {
|
|
|
909
1115
|
const match = symbol.match(/^test_(BE(?:_[A-Z0-9]+)+?_\d{2,3})(?:_|$)/i);
|
|
910
1116
|
return match ? canonicalCaseId(match[1].replaceAll("_", "-")) : undefined;
|
|
911
1117
|
}
|
|
1118
|
+
function pythonPayloadShapes(source) {
|
|
1119
|
+
const program = String.raw `
|
|
1120
|
+
import ast,json,sys
|
|
1121
|
+
source=sys.stdin.buffer.read().decode('utf-8')
|
|
1122
|
+
tree=ast.parse(source)
|
|
1123
|
+
GLOBAL_PAYLOADS={}
|
|
1124
|
+
GLOBAL_VALUES={}
|
|
1125
|
+
for top in tree.body:
|
|
1126
|
+
if isinstance(top,(ast.Assign,ast.AnnAssign)):
|
|
1127
|
+
names=([target.id for target in top.targets if isinstance(target,ast.Name)] if isinstance(top,ast.Assign) else ([top.target.id] if isinstance(top.target,ast.Name) else []))
|
|
1128
|
+
if top.value is None: continue
|
|
1129
|
+
candidates=[]
|
|
1130
|
+
for node in ast.walk(top.value):
|
|
1131
|
+
if isinstance(node,ast.Call) and isinstance(node.func,ast.Attribute) and node.func.attr == 'param' and node.args and isinstance(node.args[0],ast.Dict): candidates.append(node.args[0])
|
|
1132
|
+
if isinstance(top.value,ast.Dict) and any(name.lower() in {'payload','body','request_body','request_json','dto'} for name in names): candidates.append(top.value)
|
|
1133
|
+
for name in names:
|
|
1134
|
+
GLOBAL_PAYLOADS[name]=candidates
|
|
1135
|
+
GLOBAL_VALUES[name]=top.value
|
|
1136
|
+
FUNCTION_PAYLOADS={}
|
|
1137
|
+
FUNCTION_TRANSPORT_SHAPES={}
|
|
1138
|
+
TRANSPORT_PAYLOAD_PARAMS={}
|
|
1139
|
+
for top in ast.walk(tree):
|
|
1140
|
+
if isinstance(top,(ast.FunctionDef,ast.AsyncFunctionDef)) and not top.name.startswith('test_'):
|
|
1141
|
+
candidates=[]
|
|
1142
|
+
assigned={}
|
|
1143
|
+
mutated={}
|
|
1144
|
+
returned_names=set()
|
|
1145
|
+
positional=[arg.arg for arg in [*top.args.posonlyargs,*top.args.args]]
|
|
1146
|
+
call_offset=1 if positional and positional[0] in {'self','cls'} else 0
|
|
1147
|
+
transport=[]
|
|
1148
|
+
for node in ast.walk(top):
|
|
1149
|
+
if isinstance(node,(ast.Assign,ast.AnnAssign)):
|
|
1150
|
+
targets=node.targets if isinstance(node,ast.Assign) else [node.target]
|
|
1151
|
+
if node.value is None: continue
|
|
1152
|
+
for target in targets:
|
|
1153
|
+
if isinstance(target,ast.Name): assigned[target.id]=node.value
|
|
1154
|
+
for target in targets:
|
|
1155
|
+
if isinstance(target,ast.Subscript) and isinstance(target.value,ast.Name) and isinstance(target.slice,ast.Constant) and isinstance(target.slice.value,str):
|
|
1156
|
+
mutated.setdefault(target.value.id,[]).append((target.slice.value,node.value))
|
|
1157
|
+
if isinstance(node,ast.Return):
|
|
1158
|
+
if isinstance(node.value,ast.Dict): candidates.append(node.value)
|
|
1159
|
+
elif isinstance(node.value,ast.Name) and node.value.id in assigned:
|
|
1160
|
+
returned=assigned[node.value.id]
|
|
1161
|
+
if isinstance(returned,ast.Dict): candidates.append(returned)
|
|
1162
|
+
elif isinstance(returned,ast.Call) and isinstance(returned.func,ast.Name) and returned.func.id in FUNCTION_PAYLOADS:
|
|
1163
|
+
candidates.extend(FUNCTION_PAYLOADS[returned.func.id])
|
|
1164
|
+
returned_names.add(node.value.id)
|
|
1165
|
+
if isinstance(node,ast.Call):
|
|
1166
|
+
for keyword in node.keywords:
|
|
1167
|
+
if keyword.arg in {'payload','body','request_body','request_json','dto','json','data','json_body'} and isinstance(keyword.value,ast.Name) and keyword.value.id in positional:
|
|
1168
|
+
transport.append((keyword.value.id,positional.index(keyword.value.id)-call_offset))
|
|
1169
|
+
if isinstance(node.func,ast.Attribute) and node.func.attr.lower() in {'post','put','patch'} and len(node.args) >= 2:
|
|
1170
|
+
candidate=node.args[1]
|
|
1171
|
+
if isinstance(candidate,ast.Name) and candidate.id in positional:
|
|
1172
|
+
transport.append((candidate.id,positional.index(candidate.id)-call_offset))
|
|
1173
|
+
callee=None
|
|
1174
|
+
if isinstance(node.func,ast.Name): callee=node.func.id
|
|
1175
|
+
elif isinstance(node.func,ast.Attribute): callee=node.func.attr
|
|
1176
|
+
for _nested_name,nested_position in TRANSPORT_PAYLOAD_PARAMS.get(callee,[]):
|
|
1177
|
+
matched=node.args[nested_position] if nested_position >= 0 and nested_position < len(node.args) else None
|
|
1178
|
+
if isinstance(matched,ast.Name) and matched.id in positional:
|
|
1179
|
+
transport.append((matched.id,positional.index(matched.id)-call_offset))
|
|
1180
|
+
for returned_name in returned_names:
|
|
1181
|
+
entries=mutated.get(returned_name,[])
|
|
1182
|
+
if entries:
|
|
1183
|
+
candidates.append(ast.Dict(keys=[ast.Constant(key) for key,_value in entries],values=[value for _key,value in entries]))
|
|
1184
|
+
transport_shapes=[]
|
|
1185
|
+
for node in ast.walk(top):
|
|
1186
|
+
if not isinstance(node,ast.Call): continue
|
|
1187
|
+
for keyword in node.keywords:
|
|
1188
|
+
if keyword.arg in {'payload','body','request_body','request_json','dto','json','data','json_body'} and isinstance(keyword.value,ast.Name) and keyword.value.id in assigned:
|
|
1189
|
+
transport_shapes.append(assigned[keyword.value.id])
|
|
1190
|
+
if candidates: FUNCTION_PAYLOADS[top.name]=candidates
|
|
1191
|
+
if transport_shapes: FUNCTION_TRANSPORT_SHAPES[top.name]=transport_shapes
|
|
1192
|
+
if transport: TRANSPORT_PAYLOAD_PARAMS[top.name]=list(dict.fromkeys(transport))
|
|
1193
|
+
# Propagate transport payload positions through local wrapper chains after all
|
|
1194
|
+
# direct transport methods/functions are known. ast.walk may visit module-level
|
|
1195
|
+
# wrappers before class methods, so one-pass discovery is order-sensitive.
|
|
1196
|
+
for _round in range(6):
|
|
1197
|
+
changed=False
|
|
1198
|
+
for top in ast.walk(tree):
|
|
1199
|
+
if not isinstance(top,(ast.FunctionDef,ast.AsyncFunctionDef)) or top.name.startswith('test_'): continue
|
|
1200
|
+
positional=[arg.arg for arg in [*top.args.posonlyargs,*top.args.args]]
|
|
1201
|
+
call_offset=1 if positional and positional[0] in {'self','cls'} else 0
|
|
1202
|
+
current=list(TRANSPORT_PAYLOAD_PARAMS.get(top.name,[]))
|
|
1203
|
+
for node in ast.walk(top):
|
|
1204
|
+
if not isinstance(node,ast.Call): continue
|
|
1205
|
+
callee=node.func.id if isinstance(node.func,ast.Name) else (node.func.attr if isinstance(node.func,ast.Attribute) else None)
|
|
1206
|
+
for _nested_name,nested_position in TRANSPORT_PAYLOAD_PARAMS.get(callee,[]):
|
|
1207
|
+
matched=node.args[nested_position] if nested_position >= 0 and nested_position < len(node.args) else None
|
|
1208
|
+
if isinstance(matched,ast.Name) and matched.id in positional:
|
|
1209
|
+
item=(matched.id,positional.index(matched.id)-call_offset)
|
|
1210
|
+
if item not in current:
|
|
1211
|
+
current.append(item); changed=True
|
|
1212
|
+
if current: TRANSPORT_PAYLOAD_PARAMS[top.name]=current
|
|
1213
|
+
if not changed: break
|
|
1214
|
+
|
|
1215
|
+
# A local row factory may return pytest.param(_payload_builder(...), ..., id=tp).
|
|
1216
|
+
# Treat its first positional payload builder as shape evidence while keeping the
|
|
1217
|
+
# writer contract free to require direct pytest.param rows for scenario checks.
|
|
1218
|
+
for top in ast.walk(tree):
|
|
1219
|
+
if not isinstance(top,(ast.FunctionDef,ast.AsyncFunctionDef)) or top.name.startswith('test_'): continue
|
|
1220
|
+
for node in ast.walk(top):
|
|
1221
|
+
if not isinstance(node,ast.Return) or not isinstance(node.value,ast.Call): continue
|
|
1222
|
+
returned=node.value
|
|
1223
|
+
if not isinstance(returned.func,ast.Attribute) or returned.func.attr != 'param' or not returned.args: continue
|
|
1224
|
+
payload=returned.args[0]
|
|
1225
|
+
if isinstance(payload,ast.Call) and isinstance(payload.func,ast.Name) and payload.func.id in FUNCTION_PAYLOADS:
|
|
1226
|
+
FUNCTION_PAYLOADS[top.name]=list(FUNCTION_PAYLOADS[payload.func.id])
|
|
1227
|
+
# Resolve module-level parameter collections after local payload/row factories
|
|
1228
|
+
# are known (for example CASE_ROWS = [_post_case(...) , ...]).
|
|
1229
|
+
for top in tree.body:
|
|
1230
|
+
if not isinstance(top,ast.Assign): continue
|
|
1231
|
+
names=[target.id for target in top.targets if isinstance(target,ast.Name)]
|
|
1232
|
+
resolved=[]
|
|
1233
|
+
for node in ast.walk(top.value):
|
|
1234
|
+
if isinstance(node,ast.Call) and isinstance(node.func,ast.Name) and node.func.id in FUNCTION_PAYLOADS:
|
|
1235
|
+
resolved.extend(FUNCTION_PAYLOADS[node.func.id])
|
|
1236
|
+
for name in names:
|
|
1237
|
+
if resolved: GLOBAL_PAYLOADS[name]=list(GLOBAL_PAYLOADS.get(name,[]))+resolved
|
|
1238
|
+
|
|
1239
|
+
def literal(node):
|
|
1240
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, (str,int,float,bool,type(None))):
|
|
1241
|
+
return repr(node.value)
|
|
1242
|
+
return None
|
|
1243
|
+
|
|
1244
|
+
def collect_dict(node, prefix, paths, values):
|
|
1245
|
+
if not isinstance(node, ast.Dict): return
|
|
1246
|
+
for key,value in zip(node.keys,node.values):
|
|
1247
|
+
if not isinstance(key, ast.Constant) or not isinstance(key.value,str): continue
|
|
1248
|
+
# Ignore writer helper/meta keys that are not DTO request fields.
|
|
1249
|
+
if key.value in {'expect','expected','expected_status','expected_code','echo_title','echo_content','echo_status'} or key.value.startswith('echo_') or key.value.startswith('expected_'):
|
|
1250
|
+
continue
|
|
1251
|
+
current = key.value if not prefix else prefix + '.' + key.value
|
|
1252
|
+
paths.add(current)
|
|
1253
|
+
lv=literal(value)
|
|
1254
|
+
if lv is not None: values.setdefault(current,set()).add(lv)
|
|
1255
|
+
if isinstance(value, ast.Dict): collect_dict(value,current,paths,values)
|
|
1256
|
+
elif isinstance(value,(ast.List,ast.Tuple)):
|
|
1257
|
+
for item in value.elts:
|
|
1258
|
+
if isinstance(item,ast.Dict): collect_dict(item,current,paths,values)
|
|
1259
|
+
|
|
1260
|
+
PAYLOAD_NAMES={'payload','body','request_body','request_json','dto','json','data','json_body'}
|
|
1261
|
+
|
|
1262
|
+
def shape_only(node):
|
|
1263
|
+
if isinstance(node,ast.Dict):
|
|
1264
|
+
values=[]
|
|
1265
|
+
for value in node.values:
|
|
1266
|
+
if isinstance(value,(ast.Dict,ast.List,ast.Tuple)): values.append(shape_only(value))
|
|
1267
|
+
else: values.append(ast.Name(id='_PAYLOAD_VALUE_',ctx=ast.Load()))
|
|
1268
|
+
return ast.Dict(keys=node.keys,values=values)
|
|
1269
|
+
if isinstance(node,(ast.List,ast.Tuple)):
|
|
1270
|
+
items=[shape_only(item) if isinstance(item,(ast.Dict,ast.List,ast.Tuple)) else ast.Name(id='_PAYLOAD_VALUE_',ctx=ast.Load()) for item in node.elts]
|
|
1271
|
+
return ast.List(elts=items,ctx=ast.Load()) if isinstance(node,ast.List) else ast.Tuple(elts=items,ctx=ast.Load())
|
|
1272
|
+
return node
|
|
1273
|
+
|
|
1274
|
+
def payload_expr_nodes(expr, assigned, parameters, seen=None):
|
|
1275
|
+
seen=set() if seen is None else seen
|
|
1276
|
+
if isinstance(expr,ast.Dict): return [expr]
|
|
1277
|
+
if isinstance(expr,ast.Name):
|
|
1278
|
+
if expr.id in seen: return []
|
|
1279
|
+
seen.add(expr.id)
|
|
1280
|
+
if expr.id in assigned: return payload_expr_nodes(assigned[expr.id],assigned,parameters,seen)
|
|
1281
|
+
if expr.id in parameters:
|
|
1282
|
+
result=[]
|
|
1283
|
+
for value in parameters[expr.id]: result.extend(payload_expr_nodes(value,assigned,parameters,set(seen)))
|
|
1284
|
+
return result
|
|
1285
|
+
return list(GLOBAL_PAYLOADS.get(expr.id,[]))
|
|
1286
|
+
if isinstance(expr,ast.Subscript) and isinstance(expr.value,ast.Name) and isinstance(expr.slice,ast.Constant) and isinstance(expr.slice.value,str):
|
|
1287
|
+
result=[]
|
|
1288
|
+
for candidate in parameters.get(expr.value.id,[]):
|
|
1289
|
+
if not isinstance(candidate,ast.Dict): continue
|
|
1290
|
+
for key,value in zip(candidate.keys,candidate.values):
|
|
1291
|
+
if isinstance(key,ast.Constant) and key.value == expr.slice.value:
|
|
1292
|
+
result.extend(shape_only(item) for item in payload_expr_nodes(value,assigned,parameters,set(seen)))
|
|
1293
|
+
return result
|
|
1294
|
+
if isinstance(expr,ast.Call):
|
|
1295
|
+
result=[]
|
|
1296
|
+
if isinstance(expr.func,ast.Name) and expr.func.id in FUNCTION_PAYLOADS:
|
|
1297
|
+
result.extend(FUNCTION_PAYLOADS[expr.func.id])
|
|
1298
|
+
override_keys=[]; override_values=[]
|
|
1299
|
+
for keyword in expr.keywords:
|
|
1300
|
+
if keyword.arg:
|
|
1301
|
+
override_keys.append(ast.Constant(keyword.arg)); override_values.append(keyword.value)
|
|
1302
|
+
if override_keys: result.append(ast.Dict(keys=override_keys,values=override_values))
|
|
1303
|
+
# A transport body may be assembled by a local combinator such as
|
|
1304
|
+
# _apply_patch(_base_body(...), patch). Because this traversal starts
|
|
1305
|
+
# only from an identified transport-body position, recursively merging
|
|
1306
|
+
# argument shapes is narrower than scanning arbitrary helper calls.
|
|
1307
|
+
for argument in expr.args:
|
|
1308
|
+
result.extend(payload_expr_nodes(argument,assigned,parameters,set(seen)))
|
|
1309
|
+
for keyword in expr.keywords:
|
|
1310
|
+
result.extend(payload_expr_nodes(keyword.value,assigned,parameters,set(seen)))
|
|
1311
|
+
return result
|
|
1312
|
+
return []
|
|
1313
|
+
|
|
1314
|
+
def decorator_parameter_values(dec):
|
|
1315
|
+
result={}
|
|
1316
|
+
if not isinstance(dec,ast.Call) or not isinstance(dec.func,ast.Attribute) or dec.func.attr != 'parametrize' or len(dec.args) < 2: return result
|
|
1317
|
+
if not isinstance(dec.args[0],ast.Constant) or not isinstance(dec.args[0].value,str): return result
|
|
1318
|
+
names=[item.strip() for item in dec.args[0].value.split(',')]
|
|
1319
|
+
rows=dec.args[1]
|
|
1320
|
+
if isinstance(rows,ast.Name) and rows.id in GLOBAL_VALUES: rows=GLOBAL_VALUES[rows.id]
|
|
1321
|
+
for node in ast.walk(rows):
|
|
1322
|
+
if not isinstance(node,ast.Call) or not isinstance(node.func,ast.Attribute) or node.func.attr != 'param': continue
|
|
1323
|
+
for index,name in enumerate(names):
|
|
1324
|
+
if index < len(node.args): result.setdefault(name,[]).append(node.args[index])
|
|
1325
|
+
return result
|
|
1326
|
+
|
|
1327
|
+
def decorator_payload_nodes(parameters,assigned):
|
|
1328
|
+
result=[]
|
|
1329
|
+
for name,values in parameters.items():
|
|
1330
|
+
if name.lower() not in PAYLOAD_NAMES: continue
|
|
1331
|
+
for value in values: result.extend(payload_expr_nodes(value,assigned,parameters))
|
|
1332
|
+
return result
|
|
1333
|
+
|
|
1334
|
+
def fallback_payload_nodes(fn):
|
|
1335
|
+
result=[]
|
|
1336
|
+
assigned={}
|
|
1337
|
+
for node in ast.walk(fn):
|
|
1338
|
+
if isinstance(node,(ast.Assign,ast.AnnAssign)):
|
|
1339
|
+
if node.value is None: continue
|
|
1340
|
+
targets=node.targets if isinstance(node,ast.Assign) else [node.target]
|
|
1341
|
+
for target in targets:
|
|
1342
|
+
if isinstance(target,ast.Name): assigned[target.id]=node.value
|
|
1343
|
+
parameters={}
|
|
1344
|
+
for dec in fn.decorator_list:
|
|
1345
|
+
for name,values in decorator_parameter_values(dec).items(): parameters.setdefault(name,[]).extend(values)
|
|
1346
|
+
for node in ast.walk(fn):
|
|
1347
|
+
if not isinstance(node,ast.Call): continue
|
|
1348
|
+
callee=None
|
|
1349
|
+
if isinstance(node.func,ast.Name): callee=node.func.id
|
|
1350
|
+
elif isinstance(node.func,ast.Attribute): callee=node.func.attr
|
|
1351
|
+
result.extend(FUNCTION_TRANSPORT_SHAPES.get(callee,[]))
|
|
1352
|
+
# Wrapper payloads are fallback evidence: they are useful when the Case
|
|
1353
|
+
# contract requires a body, but must not contaminate GET/DELETE targets
|
|
1354
|
+
# merely because setup creates a resource first.
|
|
1355
|
+
for parameter_name,position in TRANSPORT_PAYLOAD_PARAMS.get(callee,[]):
|
|
1356
|
+
matched=next((keyword.value for keyword in node.keywords if keyword.arg == parameter_name),None)
|
|
1357
|
+
if matched is None and position >= 0 and position < len(node.args): matched=node.args[position]
|
|
1358
|
+
if matched is not None: result.extend(payload_expr_nodes(matched,assigned,parameters))
|
|
1359
|
+
return result
|
|
1360
|
+
|
|
1361
|
+
def is_direct_transport_call(node):
|
|
1362
|
+
if not isinstance(node,ast.Call) or not isinstance(node.func,ast.Attribute): return False
|
|
1363
|
+
if node.func.attr.lower() not in {'get','post','put','patch','delete','request'}: return False
|
|
1364
|
+
receiver_tokens=[]
|
|
1365
|
+
current=node.func.value
|
|
1366
|
+
while isinstance(current,ast.Attribute):
|
|
1367
|
+
receiver_tokens.append(current.attr.lower())
|
|
1368
|
+
current=current.value
|
|
1369
|
+
if isinstance(current,ast.Name): receiver_tokens.append(current.id.lower())
|
|
1370
|
+
return any(token in {'requests','httpx','client','session','api_client','http_client'} or token.endswith('_client') for token in receiver_tokens)
|
|
1371
|
+
|
|
1372
|
+
def payload_nodes(fn):
|
|
1373
|
+
result=[]
|
|
1374
|
+
assigned={}
|
|
1375
|
+
for node in ast.walk(fn):
|
|
1376
|
+
if isinstance(node,(ast.Assign,ast.AnnAssign)):
|
|
1377
|
+
if node.value is None: continue
|
|
1378
|
+
targets=node.targets if isinstance(node,ast.Assign) else [node.target]
|
|
1379
|
+
for target in targets:
|
|
1380
|
+
if isinstance(target,ast.Name): assigned[target.id]=node.value
|
|
1381
|
+
parameters={}
|
|
1382
|
+
for dec in fn.decorator_list:
|
|
1383
|
+
for name,values in decorator_parameter_values(dec).items(): parameters.setdefault(name,[]).extend(values)
|
|
1384
|
+
result.extend(decorator_payload_nodes(parameters,assigned))
|
|
1385
|
+
for dec in fn.decorator_list:
|
|
1386
|
+
for node in ast.walk(dec):
|
|
1387
|
+
if isinstance(node,ast.Call) and isinstance(node.func,ast.Name) and node.func.id in FUNCTION_PAYLOADS:
|
|
1388
|
+
result.extend(payload_expr_nodes(node,assigned,parameters))
|
|
1389
|
+
elif isinstance(node,ast.Name) and node.id in GLOBAL_PAYLOADS:
|
|
1390
|
+
result.extend(GLOBAL_PAYLOADS[node.id])
|
|
1391
|
+
# Observe local payload/body variables only when they flow into a direct
|
|
1392
|
+
# HTTP transport call. Arbitrary setup variables are not target requests.
|
|
1393
|
+
for node in ast.walk(fn):
|
|
1394
|
+
if not is_direct_transport_call(node): continue
|
|
1395
|
+
for keyword in node.keywords:
|
|
1396
|
+
if keyword.arg in PAYLOAD_NAMES: result.extend(payload_expr_nodes(keyword.value,assigned,parameters))
|
|
1397
|
+
return result
|
|
1398
|
+
|
|
1399
|
+
out={}
|
|
1400
|
+
for fn in ast.walk(tree):
|
|
1401
|
+
if isinstance(fn,(ast.FunctionDef,ast.AsyncFunctionDef)) and fn.name.startswith('test_'):
|
|
1402
|
+
paths=set(); values={}
|
|
1403
|
+
fallback_paths=set(); fallback_values={}
|
|
1404
|
+
for node in payload_nodes(fn): collect_dict(node,'',paths,values)
|
|
1405
|
+
for node in fallback_payload_nodes(fn): collect_dict(node,'',fallback_paths,fallback_values)
|
|
1406
|
+
out[fn.name]={
|
|
1407
|
+
'paths':sorted(paths),
|
|
1408
|
+
'values':{key:sorted(items) for key,items in sorted(values.items())},
|
|
1409
|
+
'fallbackPaths':sorted(fallback_paths),
|
|
1410
|
+
'fallbackValues':{key:sorted(items) for key,items in sorted(fallback_values.items())},
|
|
1411
|
+
}
|
|
1412
|
+
print(json.dumps(out,ensure_ascii=True))
|
|
1413
|
+
`;
|
|
1414
|
+
// Pass bytes and decode stdin explicitly as UTF-8 in Python. Clean-env
|
|
1415
|
+
// Windows Python may expose stdin as GBK; decoding generated Chinese/emoji
|
|
1416
|
+
// source through that locale can create surrogateescape code points and make
|
|
1417
|
+
// ast.parse fail with "surrogates not allowed".
|
|
1418
|
+
const result = spawnSync("python", ["-c", program], { input: Buffer.from(source, "utf8"), encoding: "utf8", maxBuffer: 4 * 1024 * 1024, windowsHide: true });
|
|
1419
|
+
if (result.status !== 0 || !result.stdout.trim())
|
|
1420
|
+
return new Map();
|
|
1421
|
+
try {
|
|
1422
|
+
const parsed = JSON.parse(result.stdout);
|
|
1423
|
+
return new Map(Object.entries(parsed));
|
|
1424
|
+
}
|
|
1425
|
+
catch {
|
|
1426
|
+
return new Map();
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
function metadataCaseIds(region) {
|
|
1430
|
+
return orderedUnique([...region.matchAll(/^\s*Case-ID\s*:\s*(BE-[A-Z0-9]+(?:-[A-Z0-9]+)*)\s*$/gmi)]
|
|
1431
|
+
.map((match) => canonicalCaseId(match[1])));
|
|
1432
|
+
}
|
|
912
1433
|
function metadataTestPoints(region, label) {
|
|
913
1434
|
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
914
1435
|
const match = new RegExp(`^\\s*${escaped}\\s*:\\s*(.*)$`, "mi").exec(region);
|
|
@@ -928,10 +1449,35 @@ function pytestFunctionRegionStart(source, functionIndex) {
|
|
|
928
1449
|
}
|
|
929
1450
|
return regionStart;
|
|
930
1451
|
}
|
|
1452
|
+
function pytestFunctionBodyEnd(source, signatureEnd, functionIndent) {
|
|
1453
|
+
let cursor = source.indexOf("\n", signatureEnd);
|
|
1454
|
+
if (cursor < 0)
|
|
1455
|
+
return source.length;
|
|
1456
|
+
cursor += 1;
|
|
1457
|
+
const baseIndent = functionIndent.replaceAll("\t", " ").length;
|
|
1458
|
+
while (cursor < source.length) {
|
|
1459
|
+
const nextLine = source.indexOf("\n", cursor);
|
|
1460
|
+
const lineEnd = nextLine < 0 ? source.length : nextLine;
|
|
1461
|
+
const line = source.slice(cursor, lineEnd).replace(/\r$/, "");
|
|
1462
|
+
if (line.trim()) {
|
|
1463
|
+
const indentation = line.match(/^[ \t]*/)?.[0]?.replaceAll("\t", " ").length ?? 0;
|
|
1464
|
+
if (indentation <= baseIndent)
|
|
1465
|
+
return cursor;
|
|
1466
|
+
}
|
|
1467
|
+
if (nextLine < 0)
|
|
1468
|
+
return source.length;
|
|
1469
|
+
cursor = nextLine + 1;
|
|
1470
|
+
}
|
|
1471
|
+
return source.length;
|
|
1472
|
+
}
|
|
931
1473
|
function pytestParameterCollections(source) {
|
|
932
1474
|
const collections = new Map();
|
|
933
|
-
for (const match of source.matchAll(/^([A-Z][A-Z0-9_]*)\s*=\s*\[([\s\S]*?)^\]/gm)) {
|
|
934
|
-
|
|
1475
|
+
for (const match of source.matchAll(/^(_?[A-Z][A-Z0-9_]*)\s*=\s*\[(?![^\r\n]*\])([\s\S]*?)^\]/gm)) {
|
|
1476
|
+
const explicitIds = [...match[2].matchAll(/\bid\s*=\s*["'](TP-[A-Z0-9-]+)["']/g)].map((item) => item[1]);
|
|
1477
|
+
const factoryIds = explicitIds.length === 0
|
|
1478
|
+
? [...match[2].matchAll(/["'](TP-[A-Z0-9-]+)["']/g)].map((item) => item[1])
|
|
1479
|
+
: [];
|
|
1480
|
+
collections.set(match[1], orderedUnique([...explicitIds, ...factoryIds]));
|
|
935
1481
|
}
|
|
936
1482
|
return collections;
|
|
937
1483
|
}
|
|
@@ -939,21 +1485,29 @@ function pytestSymbols(script, source) {
|
|
|
939
1485
|
const matches = [...source.matchAll(/^([ \t]*)(?:async\s+)?def\s+(test_[A-Za-z0-9_]+)\s*\([^)]*\)\s*(?:->\s*[^:\r\n]+)?\s*:/gm)];
|
|
940
1486
|
const regionStarts = matches.map((match) => pytestFunctionRegionStart(source, match.index));
|
|
941
1487
|
const parameterCollections = pytestParameterCollections(source);
|
|
1488
|
+
const payloadShapes = pythonPayloadShapes(source);
|
|
942
1489
|
return matches.map((match, index) => {
|
|
943
1490
|
const regionStart = regionStarts[index];
|
|
944
|
-
const regionEnd =
|
|
1491
|
+
const regionEnd = pytestFunctionBodyEnd(source, match.index + match[0].length, match[1]);
|
|
945
1492
|
const region = source.slice(match.index, regionEnd);
|
|
946
1493
|
const decorators = source.slice(regionStart, match.index);
|
|
947
|
-
const ids =
|
|
1494
|
+
const ids = metadataCaseIds(region);
|
|
948
1495
|
const fromSymbol = symbolCaseId(match[2]);
|
|
949
1496
|
if (fromSymbol)
|
|
950
1497
|
ids.unshift(fromSymbol);
|
|
1498
|
+
const explicitParameterIds = [...decorators.matchAll(/\bid\s*=\s*["'](TP-[A-Z0-9-]+)["']/g)].map((item) => item[1]);
|
|
1499
|
+
const collectionParameterIds = [...parameterCollections.entries()].flatMap(([name, values]) => new RegExp(`\\b${name}\\b`).test(decorators) ? values : []);
|
|
1500
|
+
const factoryParameterIds = explicitParameterIds.length === 0 && collectionParameterIds.length === 0
|
|
1501
|
+
? [...decorators.matchAll(/["'](TP-[A-Z0-9-]+)["']/g)].map((item) => item[1])
|
|
1502
|
+
: [];
|
|
951
1503
|
const parameterIds = orderedUnique([
|
|
952
|
-
...
|
|
953
|
-
...
|
|
1504
|
+
...explicitParameterIds,
|
|
1505
|
+
...collectionParameterIds,
|
|
1506
|
+
...factoryParameterIds,
|
|
954
1507
|
]);
|
|
955
1508
|
const assertionTestPoints = metadataTestPoints(region, "Assertion-Test-Points");
|
|
956
1509
|
const crossCuttingTestPoints = metadataTestPoints(region, "Cross-Cutting-Test-Points");
|
|
1510
|
+
const payloadShape = payloadShapes.get(match[2]) ?? { paths: [], values: {} };
|
|
957
1511
|
return {
|
|
958
1512
|
script,
|
|
959
1513
|
symbol: match[2],
|
|
@@ -962,9 +1516,64 @@ function pytestSymbols(script, source) {
|
|
|
962
1516
|
parameterIds,
|
|
963
1517
|
assertionTestPoints,
|
|
964
1518
|
crossCuttingTestPoints,
|
|
1519
|
+
payloadPaths: payloadShape.paths,
|
|
1520
|
+
payloadValues: payloadShape.values,
|
|
1521
|
+
fallbackPayloadPaths: payloadShape.fallbackPaths ?? [],
|
|
1522
|
+
fallbackPayloadValues: payloadShape.fallbackValues ?? {},
|
|
965
1523
|
};
|
|
966
1524
|
});
|
|
967
1525
|
}
|
|
1526
|
+
function executionSignature(testCase) {
|
|
1527
|
+
const operation = (testCase.body.match(/\b(?:GET|POST|PUT|PATCH|DELETE)\s+\/[^\s`|)]+/i)?.[0] ?? "operation-unavailable").toUpperCase();
|
|
1528
|
+
const inputPartition = [sectionBody(testCase.body, ["测试数据", "Test Data"]), testCase.scenarioTypes.join(" "), testCase.testPoints.join(" ")].join(" ");
|
|
1529
|
+
const precondition = sectionBody(testCase.body, ["前置条件", "Preconditions", "Precondition"]);
|
|
1530
|
+
const expectedOutcome = sectionBody(testCase.body, ["预期结果", "Expected Results", "Expected Result"]);
|
|
1531
|
+
const normalized = [operation, inputPartition, precondition, expectedOutcome]
|
|
1532
|
+
.map((value) => value.replaceAll(/\s+/g, " ").trim().toLowerCase())
|
|
1533
|
+
.join("\n");
|
|
1534
|
+
return createHash("sha256").update(normalized).digest("hex");
|
|
1535
|
+
}
|
|
1536
|
+
function assessPayloadContract(testCase, refs) {
|
|
1537
|
+
const requiredPaths = testCase.payloadContract.requiredPaths;
|
|
1538
|
+
const directObservedPaths = orderedUnique(refs.flatMap((item) => item.payloadPaths));
|
|
1539
|
+
const useFallback = requiredPaths.length > 0 && directObservedPaths.length === 0;
|
|
1540
|
+
const analyzedObservedPaths = useFallback
|
|
1541
|
+
? orderedUnique(refs.flatMap((item) => item.fallbackPayloadPaths))
|
|
1542
|
+
: directObservedPaths;
|
|
1543
|
+
// A no-body target contract describes the target GET/DELETE request, not
|
|
1544
|
+
// setup POST/PUT calls in the same lifecycle test function.
|
|
1545
|
+
const observedPaths = testCase.payloadContract.noPayload ? [] : analyzedObservedPaths;
|
|
1546
|
+
const allowedPaths = testCase.payloadContract.allowedPaths;
|
|
1547
|
+
if (!testCase.payloadContract.declared) {
|
|
1548
|
+
return { status: "UNAVAILABLE", requiredPaths, allowedPaths, observedPaths, missingPaths: [], unexpectedPaths: [], enumMismatches: [] };
|
|
1549
|
+
}
|
|
1550
|
+
const missingPaths = requiredPaths.filter((item) => !observedPaths.includes(item));
|
|
1551
|
+
const unexpectedPaths = testCase.payloadContract.noPayload
|
|
1552
|
+
? observedPaths
|
|
1553
|
+
: allowedPaths.length > 0
|
|
1554
|
+
? observedPaths.filter((item) => !allowedPaths.includes(item) && !allowedPaths.some((allowed) => item.startsWith(`${allowed}.`)))
|
|
1555
|
+
: [];
|
|
1556
|
+
const enumMismatches = [];
|
|
1557
|
+
const delegatesInvalidEnumToScenarioGate = testCase.testPointBindings
|
|
1558
|
+
.filter((binding) => binding.mode === "variant")
|
|
1559
|
+
.some((binding) => /(?:ENUM|CATEGORY).*(?:INVALID|UNKNOWN|CASE|WHITESPACE|EMPTY|WRONG-TYPE)|(?:INVALID|UNKNOWN|CASE|WHITESPACE|EMPTY|WRONG-TYPE).*(?:ENUM|CATEGORY)/.test(binding.testPoint));
|
|
1560
|
+
for (const [field, allowed] of Object.entries(testCase.payloadContract.enumValues)) {
|
|
1561
|
+
const observed = orderedUnique(refs.flatMap((item) => (useFallback ? item.fallbackPayloadValues[field] : item.payloadValues[field]) ?? []).map((value) => value.replace(/^['"]|['"]$/g, "")));
|
|
1562
|
+
for (const value of observed) {
|
|
1563
|
+
if (!allowed.includes(value) && !delegatesInvalidEnumToScenarioGate)
|
|
1564
|
+
enumMismatches.push(`${field}=${value}; allowed=${allowed.join("|")}`);
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
return {
|
|
1568
|
+
status: missingPaths.length > 0 || unexpectedPaths.length > 0 || enumMismatches.length > 0 ? "UNSAFE" : "SAFE",
|
|
1569
|
+
requiredPaths,
|
|
1570
|
+
allowedPaths,
|
|
1571
|
+
observedPaths,
|
|
1572
|
+
missingPaths,
|
|
1573
|
+
unexpectedPaths,
|
|
1574
|
+
enumMismatches,
|
|
1575
|
+
};
|
|
1576
|
+
}
|
|
968
1577
|
export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
|
|
969
1578
|
const cases = await parseMarkdownCases(input.workspaceRoot);
|
|
970
1579
|
const modulePaths = orderedUnique(cases.map((item) => item.markdownPath));
|
|
@@ -998,21 +1607,44 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
|
|
|
998
1607
|
...crossCuttingTestPoints.filter((point) => crossCuttingBindings.includes(point)),
|
|
999
1608
|
]);
|
|
1000
1609
|
const declaredPrimarySymbol = testCase.declaredPrimarySymbols[0];
|
|
1610
|
+
const evidenceOnly = isEvidenceOnlyMarkdownCase(testCase);
|
|
1001
1611
|
const cardinality = refs.length === 0 ? "1:0" : refs.length === 1 ? "1:1" : "1:N";
|
|
1002
|
-
let status = refs.length === 0
|
|
1612
|
+
let status = evidenceOnly && refs.length === 0
|
|
1613
|
+
? "EXACT_1_TO_1"
|
|
1614
|
+
: refs.length === 0
|
|
1615
|
+
? "MISSING_PYTEST"
|
|
1616
|
+
: refs.length > 1
|
|
1617
|
+
? "MULTIPLE_PYTEST"
|
|
1618
|
+
: "EXACT_1_TO_1";
|
|
1003
1619
|
const entryFindings = [];
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
if (
|
|
1007
|
-
|
|
1008
|
-
if (!declared || declared !== expected || actualScripts.some((script) => script !== expected) || !declaredPrimarySymbol || symbols.some((symbol) => symbol !== declaredPrimarySymbol)) {
|
|
1009
|
-
if (refs.length > 0 && status === "EXACT_1_TO_1")
|
|
1620
|
+
const payloadAssessment = assessPayloadContract(testCase, refs);
|
|
1621
|
+
const signature = executionSignature(testCase);
|
|
1622
|
+
if (evidenceOnly) {
|
|
1623
|
+
if (refs.length > 0) {
|
|
1010
1624
|
status = "SCRIPT_MISMATCH";
|
|
1011
|
-
|
|
1625
|
+
entryFindings.push(`${testCase.caseId} evidence-only Markdown Case must not materialize a business pytest symbol; actualSymbols=${symbols.join(", ") || "none"}`);
|
|
1626
|
+
}
|
|
1012
1627
|
}
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1628
|
+
else {
|
|
1629
|
+
if (refs.length === 0)
|
|
1630
|
+
entryFindings.push(`${testCase.caseId} has no pytest symbol`);
|
|
1631
|
+
if (refs.length > 1)
|
|
1632
|
+
entryFindings.push(`${testCase.caseId} maps to multiple pytest symbols: ${symbols.join(", ")}`);
|
|
1633
|
+
if (!declared || declared !== expected || actualScripts.some((script) => script !== expected) || !declaredPrimarySymbol || symbols.some((symbol) => symbol !== declaredPrimarySymbol)) {
|
|
1634
|
+
if (refs.length > 0 && status === "EXACT_1_TO_1")
|
|
1635
|
+
status = "SCRIPT_MISMATCH";
|
|
1636
|
+
entryFindings.push(`${testCase.caseId} script/primary-symbol mapping mismatch: declaredScript=${declared ?? "none"}, expectedScript=${expected}, actual=${actualScripts.join(", ") || "none"}, declaredPrimarySymbol=${declaredPrimarySymbol ?? "none"}, actualSymbols=${symbols.join(", ") || "none"}`);
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
const missingVariant = evidenceOnly
|
|
1640
|
+
? []
|
|
1641
|
+
: variantTestPoints.filter((point) => !parameterIds.includes(point));
|
|
1642
|
+
const missingAssertion = evidenceOnly
|
|
1643
|
+
? []
|
|
1644
|
+
: assertionTestPoints.filter((point) => !assertionBindings.includes(point));
|
|
1645
|
+
const missingCrossCutting = evidenceOnly
|
|
1646
|
+
? []
|
|
1647
|
+
: crossCuttingTestPoints.filter((point) => !crossCuttingBindings.includes(point));
|
|
1016
1648
|
const extraPoints = orderedUnique([...parameterIds, ...assertionBindings, ...crossCuttingBindings]).filter((point) => !testCase.testPoints.includes(point));
|
|
1017
1649
|
if (testCase.unclassifiedTestPoints.length > 0 || testCase.duplicateBindingTestPoints.length > 0) {
|
|
1018
1650
|
if (refs.length > 0 && status === "EXACT_1_TO_1")
|
|
@@ -1039,6 +1671,16 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
|
|
|
1039
1671
|
status = "TEST_POINT_BINDING_EXTRA";
|
|
1040
1672
|
entryFindings.push(`${testCase.caseId} has extra pytest Test Point bindings: ${extraPoints.join(", ")}`);
|
|
1041
1673
|
}
|
|
1674
|
+
if (payloadAssessment.status === "UNAVAILABLE") {
|
|
1675
|
+
if (refs.length > 0 && status === "EXACT_1_TO_1")
|
|
1676
|
+
status = "PAYLOAD_CONTRACT_UNAVAILABLE";
|
|
1677
|
+
entryFindings.push(`${testCase.caseId} has no deterministic Payload Contract declaration`);
|
|
1678
|
+
}
|
|
1679
|
+
else if (payloadAssessment.status === "UNSAFE") {
|
|
1680
|
+
if (refs.length > 0 && status === "EXACT_1_TO_1")
|
|
1681
|
+
status = "PAYLOAD_SHAPE_MISMATCH";
|
|
1682
|
+
entryFindings.push(`${testCase.caseId} payload contract mismatch: missing=${payloadAssessment.missingPaths.join(", ") || "none"}; unexpected=${payloadAssessment.unexpectedPaths.join(", ") || "none"}; enum=${payloadAssessment.enumMismatches.join(", ") || "none"}`);
|
|
1683
|
+
}
|
|
1042
1684
|
findings.push(...entryFindings);
|
|
1043
1685
|
entries.push({
|
|
1044
1686
|
markdownModule: testCase.markdownPath,
|
|
@@ -1056,6 +1698,8 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
|
|
|
1056
1698
|
parameterIds,
|
|
1057
1699
|
cardinality,
|
|
1058
1700
|
status,
|
|
1701
|
+
payloadAssessment,
|
|
1702
|
+
executionSignature: signature,
|
|
1059
1703
|
findings: entryFindings,
|
|
1060
1704
|
});
|
|
1061
1705
|
}
|
|
@@ -1082,9 +1726,27 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
|
|
|
1082
1726
|
parameterIds: symbol.parameterIds,
|
|
1083
1727
|
cardinality: "0:1",
|
|
1084
1728
|
status: unknownId ? "EXTRA_PYTEST" : "SYMBOL_MISSING_CASE_ID",
|
|
1729
|
+
payloadAssessment: {
|
|
1730
|
+
status: "UNAVAILABLE",
|
|
1731
|
+
requiredPaths: [],
|
|
1732
|
+
allowedPaths: [],
|
|
1733
|
+
observedPaths: symbol.payloadPaths,
|
|
1734
|
+
missingPaths: [],
|
|
1735
|
+
unexpectedPaths: [],
|
|
1736
|
+
enumMismatches: [],
|
|
1737
|
+
},
|
|
1085
1738
|
findings: [message],
|
|
1086
1739
|
});
|
|
1087
1740
|
}
|
|
1741
|
+
const duplicateSignatures = new Map();
|
|
1742
|
+
for (const entry of entries.filter((item) => item.caseId && item.executionSignature)) {
|
|
1743
|
+
const owners = duplicateSignatures.get(entry.executionSignature) ?? [];
|
|
1744
|
+
owners.push(entry.caseId);
|
|
1745
|
+
duplicateSignatures.set(entry.executionSignature, owners);
|
|
1746
|
+
}
|
|
1747
|
+
const duplicateSignatureGroups = [...duplicateSignatures.entries()].filter(([, owners]) => orderedUnique(owners).length > 1);
|
|
1748
|
+
for (const [signature, owners] of duplicateSignatureGroups)
|
|
1749
|
+
findings.push(`duplicate execution signature ${signature}: ${orderedUnique(owners).join(", ")}`);
|
|
1088
1750
|
const exactModuleCount = modulePaths.filter((modulePath) => {
|
|
1089
1751
|
const expected = expectedScript(modulePath);
|
|
1090
1752
|
const moduleCases = cases.filter((item) => item.markdownPath === modulePath);
|
|
@@ -1092,8 +1754,8 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
|
|
|
1092
1754
|
}).length;
|
|
1093
1755
|
const caseEntries = entries.filter((entry) => entry.cardinality !== "0:1");
|
|
1094
1756
|
const facts = backendTestMarkdownPytestCorrespondenceFactsSchema.parse({
|
|
1095
|
-
schemaId: "backend-test-markdown-pytest-correspondence-facts-
|
|
1096
|
-
schemaVersion:
|
|
1757
|
+
schemaId: "backend-test-markdown-pytest-correspondence-facts-v3",
|
|
1758
|
+
schemaVersion: 3,
|
|
1097
1759
|
taskId: input.taskId,
|
|
1098
1760
|
status: findings.length === 0 ? "PASS" : "FAIL",
|
|
1099
1761
|
inputFiles: await inputFileFacts(input.workspaceRoot, [
|
|
@@ -1118,6 +1780,10 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
|
|
|
1118
1780
|
crossCuttingTestPointCount: cases.reduce((sum, item) => sum + item.testPointBindings.filter((binding) => binding.mode === "cross-cutting").length, 0),
|
|
1119
1781
|
unclassifiedTestPointCount: cases.reduce((sum, item) => sum + item.unclassifiedTestPoints.length, 0),
|
|
1120
1782
|
duplicateBindingTestPointCount: cases.reduce((sum, item) => sum + item.duplicateBindingTestPoints.length, 0),
|
|
1783
|
+
payloadSafeCount: caseEntries.filter((entry) => entry.payloadAssessment.status === "SAFE").length,
|
|
1784
|
+
payloadUnsafeCount: caseEntries.filter((entry) => entry.payloadAssessment.status === "UNSAFE").length,
|
|
1785
|
+
payloadUnavailableCount: caseEntries.filter((entry) => entry.payloadAssessment.status === "UNAVAILABLE").length,
|
|
1786
|
+
duplicateExecutionSignatureCount: duplicateSignatureGroups.length,
|
|
1121
1787
|
},
|
|
1122
1788
|
entries,
|
|
1123
1789
|
findings: orderedUnique(findings),
|
|
@@ -1145,12 +1811,16 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
|
|
|
1145
1811
|
`- Cross-Cutting Test Points: ${facts.summary.crossCuttingTestPointCount}`,
|
|
1146
1812
|
`- Unclassified Test Points: ${facts.summary.unclassifiedTestPointCount}`,
|
|
1147
1813
|
`- Duplicate Test Point Bindings: ${facts.summary.duplicateBindingTestPointCount}`,
|
|
1814
|
+
`- Payload SAFE: ${facts.summary.payloadSafeCount}`,
|
|
1815
|
+
`- Payload UNSAFE: ${facts.summary.payloadUnsafeCount}`,
|
|
1816
|
+
`- Payload UNAVAILABLE: ${facts.summary.payloadUnavailableCount}`,
|
|
1817
|
+
`- Duplicate Execution Signatures: ${facts.summary.duplicateExecutionSignatureCount}`,
|
|
1148
1818
|
"",
|
|
1149
1819
|
"## Correspondence Matrix",
|
|
1150
1820
|
"",
|
|
1151
|
-
"| Markdown Module | Case ID | Variant | Assertion | Cross-Cutting | Mapped | Declared Script | Primary Symbol | Actual Script | Pytest Symbol | Parameter IDs | Cardinality | Status |",
|
|
1152
|
-
"
|
|
1153
|
-
...facts.entries.map((entry) => `| ${entry.markdownModule} | ${entry.caseId ?? "—"} | ${entry.variantTestPoints.join(", ") || "—"} | ${entry.assertionTestPoints.join(", ") || "—"} | ${entry.crossCuttingTestPoints.join(", ") || "—"} | ${entry.mappedTestPoints.join(", ") || "—"} | ${entry.declaredScript ?? "—"} | ${entry.declaredPrimarySymbol ?? "—"} | ${entry.actualScripts.join(", ") || "—"} | ${entry.pytestSymbols.join(", ") || "—"} | ${entry.parameterIds.join(", ") || "—"} | ${entry.cardinality} | ${entry.status} |`),
|
|
1821
|
+
"| Markdown Module | Case ID | Variant | Assertion | Cross-Cutting | Mapped | Declared Script | Primary Symbol | Actual Script | Pytest Symbol | Parameter IDs | Payload | Missing Paths | Unexpected Paths | Enum Mismatch | Cardinality | Status |",
|
|
1822
|
+
"|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|",
|
|
1823
|
+
...facts.entries.map((entry) => `| ${entry.markdownModule} | ${entry.caseId ?? "—"} | ${entry.variantTestPoints.join(", ") || "—"} | ${entry.assertionTestPoints.join(", ") || "—"} | ${entry.crossCuttingTestPoints.join(", ") || "—"} | ${entry.mappedTestPoints.join(", ") || "—"} | ${entry.declaredScript ?? "—"} | ${entry.declaredPrimarySymbol ?? "—"} | ${entry.actualScripts.join(", ") || "—"} | ${entry.pytestSymbols.join(", ") || "—"} | ${entry.parameterIds.join(", ") || "—"} | ${entry.payloadAssessment.status} | ${entry.payloadAssessment.missingPaths.join(", ") || "—"} | ${entry.payloadAssessment.unexpectedPaths.join(", ") || "—"} | ${entry.payloadAssessment.enumMismatches.join(", ") || "—"} | ${entry.cardinality} | ${entry.status} |`),
|
|
1154
1824
|
"",
|
|
1155
1825
|
"## Findings",
|
|
1156
1826
|
"",
|