@tea-agent/loop-agent 0.39.0-next.9 → 0.39.1
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 +4 -1
- package/CHANGELOG.md +269 -69
- package/README.md +11 -5
- package/bin/loop-agent.js +7 -3
- package/dist/application/dag/generate-task-dag.js +9 -0
- package/dist/application/task-lifecycle/advance.js +16 -0
- package/dist/build-stamp.json +3 -3
- package/dist/cli/command-definitions.js +16 -5
- package/dist/cli/program.js +16 -2
- package/dist/cli/update/notifier.js +2 -2
- package/dist/cli/update/runtime-activity.js +1 -29
- package/dist/cli.js +2 -1
- package/dist/commands/dag-rerun.js +55 -1
- package/dist/commands/init-model-catalog.js +464 -0
- package/dist/commands/init-upgrade.js +265 -97
- package/dist/commands/init.js +428 -27
- package/dist/commands/inspect-next.js +8 -0
- package/dist/commands/task-advance.js +31 -0
- package/dist/executors/dag-pi-executor.js +124 -18
- package/dist/executors/pi-event-serializer.js +42 -1
- package/dist/executors/pi-executor.js +51 -0
- package/dist/executors/pi-extension-resolver.js +233 -0
- package/dist/executors/pi-playwright-cli-tool.js +74 -28
- package/dist/executors/pi-sdk-executor.js +187 -59
- package/dist/executors/shell-executor.js +555 -34
- package/dist/executors/shell-write-guard.js +7 -0
- package/dist/shared/dag-failure-category.js +138 -0
- package/dist/shared/dag-prompt-override.js +27 -0
- package/dist/shared/operator/capabilities.js +181 -27
- package/dist/shared/playwright-cli-command-policy.js +15 -0
- package/dist/shared/update/console-notifier.js +100 -0
- package/dist/{cli → shared}/update/npm-client.js +70 -15
- package/dist/{cli → shared}/update/state.js +41 -13
- package/dist/task/config-types.js +28 -0
- package/dist/task/contract/apply.js +11 -1
- package/dist/task/contract/constants.js +3 -0
- package/dist/task/contract/diff.js +26 -0
- package/dist/task/contract/hash.js +3 -0
- package/dist/task/contract/observe.js +25 -4
- package/dist/task/contract/paths.js +2 -1
- package/dist/task/contract/project.js +20 -1
- package/dist/task/contract/schema.js +8 -1
- package/dist/task/contract/transaction.js +12 -0
- package/dist/task/source-prepare/build-draft.js +15 -0
- package/dist/task/source-prepare/completeness.js +188 -0
- package/dist/task/source-prepare/fragment-inventory.js +474 -0
- package/dist/task/source-prepare/index.js +3 -0
- package/dist/task/source-prepare/ledger-reconciliation.js +127 -0
- package/dist/task/source-prepare/ledger-review.js +214 -0
- package/dist/task/source-prepare/ledger.js +545 -0
- package/dist/task/source-prepare/parse-intent.js +24 -4
- package/dist/task/source-prepare/prepare.js +262 -1
- package/dist/task/source-prepare/semantic-intake.js +19 -3
- package/dist/task/source-prepare/source-fidelity-pi.js +384 -0
- package/dist/task/source-prepare/types.js +22 -0
- package/dist/worker/cli.js +18 -2
- package/dist/worker/console/chat/deferred-turn.js +91 -0
- package/dist/worker/console/chat/mcp-inventory.js +221 -0
- package/dist/worker/console/chat/operation-card.js +8 -1
- package/dist/worker/console/chat/pi-console-config.js +81 -10
- package/dist/worker/console/chat/pi-runtime.js +165 -83
- package/dist/worker/console/chat/repo-walk.js +10 -1
- package/dist/worker/console/chat/routes.js +480 -55
- package/dist/worker/console/chat/semantic-activity.js +20 -8
- package/dist/worker/console/chat/session-store.js +146 -2
- package/dist/worker/console/chat/turn-process.js +10 -10
- package/dist/worker/console/console-handoff.js +82 -0
- package/dist/worker/console/console-update-and-init.js +115 -0
- package/dist/worker/console/console-update-runtime.js +84 -0
- package/dist/worker/console/dag-execution-receipt.js +33 -0
- package/dist/worker/console/draft-store.js +49 -0
- package/dist/worker/console/inspect-split.js +18 -0
- package/dist/worker/console/operation-run-facts.js +19 -3
- package/dist/worker/console/operation-store.js +1 -1
- package/dist/worker/console/operator-actions.js +320 -4
- package/dist/worker/console/operator-surface-health.js +1 -0
- package/dist/worker/console/operator-user-error.js +2 -2
- package/dist/worker/console/pi-readiness.js +5 -4
- package/dist/worker/console/prd-intake-bridge.js +13 -0
- package/dist/worker/console/recovery-cta.js +4 -4
- package/dist/worker/console/recovery-error-copy.js +4 -4
- package/dist/worker/console/routes.js +46 -2
- package/dist/worker/console/security.js +63 -9
- package/dist/worker/console/server.js +111 -7
- package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-BLXFe4qI.js → abnfDiagram-N423BO3Z-CTx1TrNa.js} +1 -1
- package/dist/worker/console/static/assets/{arc-7iOJeqho.js → arc-B4i9cVxX.js} +1 -1
- package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-CYoyj5K7.js → architectureDiagram-T3A2C74G-BAXE8_Rl.js} +1 -1
- package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-fK01JNlR.js → blockDiagram-VBNYF7ZC-C5Xh59at.js} +1 -1
- package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-bzbw5D_Z.js → c4Diagram-5PPSVZJV-B_asOzAu.js} +1 -1
- package/dist/worker/console/static/assets/channel-Dj_4Moei.js +1 -0
- package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-Cf6hJFFG.js → chunk-2GRJ4B5K-wRO_iRNc.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-DSFXR-et.js → chunk-2Q5K7J3B-ByOaSh_G.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5RXB4S5H-BagJechl.js → chunk-5RXB4S5H-DjLzudan.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5VM5RSS4-E7QNMFaW.js → chunk-5VM5RSS4-JCNGkXYT.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-BG_eOBrC.js → chunk-6Q2QTUOP-BhrPXXMZ.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-GF5L2VYU-Bo42dqol.js → chunk-GF5L2VYU-TJ5JuEBT.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-JWPE2WC7-dibM-87v.js → chunk-JWPE2WC7-VVthn_mD.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-KBJHAD2P-Bytrp3Oe.js → chunk-KBJHAD2P-D60-Mny4.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-RYQCIY6F-DBABILrr.js → chunk-RYQCIY6F-zXv7aVye.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-XXDRQBXY-DCUk5Pf9.js → chunk-XXDRQBXY-By9VQoi1.js} +1 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-C0YYAyCa.js +1 -0
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-C0YYAyCa.js +1 -0
- package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-BlGE9v6E.js → cose-bilkent-JH36ORCC-C4zQLe-M.js} +1 -1
- package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-DGl5ewph.js → cynefin-VYW2F7L2-wh5N2ReN.js} +1 -1
- package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-DsJ_SVH_.js → cynefinDiagram-MW4NZA55-Dlu5wIqs.js} +1 -1
- package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BIhjFdzv.js → dagre-VZM6K2ZE-CrDgthc6.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-7IWD3JNH-Dmy1ctTb.js → diagram-7IWD3JNH-zN1clg27.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-DYCtWUF3.js → diagram-B4RE2ZJO-BN-o7J7-.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-LBJQPF4R-BMZFHVd2.js → diagram-LBJQPF4R-C8SuPt0d.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-Q27KOJAE-b_U8gfaO.js → diagram-Q27KOJAE-BoizuHbZ.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-UB23O5K3-Dzy_bn-I.js → diagram-UB23O5K3-BzUzXixG.js} +1 -1
- package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-DJCiMNEI.js → ebnfDiagram-BXEA7PRR-Ev3lA4Cf.js} +1 -1
- package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-28lD016B.js → erDiagram-JOGREHBK-GALUzRbs.js} +1 -1
- package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-DGyHJmIw.js → flowDiagram-UKHOOZJN-CZJN65Sb.js} +1 -1
- package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-CzdwUjrO.js → ganttDiagram-PKOTCBZU-JLB9OESl.js} +1 -1
- package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-D9bGwuo8.js → gitGraphDiagram-DS77QQ5N-CcvCr4GD.js} +1 -1
- package/dist/worker/console/static/assets/index-CW88Fjbw.js +406 -0
- package/dist/worker/console/static/assets/index-DP0ZJl39.css +1 -0
- package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-LJgJUQFC.js → infoDiagram-6WML65LV-HG5z7aVg.js} +1 -1
- package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-98LaB8zb.js → ishikawaDiagram-WSZJBQD7-BHEu9chR.js} +1 -1
- package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-D-ISzwlQ.js → journeyDiagram-NVQOT4AX-Cp_Up8-a.js} +1 -1
- package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-gfYOI-EL.js → kanban-definition-27J2QSJJ-DvVeY-em.js} +1 -1
- package/dist/worker/console/static/assets/{linear-CyntZgKV.js → linear-aGmcjgTn.js} +1 -1
- package/dist/worker/console/static/assets/{mermaid.core-BBOR7dg0.js → mermaid.core-Biyo-tqA.js} +5 -5
- package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-CQAgwPMk.js → mindmap-definition-FAOFIHXS-DyxY2pIb.js} +1 -1
- package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-DkMiZFuV.js → pegDiagram-VL7TDLO6-CkdGuc50.js} +1 -1
- package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-DLNXa_0J.js → pieDiagram-7S7Q4E2Y-NNQI9xVR.js} +1 -1
- package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-Dc7bdzjm.js → quadrantDiagram-CIZ2JOQS-PAJkuz_G.js} +1 -1
- package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-BxMB0QqW.js → railroadDiagram-AXF67PYL-ZwrQod0O.js} +1 -1
- package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-FjumoKn4.js → requirementDiagram-LRYGKXZP-D3ZfrzM2.js} +1 -1
- package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-CkhKc_E3.js → sankeyDiagram-W5VNT64P-DDGFmWk5.js} +1 -1
- package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-DoBWqTTH.js → sequenceDiagram-SI44F4Z6-DO6mmXTA.js} +1 -1
- package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-CQeh_YeE.js → sizeCapture-X5ZJPWSS-DMbCQiJt.js} +1 -1
- package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-LuHmFW-I.js → stateDiagram-OKZ733FA-Di_YUEfC.js} +1 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DrdDvela.js +1 -0
- package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-CHwbDctY.js → swimlanes-SLNWSIFB-Bn2PphNJ.js} +2 -2
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-DPkEAULl.js +8 -0
- package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-CoMngk0X.js → timeline-definition-Z64GVDOM-DPh8pRsp.js} +1 -1
- package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-CURYbtbO.js → vennDiagram-T6HMQDX7-GMfl26pW.js} +1 -1
- package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-BdqSECjU.js → wardleyDiagram-T6FBY63Y-DTlmVrmF.js} +1 -1
- package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-DbMUYuAg.js → xychartDiagram-ELKLHX3M-YGBjKAS5.js} +1 -1
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/app/console-types.js +40 -1
- package/dist/worker/console/static-src/app/useOperatorActions.js +6 -1
- package/dist/worker/console/static-src/app/useRecoveryActions.js +2 -2
- package/dist/worker/console/static-src/app/useRecoveryConsole.js +38 -2
- package/dist/worker/console/static-src/app/useRunProgress.js +46 -0
- package/dist/worker/console/static-src/app/useTaskWizard.js +73 -1
- package/dist/worker/console/static-src/operator-chat/cards/failure-category-advice.js +25 -0
- package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +74 -26
- package/dist/worker/console/static-src/operator-chat/input-history.js +76 -0
- package/dist/worker/console/static-src/operator-chat/mutation-gate.js +1 -1
- package/dist/worker/console/static-src/operator-chat/open-preview-in-browser.js +328 -0
- package/dist/worker/console/static-src/operator-chat/resource-diagnostics.js +204 -0
- package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +8 -1
- package/dist/worker/console/static-src/operator-chat/turn-submission.js +54 -0
- package/dist/worker/console/static-src/operator-chat/useChatSessions.js +3 -0
- package/dist/worker/console/static-src/operator-chat/useChatStream.js +237 -11
- package/dist/worker/console/static-src/operator-chat/useChatThread.js +50 -22
- package/dist/worker/console/static-src/operator-chat/useComposer.js +32 -0
- package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +26 -1
- package/dist/worker/console/static-src/pages/tasks/run-id-resolution.js +79 -0
- package/dist/worker/console/static-src/pages/tasks/run-ownership-verify.js +23 -0
- package/dist/worker/console/static-src/pages/tasks/run-panel-progress.js +110 -0
- package/dist/worker/loop-agent/loop-agent-client.js +7 -3
- package/dist/worker/materialize/harness-task-lineage.js +5 -2
- package/dist/worker/observability/init-runtime-activity.js +26 -0
- package/dist/worker/observability/read-model.js +22 -0
- package/dist/worker/observe/node-input.js +160 -3
- package/dist/worker/observe/node-process.js +377 -0
- package/dist/worker/observe/routes.js +224 -3
- package/dist/worker/observe/shell-handler-keys.js +34 -0
- package/dist/worker/observe/static/api.js +67 -1
- package/dist/worker/observe/static/constants.js +8 -1
- package/dist/worker/observe/static/dag-history-labels.js +4 -0
- package/dist/worker/observe/static/format.js +60 -0
- package/dist/worker/observe/static/index.html +5 -0
- package/dist/worker/observe/static/markdown-render.js +20 -1
- package/dist/worker/observe/static/operator-chrome.js +4 -0
- package/dist/worker/observe/static/prompt-restart-candidates.d.ts +18 -0
- package/dist/worker/observe/static/prompt-restart-candidates.js +36 -0
- package/dist/worker/observe/static/state.js +13 -2
- package/dist/worker/observe/static/styles.css +85 -0
- package/dist/worker/observe/static/views/dag-inspector.js +912 -8
- package/dist/worker/observe/static/views/dag.js +8 -1
- package/dist/worker/observe/static/views/failures.js +5 -2
- package/dist/worker/observe/static/views/session-timeline.js +78 -9
- package/dist/worker/observe/static/views/task.js +55 -0
- package/dist/workflows/dag/backend-test-case-coverage-analysis.js +856 -54
- package/dist/workflows/dag/backend-test-case-manifest.js +4 -0
- package/dist/workflows/dag/backend-test-execution-contract.js +18 -9
- package/dist/workflows/dag/backend-test-gap-fill.js +205 -0
- package/dist/workflows/dag/backend-test-intake-context.js +32 -0
- package/dist/workflows/dag/backend-test-layout.js +133 -0
- package/dist/workflows/dag/backend-test-markdown-workflow.js +343 -33
- package/dist/workflows/dag/backend-test-module-stem.js +5 -0
- package/dist/workflows/dag/backend-test-pytest-collection.js +454 -36
- package/dist/workflows/dag/backend-test-result-contract.js +1 -1
- package/dist/workflows/dag/backend-test-scenario-param.js +884 -140
- package/dist/workflows/dag/backend-test-scenario-partitions.js +321 -0
- package/dist/workflows/dag/backend-test-writer-completeness.js +110 -61
- package/dist/workflows/dag/contract-output-registry.js +15 -0
- package/dist/workflows/dag/contract-validator-registrations.js +3 -1
- package/dist/workflows/dag/convergence/controller.js +141 -0
- package/dist/workflows/dag/dynamic-runtime/loop-until.js +1 -1
- package/dist/workflows/dag/dynamic-runtime/map.js +25 -9
- package/dist/workflows/dag/dynamic-runtime/shared.js +1 -1
- package/dist/workflows/dag/failure-category.js +7 -116
- package/dist/workflows/dag/frontend-implementation-contract.js +465 -6
- package/dist/workflows/dag/frontend-plan-render.js +24 -0
- package/dist/workflows/dag/frontend-prewrite-gate.js +293 -84
- package/dist/workflows/dag/frontend-repair.js +14 -0
- package/dist/workflows/dag/frontend-review-context.js +92 -10
- package/dist/workflows/dag/frontend-risk.js +15 -2
- package/dist/workflows/dag/frontend-test-case-checklist.js +30 -4
- package/dist/workflows/dag/frontend-test-case-manifest.js +11 -4
- package/dist/workflows/dag/frontend-test-case-quality.js +11 -16
- package/dist/workflows/dag/frontend-test-environment-probe.js +230 -0
- package/dist/workflows/dag/frontend-test-html-report.js +3 -1
- package/dist/workflows/dag/frontend-test-l5-report.js +3 -1
- package/dist/workflows/dag/frontend-test-layout.js +159 -0
- package/dist/workflows/dag/frontend-test-markdown.js +61 -0
- package/dist/workflows/dag/frontend-test-result-contract.js +188 -42
- package/dist/workflows/dag/frontend-test-standard-scenarios.js +70 -0
- package/dist/workflows/dag/frontend-verification-trace.js +29 -4
- package/dist/workflows/dag/init-hybrid.js +1019 -209
- package/dist/workflows/dag/node-execution.js +228 -15
- package/dist/workflows/dag/prompt.js +60 -1
- package/dist/workflows/dag/rerun-feedback.js +212 -0
- package/dist/workflows/dag/rerun-plan.js +224 -27
- package/dist/workflows/dag/rerun-run.js +61 -6
- package/dist/workflows/dag/rerun-task.js +61 -2
- package/dist/workflows/dag/retry-policy.js +109 -0
- package/dist/workflows/dag/structured-output-repair.js +712 -0
- package/dist/workflows/dag/types.js +185 -12
- package/dist/workflows/dag/validate.js +64 -20
- package/docs/README.md +1 -0
- package/docs/architecture/evolution.md +45 -0
- package/docs/init-surface.manifest.json +6 -0
- package/docs/operations/README.md +1 -1
- package/docs/operations/local-development-environment.md +1 -5
- package/docs/skills/vetted-skill-registry.md +14 -0
- package/docs/templates/README.md +2 -2
- package/docs/templates/agent-dag.schema.json +44 -0
- package/docs/templates/backend-test-dag.json +60 -35
- package/docs/templates/frontend-implementation-contract.schema.json +681 -24
- package/docs/templates/frontend-test-case-checklist.md +2 -2
- package/docs/templates/frontend-test-dag.json +8 -10
- package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +1 -1
- package/docs/templates/init-managed-agents.md +13 -3
- package/harness.json +6 -4
- package/package.json +12 -4
- package/scripts/next-info.mjs +356 -0
- package/scripts/next-publish-gate.mjs +238 -0
- package/scripts/release-source-binding.mjs +251 -0
- package/skills/analyze-product-requirements/SKILL.md +8 -5
- package/skills/analyze-product-requirements/references/acceptance-criteria.md +1 -1
- package/skills/analyze-product-requirements/references/clarification-and-knowledge.md +10 -6
- package/skills/analyze-product-requirements/references/example.md +50 -0
- package/skills/analyze-product-requirements/references/forward-test-cases.md +11 -7
- package/skills/analyze-product-requirements/references/kb-integration.md +5 -5
- package/skills/analyze-product-requirements/references/product-analysis-schema.md +8 -4
- package/skills/analyze-product-requirements/references/product-requirement-schema.md +2 -2
- package/skills/analyze-product-requirements/references/requirement-clarification-schema.md +1 -1
- package/skills/analyze-product-requirements/scripts/test-validators.mjs +11 -1
- package/skills/analyze-product-requirements/scripts/validate-product-analysis.mjs +20 -1
- package/skills/analyze-product-requirements/scripts/validate-requirement-clarification.mjs +9 -0
- package/skills/codebase-scout/SKILL.md +1 -1
- package/skills/fe-test-ui-scout/SKILL.md +65 -0
- package/skills/fe-test-ui-scout/references/ledger-schema.md +62 -0
- package/skills/fe-test-ui-scout/references/recon-protocol.md +54 -0
- package/skills/frontend-implementation/references/node-contracts.md +2 -2
- package/skills/improve-codebase-architecture/SKILL.md +81 -0
- package/skills/improve-codebase-architecture/deepening.md +37 -0
- package/skills/improve-codebase-architecture/html-report.md +123 -0
- package/skills/improve-codebase-architecture/interface-design.md +44 -0
- package/skills/improve-codebase-architecture/language.md +53 -0
- package/skills/loop-agent/references/command-reference.md +11 -4
- package/skills/loop-agent/references/hybrid-dag.md +7 -0
- package/skills/playwright-cli/SKILL.md +23 -1
- package/skills/playwright-cli-case-generator/SKILL.md +29 -8
- package/dist/worker/console/static/assets/channel-BBOTJHvD.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-Cm8Yf8tD.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-Cm8Yf8tD.js +0 -1
- package/dist/worker/console/static/assets/index-B-qWGThd.js +0 -325
- package/dist/worker/console/static/assets/index-C-xRgY_0.css +0 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DZihfdKc.js +0 -1
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-CKnXCjHX.js +0 -8
|
@@ -1,14 +1,26 @@
|
|
|
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";
|
|
5
6
|
import YAML from "yaml";
|
|
7
|
+
import { resolveBackendTestLayout, } from "./backend-test-layout.js";
|
|
8
|
+
import { resolveBackendTestManifestModules, backendManifestStatusFinding } from "./backend-test-markdown-workflow.js";
|
|
9
|
+
import { applyOpenApiPartitionDomainPolicy, assessScenarioPartitionCoverage, parseScenarioPartitions, } from "./backend-test-scenario-partitions.js";
|
|
6
10
|
import { backendTestCaseManifestSchema, computeCaseManifestCoverageSummary, } from "./backend-test-case-manifest.js";
|
|
7
11
|
const CASE_HEADING = /^##\s+(BE-[A-Z0-9_-]+-\d{3})\b.*$/gm;
|
|
8
12
|
const CASE_ID_IN_TEXT = /\bBE-[A-Z0-9_-]+-\d{3}\b/g;
|
|
9
13
|
const NON_CANONICAL_CASE_HEADING = /^##\s+(BE-[A-Z0-9_-]+-(?:\d{2}|\d{2,3}[A-Z]+))\b.*$/gm;
|
|
10
14
|
const AC_ID_IN_TEXT = /\bAC-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/g;
|
|
11
|
-
const
|
|
15
|
+
const SOURCE_RULE_ID_IN_TEXT = /\b(?:AC|REQ|BR)-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/g;
|
|
16
|
+
const SOURCE_ERROR_CODE_IN_TEXT = /\b[A-Z][0-9]{5,}\b/g;
|
|
17
|
+
const JSON_FIELD_IN_TEXT = /["']([A-Za-z][A-Za-z0-9_]*)["']\s*:/g;
|
|
18
|
+
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;
|
|
19
|
+
const NATURAL_BUSINESS_RULE_KEYWORDS = /(?:事务|原子性|回滚|幂等|唯一性|唯一|状态转换|状态流转|权限|角色|租户|跨字段|transaction|atomic|rollback|idempoten|unique|state transition|authorization|permission|tenant|cross-field)/i;
|
|
20
|
+
const NATURAL_BUSINESS_RULE_NORMATIVE = /(?:必须|不得|禁止|仅当|应当|需要|不能|保证|must|shall|required|must not|only when|guarantee)/i;
|
|
21
|
+
// Token form is used for section scanning; anchored form validates full cells.
|
|
22
|
+
const RULE_KEY_TOKEN = /(?:AC|REQ|BR|API|GET|POST|PUT|PATCH|DELETE)-[A-Z0-9]+(?:-[A-Z0-9]+)*|[A-Z][0-9]{5,}/;
|
|
23
|
+
const RULE_KEY = new RegExp(`^(?:${RULE_KEY_TOKEN.source})$`);
|
|
12
24
|
const TEST_POINT = /^TP-[A-Z0-9]+(?:-[A-Z0-9]+)*$/;
|
|
13
25
|
const COVERAGE_HEADERS = [
|
|
14
26
|
"Rule Key",
|
|
@@ -87,6 +99,13 @@ const coverageCaseSchema = z.object({
|
|
|
87
99
|
scenarioTypes: z.array(z.string()),
|
|
88
100
|
declaredScripts: z.array(z.string()),
|
|
89
101
|
declaredPrimarySymbols: z.array(z.string()),
|
|
102
|
+
payloadContract: z.object({
|
|
103
|
+
declared: z.boolean(),
|
|
104
|
+
noPayload: z.boolean(),
|
|
105
|
+
requiredPaths: z.array(z.string()),
|
|
106
|
+
allowedPaths: z.array(z.string()),
|
|
107
|
+
enumValues: z.record(z.string(), z.array(z.string())),
|
|
108
|
+
}).strict(),
|
|
90
109
|
}).strict();
|
|
91
110
|
const coverageRuleSchema = z.object({
|
|
92
111
|
ruleKey: z.string().min(1),
|
|
@@ -102,6 +121,23 @@ const coverageRuleSchema = z.object({
|
|
|
102
121
|
missingTestPoints: z.array(z.string()),
|
|
103
122
|
status: z.enum(["COVERED", "PARTIAL", "GAP", "CONFLICT"]),
|
|
104
123
|
}).strict();
|
|
124
|
+
const sourceCoverageFactSchema = z.object({
|
|
125
|
+
kind: z.enum(["requirement-id", "error-code", "dto-field", "business-rule"]),
|
|
126
|
+
key: z.string().min(1),
|
|
127
|
+
sourcePath: z.string().min(1),
|
|
128
|
+
sourceSha256: sha256Schema,
|
|
129
|
+
lineStart: z.number().int().positive(),
|
|
130
|
+
lineEnd: z.number().int().positive(),
|
|
131
|
+
quote: z.string().min(1).max(1000),
|
|
132
|
+
covered: z.boolean(),
|
|
133
|
+
}).strict();
|
|
134
|
+
const sourceCoverageSchema = z.object({
|
|
135
|
+
sourceFactCount: z.number().int().min(0),
|
|
136
|
+
coveredSourceFactCount: z.number().int().min(0),
|
|
137
|
+
residualSourceFactCount: z.number().int().min(0),
|
|
138
|
+
facts: z.array(sourceCoverageFactSchema),
|
|
139
|
+
residual: z.array(sourceCoverageFactSchema),
|
|
140
|
+
}).strict();
|
|
105
141
|
const coverageScopeSchema = z.object({
|
|
106
142
|
changeClassification: z.enum(CHANGE_CLASSIFICATIONS),
|
|
107
143
|
coveragePolicy: z.enum(COVERAGE_POLICIES),
|
|
@@ -112,13 +148,14 @@ const coverageScopeSchema = z.object({
|
|
|
112
148
|
completenessClaim: z.enum(["affected-operations-full", "affected-scope"]),
|
|
113
149
|
}).strict();
|
|
114
150
|
export const backendTestCaseCoverageFactsSchema = z.object({
|
|
115
|
-
schemaId: z.literal("backend-test-case-coverage-facts-
|
|
116
|
-
schemaVersion: z.literal(
|
|
151
|
+
schemaId: z.literal("backend-test-case-coverage-facts-v4"),
|
|
152
|
+
schemaVersion: z.literal(4),
|
|
117
153
|
taskId: z.string().min(1),
|
|
118
154
|
status: z.enum(["PASS", "FAIL", "UNAVAILABLE"]),
|
|
119
155
|
sourceBinding: sourceBindingSchema,
|
|
120
156
|
inputFiles: z.array(inputFileSchema),
|
|
121
157
|
coverageScope: coverageScopeSchema,
|
|
158
|
+
sourceCoverage: sourceCoverageSchema,
|
|
122
159
|
summary: z.object({
|
|
123
160
|
explicitAcCount: z.number().int().min(0),
|
|
124
161
|
coveredAcCount: z.number().int().min(0),
|
|
@@ -146,6 +183,27 @@ export const backendTestCaseCoverageFactsSchema = z.object({
|
|
|
146
183
|
}).strict(),
|
|
147
184
|
cases: z.array(coverageCaseSchema),
|
|
148
185
|
rules: z.array(coverageRuleSchema),
|
|
186
|
+
/** Plan C: declared query/filter axis partitions and their slot coverage. */
|
|
187
|
+
scenarioPartitions: z.object({
|
|
188
|
+
partitions: z.array(z.object({
|
|
189
|
+
partitionId: z.string(),
|
|
190
|
+
operation: z.string(),
|
|
191
|
+
axis: z.string(),
|
|
192
|
+
domain: z.array(z.string()),
|
|
193
|
+
optional: z.boolean(),
|
|
194
|
+
complementExpected: z.string(),
|
|
195
|
+
bindRule: z.string(),
|
|
196
|
+
slotIds: z.array(z.string()),
|
|
197
|
+
})),
|
|
198
|
+
slots: z.array(z.object({
|
|
199
|
+
slotId: z.string(),
|
|
200
|
+
partitionId: z.string(),
|
|
201
|
+
kind: z.string(),
|
|
202
|
+
intent: z.string(),
|
|
203
|
+
expected: z.string(),
|
|
204
|
+
})),
|
|
205
|
+
missingSlots: z.array(z.string()),
|
|
206
|
+
}),
|
|
149
207
|
findings: z.array(z.string()),
|
|
150
208
|
evidenceGaps: z.array(z.string()),
|
|
151
209
|
conflicts: z.array(z.string()),
|
|
@@ -177,12 +235,24 @@ const correspondenceEntrySchema = z.object({
|
|
|
177
235
|
"CROSS_CUTTING_EVIDENCE_MISSING",
|
|
178
236
|
"TEST_POINT_BINDING_DUPLICATE",
|
|
179
237
|
"TEST_POINT_BINDING_EXTRA",
|
|
238
|
+
"PAYLOAD_CONTRACT_UNAVAILABLE",
|
|
239
|
+
"PAYLOAD_SHAPE_MISMATCH",
|
|
180
240
|
]),
|
|
241
|
+
payloadAssessment: z.object({
|
|
242
|
+
status: z.enum(["SAFE", "UNSAFE", "UNAVAILABLE"]),
|
|
243
|
+
requiredPaths: z.array(z.string()),
|
|
244
|
+
allowedPaths: z.array(z.string()),
|
|
245
|
+
observedPaths: z.array(z.string()),
|
|
246
|
+
missingPaths: z.array(z.string()),
|
|
247
|
+
unexpectedPaths: z.array(z.string()),
|
|
248
|
+
enumMismatches: z.array(z.string()),
|
|
249
|
+
}).strict(),
|
|
250
|
+
executionSignature: z.string().min(1).optional(),
|
|
181
251
|
findings: z.array(z.string()),
|
|
182
252
|
}).strict();
|
|
183
253
|
export const backendTestMarkdownPytestCorrespondenceFactsSchema = z.object({
|
|
184
|
-
schemaId: z.literal("backend-test-markdown-pytest-correspondence-facts-
|
|
185
|
-
schemaVersion: z.literal(
|
|
254
|
+
schemaId: z.literal("backend-test-markdown-pytest-correspondence-facts-v3"),
|
|
255
|
+
schemaVersion: z.literal(3),
|
|
186
256
|
taskId: z.string().min(1),
|
|
187
257
|
status: z.enum(["PASS", "FAIL", "UNAVAILABLE"]),
|
|
188
258
|
inputFiles: z.array(inputFileSchema),
|
|
@@ -203,6 +273,10 @@ export const backendTestMarkdownPytestCorrespondenceFactsSchema = z.object({
|
|
|
203
273
|
crossCuttingTestPointCount: z.number().int().min(0),
|
|
204
274
|
unclassifiedTestPointCount: z.number().int().min(0),
|
|
205
275
|
duplicateBindingTestPointCount: z.number().int().min(0),
|
|
276
|
+
payloadSafeCount: z.number().int().min(0),
|
|
277
|
+
payloadUnsafeCount: z.number().int().min(0),
|
|
278
|
+
payloadUnavailableCount: z.number().int().min(0),
|
|
279
|
+
duplicateExecutionSignatureCount: z.number().int().min(0),
|
|
206
280
|
}).strict(),
|
|
207
281
|
entries: z.array(correspondenceEntrySchema),
|
|
208
282
|
findings: z.array(z.string()),
|
|
@@ -236,6 +310,64 @@ function openApiTestPoints(field, dimension, values = []) {
|
|
|
236
310
|
return [`${prefix}-VALID-CLASS`, `${prefix}-VALID-MIXED`, `${prefix}-UPPERCASE`, `${prefix}-WHITESPACE`, `${prefix}-PUNCTUATION`, `${prefix}-SLASH`, `${prefix}-EMOJI`, `${prefix}-CONTROL`];
|
|
237
311
|
return [`${prefix}-PRESENT`, `${prefix}-MISSING`, `${prefix}-NULL`, `${prefix}-WRONG-TYPE`];
|
|
238
312
|
}
|
|
313
|
+
async function collectSourceCoverageFacts(input) {
|
|
314
|
+
const paths = orderedUnique([
|
|
315
|
+
input.sourceBinding.requirementPath,
|
|
316
|
+
...input.sourceBinding.referencePaths,
|
|
317
|
+
]);
|
|
318
|
+
const facts = [];
|
|
319
|
+
const seen = new Set();
|
|
320
|
+
for (const sourcePath of paths) {
|
|
321
|
+
const physicalPath = resolveBoundSourcePath(input.workspaceRoot, input.sourceBinding.taskId, sourcePath);
|
|
322
|
+
let text;
|
|
323
|
+
try {
|
|
324
|
+
text = await readFile(physicalPath, "utf8");
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
const sourceSha256 = createHash("sha256").update(await readFile(physicalPath)).digest("hex");
|
|
330
|
+
const lines = text.split(/\r?\n/);
|
|
331
|
+
const add = (kind, key, lineIndex, quote) => {
|
|
332
|
+
const normalizedQuote = quote.trim().slice(0, 1000);
|
|
333
|
+
if (!normalizedQuote)
|
|
334
|
+
return;
|
|
335
|
+
const identity = `${kind}:${key}:${sourcePath}:${lineIndex + 1}`;
|
|
336
|
+
if (seen.has(identity))
|
|
337
|
+
return;
|
|
338
|
+
seen.add(identity);
|
|
339
|
+
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
340
|
+
const covered = kind === "business-rule"
|
|
341
|
+
? input.generatedMarkdown.includes(normalizedQuote)
|
|
342
|
+
: new RegExp(`\\b${escaped}\\b`).test(input.generatedMarkdown);
|
|
343
|
+
facts.push({
|
|
344
|
+
kind,
|
|
345
|
+
key,
|
|
346
|
+
sourcePath,
|
|
347
|
+
sourceSha256,
|
|
348
|
+
lineStart: lineIndex + 1,
|
|
349
|
+
lineEnd: lineIndex + 1,
|
|
350
|
+
quote: normalizedQuote,
|
|
351
|
+
covered,
|
|
352
|
+
});
|
|
353
|
+
};
|
|
354
|
+
lines.forEach((line, lineIndex) => {
|
|
355
|
+
for (const key of line.match(SOURCE_RULE_ID_IN_TEXT) ?? [])
|
|
356
|
+
add("requirement-id", key, lineIndex, line);
|
|
357
|
+
for (const key of line.match(SOURCE_ERROR_CODE_IN_TEXT) ?? [])
|
|
358
|
+
add("error-code", key, lineIndex, line);
|
|
359
|
+
for (const match of line.matchAll(JSON_FIELD_IN_TEXT))
|
|
360
|
+
add("dto-field", match[1], lineIndex, line);
|
|
361
|
+
for (const match of line.matchAll(DTO_FIELD_IN_TEXT))
|
|
362
|
+
add("dto-field", match[1], lineIndex, line);
|
|
363
|
+
const trimmed = line.trim();
|
|
364
|
+
if (trimmed.length >= 8 && trimmed.length <= 1000 && !/^\s*(?:#|\||[-:]{3,})/.test(trimmed) && NATURAL_BUSINESS_RULE_KEYWORDS.test(trimmed) && NATURAL_BUSINESS_RULE_NORMATIVE.test(trimmed)) {
|
|
365
|
+
add("business-rule", `BR-SOURCE-${sourceSha256.slice(0, 8).toUpperCase()}-L${lineIndex + 1}`, lineIndex, trimmed);
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
return facts.sort((left, right) => left.kind.localeCompare(right.kind) || left.key.localeCompare(right.key) || left.sourcePath.localeCompare(right.sourcePath) || left.lineStart - right.lineStart);
|
|
370
|
+
}
|
|
239
371
|
function resolveBoundSourcePath(workspaceRoot, taskId, sourcePath) {
|
|
240
372
|
if (path.isAbsolute(sourcePath))
|
|
241
373
|
return sourcePath;
|
|
@@ -355,6 +487,55 @@ export async function extractBackendTestOpenApiRules(input) {
|
|
|
355
487
|
}
|
|
356
488
|
return rules.sort((left, right) => left.ruleKey.localeCompare(right.ruleKey));
|
|
357
489
|
}
|
|
490
|
+
export async function extractBackendTestOpenApiPartitionAxes(input) {
|
|
491
|
+
const axes = [];
|
|
492
|
+
const seen = new Set();
|
|
493
|
+
for (const referencePath of input.sourceBinding.referencePaths) {
|
|
494
|
+
const physicalPath = resolveBoundSourcePath(input.workspaceRoot, input.sourceBinding.taskId, referencePath);
|
|
495
|
+
let document;
|
|
496
|
+
try {
|
|
497
|
+
const parsed = YAML.parse(await readFile(physicalPath, "utf8"));
|
|
498
|
+
if (!isRecord(parsed) || !isRecord(parsed.paths))
|
|
499
|
+
continue;
|
|
500
|
+
document = parsed;
|
|
501
|
+
}
|
|
502
|
+
catch {
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
for (const [apiPath, pathItem] of Object.entries(document.paths)) {
|
|
506
|
+
if (!isRecord(pathItem))
|
|
507
|
+
continue;
|
|
508
|
+
const operation = pathItem.get;
|
|
509
|
+
if (!isRecord(operation))
|
|
510
|
+
continue;
|
|
511
|
+
const parameterValues = [
|
|
512
|
+
...(Array.isArray(pathItem.parameters) ? pathItem.parameters : []),
|
|
513
|
+
...(Array.isArray(operation.parameters) ? operation.parameters : []),
|
|
514
|
+
];
|
|
515
|
+
for (const rawParameter of parameterValues) {
|
|
516
|
+
const parameter = resolveOpenApiSchema(document, rawParameter);
|
|
517
|
+
if (!parameter || typeof parameter.name !== "string")
|
|
518
|
+
continue;
|
|
519
|
+
const location = parameter.in === "path" ? "path" : parameter.in === "query" ? "query" : undefined;
|
|
520
|
+
if (!location)
|
|
521
|
+
continue;
|
|
522
|
+
const property = resolveOpenApiSchema(document, parameter.schema);
|
|
523
|
+
const enumValues = property && Array.isArray(property.enum) ? property.enum.map(String) : null;
|
|
524
|
+
const identity = `GET ${apiPath}|${location}|${parameter.name}`;
|
|
525
|
+
if (seen.has(identity))
|
|
526
|
+
continue;
|
|
527
|
+
seen.add(identity);
|
|
528
|
+
axes.push({
|
|
529
|
+
operation: `GET ${apiPath}`,
|
|
530
|
+
name: parameter.name,
|
|
531
|
+
in: location,
|
|
532
|
+
enumValues,
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return axes;
|
|
538
|
+
}
|
|
358
539
|
function orderedUnique(values) {
|
|
359
540
|
const result = [];
|
|
360
541
|
const seen = new Set();
|
|
@@ -385,8 +566,14 @@ function sectionBody(body, aliases) {
|
|
|
385
566
|
}
|
|
386
567
|
function listTokens(body, aliases, pattern) {
|
|
387
568
|
const section = sectionBody(body, aliases).replaceAll("`", "");
|
|
388
|
-
|
|
389
|
-
|
|
569
|
+
// Prefer dedicated token patterns; never strip ^/$ from alternation-based anchors
|
|
570
|
+
// (that would turn `^A$|^B$` into `A$|^B` and break scanning).
|
|
571
|
+
const tokenSource = pattern === RULE_KEY
|
|
572
|
+
? RULE_KEY_TOKEN.source
|
|
573
|
+
: pattern === TEST_POINT
|
|
574
|
+
? TEST_POINT.source.replace(/^\^/, "").replace(/\$$/, "")
|
|
575
|
+
: pattern.source.replace(/^\^/, "").replace(/\$$/, "");
|
|
576
|
+
return orderedUnique(section.match(new RegExp(tokenSource, "g")) ?? []);
|
|
390
577
|
}
|
|
391
578
|
function scenarioTypes(body) {
|
|
392
579
|
return orderedUnique(sectionBody(body, CASE_SECTION_ALIASES.scenarioTypes)
|
|
@@ -423,8 +610,40 @@ function testPointBindingFacts(body, testPoints) {
|
|
|
423
610
|
}
|
|
424
611
|
function declaredPrimarySymbols(body) {
|
|
425
612
|
const value = automationLabelBody(body, ["primary symbol", "Primary Symbol", "主测试符号"]);
|
|
613
|
+
if (/^(?:none|无|n\/a|not-applicable)$/i.test(value.trim()))
|
|
614
|
+
return [];
|
|
426
615
|
return orderedUnique(value.match(/\btest_[A-Za-z0-9_]+\b/g) ?? []);
|
|
427
616
|
}
|
|
617
|
+
function isEvidenceOnlyMarkdownCase(testCase) {
|
|
618
|
+
const hasVariant = testCase.testPointBindings.some((item) => item.mode === "variant");
|
|
619
|
+
const hasNonVariant = testCase.testPointBindings.some((item) => item.mode === "assertion" || item.mode === "cross-cutting");
|
|
620
|
+
return (testCase.declaredScripts.length === 0 &&
|
|
621
|
+
testCase.declaredPrimarySymbols.length === 0 &&
|
|
622
|
+
!hasVariant &&
|
|
623
|
+
hasNonVariant);
|
|
624
|
+
}
|
|
625
|
+
function payloadContract(body) {
|
|
626
|
+
const contractValue = automationLabelBody(body, ["Payload Contract", "载荷合同"]);
|
|
627
|
+
const requiredValue = automationLabelBody(body, ["Payload Required Paths", "载荷必需路径"]);
|
|
628
|
+
const allowedValue = automationLabelBody(body, ["Payload Allowed Paths", "载荷允许路径"]);
|
|
629
|
+
const enumValue = automationLabelBody(body, ["Payload Enum", "载荷枚举"]);
|
|
630
|
+
const tokens = (value) => orderedUnique(value.split(/[,;,;]/).map((item) => item.trim()).filter((item) => /^[A-Za-z_][A-Za-z0-9_.]*$/.test(item)));
|
|
631
|
+
const enumValues = {};
|
|
632
|
+
for (const assignment of enumValue.split(/[,;,;]/).map((item) => item.trim()).filter(Boolean)) {
|
|
633
|
+
const match = /^([A-Za-z_][A-Za-z0-9_.]*)\s*(?:=|:)\s*(.+)$/.exec(assignment);
|
|
634
|
+
if (!match)
|
|
635
|
+
continue;
|
|
636
|
+
enumValues[match[1]] = orderedUnique(match[2].split(/\||\//).map((item) => item.trim().replace(/[((].*$/, "").trim().replace(/^['"]|['"]$/g, "")).filter(Boolean));
|
|
637
|
+
}
|
|
638
|
+
const noPayload = /^(?:none|无|not-applicable|n\/a)(?:\s|[((]|$)/i.test(contractValue.trim());
|
|
639
|
+
return {
|
|
640
|
+
declared: noPayload || requiredValue.length > 0 || allowedValue.length > 0 || enumValue.length > 0,
|
|
641
|
+
noPayload,
|
|
642
|
+
requiredPaths: tokens(requiredValue),
|
|
643
|
+
allowedPaths: tokens(allowedValue),
|
|
644
|
+
enumValues,
|
|
645
|
+
};
|
|
646
|
+
}
|
|
428
647
|
function cleanTitle(heading, id) {
|
|
429
648
|
return heading.replace(/^##\s+/, "").replace(/^BE-[A-Z0-9_-]+-\d{2,3}\s*(?:[||—–-]\s*)?/i, "").trim() || id;
|
|
430
649
|
}
|
|
@@ -437,17 +656,26 @@ async function exists(filePath) {
|
|
|
437
656
|
return false;
|
|
438
657
|
}
|
|
439
658
|
}
|
|
440
|
-
async function markdownModuleFiles(workspaceRoot) {
|
|
441
|
-
const
|
|
659
|
+
async function markdownModuleFiles(workspaceRoot, layout) {
|
|
660
|
+
const resolved = layout ?? resolveBackendTestLayout(undefined);
|
|
661
|
+
const dir = path.join(workspaceRoot, ...resolved.markdownDir.split("/"));
|
|
442
662
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
443
|
-
|
|
663
|
+
const all = entries
|
|
444
664
|
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".md") && entry.name.toLowerCase() !== "readme.md")
|
|
445
665
|
.map((entry) => path.join(dir, entry.name))
|
|
446
666
|
.sort();
|
|
667
|
+
// Current-round Markdown scope is the README Module Index manifest: unmanifested
|
|
668
|
+
// legacy .md files must not enter Case/coverage/partition statistics. Workspaces
|
|
669
|
+
// without a Module Index fall back to the whole directory (prior behaviour).
|
|
670
|
+
const manifest = await resolveBackendTestManifestModules(workspaceRoot, resolved);
|
|
671
|
+
if (manifest.manifestStatus !== "valid")
|
|
672
|
+
return [];
|
|
673
|
+
const declaredAbs = new Set(manifest.declaredMdFiles.map((rel) => path.join(workspaceRoot, ...rel.split("/")).toLowerCase()));
|
|
674
|
+
return all.filter((file) => declaredAbs.has(file.toLowerCase())).sort();
|
|
447
675
|
}
|
|
448
|
-
async function parseMarkdownCases(workspaceRoot) {
|
|
676
|
+
async function parseMarkdownCases(workspaceRoot, layout) {
|
|
449
677
|
const result = [];
|
|
450
|
-
for (const file of await markdownModuleFiles(workspaceRoot)) {
|
|
678
|
+
for (const file of await markdownModuleFiles(workspaceRoot, layout)) {
|
|
451
679
|
const markdown = await readFile(file, "utf8");
|
|
452
680
|
const headings = [...markdown.matchAll(CASE_HEADING)];
|
|
453
681
|
for (let index = 0; index < headings.length; index += 1) {
|
|
@@ -466,6 +694,7 @@ async function parseMarkdownCases(workspaceRoot) {
|
|
|
466
694
|
scenarioTypes: scenarioTypes(body),
|
|
467
695
|
declaredScripts: declaredScripts(body),
|
|
468
696
|
declaredPrimarySymbols: declaredPrimarySymbols(body),
|
|
697
|
+
payloadContract: payloadContract(body),
|
|
469
698
|
body,
|
|
470
699
|
});
|
|
471
700
|
}
|
|
@@ -483,8 +712,17 @@ function parseTableRow(line) {
|
|
|
483
712
|
index += 1;
|
|
484
713
|
}
|
|
485
714
|
else if (char === "|") {
|
|
486
|
-
|
|
487
|
-
|
|
715
|
+
const prev = trimmed[index - 1] ?? "";
|
|
716
|
+
const next = trimmed[index + 1] ?? "";
|
|
717
|
+
// In-cell enum/value pipes look like ACTIVE|ARCHIVED (alnum on both sides).
|
|
718
|
+
// Column separators use " | " or touch markdown separator dashes (---|---).
|
|
719
|
+
if (/[A-Za-z0-9_]/.test(prev) && /[A-Za-z0-9_]/.test(next)) {
|
|
720
|
+
current += "|";
|
|
721
|
+
}
|
|
722
|
+
else {
|
|
723
|
+
cells.push(current.trim());
|
|
724
|
+
current = "";
|
|
725
|
+
}
|
|
488
726
|
}
|
|
489
727
|
else {
|
|
490
728
|
current += char;
|
|
@@ -637,15 +875,26 @@ function renderCoverageTable(rules) {
|
|
|
637
875
|
];
|
|
638
876
|
}
|
|
639
877
|
export async function analyzeBackendTestCaseCoverage(input) {
|
|
878
|
+
const layout = input.layout ?? resolveBackendTestLayout(undefined);
|
|
640
879
|
const findings = [];
|
|
641
|
-
const readmePath = path.join(input.workspaceRoot, "
|
|
880
|
+
const readmePath = path.join(input.workspaceRoot, ...layout.readmePath.split("/"));
|
|
642
881
|
const readme = (await exists(readmePath)) ? await readFile(readmePath, "utf8") : "";
|
|
643
882
|
const coverageScope = parseCoverageScope(readme);
|
|
644
883
|
findings.push(...coverageScope.findings);
|
|
645
884
|
const matrix = parseCoverageMatrix(readme);
|
|
646
885
|
findings.push(...matrix.findings);
|
|
647
|
-
const
|
|
648
|
-
|
|
886
|
+
const manifest = await resolveBackendTestManifestModules(input.workspaceRoot, layout);
|
|
887
|
+
// Fail closed on README Module Index entries declared but never materialized.
|
|
888
|
+
if (manifest.declaredMissing.length > 0) {
|
|
889
|
+
findings.push(`declared Markdown module is missing (Module Index): ${manifest.declaredMissing.join(", ")}`);
|
|
890
|
+
}
|
|
891
|
+
// Fail closed on every non-valid Module Index state: without a valid unique
|
|
892
|
+
// Module Index the current round must not re-scan historical `.md` files.
|
|
893
|
+
if (manifest.manifestStatus !== "valid") {
|
|
894
|
+
findings.push(backendManifestStatusFinding(manifest.manifestStatus, manifest.moduleIndexHeadingCount));
|
|
895
|
+
}
|
|
896
|
+
const cases = await parseMarkdownCases(input.workspaceRoot, layout);
|
|
897
|
+
for (const file of await markdownModuleFiles(input.workspaceRoot, layout)) {
|
|
649
898
|
const markdown = await readFile(file, "utf8");
|
|
650
899
|
for (const match of markdown.matchAll(NON_CANONICAL_CASE_HEADING)) {
|
|
651
900
|
findings.push(`non-canonical Case ID heading must use BE-<MODULE>-<NNN>: ${match[1]}`);
|
|
@@ -687,7 +936,10 @@ export async function analyzeBackendTestCaseCoverage(input) {
|
|
|
687
936
|
if (!rowCaseIds.includes(testCase.caseId))
|
|
688
937
|
findings.push(`${testCase.caseId} references ${ruleKey} but is absent from its Coverage Matrix Case IDs`);
|
|
689
938
|
}
|
|
690
|
-
|
|
939
|
+
const normalizedDeclaredStatus = /^(?:COVERED|PARTIAL|GAP|CONFLICT)$/.test(declaredStatus)
|
|
940
|
+
? declaredStatus
|
|
941
|
+
: "PARTIAL";
|
|
942
|
+
let status = normalizedDeclaredStatus;
|
|
691
943
|
if (status === "COVERED" && missingTestPoints.length > 0)
|
|
692
944
|
status = "PARTIAL";
|
|
693
945
|
if (missingTestPoints.length > 0)
|
|
@@ -707,7 +959,7 @@ export async function analyzeBackendTestCaseCoverage(input) {
|
|
|
707
959
|
rule: row.Rule || "unavailable",
|
|
708
960
|
requiredTestPoints,
|
|
709
961
|
caseIds: rowCaseIds,
|
|
710
|
-
declaredStatus:
|
|
962
|
+
declaredStatus: normalizedDeclaredStatus,
|
|
711
963
|
coveredTestPoints,
|
|
712
964
|
missingTestPoints,
|
|
713
965
|
status,
|
|
@@ -749,8 +1001,12 @@ export async function analyzeBackendTestCaseCoverage(input) {
|
|
|
749
1001
|
findings.push(`${testCase.caseId} missing section or entries: 测试点`);
|
|
750
1002
|
if (testCase.scenarioTypes.length === 0)
|
|
751
1003
|
findings.push(`${testCase.caseId} missing section or entries: 场景类型`);
|
|
752
|
-
if (testCase
|
|
1004
|
+
if (isEvidenceOnlyMarkdownCase(testCase)) {
|
|
1005
|
+
// Evidence-only meta cases intentionally declare 脚本/primary symbol as 无 and only keep assertion/cross-cutting TPs.
|
|
1006
|
+
}
|
|
1007
|
+
else if (testCase.declaredPrimarySymbols.length !== 1) {
|
|
753
1008
|
findings.push(`${testCase.caseId} must declare exactly one primary symbol`);
|
|
1009
|
+
}
|
|
754
1010
|
if (testCase.unclassifiedTestPoints.length > 0)
|
|
755
1011
|
findings.push(`${testCase.caseId} has unclassified Test Points: ${testCase.unclassifiedTestPoints.join(", ")}`);
|
|
756
1012
|
if (testCase.duplicateBindingTestPoints.length > 0)
|
|
@@ -772,8 +1028,37 @@ export async function analyzeBackendTestCaseCoverage(input) {
|
|
|
772
1028
|
if (uniqueOwners.length > 1)
|
|
773
1029
|
findings.push(`${testPoint} is bound as a non-cross-cutting Test Point by multiple Cases: ${uniqueOwners.join(", ")}`);
|
|
774
1030
|
}
|
|
775
|
-
const
|
|
1031
|
+
const generatedMarkdown = [readme, ...cases.map((item) => item.body)].join("\n\n");
|
|
1032
|
+
const sourceCoverageFacts = await collectSourceCoverageFacts({
|
|
1033
|
+
workspaceRoot: input.workspaceRoot,
|
|
1034
|
+
sourceBinding: input.sourceBinding,
|
|
1035
|
+
generatedMarkdown,
|
|
1036
|
+
});
|
|
1037
|
+
const residualSourceFacts = sourceCoverageFacts.filter((fact) => !fact.covered);
|
|
1038
|
+
for (const fact of residualSourceFacts) {
|
|
1039
|
+
findings.push(`source coverage is missing ${fact.kind} ${fact.key} from ${fact.sourcePath}`);
|
|
1040
|
+
}
|
|
1041
|
+
const sourceRequirementIds = sourceCoverageFacts
|
|
1042
|
+
.filter((fact) => fact.kind === "requirement-id")
|
|
1043
|
+
.map((fact) => fact.key);
|
|
1044
|
+
const explicitAc = orderedUnique([
|
|
1045
|
+
...input.sourceBinding.requirementIds.filter((id) => id.startsWith("AC-")),
|
|
1046
|
+
...sourceRequirementIds.filter((id) => id.startsWith("AC-")),
|
|
1047
|
+
]);
|
|
776
1048
|
const coveredAc = new Set(cases.flatMap((item) => item.acIds));
|
|
1049
|
+
for (const acId of explicitAc) {
|
|
1050
|
+
if (!coveredAc.has(acId))
|
|
1051
|
+
findings.push(`explicit source AC is missing from Markdown Cases: ${acId}`);
|
|
1052
|
+
}
|
|
1053
|
+
// Plan C: deterministic query/filter axis partitions (fail-closed on missing slots).
|
|
1054
|
+
const parsedPartitions = parseScenarioPartitions(readme);
|
|
1055
|
+
findings.push(...parsedPartitions.issues);
|
|
1056
|
+
const openApiPartitionAxes = await extractBackendTestOpenApiPartitionAxes(input);
|
|
1057
|
+
const partitionedBySource = applyOpenApiPartitionDomainPolicy(parsedPartitions.partitions, openApiPartitionAxes);
|
|
1058
|
+
findings.push(...partitionedBySource.issues);
|
|
1059
|
+
const declaredPointUniverse = new Set(cases.flatMap((item) => item.testPoints));
|
|
1060
|
+
const partitionAssessment = assessScenarioPartitionCoverage(partitionedBySource.partitions, declaredPointUniverse);
|
|
1061
|
+
findings.push(...partitionAssessment.findings);
|
|
777
1062
|
const [enumValueCount, coveredEnumValueCount] = dimensionCounts(rules, /enum/);
|
|
778
1063
|
const [boundaryPointCount, coveredBoundaryPointCount] = dimensionCounts(rules, /boundary|length|numeric/);
|
|
779
1064
|
const [formatClassCount, coveredFormatClassCount] = dimensionCounts(rules, /format|pattern|charset/);
|
|
@@ -782,20 +1067,27 @@ export async function analyzeBackendTestCaseCoverage(input) {
|
|
|
782
1067
|
const invalidEquivalenceClassCount = rules.reduce((sum, rule) => sum + rule.requiredTestPoints.filter((point) => invalidPointPattern.test(point)).length, 0);
|
|
783
1068
|
const coveredInvalidEquivalenceClassCount = rules.reduce((sum, rule) => sum + rule.coveredTestPoints.filter((point) => invalidPointPattern.test(point)).length, 0);
|
|
784
1069
|
const facts = backendTestCaseCoverageFactsSchema.parse({
|
|
785
|
-
schemaId: "backend-test-case-coverage-facts-
|
|
786
|
-
schemaVersion:
|
|
1070
|
+
schemaId: "backend-test-case-coverage-facts-v4",
|
|
1071
|
+
schemaVersion: 4,
|
|
787
1072
|
taskId: input.sourceBinding.taskId,
|
|
788
1073
|
status: findings.length === 0 ? "PASS" : "FAIL",
|
|
789
1074
|
sourceBinding: input.sourceBinding,
|
|
790
1075
|
inputFiles: await inputFileFacts(input.workspaceRoot, [
|
|
791
1076
|
...((await exists(readmePath)) ? [readmePath] : []),
|
|
792
|
-
...(await markdownModuleFiles(input.workspaceRoot)),
|
|
1077
|
+
...(await markdownModuleFiles(input.workspaceRoot, layout)),
|
|
793
1078
|
...(await Promise.all(input.sourceBinding.referencePaths.map(async (referencePath) => {
|
|
794
1079
|
const physicalPath = resolveBoundSourcePath(input.workspaceRoot, input.sourceBinding.taskId, referencePath);
|
|
795
1080
|
return (await exists(physicalPath)) ? physicalPath : "";
|
|
796
1081
|
}))).filter(Boolean),
|
|
797
1082
|
]),
|
|
798
1083
|
coverageScope: coverageScope.scope,
|
|
1084
|
+
sourceCoverage: {
|
|
1085
|
+
sourceFactCount: sourceCoverageFacts.length,
|
|
1086
|
+
coveredSourceFactCount: sourceCoverageFacts.length - residualSourceFacts.length,
|
|
1087
|
+
residualSourceFactCount: residualSourceFacts.length,
|
|
1088
|
+
facts: sourceCoverageFacts,
|
|
1089
|
+
residual: residualSourceFacts,
|
|
1090
|
+
},
|
|
799
1091
|
summary: {
|
|
800
1092
|
explicitAcCount: explicitAc.length,
|
|
801
1093
|
coveredAcCount: explicitAc.filter((id) => coveredAc.has(id)).length,
|
|
@@ -823,6 +1115,7 @@ export async function analyzeBackendTestCaseCoverage(input) {
|
|
|
823
1115
|
},
|
|
824
1116
|
cases: cases.map(({ body: _body, ...item }) => item),
|
|
825
1117
|
rules,
|
|
1118
|
+
scenarioPartitions: partitionAssessment.facts,
|
|
826
1119
|
findings: orderedUnique(findings),
|
|
827
1120
|
evidenceGaps,
|
|
828
1121
|
conflicts,
|
|
@@ -835,6 +1128,15 @@ export async function analyzeBackendTestCaseCoverage(input) {
|
|
|
835
1128
|
"",
|
|
836
1129
|
facts.status,
|
|
837
1130
|
"",
|
|
1131
|
+
`- Manifest scope: ${manifest.manifestStatus === "valid" ? "declared-modules" : `fails-closed:${manifest.manifestStatus}`} (declared-missing=${manifest.declaredMissing.length}, unmanifested-advisory=${manifest.unmanifested.length}, module-index-heading-count=${manifest.moduleIndexHeadingCount})`,
|
|
1132
|
+
"",
|
|
1133
|
+
"## Unmanifested Markdown Modules",
|
|
1134
|
+
"",
|
|
1135
|
+
"- Advisory: existing .md files not declared by the README Module Index are excluded from Case/coverage/partition statistics for this round.",
|
|
1136
|
+
...(manifest.unmanifested.length > 0
|
|
1137
|
+
? manifest.unmanifested.map((item) => `- ${item}`)
|
|
1138
|
+
: ["- None"]),
|
|
1139
|
+
"",
|
|
838
1140
|
"## Coverage Scope",
|
|
839
1141
|
"",
|
|
840
1142
|
`- Change Classification: ${facts.coverageScope.changeClassification}`,
|
|
@@ -846,6 +1148,22 @@ export async function analyzeBackendTestCaseCoverage(input) {
|
|
|
846
1148
|
`- Scope Evidence: ${facts.coverageScope.scopeEvidence.join(", ") || "Unavailable"}`,
|
|
847
1149
|
...(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
1150
|
"",
|
|
1151
|
+
"## Source Coverage Inventory",
|
|
1152
|
+
"",
|
|
1153
|
+
`- Source facts: ${facts.sourceCoverage.sourceFactCount}`,
|
|
1154
|
+
`- Covered source facts: ${facts.sourceCoverage.coveredSourceFactCount}`,
|
|
1155
|
+
`- Residual source facts: ${facts.sourceCoverage.residualSourceFactCount}`,
|
|
1156
|
+
"",
|
|
1157
|
+
"| Kind | Key | Source Anchor | Source SHA-256 | Exact Quote | Status |",
|
|
1158
|
+
"|---|---|---|---|---|---|",
|
|
1159
|
+
...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"} |`),
|
|
1160
|
+
"",
|
|
1161
|
+
"## Residual Missing Source Scenarios",
|
|
1162
|
+
"",
|
|
1163
|
+
...(facts.sourceCoverage.residual.length > 0
|
|
1164
|
+
? 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.`)
|
|
1165
|
+
: ["- None"]),
|
|
1166
|
+
"",
|
|
849
1167
|
"## Coverage Summary",
|
|
850
1168
|
"",
|
|
851
1169
|
"| Dimension | Total | Covered |",
|
|
@@ -902,13 +1220,347 @@ export async function analyzeBackendTestCaseCoverage(input) {
|
|
|
902
1220
|
function normalizeModuleStem(markdownPath) {
|
|
903
1221
|
return path.posix.basename(markdownPath).replace(/\.md$/i, "").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").replace(/_+/g, "_");
|
|
904
1222
|
}
|
|
905
|
-
function expectedScript(markdownPath) {
|
|
906
|
-
|
|
1223
|
+
function expectedScript(markdownPath, layout) {
|
|
1224
|
+
const resolved = layout ?? resolveBackendTestLayout(undefined);
|
|
1225
|
+
return `${resolved.scriptDir}/test_${normalizeModuleStem(markdownPath)}.py`;
|
|
907
1226
|
}
|
|
908
1227
|
function symbolCaseId(symbol) {
|
|
909
1228
|
const match = symbol.match(/^test_(BE(?:_[A-Z0-9]+)+?_\d{2,3})(?:_|$)/i);
|
|
910
1229
|
return match ? canonicalCaseId(match[1].replaceAll("_", "-")) : undefined;
|
|
911
1230
|
}
|
|
1231
|
+
function pythonPayloadShapes(source) {
|
|
1232
|
+
const program = String.raw `
|
|
1233
|
+
import ast,json,sys
|
|
1234
|
+
source=sys.stdin.buffer.read().decode('utf-8')
|
|
1235
|
+
tree=ast.parse(source)
|
|
1236
|
+
GLOBAL_PAYLOADS={}
|
|
1237
|
+
GLOBAL_VALUES={}
|
|
1238
|
+
for top in tree.body:
|
|
1239
|
+
if isinstance(top,(ast.Assign,ast.AnnAssign)):
|
|
1240
|
+
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 []))
|
|
1241
|
+
if top.value is None: continue
|
|
1242
|
+
candidates=[]
|
|
1243
|
+
for node in ast.walk(top.value):
|
|
1244
|
+
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])
|
|
1245
|
+
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)
|
|
1246
|
+
for name in names:
|
|
1247
|
+
GLOBAL_PAYLOADS[name]=candidates
|
|
1248
|
+
GLOBAL_VALUES[name]=top.value
|
|
1249
|
+
FUNCTION_PAYLOADS={}
|
|
1250
|
+
FUNCTION_TRANSPORT_SHAPES={}
|
|
1251
|
+
TRANSPORT_PAYLOAD_PARAMS={}
|
|
1252
|
+
for top in ast.walk(tree):
|
|
1253
|
+
if isinstance(top,(ast.FunctionDef,ast.AsyncFunctionDef)) and not top.name.startswith('test_'):
|
|
1254
|
+
candidates=[]
|
|
1255
|
+
assigned={}
|
|
1256
|
+
mutated={}
|
|
1257
|
+
returned_names=set()
|
|
1258
|
+
positional=[arg.arg for arg in [*top.args.posonlyargs,*top.args.args]]
|
|
1259
|
+
call_offset=1 if positional and positional[0] in {'self','cls'} else 0
|
|
1260
|
+
transport=[]
|
|
1261
|
+
for node in ast.walk(top):
|
|
1262
|
+
if isinstance(node,(ast.Assign,ast.AnnAssign)):
|
|
1263
|
+
targets=node.targets if isinstance(node,ast.Assign) else [node.target]
|
|
1264
|
+
if node.value is None: continue
|
|
1265
|
+
for target in targets:
|
|
1266
|
+
if isinstance(target,ast.Name): assigned[target.id]=node.value
|
|
1267
|
+
for target in targets:
|
|
1268
|
+
if isinstance(target,ast.Subscript) and isinstance(target.value,ast.Name) and isinstance(target.slice,ast.Constant) and isinstance(target.slice.value,str):
|
|
1269
|
+
mutated.setdefault(target.value.id,[]).append((target.slice.value,node.value))
|
|
1270
|
+
if isinstance(node,ast.Return):
|
|
1271
|
+
if isinstance(node.value,ast.Dict): candidates.append(node.value)
|
|
1272
|
+
elif isinstance(node.value,ast.Name) and node.value.id in assigned:
|
|
1273
|
+
returned=assigned[node.value.id]
|
|
1274
|
+
if isinstance(returned,ast.Dict): candidates.append(returned)
|
|
1275
|
+
elif isinstance(returned,ast.Call) and isinstance(returned.func,ast.Name) and returned.func.id in FUNCTION_PAYLOADS:
|
|
1276
|
+
candidates.extend(FUNCTION_PAYLOADS[returned.func.id])
|
|
1277
|
+
returned_names.add(node.value.id)
|
|
1278
|
+
if isinstance(node,ast.Call):
|
|
1279
|
+
for keyword in node.keywords:
|
|
1280
|
+
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:
|
|
1281
|
+
transport.append((keyword.value.id,positional.index(keyword.value.id)-call_offset))
|
|
1282
|
+
if isinstance(node.func,ast.Attribute) and node.func.attr.lower() in {'post','put','patch'} and len(node.args) >= 2:
|
|
1283
|
+
candidate=node.args[1]
|
|
1284
|
+
if isinstance(candidate,ast.Name) and candidate.id in positional:
|
|
1285
|
+
transport.append((candidate.id,positional.index(candidate.id)-call_offset))
|
|
1286
|
+
callee=None
|
|
1287
|
+
if isinstance(node.func,ast.Name): callee=node.func.id
|
|
1288
|
+
elif isinstance(node.func,ast.Attribute): callee=node.func.attr
|
|
1289
|
+
for _nested_name,nested_position in TRANSPORT_PAYLOAD_PARAMS.get(callee,[]):
|
|
1290
|
+
matched=node.args[nested_position] if nested_position >= 0 and nested_position < len(node.args) else None
|
|
1291
|
+
if isinstance(matched,ast.Name) and matched.id in positional:
|
|
1292
|
+
transport.append((matched.id,positional.index(matched.id)-call_offset))
|
|
1293
|
+
for returned_name in returned_names:
|
|
1294
|
+
entries=mutated.get(returned_name,[])
|
|
1295
|
+
if entries:
|
|
1296
|
+
candidates.append(ast.Dict(keys=[ast.Constant(key) for key,_value in entries],values=[value for _key,value in entries]))
|
|
1297
|
+
transport_shapes=[]
|
|
1298
|
+
for node in ast.walk(top):
|
|
1299
|
+
if not isinstance(node,ast.Call): continue
|
|
1300
|
+
for keyword in node.keywords:
|
|
1301
|
+
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:
|
|
1302
|
+
transport_shapes.append(assigned[keyword.value.id])
|
|
1303
|
+
if candidates: FUNCTION_PAYLOADS[top.name]=candidates
|
|
1304
|
+
if transport_shapes: FUNCTION_TRANSPORT_SHAPES[top.name]=transport_shapes
|
|
1305
|
+
if transport: TRANSPORT_PAYLOAD_PARAMS[top.name]=list(dict.fromkeys(transport))
|
|
1306
|
+
# Propagate transport payload positions through local wrapper chains after all
|
|
1307
|
+
# direct transport methods/functions are known. ast.walk may visit module-level
|
|
1308
|
+
# wrappers before class methods, so one-pass discovery is order-sensitive.
|
|
1309
|
+
for _round in range(6):
|
|
1310
|
+
changed=False
|
|
1311
|
+
for top in ast.walk(tree):
|
|
1312
|
+
if not isinstance(top,(ast.FunctionDef,ast.AsyncFunctionDef)) or top.name.startswith('test_'): continue
|
|
1313
|
+
positional=[arg.arg for arg in [*top.args.posonlyargs,*top.args.args]]
|
|
1314
|
+
call_offset=1 if positional and positional[0] in {'self','cls'} else 0
|
|
1315
|
+
current=list(TRANSPORT_PAYLOAD_PARAMS.get(top.name,[]))
|
|
1316
|
+
for node in ast.walk(top):
|
|
1317
|
+
if not isinstance(node,ast.Call): continue
|
|
1318
|
+
callee=node.func.id if isinstance(node.func,ast.Name) else (node.func.attr if isinstance(node.func,ast.Attribute) else None)
|
|
1319
|
+
for _nested_name,nested_position in TRANSPORT_PAYLOAD_PARAMS.get(callee,[]):
|
|
1320
|
+
matched=node.args[nested_position] if nested_position >= 0 and nested_position < len(node.args) else None
|
|
1321
|
+
if isinstance(matched,ast.Name) and matched.id in positional:
|
|
1322
|
+
item=(matched.id,positional.index(matched.id)-call_offset)
|
|
1323
|
+
if item not in current:
|
|
1324
|
+
current.append(item); changed=True
|
|
1325
|
+
if current: TRANSPORT_PAYLOAD_PARAMS[top.name]=current
|
|
1326
|
+
if not changed: break
|
|
1327
|
+
|
|
1328
|
+
# A local row factory may return pytest.param(_payload_builder(...), ..., id=tp).
|
|
1329
|
+
# Treat its first positional payload builder as shape evidence while keeping the
|
|
1330
|
+
# writer contract free to require direct pytest.param rows for scenario checks.
|
|
1331
|
+
for top in ast.walk(tree):
|
|
1332
|
+
if not isinstance(top,(ast.FunctionDef,ast.AsyncFunctionDef)) or top.name.startswith('test_'): continue
|
|
1333
|
+
for node in ast.walk(top):
|
|
1334
|
+
if not isinstance(node,ast.Return) or not isinstance(node.value,ast.Call): continue
|
|
1335
|
+
returned=node.value
|
|
1336
|
+
if not isinstance(returned.func,ast.Attribute) or returned.func.attr != 'param' or not returned.args: continue
|
|
1337
|
+
payload=returned.args[0]
|
|
1338
|
+
if isinstance(payload,ast.Call) and isinstance(payload.func,ast.Name) and payload.func.id in FUNCTION_PAYLOADS:
|
|
1339
|
+
FUNCTION_PAYLOADS[top.name]=list(FUNCTION_PAYLOADS[payload.func.id])
|
|
1340
|
+
# Resolve module-level parameter collections after local payload/row factories
|
|
1341
|
+
# are known (for example CASE_ROWS = [_post_case(...) , ...]).
|
|
1342
|
+
for top in tree.body:
|
|
1343
|
+
if not isinstance(top,ast.Assign): continue
|
|
1344
|
+
names=[target.id for target in top.targets if isinstance(target,ast.Name)]
|
|
1345
|
+
resolved=[]
|
|
1346
|
+
for node in ast.walk(top.value):
|
|
1347
|
+
if isinstance(node,ast.Call) and isinstance(node.func,ast.Name) and node.func.id in FUNCTION_PAYLOADS:
|
|
1348
|
+
resolved.extend(FUNCTION_PAYLOADS[node.func.id])
|
|
1349
|
+
for name in names:
|
|
1350
|
+
if resolved: GLOBAL_PAYLOADS[name]=list(GLOBAL_PAYLOADS.get(name,[]))+resolved
|
|
1351
|
+
|
|
1352
|
+
def literal(node):
|
|
1353
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, (str,int,float,bool,type(None))):
|
|
1354
|
+
return repr(node.value)
|
|
1355
|
+
return None
|
|
1356
|
+
|
|
1357
|
+
def collect_dict(node, prefix, paths, values):
|
|
1358
|
+
if not isinstance(node, ast.Dict): return
|
|
1359
|
+
for key,value in zip(node.keys,node.values):
|
|
1360
|
+
if not isinstance(key, ast.Constant) or not isinstance(key.value,str): continue
|
|
1361
|
+
# Ignore writer helper/meta keys that are not DTO request fields.
|
|
1362
|
+
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_'):
|
|
1363
|
+
continue
|
|
1364
|
+
current = key.value if not prefix else prefix + '.' + key.value
|
|
1365
|
+
paths.add(current)
|
|
1366
|
+
lv=literal(value)
|
|
1367
|
+
if lv is not None: values.setdefault(current,set()).add(lv)
|
|
1368
|
+
if isinstance(value, ast.Dict): collect_dict(value,current,paths,values)
|
|
1369
|
+
elif isinstance(value,(ast.List,ast.Tuple)):
|
|
1370
|
+
for item in value.elts:
|
|
1371
|
+
if isinstance(item,ast.Dict): collect_dict(item,current,paths,values)
|
|
1372
|
+
|
|
1373
|
+
PAYLOAD_NAMES={'payload','body','request_body','request_json','dto','json','data','json_body'}
|
|
1374
|
+
|
|
1375
|
+
def shape_only(node):
|
|
1376
|
+
if isinstance(node,ast.Dict):
|
|
1377
|
+
values=[]
|
|
1378
|
+
for value in node.values:
|
|
1379
|
+
if isinstance(value,(ast.Dict,ast.List,ast.Tuple)): values.append(shape_only(value))
|
|
1380
|
+
else: values.append(ast.Name(id='_PAYLOAD_VALUE_',ctx=ast.Load()))
|
|
1381
|
+
return ast.Dict(keys=node.keys,values=values)
|
|
1382
|
+
if isinstance(node,(ast.List,ast.Tuple)):
|
|
1383
|
+
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]
|
|
1384
|
+
return ast.List(elts=items,ctx=ast.Load()) if isinstance(node,ast.List) else ast.Tuple(elts=items,ctx=ast.Load())
|
|
1385
|
+
return node
|
|
1386
|
+
|
|
1387
|
+
def payload_expr_nodes(expr, assigned, parameters, seen=None):
|
|
1388
|
+
seen=set() if seen is None else seen
|
|
1389
|
+
if isinstance(expr,ast.Dict): return [expr]
|
|
1390
|
+
if isinstance(expr,ast.Name):
|
|
1391
|
+
if expr.id in seen: return []
|
|
1392
|
+
seen.add(expr.id)
|
|
1393
|
+
if expr.id in assigned: return payload_expr_nodes(assigned[expr.id],assigned,parameters,seen)
|
|
1394
|
+
if expr.id in parameters:
|
|
1395
|
+
result=[]
|
|
1396
|
+
for value in parameters[expr.id]: result.extend(payload_expr_nodes(value,assigned,parameters,set(seen)))
|
|
1397
|
+
return result
|
|
1398
|
+
return list(GLOBAL_PAYLOADS.get(expr.id,[]))
|
|
1399
|
+
if isinstance(expr,ast.Subscript) and isinstance(expr.value,ast.Name) and isinstance(expr.slice,ast.Constant) and isinstance(expr.slice.value,str):
|
|
1400
|
+
result=[]
|
|
1401
|
+
for candidate in parameters.get(expr.value.id,[]):
|
|
1402
|
+
if not isinstance(candidate,ast.Dict): continue
|
|
1403
|
+
for key,value in zip(candidate.keys,candidate.values):
|
|
1404
|
+
if isinstance(key,ast.Constant) and key.value == expr.slice.value:
|
|
1405
|
+
result.extend(shape_only(item) for item in payload_expr_nodes(value,assigned,parameters,set(seen)))
|
|
1406
|
+
return result
|
|
1407
|
+
if isinstance(expr,ast.Call):
|
|
1408
|
+
result=[]
|
|
1409
|
+
if isinstance(expr.func,ast.Name) and expr.func.id in FUNCTION_PAYLOADS:
|
|
1410
|
+
result.extend(FUNCTION_PAYLOADS[expr.func.id])
|
|
1411
|
+
override_keys=[]; override_values=[]
|
|
1412
|
+
for keyword in expr.keywords:
|
|
1413
|
+
if keyword.arg:
|
|
1414
|
+
override_keys.append(ast.Constant(keyword.arg)); override_values.append(keyword.value)
|
|
1415
|
+
if override_keys: result.append(ast.Dict(keys=override_keys,values=override_values))
|
|
1416
|
+
# A transport body may be assembled by a local combinator such as
|
|
1417
|
+
# _apply_patch(_base_body(...), patch). Because this traversal starts
|
|
1418
|
+
# only from an identified transport-body position, recursively merging
|
|
1419
|
+
# argument shapes is narrower than scanning arbitrary helper calls.
|
|
1420
|
+
for argument in expr.args:
|
|
1421
|
+
result.extend(payload_expr_nodes(argument,assigned,parameters,set(seen)))
|
|
1422
|
+
for keyword in expr.keywords:
|
|
1423
|
+
result.extend(payload_expr_nodes(keyword.value,assigned,parameters,set(seen)))
|
|
1424
|
+
return result
|
|
1425
|
+
return []
|
|
1426
|
+
|
|
1427
|
+
def decorator_parameter_values(dec):
|
|
1428
|
+
result={}
|
|
1429
|
+
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
|
|
1430
|
+
if not isinstance(dec.args[0],ast.Constant) or not isinstance(dec.args[0].value,str): return result
|
|
1431
|
+
names=[item.strip() for item in dec.args[0].value.split(',')]
|
|
1432
|
+
rows=dec.args[1]
|
|
1433
|
+
if isinstance(rows,ast.Name) and rows.id in GLOBAL_VALUES: rows=GLOBAL_VALUES[rows.id]
|
|
1434
|
+
for node in ast.walk(rows):
|
|
1435
|
+
if not isinstance(node,ast.Call) or not isinstance(node.func,ast.Attribute) or node.func.attr != 'param': continue
|
|
1436
|
+
for index,name in enumerate(names):
|
|
1437
|
+
if index < len(node.args): result.setdefault(name,[]).append(node.args[index])
|
|
1438
|
+
return result
|
|
1439
|
+
|
|
1440
|
+
def decorator_payload_nodes(parameters,assigned):
|
|
1441
|
+
result=[]
|
|
1442
|
+
for name,values in parameters.items():
|
|
1443
|
+
if name.lower() not in PAYLOAD_NAMES: continue
|
|
1444
|
+
for value in values: result.extend(payload_expr_nodes(value,assigned,parameters))
|
|
1445
|
+
return result
|
|
1446
|
+
|
|
1447
|
+
def fallback_payload_nodes(fn):
|
|
1448
|
+
result=[]
|
|
1449
|
+
assigned={}
|
|
1450
|
+
for node in ast.walk(fn):
|
|
1451
|
+
if isinstance(node,(ast.Assign,ast.AnnAssign)):
|
|
1452
|
+
if node.value is None: continue
|
|
1453
|
+
targets=node.targets if isinstance(node,ast.Assign) else [node.target]
|
|
1454
|
+
for target in targets:
|
|
1455
|
+
if isinstance(target,ast.Name): assigned[target.id]=node.value
|
|
1456
|
+
parameters={}
|
|
1457
|
+
for dec in fn.decorator_list:
|
|
1458
|
+
for name,values in decorator_parameter_values(dec).items(): parameters.setdefault(name,[]).extend(values)
|
|
1459
|
+
for node in ast.walk(fn):
|
|
1460
|
+
if not isinstance(node,ast.Call): continue
|
|
1461
|
+
callee=None
|
|
1462
|
+
if isinstance(node.func,ast.Name): callee=node.func.id
|
|
1463
|
+
elif isinstance(node.func,ast.Attribute): callee=node.func.attr
|
|
1464
|
+
result.extend(FUNCTION_TRANSPORT_SHAPES.get(callee,[]))
|
|
1465
|
+
# Wrapper payloads are fallback evidence: they are useful when the Case
|
|
1466
|
+
# contract requires a body, but must not contaminate GET/DELETE targets
|
|
1467
|
+
# merely because setup creates a resource first.
|
|
1468
|
+
for parameter_name,position in TRANSPORT_PAYLOAD_PARAMS.get(callee,[]):
|
|
1469
|
+
matched=next((keyword.value for keyword in node.keywords if keyword.arg == parameter_name),None)
|
|
1470
|
+
if matched is None and position >= 0 and position < len(node.args): matched=node.args[position]
|
|
1471
|
+
if matched is not None: result.extend(payload_expr_nodes(matched,assigned,parameters))
|
|
1472
|
+
return result
|
|
1473
|
+
|
|
1474
|
+
def is_direct_transport_call(node):
|
|
1475
|
+
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
|
|
1477
|
+
receiver_tokens=[]
|
|
1478
|
+
current=node.func.value
|
|
1479
|
+
while isinstance(current,ast.Attribute):
|
|
1480
|
+
receiver_tokens.append(current.attr.lower())
|
|
1481
|
+
current=current.value
|
|
1482
|
+
if isinstance(current,ast.Name): receiver_tokens.append(current.id.lower())
|
|
1483
|
+
return any(token in {'requests','httpx','client','session','api_client','http_client'} or token.endswith('_client') for token in receiver_tokens)
|
|
1484
|
+
|
|
1485
|
+
def payload_nodes(fn):
|
|
1486
|
+
result=[]
|
|
1487
|
+
assigned={}
|
|
1488
|
+
for node in ast.walk(fn):
|
|
1489
|
+
if isinstance(node,(ast.Assign,ast.AnnAssign)):
|
|
1490
|
+
if node.value is None: continue
|
|
1491
|
+
targets=node.targets if isinstance(node,ast.Assign) else [node.target]
|
|
1492
|
+
for target in targets:
|
|
1493
|
+
if isinstance(target,ast.Name): assigned[target.id]=node.value
|
|
1494
|
+
parameters={}
|
|
1495
|
+
for dec in fn.decorator_list:
|
|
1496
|
+
for name,values in decorator_parameter_values(dec).items(): parameters.setdefault(name,[]).extend(values)
|
|
1497
|
+
result.extend(decorator_payload_nodes(parameters,assigned))
|
|
1498
|
+
for dec in fn.decorator_list:
|
|
1499
|
+
for node in ast.walk(dec):
|
|
1500
|
+
if isinstance(node,ast.Call) and isinstance(node.func,ast.Name) and node.func.id in FUNCTION_PAYLOADS:
|
|
1501
|
+
result.extend(payload_expr_nodes(node,assigned,parameters))
|
|
1502
|
+
elif isinstance(node,ast.Name) and node.id in GLOBAL_PAYLOADS:
|
|
1503
|
+
result.extend(GLOBAL_PAYLOADS[node.id])
|
|
1504
|
+
# Observe local payload/body variables only when they flow into a direct
|
|
1505
|
+
# HTTP transport call. Arbitrary setup variables are not target requests.
|
|
1506
|
+
for node in ast.walk(fn):
|
|
1507
|
+
if not is_direct_transport_call(node): continue
|
|
1508
|
+
for keyword in node.keywords:
|
|
1509
|
+
if keyword.arg in PAYLOAD_NAMES: result.extend(payload_expr_nodes(keyword.value,assigned,parameters))
|
|
1510
|
+
return result
|
|
1511
|
+
|
|
1512
|
+
out={}
|
|
1513
|
+
for fn in ast.walk(tree):
|
|
1514
|
+
if isinstance(fn,(ast.FunctionDef,ast.AsyncFunctionDef)) and fn.name.startswith('test_'):
|
|
1515
|
+
paths=set(); values={}
|
|
1516
|
+
fallback_paths=set(); fallback_values={}
|
|
1517
|
+
for node in payload_nodes(fn): collect_dict(node,'',paths,values)
|
|
1518
|
+
for node in fallback_payload_nodes(fn): collect_dict(node,'',fallback_paths,fallback_values)
|
|
1519
|
+
out[fn.name]={
|
|
1520
|
+
'paths':sorted(paths),
|
|
1521
|
+
'values':{key:sorted(items) for key,items in sorted(values.items())},
|
|
1522
|
+
'fallbackPaths':sorted(fallback_paths),
|
|
1523
|
+
'fallbackValues':{key:sorted(items) for key,items in sorted(fallback_values.items())},
|
|
1524
|
+
}
|
|
1525
|
+
print(json.dumps(out,ensure_ascii=True))
|
|
1526
|
+
`;
|
|
1527
|
+
// Pass bytes and decode stdin explicitly as UTF-8 in Python. Clean-env
|
|
1528
|
+
// Windows Python may expose stdin as GBK; decoding generated Chinese/emoji
|
|
1529
|
+
// source through that locale can create surrogateescape code points and make
|
|
1530
|
+
// ast.parse fail with "surrogates not allowed".
|
|
1531
|
+
// POSIX images usually expose only `python3` (modern macOS has no `python`);
|
|
1532
|
+
// Windows often has a real `python` plus a Microsoft Store `python3` stub.
|
|
1533
|
+
// A missing interpreter must not silently degrade to empty payload shapes.
|
|
1534
|
+
const interpreterCandidates = process.platform === "win32" ? ["python", "python3"] : ["python3", "python"];
|
|
1535
|
+
let result;
|
|
1536
|
+
for (const interpreter of interpreterCandidates) {
|
|
1537
|
+
const attempt = spawnSync(interpreter, ["-c", program], {
|
|
1538
|
+
input: Buffer.from(source, "utf8"),
|
|
1539
|
+
encoding: "utf8",
|
|
1540
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
1541
|
+
windowsHide: true,
|
|
1542
|
+
});
|
|
1543
|
+
// Interpreter missing/unspawnable → try the next candidate. A spawned
|
|
1544
|
+
// interpreter that exits nonzero analyzed real input; do not retry then.
|
|
1545
|
+
if (attempt.error)
|
|
1546
|
+
continue;
|
|
1547
|
+
result = attempt;
|
|
1548
|
+
break;
|
|
1549
|
+
}
|
|
1550
|
+
if (!result || result.status !== 0 || !result.stdout.trim())
|
|
1551
|
+
return new Map();
|
|
1552
|
+
try {
|
|
1553
|
+
const parsed = JSON.parse(result.stdout);
|
|
1554
|
+
return new Map(Object.entries(parsed));
|
|
1555
|
+
}
|
|
1556
|
+
catch {
|
|
1557
|
+
return new Map();
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
function metadataCaseIds(region) {
|
|
1561
|
+
return orderedUnique([...region.matchAll(/^\s*Case-ID\s*:\s*(BE-[A-Z0-9]+(?:-[A-Z0-9]+)*)\s*$/gmi)]
|
|
1562
|
+
.map((match) => canonicalCaseId(match[1])));
|
|
1563
|
+
}
|
|
912
1564
|
function metadataTestPoints(region, label) {
|
|
913
1565
|
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
914
1566
|
const match = new RegExp(`^\\s*${escaped}\\s*:\\s*(.*)$`, "mi").exec(region);
|
|
@@ -928,10 +1580,35 @@ function pytestFunctionRegionStart(source, functionIndex) {
|
|
|
928
1580
|
}
|
|
929
1581
|
return regionStart;
|
|
930
1582
|
}
|
|
1583
|
+
function pytestFunctionBodyEnd(source, signatureEnd, functionIndent) {
|
|
1584
|
+
let cursor = source.indexOf("\n", signatureEnd);
|
|
1585
|
+
if (cursor < 0)
|
|
1586
|
+
return source.length;
|
|
1587
|
+
cursor += 1;
|
|
1588
|
+
const baseIndent = functionIndent.replaceAll("\t", " ").length;
|
|
1589
|
+
while (cursor < source.length) {
|
|
1590
|
+
const nextLine = source.indexOf("\n", cursor);
|
|
1591
|
+
const lineEnd = nextLine < 0 ? source.length : nextLine;
|
|
1592
|
+
const line = source.slice(cursor, lineEnd).replace(/\r$/, "");
|
|
1593
|
+
if (line.trim()) {
|
|
1594
|
+
const indentation = line.match(/^[ \t]*/)?.[0]?.replaceAll("\t", " ").length ?? 0;
|
|
1595
|
+
if (indentation <= baseIndent)
|
|
1596
|
+
return cursor;
|
|
1597
|
+
}
|
|
1598
|
+
if (nextLine < 0)
|
|
1599
|
+
return source.length;
|
|
1600
|
+
cursor = nextLine + 1;
|
|
1601
|
+
}
|
|
1602
|
+
return source.length;
|
|
1603
|
+
}
|
|
931
1604
|
function pytestParameterCollections(source) {
|
|
932
1605
|
const collections = new Map();
|
|
933
|
-
for (const match of source.matchAll(/^([A-Z][A-Z0-9_]*)\s*=\s*\[([\s\S]*?)^\]/gm)) {
|
|
934
|
-
|
|
1606
|
+
for (const match of source.matchAll(/^(_?[A-Z][A-Z0-9_]*)\s*=\s*\[(?![^\r\n]*\])([\s\S]*?)^\]/gm)) {
|
|
1607
|
+
const explicitIds = [...match[2].matchAll(/\bid\s*=\s*["'](TP-[A-Z0-9-]+)["']/g)].map((item) => item[1]);
|
|
1608
|
+
const factoryIds = explicitIds.length === 0
|
|
1609
|
+
? [...match[2].matchAll(/["'](TP-[A-Z0-9-]+)["']/g)].map((item) => item[1])
|
|
1610
|
+
: [];
|
|
1611
|
+
collections.set(match[1], orderedUnique([...explicitIds, ...factoryIds]));
|
|
935
1612
|
}
|
|
936
1613
|
return collections;
|
|
937
1614
|
}
|
|
@@ -939,21 +1616,29 @@ function pytestSymbols(script, source) {
|
|
|
939
1616
|
const matches = [...source.matchAll(/^([ \t]*)(?:async\s+)?def\s+(test_[A-Za-z0-9_]+)\s*\([^)]*\)\s*(?:->\s*[^:\r\n]+)?\s*:/gm)];
|
|
940
1617
|
const regionStarts = matches.map((match) => pytestFunctionRegionStart(source, match.index));
|
|
941
1618
|
const parameterCollections = pytestParameterCollections(source);
|
|
1619
|
+
const payloadShapes = pythonPayloadShapes(source);
|
|
942
1620
|
return matches.map((match, index) => {
|
|
943
1621
|
const regionStart = regionStarts[index];
|
|
944
|
-
const regionEnd =
|
|
1622
|
+
const regionEnd = pytestFunctionBodyEnd(source, match.index + match[0].length, match[1]);
|
|
945
1623
|
const region = source.slice(match.index, regionEnd);
|
|
946
1624
|
const decorators = source.slice(regionStart, match.index);
|
|
947
|
-
const ids =
|
|
1625
|
+
const ids = metadataCaseIds(region);
|
|
948
1626
|
const fromSymbol = symbolCaseId(match[2]);
|
|
949
1627
|
if (fromSymbol)
|
|
950
1628
|
ids.unshift(fromSymbol);
|
|
1629
|
+
const explicitParameterIds = [...decorators.matchAll(/\bid\s*=\s*["'](TP-[A-Z0-9-]+)["']/g)].map((item) => item[1]);
|
|
1630
|
+
const collectionParameterIds = [...parameterCollections.entries()].flatMap(([name, values]) => new RegExp(`\\b${name}\\b`).test(decorators) ? values : []);
|
|
1631
|
+
const factoryParameterIds = explicitParameterIds.length === 0 && collectionParameterIds.length === 0
|
|
1632
|
+
? [...decorators.matchAll(/["'](TP-[A-Z0-9-]+)["']/g)].map((item) => item[1])
|
|
1633
|
+
: [];
|
|
951
1634
|
const parameterIds = orderedUnique([
|
|
952
|
-
...
|
|
953
|
-
...
|
|
1635
|
+
...explicitParameterIds,
|
|
1636
|
+
...collectionParameterIds,
|
|
1637
|
+
...factoryParameterIds,
|
|
954
1638
|
]);
|
|
955
1639
|
const assertionTestPoints = metadataTestPoints(region, "Assertion-Test-Points");
|
|
956
1640
|
const crossCuttingTestPoints = metadataTestPoints(region, "Cross-Cutting-Test-Points");
|
|
1641
|
+
const payloadShape = payloadShapes.get(match[2]) ?? { paths: [], values: {} };
|
|
957
1642
|
return {
|
|
958
1643
|
script,
|
|
959
1644
|
symbol: match[2],
|
|
@@ -962,13 +1647,69 @@ function pytestSymbols(script, source) {
|
|
|
962
1647
|
parameterIds,
|
|
963
1648
|
assertionTestPoints,
|
|
964
1649
|
crossCuttingTestPoints,
|
|
1650
|
+
payloadPaths: payloadShape.paths,
|
|
1651
|
+
payloadValues: payloadShape.values,
|
|
1652
|
+
fallbackPayloadPaths: payloadShape.fallbackPaths ?? [],
|
|
1653
|
+
fallbackPayloadValues: payloadShape.fallbackValues ?? {},
|
|
965
1654
|
};
|
|
966
1655
|
});
|
|
967
1656
|
}
|
|
1657
|
+
function executionSignature(testCase) {
|
|
1658
|
+
const operation = (testCase.body.match(/\b(?:GET|POST|PUT|PATCH|DELETE)\s+\/[^\s`|)]+/i)?.[0] ?? "operation-unavailable").toUpperCase();
|
|
1659
|
+
const inputPartition = [sectionBody(testCase.body, ["测试数据", "Test Data"]), testCase.scenarioTypes.join(" "), testCase.testPoints.join(" ")].join(" ");
|
|
1660
|
+
const precondition = sectionBody(testCase.body, ["前置条件", "Preconditions", "Precondition"]);
|
|
1661
|
+
const expectedOutcome = sectionBody(testCase.body, ["预期结果", "Expected Results", "Expected Result"]);
|
|
1662
|
+
const normalized = [operation, inputPartition, precondition, expectedOutcome]
|
|
1663
|
+
.map((value) => value.replaceAll(/\s+/g, " ").trim().toLowerCase())
|
|
1664
|
+
.join("\n");
|
|
1665
|
+
return createHash("sha256").update(normalized).digest("hex");
|
|
1666
|
+
}
|
|
1667
|
+
function assessPayloadContract(testCase, refs) {
|
|
1668
|
+
const requiredPaths = testCase.payloadContract.requiredPaths;
|
|
1669
|
+
const directObservedPaths = orderedUnique(refs.flatMap((item) => item.payloadPaths));
|
|
1670
|
+
const useFallback = requiredPaths.length > 0 && directObservedPaths.length === 0;
|
|
1671
|
+
const analyzedObservedPaths = useFallback
|
|
1672
|
+
? orderedUnique(refs.flatMap((item) => item.fallbackPayloadPaths))
|
|
1673
|
+
: directObservedPaths;
|
|
1674
|
+
// A no-body target contract describes the target GET/DELETE request, not
|
|
1675
|
+
// setup POST/PUT calls in the same lifecycle test function.
|
|
1676
|
+
const observedPaths = testCase.payloadContract.noPayload ? [] : analyzedObservedPaths;
|
|
1677
|
+
const allowedPaths = testCase.payloadContract.allowedPaths;
|
|
1678
|
+
if (!testCase.payloadContract.declared) {
|
|
1679
|
+
return { status: "UNAVAILABLE", requiredPaths, allowedPaths, observedPaths, missingPaths: [], unexpectedPaths: [], enumMismatches: [] };
|
|
1680
|
+
}
|
|
1681
|
+
const missingPaths = requiredPaths.filter((item) => !observedPaths.includes(item));
|
|
1682
|
+
const unexpectedPaths = testCase.payloadContract.noPayload
|
|
1683
|
+
? observedPaths
|
|
1684
|
+
: allowedPaths.length > 0
|
|
1685
|
+
? observedPaths.filter((item) => !allowedPaths.includes(item) && !allowedPaths.some((allowed) => item.startsWith(`${allowed}.`)))
|
|
1686
|
+
: [];
|
|
1687
|
+
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
|
+
for (const [field, allowed] of Object.entries(testCase.payloadContract.enumValues)) {
|
|
1692
|
+
const observed = orderedUnique(refs.flatMap((item) => (useFallback ? item.fallbackPayloadValues[field] : item.payloadValues[field]) ?? []).map((value) => value.replace(/^['"]|['"]$/g, "")));
|
|
1693
|
+
for (const value of observed) {
|
|
1694
|
+
if (!allowed.includes(value) && !delegatesInvalidEnumToScenarioGate)
|
|
1695
|
+
enumMismatches.push(`${field}=${value}; allowed=${allowed.join("|")}`);
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
return {
|
|
1699
|
+
status: missingPaths.length > 0 || unexpectedPaths.length > 0 || enumMismatches.length > 0 ? "UNSAFE" : "SAFE",
|
|
1700
|
+
requiredPaths,
|
|
1701
|
+
allowedPaths,
|
|
1702
|
+
observedPaths,
|
|
1703
|
+
missingPaths,
|
|
1704
|
+
unexpectedPaths,
|
|
1705
|
+
enumMismatches,
|
|
1706
|
+
};
|
|
1707
|
+
}
|
|
968
1708
|
export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
|
|
969
|
-
const
|
|
1709
|
+
const layout = input.layout ?? resolveBackendTestLayout(undefined);
|
|
1710
|
+
const cases = await parseMarkdownCases(input.workspaceRoot, layout);
|
|
970
1711
|
const modulePaths = orderedUnique(cases.map((item) => item.markdownPath));
|
|
971
|
-
const scripts = orderedUnique(cases.flatMap((item) => [...item.declaredScripts, expectedScript(item.markdownPath)]));
|
|
1712
|
+
const scripts = orderedUnique(cases.flatMap((item) => [...item.declaredScripts, expectedScript(item.markdownPath, layout)]));
|
|
972
1713
|
const allSymbols = [];
|
|
973
1714
|
const scriptFiles = [];
|
|
974
1715
|
for (const script of scripts) {
|
|
@@ -981,7 +1722,7 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
|
|
|
981
1722
|
const findings = [];
|
|
982
1723
|
const entries = [];
|
|
983
1724
|
for (const testCase of cases) {
|
|
984
|
-
const expected = expectedScript(testCase.markdownPath);
|
|
1725
|
+
const expected = expectedScript(testCase.markdownPath, layout);
|
|
985
1726
|
const declared = testCase.declaredScripts[0];
|
|
986
1727
|
const refs = allSymbols.filter((item) => item.caseIds.includes(testCase.caseId));
|
|
987
1728
|
const actualScripts = orderedUnique(refs.map((item) => item.script));
|
|
@@ -998,21 +1739,44 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
|
|
|
998
1739
|
...crossCuttingTestPoints.filter((point) => crossCuttingBindings.includes(point)),
|
|
999
1740
|
]);
|
|
1000
1741
|
const declaredPrimarySymbol = testCase.declaredPrimarySymbols[0];
|
|
1742
|
+
const evidenceOnly = isEvidenceOnlyMarkdownCase(testCase);
|
|
1001
1743
|
const cardinality = refs.length === 0 ? "1:0" : refs.length === 1 ? "1:1" : "1:N";
|
|
1002
|
-
let status = refs.length === 0
|
|
1744
|
+
let status = evidenceOnly && refs.length === 0
|
|
1745
|
+
? "EXACT_1_TO_1"
|
|
1746
|
+
: refs.length === 0
|
|
1747
|
+
? "MISSING_PYTEST"
|
|
1748
|
+
: refs.length > 1
|
|
1749
|
+
? "MULTIPLE_PYTEST"
|
|
1750
|
+
: "EXACT_1_TO_1";
|
|
1003
1751
|
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")
|
|
1752
|
+
const payloadAssessment = assessPayloadContract(testCase, refs);
|
|
1753
|
+
const signature = executionSignature(testCase);
|
|
1754
|
+
if (evidenceOnly) {
|
|
1755
|
+
if (refs.length > 0) {
|
|
1010
1756
|
status = "SCRIPT_MISMATCH";
|
|
1011
|
-
|
|
1757
|
+
entryFindings.push(`${testCase.caseId} evidence-only Markdown Case must not materialize a business pytest symbol; actualSymbols=${symbols.join(", ") || "none"}`);
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
else {
|
|
1761
|
+
if (refs.length === 0)
|
|
1762
|
+
entryFindings.push(`${testCase.caseId} has no pytest symbol`);
|
|
1763
|
+
if (refs.length > 1)
|
|
1764
|
+
entryFindings.push(`${testCase.caseId} maps to multiple pytest symbols: ${symbols.join(", ")}`);
|
|
1765
|
+
if (!declared || declared !== expected || actualScripts.some((script) => script !== expected) || !declaredPrimarySymbol || symbols.some((symbol) => symbol !== declaredPrimarySymbol)) {
|
|
1766
|
+
if (refs.length > 0 && status === "EXACT_1_TO_1")
|
|
1767
|
+
status = "SCRIPT_MISMATCH";
|
|
1768
|
+
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"}`);
|
|
1769
|
+
}
|
|
1012
1770
|
}
|
|
1013
|
-
const missingVariant =
|
|
1014
|
-
|
|
1015
|
-
|
|
1771
|
+
const missingVariant = evidenceOnly
|
|
1772
|
+
? []
|
|
1773
|
+
: variantTestPoints.filter((point) => !parameterIds.includes(point));
|
|
1774
|
+
const missingAssertion = evidenceOnly
|
|
1775
|
+
? []
|
|
1776
|
+
: assertionTestPoints.filter((point) => !assertionBindings.includes(point));
|
|
1777
|
+
const missingCrossCutting = evidenceOnly
|
|
1778
|
+
? []
|
|
1779
|
+
: crossCuttingTestPoints.filter((point) => !crossCuttingBindings.includes(point));
|
|
1016
1780
|
const extraPoints = orderedUnique([...parameterIds, ...assertionBindings, ...crossCuttingBindings]).filter((point) => !testCase.testPoints.includes(point));
|
|
1017
1781
|
if (testCase.unclassifiedTestPoints.length > 0 || testCase.duplicateBindingTestPoints.length > 0) {
|
|
1018
1782
|
if (refs.length > 0 && status === "EXACT_1_TO_1")
|
|
@@ -1039,6 +1803,16 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
|
|
|
1039
1803
|
status = "TEST_POINT_BINDING_EXTRA";
|
|
1040
1804
|
entryFindings.push(`${testCase.caseId} has extra pytest Test Point bindings: ${extraPoints.join(", ")}`);
|
|
1041
1805
|
}
|
|
1806
|
+
if (payloadAssessment.status === "UNAVAILABLE") {
|
|
1807
|
+
if (refs.length > 0 && status === "EXACT_1_TO_1")
|
|
1808
|
+
status = "PAYLOAD_CONTRACT_UNAVAILABLE";
|
|
1809
|
+
entryFindings.push(`${testCase.caseId} has no deterministic Payload Contract declaration`);
|
|
1810
|
+
}
|
|
1811
|
+
else if (payloadAssessment.status === "UNSAFE") {
|
|
1812
|
+
if (refs.length > 0 && status === "EXACT_1_TO_1")
|
|
1813
|
+
status = "PAYLOAD_SHAPE_MISMATCH";
|
|
1814
|
+
entryFindings.push(`${testCase.caseId} payload contract mismatch: missing=${payloadAssessment.missingPaths.join(", ") || "none"}; unexpected=${payloadAssessment.unexpectedPaths.join(", ") || "none"}; enum=${payloadAssessment.enumMismatches.join(", ") || "none"}`);
|
|
1815
|
+
}
|
|
1042
1816
|
findings.push(...entryFindings);
|
|
1043
1817
|
entries.push({
|
|
1044
1818
|
markdownModule: testCase.markdownPath,
|
|
@@ -1056,6 +1830,8 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
|
|
|
1056
1830
|
parameterIds,
|
|
1057
1831
|
cardinality,
|
|
1058
1832
|
status,
|
|
1833
|
+
payloadAssessment,
|
|
1834
|
+
executionSignature: signature,
|
|
1059
1835
|
findings: entryFindings,
|
|
1060
1836
|
});
|
|
1061
1837
|
}
|
|
@@ -1082,23 +1858,41 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
|
|
|
1082
1858
|
parameterIds: symbol.parameterIds,
|
|
1083
1859
|
cardinality: "0:1",
|
|
1084
1860
|
status: unknownId ? "EXTRA_PYTEST" : "SYMBOL_MISSING_CASE_ID",
|
|
1861
|
+
payloadAssessment: {
|
|
1862
|
+
status: "UNAVAILABLE",
|
|
1863
|
+
requiredPaths: [],
|
|
1864
|
+
allowedPaths: [],
|
|
1865
|
+
observedPaths: symbol.payloadPaths,
|
|
1866
|
+
missingPaths: [],
|
|
1867
|
+
unexpectedPaths: [],
|
|
1868
|
+
enumMismatches: [],
|
|
1869
|
+
},
|
|
1085
1870
|
findings: [message],
|
|
1086
1871
|
});
|
|
1087
1872
|
}
|
|
1873
|
+
const duplicateSignatures = new Map();
|
|
1874
|
+
for (const entry of entries.filter((item) => item.caseId && item.executionSignature)) {
|
|
1875
|
+
const owners = duplicateSignatures.get(entry.executionSignature) ?? [];
|
|
1876
|
+
owners.push(entry.caseId);
|
|
1877
|
+
duplicateSignatures.set(entry.executionSignature, owners);
|
|
1878
|
+
}
|
|
1879
|
+
const duplicateSignatureGroups = [...duplicateSignatures.entries()].filter(([, owners]) => orderedUnique(owners).length > 1);
|
|
1880
|
+
for (const [signature, owners] of duplicateSignatureGroups)
|
|
1881
|
+
findings.push(`duplicate execution signature ${signature}: ${orderedUnique(owners).join(", ")}`);
|
|
1088
1882
|
const exactModuleCount = modulePaths.filter((modulePath) => {
|
|
1089
|
-
const expected = expectedScript(modulePath);
|
|
1883
|
+
const expected = expectedScript(modulePath, layout);
|
|
1090
1884
|
const moduleCases = cases.filter((item) => item.markdownPath === modulePath);
|
|
1091
1885
|
return moduleCases.every((item) => item.declaredScripts.length === 1 && item.declaredScripts[0] === expected) && scriptFiles.some((file) => path.relative(input.workspaceRoot, file).replaceAll(path.sep, "/") === expected);
|
|
1092
1886
|
}).length;
|
|
1093
1887
|
const caseEntries = entries.filter((entry) => entry.cardinality !== "0:1");
|
|
1094
1888
|
const facts = backendTestMarkdownPytestCorrespondenceFactsSchema.parse({
|
|
1095
|
-
schemaId: "backend-test-markdown-pytest-correspondence-facts-
|
|
1096
|
-
schemaVersion:
|
|
1889
|
+
schemaId: "backend-test-markdown-pytest-correspondence-facts-v3",
|
|
1890
|
+
schemaVersion: 3,
|
|
1097
1891
|
taskId: input.taskId,
|
|
1098
1892
|
status: findings.length === 0 ? "PASS" : "FAIL",
|
|
1099
1893
|
inputFiles: await inputFileFacts(input.workspaceRoot, [
|
|
1100
|
-
path.join(input.workspaceRoot, "
|
|
1101
|
-
...(await markdownModuleFiles(input.workspaceRoot)),
|
|
1894
|
+
path.join(input.workspaceRoot, ...layout.readmePath.split("/")),
|
|
1895
|
+
...(await markdownModuleFiles(input.workspaceRoot, layout)),
|
|
1102
1896
|
...scriptFiles,
|
|
1103
1897
|
].filter((file) => file && path.isAbsolute(file))),
|
|
1104
1898
|
summary: {
|
|
@@ -1118,6 +1912,10 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
|
|
|
1118
1912
|
crossCuttingTestPointCount: cases.reduce((sum, item) => sum + item.testPointBindings.filter((binding) => binding.mode === "cross-cutting").length, 0),
|
|
1119
1913
|
unclassifiedTestPointCount: cases.reduce((sum, item) => sum + item.unclassifiedTestPoints.length, 0),
|
|
1120
1914
|
duplicateBindingTestPointCount: cases.reduce((sum, item) => sum + item.duplicateBindingTestPoints.length, 0),
|
|
1915
|
+
payloadSafeCount: caseEntries.filter((entry) => entry.payloadAssessment.status === "SAFE").length,
|
|
1916
|
+
payloadUnsafeCount: caseEntries.filter((entry) => entry.payloadAssessment.status === "UNSAFE").length,
|
|
1917
|
+
payloadUnavailableCount: caseEntries.filter((entry) => entry.payloadAssessment.status === "UNAVAILABLE").length,
|
|
1918
|
+
duplicateExecutionSignatureCount: duplicateSignatureGroups.length,
|
|
1121
1919
|
},
|
|
1122
1920
|
entries,
|
|
1123
1921
|
findings: orderedUnique(findings),
|
|
@@ -1145,12 +1943,16 @@ export async function analyzeBackendTestMarkdownPytestCorrespondence(input) {
|
|
|
1145
1943
|
`- Cross-Cutting Test Points: ${facts.summary.crossCuttingTestPointCount}`,
|
|
1146
1944
|
`- Unclassified Test Points: ${facts.summary.unclassifiedTestPointCount}`,
|
|
1147
1945
|
`- Duplicate Test Point Bindings: ${facts.summary.duplicateBindingTestPointCount}`,
|
|
1946
|
+
`- Payload SAFE: ${facts.summary.payloadSafeCount}`,
|
|
1947
|
+
`- Payload UNSAFE: ${facts.summary.payloadUnsafeCount}`,
|
|
1948
|
+
`- Payload UNAVAILABLE: ${facts.summary.payloadUnavailableCount}`,
|
|
1949
|
+
`- Duplicate Execution Signatures: ${facts.summary.duplicateExecutionSignatureCount}`,
|
|
1148
1950
|
"",
|
|
1149
1951
|
"## Correspondence Matrix",
|
|
1150
1952
|
"",
|
|
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} |`),
|
|
1953
|
+
"| 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 |",
|
|
1954
|
+
"|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|",
|
|
1955
|
+
...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
1956
|
"",
|
|
1155
1957
|
"## Findings",
|
|
1156
1958
|
"",
|