@ccoalm/ccl-skills 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +49 -0
- package/dist/assets/marketplace/.agents/plugins/marketplace.json +12 -0
- package/dist/assets/marketplace/.claude-plugin/marketplace.json +13 -0
- package/dist/assets/marketplace/marketplace-manifest.json +12 -0
- package/dist/assets/marketplace/plugins/ccl-skills/.claude-plugin/marketplace.json +16 -0
- package/dist/assets/marketplace/plugins/ccl-skills/.claude-plugin/plugin.json +5 -0
- package/dist/assets/marketplace/plugins/ccl-skills/.codex-plugin/plugin.json +5 -0
- package/dist/assets/marketplace/plugins/ccl-skills/.worktree-only +3 -0
- package/dist/assets/marketplace/plugins/ccl-skills/agent-context/session-start.md +45 -0
- package/dist/assets/marketplace/plugins/ccl-skills/agent-context/subagent-start.md +12 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/AGENTS.md +19 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/guard-delegation-owner.sh +125 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/guard-edit-isolation.sh +102 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/guard-merge-authorization.sh +1156 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/hooks.json +131 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/merge-authorization-prompt.sh +142 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/owner-dispatch-guard.sh +12 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/owner-dispatch-stop.sh +13 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/remind-post-merge-cleanup.sh +144 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/session-context.sh +87 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/session-start.sh +86 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/skill-extraction-gate-stop.sh +69 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/subagent-start.sh +26 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_guard_delegation_owner.sh +329 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_guard_edit_isolation.sh +322 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_guard_merge_authorization.sh +902 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_merge_authorization_prompt.sh +178 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_remind_post_merge_cleanup.sh +121 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_session_start.sh +170 -0
- package/dist/assets/marketplace/plugins/ccl-skills/packages/opencode-plugin/AGENTS.md +17 -0
- package/dist/assets/marketplace/plugins/ccl-skills/packages/opencode-plugin/ccl-skills.ts +564 -0
- package/dist/assets/marketplace/plugins/ccl-skills/packages/opencode-plugin/commands/ccl-install-skills.md +14 -0
- package/dist/assets/marketplace/plugins/ccl-skills/packages/opencode-plugin/commands/ccl-update-skills.md +44 -0
- package/dist/assets/marketplace/plugins/ccl-skills/packages/opencode-plugin/commands/ccl-verify-skills.md +109 -0
- package/dist/assets/marketplace/plugins/ccl-skills/packages/opencode-plugin/commands/ccl-worktree-check.md +36 -0
- package/dist/assets/marketplace/plugins/ccl-skills/scripts/owner-dispatch/AGENTS.md +28 -0
- package/dist/assets/marketplace/plugins/ccl-skills/scripts/owner-dispatch/README.md +276 -0
- package/dist/assets/marketplace/plugins/ccl-skills/scripts/owner-dispatch/owner-dispatch.example.json +10 -0
- package/dist/assets/marketplace/plugins/ccl-skills/scripts/owner-dispatch/owner-dispatch.sh +1307 -0
- package/dist/assets/marketplace/plugins/ccl-skills/scripts/owner-dispatch/test.sh +941 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/agents-file-coverage-gate/SKILL.md +45 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/agents-file-coverage-gate/agents/openai.yaml +4 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/app-cross-platform-dev/SKILL.md +188 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/app-cross-platform-dev/agents/openai.yaml +4 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/app-cross-platform-dev/references/android-dev.md +92 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/app-cross-platform-dev/references/flutter-dev.md +80 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/app-cross-platform-dev/references/ios-dev.md +72 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/app-cross-platform-dev/references/kotlin-multiplatform.md +93 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/app-cross-platform-dev/references/mobile-platform-boundaries.md +77 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/app-cross-platform-dev/references/mobile-quality-release.md +77 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/app-cross-platform-dev/references/source-evidence-map.md +64 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/SKILL.md +353 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/agents/openai.yaml +4 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/references/client-routing.md +419 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/references/manual-invocation-and-prompts.md +126 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/references/staged-review-contract.md +197 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/references/timeout-auth-and-capabilities.md +179 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/AGENTS.md +98 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/classify_envelope.py +93 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/classify_timeout_exit.sh +15 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/claude_review.sh +1438 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/codex_review.sh +324 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/concern_excerpt.py +295 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/egress_schema.py +214 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/init_policy_matrix.py +642 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/kimi_packet_mcp.py +181 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/kimi_review.sh +1165 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/opencode_review.sh +1190 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/parse_cli_review.py +946 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/parse_opencode_review.py +474 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/parse_probe_result.py +1899 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/parse_review_json.py +200 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/review_gate.py +2845 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/review_gate.sh +6 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/run_claude_capture.py +71 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/runtime-surface-verification-design.md +53 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_classify_envelope.sh +68 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_claude_review_probe.sh +2311 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_cli_review_wrappers.sh +1832 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_code_review_identity.sh +73 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_concern_excerpt.sh +245 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_egress_schema.sh +177 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_init_policy_matrix.sh +272 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_kimi_packet_mcp.py +195 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_opencode_review_concurrency.sh +120 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_opencode_review_retry.sh +1005 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_parse_opencode_review.sh +258 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_parse_probe_result.sh +574 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_parse_review_json.sh +349 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_review_client_compat.py +434 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_review_client_order.sh +264 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_review_gate.sh +2412 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/verify_native_skill_binding.py +123 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/defect-diagnosis/SKILL.md +153 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/defect-diagnosis/agents/openai.yaml +4 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/defect-diagnosis/references/diagnosis-playbook.md +54 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/defect-diagnosis/references/prevention-routing.md +36 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/feature-risk-router/SKILL.md +69 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/feature-risk-router/agents/openai.yaml +4 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/feature-risk-router/references/security-review-gate.md +41 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/SKILL.md +165 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/agents/openai.yaml +4 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/api-security-boundaries.md +47 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/architecture-playbook.md +160 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/artifact-generation-architecture.md +37 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/audit-history-architecture.md +29 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/bulk-workflow-architecture.md +33 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/config-rule-routing-architecture.md +34 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/cross-cutting-concerns.md +72 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/data-modeling-and-migrations.md +79 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/data-platform-architecture.md +210 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/dependency-platform.md +105 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/developer-tooling-architecture.md +38 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/error-contract-architecture.md +36 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/event-driven-architecture.md +260 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/http-gateway-architecture.md +74 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/mq-consumer-architecture.md +38 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/multi-tenant-isolation.md +275 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/notification-architecture.md +25 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/ops-checklist.md +57 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/performance-capacity-architecture.md +38 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/protobuf-contract-architecture.md +119 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/redis-cache-coordination.md +93 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/release-runtime-readiness.md +65 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/replay-comparison-architecture.md +26 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/runtime-observability.md +94 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/service-scaffold.md +76 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/source-evidence-map.md +55 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/workflow-state-architecture.md +38 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-dev/SKILL.md +159 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-dev/agents/openai.yaml +4 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-dev/references/artifact-generation-patterns.md +37 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-dev/references/audit-history-patterns.md +28 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-dev/references/bulk-import-export-patterns.md +56 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-dev/references/config-rule-routing-patterns.md +38 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-dev/references/data-access-patterns.md +55 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-dev/references/db-schema-and-dal-patterns.md +109 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-dev/references/dependency-client-patterns.md +130 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-dev/references/developer-tooling-patterns.md +70 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-dev/references/domain-feature-patterns.md +78 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-dev/references/engineering-patterns.md +119 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-dev/references/error-contract-patterns.md +55 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-dev/references/feature-playbook.md +61 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-dev/references/http-gateway-client-patterns.md +76 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-dev/references/mq-consumer-patterns.md +55 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-dev/references/notification-patterns.md +42 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-dev/references/observability-implementation-patterns.md +101 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-dev/references/performance-capacity-patterns.md +44 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-dev/references/protobuf-contract-patterns.md +72 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-dev/references/public-api-integration-patterns.md +56 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-dev/references/quality-and-testing-patterns.md +91 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-dev/references/redis-cache-lock-patterns.md +123 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-dev/references/release-ops-patterns.md +112 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-dev/references/reliability-patterns.md +83 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-dev/references/replay-comparison-patterns.md +32 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-dev/references/scaffold-and-codegen.md +86 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-dev/references/source-evidence-map.md +54 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-dev/references/state-machine-task-patterns.md +45 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/grill-me/SKILL.md +80 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/grill-me/agents/openai.yaml +4 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/SKILL.md +117 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/agents/openai.yaml +4 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/references/agent-approval-auto-reviewer.md +106 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/references/agent-command-sandbox.md +441 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/references/agent-context-freshness.md +47 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/references/agent-credentials-auth.md +13 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/references/agent-extensions-skills.md +13 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/references/agent-file-edit-protocol.md +129 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/references/agent-ide-integration.md +5 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/references/agent-input-ingestion.md +13 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/references/agent-instruction-composition.md +13 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/references/agent-lifecycle-hooks.md +92 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/references/agent-messaging.md +5 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/references/agent-runtime-bootstrap.md +5 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/references/agent-session-persistence.md +448 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/references/agent-task-orchestration.md +13 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/references/agent-tool-dispatch.md +123 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/references/agent-turn-lifecycle.md +131 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/references/inference-capacity-operations.md +162 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/references/llm-client-gateway.md +156 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/references/model-prompt-evaluation.md +146 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/references/retrieval-agent-safety.md +273 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/miniapp-product-dev/SKILL.md +202 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/miniapp-product-dev/agents/openai.yaml +4 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/miniapp-product-dev/references/contracts-and-state.md +62 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/miniapp-product-dev/references/cross-stack-alignment.md +94 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/miniapp-product-dev/references/framework-choice.md +76 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/miniapp-product-dev/references/online-practice-uptake.md +56 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/miniapp-product-dev/references/platform-capabilities.md +91 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/miniapp-product-dev/references/product-page-checklist.md +40 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/miniapp-product-dev/references/qa-release.md +72 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/miniapp-product-dev/references/source-evidence-map.md +82 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/multi-agent-delegation/SKILL.md +103 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/multi-agent-delegation/agents/openai.yaml +5 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/multi-agent-delegation/references/multi-agent-delegation-playbook.md +100 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/multi-perspective-research/SKILL.md +70 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/multi-perspective-research/agents/openai.yaml +4 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/multi-perspective-research/references/public-data-acquisition.md +549 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/multi-perspective-research/references/public-disclosure-channels.md +97 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/multi-perspective-research/references/research-prompts.md +66 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/multi-perspective-research/scripts/AGENTS.md +32 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/multi-perspective-research/scripts/test-public-data-acquisition-recipes.sh +379 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-observability/SKILL.md +244 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-observability/agents/openai.yaml +4 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-observability/references/alerting-and-on-call.md +76 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-observability/references/framework-middleware-checklist.md +142 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-observability/references/infra-component-deployment.md +268 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-observability/references/log-correlation-recipe.md +124 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-observability/references/log-schema-canonical.md +208 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-observability/references/metrics-conventions.md +105 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-observability/references/obs-stack-architecture.md +107 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-observability/references/sli-slo-design.md +95 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-observability/references/source-register.md +11 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-release-engineering/SKILL.md +303 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-release-engineering/agents/openai.yaml +4 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-release-engineering/references/canary-and-rollout-strategy.md +163 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-release-engineering/references/config-center-via-etcd.md +245 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-release-engineering/references/custom-control-plane-boundary.md +298 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-release-engineering/references/deploy-cli-concrete-recipe.md +312 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-release-engineering/references/deploy-pipeline.md +165 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-release-engineering/references/env-and-lane-matrix.md +126 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-release-engineering/references/lane-orchestration-control-plane.md +383 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-release-engineering/references/multi-region-and-cluster.md +135 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-release-engineering/references/promotion-gate-and-review.md +149 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-release-engineering/references/python-package-registry-release.md +462 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-release-engineering/references/rollback-playbook.md +123 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-release-engineering/references/secret-and-config-management.md +231 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-release-engineering/references/version-authority-and-deprecation.md +21 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-service-connectivity/SKILL.md +276 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-service-connectivity/agents/openai.yaml +4 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-service-connectivity/references/dual-sidecar-and-traffic-config-center.md +127 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-service-connectivity/references/framework-middleware.md +143 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-service-connectivity/references/grpc-authority-workaround.md +90 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-service-connectivity/references/http-response-envelope-contract.md +24 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-service-connectivity/references/mesh-architecture.md +127 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-service-connectivity/references/multi-env-routing.md +192 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-service-connectivity/references/protobuf-http-contract-signals.md +64 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-service-connectivity/references/retry-timeout-circuit-breaker.md +124 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-service-connectivity/references/rpc-framework-recipe.md +494 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-service-connectivity/references/service-discovery-choice.md +113 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-service-connectivity/references/service-discovery-migration-playbook.md +231 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-service-connectivity/references/service-discovery-recipe.md +131 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/SKILL.md +235 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/agents/openai.yaml +4 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/adr-convention.md +146 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/algorithm-launch-checklist.md +30 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/algorithm-launch-evaluation-report-template.md +25 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/algorithm-launch-execution-spec.md +108 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/algorithm-launch-sop.md +457 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/algorithm-launch-templates.md +24 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/artifact-egress-confidentiality.md +58 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/code-review-checklist.md +86 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/cross-repo-coordination.md +46 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/delivery-lifecycle.md +192 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/design-review-gate-mechanics.md +62 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/design-routing-and-readiness.md +45 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/diagnostic-spec-match-gate.md +36 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/dispatch-owner-skills.md +35 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/dormant-code-activation.md +47 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/existing-project-assessment-report.md +223 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/external-skill-augmentation.md +46 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/feature-deprecation-cascade.md +15 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/high-risk-resilience-gates.md +73 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/implementation-completeness-and-minimality.md +120 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/implementation-entry-reentry-gate.md +122 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/modular-monolith-heuristic.md +105 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/pre-final-continuation-gate.md +115 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/problem-resolution-and-learning.md +62 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/quality-attributes.md +112 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/quality-remediation-program.md +88 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/rd-standards-doc-family-checklist.md +27 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/refactoring-discipline.md +52 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/review-reception.md +34 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/shared-gate-artifact-classification.md +76 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/source-evidence-map.md +31 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/status-tracker-sync.md +77 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/sync-spec-repo-contract.md +25 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/verify-developer-experience.md +34 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/worktree-mechanics.md +55 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/scripts/AGENTS.md +18 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/scripts/check-agent-contract-coverage.sh +213 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/SKILL.md +136 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/agents/openai.yaml +9 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/analytics-visualization-interactions.md +206 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/behavioral-aesthetic-logic.md +108 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/complex-creation-interactions.md +194 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/design-execution-checklist.md +214 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/design-impl-naming-and-versioning.md +53 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/design-intake-and-acceptance.md +129 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/design-system-source-of-truth.md +97 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/external-ui-ux-quality-benchmarks.md +79 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/frontend-code-evidence-map.md +63 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/interaction-design-patterns.md +146 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/layout-recipes-and-screenshot-acceptance.md +250 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/multi-project-token-consistency.md +237 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/multi-stack-strategy.md +65 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/operational-processing-workflows.md +237 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/platform-mobile-patterns.md +324 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/platform-web-desktop-patterns.md +456 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/product-lifecycle-acceptance-and-iteration.md +114 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/product-surface-patterns.md +79 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/resource-management-interactions.md +113 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/scenario-community-patterns.md +133 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/source-map.md +130 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/tokens-and-components.md +47 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/trust-sensitive-ai-and-data-patterns.md +96 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/ui-ux-audit.md +106 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/ui-ux-design-development.md +176 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/visual-craft.md +111 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/SKILL.md +157 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/agents/openai.yaml +4 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/ai-service-integration-boundaries.md +57 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/api-contract-and-schema.md +62 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/api-security-boundaries.md +39 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/architecture-playbook.md +46 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/async-execution-model.md +24 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/background-jobs-and-scheduling.md +18 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/batch-and-pipeline-architecture.md +11 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/config-secrets-runtime.md +22 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/data-modeling-and-migrations.md +64 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/data-platform-architecture.md +211 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/event-driven-architecture.md +263 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/multi-tenant-isolation.md +281 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/observability-and-ops.md +26 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/packaging-runtime-readiness.md +20 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/redis-cache-coordination.md +41 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/reliability-and-error-contract.md +17 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/source-evidence-map.md +55 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/web-framework-boundaries.md +26 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/SKILL.md +143 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/agents/openai.yaml +4 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/ai-service-wiring-patterns.md +16 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/async-and-worker-patterns.md +24 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/background-job-patterns.md +18 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/batch-and-artifact-patterns.md +13 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/dependency-client-patterns.md +39 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/error-handling-patterns.md +26 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/feature-playbook.md +43 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/observability-implementation-patterns.md +31 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/project-structure-and-tooling.md +24 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/public-api-security-patterns.md +52 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/redis-cache-lock-patterns.md +78 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/schema-and-validation-patterns.md +23 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/source-evidence-map.md +56 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/sqlalchemy-and-migrations-patterns.md +99 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/testing-and-quality-patterns.md +61 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/web-framework-patterns.md +35 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/release-coordination/SKILL.md +91 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/release-coordination/agents/openai.yaml +4 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/release-coordination/references/config-runtime-readback.md +20 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/release-coordination/references/mr-merge-authorization.md +31 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/release-coordination/references/post-release-env-reset.md +31 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/release-coordination/references/release-closeout-evidence.md +20 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/release-coordination/references/release-scope-confirmation.md +21 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/release-coordination/references/tag-and-prod-pipeline-gate.md +20 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/release-coordination/references/test-scope-prompt.md +24 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/release-coordination/references/watcher-discipline.md +14 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/release-doc-writer/SKILL.md +64 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/release-doc-writer/agents/openai.yaml +4 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/release-doc-writer/references/comment-safe-release-doc.md +19 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/release-doc-writer/references/release-evidence-workflow.md +23 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/release-doc-writer/references/release-testing-scope-section.md +15 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/requirement-baseline/SKILL.md +87 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/requirement-baseline/agents/openai.yaml +4 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/requirement-doc-writer/SKILL.md +130 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/requirement-doc-writer/agents/openai.yaml +4 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/requirement-doc-writer/references/prd-composition-contract.md +35 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/requirement-doc-writer/references/requirement-closure-contract.md +86 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/requirement-doc-writer/references/security-four-questions.md +38 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/requirement-intent/SKILL.md +91 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/requirement-intent/agents/openai.yaml +4 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/requirement-scope/SKILL.md +88 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/requirement-scope/agents/openai.yaml +4 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/SKILL.md +337 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/agents/openai.yaml +4 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/analysis-parse-fix-test-challenge-replay.md +47 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/attribution-verification.md +69 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/bootstrap-slim-c3-obligation-table.md +112 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/coverage-exhaustion-traps.md +45 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/description-authoring.md +162 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/dual-track-review-gate.md +507 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/eval-routing.md +86 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/evidence-card-template.md +51 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/example-domain-preselect.md +79 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/external-practice-controls.md +57 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/extraction-lifecycle-handoff.md +65 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/extraction-quickstart.md +194 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/firing-point-placement.md +75 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/harness-patterns-and-eval.md +286 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/incident-postmortem-extraction.md +190 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/l0-l1-l2-routing.md +114 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/online-skill-review.md +47 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/parallel-stack-references-pattern.md +164 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/r0-leakage-audit.md +90 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/recurring-anti-patterns-checklist.md +320 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/resume-paused-delivery.md +16 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/review-feedback-mining.md +33 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/review-finding-standards.md +57 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/review-rubric.md +40 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/rule-consolidation.md +118 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/skill-listing-budget.md +19 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-register.md +254 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-to-skill-extraction.md +658 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/two-source-extraction-pattern.md +167 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/uiux-judgment-extraction.md +179 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/uiux-routing-map.md +51 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/validation-and-landing.md +180 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/AGENTS.md +18 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/check-ccl-skills.sh +1452 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/check-evidence-card-leak.sh +491 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/check-mr-target-freshness.sh +173 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/check-size-budget.sh +488 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/check-sync-pointers.sh +419 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/eval-golden-trace.rb +197 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/eval-health.rb +327 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/eval-routing-bank.rb +401 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/eval-routing.rb +248 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/generic-r0-leak-scan.sh +282 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/governing-chain-diff.py +321 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/impact-chain-gate.rb +964 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/register-firing-path-resolution.rb +708 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/skill-behavior-eval.py +540 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/source-register-lifecycle.rb +51 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/source-register-pending-status.rb +55 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_ai_coding_implementation_gates.sh +829 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_impact_chain_refscripts.sh +1203 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_r0_status.sh +75 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_register_pending_exclusion.sh +137 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_regressions.sh +173 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_route_drift.sh +377 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_size_budget.sh +833 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_skill_catalog.sh +491 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_source_register_lifecycle.sh +114 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_mr_target_freshness.sh +261 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_sync_pointers.sh +538 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_controlled_escalation_pins.sh +154 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_eval_routing_bank_grader_diagnostics.sh +190 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_eval_routing_bank_surface_binding.sh +178 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_eval_routing_prose_target.sh +86 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_generic_r0_leak_scan.sh +131 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_git_identity_predicate_gate.sh +243 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_governing_chain_diff.sh +419 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_impact_chain_gate_dateless_host.sh +120 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_register_firing_path_resolution.sh +724 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_register_firing_path_wiring.sh +414 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_regression_runner_registration.sh +34 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_routing_bank_integrity.sh +205 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_routing_pointer_integrity.sh +194 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_validate_skill_credential_cwd.sh +61 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_validate_skill_cross_refs.sh +111 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_validate_skill_root_depth.sh +53 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/validate-skill.sh +257 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/terminal-cli-dev/SKILL.md +98 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/terminal-cli-dev/agents/openai.yaml +4 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/terminal-cli-dev/references/input-state-machines.md +36 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/terminal-cli-dev/references/streaming-rich-output.md +130 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/terminal-cli-dev/references/terminal-side-channels.md +96 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/test-artifact-management/SKILL.md +408 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/test-artifact-management/agents/openai.yaml +4 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/test-artifact-management/references/AGENTS.md +18 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/test-artifact-management/references/bitable-setup.md +573 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/test-artifact-management/references/ci_templates/README.md +120 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/test-artifact-management/references/ci_templates/github-actions.yml +119 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/test-artifact-management/references/ci_templates/gitlab-ci.yml +76 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/test-artifact-management/references/ci_templates/jenkins.Jenkinsfile +106 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/test-artifact-management/references/classical-test-design-techniques.md +279 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/test-artifact-management/references/gen_report.py +2807 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/test-artifact-management/references/makefile-template.md +200 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/test-artifact-management/references/report-config-schema.md +272 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/test-artifact-management/references/run_pytestless.py +475 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/test-artifact-management/references/source-to-case-workflows.md +258 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/test-artifact-management/references/tc-marker-conventions.md +316 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/test-artifact-management/references/tc-review-and-prioritization.md +145 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/test-artifact-management/references/tc_helpers/AGENTS.md +16 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/test-artifact-management/references/tc_helpers/tc.dart +129 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/test-artifact-management/references/tc_helpers/tc.go +197 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/test-artifact-management/references/tc_helpers/tc.py +135 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/test-artifact-management/references/tc_helpers/tc.ts +285 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/test-artifact-management/references/test_gen_report.py +2144 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/test-artifact-management/references/update-lifecycle.md +62 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/SKILL.md +212 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/agents/openai.yaml +4 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/ci-fixtures-and-flake-control.md +75 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/client-runtime-test-matrices.md +50 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/data-and-workflow-testing.md +34 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/design-closed-contract-oracles.md +31 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/e2e-real-flow-testing.md +71 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/fitness-functions.md +240 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/integration-contract-testing.md +235 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/non-functional-specialized-scenarios.md +296 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/rd-testing-standard-template.md +126 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/run-killing-mutation-walk.md +43 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/scenario-testing.md +136 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/source-evidence-map.md +59 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/structured-tc-input-translation.md +67 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/test-code-authoring-patterns.md +392 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/test-data-and-determinism.md +39 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/test-topology-and-commands.md +92 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/unit-testing.md +46 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/vendored-contract-drift-checklist.md +64 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/verify-enforcement-mechanisms.md +18 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/scripts/AGENTS.md +17 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/scripts/client-terminal-ansi-check.py +140 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/scripts/client-terminal-ansi-check.test.sh +75 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/scripts/lang-basics-ast-check.py +170 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/scripts/lang-basics-ast-check.test.sh +87 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/scripts/lang-basics-go-check.go +198 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/scripts/lang-basics-go-check.test.sh +109 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/scripts/test_mutation_backup_recipe.sh +237 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/SKILL.md +184 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/agents/openai.yaml +4 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/references/comment-safe-feishu.md +93 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/references/cross-model-co-review.md +3 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/references/delivery-face-closeout.md +60 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/references/doc-charter-first.md +17 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/references/session-vantage-leakage.md +58 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/web-react-dev/SKILL.md +126 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/web-react-dev/agents/openai.yaml +4 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/web-react-dev/references/complex-workspace-patterns.md +47 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/web-react-dev/references/embedded-h5-in-host.md +87 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/web-react-dev/references/react-architecture.md +194 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/web-react-dev/references/source-evidence-map.md +60 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/web-react-dev/references/web-quality-release.md +190 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/web-react-dev/references/web-ui-quality.md +83 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/worktree-isolation/SKILL.md +179 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/worktree-isolation/agents/openai.yaml +4 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/worktree-isolation/references/shared-branch-rebase.md +25 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/worktree-isolation/scripts/AGENTS.md +23 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/worktree-isolation/scripts/test_worktree_status.sh +207 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/worktree-isolation/scripts/test_worktree_sweep.sh +481 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/worktree-isolation/scripts/worktree-status.sh +325 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/worktree-isolation/scripts/worktree-sweep.sh +245 -0
- package/dist/assets/release.json +2797 -0
- package/dist/claude-adapter.d.ts +9 -0
- package/dist/claude-adapter.js +240 -0
- package/dist/cli-worker.d.ts +1 -0
- package/dist/cli-worker.js +32 -0
- package/dist/cli.d.ts +22 -0
- package/dist/cli.js +214 -0
- package/dist/codex-host.d.ts +30 -0
- package/dist/codex-host.js +162 -0
- package/dist/fs-safe.d.ts +21 -0
- package/dist/fs-safe.js +241 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/manifest.d.ts +8 -0
- package/dist/manifest.js +135 -0
- package/dist/opencode-adapter.d.ts +10 -0
- package/dist/opencode-adapter.js +416 -0
- package/dist/operations.d.ts +3 -0
- package/dist/operations.js +956 -0
- package/dist/paths.d.ts +20 -0
- package/dist/paths.js +4 -0
- package/dist/types.d.ts +58 -0
- package/dist/types.js +1 -0
- package/dist/unified.d.ts +4 -0
- package/dist/unified.js +64 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +5 -0
- package/package.json +35 -0
|
@@ -0,0 +1,2807 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
gen_report.py — run tests, sync results to Bitable, generate Feishu report.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
# First-time setup: parse base_token + table_id from Bitable URL, write config
|
|
7
|
+
python gen_report.py --config .report-config.json \
|
|
8
|
+
--init --bitable-url "https://xxx.feishu.cn/base/BASxxx?table=tblxxx"
|
|
9
|
+
|
|
10
|
+
# Run tests + update Bitable + generate report — local, zero extra args:
|
|
11
|
+
python gen_report.py --run-tests
|
|
12
|
+
|
|
13
|
+
# CI — only --as bot needed (source/author/version auto-detected):
|
|
14
|
+
python gen_report.py --run-tests --as bot
|
|
15
|
+
|
|
16
|
+
# Skip running tests; read existing result files → Bitable → report:
|
|
17
|
+
python gen_report.py
|
|
18
|
+
|
|
19
|
+
# Preview report Markdown without writing to Feishu:
|
|
20
|
+
python gen_report.py --dry-run
|
|
21
|
+
|
|
22
|
+
# Override any default explicitly:
|
|
23
|
+
python gen_report.py --run-tests --author "张三" --version "v1.2.3"
|
|
24
|
+
|
|
25
|
+
Config file (.report-config.json, committed to repo; no secrets):
|
|
26
|
+
{
|
|
27
|
+
"base_token": "", ← auto-populated by --init
|
|
28
|
+
"table_id": "", ← auto-populated by --init
|
|
29
|
+
"folder_token": "", ← optional: Feishu folder for first doc creation
|
|
30
|
+
"report_doc_url": "", ← auto-populated after first report run; commit this
|
|
31
|
+
"test_suites": [
|
|
32
|
+
{
|
|
33
|
+
"name": "API tests",
|
|
34
|
+
"command": "pytest tests/api/ --junit-xml=results/api.xml -q",
|
|
35
|
+
"results_file": "results/api.xml"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"name": "Go service",
|
|
39
|
+
"command": "go test ./... -v 2>&1 | go-junit-report -set-exit-code > results/go.xml",
|
|
40
|
+
"results_file": "results/go.xml"
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
TC ID linking: tests register their TC IDs via a small per-stack helper that appends
|
|
46
|
+
to test/results/tc-map.jsonl (one JSON line per call):
|
|
47
|
+
Python: from tc import tc; def test_login(): tc("TC-SY-001"); ...
|
|
48
|
+
Go: import "yourrepo/testkit/tc"; func TestLogin(t *testing.T) { tc.Mark(t, "TC-SY-001"); ... }
|
|
49
|
+
JS/TS: import { tc } from './tc'; test('login', () => { tc('TC-SY-001'); ... })
|
|
50
|
+
Dart: tc(['TC-SY-001'], 'login', () { ... })
|
|
51
|
+
gen_report.py joins JUnit <testcase> name → sidecar entry → TC IDs.
|
|
52
|
+
See references/tc-marker-conventions.md.
|
|
53
|
+
|
|
54
|
+
Tests not registered in the sidecar are excluded from Bitable sync — by design.
|
|
55
|
+
Unit/boundary/internal tests don't need TC IDs; only TC-linked tests participate
|
|
56
|
+
in status aggregation and the Feishu report.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
from __future__ import annotations
|
|
60
|
+
|
|
61
|
+
import argparse
|
|
62
|
+
import json
|
|
63
|
+
import os
|
|
64
|
+
import re
|
|
65
|
+
import subprocess
|
|
66
|
+
import sys
|
|
67
|
+
import xml.etree.ElementTree as ET
|
|
68
|
+
from collections import defaultdict
|
|
69
|
+
from datetime import datetime
|
|
70
|
+
from pathlib import Path
|
|
71
|
+
from urllib.parse import urlparse, parse_qs
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
DATE_TODAY = datetime.now().strftime("%Y-%m-%d")
|
|
75
|
+
|
|
76
|
+
_CI_ENV_VARS = ("CI", "GITHUB_ACTIONS", "GITLAB_CI", "CIRCLECI", "JENKINS_URL",
|
|
77
|
+
"BUILDKITE", "TF_BUILD", "CODEBUILD_BUILD_ID")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _default_config() -> str:
|
|
81
|
+
# Prefer test/.report-config.json (template convention); fall back to root
|
|
82
|
+
if Path("test/.report-config.json").exists():
|
|
83
|
+
return "test/.report-config.json"
|
|
84
|
+
return ".report-config.json"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _default_source() -> str:
|
|
88
|
+
return "CI" if any(os.environ.get(v) for v in _CI_ENV_VARS) else "本地"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _default_author() -> str:
|
|
92
|
+
result = subprocess.run(
|
|
93
|
+
["git", "config", "user.name"], capture_output=True, text=True
|
|
94
|
+
)
|
|
95
|
+
name = result.stdout.strip()
|
|
96
|
+
return name if name else os.environ.get("USER", "CI")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _default_version() -> str:
|
|
100
|
+
result = subprocess.run(
|
|
101
|
+
["git", "describe", "--tags", "--always", "--dirty"],
|
|
102
|
+
capture_output=True, text=True
|
|
103
|
+
)
|
|
104
|
+
return result.stdout.strip() if result.returncode == 0 else ""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _default_identity() -> str:
|
|
108
|
+
return "bot" if any(os.environ.get(v) for v in _CI_ENV_VARS) else "user"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ── Test runner ────────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
def run_suite(suite: dict) -> bool:
|
|
114
|
+
"""Run one test suite command. Returns True if exit code == 0."""
|
|
115
|
+
name = suite.get("name", suite["command"])
|
|
116
|
+
print(f" Running: {name}", file=sys.stderr)
|
|
117
|
+
result = subprocess.run(suite["command"], shell=True)
|
|
118
|
+
if result.returncode != 0:
|
|
119
|
+
print(f" ⚠ Suite '{name}' exited {result.returncode} (failures recorded in results file)",
|
|
120
|
+
file=sys.stderr)
|
|
121
|
+
return result.returncode == 0
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ── TC ID extraction (sidecar JSONL) ──────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
def load_sidecar(sidecar_path: Path) -> dict[str, list[str]]:
|
|
127
|
+
"""
|
|
128
|
+
Load test-name → tc_ids mapping from a JSONL sidecar.
|
|
129
|
+
Each line: {"test": "<name>", "tc_ids": ["TC-XX-NNN", ...]}.
|
|
130
|
+
Multiple entries for the same test name are MERGED (union of TC IDs, first-seen
|
|
131
|
+
order preserved). This supports combining marker-form + in-body-form registration
|
|
132
|
+
on the same test without losing IDs from either path.
|
|
133
|
+
|
|
134
|
+
Missing/unreadable file → empty mapping (not an error).
|
|
135
|
+
"""
|
|
136
|
+
mapping: dict[str, list[str]] = {}
|
|
137
|
+
if not sidecar_path.exists():
|
|
138
|
+
return mapping
|
|
139
|
+
try:
|
|
140
|
+
for raw in sidecar_path.read_text(encoding="utf-8").splitlines():
|
|
141
|
+
line = raw.strip()
|
|
142
|
+
if not line:
|
|
143
|
+
continue
|
|
144
|
+
try:
|
|
145
|
+
entry = json.loads(line)
|
|
146
|
+
except json.JSONDecodeError:
|
|
147
|
+
continue
|
|
148
|
+
test = entry.get("test", "")
|
|
149
|
+
ids = entry.get("tc_ids", [])
|
|
150
|
+
if not test or not ids:
|
|
151
|
+
continue
|
|
152
|
+
normalised = [tc_id.replace("_", "-") for tc_id in ids]
|
|
153
|
+
if test not in mapping:
|
|
154
|
+
mapping[test] = list(normalised)
|
|
155
|
+
continue
|
|
156
|
+
existing = mapping[test]
|
|
157
|
+
seen = set(existing)
|
|
158
|
+
for tc_id in normalised:
|
|
159
|
+
if tc_id not in seen:
|
|
160
|
+
existing.append(tc_id)
|
|
161
|
+
seen.add(tc_id)
|
|
162
|
+
except OSError:
|
|
163
|
+
pass
|
|
164
|
+
return mapping
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _candidate_keys(classname: str, name: str) -> list[str]:
|
|
168
|
+
"""Build possible sidecar lookup keys from a JUnit testcase element."""
|
|
169
|
+
keys = [name]
|
|
170
|
+
if classname:
|
|
171
|
+
keys.extend([
|
|
172
|
+
f"{classname}::{name}",
|
|
173
|
+
f"{classname}.{name}",
|
|
174
|
+
# pytest classname looks like "tests.test_auth"; nodeid uses "tests/test_auth.py"
|
|
175
|
+
f"{classname.replace('.', '/')}.py::{name}",
|
|
176
|
+
f"{classname.replace('.', '/')}::{name}",
|
|
177
|
+
])
|
|
178
|
+
return keys
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _extract_tc_ids(tc_el: ET.Element, sidecar: dict[str, list[str]]) -> list[str]:
|
|
182
|
+
"""
|
|
183
|
+
Return TC IDs for a JUnit testcase, looked up in the sidecar.
|
|
184
|
+
Tries common name forms (name, classname::name, classname.name, pytest nodeid).
|
|
185
|
+
Tests not in the sidecar are silently excluded — by design.
|
|
186
|
+
"""
|
|
187
|
+
classname = tc_el.get("classname", "")
|
|
188
|
+
name = tc_el.get("name", "")
|
|
189
|
+
for key in _candidate_keys(classname, name):
|
|
190
|
+
if key in sidecar:
|
|
191
|
+
return sidecar[key]
|
|
192
|
+
return []
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _junit_status(tc_el: ET.Element) -> str:
|
|
196
|
+
"""Map JUnit testcase element to Bitable status string."""
|
|
197
|
+
skipped = tc_el.find("skipped")
|
|
198
|
+
if skipped is not None:
|
|
199
|
+
# Pytest @skip with environment reason → 阻塞 (env not ready, restorable).
|
|
200
|
+
# Otherwise (e.g. @skip("deprecated"), manual exclude) → 跳过.
|
|
201
|
+
msg = (skipped.get("message", "") + " " + (skipped.text or "")).lower()
|
|
202
|
+
env_markers = ("environment", "platform", "windows", "linux", "darwin",
|
|
203
|
+
"ci only", "requires ", "needs ", "no driver", "no device",
|
|
204
|
+
"service unavailable", "credential", "fixture not ready")
|
|
205
|
+
if any(m in msg for m in env_markers):
|
|
206
|
+
return "阻塞"
|
|
207
|
+
return "跳过"
|
|
208
|
+
if tc_el.find("failure") is not None or tc_el.find("error") is not None:
|
|
209
|
+
return "失败"
|
|
210
|
+
return "通过"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def parse_junit(results_file: str,
|
|
214
|
+
sidecar: dict[str, list[str]] | None = None) -> dict:
|
|
215
|
+
"""
|
|
216
|
+
Parse a JUnit XML file.
|
|
217
|
+
Returns:
|
|
218
|
+
{
|
|
219
|
+
"linked": {tc_id: status, ...}, # tests registered in sidecar
|
|
220
|
+
"untracked": [{"name", "classname", "status"}, ...] # tests with no sidecar entry
|
|
221
|
+
}
|
|
222
|
+
A TC appearing in multiple linked tests merges pessimistically:
|
|
223
|
+
失败 > 阻塞 > 跳过 > 通过.
|
|
224
|
+
"""
|
|
225
|
+
p = Path(results_file)
|
|
226
|
+
if not p.exists():
|
|
227
|
+
print(f" ⚠ Results file not found: {results_file} — skipping", file=sys.stderr)
|
|
228
|
+
return {"linked": {}, "untracked": []}
|
|
229
|
+
|
|
230
|
+
tree = ET.parse(p)
|
|
231
|
+
root = tree.getroot()
|
|
232
|
+
testcases = root.findall(".//testcase")
|
|
233
|
+
|
|
234
|
+
sidecar = sidecar or {}
|
|
235
|
+
tc_results: dict[str, list[str]] = defaultdict(list)
|
|
236
|
+
untracked: list[dict] = []
|
|
237
|
+
for tc_el in testcases:
|
|
238
|
+
status = _junit_status(tc_el)
|
|
239
|
+
ids = _extract_tc_ids(tc_el, sidecar)
|
|
240
|
+
if ids:
|
|
241
|
+
for tc_id in ids:
|
|
242
|
+
tc_results[tc_id].append(status)
|
|
243
|
+
else:
|
|
244
|
+
untracked.append({
|
|
245
|
+
"name": tc_el.get("name", ""),
|
|
246
|
+
"classname": tc_el.get("classname", ""),
|
|
247
|
+
"status": status,
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
_rank = {"失败": 4, "阻塞": 3, "跳过": 2, "通过": 1}
|
|
251
|
+
linked: dict[str, str] = {}
|
|
252
|
+
for tc_id, statuses in tc_results.items():
|
|
253
|
+
linked[tc_id] = max(statuses, key=lambda s: _rank.get(s, 0))
|
|
254
|
+
return {"linked": linked, "untracked": untracked}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _sidecar_path() -> Path:
|
|
258
|
+
"""Default sidecar location; override via TC_SIDECAR env."""
|
|
259
|
+
return Path(os.environ.get("TC_SIDECAR", "test/results/tc-map.jsonl"))
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _last_run_path() -> Path:
|
|
263
|
+
"""Where the previous run's results snapshot lives (for vs-last-run diff)."""
|
|
264
|
+
return Path(os.environ.get("TC_LAST_RUN", "test/results/last-run.json"))
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# ── vs-last-run diff (regression delta) ───────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
def load_last_run(path: Path) -> dict:
|
|
270
|
+
"""Load previous run's snapshot. Returns {} on first run or read error.
|
|
271
|
+
|
|
272
|
+
Reads `schema_version` if present; treats older bare-key snapshots as
|
|
273
|
+
cold-start (returns {}). This avoids a false `no_longer_seen` storm
|
|
274
|
+
after the snapshot key format changed (e.g. minimal-mode namespacing
|
|
275
|
+
introduced in v2).
|
|
276
|
+
"""
|
|
277
|
+
if not path.exists():
|
|
278
|
+
return {}
|
|
279
|
+
try:
|
|
280
|
+
snap = json.loads(path.read_text(encoding="utf-8"))
|
|
281
|
+
except (json.JSONDecodeError, OSError):
|
|
282
|
+
return {}
|
|
283
|
+
if snap.get("schema_version") != LAST_RUN_SCHEMA_VERSION:
|
|
284
|
+
# Old schema → don't diff against it (would noise-flood the report).
|
|
285
|
+
# Treat as cold start; the next save will write the new schema.
|
|
286
|
+
return {}
|
|
287
|
+
return snap
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
LAST_RUN_SCHEMA_VERSION = 2
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def save_last_run(path: Path, linked: dict[str, str]) -> None:
|
|
294
|
+
"""Persist this run's linked results for next time's diff."""
|
|
295
|
+
try:
|
|
296
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
297
|
+
snapshot = {
|
|
298
|
+
"schema_version": LAST_RUN_SCHEMA_VERSION,
|
|
299
|
+
"timestamp": datetime.now().isoformat(timespec="seconds"),
|
|
300
|
+
"linked": linked,
|
|
301
|
+
}
|
|
302
|
+
path.write_text(json.dumps(snapshot, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
303
|
+
except OSError:
|
|
304
|
+
pass
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def diff_vs_last_run(current: dict[str, str], previous: dict) -> dict:
|
|
308
|
+
"""Compare current vs previous {tc_id: status}. Returns:
|
|
309
|
+
{
|
|
310
|
+
"new_failures": [tc_id, ...], # was 通过/跳过/missing, now 失败/阻塞
|
|
311
|
+
"fixed": [tc_id, ...], # was 失败/阻塞, now 通过
|
|
312
|
+
"flips": [{tc_id, from, to}],# other status changes
|
|
313
|
+
"newly_seen": [tc_id, ...], # not in previous run at all
|
|
314
|
+
"no_longer_seen":[tc_id, ...], # in previous run, missing now
|
|
315
|
+
"had_previous": bool # False on the very first run
|
|
316
|
+
}
|
|
317
|
+
"""
|
|
318
|
+
prev_linked = previous.get("linked", {}) if previous else {}
|
|
319
|
+
if not prev_linked:
|
|
320
|
+
return {"had_previous": False, "new_failures": [], "fixed": [],
|
|
321
|
+
"flips": [], "newly_seen": sorted(current.keys()), "no_longer_seen": []}
|
|
322
|
+
|
|
323
|
+
failing = {"失败", "阻塞"}
|
|
324
|
+
passing = {"通过"}
|
|
325
|
+
|
|
326
|
+
new_failures: list[str] = []
|
|
327
|
+
fixed: list[str] = []
|
|
328
|
+
flips: list[dict] = []
|
|
329
|
+
newly_seen: list[str] = []
|
|
330
|
+
for tc_id, cur in sorted(current.items()):
|
|
331
|
+
prev = prev_linked.get(tc_id)
|
|
332
|
+
if prev is None:
|
|
333
|
+
newly_seen.append(tc_id)
|
|
334
|
+
if cur in failing:
|
|
335
|
+
new_failures.append(tc_id)
|
|
336
|
+
continue
|
|
337
|
+
if prev == cur:
|
|
338
|
+
continue
|
|
339
|
+
if cur in failing and prev not in failing:
|
|
340
|
+
new_failures.append(tc_id)
|
|
341
|
+
elif prev in failing and cur in passing:
|
|
342
|
+
fixed.append(tc_id)
|
|
343
|
+
else:
|
|
344
|
+
flips.append({"tc_id": tc_id, "from": prev, "to": cur})
|
|
345
|
+
|
|
346
|
+
no_longer_seen = sorted(set(prev_linked) - set(current))
|
|
347
|
+
return {"had_previous": True, "new_failures": new_failures, "fixed": fixed,
|
|
348
|
+
"flips": flips, "newly_seen": newly_seen, "no_longer_seen": no_longer_seen}
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# ── Code coverage (cobertura.xml) ─────────────────────────────────────────────
|
|
352
|
+
|
|
353
|
+
def parse_coverage(path: str) -> dict | None:
|
|
354
|
+
"""Parse a cobertura.xml. Returns {covered, valid, rate} or None.
|
|
355
|
+
Supports: pytest-cov, go-cover-treemap, jest, vitest --coverage.cobertura."""
|
|
356
|
+
p = Path(path)
|
|
357
|
+
if not p.exists():
|
|
358
|
+
return None
|
|
359
|
+
try:
|
|
360
|
+
root = ET.parse(p).getroot()
|
|
361
|
+
except ET.ParseError:
|
|
362
|
+
return None
|
|
363
|
+
covered = root.get("lines-covered")
|
|
364
|
+
valid = root.get("lines-valid")
|
|
365
|
+
rate = root.get("line-rate")
|
|
366
|
+
if covered and valid:
|
|
367
|
+
c = int(covered)
|
|
368
|
+
v = int(valid)
|
|
369
|
+
return {"covered": c, "valid": v, "rate": c / v if v else 0.0}
|
|
370
|
+
if rate:
|
|
371
|
+
try:
|
|
372
|
+
return {"rate": float(rate)}
|
|
373
|
+
except ValueError:
|
|
374
|
+
return None
|
|
375
|
+
return None
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def collect_coverage(suites: list[dict]) -> dict | None:
|
|
379
|
+
"""Aggregate coverage across suites. Sums lines when available; falls back
|
|
380
|
+
to mean of rates. Returns None when no suite reports coverage."""
|
|
381
|
+
total_covered = 0
|
|
382
|
+
total_valid = 0
|
|
383
|
+
rates: list[float] = []
|
|
384
|
+
found = False
|
|
385
|
+
for suite in suites:
|
|
386
|
+
cov_path = suite.get("coverage_file", "")
|
|
387
|
+
if not cov_path:
|
|
388
|
+
continue
|
|
389
|
+
parsed = parse_coverage(cov_path)
|
|
390
|
+
if not parsed:
|
|
391
|
+
continue
|
|
392
|
+
found = True
|
|
393
|
+
if "covered" in parsed:
|
|
394
|
+
total_covered += parsed["covered"]
|
|
395
|
+
total_valid += parsed["valid"]
|
|
396
|
+
else:
|
|
397
|
+
rates.append(parsed["rate"])
|
|
398
|
+
if not found:
|
|
399
|
+
return None
|
|
400
|
+
if total_valid > 0:
|
|
401
|
+
return {"covered": total_covered, "valid": total_valid,
|
|
402
|
+
"rate": total_covered / total_valid}
|
|
403
|
+
if rates:
|
|
404
|
+
return {"rate": sum(rates) / len(rates)}
|
|
405
|
+
return None
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _matrix_gate_enabled(suite: dict) -> bool:
|
|
409
|
+
value = suite.get("matrix_gate", True)
|
|
410
|
+
if value is None:
|
|
411
|
+
return True
|
|
412
|
+
if isinstance(value, str):
|
|
413
|
+
return value.strip().lower() not in {"", "0", "false", "no", "off"}
|
|
414
|
+
return bool(value)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _clear_suite_result_files(suites: list[dict]) -> dict[str, Path]:
|
|
418
|
+
seen: set[str] = set()
|
|
419
|
+
candidates: list[tuple[str, Path]] = []
|
|
420
|
+
for suite in suites:
|
|
421
|
+
rf = suite.get("results_file", "")
|
|
422
|
+
if not rf:
|
|
423
|
+
continue
|
|
424
|
+
path = Path(rf)
|
|
425
|
+
try:
|
|
426
|
+
real_path = str(path.resolve())
|
|
427
|
+
except OSError as exc:
|
|
428
|
+
raise RuntimeError(f"failed to resolve results_file {rf}: {exc}") from exc
|
|
429
|
+
if real_path in seen:
|
|
430
|
+
continue
|
|
431
|
+
seen.add(real_path)
|
|
432
|
+
if not path.exists():
|
|
433
|
+
continue
|
|
434
|
+
if not path.is_file():
|
|
435
|
+
raise RuntimeError(f"results_file is not a file: {rf}")
|
|
436
|
+
candidates.append((rf, path))
|
|
437
|
+
|
|
438
|
+
backups: dict[str, Path] = {}
|
|
439
|
+
for rf, path in candidates:
|
|
440
|
+
stamp = datetime.now().strftime("%Y%m%d%H%M%S%f")
|
|
441
|
+
backup = path.with_name(f"{path.name}.pre-gate-bak-{stamp}")
|
|
442
|
+
try:
|
|
443
|
+
path.replace(backup)
|
|
444
|
+
except OSError as exc:
|
|
445
|
+
try:
|
|
446
|
+
_restore_missing_suite_results(backups)
|
|
447
|
+
except RuntimeError as restore_exc:
|
|
448
|
+
raise RuntimeError(
|
|
449
|
+
f"failed to back up results_file {rf}: {exc}; "
|
|
450
|
+
f"also failed to restore earlier backups: {restore_exc}"
|
|
451
|
+
) from exc
|
|
452
|
+
raise RuntimeError(f"failed to back up results_file {rf}: {exc}") from exc
|
|
453
|
+
backups[rf] = backup
|
|
454
|
+
return backups
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _sweep_pre_gate_backups(suites: list[dict]) -> list[str]:
|
|
458
|
+
restored: list[str] = []
|
|
459
|
+
seen: set[str] = set()
|
|
460
|
+
for suite in suites:
|
|
461
|
+
rf = suite.get("results_file", "")
|
|
462
|
+
if not rf:
|
|
463
|
+
continue
|
|
464
|
+
path = Path(rf)
|
|
465
|
+
try:
|
|
466
|
+
real_path = str(path.resolve())
|
|
467
|
+
except OSError as exc:
|
|
468
|
+
raise RuntimeError(f"failed to resolve results_file {rf}: {exc}") from exc
|
|
469
|
+
if real_path in seen:
|
|
470
|
+
continue
|
|
471
|
+
seen.add(real_path)
|
|
472
|
+
backups = sorted(path.parent.glob(f"{path.name}.pre-gate-bak-*"))
|
|
473
|
+
if not backups:
|
|
474
|
+
continue
|
|
475
|
+
newest = backups[-1]
|
|
476
|
+
if path.exists():
|
|
477
|
+
for backup in backups:
|
|
478
|
+
try:
|
|
479
|
+
backup.unlink()
|
|
480
|
+
except OSError as exc:
|
|
481
|
+
raise RuntimeError(f"failed to remove stale results_file backup {backup}: {exc}") from exc
|
|
482
|
+
continue
|
|
483
|
+
try:
|
|
484
|
+
newest.replace(path)
|
|
485
|
+
except OSError as exc:
|
|
486
|
+
raise RuntimeError(f"failed to restore orphaned results_file backup {newest}: {exc}") from exc
|
|
487
|
+
restored.append(rf)
|
|
488
|
+
for backup in backups[:-1]:
|
|
489
|
+
try:
|
|
490
|
+
backup.unlink()
|
|
491
|
+
except OSError as exc:
|
|
492
|
+
raise RuntimeError(f"failed to remove stale results_file backup {backup}: {exc}") from exc
|
|
493
|
+
return restored
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _is_fresh_regenerated_result(path: Path, backup: Path) -> bool:
|
|
497
|
+
return path.exists() and backup.exists()
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _restore_missing_suite_results(backups: dict[str, Path]) -> list[str]:
|
|
501
|
+
restored: list[str] = []
|
|
502
|
+
for rf, backup in backups.items():
|
|
503
|
+
path = Path(rf)
|
|
504
|
+
if path.exists():
|
|
505
|
+
if not path.is_file():
|
|
506
|
+
raise RuntimeError(f"results_file is not a file after test run: {rf}")
|
|
507
|
+
if backup.exists():
|
|
508
|
+
if not _is_fresh_regenerated_result(path, backup):
|
|
509
|
+
try:
|
|
510
|
+
path.unlink()
|
|
511
|
+
except OSError as exc:
|
|
512
|
+
raise RuntimeError(f"failed to remove stale results_file {rf}: {exc}") from exc
|
|
513
|
+
try:
|
|
514
|
+
backup.replace(path)
|
|
515
|
+
except OSError as exc:
|
|
516
|
+
raise RuntimeError(f"failed to restore results_file {rf}: {exc}") from exc
|
|
517
|
+
restored.append(rf)
|
|
518
|
+
continue
|
|
519
|
+
try:
|
|
520
|
+
backup.unlink()
|
|
521
|
+
except OSError as exc:
|
|
522
|
+
raise RuntimeError(f"failed to remove results_file backup {backup}: {exc}") from exc
|
|
523
|
+
continue
|
|
524
|
+
if not backup.exists():
|
|
525
|
+
continue
|
|
526
|
+
try:
|
|
527
|
+
backup.replace(path)
|
|
528
|
+
except OSError as exc:
|
|
529
|
+
raise RuntimeError(f"failed to restore results_file {rf}: {exc}") from exc
|
|
530
|
+
restored.append(rf)
|
|
531
|
+
return restored
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _run_suites_and_restore_backups(suites: list[dict], backups: dict[str, Path]) -> tuple[bool, list[str]]:
|
|
535
|
+
suite_failed = False
|
|
536
|
+
restored: list[str] = []
|
|
537
|
+
try:
|
|
538
|
+
for suite in suites:
|
|
539
|
+
if not run_suite(suite):
|
|
540
|
+
suite_failed = True
|
|
541
|
+
finally:
|
|
542
|
+
if backups:
|
|
543
|
+
restored = _restore_missing_suite_results(backups)
|
|
544
|
+
return suite_failed, restored
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def collect_results(suites: list[dict], *, parse_excluded: bool = True,
|
|
548
|
+
stale_result_files: set[str] | None = None) -> dict:
|
|
549
|
+
"""
|
|
550
|
+
Merge results from all suites.
|
|
551
|
+
Returns:
|
|
552
|
+
{
|
|
553
|
+
"linked": {tc_id: status, ...}, # pessimistic merge across suites
|
|
554
|
+
"linked_test_types": {tc_id: test_type, ...}, # optional suite-declared test type
|
|
555
|
+
"tc_layers": {layer: [tc_id, ...]}, # optional suite layer evidence
|
|
556
|
+
"by_test_type": {test_type: {status counts}}, # minimal-report execution-form grouping
|
|
557
|
+
"configured_layers": [layer, ...], # layers declared in config
|
|
558
|
+
"unlayered_tcs": [tc_id, ...], # linked TCs from unlabeled suites
|
|
559
|
+
"untracked": [{"name", "classname", "status", "suite"}, ...],
|
|
560
|
+
"missing_files": [path, ...], # suites configured but no JUnit XML
|
|
561
|
+
"excluded_missing_files": [path, ...], # matrix_gate:false suites missing XML
|
|
562
|
+
"missing_layered_files": [path, ...], # layered suites with no JUnit XML
|
|
563
|
+
"missing_layered_layers": [layer, ...], # gate-participating missing layers
|
|
564
|
+
"excluded_layered_layers": [layer, ...], # matrix_gate:false layer evidence
|
|
565
|
+
"invalid_layer_suites": [{"suite", "layer"}, ...],
|
|
566
|
+
"parsed_suites": int, # suites that produced parseable XML
|
|
567
|
+
}
|
|
568
|
+
missing_files lets the gate distinguish:
|
|
569
|
+
- "infra hard-fail" (suites configured, JUnit XML missing) → exit non-zero
|
|
570
|
+
- "no TC linked" (JUnit XML parsed but no sidecar hits) → exit 0 if no fails
|
|
571
|
+
"""
|
|
572
|
+
_rank = {"失败": 4, "阻塞": 3, "跳过": 2, "通过": 1}
|
|
573
|
+
|
|
574
|
+
sidecar = load_sidecar(_sidecar_path())
|
|
575
|
+
if sidecar:
|
|
576
|
+
print(f" Sidecar: {len(sidecar)} test → TC mappings loaded from {_sidecar_path()}",
|
|
577
|
+
file=sys.stderr)
|
|
578
|
+
|
|
579
|
+
linked: dict[str, str] = {}
|
|
580
|
+
linked_test_types: dict[str, str] = {}
|
|
581
|
+
test_type_conflicts: set[str] = set()
|
|
582
|
+
tc_layers: dict[str, set[str]] = defaultdict(set)
|
|
583
|
+
by_test_type: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
|
584
|
+
configured_layers: set[str] = set()
|
|
585
|
+
unlayered_tcs: set[str] = set()
|
|
586
|
+
untracked: list[dict] = []
|
|
587
|
+
missing_files: list[str] = []
|
|
588
|
+
excluded_missing_files: list[str] = []
|
|
589
|
+
missing_layered_files: list[str] = []
|
|
590
|
+
missing_layered_layers: set[str] = set()
|
|
591
|
+
excluded_layered_layers: set[str] = set()
|
|
592
|
+
invalid_layer_suites: list[dict] = []
|
|
593
|
+
parsed_suites = 0
|
|
594
|
+
stale_result_files = stale_result_files or set()
|
|
595
|
+
for suite in suites:
|
|
596
|
+
has_layer_key = "layer" in suite
|
|
597
|
+
raw_layer = str(suite.get("layer", "")).strip()
|
|
598
|
+
if not raw_layer:
|
|
599
|
+
has_layer_key = False
|
|
600
|
+
suite_layer = _normalize_layer(raw_layer)
|
|
601
|
+
suite_test_type = _str(suite.get("test_type", "")).strip()
|
|
602
|
+
participates_in_matrix_gate = _matrix_gate_enabled(suite)
|
|
603
|
+
rf = suite.get("results_file", "")
|
|
604
|
+
if has_layer_key and suite_layer not in _MATRIX_ENFORCED_LAYERS:
|
|
605
|
+
invalid_layer_suites.append({
|
|
606
|
+
"suite": suite.get("name", rf),
|
|
607
|
+
"layer": raw_layer,
|
|
608
|
+
})
|
|
609
|
+
if participates_in_matrix_gate and suite_layer in _MATRIX_ENFORCED_LAYERS:
|
|
610
|
+
configured_layers.add(suite_layer)
|
|
611
|
+
if not rf:
|
|
612
|
+
if has_layer_key and participates_in_matrix_gate:
|
|
613
|
+
missing_label = f"{suite.get('name', '<unnamed>')}: results_file missing"
|
|
614
|
+
if suite_layer in _MATRIX_ENFORCED_LAYERS:
|
|
615
|
+
missing_files.append(missing_label)
|
|
616
|
+
missing_layered_files.append(missing_label)
|
|
617
|
+
missing_layered_layers.add(suite_layer)
|
|
618
|
+
print(f" ⚠ {suite.get('name', '<unnamed>')}: layered suite has no results_file",
|
|
619
|
+
file=sys.stderr)
|
|
620
|
+
continue
|
|
621
|
+
if not participates_in_matrix_gate and not parse_excluded:
|
|
622
|
+
if suite_layer in _MATRIX_ENFORCED_LAYERS:
|
|
623
|
+
excluded_layered_layers.add(suite_layer)
|
|
624
|
+
if not Path(rf).exists():
|
|
625
|
+
excluded_missing_files.append(rf)
|
|
626
|
+
print(f" ⚠ {suite.get('name', rf)}: results file missing outside matrix gate: {rf}",
|
|
627
|
+
file=sys.stderr)
|
|
628
|
+
else:
|
|
629
|
+
print(f" {suite.get('name', rf)}: skipped outside matrix gate", file=sys.stderr)
|
|
630
|
+
continue
|
|
631
|
+
if rf in stale_result_files or not Path(rf).exists():
|
|
632
|
+
if participates_in_matrix_gate:
|
|
633
|
+
missing_files.append(rf)
|
|
634
|
+
else:
|
|
635
|
+
excluded_missing_files.append(rf)
|
|
636
|
+
if participates_in_matrix_gate and suite_layer in _MATRIX_ENFORCED_LAYERS:
|
|
637
|
+
missing_layered_files.append(rf)
|
|
638
|
+
missing_layered_layers.add(suite_layer)
|
|
639
|
+
if participates_in_matrix_gate:
|
|
640
|
+
reason = "stale pre-gate XML restored" if rf in stale_result_files else "results file missing"
|
|
641
|
+
print(f" ⚠ {suite.get('name', rf)}: {reason} → suite presumed failed: {rf}",
|
|
642
|
+
file=sys.stderr)
|
|
643
|
+
else:
|
|
644
|
+
print(f" ⚠ {suite.get('name', rf)}: results file missing outside matrix gate: {rf}",
|
|
645
|
+
file=sys.stderr)
|
|
646
|
+
continue
|
|
647
|
+
parsed_suites += 1
|
|
648
|
+
parsed = parse_junit(rf, sidecar)
|
|
649
|
+
suite_name = suite.get("name", rf)
|
|
650
|
+
print(f" {suite_name}: {len(parsed['linked'])} linked, "
|
|
651
|
+
f"{len(parsed['untracked'])} untracked", file=sys.stderr)
|
|
652
|
+
for tc_id, status in parsed["linked"].items():
|
|
653
|
+
if tc_id not in linked or _rank.get(status, 0) > _rank.get(linked[tc_id], 0):
|
|
654
|
+
linked[tc_id] = status
|
|
655
|
+
if suite_test_type:
|
|
656
|
+
existing_type = linked_test_types.get(tc_id, "")
|
|
657
|
+
if not existing_type:
|
|
658
|
+
linked_test_types[tc_id] = suite_test_type
|
|
659
|
+
elif existing_type != suite_test_type:
|
|
660
|
+
linked_test_types[tc_id] = "(多种测试类型)"
|
|
661
|
+
test_type_conflicts.add(tc_id)
|
|
662
|
+
if participates_in_matrix_gate and suite_layer in _MATRIX_ENFORCED_LAYERS:
|
|
663
|
+
if status == "通过":
|
|
664
|
+
tc_layers[suite_layer].add(tc_id)
|
|
665
|
+
elif not participates_in_matrix_gate and suite_layer in _MATRIX_ENFORCED_LAYERS:
|
|
666
|
+
if status == "通过":
|
|
667
|
+
excluded_layered_layers.add(suite_layer)
|
|
668
|
+
elif participates_in_matrix_gate and not has_layer_key:
|
|
669
|
+
if status == "通过":
|
|
670
|
+
unlayered_tcs.add(tc_id)
|
|
671
|
+
for u in parsed["untracked"]:
|
|
672
|
+
u["suite"] = suite_name
|
|
673
|
+
if suite_test_type:
|
|
674
|
+
u["test_type"] = suite_test_type
|
|
675
|
+
untracked.append(u)
|
|
676
|
+
for tc_id, status in linked.items():
|
|
677
|
+
test_type = linked_test_types.get(tc_id, "")
|
|
678
|
+
if not test_type or test_type == "(多种测试类型)":
|
|
679
|
+
continue
|
|
680
|
+
by_test_type[test_type][status] += 1
|
|
681
|
+
by_test_type[test_type]["_total"] = by_test_type[test_type].get("_total", 0) + 1
|
|
682
|
+
return {"linked": linked, "untracked": untracked,
|
|
683
|
+
"linked_test_types": linked_test_types,
|
|
684
|
+
"test_type_conflicts": sorted(test_type_conflicts),
|
|
685
|
+
"tc_layers": {layer: sorted(tcs) for layer, tcs in sorted(tc_layers.items())},
|
|
686
|
+
"by_test_type": {name: dict(v) for name, v in sorted(by_test_type.items())},
|
|
687
|
+
"configured_layers": sorted(configured_layers),
|
|
688
|
+
"unlayered_tcs": sorted(unlayered_tcs),
|
|
689
|
+
"missing_files": missing_files,
|
|
690
|
+
"excluded_missing_files": excluded_missing_files,
|
|
691
|
+
"missing_layered_files": missing_layered_files,
|
|
692
|
+
"missing_layered_layers": sorted(missing_layered_layers),
|
|
693
|
+
"excluded_layered_layers": sorted(excluded_layered_layers),
|
|
694
|
+
"invalid_layer_suites": invalid_layer_suites,
|
|
695
|
+
"parsed_suites": parsed_suites}
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
# ── Bitable helpers ────────────────────────────────────────────────────────────
|
|
699
|
+
|
|
700
|
+
def record_list(base_token: str, table_id: str, identity: str) -> list[dict]:
|
|
701
|
+
"""Return all records as ``[{"record_id": str, "fields": {name: value}}]``.
|
|
702
|
+
|
|
703
|
+
Parses lark-cli's columnar +record-list response: ``data.data`` is a list of
|
|
704
|
+
rows (each a list of cell values), ``data.fields`` gives the column-name
|
|
705
|
+
order, and ``data.record_id_list`` gives the parallel record ids. Paginates
|
|
706
|
+
via ``has_more``. Raises on CLI failure.
|
|
707
|
+
"""
|
|
708
|
+
records: list[dict] = []
|
|
709
|
+
offset = 0
|
|
710
|
+
limit = 200
|
|
711
|
+
while True:
|
|
712
|
+
result = subprocess.run(
|
|
713
|
+
["lark-cli", "base", "+record-list",
|
|
714
|
+
"--base-token", base_token,
|
|
715
|
+
"--table-id", table_id,
|
|
716
|
+
"--as", identity,
|
|
717
|
+
"--limit", str(limit),
|
|
718
|
+
"--offset", str(offset),
|
|
719
|
+
"--format", "json"],
|
|
720
|
+
capture_output=True, text=True
|
|
721
|
+
)
|
|
722
|
+
if result.returncode != 0:
|
|
723
|
+
raise RuntimeError(f"+record-list failed: {result.stderr.strip()}")
|
|
724
|
+
inner = json.loads(result.stdout).get("data", {})
|
|
725
|
+
cols = inner.get("fields", [])
|
|
726
|
+
rows = inner.get("data", [])
|
|
727
|
+
rids = inner.get("record_id_list", [])
|
|
728
|
+
page = []
|
|
729
|
+
for i, row in enumerate(rows):
|
|
730
|
+
fields = {cols[j]: row[j] for j in range(min(len(cols), len(row)))}
|
|
731
|
+
page.append({
|
|
732
|
+
"record_id": rids[i] if i < len(rids) else "",
|
|
733
|
+
"fields": fields,
|
|
734
|
+
})
|
|
735
|
+
records.extend(page)
|
|
736
|
+
if not page or not inner.get("has_more"):
|
|
737
|
+
break
|
|
738
|
+
offset += len(page)
|
|
739
|
+
return records
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _md_cell(s: str) -> str:
|
|
743
|
+
"""Escape a string for safe embedding in a Markdown table cell.
|
|
744
|
+
- `|` → `\\|` so the column separator is preserved (parse_md_table reverses this)
|
|
745
|
+
- all line breaks (`\\r\\n`, `\\n`, lone `\\r` from classic-Mac files) → `<br>`
|
|
746
|
+
"""
|
|
747
|
+
if not s:
|
|
748
|
+
return ""
|
|
749
|
+
return (
|
|
750
|
+
s.replace("|", "\\|")
|
|
751
|
+
.replace("\r\n", "<br>")
|
|
752
|
+
.replace("\n", "<br>")
|
|
753
|
+
.replace("\r", "<br>")
|
|
754
|
+
)
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def _str(val) -> str:
|
|
758
|
+
"""Coerce a Bitable field value to a string.
|
|
759
|
+
|
|
760
|
+
Bitable text/rich-text fields are returned as a list of segments. Each
|
|
761
|
+
segment is either a plain string or a dict like ``{"type": "text", "text": "..."}``.
|
|
762
|
+
Single-select fields come back as plain strings; user/link fields come
|
|
763
|
+
back as lists of objects with non-text identifiers.
|
|
764
|
+
"""
|
|
765
|
+
if val is None or val == "":
|
|
766
|
+
return ""
|
|
767
|
+
if isinstance(val, list):
|
|
768
|
+
parts: list[str] = []
|
|
769
|
+
for seg in val:
|
|
770
|
+
if isinstance(seg, dict):
|
|
771
|
+
# Text segment: prefer "text" key (rich-text segments)
|
|
772
|
+
t = seg.get("text")
|
|
773
|
+
if isinstance(t, str):
|
|
774
|
+
parts.append(t)
|
|
775
|
+
continue
|
|
776
|
+
# Single-select option: {"name": "P0"} / {"text": ...} / {"value": ...}
|
|
777
|
+
for k in ("name", "value"):
|
|
778
|
+
if isinstance(seg.get(k), str):
|
|
779
|
+
parts.append(seg[k])
|
|
780
|
+
break
|
|
781
|
+
elif isinstance(seg, str):
|
|
782
|
+
parts.append(seg)
|
|
783
|
+
return "".join(parts)
|
|
784
|
+
if isinstance(val, dict):
|
|
785
|
+
for k in ("text", "name", "value"):
|
|
786
|
+
if isinstance(val.get(k), str):
|
|
787
|
+
return val[k]
|
|
788
|
+
return ""
|
|
789
|
+
return str(val)
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def _info_keep_count() -> int:
|
|
793
|
+
"""How many 信息流转 entries to retain when writing (ring buffer; oldest
|
|
794
|
+
entries dropped). Default 100. Set TC_INFO_KEEP=0 for unbounded (caps at
|
|
795
|
+
Bitable's per-cell limit ~10万字符 — your problem to manage)."""
|
|
796
|
+
raw = os.environ.get("TC_INFO_KEEP", "100")
|
|
797
|
+
try:
|
|
798
|
+
n = int(raw)
|
|
799
|
+
return n if n >= 0 else 100
|
|
800
|
+
except ValueError:
|
|
801
|
+
return 100
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def _truncate_info(info: str) -> str:
|
|
805
|
+
"""Keep only the last N entries (one per non-empty line). 0 = unbounded."""
|
|
806
|
+
n = _info_keep_count()
|
|
807
|
+
if n == 0:
|
|
808
|
+
return info.strip()
|
|
809
|
+
lines = [ln for ln in info.split("\n") if ln.strip()]
|
|
810
|
+
if len(lines) <= n:
|
|
811
|
+
return info.strip()
|
|
812
|
+
return "\n".join(lines[-n:])
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def build_index(records: list[dict]) -> dict[str, tuple[str, str]]:
|
|
816
|
+
"""Return {tc_id: (record_id, current_status)}. Raises on duplicate tc_id."""
|
|
817
|
+
index: dict[str, tuple[str, str]] = {}
|
|
818
|
+
for rec in records:
|
|
819
|
+
tc_id = _str(rec["fields"].get("用例ID", ""))
|
|
820
|
+
if not tc_id:
|
|
821
|
+
continue
|
|
822
|
+
if tc_id in index:
|
|
823
|
+
raise ValueError(f"Duplicate 用例ID '{tc_id}' in Bitable — fix before syncing")
|
|
824
|
+
status = _str(rec["fields"].get("状态", "未测试")) or "未测试"
|
|
825
|
+
index[tc_id] = (rec["record_id"], status)
|
|
826
|
+
return index
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def sync_results_with_append(base_token: str, table_id: str, identity: str,
|
|
830
|
+
records: list[dict],
|
|
831
|
+
results: dict[str, str], author: str) -> int:
|
|
832
|
+
"""
|
|
833
|
+
Update Bitable statuses and append to 信息流转 (read-then-append, never overwrite).
|
|
834
|
+
Returns count of records updated.
|
|
835
|
+
"""
|
|
836
|
+
index = build_index(records)
|
|
837
|
+
# Build record_id → fields map from already-fetched records
|
|
838
|
+
rec_map: dict[str, dict] = {r["record_id"]: r["fields"] for r in records}
|
|
839
|
+
|
|
840
|
+
updated = 0
|
|
841
|
+
for tc_id, new_status in results.items():
|
|
842
|
+
if tc_id not in index:
|
|
843
|
+
print(f" ⚠ {tc_id} not found in Bitable — skipping", file=sys.stderr)
|
|
844
|
+
continue
|
|
845
|
+
record_id, current_status = index[tc_id]
|
|
846
|
+
if current_status == "废弃":
|
|
847
|
+
continue # deprecated TCs are frozen — automation must not change their status
|
|
848
|
+
if current_status == new_status:
|
|
849
|
+
continue
|
|
850
|
+
|
|
851
|
+
# Read 信息流转 from in-memory records (no extra API call); truncate to last N
|
|
852
|
+
current_info = _str(rec_map.get(record_id, {}).get("信息流转", ""))
|
|
853
|
+
entry = f"[{author} {DATE_TODAY}] 自动化测试:{new_status}"
|
|
854
|
+
new_info = _truncate_info((current_info + "\n" + entry).strip())
|
|
855
|
+
|
|
856
|
+
result = subprocess.run(
|
|
857
|
+
["lark-cli", "base", "+record-upsert",
|
|
858
|
+
"--base-token", base_token,
|
|
859
|
+
"--table-id", table_id,
|
|
860
|
+
"--as", identity,
|
|
861
|
+
"--record-id", record_id,
|
|
862
|
+
"--json", json.dumps({"状态": new_status, "信息流转": new_info})],
|
|
863
|
+
capture_output=True, text=True
|
|
864
|
+
)
|
|
865
|
+
if result.returncode != 0:
|
|
866
|
+
print(f" ✗ Failed to update {tc_id}: {result.stderr.strip()}", file=sys.stderr)
|
|
867
|
+
else:
|
|
868
|
+
updated += 1
|
|
869
|
+
# Update in-memory so aggregate uses fresh status
|
|
870
|
+
if record_id in rec_map:
|
|
871
|
+
rec_map[record_id]["状态"] = new_status
|
|
872
|
+
rec_map[record_id]["信息流转"] = new_info
|
|
873
|
+
# Propagate back to records list
|
|
874
|
+
for rec in records:
|
|
875
|
+
if rec["record_id"] == record_id:
|
|
876
|
+
rec["fields"]["状态"] = new_status
|
|
877
|
+
rec["fields"]["信息流转"] = new_info
|
|
878
|
+
break
|
|
879
|
+
return updated
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
# ── Unfreeze (废弃 → 未测试 recovery) ─────────────────────────────────────────
|
|
883
|
+
|
|
884
|
+
def unfreeze_tc(base_token: str, table_id: str, identity: str,
|
|
885
|
+
records: list[dict], tc_ids: list[str], author: str,
|
|
886
|
+
reason: str = "") -> int:
|
|
887
|
+
"""Recover one or more TCs from 废弃 back to 未测试.
|
|
888
|
+
Appends a 信息流转 entry recording the recovery. Returns count restored."""
|
|
889
|
+
rec_map: dict[str, dict] = {}
|
|
890
|
+
for r in records:
|
|
891
|
+
case_id = _str(r["fields"].get("用例ID", ""))
|
|
892
|
+
if case_id:
|
|
893
|
+
rec_map[case_id] = r
|
|
894
|
+
|
|
895
|
+
restored = 0
|
|
896
|
+
for tc_id in tc_ids:
|
|
897
|
+
if tc_id not in rec_map:
|
|
898
|
+
print(f" ⚠ {tc_id} not found in Bitable — skipping", file=sys.stderr)
|
|
899
|
+
continue
|
|
900
|
+
rec = rec_map[tc_id]
|
|
901
|
+
current_status = _str(rec["fields"].get("状态", "")) or "未测试"
|
|
902
|
+
if current_status != "废弃":
|
|
903
|
+
print(f" ⓘ {tc_id} is currently {current_status!r}, not 废弃 — skipping",
|
|
904
|
+
file=sys.stderr)
|
|
905
|
+
continue
|
|
906
|
+
current_info = _str(rec["fields"].get("信息流转", ""))
|
|
907
|
+
msg = f"误标恢复({reason})" if reason else "误标恢复"
|
|
908
|
+
entry = f"[{author} {DATE_TODAY}] {msg}:废弃 → 未测试"
|
|
909
|
+
new_info = _truncate_info((current_info + "\n" + entry).strip())
|
|
910
|
+
result = subprocess.run(
|
|
911
|
+
["lark-cli", "base", "+record-upsert",
|
|
912
|
+
"--base-token", base_token,
|
|
913
|
+
"--table-id", table_id,
|
|
914
|
+
"--as", identity,
|
|
915
|
+
"--record-id", rec["record_id"],
|
|
916
|
+
"--json", json.dumps({"状态": "未测试", "信息流转": new_info})],
|
|
917
|
+
capture_output=True, text=True
|
|
918
|
+
)
|
|
919
|
+
if result.returncode != 0:
|
|
920
|
+
print(f" ✗ Failed to unfreeze {tc_id}: {result.stderr.strip()}", file=sys.stderr)
|
|
921
|
+
else:
|
|
922
|
+
restored += 1
|
|
923
|
+
rec["fields"]["状态"] = "未测试"
|
|
924
|
+
rec["fields"]["信息流转"] = new_info
|
|
925
|
+
return restored
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
# ── Matrix validation ─────────────────────────────────────────────────────────
|
|
929
|
+
|
|
930
|
+
_TC_ID_RE = re.compile(r"TC-[A-Z]+-\d+")
|
|
931
|
+
_MATRIX_ENFORCED_LAYERS = {"unit", "contract", "integration", "e2e"}
|
|
932
|
+
_ALL_MATRIX_LAYERS = _MATRIX_ENFORCED_LAYERS | {"manual", "blocked"}
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def _normalize_layer(layer: str) -> str:
|
|
936
|
+
"""Normalize suite/matrix layer names used for optional layer enforcement."""
|
|
937
|
+
value = (layer or "").strip().lower()
|
|
938
|
+
aliases = {
|
|
939
|
+
"api": "contract",
|
|
940
|
+
"api-contract": "contract",
|
|
941
|
+
"integration-contract": "integration",
|
|
942
|
+
"browser": "e2e",
|
|
943
|
+
"host": "e2e",
|
|
944
|
+
"host-smoke": "e2e",
|
|
945
|
+
"device": "e2e",
|
|
946
|
+
"smoke": "e2e",
|
|
947
|
+
}
|
|
948
|
+
return aliases.get(value, value)
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def _is_matrix_header(cells: list[str]) -> bool:
|
|
952
|
+
lower = [c.strip().lower() for c in cells]
|
|
953
|
+
if any(_TC_ID_RE.findall(c) for c in cells):
|
|
954
|
+
return False
|
|
955
|
+
layer_count = sum(1 for c in lower if c in _ALL_MATRIX_LAYERS)
|
|
956
|
+
return layer_count >= 1
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
def parse_matrix_tcs(path: str) -> dict[str, list[str]]:
|
|
960
|
+
"""Read test/cases/test-matrix.md; return {layer: [tc_ids]}.
|
|
961
|
+
|
|
962
|
+
Format expected (loose): a markdown table whose header row contains
|
|
963
|
+
column names matching one of {unit, contract, integration, e2e, manual,
|
|
964
|
+
blocked}. Each cell is scanned for TC-XX-NNN patterns; non-TC content
|
|
965
|
+
(notes, owners, dashes) is ignored.
|
|
966
|
+
|
|
967
|
+
The parser is intentionally lenient — the matrix is human-edited and may
|
|
968
|
+
contain notes / formatting variations.
|
|
969
|
+
"""
|
|
970
|
+
p = Path(path)
|
|
971
|
+
if not p.exists():
|
|
972
|
+
return {}
|
|
973
|
+
lines = p.read_text(encoding="utf-8").splitlines()
|
|
974
|
+
headers: list[str] | None = None
|
|
975
|
+
by_layer: dict[str, list[str]] = defaultdict(list)
|
|
976
|
+
for idx, raw in enumerate(lines):
|
|
977
|
+
line = raw.strip()
|
|
978
|
+
if not (line.startswith("|") and line.endswith("|")):
|
|
979
|
+
headers = None
|
|
980
|
+
continue
|
|
981
|
+
cells = _split_md_row(line)
|
|
982
|
+
lower = [c.strip().lower() for c in cells]
|
|
983
|
+
next_line = lines[idx + 1].strip() if idx + 1 < len(lines) else ""
|
|
984
|
+
if headers is not None and _is_matrix_header(cells) and _is_md_separator_row(next_line):
|
|
985
|
+
headers = lower
|
|
986
|
+
continue
|
|
987
|
+
if headers is None:
|
|
988
|
+
if _is_matrix_header(cells):
|
|
989
|
+
headers = lower
|
|
990
|
+
continue
|
|
991
|
+
if all(set(c) <= {"-", ":", " "} for c in cells if c):
|
|
992
|
+
continue
|
|
993
|
+
for col, cell in zip(headers, cells):
|
|
994
|
+
if col not in _ALL_MATRIX_LAYERS:
|
|
995
|
+
continue
|
|
996
|
+
for tc_id in _TC_ID_RE.findall(cell):
|
|
997
|
+
by_layer[col].append(tc_id)
|
|
998
|
+
return dict(by_layer)
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def parse_matrix_all_tcs(path: str) -> set[str]:
|
|
1002
|
+
p = Path(path)
|
|
1003
|
+
if not p.exists():
|
|
1004
|
+
return set()
|
|
1005
|
+
headers: list[str] | None = None
|
|
1006
|
+
all_tcs: set[str] = set()
|
|
1007
|
+
non_evidence_headers = {
|
|
1008
|
+
"模块", "module", "功能", "feature", "owner", "负责人",
|
|
1009
|
+
"notes", "note", "备注", "状态", "status",
|
|
1010
|
+
}
|
|
1011
|
+
lines = p.read_text(encoding="utf-8").splitlines()
|
|
1012
|
+
for idx, raw in enumerate(lines):
|
|
1013
|
+
line = raw.strip()
|
|
1014
|
+
if not (line.startswith("|") and line.endswith("|")):
|
|
1015
|
+
headers = None
|
|
1016
|
+
continue
|
|
1017
|
+
cells = _split_md_row(line)
|
|
1018
|
+
lower = [c.strip().lower() for c in cells]
|
|
1019
|
+
next_line = lines[idx + 1].strip() if idx + 1 < len(lines) else ""
|
|
1020
|
+
if headers is not None and _is_matrix_header(cells) and _is_md_separator_row(next_line):
|
|
1021
|
+
headers = lower
|
|
1022
|
+
continue
|
|
1023
|
+
if headers is None:
|
|
1024
|
+
if _is_matrix_header(cells):
|
|
1025
|
+
headers = lower
|
|
1026
|
+
continue
|
|
1027
|
+
if all(set(c) <= {"-", ":", " "} for c in cells if c):
|
|
1028
|
+
continue
|
|
1029
|
+
for header, cell in zip(headers, cells):
|
|
1030
|
+
if header in non_evidence_headers:
|
|
1031
|
+
continue
|
|
1032
|
+
all_tcs.update(_TC_ID_RE.findall(cell))
|
|
1033
|
+
return all_tcs
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def _matrix_has_canonical_header(path: str) -> bool:
|
|
1037
|
+
p = Path(path)
|
|
1038
|
+
if not p.exists():
|
|
1039
|
+
return False
|
|
1040
|
+
for raw in p.read_text(encoding="utf-8").splitlines():
|
|
1041
|
+
line = raw.strip()
|
|
1042
|
+
if not (line.startswith("|") and line.endswith("|")):
|
|
1043
|
+
continue
|
|
1044
|
+
cells = _split_md_row(line)
|
|
1045
|
+
lower = [c.strip().lower() for c in cells]
|
|
1046
|
+
if _is_matrix_header(cells):
|
|
1047
|
+
return True
|
|
1048
|
+
return False
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
def _matrix_row_shape_error(path: str) -> str:
|
|
1052
|
+
p = Path(path)
|
|
1053
|
+
if not p.exists():
|
|
1054
|
+
return ""
|
|
1055
|
+
headers: list[str] | None = None
|
|
1056
|
+
lines = p.read_text(encoding="utf-8").splitlines()
|
|
1057
|
+
for line_no, raw in enumerate(lines, start=1):
|
|
1058
|
+
line = raw.strip()
|
|
1059
|
+
if not (line.startswith("|") and line.endswith("|")):
|
|
1060
|
+
headers = None
|
|
1061
|
+
continue
|
|
1062
|
+
cells = _split_md_row(line)
|
|
1063
|
+
lower = [c.strip().lower() for c in cells]
|
|
1064
|
+
next_line = lines[line_no].strip() if line_no < len(lines) else ""
|
|
1065
|
+
if headers is not None and _is_matrix_header(cells) and _is_md_separator_row(next_line):
|
|
1066
|
+
headers = lower
|
|
1067
|
+
continue
|
|
1068
|
+
if headers is None:
|
|
1069
|
+
if _is_matrix_header(cells):
|
|
1070
|
+
headers = lower
|
|
1071
|
+
continue
|
|
1072
|
+
if all(set(c) <= {"-", ":", " "} for c in cells if c):
|
|
1073
|
+
continue
|
|
1074
|
+
if len(cells) > len(headers):
|
|
1075
|
+
return f"matrix row {line_no} has {len(cells)} cells; header has {len(headers)}"
|
|
1076
|
+
return ""
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
def parse_matrix_declared_layers(path: str) -> set[str]:
|
|
1080
|
+
p = Path(path)
|
|
1081
|
+
if not p.exists():
|
|
1082
|
+
return set()
|
|
1083
|
+
declared: set[str] = set()
|
|
1084
|
+
for raw in p.read_text(encoding="utf-8").splitlines():
|
|
1085
|
+
line = raw.strip()
|
|
1086
|
+
if not (line.startswith("|") and line.endswith("|")):
|
|
1087
|
+
continue
|
|
1088
|
+
cells = _split_md_row(line)
|
|
1089
|
+
if _is_matrix_header(cells):
|
|
1090
|
+
declared.update(c.strip().lower() for c in cells if c.strip().lower() in _ALL_MATRIX_LAYERS)
|
|
1091
|
+
return declared
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
def validate_matrix(matrix_path: str, records: list[dict],
|
|
1095
|
+
actual_layers: dict[str, list[str]] | None = None,
|
|
1096
|
+
configured_layers: list[str] | None = None,
|
|
1097
|
+
unlayered_tcs: list[str] | None = None) -> dict:
|
|
1098
|
+
"""Compare matrix TC IDs vs Bitable active records.
|
|
1099
|
+
Returns:
|
|
1100
|
+
{
|
|
1101
|
+
"in_matrix_not_bitable": [tc_id, ...], # matrix lists, Bitable doesn't have (or has 废弃)
|
|
1102
|
+
"in_bitable_not_matrix": [tc_id, ...], # active Bitable TCs absent from matrix
|
|
1103
|
+
"deprecated_in_matrix": [tc_id, ...], # matrix lists, Bitable status is 废弃
|
|
1104
|
+
"layer_mismatches": [{"tc_id", "declared", "actual"}],
|
|
1105
|
+
"matrix_tcs_by_layer": {layer: [tc_id]},
|
|
1106
|
+
"actual_tcs_by_layer": {layer: [tc_id]},
|
|
1107
|
+
"configured_layers": [layer, ...],
|
|
1108
|
+
"parse_error": str,
|
|
1109
|
+
}
|
|
1110
|
+
"""
|
|
1111
|
+
matrix = parse_matrix_tcs(matrix_path)
|
|
1112
|
+
declared_layers = parse_matrix_declared_layers(matrix_path)
|
|
1113
|
+
parse_error = ""
|
|
1114
|
+
if not Path(matrix_path).exists():
|
|
1115
|
+
parse_error = "matrix file not found"
|
|
1116
|
+
elif not matrix and not _matrix_has_canonical_header(matrix_path):
|
|
1117
|
+
parse_error = "no canonical matrix layer header found"
|
|
1118
|
+
else:
|
|
1119
|
+
parse_error = _matrix_row_shape_error(matrix_path)
|
|
1120
|
+
all_in_matrix = parse_matrix_all_tcs(matrix_path)
|
|
1121
|
+
|
|
1122
|
+
active: set[str] = set()
|
|
1123
|
+
deprecated: set[str] = set()
|
|
1124
|
+
for rec in records:
|
|
1125
|
+
tc_id = _str(rec["fields"].get("用例ID", ""))
|
|
1126
|
+
if not tc_id:
|
|
1127
|
+
continue
|
|
1128
|
+
status = _str(rec["fields"].get("状态", ""))
|
|
1129
|
+
if status == "废弃":
|
|
1130
|
+
deprecated.add(tc_id)
|
|
1131
|
+
else:
|
|
1132
|
+
active.add(tc_id)
|
|
1133
|
+
|
|
1134
|
+
in_matrix_not_bitable = sorted(all_in_matrix - active - deprecated)
|
|
1135
|
+
deprecated_in_matrix = sorted(all_in_matrix & deprecated)
|
|
1136
|
+
in_bitable_not_matrix = [] if parse_error else sorted(active - all_in_matrix)
|
|
1137
|
+
normalized_actual: dict[str, set[str]] = defaultdict(set)
|
|
1138
|
+
for layer, tcs in (actual_layers or {}).items():
|
|
1139
|
+
normalized = _normalize_layer(layer)
|
|
1140
|
+
if normalized not in _MATRIX_ENFORCED_LAYERS:
|
|
1141
|
+
continue
|
|
1142
|
+
normalized_actual[normalized].update(tcs)
|
|
1143
|
+
configured_source = normalized_actual.keys() if configured_layers is None else configured_layers
|
|
1144
|
+
configured_normalized = {
|
|
1145
|
+
_normalize_layer(layer)
|
|
1146
|
+
for layer in configured_source
|
|
1147
|
+
if _normalize_layer(layer) in _MATRIX_ENFORCED_LAYERS
|
|
1148
|
+
}
|
|
1149
|
+
unlayered = set(unlayered_tcs or [])
|
|
1150
|
+
unverified_layers = sorted(set(matrix) & _MATRIX_ENFORCED_LAYERS - configured_normalized)
|
|
1151
|
+
|
|
1152
|
+
layer_mismatches: list[dict] = []
|
|
1153
|
+
if configured_normalized:
|
|
1154
|
+
actual_by_tc: dict[str, set[str]] = defaultdict(set)
|
|
1155
|
+
for layer, tcs in normalized_actual.items():
|
|
1156
|
+
for tc_id in tcs:
|
|
1157
|
+
actual_by_tc[tc_id].add(layer)
|
|
1158
|
+
for layer, tcs in sorted(matrix.items()):
|
|
1159
|
+
if layer not in configured_normalized:
|
|
1160
|
+
continue
|
|
1161
|
+
for tc_id in sorted(set(tcs)):
|
|
1162
|
+
if tc_id in deprecated or tc_id not in active:
|
|
1163
|
+
continue
|
|
1164
|
+
actual = actual_by_tc.get(tc_id, set())
|
|
1165
|
+
if layer not in actual:
|
|
1166
|
+
actual_list = sorted(actual)
|
|
1167
|
+
if tc_id in unlayered:
|
|
1168
|
+
actual_list.append("unlabeled-suite")
|
|
1169
|
+
layer_mismatches.append({
|
|
1170
|
+
"tc_id": tc_id,
|
|
1171
|
+
"declared": layer,
|
|
1172
|
+
"actual": actual_list,
|
|
1173
|
+
})
|
|
1174
|
+
|
|
1175
|
+
return {
|
|
1176
|
+
"in_matrix_not_bitable": in_matrix_not_bitable,
|
|
1177
|
+
"in_bitable_not_matrix": in_bitable_not_matrix,
|
|
1178
|
+
"deprecated_in_matrix": deprecated_in_matrix,
|
|
1179
|
+
"layer_mismatches": layer_mismatches,
|
|
1180
|
+
"matrix_tcs_by_layer": matrix,
|
|
1181
|
+
"actual_tcs_by_layer": {layer: sorted(tcs) for layer, tcs in sorted(normalized_actual.items())},
|
|
1182
|
+
"configured_layers": sorted(configured_normalized),
|
|
1183
|
+
"unverified_layers": unverified_layers,
|
|
1184
|
+
"parse_error": parse_error,
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
def print_matrix_validation(report: dict, results: dict | None = None) -> None:
|
|
1189
|
+
a = report["in_matrix_not_bitable"]
|
|
1190
|
+
b = report["in_bitable_not_matrix"]
|
|
1191
|
+
c = report["deprecated_in_matrix"]
|
|
1192
|
+
d = report.get("layer_mismatches", [])
|
|
1193
|
+
parse_error = report.get("parse_error", "")
|
|
1194
|
+
missing_files = (results or {}).get("missing_files", [])
|
|
1195
|
+
missing_layered_files = (results or {}).get("missing_layered_files", [])
|
|
1196
|
+
invalid_layer_suites = (results or {}).get("invalid_layer_suites", [])
|
|
1197
|
+
by_layer = report["matrix_tcs_by_layer"]
|
|
1198
|
+
actual_by_layer = report.get("actual_tcs_by_layer", {})
|
|
1199
|
+
configured_layers = report.get("configured_layers", [])
|
|
1200
|
+
configured_layer_set = set(configured_layers)
|
|
1201
|
+
excluded_layered_layers = sorted(
|
|
1202
|
+
(
|
|
1203
|
+
set((results or {}).get("excluded_layered_layers", []))
|
|
1204
|
+
& (set(by_layer) & _MATRIX_ENFORCED_LAYERS)
|
|
1205
|
+
) - configured_layer_set
|
|
1206
|
+
)
|
|
1207
|
+
unverified_layers = report.get("unverified_layers", [])
|
|
1208
|
+
blocking_unverified_layers = unverified_layers if configured_layers else []
|
|
1209
|
+
layer_counts = ", ".join(f"{layer}={len(tcs)}" for layer, tcs in sorted(by_layer.items()))
|
|
1210
|
+
print(f"Matrix layers: {layer_counts or '(none parsed)'}")
|
|
1211
|
+
if parse_error:
|
|
1212
|
+
print(f"Matrix parse error: {parse_error}")
|
|
1213
|
+
if configured_layers:
|
|
1214
|
+
print(f"Configured suite layers: {', '.join(configured_layers)}")
|
|
1215
|
+
if unverified_layers:
|
|
1216
|
+
print(f"Unverified matrix layers: {', '.join(unverified_layers)} (no matching suite layer configured)")
|
|
1217
|
+
if actual_by_layer:
|
|
1218
|
+
actual_counts = ", ".join(f"{layer}={len(tcs)}" for layer, tcs in sorted(actual_by_layer.items()))
|
|
1219
|
+
print(f"Actual suite layers: {actual_counts}")
|
|
1220
|
+
elif configured_layers:
|
|
1221
|
+
print("Actual suite layers: (configured, but no linked TC evidence found)")
|
|
1222
|
+
else:
|
|
1223
|
+
print("Actual suite layers: (not configured; layer enforcement skipped)")
|
|
1224
|
+
if missing_files:
|
|
1225
|
+
print(f"Missing JUnit XML: {len(missing_files)} file(s)")
|
|
1226
|
+
if missing_layered_files:
|
|
1227
|
+
print(f"Missing layered JUnit XML: {len(missing_layered_files)} file(s)")
|
|
1228
|
+
if invalid_layer_suites:
|
|
1229
|
+
print(f"Invalid suite layer config: {len(invalid_layer_suites)} suite(s)")
|
|
1230
|
+
if excluded_layered_layers:
|
|
1231
|
+
print(f"Excluded matrix-gate layers still claimed in matrix: {', '.join(excluded_layered_layers)}")
|
|
1232
|
+
if not (a or b or c or d or parse_error or blocking_unverified_layers or missing_files or missing_layered_files or invalid_layer_suites or excluded_layered_layers):
|
|
1233
|
+
if unverified_layers:
|
|
1234
|
+
print("\n⚠ test-matrix.md and Bitable TC presence are in sync; layer coverage is unverified.")
|
|
1235
|
+
return
|
|
1236
|
+
print("\n✅ test-matrix.md and Bitable are in sync.")
|
|
1237
|
+
return
|
|
1238
|
+
print(f"\nDrift: +{len(a)} matrix-only, -{len(b)} missing from matrix, ~{len(c)} 废弃-in-matrix, !{len(d)} layer-mismatch")
|
|
1239
|
+
if parse_error:
|
|
1240
|
+
print("\n⚠ Matrix could not be parsed:")
|
|
1241
|
+
print(" Use canonical headers: unit, contract, integration, e2e, manual, blocked.")
|
|
1242
|
+
if a:
|
|
1243
|
+
print(f"\n⚠ In matrix but NOT in Bitable ({len(a)}):")
|
|
1244
|
+
print(" These TC IDs are claimed in the matrix but don't exist in Bitable.")
|
|
1245
|
+
print(" Either add to Bitable or remove from matrix.")
|
|
1246
|
+
for tc_id in a:
|
|
1247
|
+
print(f" {tc_id}")
|
|
1248
|
+
if b:
|
|
1249
|
+
print(f"\n⚠ Active in Bitable but NOT in matrix ({len(b)}):")
|
|
1250
|
+
print(" Pick a layer for each and add to the matrix.")
|
|
1251
|
+
for tc_id in b[:30]:
|
|
1252
|
+
print(f" {tc_id}")
|
|
1253
|
+
if len(b) > 30:
|
|
1254
|
+
print(f" ... and {len(b) - 30} more")
|
|
1255
|
+
if c:
|
|
1256
|
+
print(f"\n⚠ Listed in matrix but 废弃 in Bitable ({len(c)}):")
|
|
1257
|
+
print(" Remove from matrix (the TC is permanently retired).")
|
|
1258
|
+
for tc_id in c:
|
|
1259
|
+
print(f" {tc_id}")
|
|
1260
|
+
if d:
|
|
1261
|
+
print(f"\n⚠ Matrix layer claim not covered by configured suite layer ({len(d)}):")
|
|
1262
|
+
print(" Either move the TC to the actual layer, add suite layer metadata, add matching test coverage, or mark it manual/blocked with owner.")
|
|
1263
|
+
for item in d[:30]:
|
|
1264
|
+
actual = ", ".join(item["actual"]) if item["actual"] else "none"
|
|
1265
|
+
print(f" {item['tc_id']}: declared={item['declared']} actual={actual}")
|
|
1266
|
+
if len(d) > 30:
|
|
1267
|
+
print(f" ... and {len(d) - 30} more")
|
|
1268
|
+
if missing_layered_files:
|
|
1269
|
+
print(f"\n⚠ Configured layer suites did not produce JUnit XML ({len(missing_layered_files)}):")
|
|
1270
|
+
print(" Run suites first in the same CI job, fix the results_file path, or disable matrix fail mode.")
|
|
1271
|
+
for path in missing_layered_files[:10]:
|
|
1272
|
+
print(f" {path}")
|
|
1273
|
+
if len(missing_layered_files) > 10:
|
|
1274
|
+
print(f" ... and {len(missing_layered_files) - 10} more")
|
|
1275
|
+
elif missing_files:
|
|
1276
|
+
print(f"\n⚠ Configured suites did not produce JUnit XML ({len(missing_files)}):")
|
|
1277
|
+
print(" Run suites first in the same CI job, or fix the results_file path.")
|
|
1278
|
+
for path in missing_files[:10]:
|
|
1279
|
+
print(f" {path}")
|
|
1280
|
+
if len(missing_files) > 10:
|
|
1281
|
+
print(f" ... and {len(missing_files) - 10} more")
|
|
1282
|
+
if invalid_layer_suites:
|
|
1283
|
+
print(f"\n⚠ Invalid suite layer config ({len(invalid_layer_suites)}):")
|
|
1284
|
+
print(" Use unit, contract, integration, or e2e (suite aliases such as api/browser normalize).")
|
|
1285
|
+
for item in invalid_layer_suites[:10]:
|
|
1286
|
+
print(f" {item.get('suite')}: layer={item.get('layer')}")
|
|
1287
|
+
if len(invalid_layer_suites) > 10:
|
|
1288
|
+
print(f" ... and {len(invalid_layer_suites) - 10} more")
|
|
1289
|
+
if excluded_layered_layers:
|
|
1290
|
+
print("\n⚠ Matrix claims automated layers that are excluded from the matrix gate:")
|
|
1291
|
+
print(" Move those TCs to manual/blocked owner rows, or include the suite in the fast gate.")
|
|
1292
|
+
for layer in excluded_layered_layers:
|
|
1293
|
+
print(f" {layer}")
|
|
1294
|
+
|
|
1295
|
+
|
|
1296
|
+
def _matrix_validation_has_drift(report: dict) -> bool:
|
|
1297
|
+
return bool(
|
|
1298
|
+
report.get("in_matrix_not_bitable")
|
|
1299
|
+
or report.get("in_bitable_not_matrix")
|
|
1300
|
+
or report.get("deprecated_in_matrix")
|
|
1301
|
+
or report.get("layer_mismatches")
|
|
1302
|
+
or report.get("parse_error")
|
|
1303
|
+
)
|
|
1304
|
+
|
|
1305
|
+
|
|
1306
|
+
def _matrix_gate_should_fail(report: dict, results: dict) -> bool:
|
|
1307
|
+
missing_files = results.get("missing_files", [])
|
|
1308
|
+
missing_gate_layers = set(results.get("missing_layered_layers", []))
|
|
1309
|
+
invalid_layer_suites = results.get("invalid_layer_suites", [])
|
|
1310
|
+
configured_layers = set(report.get("configured_layers", []))
|
|
1311
|
+
excluded_claimed_layers = set(results.get("excluded_layered_layers", [])) & (
|
|
1312
|
+
set(report.get("matrix_tcs_by_layer", {})) & _MATRIX_ENFORCED_LAYERS
|
|
1313
|
+
) - configured_layers
|
|
1314
|
+
return bool(
|
|
1315
|
+
_matrix_validation_has_drift(report)
|
|
1316
|
+
or missing_files
|
|
1317
|
+
or (report.get("configured_layers") and report.get("unverified_layers"))
|
|
1318
|
+
or missing_gate_layers
|
|
1319
|
+
or invalid_layer_suites
|
|
1320
|
+
or excluded_claimed_layers
|
|
1321
|
+
)
|
|
1322
|
+
|
|
1323
|
+
|
|
1324
|
+
# ── md ↔ Bitable diff ─────────────────────────────────────────────────────────
|
|
1325
|
+
|
|
1326
|
+
# Fields synced between local md mirror and Bitable (definition fields only).
|
|
1327
|
+
# 状态/跟进人/信息流转 are Bitable-only — never expected in md.
|
|
1328
|
+
_MD_DEFINITION_FIELDS = ["模块", "功能点", "优先级", "测试层级", "测试类型", "前置条件", "操作步骤", "预期结果"]
|
|
1329
|
+
|
|
1330
|
+
|
|
1331
|
+
class MdTable(dict):
|
|
1332
|
+
"""Parsed md mirror rows plus the definition columns declared by its header."""
|
|
1333
|
+
|
|
1334
|
+
def __init__(self, *args, definition_fields_present: set[str] | None = None, **kwargs):
|
|
1335
|
+
super().__init__(*args, **kwargs)
|
|
1336
|
+
self.definition_fields_present = definition_fields_present
|
|
1337
|
+
|
|
1338
|
+
|
|
1339
|
+
def parse_md_table(path: str) -> dict[str, dict[str, str]]:
|
|
1340
|
+
"""Parse a Markdown table into {tc_id: {field: value}}.
|
|
1341
|
+
|
|
1342
|
+
Recognises the canonical header: | 用例ID | 模块 | 功能点 | 优先级 | 测试层级 | 测试类型 | 前置条件 | 操作步骤 | 预期结果 |
|
|
1343
|
+
(extra columns allowed; only present definition fields are kept, so older
|
|
1344
|
+
mirrors without 测试层级/测试类型 don't create false drift). Cells may contain
|
|
1345
|
+
escaped pipes (`\\|`) — those are unescaped. Multi-line cells use `<br>` or `\\n`
|
|
1346
|
+
inside the cell; line breaks across markdown table rows are not supported.
|
|
1347
|
+
|
|
1348
|
+
Returns empty dict and prints a warning when the file is missing or has no
|
|
1349
|
+
recognisable table.
|
|
1350
|
+
"""
|
|
1351
|
+
p = Path(path)
|
|
1352
|
+
if not p.exists():
|
|
1353
|
+
print(f" ⚠ md file not found: {path}", file=sys.stderr)
|
|
1354
|
+
return MdTable()
|
|
1355
|
+
text = p.read_text(encoding="utf-8")
|
|
1356
|
+
|
|
1357
|
+
header_cols: list[str] | None = None
|
|
1358
|
+
rows: dict[str, dict[str, str]] = {}
|
|
1359
|
+
for raw in text.splitlines():
|
|
1360
|
+
line = raw.strip()
|
|
1361
|
+
if not line.startswith("|") or not line.endswith("|"):
|
|
1362
|
+
continue
|
|
1363
|
+
# split cells; respect \| escape
|
|
1364
|
+
cells = _split_md_row(line)
|
|
1365
|
+
if header_cols is None:
|
|
1366
|
+
if "用例ID" in cells or "ID" in cells:
|
|
1367
|
+
header_cols = cells
|
|
1368
|
+
continue
|
|
1369
|
+
# separator row: |---|---|---|
|
|
1370
|
+
if all(set(c) <= {"-", ":", " "} for c in cells if c):
|
|
1371
|
+
continue
|
|
1372
|
+
if len(cells) < 2:
|
|
1373
|
+
continue
|
|
1374
|
+
row = dict(zip(header_cols, cells))
|
|
1375
|
+
tc_id = (row.get("用例ID") or row.get("ID") or "").strip()
|
|
1376
|
+
if not tc_id:
|
|
1377
|
+
continue
|
|
1378
|
+
fields: dict[str, str] = {}
|
|
1379
|
+
for field in _MD_DEFINITION_FIELDS:
|
|
1380
|
+
if field not in header_cols:
|
|
1381
|
+
continue
|
|
1382
|
+
v = row.get(field, "").strip()
|
|
1383
|
+
# normalise multi-line conventions: <br> / \n in cell → real \n
|
|
1384
|
+
v = v.replace("<br>", "\n").replace("\\n", "\n")
|
|
1385
|
+
fields[field] = v
|
|
1386
|
+
rows[tc_id] = fields
|
|
1387
|
+
if header_cols is None:
|
|
1388
|
+
print(f" ⚠ no markdown table header found in {path}", file=sys.stderr)
|
|
1389
|
+
return MdTable(rows)
|
|
1390
|
+
present = set(header_cols) & set(_MD_DEFINITION_FIELDS)
|
|
1391
|
+
return MdTable(rows, definition_fields_present=present)
|
|
1392
|
+
|
|
1393
|
+
|
|
1394
|
+
def _split_md_row(line: str) -> list[str]:
|
|
1395
|
+
"""Split a markdown table row by `|`, honoring `\\|` escapes."""
|
|
1396
|
+
# remove outer pipes
|
|
1397
|
+
body = line.strip()
|
|
1398
|
+
if body.startswith("|"): body = body[1:]
|
|
1399
|
+
if body.endswith("|"): body = body[:-1]
|
|
1400
|
+
# split on un-escaped |
|
|
1401
|
+
cells: list[str] = []
|
|
1402
|
+
cur = ""
|
|
1403
|
+
i = 0
|
|
1404
|
+
while i < len(body):
|
|
1405
|
+
ch = body[i]
|
|
1406
|
+
if ch == "\\" and i + 1 < len(body) and body[i + 1] == "|":
|
|
1407
|
+
cur += "|"
|
|
1408
|
+
i += 2
|
|
1409
|
+
continue
|
|
1410
|
+
if ch == "|":
|
|
1411
|
+
cells.append(cur.strip())
|
|
1412
|
+
cur = ""
|
|
1413
|
+
else:
|
|
1414
|
+
cur += ch
|
|
1415
|
+
i += 1
|
|
1416
|
+
cells.append(cur.strip())
|
|
1417
|
+
return cells
|
|
1418
|
+
|
|
1419
|
+
|
|
1420
|
+
def _is_md_separator_row(line: str) -> bool:
|
|
1421
|
+
stripped = line.strip()
|
|
1422
|
+
if not (stripped.startswith("|") and stripped.endswith("|")):
|
|
1423
|
+
return False
|
|
1424
|
+
cells = _split_md_row(stripped)
|
|
1425
|
+
return bool(cells) and all(set(c) <= {"-", ":", " "} for c in cells if c)
|
|
1426
|
+
|
|
1427
|
+
|
|
1428
|
+
def _record_to_definition(rec: dict) -> dict[str, str]:
|
|
1429
|
+
"""Extract Bitable record's definition fields (for diff)."""
|
|
1430
|
+
f = rec.get("fields", {})
|
|
1431
|
+
out: dict[str, str] = {}
|
|
1432
|
+
for field in _MD_DEFINITION_FIELDS:
|
|
1433
|
+
v = _str(f.get(field, ""))
|
|
1434
|
+
# Bitable text fields sometimes return list-of-segment objects; _str flattens
|
|
1435
|
+
out[field] = v.strip()
|
|
1436
|
+
return out
|
|
1437
|
+
|
|
1438
|
+
|
|
1439
|
+
def diff_md_vs_bitable(md: dict[str, dict[str, str]],
|
|
1440
|
+
records: list[dict]) -> dict:
|
|
1441
|
+
"""Compute structural diff between local md mirror and Bitable definition fields.
|
|
1442
|
+
|
|
1443
|
+
Returns:
|
|
1444
|
+
{
|
|
1445
|
+
"added": [{tc_id, fields}], # in md, not in Bitable
|
|
1446
|
+
"removed": [{tc_id, fields}], # in Bitable, not in md (active records only)
|
|
1447
|
+
"changed": [{tc_id, field, md, bitable}]
|
|
1448
|
+
}
|
|
1449
|
+
Deprecated (废弃) Bitable records are excluded from "removed" — they intentionally
|
|
1450
|
+
stay in Bitable but may not be in md.
|
|
1451
|
+
"""
|
|
1452
|
+
bitable: dict[str, dict[str, str]] = {}
|
|
1453
|
+
deprecated_ids: set[str] = set()
|
|
1454
|
+
for rec in records:
|
|
1455
|
+
tc_id = _str(rec["fields"].get("用例ID", ""))
|
|
1456
|
+
if not tc_id:
|
|
1457
|
+
continue
|
|
1458
|
+
status = _str(rec["fields"].get("状态", ""))
|
|
1459
|
+
if status == "废弃":
|
|
1460
|
+
deprecated_ids.add(tc_id)
|
|
1461
|
+
bitable[tc_id] = _record_to_definition(rec)
|
|
1462
|
+
|
|
1463
|
+
md_ids = set(md.keys())
|
|
1464
|
+
bitable_ids = set(bitable.keys())
|
|
1465
|
+
|
|
1466
|
+
added = []
|
|
1467
|
+
for tc_id in sorted(md_ids - bitable_ids):
|
|
1468
|
+
added.append({"tc_id": tc_id, "fields": md[tc_id]})
|
|
1469
|
+
|
|
1470
|
+
removed = []
|
|
1471
|
+
for tc_id in sorted(bitable_ids - md_ids - deprecated_ids):
|
|
1472
|
+
removed.append({"tc_id": tc_id, "fields": bitable[tc_id]})
|
|
1473
|
+
|
|
1474
|
+
changed = []
|
|
1475
|
+
md_fields_present = getattr(md, "definition_fields_present", None)
|
|
1476
|
+
for tc_id in sorted(md_ids & bitable_ids):
|
|
1477
|
+
if tc_id in deprecated_ids:
|
|
1478
|
+
continue # 废弃 records: md mirror may legitimately differ
|
|
1479
|
+
for field in _MD_DEFINITION_FIELDS:
|
|
1480
|
+
if field not in md[tc_id]:
|
|
1481
|
+
if md_fields_present is not None and field not in md_fields_present:
|
|
1482
|
+
continue # older md mirrors have not opted into this definition column yet
|
|
1483
|
+
mv = md[tc_id].get(field, "")
|
|
1484
|
+
bv = bitable[tc_id].get(field, "")
|
|
1485
|
+
if _normalize_cell(mv) != _normalize_cell(bv):
|
|
1486
|
+
changed.append({"tc_id": tc_id, "field": field, "md": mv, "bitable": bv})
|
|
1487
|
+
return {"added": added, "removed": removed, "changed": changed}
|
|
1488
|
+
|
|
1489
|
+
|
|
1490
|
+
def _normalize_cell(s: str) -> str:
|
|
1491
|
+
"""Normalize whitespace for diff comparison (ignore trailing/leading; collapse runs)."""
|
|
1492
|
+
return " ".join(s.split())
|
|
1493
|
+
|
|
1494
|
+
|
|
1495
|
+
def print_diff_report(diff: dict) -> None:
|
|
1496
|
+
a = diff["added"]; r = diff["removed"]; c = diff["changed"]
|
|
1497
|
+
if not (a or r or c):
|
|
1498
|
+
print("✅ md and Bitable are in sync (definition fields).")
|
|
1499
|
+
return
|
|
1500
|
+
print(f"md ↔ Bitable diff: +{len(a)} added, -{len(r)} removed, ~{len(c)} changed")
|
|
1501
|
+
if a:
|
|
1502
|
+
print(f"\n+ ADDED in md, missing in Bitable ({len(a)}):")
|
|
1503
|
+
print(" Create these records via lark-cli base +record-upsert / batch-create.")
|
|
1504
|
+
for item in a:
|
|
1505
|
+
print(f" {item['tc_id']} ({item['fields'].get('模块', '')} / {item['fields'].get('功能点', '')})")
|
|
1506
|
+
if r:
|
|
1507
|
+
print(f"\n- REMOVED from md, still active in Bitable ({len(r)}):")
|
|
1508
|
+
print(" Either re-add to md (md drifted), mark Bitable record 废弃, or delete.")
|
|
1509
|
+
for item in r:
|
|
1510
|
+
print(f" {item['tc_id']} ({item['fields'].get('模块', '')} / {item['fields'].get('功能点', '')})")
|
|
1511
|
+
if c:
|
|
1512
|
+
print(f"\n~ CHANGED field values ({len(c)}):")
|
|
1513
|
+
for item in c:
|
|
1514
|
+
md_short = _normalize_cell(item['md'])[:60]
|
|
1515
|
+
bt_short = _normalize_cell(item['bitable'])[:60]
|
|
1516
|
+
print(f" {item['tc_id']} [{item['field']}]")
|
|
1517
|
+
print(f" md : {md_short}")
|
|
1518
|
+
print(f" Bitable : {bt_short}")
|
|
1519
|
+
|
|
1520
|
+
|
|
1521
|
+
# ── Inventory dump (for iteration delta workflow) ─────────────────────────────
|
|
1522
|
+
|
|
1523
|
+
def print_inventory(records: list[dict], as_md: bool = False) -> None:
|
|
1524
|
+
"""Print current Bitable TC inventory grouped by 模块.
|
|
1525
|
+
|
|
1526
|
+
Two formats:
|
|
1527
|
+
- default: human-readable list (per module: 用例ID 功能点 [优先级])
|
|
1528
|
+
- --inventory-md: full md table (compatible with --diff-md)
|
|
1529
|
+
|
|
1530
|
+
Deprecated records are listed in a separate section so iteration planning
|
|
1531
|
+
can see what was retired without conflating with active TCs.
|
|
1532
|
+
"""
|
|
1533
|
+
active: dict[str, list[dict]] = defaultdict(list)
|
|
1534
|
+
deprecated: dict[str, list[dict]] = defaultdict(list)
|
|
1535
|
+
for rec in records:
|
|
1536
|
+
f = rec.get("fields", {})
|
|
1537
|
+
case_id = _str(f.get("用例ID", ""))
|
|
1538
|
+
if not case_id:
|
|
1539
|
+
continue
|
|
1540
|
+
module = _str(f.get("模块", "(未分模块)")) or "(未分模块)"
|
|
1541
|
+
status = _str(f.get("状态", ""))
|
|
1542
|
+
bucket = deprecated if status == "废弃" else active
|
|
1543
|
+
bucket[module].append(rec)
|
|
1544
|
+
|
|
1545
|
+
if as_md:
|
|
1546
|
+
# md table format (matches parse_md_table expected schema)
|
|
1547
|
+
print("| 用例ID | 模块 | 功能点 | 优先级 | 测试层级 | 测试类型 | 前置条件 | 操作步骤 | 预期结果 |")
|
|
1548
|
+
print("|---|---|---|---|---|---|---|---|---|")
|
|
1549
|
+
rows = []
|
|
1550
|
+
for module in sorted(active):
|
|
1551
|
+
for rec in active[module]:
|
|
1552
|
+
f = rec["fields"]
|
|
1553
|
+
row = [
|
|
1554
|
+
_md_cell(_str(f.get("用例ID", ""))),
|
|
1555
|
+
_md_cell(module),
|
|
1556
|
+
_md_cell(_str(f.get("功能点", ""))),
|
|
1557
|
+
_md_cell(_str(f.get("优先级", ""))),
|
|
1558
|
+
_md_cell(_str(f.get("测试层级", ""))),
|
|
1559
|
+
_md_cell(_str(f.get("测试类型", ""))),
|
|
1560
|
+
_md_cell(_str(f.get("前置条件", ""))),
|
|
1561
|
+
_md_cell(_str(f.get("操作步骤", ""))),
|
|
1562
|
+
_md_cell(_str(f.get("预期结果", ""))),
|
|
1563
|
+
]
|
|
1564
|
+
rows.append("| " + " | ".join(row) + " |")
|
|
1565
|
+
rows.sort()
|
|
1566
|
+
for r in rows:
|
|
1567
|
+
print(r)
|
|
1568
|
+
return
|
|
1569
|
+
|
|
1570
|
+
total = sum(len(v) for v in active.values())
|
|
1571
|
+
dep_total = sum(len(v) for v in deprecated.values())
|
|
1572
|
+
print(f"# TC Inventory (active: {total}, 废弃: {dep_total})")
|
|
1573
|
+
print()
|
|
1574
|
+
for module in sorted(active):
|
|
1575
|
+
recs = sorted(active[module], key=lambda r: _str(r["fields"].get("用例ID", "")))
|
|
1576
|
+
print(f"## {module} ({len(recs)} cases)")
|
|
1577
|
+
for rec in recs:
|
|
1578
|
+
f = rec["fields"]
|
|
1579
|
+
case_id = _str(f.get("用例ID", ""))
|
|
1580
|
+
feature = _str(f.get("功能点", ""))
|
|
1581
|
+
priority = _str(f.get("优先级", ""))
|
|
1582
|
+
status = _str(f.get("状态", "")) or "未测试"
|
|
1583
|
+
print(f" {case_id} [{priority}] [{status}] {feature}")
|
|
1584
|
+
print()
|
|
1585
|
+
if deprecated:
|
|
1586
|
+
print("## 废弃记录(不参与统计;新迭代规划时排除)")
|
|
1587
|
+
for module in sorted(deprecated):
|
|
1588
|
+
for rec in deprecated[module]:
|
|
1589
|
+
f = rec["fields"]
|
|
1590
|
+
print(f" {_str(f.get('用例ID', ''))} [{module}] {_str(f.get('功能点', ''))}")
|
|
1591
|
+
|
|
1592
|
+
|
|
1593
|
+
# ── Next-ID hint (collision avoidance) ───────────────────────────────────────
|
|
1594
|
+
|
|
1595
|
+
def print_next_ids(records: list[dict]) -> None:
|
|
1596
|
+
"""Print next available TC sequence per module, so parallel devs avoid
|
|
1597
|
+
picking the same TC-XX-NNN. Format: TC-{module_abbr}-{NNN}."""
|
|
1598
|
+
pattern = re.compile(r"^TC-([A-Z]+)-(\d+)$")
|
|
1599
|
+
max_seq: dict[str, int] = {}
|
|
1600
|
+
bad: list[str] = []
|
|
1601
|
+
for rec in records:
|
|
1602
|
+
tc_id = _str(rec["fields"].get("用例ID", ""))
|
|
1603
|
+
m = pattern.match(tc_id)
|
|
1604
|
+
if not m:
|
|
1605
|
+
if tc_id:
|
|
1606
|
+
bad.append(tc_id)
|
|
1607
|
+
continue
|
|
1608
|
+
module, seq = m.group(1), int(m.group(2))
|
|
1609
|
+
if seq > max_seq.get(module, 0):
|
|
1610
|
+
max_seq[module] = seq
|
|
1611
|
+
if not max_seq:
|
|
1612
|
+
print("No TC IDs found matching TC-{MODULE}-{NNN} format.")
|
|
1613
|
+
return
|
|
1614
|
+
print("Next available TC ID per module (use these when creating new TCs):")
|
|
1615
|
+
for module in sorted(max_seq):
|
|
1616
|
+
next_seq = max_seq[module] + 1
|
|
1617
|
+
print(f" TC-{module}-{next_seq:03d} (current max: TC-{module}-{max_seq[module]:03d})")
|
|
1618
|
+
if bad:
|
|
1619
|
+
print("\n⚠ TC IDs not matching the canonical TC-{MODULE}-{NNN} format:")
|
|
1620
|
+
for tc_id in bad[:10]:
|
|
1621
|
+
print(f" {tc_id}")
|
|
1622
|
+
if len(bad) > 10:
|
|
1623
|
+
print(f" ... and {len(bad) - 10} more")
|
|
1624
|
+
|
|
1625
|
+
|
|
1626
|
+
# ── Orphan detection ──────────────────────────────────────────────────────────
|
|
1627
|
+
|
|
1628
|
+
def detect_orphans(records: list[dict], results: dict[str, str]) -> dict:
|
|
1629
|
+
"""
|
|
1630
|
+
Compare TC IDs found in test results against Bitable records.
|
|
1631
|
+
Returns:
|
|
1632
|
+
deprecated_in_tests: TC IDs in test results that are marked 废弃 in Bitable
|
|
1633
|
+
missing_in_bitable: TC IDs in test results not registered in Bitable at all
|
|
1634
|
+
Next step is human judgment:
|
|
1635
|
+
- Only prompt for deletion when the underlying code/business is also removed.
|
|
1636
|
+
- If the code is still active, take no action by default.
|
|
1637
|
+
"""
|
|
1638
|
+
bitable_status: dict[str, str] = {}
|
|
1639
|
+
for rec in records:
|
|
1640
|
+
tc_id = _str(rec["fields"].get("用例ID", ""))
|
|
1641
|
+
if tc_id:
|
|
1642
|
+
bitable_status[tc_id] = _str(rec["fields"].get("状态", "未测试")) or "未测试"
|
|
1643
|
+
|
|
1644
|
+
deprecated = sorted(
|
|
1645
|
+
tc_id for tc_id in results if bitable_status.get(tc_id) == "废弃"
|
|
1646
|
+
)
|
|
1647
|
+
missing = sorted(
|
|
1648
|
+
tc_id for tc_id in results if tc_id not in bitable_status
|
|
1649
|
+
)
|
|
1650
|
+
return {"deprecated_in_tests": deprecated, "missing_in_bitable": missing}
|
|
1651
|
+
|
|
1652
|
+
|
|
1653
|
+
def print_orphan_report(orphans: dict) -> None:
|
|
1654
|
+
deprecated = orphans["deprecated_in_tests"]
|
|
1655
|
+
missing = orphans["missing_in_bitable"]
|
|
1656
|
+
if not deprecated and not missing:
|
|
1657
|
+
print("✅ No orphan TC IDs found in test results.")
|
|
1658
|
+
return
|
|
1659
|
+
if deprecated:
|
|
1660
|
+
print(f"\n⚠ Tests with DEPRECATED TC IDs ({len(deprecated)}):")
|
|
1661
|
+
print(" Check if underlying code is also removed.")
|
|
1662
|
+
print(" If code is gone → delete the test in the same commit.")
|
|
1663
|
+
print(" If code still exists → no action needed by default.")
|
|
1664
|
+
for tc_id in deprecated:
|
|
1665
|
+
print(f" {tc_id}")
|
|
1666
|
+
if missing:
|
|
1667
|
+
print(f"\n⚠ Tests with TC IDs NOT in Bitable ({len(missing)}):")
|
|
1668
|
+
print(" These TC IDs are in test code but not registered in Bitable.")
|
|
1669
|
+
print(" Register the TC or remove the ID from the test function name.")
|
|
1670
|
+
for tc_id in missing:
|
|
1671
|
+
print(f" {tc_id}")
|
|
1672
|
+
|
|
1673
|
+
|
|
1674
|
+
# ── Aggregation ────────────────────────────────────────────────────────────────
|
|
1675
|
+
|
|
1676
|
+
RELEASE_BLOCKING = {"失败", "阻塞"}
|
|
1677
|
+
|
|
1678
|
+
|
|
1679
|
+
def aggregate(records: list[dict]) -> dict:
|
|
1680
|
+
# 废弃记录不参与统计 — 计数但不计入分母
|
|
1681
|
+
deprecated_count = sum(
|
|
1682
|
+
1 for r in records
|
|
1683
|
+
if (_str(r.get("fields", {}).get("状态", "")) == "废弃")
|
|
1684
|
+
)
|
|
1685
|
+
active = [r for r in records if _str(r.get("fields", {}).get("状态", "")) != "废弃"]
|
|
1686
|
+
total = len(active)
|
|
1687
|
+
by_status: dict[str, int] = defaultdict(int)
|
|
1688
|
+
by_module: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
|
1689
|
+
by_layer: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
|
1690
|
+
by_test_type: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
|
1691
|
+
p0_total = 0
|
|
1692
|
+
p0_pass = 0
|
|
1693
|
+
failures: list[dict] = []
|
|
1694
|
+
p0_blocking: list[dict] = []
|
|
1695
|
+
|
|
1696
|
+
for rec in active:
|
|
1697
|
+
f = rec.get("fields", {})
|
|
1698
|
+
status = _str(f.get("状态", "未测试")) or "未测试"
|
|
1699
|
+
module = _str(f.get("模块", "(未分模块)")) or "(未分模块)"
|
|
1700
|
+
layer = _str(f.get("测试层级", "")).strip()
|
|
1701
|
+
test_type = _str(f.get("测试类型", "")).strip()
|
|
1702
|
+
priority = _str(f.get("优先级", ""))
|
|
1703
|
+
case_id = _str(f.get("用例ID", ""))
|
|
1704
|
+
feature = _str(f.get("功能点", ""))
|
|
1705
|
+
info = _str(f.get("信息流转", ""))
|
|
1706
|
+
last_info = info.strip().split("\n")[-1] if info.strip() else ""
|
|
1707
|
+
|
|
1708
|
+
by_status[status] += 1
|
|
1709
|
+
by_module[module][status] += 1
|
|
1710
|
+
by_module[module]["_total"] = by_module[module].get("_total", 0) + 1
|
|
1711
|
+
if layer:
|
|
1712
|
+
by_layer[layer][status] += 1
|
|
1713
|
+
by_layer[layer]["_total"] = by_layer[layer].get("_total", 0) + 1
|
|
1714
|
+
if test_type:
|
|
1715
|
+
by_test_type[test_type][status] += 1
|
|
1716
|
+
by_test_type[test_type]["_total"] = by_test_type[test_type].get("_total", 0) + 1
|
|
1717
|
+
|
|
1718
|
+
if priority == "P0":
|
|
1719
|
+
p0_total += 1
|
|
1720
|
+
if status == "通过":
|
|
1721
|
+
p0_pass += 1
|
|
1722
|
+
|
|
1723
|
+
if status in RELEASE_BLOCKING:
|
|
1724
|
+
entry = {"id": case_id, "module": module, "feature": feature,
|
|
1725
|
+
"status": status, "last_info": last_info, "priority": priority}
|
|
1726
|
+
failures.append(entry)
|
|
1727
|
+
if priority == "P0":
|
|
1728
|
+
p0_blocking.append(entry)
|
|
1729
|
+
|
|
1730
|
+
tested = sum(by_status[s] for s in ["通过", "失败", "阻塞"])
|
|
1731
|
+
pass_rate = f"{by_status['通过'] / tested * 100:.1f}%" if tested else "N/A"
|
|
1732
|
+
p0_pass_rate = f"{p0_pass / p0_total * 100:.1f}%" if p0_total else "N/A"
|
|
1733
|
+
|
|
1734
|
+
return {
|
|
1735
|
+
"total": total,
|
|
1736
|
+
"deprecated_count": deprecated_count,
|
|
1737
|
+
"by_status": dict(by_status),
|
|
1738
|
+
"by_module": {m: dict(v) for m, v in by_module.items()},
|
|
1739
|
+
"by_layer": {m: dict(v) for m, v in by_layer.items()},
|
|
1740
|
+
"by_test_type": {m: dict(v) for m, v in by_test_type.items()},
|
|
1741
|
+
"pass_rate": pass_rate,
|
|
1742
|
+
"p0_total": p0_total,
|
|
1743
|
+
"p0_pass": p0_pass,
|
|
1744
|
+
"p0_pass_rate": p0_pass_rate,
|
|
1745
|
+
"failures": failures,
|
|
1746
|
+
"p0_blocking": p0_blocking,
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
|
|
1750
|
+
# ── Markdown generation ────────────────────────────────────────────────────────
|
|
1751
|
+
|
|
1752
|
+
def _untracked_section(untracked: list[dict]) -> list[str]:
|
|
1753
|
+
"""Build a Markdown section summarizing tests that ran but have no TC link.
|
|
1754
|
+
Returns an empty list when there are no untracked tests."""
|
|
1755
|
+
if not untracked:
|
|
1756
|
+
return []
|
|
1757
|
+
counts: dict[str, int] = defaultdict(int)
|
|
1758
|
+
for u in untracked:
|
|
1759
|
+
counts[u["status"]] += 1
|
|
1760
|
+
total = len(untracked)
|
|
1761
|
+
by_suite: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
|
1762
|
+
for u in untracked:
|
|
1763
|
+
s = u.get("suite", "(未分套件)")
|
|
1764
|
+
by_suite[s][u["status"]] += 1
|
|
1765
|
+
by_suite[s]["_total"] += 1
|
|
1766
|
+
|
|
1767
|
+
lines = [
|
|
1768
|
+
"## 未链接 TC 的测试",
|
|
1769
|
+
"",
|
|
1770
|
+
"说明:这些测试运行但未通过 `tc(\"TC-XX-NNN\")` 关联任何 TC,"
|
|
1771
|
+
"常见于单元测试、边界检查、内部不变式 — 属于代码层面质量信号,不计入 Bitable 统计。",
|
|
1772
|
+
"",
|
|
1773
|
+
"| 指标 | 数量 |",
|
|
1774
|
+
"|---|---|",
|
|
1775
|
+
f"| 总数 | {total} |",
|
|
1776
|
+
f"| 通过 | {counts.get('通过', 0)} |",
|
|
1777
|
+
f"| 失败 | {counts.get('失败', 0)} |",
|
|
1778
|
+
f"| 阻塞 | {counts.get('阻塞', 0)} |",
|
|
1779
|
+
f"| 跳过 | {counts.get('跳过', 0)} |",
|
|
1780
|
+
"",
|
|
1781
|
+
]
|
|
1782
|
+
if any(counts.get(s, 0) for s in ("失败", "阻塞")):
|
|
1783
|
+
lines += [
|
|
1784
|
+
"### 未链接 TC 的失败 / 阻塞",
|
|
1785
|
+
"",
|
|
1786
|
+
"| 套件 | 测试 | 状态 |",
|
|
1787
|
+
"|---|---|---|",
|
|
1788
|
+
]
|
|
1789
|
+
for u in untracked:
|
|
1790
|
+
if u["status"] in ("失败", "阻塞"):
|
|
1791
|
+
test_id = (u.get("classname", "") + "::" if u.get("classname") else "") + u.get("name", "")
|
|
1792
|
+
lines.append(f"| {_md_cell(u.get('suite', ''))} | {_md_cell(test_id)} | {u['status']} |")
|
|
1793
|
+
lines.append("")
|
|
1794
|
+
return lines
|
|
1795
|
+
|
|
1796
|
+
|
|
1797
|
+
def build_pr_summary(results: dict, coverage: dict | None,
|
|
1798
|
+
last_run_diff: dict | None) -> str:
|
|
1799
|
+
"""Build a short Markdown summary suitable for a PR / MR comment.
|
|
1800
|
+
Format: 1-line overview + vs-last-run delta. No Bitable fields needed."""
|
|
1801
|
+
linked = results.get("linked", {})
|
|
1802
|
+
untracked = results.get("untracked", [])
|
|
1803
|
+
missing_files = results.get("missing_files", [])
|
|
1804
|
+
counts: dict[str, int] = defaultdict(int)
|
|
1805
|
+
for s in linked.values():
|
|
1806
|
+
counts[s] += 1
|
|
1807
|
+
for u in untracked:
|
|
1808
|
+
counts[u["status"]] += 1
|
|
1809
|
+
total = sum(counts.values())
|
|
1810
|
+
passed = counts.get("通过", 0)
|
|
1811
|
+
failed = counts.get("失败", 0)
|
|
1812
|
+
blocked = counts.get("阻塞", 0)
|
|
1813
|
+
skipped = counts.get("跳过", 0)
|
|
1814
|
+
|
|
1815
|
+
lines = ["### 🧪 自动化测试结果", ""]
|
|
1816
|
+
|
|
1817
|
+
# Infra-failure banner FIRST — otherwise "总 0" misleads on red CI where
|
|
1818
|
+
# JUnit XML never got written.
|
|
1819
|
+
if missing_files:
|
|
1820
|
+
lines += [
|
|
1821
|
+
f"**🚨 基础设施失败**:{len(missing_files)} 个测试套件未产出 JUnit XML(编译失败 / runner 异常 / 路径错?)",
|
|
1822
|
+
"",
|
|
1823
|
+
"未产出 XML 的套件:",
|
|
1824
|
+
]
|
|
1825
|
+
for path in missing_files[:5]:
|
|
1826
|
+
lines.append(f"- `{path}`")
|
|
1827
|
+
if len(missing_files) > 5:
|
|
1828
|
+
lines.append(f"- ... 另外 {len(missing_files) - 5} 个")
|
|
1829
|
+
lines += ["", "下方计数仅反映 *已产出* XML 的套件,不代表全量。", ""]
|
|
1830
|
+
|
|
1831
|
+
parts = [f"通过 **{passed}** / 总 **{total}**"]
|
|
1832
|
+
if failed: parts.append(f"❌ 失败 **{failed}**")
|
|
1833
|
+
if blocked: parts.append(f"⚠ 阻塞 **{blocked}**")
|
|
1834
|
+
if skipped: parts.append(f"⏭ 跳过 {skipped}")
|
|
1835
|
+
if coverage:
|
|
1836
|
+
r = coverage.get("rate", 0) * 100
|
|
1837
|
+
if "covered" in coverage:
|
|
1838
|
+
parts.append(f"📊 覆盖率 {r:.1f}% ({coverage['covered']}/{coverage['valid']})")
|
|
1839
|
+
else:
|
|
1840
|
+
parts.append(f"📊 覆盖率 {r:.1f}%")
|
|
1841
|
+
lines.append(" · ".join(parts))
|
|
1842
|
+
lines.append("")
|
|
1843
|
+
|
|
1844
|
+
if last_run_diff and last_run_diff.get("had_previous"):
|
|
1845
|
+
nf = last_run_diff["new_failures"]
|
|
1846
|
+
fx = last_run_diff["fixed"]
|
|
1847
|
+
if nf:
|
|
1848
|
+
lines += [f"**🔴 本次新增失败 / 阻塞({len(nf)})**:", ""]
|
|
1849
|
+
for tc_id in nf[:20]:
|
|
1850
|
+
lines.append(f"- `{tc_id}`")
|
|
1851
|
+
if len(nf) > 20:
|
|
1852
|
+
lines.append(f"- ... 另外 {len(nf) - 20} 个")
|
|
1853
|
+
lines.append("")
|
|
1854
|
+
if fx:
|
|
1855
|
+
lines += [f"**🟢 本次修复({len(fx)})**:", ""]
|
|
1856
|
+
for tc_id in fx[:10]:
|
|
1857
|
+
lines.append(f"- `{tc_id}`")
|
|
1858
|
+
if len(fx) > 10:
|
|
1859
|
+
lines.append(f"- ... 另外 {len(fx) - 10} 个")
|
|
1860
|
+
lines.append("")
|
|
1861
|
+
if not (nf or fx):
|
|
1862
|
+
lines.append("✅ 相比上次运行,无新增失败或修复。")
|
|
1863
|
+
lines.append("")
|
|
1864
|
+
|
|
1865
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
1866
|
+
|
|
1867
|
+
|
|
1868
|
+
def build_minimal_markdown(results: dict, author: str, source: str, version: str,
|
|
1869
|
+
coverage: dict | None = None,
|
|
1870
|
+
last_run_diff: dict | None = None) -> str:
|
|
1871
|
+
"""Minimal report for projects with no Bitable / no TC management.
|
|
1872
|
+
Summarizes JUnit XML directly: per-suite counts, optional test-type counts,
|
|
1873
|
+
and failure details."""
|
|
1874
|
+
now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
|
1875
|
+
linked = results["linked"]
|
|
1876
|
+
untracked = results["untracked"]
|
|
1877
|
+
# Linked tests are treated as untracked here (no Bitable to look up modules).
|
|
1878
|
+
all_tests = list(untracked)
|
|
1879
|
+
for tc_id, status in linked.items():
|
|
1880
|
+
all_tests.append({"name": tc_id, "classname": "(TC-linked)",
|
|
1881
|
+
"status": status, "suite": "(TC-linked)"})
|
|
1882
|
+
|
|
1883
|
+
counts: dict[str, int] = defaultdict(int)
|
|
1884
|
+
by_suite: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
|
1885
|
+
for u in all_tests:
|
|
1886
|
+
counts[u["status"]] += 1
|
|
1887
|
+
s = u.get("suite", "(未分套件)")
|
|
1888
|
+
by_suite[s][u["status"]] += 1
|
|
1889
|
+
by_suite[s]["_total"] += 1
|
|
1890
|
+
|
|
1891
|
+
total = len(all_tests)
|
|
1892
|
+
executed = counts.get("通过", 0) + counts.get("失败", 0) + counts.get("阻塞", 0)
|
|
1893
|
+
pass_rate = f"{counts.get('通过', 0) / executed * 100:.1f}%" if executed else "N/A"
|
|
1894
|
+
|
|
1895
|
+
ver_str = f" | 版本: {version}" if version else ""
|
|
1896
|
+
lines = [
|
|
1897
|
+
"# 自动化测试报告",
|
|
1898
|
+
"",
|
|
1899
|
+
f"> 执行人: {author} | 来源: {source}{ver_str} | 时间: {now}",
|
|
1900
|
+
"",
|
|
1901
|
+
"## 总览",
|
|
1902
|
+
"",
|
|
1903
|
+
"| 指标 | 数量 |",
|
|
1904
|
+
"|---|---|",
|
|
1905
|
+
f"| 测试总数 | {total} |",
|
|
1906
|
+
f"| 通过 | {counts.get('通过', 0)} |",
|
|
1907
|
+
f"| 失败 | {counts.get('失败', 0)} |",
|
|
1908
|
+
f"| 阻塞 | {counts.get('阻塞', 0)} |",
|
|
1909
|
+
f"| 跳过 | {counts.get('跳过', 0)} |",
|
|
1910
|
+
f"| **通过率(已执行)** | **{pass_rate}** |",
|
|
1911
|
+
]
|
|
1912
|
+
lines += _coverage_row(coverage)
|
|
1913
|
+
lines.append("")
|
|
1914
|
+
lines += _last_run_section(last_run_diff)
|
|
1915
|
+
lines += [
|
|
1916
|
+
"## 按套件统计",
|
|
1917
|
+
"",
|
|
1918
|
+
"| 套件 | 总数 | 通过 | 失败 | 阻塞 | 跳过 | 通过率 |",
|
|
1919
|
+
"|---|---|---|---|---|---|---|",
|
|
1920
|
+
]
|
|
1921
|
+
for suite, c in sorted(by_suite.items()):
|
|
1922
|
+
t = c.get("_total", 0)
|
|
1923
|
+
passed = c.get("通过", 0)
|
|
1924
|
+
failed = c.get("失败", 0)
|
|
1925
|
+
blocked = c.get("阻塞", 0)
|
|
1926
|
+
skipped = c.get("跳过", 0)
|
|
1927
|
+
ex = passed + failed + blocked
|
|
1928
|
+
rate = f"{passed / ex * 100:.0f}%" if ex else "—"
|
|
1929
|
+
lines.append(f"| {_md_cell(suite)} | {t} | {passed} | {failed} | {blocked} | {skipped} | {rate} |")
|
|
1930
|
+
lines.append("")
|
|
1931
|
+
|
|
1932
|
+
if results.get("by_test_type"):
|
|
1933
|
+
lines += _dimension_section("## 按测试类型统计", "测试类型", results["by_test_type"])
|
|
1934
|
+
|
|
1935
|
+
failures = [u for u in all_tests if u["status"] in ("失败", "阻塞")]
|
|
1936
|
+
if failures:
|
|
1937
|
+
lines += [
|
|
1938
|
+
"## 失败 / 阻塞明细",
|
|
1939
|
+
"",
|
|
1940
|
+
"| 套件 | 测试 | 状态 |",
|
|
1941
|
+
"|---|---|---|",
|
|
1942
|
+
]
|
|
1943
|
+
for u in failures:
|
|
1944
|
+
test_id = (u.get("classname", "") + "::" if u.get("classname") else "") + u.get("name", "")
|
|
1945
|
+
lines.append(f"| {_md_cell(u.get('suite', ''))} | {_md_cell(test_id)} | {u['status']} |")
|
|
1946
|
+
lines.append("")
|
|
1947
|
+
|
|
1948
|
+
lines += ["## 发布建议", ""]
|
|
1949
|
+
if counts.get("失败", 0) or counts.get("阻塞", 0):
|
|
1950
|
+
lines.append(f"**⚠️ {counts.get('失败', 0)} 失败 + {counts.get('阻塞', 0)} 阻塞** — 需先解决再发布。")
|
|
1951
|
+
elif executed == 0:
|
|
1952
|
+
lines.append("**未执行测试** — 无法判断。")
|
|
1953
|
+
else:
|
|
1954
|
+
lines.append("**✅ 全部通过** — 可发布。")
|
|
1955
|
+
lines.append("")
|
|
1956
|
+
return "\n".join(lines)
|
|
1957
|
+
|
|
1958
|
+
|
|
1959
|
+
def _residual_risk_section(agg: dict, untracked: list[dict] | None,
|
|
1960
|
+
results: dict | None) -> list[str]:
|
|
1961
|
+
"""Build the "📋 覆盖与残余风险" section.
|
|
1962
|
+
|
|
1963
|
+
Surfaces what's NOT well-covered: modules with no passing TC, untested P0s,
|
|
1964
|
+
blocked items + reasons, skipped items + reasons, infra-failed suites.
|
|
1965
|
+
The "all green" report can still ship if this section lists important gaps.
|
|
1966
|
+
"""
|
|
1967
|
+
lines = ["## 📋 覆盖与残余风险", ""]
|
|
1968
|
+
risks: list[str] = []
|
|
1969
|
+
|
|
1970
|
+
# 1. Modules with active TCs that have ANY 失败/阻塞/未测试
|
|
1971
|
+
weak_modules: list[tuple[str, dict]] = []
|
|
1972
|
+
for module, counts in (agg.get("by_module") or {}).items():
|
|
1973
|
+
bad = counts.get("失败", 0) + counts.get("阻塞", 0) + counts.get("未测试", 0)
|
|
1974
|
+
if bad:
|
|
1975
|
+
weak_modules.append((module, counts))
|
|
1976
|
+
if weak_modules:
|
|
1977
|
+
risks.append("### 模块未达「绿色」标准")
|
|
1978
|
+
risks.append("")
|
|
1979
|
+
risks.append("| 模块 | 总 | 失败 | 阻塞 | 未测试 |")
|
|
1980
|
+
risks.append("|---|---|---|---|---|")
|
|
1981
|
+
for m, c in sorted(weak_modules):
|
|
1982
|
+
risks.append(f"| {_md_cell(m)} | {c.get('_total', 0)} | "
|
|
1983
|
+
f"{c.get('失败', 0)} | {c.get('阻塞', 0)} | {c.get('未测试', 0)} |")
|
|
1984
|
+
risks.append("")
|
|
1985
|
+
|
|
1986
|
+
# 2. Untested P0s
|
|
1987
|
+
p0_untested = agg.get("p0_total", 0) - agg.get("p0_pass", 0) - \
|
|
1988
|
+
sum(1 for r in (agg.get("p0_blocking") or []) if r.get("priority") == "P0")
|
|
1989
|
+
# simpler: count P0s whose status is neither 通过 nor 阻塞/失败 (already in p0_blocking)
|
|
1990
|
+
bs = agg.get("by_status", {})
|
|
1991
|
+
if agg.get("p0_total", 0) and (agg.get("p0_pass", 0) < agg["p0_total"]):
|
|
1992
|
+
not_passing_p0 = agg["p0_total"] - agg.get("p0_pass", 0)
|
|
1993
|
+
risks.append(f"### P0 未通过 / 未测试")
|
|
1994
|
+
risks.append("")
|
|
1995
|
+
risks.append(f"P0 共 {agg['p0_total']} 条,已通过 {agg.get('p0_pass', 0)} 条,"
|
|
1996
|
+
f"**剩余 {not_passing_p0} 条未达通过状态**。详见上方 P0 明细。")
|
|
1997
|
+
risks.append("")
|
|
1998
|
+
|
|
1999
|
+
# 3. Infra-failed suites (JUnit XML missing)
|
|
2000
|
+
missing_files = (results or {}).get("missing_files") if isinstance(results, dict) else None
|
|
2001
|
+
if missing_files:
|
|
2002
|
+
risks.append(f"### 🚨 基础设施失败:{len(missing_files)} 个套件无 JUnit XML")
|
|
2003
|
+
risks.append("")
|
|
2004
|
+
risks.append("以下套件未产出测试结果(编译失败 / runner 异常 / 路径配置错?)。"
|
|
2005
|
+
"上方所有统计仅覆盖 *已产出* XML 的套件。")
|
|
2006
|
+
risks.append("")
|
|
2007
|
+
for path in missing_files[:10]:
|
|
2008
|
+
risks.append(f"- `{path}`")
|
|
2009
|
+
if len(missing_files) > 10:
|
|
2010
|
+
risks.append(f"- ... 另外 {len(missing_files) - 10} 个")
|
|
2011
|
+
risks.append("")
|
|
2012
|
+
|
|
2013
|
+
# 4. Untracked-test failures (tests that ran but have no TC link)
|
|
2014
|
+
if untracked:
|
|
2015
|
+
bad_untracked = [u for u in untracked if u.get("status") in ("失败", "阻塞")]
|
|
2016
|
+
if bad_untracked:
|
|
2017
|
+
risks.append(f"### 未链接 TC 但失败的测试({len(bad_untracked)} 条)")
|
|
2018
|
+
risks.append("")
|
|
2019
|
+
risks.append("这些测试运行但没关联到任何 TC,所以不影响 Bitable 通过率 — "
|
|
2020
|
+
"但代码层面是真实失败,需修复或登记为 TC。")
|
|
2021
|
+
risks.append("")
|
|
2022
|
+
for u in bad_untracked[:10]:
|
|
2023
|
+
test_id = (u.get("classname", "") + "::" if u.get("classname") else "") + u.get("name", "")
|
|
2024
|
+
lines_status = u.get("status", "")
|
|
2025
|
+
risks.append(f"- `{_md_cell(test_id)}` → {lines_status}")
|
|
2026
|
+
if len(bad_untracked) > 10:
|
|
2027
|
+
risks.append(f"- ... 另外 {len(bad_untracked) - 10} 条")
|
|
2028
|
+
risks.append("")
|
|
2029
|
+
|
|
2030
|
+
if not risks:
|
|
2031
|
+
lines.append("✅ 所有活跃模块均无失败/阻塞/未测试;P0 全部通过;无 infra 失败;无未链接 TC 的失败测试。")
|
|
2032
|
+
lines.append("")
|
|
2033
|
+
else:
|
|
2034
|
+
lines.extend(risks)
|
|
2035
|
+
return lines
|
|
2036
|
+
|
|
2037
|
+
|
|
2038
|
+
def _orphans_section(orphans: dict) -> list[str]:
|
|
2039
|
+
"""Build a Markdown section for orphan TC IDs (deprecated-in-tests + missing-in-Bitable).
|
|
2040
|
+
Returns empty list when both are empty."""
|
|
2041
|
+
deprecated = orphans.get("deprecated_in_tests", [])
|
|
2042
|
+
missing = orphans.get("missing_in_bitable", [])
|
|
2043
|
+
if not deprecated and not missing:
|
|
2044
|
+
return []
|
|
2045
|
+
lines = ["## 孤儿 TC ID", ""]
|
|
2046
|
+
if deprecated:
|
|
2047
|
+
lines += [
|
|
2048
|
+
f"### 测试关联了 `废弃` TC({len(deprecated)} 条)",
|
|
2049
|
+
"",
|
|
2050
|
+
"说明:测试代码里调了 `tc(\"<ID>\")` 但 Bitable 该 TC 已废弃。判断:业务代码已删 → 同 commit 删测试;业务代码仍有效 → 不动(可能 TC 提前标废弃)。",
|
|
2051
|
+
"",
|
|
2052
|
+
]
|
|
2053
|
+
for tc_id in deprecated:
|
|
2054
|
+
lines.append(f"- {tc_id}")
|
|
2055
|
+
lines.append("")
|
|
2056
|
+
if missing:
|
|
2057
|
+
lines += [
|
|
2058
|
+
f"### 测试关联的 TC 不在 Bitable({len(missing)} 条)",
|
|
2059
|
+
"",
|
|
2060
|
+
"说明:测试代码里调了 `tc(\"<ID>\")` 但 Bitable 没这条记录。要么补登 TC,要么从测试里删掉错误的 ID。",
|
|
2061
|
+
"",
|
|
2062
|
+
]
|
|
2063
|
+
for tc_id in missing:
|
|
2064
|
+
lines.append(f"- {tc_id}")
|
|
2065
|
+
lines.append("")
|
|
2066
|
+
return lines
|
|
2067
|
+
|
|
2068
|
+
|
|
2069
|
+
def _last_run_section(diff: dict | None) -> list[str]:
|
|
2070
|
+
"""Markdown section for vs-last-run delta. Returns [] when nothing to show."""
|
|
2071
|
+
if not diff or not diff.get("had_previous"):
|
|
2072
|
+
return []
|
|
2073
|
+
nf = diff["new_failures"]
|
|
2074
|
+
fx = diff["fixed"]
|
|
2075
|
+
fl = diff["flips"]
|
|
2076
|
+
nls = diff["no_longer_seen"]
|
|
2077
|
+
if not (nf or fx or fl or nls):
|
|
2078
|
+
return ["## vs 上次跑", "", "状态与上次完全一致。", ""]
|
|
2079
|
+
lines = ["## vs 上次跑", ""]
|
|
2080
|
+
if nf:
|
|
2081
|
+
lines += [f"### 🔴 新增失败 / 阻塞({len(nf)})", ""]
|
|
2082
|
+
for tc_id in nf:
|
|
2083
|
+
lines.append(f"- {tc_id}")
|
|
2084
|
+
lines.append("")
|
|
2085
|
+
if fx:
|
|
2086
|
+
lines += [f"### 🟢 修复({len(fx)})", ""]
|
|
2087
|
+
for tc_id in fx:
|
|
2088
|
+
lines.append(f"- {tc_id}")
|
|
2089
|
+
lines.append("")
|
|
2090
|
+
if fl:
|
|
2091
|
+
lines += [f"### 状态翻转({len(fl)})", "", "| 用例ID | 上次 | 本次 |", "|---|---|---|"]
|
|
2092
|
+
for f in fl:
|
|
2093
|
+
lines.append(f"| {_md_cell(f['tc_id'])} | {_md_cell(f['from'])} | {_md_cell(f['to'])} |")
|
|
2094
|
+
lines.append("")
|
|
2095
|
+
if nls:
|
|
2096
|
+
lines += [f"### ⚠ 上次有结果但本次未跑({len(nls)})", "",
|
|
2097
|
+
"可能 TC 被删、测试被改名或 sidecar 未覆盖。", ""]
|
|
2098
|
+
for tc_id in nls:
|
|
2099
|
+
lines.append(f"- {tc_id}")
|
|
2100
|
+
lines.append("")
|
|
2101
|
+
return lines
|
|
2102
|
+
|
|
2103
|
+
|
|
2104
|
+
def _coverage_row(cov: dict | None) -> list[str]:
|
|
2105
|
+
"""Return markdown table rows for the coverage signal, or [] if absent."""
|
|
2106
|
+
if not cov:
|
|
2107
|
+
return []
|
|
2108
|
+
if "covered" in cov:
|
|
2109
|
+
pct = f"{cov['rate'] * 100:.1f}%"
|
|
2110
|
+
return [f"| 代码覆盖率 | {pct}({cov['covered']} / {cov['valid']} 行)|"]
|
|
2111
|
+
return [f"| 代码覆盖率 | {cov['rate'] * 100:.1f}% |"]
|
|
2112
|
+
|
|
2113
|
+
|
|
2114
|
+
def _dimension_section(title: str, key_label: str, rows: dict[str, dict[str, int]]) -> list[str]:
|
|
2115
|
+
lines = [title, "", f"| {key_label} | 总数 | 通过 | 失败 | 阻塞 | 跳过 | 未测试 | 通过率 |", "|---|---|---|---|---|---|---|---|"]
|
|
2116
|
+
for name, counts in sorted(rows.items()):
|
|
2117
|
+
total = counts.get("_total", 0)
|
|
2118
|
+
passed = counts.get("通过", 0)
|
|
2119
|
+
failed = counts.get("失败", 0)
|
|
2120
|
+
blocked = counts.get("阻塞", 0)
|
|
2121
|
+
skipped = counts.get("跳过", 0)
|
|
2122
|
+
untested = counts.get("未测试", 0)
|
|
2123
|
+
executed = passed + failed + blocked
|
|
2124
|
+
rate = f"{passed / executed * 100:.0f}%" if executed else "—"
|
|
2125
|
+
lines.append(f"| {_md_cell(name)} | {total} | {passed} | {failed} | {blocked} | {skipped} | {untested} | {rate} |")
|
|
2126
|
+
lines.append("")
|
|
2127
|
+
return lines
|
|
2128
|
+
|
|
2129
|
+
|
|
2130
|
+
def build_markdown(agg: dict, author: str, source: str, version: str,
|
|
2131
|
+
auto_count: int = 0, manual_note: str = "",
|
|
2132
|
+
untracked: list[dict] | None = None,
|
|
2133
|
+
orphans: dict | None = None,
|
|
2134
|
+
coverage: dict | None = None,
|
|
2135
|
+
last_run_diff: dict | None = None,
|
|
2136
|
+
results_for_risk: dict | None = None) -> str:
|
|
2137
|
+
now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
|
2138
|
+
bs = agg["by_status"]
|
|
2139
|
+
non_p0_failures = [r for r in agg["failures"] if r["priority"] != "P0"]
|
|
2140
|
+
lines = []
|
|
2141
|
+
|
|
2142
|
+
ver_str = f" | 版本: {version}" if version else ""
|
|
2143
|
+
lines += [
|
|
2144
|
+
"# 自动化测试报告",
|
|
2145
|
+
"",
|
|
2146
|
+
f"> 执行人: {author} | 来源: {source}{ver_str} | 时间: {now}",
|
|
2147
|
+
"",
|
|
2148
|
+
]
|
|
2149
|
+
|
|
2150
|
+
lines += [
|
|
2151
|
+
"## 总览",
|
|
2152
|
+
"",
|
|
2153
|
+
"| 指标 | 数量 |",
|
|
2154
|
+
"|---|---|",
|
|
2155
|
+
f"| 活跃用例 | {agg['total']} |",
|
|
2156
|
+
f"| 通过 | {bs.get('通过', 0)} |",
|
|
2157
|
+
f"| 失败 | {bs.get('失败', 0)} |",
|
|
2158
|
+
f"| 阻塞 | {bs.get('阻塞', 0)} |",
|
|
2159
|
+
f"| 跳过 | {bs.get('跳过', 0)} |",
|
|
2160
|
+
f"| 未测试 | {bs.get('未测试', 0)} |",
|
|
2161
|
+
f"| 废弃(不计入统计)| {agg['deprecated_count']} |",
|
|
2162
|
+
f"| **通过率(已执行)** | **{agg['pass_rate']}** |",
|
|
2163
|
+
f"| **P0 通过率** | **{agg['p0_pass_rate']}** |",
|
|
2164
|
+
]
|
|
2165
|
+
lines += _coverage_row(coverage)
|
|
2166
|
+
if auto_count:
|
|
2167
|
+
lines.append(f"| 本次自动化更新 | {auto_count} 条 |")
|
|
2168
|
+
lines.append("")
|
|
2169
|
+
|
|
2170
|
+
lines += _last_run_section(last_run_diff)
|
|
2171
|
+
|
|
2172
|
+
lines += [
|
|
2173
|
+
"## 按模块统计",
|
|
2174
|
+
"",
|
|
2175
|
+
"| 模块 | 总数 | 通过 | 失败 | 阻塞 | 跳过 | 未测试 | 通过率 |",
|
|
2176
|
+
"|---|---|---|---|---|---|---|---|",
|
|
2177
|
+
]
|
|
2178
|
+
for module, counts in sorted(agg["by_module"].items()):
|
|
2179
|
+
t = counts.get("_total", 0)
|
|
2180
|
+
passed = counts.get("通过", 0)
|
|
2181
|
+
failed = counts.get("失败", 0)
|
|
2182
|
+
blocked = counts.get("阻塞", 0)
|
|
2183
|
+
skipped = counts.get("跳过", 0)
|
|
2184
|
+
untested = counts.get("未测试", 0)
|
|
2185
|
+
executed = passed + failed + blocked
|
|
2186
|
+
rate = f"{passed / executed * 100:.0f}%" if executed else "—"
|
|
2187
|
+
lines.append(f"| {_md_cell(module)} | {t} | {passed} | {failed} | {blocked} | {skipped} | {untested} | {rate} |")
|
|
2188
|
+
lines.append("")
|
|
2189
|
+
|
|
2190
|
+
if agg.get("by_layer"):
|
|
2191
|
+
lines += _dimension_section("## 按测试层级统计", "测试层级", agg["by_layer"])
|
|
2192
|
+
|
|
2193
|
+
if agg.get("by_test_type"):
|
|
2194
|
+
lines += _dimension_section("## 按测试类型统计", "测试类型", agg["by_test_type"])
|
|
2195
|
+
|
|
2196
|
+
if agg["p0_blocking"]:
|
|
2197
|
+
lines += [
|
|
2198
|
+
"## P0 未通过明细",
|
|
2199
|
+
"",
|
|
2200
|
+
"| 用例ID | 模块 | 功能点 | 状态 | 最新信息流转 |",
|
|
2201
|
+
"|---|---|---|---|---|",
|
|
2202
|
+
]
|
|
2203
|
+
for r in agg["p0_blocking"]:
|
|
2204
|
+
lines.append(
|
|
2205
|
+
f"| {_md_cell(r['id'])} | {_md_cell(r['module'])} | {_md_cell(r['feature'])} | "
|
|
2206
|
+
f"{_md_cell(r['status'])} | {_md_cell(r['last_info'])} |"
|
|
2207
|
+
)
|
|
2208
|
+
lines.append("")
|
|
2209
|
+
else:
|
|
2210
|
+
lines += ["## P0 未通过明细", "", "无。", ""]
|
|
2211
|
+
|
|
2212
|
+
if non_p0_failures:
|
|
2213
|
+
lines += [
|
|
2214
|
+
"## 失败 / 阻塞明细(P1/P2)",
|
|
2215
|
+
"",
|
|
2216
|
+
"| 用例ID | 模块 | 功能点 | 优先级 | 状态 | 最新信息流转 |",
|
|
2217
|
+
"|---|---|---|---|---|---|",
|
|
2218
|
+
]
|
|
2219
|
+
for r in non_p0_failures:
|
|
2220
|
+
lines.append(
|
|
2221
|
+
f"| {_md_cell(r['id'])} | {_md_cell(r['module'])} | {_md_cell(r['feature'])} | "
|
|
2222
|
+
f"{_md_cell(r['priority'])} | {_md_cell(r['status'])} | {_md_cell(r['last_info'])} |"
|
|
2223
|
+
)
|
|
2224
|
+
lines.append("")
|
|
2225
|
+
|
|
2226
|
+
if manual_note:
|
|
2227
|
+
lines += ["## 手动测试说明", "", manual_note, ""]
|
|
2228
|
+
|
|
2229
|
+
if untracked:
|
|
2230
|
+
lines += _untracked_section(untracked)
|
|
2231
|
+
|
|
2232
|
+
if orphans:
|
|
2233
|
+
lines += _orphans_section(orphans)
|
|
2234
|
+
|
|
2235
|
+
# Residual-risk section: what's not green-covered, regardless of overall pass rate.
|
|
2236
|
+
lines += _residual_risk_section(agg, untracked, results_for_risk)
|
|
2237
|
+
|
|
2238
|
+
# Untested P0s are NOT in p0_blocking (which only tracks 失败/阻塞),
|
|
2239
|
+
# but they DO matter for release. A P0 marked 未测试 means we don't know
|
|
2240
|
+
# if it works → cannot recommend release.
|
|
2241
|
+
p0_untested = agg["p0_total"] - agg["p0_pass"] - len(agg["p0_blocking"])
|
|
2242
|
+
|
|
2243
|
+
lines += ["## 发布建议", ""]
|
|
2244
|
+
if agg["p0_blocking"]:
|
|
2245
|
+
lines.append(
|
|
2246
|
+
f"**❌ 不建议发布** — {len(agg['p0_blocking'])} 个 P0 用例未通过(失败/阻塞),需先解决后再发布。"
|
|
2247
|
+
)
|
|
2248
|
+
elif p0_untested > 0:
|
|
2249
|
+
lines.append(
|
|
2250
|
+
f"**❌ 不建议发布** — {p0_untested} 个 P0 用例尚未测试(状态 `未测试`/`跳过`),"
|
|
2251
|
+
"无法判断这部分行为正确性。必须先执行后再考虑发布。"
|
|
2252
|
+
)
|
|
2253
|
+
else:
|
|
2254
|
+
if non_p0_failures:
|
|
2255
|
+
lines.append(
|
|
2256
|
+
f"**⚠️ 可谨慎发布** — P0 全部通过,{len(non_p0_failures)} 个 P1/P2 用例未通过,需评估影响。"
|
|
2257
|
+
)
|
|
2258
|
+
else:
|
|
2259
|
+
lines.append("**✅ 建议发布** — 所有已执行用例通过,无阻塞项。")
|
|
2260
|
+
lines.append("")
|
|
2261
|
+
|
|
2262
|
+
return "\n".join(lines)
|
|
2263
|
+
|
|
2264
|
+
|
|
2265
|
+
# ── Feishu doc ─────────────────────────────────────────────────────────────────
|
|
2266
|
+
|
|
2267
|
+
def create_doc(title: str, markdown: str, folder_token: str, identity: str) -> str:
|
|
2268
|
+
cmd = ["lark-cli", "docs", "+create",
|
|
2269
|
+
"--title", title,
|
|
2270
|
+
"--as", identity,
|
|
2271
|
+
"--markdown", markdown]
|
|
2272
|
+
if folder_token:
|
|
2273
|
+
cmd += ["--folder-token", folder_token]
|
|
2274
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
2275
|
+
if result.returncode != 0:
|
|
2276
|
+
raise RuntimeError(f"docs +create failed: {result.stderr.strip()}")
|
|
2277
|
+
# Prefer JSON parse first; fall back to plain-URL line for human-readable output
|
|
2278
|
+
raw = result.stdout.strip()
|
|
2279
|
+
url = ""
|
|
2280
|
+
try:
|
|
2281
|
+
data = json.loads(raw)
|
|
2282
|
+
url = data.get("url") or data.get("obj_url") or data.get("data", {}).get("url", "")
|
|
2283
|
+
except (json.JSONDecodeError, AttributeError):
|
|
2284
|
+
# Look for a line that IS a URL (not JSON containing a URL)
|
|
2285
|
+
for line in raw.splitlines():
|
|
2286
|
+
line = line.strip()
|
|
2287
|
+
if line.startswith("http"):
|
|
2288
|
+
url = line
|
|
2289
|
+
break
|
|
2290
|
+
if not url:
|
|
2291
|
+
raise RuntimeError(f"docs +create succeeded but no URL found in output: {raw!r}")
|
|
2292
|
+
return url
|
|
2293
|
+
|
|
2294
|
+
|
|
2295
|
+
def update_doc(doc_url: str, markdown: str, identity: str) -> None:
|
|
2296
|
+
result = subprocess.run(
|
|
2297
|
+
["lark-cli", "docs", "+update",
|
|
2298
|
+
"--doc", doc_url,
|
|
2299
|
+
"--as", identity,
|
|
2300
|
+
"--mode", "replace_all",
|
|
2301
|
+
"--markdown", markdown],
|
|
2302
|
+
capture_output=True, text=True
|
|
2303
|
+
)
|
|
2304
|
+
if result.returncode != 0:
|
|
2305
|
+
raise RuntimeError(f"docs +update failed: {result.stderr.strip()}")
|
|
2306
|
+
|
|
2307
|
+
|
|
2308
|
+
# ── Config helpers ─────────────────────────────────────────────────────────────
|
|
2309
|
+
|
|
2310
|
+
def parse_bitable_url(url: str) -> tuple[str, str]:
|
|
2311
|
+
parsed = urlparse(url)
|
|
2312
|
+
base_token = parsed.path.rstrip("/").split("/")[-1]
|
|
2313
|
+
if not re.match(r"^[A-Za-z0-9]{10,}$", base_token):
|
|
2314
|
+
raise ValueError(f"Could not parse base_token from URL: {url}")
|
|
2315
|
+
qs = parse_qs(parsed.query)
|
|
2316
|
+
table_id = (qs.get("table") or [""])[0]
|
|
2317
|
+
return base_token, table_id
|
|
2318
|
+
|
|
2319
|
+
|
|
2320
|
+
def fetch_first_table_id(base_token: str, identity: str) -> str:
|
|
2321
|
+
# lark-cli +table-list emits JSON by default (no --format flag) and returns
|
|
2322
|
+
# data.tables = [{"id", "name"}, ...].
|
|
2323
|
+
result = subprocess.run(
|
|
2324
|
+
["lark-cli", "base", "+table-list",
|
|
2325
|
+
"--base-token", base_token, "--as", identity],
|
|
2326
|
+
capture_output=True, text=True
|
|
2327
|
+
)
|
|
2328
|
+
if result.returncode != 0:
|
|
2329
|
+
raise RuntimeError(f"+table-list failed: {result.stderr.strip()}")
|
|
2330
|
+
tables = json.loads(result.stdout).get("data", {}).get("tables", [])
|
|
2331
|
+
if not tables:
|
|
2332
|
+
raise RuntimeError("No tables found in this Bitable app")
|
|
2333
|
+
tid = tables[0].get("id", "")
|
|
2334
|
+
if not tid:
|
|
2335
|
+
raise RuntimeError("Could not read table id from +table-list output")
|
|
2336
|
+
return tid
|
|
2337
|
+
|
|
2338
|
+
|
|
2339
|
+
def init_config(config_path: str, bitable_url: str, identity: str) -> None:
|
|
2340
|
+
base_token, table_id = parse_bitable_url(bitable_url)
|
|
2341
|
+
if not table_id:
|
|
2342
|
+
print("table_id not in URL — fetching via +table-list...", file=sys.stderr)
|
|
2343
|
+
table_id = fetch_first_table_id(base_token, identity)
|
|
2344
|
+
p = Path(config_path)
|
|
2345
|
+
cfg: dict = {}
|
|
2346
|
+
if p.exists():
|
|
2347
|
+
with open(p) as f:
|
|
2348
|
+
cfg = json.load(f)
|
|
2349
|
+
cfg["base_token"] = base_token
|
|
2350
|
+
cfg["table_id"] = table_id
|
|
2351
|
+
cfg.setdefault("folder_token", "")
|
|
2352
|
+
cfg.setdefault("report_doc_url", "")
|
|
2353
|
+
cfg.setdefault("test_suites", [])
|
|
2354
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
2355
|
+
with open(p, "w") as f:
|
|
2356
|
+
json.dump(cfg, f, ensure_ascii=False, indent=2)
|
|
2357
|
+
print(f"Config written: {config_path}", file=sys.stderr)
|
|
2358
|
+
print(f" base_token: {base_token}", file=sys.stderr)
|
|
2359
|
+
print(f" table_id: {table_id}", file=sys.stderr)
|
|
2360
|
+
print(" Next: add test_suites entries, then run with --run-tests", file=sys.stderr)
|
|
2361
|
+
|
|
2362
|
+
|
|
2363
|
+
def load_config(path: str) -> dict:
|
|
2364
|
+
p = Path(path)
|
|
2365
|
+
if not p.exists():
|
|
2366
|
+
raise FileNotFoundError(
|
|
2367
|
+
f"Config not found: {path}\n"
|
|
2368
|
+
"Run: python gen_report.py --config <path> --init [--bitable-url <url>]"
|
|
2369
|
+
)
|
|
2370
|
+
with open(p) as f:
|
|
2371
|
+
cfg = json.load(f)
|
|
2372
|
+
# base_token + table_id MUST be both set (Bitable mode) or both empty
|
|
2373
|
+
# (minimal mode). A half-populated config is almost always a typo or
|
|
2374
|
+
# interrupted --init; failing loud is better than silently falling into
|
|
2375
|
+
# minimal mode and skipping all Bitable sync.
|
|
2376
|
+
base = bool(cfg.get("base_token"))
|
|
2377
|
+
table = bool(cfg.get("table_id"))
|
|
2378
|
+
if base != table:
|
|
2379
|
+
raise ValueError(
|
|
2380
|
+
f"Config {path}: base_token and table_id must both be set (Bitable mode) "
|
|
2381
|
+
f"or both empty (minimal mode); got base_token={'set' if base else 'empty'}, "
|
|
2382
|
+
f"table_id={'set' if table else 'empty'}. "
|
|
2383
|
+
"Re-run --init or edit the config to fix."
|
|
2384
|
+
)
|
|
2385
|
+
return cfg
|
|
2386
|
+
|
|
2387
|
+
|
|
2388
|
+
def init_minimal_config(config_path: str) -> None:
|
|
2389
|
+
"""Create a config skeleton without Bitable. test_suites must be filled in."""
|
|
2390
|
+
p = Path(config_path)
|
|
2391
|
+
cfg: dict = {}
|
|
2392
|
+
if p.exists():
|
|
2393
|
+
with open(p) as f:
|
|
2394
|
+
cfg = json.load(f)
|
|
2395
|
+
cfg.setdefault("base_token", "")
|
|
2396
|
+
cfg.setdefault("table_id", "")
|
|
2397
|
+
cfg.setdefault("folder_token", "")
|
|
2398
|
+
cfg.setdefault("report_doc_url", "")
|
|
2399
|
+
cfg.setdefault("test_suites", [])
|
|
2400
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
2401
|
+
with open(p, "w") as f:
|
|
2402
|
+
json.dump(cfg, f, ensure_ascii=False, indent=2)
|
|
2403
|
+
print(f"Minimal config written: {config_path}", file=sys.stderr)
|
|
2404
|
+
print(" No Bitable — JUnit-only mode.", file=sys.stderr)
|
|
2405
|
+
print(" Add test_suites entries; run with --run-tests; report prints to stdout", file=sys.stderr)
|
|
2406
|
+
print(" (or set report_doc_url to publish to a Feishu doc).", file=sys.stderr)
|
|
2407
|
+
|
|
2408
|
+
|
|
2409
|
+
def save_doc_url(config_path: str, url: str) -> None:
|
|
2410
|
+
p = Path(config_path)
|
|
2411
|
+
with open(p) as f:
|
|
2412
|
+
cfg = json.load(f)
|
|
2413
|
+
cfg["report_doc_url"] = url
|
|
2414
|
+
with open(p, "w") as f:
|
|
2415
|
+
json.dump(cfg, f, ensure_ascii=False, indent=2)
|
|
2416
|
+
|
|
2417
|
+
|
|
2418
|
+
# ── CI exit gate ──────────────────────────────────────────────────────────────
|
|
2419
|
+
|
|
2420
|
+
def _exit_with_gate(args: argparse.Namespace, suite_failed: bool,
|
|
2421
|
+
results: dict, current_snapshot: dict,
|
|
2422
|
+
*, needs_results: bool = True) -> None:
|
|
2423
|
+
"""Single exit point. Saves the last-run snapshot (non-dry-run only) and
|
|
2424
|
+
exits per --fail-on policy.
|
|
2425
|
+
|
|
2426
|
+
`needs_results=False` is for utility modes (--inventory, --next-id, --diff-md,
|
|
2427
|
+
--unfreeze) that don't act on test results — missing JUnit XML is just
|
|
2428
|
+
"tests not run yet", NOT an infra failure. With this flag set, the gate
|
|
2429
|
+
only fires on suite_failed (when --run-tests was used in the same command).
|
|
2430
|
+
|
|
2431
|
+
Snapshot save policy (independent of `--run-tests`, because report-only
|
|
2432
|
+
aggregation may parse existing XML without running suites in this process):
|
|
2433
|
+
- dry-run → never save
|
|
2434
|
+
- utility mode (needs_results=False) → never save
|
|
2435
|
+
- missing_files non-empty AND parsed_suites == 0 → INFRA HARD-FAIL:
|
|
2436
|
+
preserve baseline (don't overwrite "nothing ran" on top of a good snapshot)
|
|
2437
|
+
- otherwise → save (even an empty `linked` from a project with no markers
|
|
2438
|
+
yet is a valid baseline)
|
|
2439
|
+
"""
|
|
2440
|
+
if not args.dry_run and needs_results:
|
|
2441
|
+
missing = results.get("missing_files", []) if isinstance(results, dict) else []
|
|
2442
|
+
parsed = results.get("parsed_suites", 0) if isinstance(results, dict) else 0
|
|
2443
|
+
if missing and parsed == 0:
|
|
2444
|
+
print(" ⚠ All configured test suites failed to produce JUnit XML — "
|
|
2445
|
+
"treating as infra failure; preserving previous last-run.json",
|
|
2446
|
+
file=sys.stderr)
|
|
2447
|
+
else:
|
|
2448
|
+
save_last_run(_last_run_path(), current_snapshot)
|
|
2449
|
+
if needs_results:
|
|
2450
|
+
rc = _compute_exit_code(args.fail_on, suite_failed, results)
|
|
2451
|
+
else:
|
|
2452
|
+
# Utility mode: only gate on suite execution failure (--run-tests path),
|
|
2453
|
+
# NOT on test-result content (a clean checkout with no prior JUnit XML
|
|
2454
|
+
# should still let `make inventory` / `--next-id` succeed).
|
|
2455
|
+
rc = 1 if suite_failed and args.fail_on != "never" else 0
|
|
2456
|
+
if rc:
|
|
2457
|
+
print(f"⚠ Exiting non-zero (--fail-on={args.fail_on}): "
|
|
2458
|
+
f"suite_failed={suite_failed}, tc-failures or missing results present.",
|
|
2459
|
+
file=sys.stderr)
|
|
2460
|
+
sys.exit(rc)
|
|
2461
|
+
|
|
2462
|
+
|
|
2463
|
+
def _compute_exit_code(fail_on: str, suite_failed: bool,
|
|
2464
|
+
results: dict | None) -> int:
|
|
2465
|
+
"""Return the process exit code per --fail-on policy.
|
|
2466
|
+
any: suite_failed OR missing JUnit XML OR any failing/blocked TC/untracked
|
|
2467
|
+
tc-failures: failing/blocked TC or untracked test only
|
|
2468
|
+
suite-only: only suite_failed OR missing JUnit XML
|
|
2469
|
+
never: always 0
|
|
2470
|
+
|
|
2471
|
+
`missing_files` (suites configured but JUnit XML absent) counts as a
|
|
2472
|
+
suite-level failure — this is how we catch infra hard-fail when tests run
|
|
2473
|
+
outside gen_report.py (the Makefile chain).
|
|
2474
|
+
"""
|
|
2475
|
+
if fail_on == "never":
|
|
2476
|
+
return 0
|
|
2477
|
+
infra_failed = False
|
|
2478
|
+
tc_failed = False
|
|
2479
|
+
if results and isinstance(results, dict):
|
|
2480
|
+
if results.get("missing_files"):
|
|
2481
|
+
infra_failed = True
|
|
2482
|
+
linked = results.get("linked", {})
|
|
2483
|
+
untracked = results.get("untracked", [])
|
|
2484
|
+
bad = {"失败", "阻塞"}
|
|
2485
|
+
if any(s in bad for s in linked.values()):
|
|
2486
|
+
tc_failed = True
|
|
2487
|
+
elif any(u.get("status") in bad for u in untracked):
|
|
2488
|
+
tc_failed = True
|
|
2489
|
+
suite_or_infra = suite_failed or infra_failed
|
|
2490
|
+
if fail_on == "suite-only":
|
|
2491
|
+
return 1 if suite_or_infra else 0
|
|
2492
|
+
if fail_on == "tc-failures":
|
|
2493
|
+
return 1 if tc_failed else 0
|
|
2494
|
+
return 1 if (suite_or_infra or tc_failed) else 0
|
|
2495
|
+
|
|
2496
|
+
|
|
2497
|
+
# ── Main ───────────────────────────────────────────────────────────────────────
|
|
2498
|
+
|
|
2499
|
+
def main() -> None:
|
|
2500
|
+
parser = argparse.ArgumentParser(
|
|
2501
|
+
description="Run tests, sync results to Bitable, generate Feishu report",
|
|
2502
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
2503
|
+
epilog=(
|
|
2504
|
+
"Most arguments have smart defaults — minimal invocations:\n"
|
|
2505
|
+
" Local: python gen_report.py --run-tests\n"
|
|
2506
|
+
" CI: python gen_report.py --run-tests (--as bot auto-detected)"
|
|
2507
|
+
)
|
|
2508
|
+
)
|
|
2509
|
+
parser.add_argument("--config", default=_default_config(),
|
|
2510
|
+
help="Path to .report-config.json (default: .report-config.json)")
|
|
2511
|
+
parser.add_argument("--init", action="store_true",
|
|
2512
|
+
help="First-time setup: parse Bitable URL → write config")
|
|
2513
|
+
parser.add_argument("--bitable-url", default="",
|
|
2514
|
+
help="Feishu Bitable URL (required with --init)")
|
|
2515
|
+
parser.add_argument("--run-tests", action="store_true",
|
|
2516
|
+
help="Run test_suites commands before syncing results")
|
|
2517
|
+
parser.add_argument("--as", dest="identity", default=None,
|
|
2518
|
+
choices=["user", "bot"],
|
|
2519
|
+
help="lark-cli identity (default: bot in CI env, user locally)")
|
|
2520
|
+
parser.add_argument("--author", default=None,
|
|
2521
|
+
help="Author for report and 信息流转 (default: git config user.name)")
|
|
2522
|
+
parser.add_argument("--source", default=None,
|
|
2523
|
+
help="CI or 本地 (default: auto-detected from env vars)")
|
|
2524
|
+
parser.add_argument("--version", default=None,
|
|
2525
|
+
help="Build/version label (default: git describe --tags --always)")
|
|
2526
|
+
parser.add_argument("--dry-run", action="store_true",
|
|
2527
|
+
help="Print Markdown only, skip Bitable update and Feishu write")
|
|
2528
|
+
parser.add_argument("--detect-orphans", action="store_true",
|
|
2529
|
+
help="Surface tests with deprecated/unregistered TC IDs; no auto-delete")
|
|
2530
|
+
parser.add_argument("--unfreeze", default="",
|
|
2531
|
+
help="Comma-separated TC IDs to recover from 废弃 → 未测试 (mis-mark recovery)")
|
|
2532
|
+
parser.add_argument("--unfreeze-reason", default="",
|
|
2533
|
+
help="Optional reason recorded in 信息流转 when --unfreeze runs")
|
|
2534
|
+
parser.add_argument("--next-id", action="store_true",
|
|
2535
|
+
help="Print next available TC sequence number per module (collision prevention)")
|
|
2536
|
+
parser.add_argument("--diff-md", default="",
|
|
2537
|
+
help="Compare a local md TC table against Bitable; print added/removed/changed")
|
|
2538
|
+
parser.add_argument("--inventory", action="store_true",
|
|
2539
|
+
help="Print current Bitable TC inventory grouped by 模块 (use before iteration TC writing)")
|
|
2540
|
+
parser.add_argument("--inventory-md", action="store_true",
|
|
2541
|
+
help="Same as --inventory but emits a md table (pipe to file then --diff-md it)")
|
|
2542
|
+
parser.add_argument("--validate-matrix", default=None,
|
|
2543
|
+
help="Path to test/cases/test-matrix.md. Check that every TC ID in the "
|
|
2544
|
+
"matrix exists in Bitable (not 废弃) and every active Bitable TC appears "
|
|
2545
|
+
"in at least one matrix cell. Reports drift; does not modify either.")
|
|
2546
|
+
parser.add_argument("--matrix-drift-gate", choices=["warn", "fail"], default="warn",
|
|
2547
|
+
help="With --validate-matrix, fail process on matrix drift only when set to fail")
|
|
2548
|
+
parser.add_argument("--pr-summary", action="store_true",
|
|
2549
|
+
help="Print a short PR-comment summary (overview + vs-last-run) to stdout; "
|
|
2550
|
+
"skip Bitable sync and Feishu publish. For CI 'gh pr comment' / GitLab MR notes.")
|
|
2551
|
+
parser.add_argument("--fail-on", default="any",
|
|
2552
|
+
choices=["any", "tc-failures", "suite-only", "never"],
|
|
2553
|
+
help="CI gate: process exit code is non-zero when "
|
|
2554
|
+
"any (default: suite OR TC failure/block) / tc-failures (TC failure/block only) / "
|
|
2555
|
+
"suite-only (suite command non-zero) / never (always exit 0)")
|
|
2556
|
+
args = parser.parse_args()
|
|
2557
|
+
|
|
2558
|
+
# Apply smart defaults (evaluated lazily to avoid slow git calls when not needed)
|
|
2559
|
+
if args.identity is None:
|
|
2560
|
+
args.identity = _default_identity()
|
|
2561
|
+
if args.author is None:
|
|
2562
|
+
args.author = _default_author()
|
|
2563
|
+
if args.source is None:
|
|
2564
|
+
args.source = _default_source()
|
|
2565
|
+
if args.version is None:
|
|
2566
|
+
args.version = _default_version()
|
|
2567
|
+
|
|
2568
|
+
# ── Init mode ──────────────────────────────────────────────────────────────
|
|
2569
|
+
if args.init:
|
|
2570
|
+
if args.bitable_url:
|
|
2571
|
+
init_config(args.config, args.bitable_url, args.identity)
|
|
2572
|
+
else:
|
|
2573
|
+
init_minimal_config(args.config)
|
|
2574
|
+
return
|
|
2575
|
+
|
|
2576
|
+
cfg = load_config(args.config)
|
|
2577
|
+
base_token = cfg.get("base_token", "")
|
|
2578
|
+
table_id = cfg.get("table_id", "")
|
|
2579
|
+
folder_token = cfg.get("folder_token", "")
|
|
2580
|
+
doc_url = cfg.get("report_doc_url", "")
|
|
2581
|
+
suites: list[dict] = cfg.get("test_suites", [])
|
|
2582
|
+
has_bitable = bool(base_token and table_id)
|
|
2583
|
+
|
|
2584
|
+
if (args.validate_matrix is not None
|
|
2585
|
+
and not args.validate_matrix.strip()
|
|
2586
|
+
and args.matrix_drift_gate == "fail"):
|
|
2587
|
+
print("⚠ Exiting non-zero: --validate-matrix requires a non-empty matrix path in fail mode.",
|
|
2588
|
+
file=sys.stderr)
|
|
2589
|
+
sys.exit(1)
|
|
2590
|
+
if (args.validate_matrix is not None
|
|
2591
|
+
and args.matrix_drift_gate == "fail"
|
|
2592
|
+
and not has_bitable):
|
|
2593
|
+
print("⚠ Exiting non-zero: fail-mode matrix validation requires Bitable config.",
|
|
2594
|
+
file=sys.stderr)
|
|
2595
|
+
sys.exit(1)
|
|
2596
|
+
|
|
2597
|
+
# ── Run tests ──────────────────────────────────────────────────────────────
|
|
2598
|
+
suite_failed = False
|
|
2599
|
+
stale_result_files: set[str] = set()
|
|
2600
|
+
if args.run_tests:
|
|
2601
|
+
suites_to_run = suites
|
|
2602
|
+
result_backups: dict[str, Path] = {}
|
|
2603
|
+
if args.validate_matrix is not None and args.matrix_drift_gate == "fail":
|
|
2604
|
+
suites_to_run = [s for s in suites if _matrix_gate_enabled(s)]
|
|
2605
|
+
if not suites_to_run:
|
|
2606
|
+
print("⚠ Exiting non-zero: fail-mode matrix validation requires at least one fast-gate test suite.",
|
|
2607
|
+
file=sys.stderr)
|
|
2608
|
+
sys.exit(1)
|
|
2609
|
+
if not args.dry_run:
|
|
2610
|
+
try:
|
|
2611
|
+
restored_orphans = _sweep_pre_gate_backups(suites_to_run)
|
|
2612
|
+
if restored_orphans:
|
|
2613
|
+
print("⚠ Restored orphaned pre-gate JUnit XML backup before running suites:",
|
|
2614
|
+
file=sys.stderr)
|
|
2615
|
+
for path in restored_orphans:
|
|
2616
|
+
print(f" - {path}", file=sys.stderr)
|
|
2617
|
+
result_backups = _clear_suite_result_files(suites_to_run)
|
|
2618
|
+
except RuntimeError as exc:
|
|
2619
|
+
print(f"⚠ Exiting non-zero: {exc}", file=sys.stderr)
|
|
2620
|
+
sys.exit(1)
|
|
2621
|
+
if not suites_to_run:
|
|
2622
|
+
print("⚠ No test_suites defined in config — skipping test run", file=sys.stderr)
|
|
2623
|
+
else:
|
|
2624
|
+
print(f"Running {len(suites_to_run)} test suite(s)...", file=sys.stderr)
|
|
2625
|
+
try:
|
|
2626
|
+
run_failed, restored = _run_suites_and_restore_backups(suites_to_run, result_backups)
|
|
2627
|
+
suite_failed = suite_failed or run_failed
|
|
2628
|
+
except RuntimeError as exc:
|
|
2629
|
+
print(f"⚠ Exiting non-zero: {exc}", file=sys.stderr)
|
|
2630
|
+
sys.exit(1)
|
|
2631
|
+
if restored:
|
|
2632
|
+
suite_failed = True
|
|
2633
|
+
stale_result_files.update(restored)
|
|
2634
|
+
print("⚠ Restored pre-gate JUnit XML because suites did not regenerate configured results_file:",
|
|
2635
|
+
file=sys.stderr)
|
|
2636
|
+
for path in restored:
|
|
2637
|
+
print(f" - {path}", file=sys.stderr)
|
|
2638
|
+
|
|
2639
|
+
# ── Parse results ──────────────────────────────────────────────────────────
|
|
2640
|
+
# Always call collect_results so the dict has the full schema (linked,
|
|
2641
|
+
# untracked, missing_files, parsed_suites) — keeps downstream consumers
|
|
2642
|
+
# from having to .get() with defaults.
|
|
2643
|
+
parse_excluded_results = not (
|
|
2644
|
+
args.validate_matrix is not None
|
|
2645
|
+
and args.matrix_drift_gate == "fail"
|
|
2646
|
+
)
|
|
2647
|
+
results: dict = collect_results(
|
|
2648
|
+
suites,
|
|
2649
|
+
parse_excluded=parse_excluded_results,
|
|
2650
|
+
stale_result_files=stale_result_files,
|
|
2651
|
+
)
|
|
2652
|
+
coverage: dict | None = collect_coverage(suites) if suites else None
|
|
2653
|
+
if coverage:
|
|
2654
|
+
r = coverage.get("rate", 0)
|
|
2655
|
+
print(f" Code coverage: {r * 100:.1f}%", file=sys.stderr)
|
|
2656
|
+
|
|
2657
|
+
# ── vs-last-run diff (also persists this run for next time) ─────────────
|
|
2658
|
+
previous = load_last_run(_last_run_path())
|
|
2659
|
+
if has_bitable:
|
|
2660
|
+
# Bitable mode: keys are TC IDs only (no namespace prefix needed)
|
|
2661
|
+
current_snapshot = dict(results["linked"])
|
|
2662
|
+
else:
|
|
2663
|
+
# Minimal mode: combine linked + untracked but PREFIX each identity
|
|
2664
|
+
# with its namespace so the diff doesn't mix TC IDs and test paths
|
|
2665
|
+
# (e.g. starting/stopping use of sidecar mappings won't generate
|
|
2666
|
+
# phantom newly_seen / no_longer_seen).
|
|
2667
|
+
current_snapshot = {f"tc::{tc_id}": status
|
|
2668
|
+
for tc_id, status in results["linked"].items()}
|
|
2669
|
+
for u in results["untracked"]:
|
|
2670
|
+
ident = (u.get("classname", "") + "::" if u.get("classname") else "") + u.get("name", "")
|
|
2671
|
+
if ident:
|
|
2672
|
+
current_snapshot[f"test::{ident}"] = u["status"]
|
|
2673
|
+
last_run_diff = diff_vs_last_run(current_snapshot, previous) if current_snapshot or previous else None
|
|
2674
|
+
|
|
2675
|
+
# ── PR-summary mode (CI integration) ────────────────────────────────────
|
|
2676
|
+
if args.pr_summary:
|
|
2677
|
+
print(build_pr_summary(results, coverage, last_run_diff))
|
|
2678
|
+
_exit_with_gate(args, suite_failed, results, current_snapshot)
|
|
2679
|
+
|
|
2680
|
+
# ── Minimal mode (no Bitable) ──────────────────────────────────────────────
|
|
2681
|
+
if not has_bitable:
|
|
2682
|
+
if args.detect_orphans:
|
|
2683
|
+
print("--detect-orphans requires Bitable config (base_token + table_id).",
|
|
2684
|
+
file=sys.stderr)
|
|
2685
|
+
_exit_with_gate(args, suite_failed, results, current_snapshot)
|
|
2686
|
+
markdown = build_minimal_markdown(results, args.author, args.source, args.version,
|
|
2687
|
+
coverage=coverage, last_run_diff=last_run_diff)
|
|
2688
|
+
if args.dry_run or not doc_url:
|
|
2689
|
+
print(markdown)
|
|
2690
|
+
else:
|
|
2691
|
+
print(f"Updating report doc: {doc_url}", file=sys.stderr)
|
|
2692
|
+
update_doc(doc_url, markdown, args.identity)
|
|
2693
|
+
print(f"Updated: {doc_url}")
|
|
2694
|
+
_exit_with_gate(args, suite_failed, results, current_snapshot)
|
|
2695
|
+
|
|
2696
|
+
# ── Bitable mode ──────────────────────────────────────────────────────────
|
|
2697
|
+
print("Reading Bitable records...", file=sys.stderr)
|
|
2698
|
+
records = record_list(base_token, table_id, args.identity)
|
|
2699
|
+
print(f" {len(records)} records loaded", file=sys.stderr)
|
|
2700
|
+
|
|
2701
|
+
if args.next_id:
|
|
2702
|
+
print_next_ids(records)
|
|
2703
|
+
_exit_with_gate(args, suite_failed, results, current_snapshot, needs_results=False)
|
|
2704
|
+
|
|
2705
|
+
if args.inventory or args.inventory_md:
|
|
2706
|
+
print_inventory(records, as_md=args.inventory_md)
|
|
2707
|
+
_exit_with_gate(args, suite_failed, results, current_snapshot, needs_results=False)
|
|
2708
|
+
|
|
2709
|
+
if args.diff_md:
|
|
2710
|
+
md = parse_md_table(args.diff_md)
|
|
2711
|
+
if not md:
|
|
2712
|
+
print("No TCs parsed from md; nothing to diff.", file=sys.stderr)
|
|
2713
|
+
_exit_with_gate(args, suite_failed, results, current_snapshot, needs_results=False)
|
|
2714
|
+
diff = diff_md_vs_bitable(md, records)
|
|
2715
|
+
print_diff_report(diff)
|
|
2716
|
+
_exit_with_gate(args, suite_failed, results, current_snapshot, needs_results=False)
|
|
2717
|
+
|
|
2718
|
+
if args.validate_matrix is not None:
|
|
2719
|
+
if not args.validate_matrix.strip():
|
|
2720
|
+
print("⚠ --validate-matrix is empty; skipping matrix validation.", file=sys.stderr)
|
|
2721
|
+
_exit_with_gate(args, suite_failed, results, current_snapshot, needs_results=False)
|
|
2722
|
+
if args.matrix_drift_gate == "warn":
|
|
2723
|
+
print("⚠ report-only: matrix drift is printed but does not affect exit code.", file=sys.stderr)
|
|
2724
|
+
elif not args.run_tests:
|
|
2725
|
+
print("⚠ Exiting non-zero: fail-mode matrix validation requires --run-tests "
|
|
2726
|
+
"in the same gen_report.py invocation to prove JUnit freshness.",
|
|
2727
|
+
file=sys.stderr)
|
|
2728
|
+
sys.exit(1)
|
|
2729
|
+
report = validate_matrix(
|
|
2730
|
+
args.validate_matrix,
|
|
2731
|
+
records,
|
|
2732
|
+
results.get("tc_layers", {}),
|
|
2733
|
+
results.get("configured_layers", []),
|
|
2734
|
+
results.get("unlayered_tcs", []),
|
|
2735
|
+
)
|
|
2736
|
+
print_matrix_validation(report, results)
|
|
2737
|
+
if args.matrix_drift_gate == "fail" and _matrix_gate_should_fail(report, results):
|
|
2738
|
+
print("⚠ Exiting non-zero: test-matrix drift detected.", file=sys.stderr)
|
|
2739
|
+
sys.exit(1)
|
|
2740
|
+
_exit_with_gate(
|
|
2741
|
+
args,
|
|
2742
|
+
suite_failed,
|
|
2743
|
+
results,
|
|
2744
|
+
current_snapshot,
|
|
2745
|
+
needs_results=args.matrix_drift_gate == "fail",
|
|
2746
|
+
)
|
|
2747
|
+
|
|
2748
|
+
if args.unfreeze:
|
|
2749
|
+
if not args.unfreeze_reason.strip():
|
|
2750
|
+
parser.error("--unfreeze requires --unfreeze-reason '<why this 废弃 is being reopened>'")
|
|
2751
|
+
ids = [s.strip() for s in args.unfreeze.split(",") if s.strip()]
|
|
2752
|
+
restored = unfreeze_tc(base_token, table_id, args.identity,
|
|
2753
|
+
records, ids, args.author, args.unfreeze_reason)
|
|
2754
|
+
print(f"Unfroze {restored}/{len(ids)} TC(s).", file=sys.stderr)
|
|
2755
|
+
_exit_with_gate(args, suite_failed, results, current_snapshot, needs_results=False)
|
|
2756
|
+
|
|
2757
|
+
if args.detect_orphans:
|
|
2758
|
+
if not results["linked"]:
|
|
2759
|
+
print("No linked test results — run tests first.", file=sys.stderr)
|
|
2760
|
+
else:
|
|
2761
|
+
orphans = detect_orphans(records, results["linked"])
|
|
2762
|
+
print_orphan_report(orphans)
|
|
2763
|
+
_exit_with_gate(args, suite_failed, results, current_snapshot)
|
|
2764
|
+
|
|
2765
|
+
auto_count = 0
|
|
2766
|
+
if results["linked"] and not args.dry_run:
|
|
2767
|
+
print(f"Syncing {len(results['linked'])} TC results to Bitable...", file=sys.stderr)
|
|
2768
|
+
auto_count = sync_results_with_append(
|
|
2769
|
+
base_token, table_id, args.identity,
|
|
2770
|
+
records, results["linked"], args.author
|
|
2771
|
+
)
|
|
2772
|
+
print(f" {auto_count} records updated", file=sys.stderr)
|
|
2773
|
+
elif results["linked"] and args.dry_run:
|
|
2774
|
+
print(f" [dry-run] would update {len(results['linked'])} TC results", file=sys.stderr)
|
|
2775
|
+
|
|
2776
|
+
# Orphan detection always runs in Bitable mode; surfaces in report + stderr.
|
|
2777
|
+
orphans = detect_orphans(records, results["linked"]) if results["linked"] else \
|
|
2778
|
+
{"deprecated_in_tests": [], "missing_in_bitable": []}
|
|
2779
|
+
if orphans["deprecated_in_tests"] or orphans["missing_in_bitable"]:
|
|
2780
|
+
print_orphan_report(orphans)
|
|
2781
|
+
|
|
2782
|
+
agg = aggregate(records)
|
|
2783
|
+
markdown = build_markdown(agg, args.author, args.source, args.version,
|
|
2784
|
+
auto_count=auto_count,
|
|
2785
|
+
untracked=results["untracked"],
|
|
2786
|
+
orphans=orphans,
|
|
2787
|
+
coverage=coverage,
|
|
2788
|
+
last_run_diff=last_run_diff,
|
|
2789
|
+
results_for_risk=results)
|
|
2790
|
+
if args.dry_run:
|
|
2791
|
+
print(markdown)
|
|
2792
|
+
else:
|
|
2793
|
+
doc_title = "自动化测试报告"
|
|
2794
|
+
if doc_url:
|
|
2795
|
+
print(f"Updating report doc: {doc_url}", file=sys.stderr)
|
|
2796
|
+
update_doc(doc_url, markdown, args.identity)
|
|
2797
|
+
print(f"Updated: {doc_url}")
|
|
2798
|
+
else:
|
|
2799
|
+
print("Creating report doc...", file=sys.stderr)
|
|
2800
|
+
doc_url = create_doc(doc_title, markdown, folder_token, args.identity)
|
|
2801
|
+
save_doc_url(args.config, doc_url)
|
|
2802
|
+
print(f"Created: {doc_url}")
|
|
2803
|
+
_exit_with_gate(args, suite_failed, results, current_snapshot)
|
|
2804
|
+
|
|
2805
|
+
|
|
2806
|
+
if __name__ == "__main__":
|
|
2807
|
+
main()
|