tapps-agents 3.5.40__py3-none-any.whl → 3.6.0__py3-none-any.whl
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.
- tapps_agents/__init__.py +2 -2
- tapps_agents/agents/__init__.py +22 -22
- tapps_agents/agents/analyst/__init__.py +5 -5
- tapps_agents/agents/architect/__init__.py +5 -5
- tapps_agents/agents/architect/agent.py +1033 -1033
- tapps_agents/agents/architect/pattern_detector.py +75 -75
- tapps_agents/agents/cleanup/__init__.py +7 -7
- tapps_agents/agents/cleanup/agent.py +445 -445
- tapps_agents/agents/debugger/__init__.py +7 -7
- tapps_agents/agents/debugger/agent.py +310 -310
- tapps_agents/agents/debugger/error_analyzer.py +437 -437
- tapps_agents/agents/designer/__init__.py +5 -5
- tapps_agents/agents/designer/agent.py +786 -786
- tapps_agents/agents/designer/visual_designer.py +638 -638
- tapps_agents/agents/documenter/__init__.py +7 -7
- tapps_agents/agents/documenter/agent.py +531 -531
- tapps_agents/agents/documenter/doc_generator.py +472 -472
- tapps_agents/agents/documenter/doc_validator.py +393 -393
- tapps_agents/agents/documenter/framework_doc_updater.py +493 -493
- tapps_agents/agents/enhancer/__init__.py +7 -7
- tapps_agents/agents/evaluator/__init__.py +7 -7
- tapps_agents/agents/evaluator/agent.py +443 -443
- tapps_agents/agents/evaluator/priority_evaluator.py +641 -641
- tapps_agents/agents/evaluator/quality_analyzer.py +147 -147
- tapps_agents/agents/evaluator/report_generator.py +344 -344
- tapps_agents/agents/evaluator/usage_analyzer.py +192 -192
- tapps_agents/agents/evaluator/workflow_analyzer.py +189 -189
- tapps_agents/agents/implementer/__init__.py +7 -7
- tapps_agents/agents/implementer/agent.py +798 -798
- tapps_agents/agents/implementer/auto_fix.py +1119 -1119
- tapps_agents/agents/implementer/code_generator.py +73 -73
- tapps_agents/agents/improver/__init__.py +1 -1
- tapps_agents/agents/improver/agent.py +753 -753
- tapps_agents/agents/ops/__init__.py +1 -1
- tapps_agents/agents/ops/agent.py +619 -619
- tapps_agents/agents/ops/dependency_analyzer.py +600 -600
- tapps_agents/agents/orchestrator/__init__.py +5 -5
- tapps_agents/agents/orchestrator/agent.py +522 -522
- tapps_agents/agents/planner/__init__.py +7 -7
- tapps_agents/agents/planner/agent.py +1127 -1127
- tapps_agents/agents/reviewer/__init__.py +24 -24
- tapps_agents/agents/reviewer/agent.py +3513 -3513
- tapps_agents/agents/reviewer/aggregator.py +213 -213
- tapps_agents/agents/reviewer/batch_review.py +448 -448
- tapps_agents/agents/reviewer/cache.py +443 -443
- tapps_agents/agents/reviewer/context7_enhancer.py +630 -630
- tapps_agents/agents/reviewer/context_detector.py +203 -203
- tapps_agents/agents/reviewer/docker_compose_validator.py +158 -158
- tapps_agents/agents/reviewer/dockerfile_validator.py +176 -176
- tapps_agents/agents/reviewer/error_handling.py +126 -126
- tapps_agents/agents/reviewer/feedback_generator.py +490 -490
- tapps_agents/agents/reviewer/influxdb_validator.py +316 -316
- tapps_agents/agents/reviewer/issue_tracking.py +169 -169
- tapps_agents/agents/reviewer/library_detector.py +295 -295
- tapps_agents/agents/reviewer/library_patterns.py +268 -268
- tapps_agents/agents/reviewer/maintainability_scorer.py +593 -593
- tapps_agents/agents/reviewer/metric_strategies.py +276 -276
- tapps_agents/agents/reviewer/mqtt_validator.py +160 -160
- tapps_agents/agents/reviewer/output_enhancer.py +105 -105
- tapps_agents/agents/reviewer/pattern_detector.py +241 -241
- tapps_agents/agents/reviewer/performance_scorer.py +357 -357
- tapps_agents/agents/reviewer/phased_review.py +516 -516
- tapps_agents/agents/reviewer/progressive_review.py +435 -435
- tapps_agents/agents/reviewer/react_scorer.py +331 -331
- tapps_agents/agents/reviewer/score_constants.py +228 -228
- tapps_agents/agents/reviewer/score_validator.py +507 -507
- tapps_agents/agents/reviewer/scorer_registry.py +373 -373
- tapps_agents/agents/reviewer/scoring.py +1566 -1566
- tapps_agents/agents/reviewer/service_discovery.py +534 -534
- tapps_agents/agents/reviewer/tools/__init__.py +41 -41
- tapps_agents/agents/reviewer/tools/parallel_executor.py +581 -581
- tapps_agents/agents/reviewer/tools/ruff_grouping.py +250 -250
- tapps_agents/agents/reviewer/tools/scoped_mypy.py +284 -284
- tapps_agents/agents/reviewer/typescript_scorer.py +1142 -1142
- tapps_agents/agents/reviewer/validation.py +208 -208
- tapps_agents/agents/reviewer/websocket_validator.py +132 -132
- tapps_agents/agents/tester/__init__.py +7 -7
- tapps_agents/agents/tester/accessibility_auditor.py +309 -309
- tapps_agents/agents/tester/agent.py +1080 -1080
- tapps_agents/agents/tester/batch_generator.py +54 -54
- tapps_agents/agents/tester/context_learner.py +51 -51
- tapps_agents/agents/tester/coverage_analyzer.py +386 -386
- tapps_agents/agents/tester/coverage_test_generator.py +290 -290
- tapps_agents/agents/tester/debug_enhancer.py +238 -238
- tapps_agents/agents/tester/device_emulator.py +241 -241
- tapps_agents/agents/tester/integration_generator.py +62 -62
- tapps_agents/agents/tester/network_recorder.py +300 -300
- tapps_agents/agents/tester/performance_monitor.py +320 -320
- tapps_agents/agents/tester/test_fixer.py +316 -316
- tapps_agents/agents/tester/test_generator.py +632 -632
- tapps_agents/agents/tester/trace_manager.py +234 -234
- tapps_agents/agents/tester/visual_regression.py +291 -291
- tapps_agents/analysis/pattern_detector.py +36 -36
- tapps_agents/beads/hydration.py +213 -213
- tapps_agents/beads/parse.py +32 -32
- tapps_agents/beads/specs.py +206 -206
- tapps_agents/cli/__init__.py +9 -9
- tapps_agents/cli/__main__.py +8 -8
- tapps_agents/cli/base.py +478 -478
- tapps_agents/cli/command_classifier.py +72 -72
- tapps_agents/cli/commands/__init__.py +2 -2
- tapps_agents/cli/commands/analyst.py +173 -173
- tapps_agents/cli/commands/architect.py +109 -109
- tapps_agents/cli/commands/cleanup_agent.py +92 -92
- tapps_agents/cli/commands/common.py +126 -126
- tapps_agents/cli/commands/debugger.py +90 -90
- tapps_agents/cli/commands/designer.py +112 -112
- tapps_agents/cli/commands/documenter.py +136 -136
- tapps_agents/cli/commands/enhancer.py +110 -110
- tapps_agents/cli/commands/evaluator.py +255 -255
- tapps_agents/cli/commands/health.py +665 -665
- tapps_agents/cli/commands/implementer.py +301 -301
- tapps_agents/cli/commands/improver.py +91 -91
- tapps_agents/cli/commands/knowledge.py +111 -111
- tapps_agents/cli/commands/learning.py +172 -172
- tapps_agents/cli/commands/observability.py +283 -283
- tapps_agents/cli/commands/ops.py +135 -135
- tapps_agents/cli/commands/orchestrator.py +116 -116
- tapps_agents/cli/commands/planner.py +237 -237
- tapps_agents/cli/commands/reviewer.py +1872 -1872
- tapps_agents/cli/commands/status.py +285 -285
- tapps_agents/cli/commands/task.py +227 -219
- tapps_agents/cli/commands/tester.py +191 -191
- tapps_agents/cli/commands/top_level.py +3586 -3586
- tapps_agents/cli/feedback.py +936 -936
- tapps_agents/cli/formatters.py +608 -608
- tapps_agents/cli/help/__init__.py +7 -7
- tapps_agents/cli/help/static_help.py +425 -425
- tapps_agents/cli/network_detection.py +110 -110
- tapps_agents/cli/output_compactor.py +274 -274
- tapps_agents/cli/parsers/__init__.py +2 -2
- tapps_agents/cli/parsers/analyst.py +186 -186
- tapps_agents/cli/parsers/architect.py +167 -167
- tapps_agents/cli/parsers/cleanup_agent.py +228 -228
- tapps_agents/cli/parsers/debugger.py +116 -116
- tapps_agents/cli/parsers/designer.py +182 -182
- tapps_agents/cli/parsers/documenter.py +134 -134
- tapps_agents/cli/parsers/enhancer.py +113 -113
- tapps_agents/cli/parsers/evaluator.py +213 -213
- tapps_agents/cli/parsers/implementer.py +168 -168
- tapps_agents/cli/parsers/improver.py +132 -132
- tapps_agents/cli/parsers/ops.py +159 -159
- tapps_agents/cli/parsers/orchestrator.py +98 -98
- tapps_agents/cli/parsers/planner.py +145 -145
- tapps_agents/cli/parsers/reviewer.py +462 -462
- tapps_agents/cli/parsers/tester.py +124 -124
- tapps_agents/cli/progress_heartbeat.py +254 -254
- tapps_agents/cli/streaming_progress.py +336 -336
- tapps_agents/cli/utils/__init__.py +6 -6
- tapps_agents/cli/utils/agent_lifecycle.py +48 -48
- tapps_agents/cli/utils/error_formatter.py +82 -82
- tapps_agents/cli/utils/error_recovery.py +188 -188
- tapps_agents/cli/utils/output_handler.py +59 -59
- tapps_agents/cli/utils/prompt_enhancer.py +319 -319
- tapps_agents/cli/validators/__init__.py +9 -9
- tapps_agents/cli/validators/command_validator.py +81 -81
- tapps_agents/context7/__init__.py +112 -112
- tapps_agents/context7/agent_integration.py +869 -869
- tapps_agents/context7/analytics.py +382 -382
- tapps_agents/context7/analytics_dashboard.py +299 -299
- tapps_agents/context7/async_cache.py +681 -681
- tapps_agents/context7/backup_client.py +958 -958
- tapps_agents/context7/cache_locking.py +194 -194
- tapps_agents/context7/cache_metadata.py +214 -214
- tapps_agents/context7/cache_prewarm.py +488 -488
- tapps_agents/context7/cache_structure.py +168 -168
- tapps_agents/context7/cache_warming.py +604 -604
- tapps_agents/context7/circuit_breaker.py +376 -376
- tapps_agents/context7/cleanup.py +461 -461
- tapps_agents/context7/commands.py +858 -858
- tapps_agents/context7/credential_validation.py +276 -276
- tapps_agents/context7/cross_reference_resolver.py +168 -168
- tapps_agents/context7/cross_references.py +424 -424
- tapps_agents/context7/doc_manager.py +225 -225
- tapps_agents/context7/fuzzy_matcher.py +369 -369
- tapps_agents/context7/kb_cache.py +404 -404
- tapps_agents/context7/language_detector.py +219 -219
- tapps_agents/context7/library_detector.py +725 -725
- tapps_agents/context7/lookup.py +738 -738
- tapps_agents/context7/metadata.py +258 -258
- tapps_agents/context7/refresh_queue.py +300 -300
- tapps_agents/context7/security.py +373 -373
- tapps_agents/context7/staleness_policies.py +278 -278
- tapps_agents/context7/tiles_integration.py +47 -47
- tapps_agents/continuous_bug_fix/__init__.py +20 -20
- tapps_agents/continuous_bug_fix/bug_finder.py +306 -306
- tapps_agents/continuous_bug_fix/bug_fix_coordinator.py +177 -177
- tapps_agents/continuous_bug_fix/commit_manager.py +178 -178
- tapps_agents/continuous_bug_fix/continuous_bug_fixer.py +322 -322
- tapps_agents/continuous_bug_fix/proactive_bug_finder.py +285 -285
- tapps_agents/core/__init__.py +298 -298
- tapps_agents/core/adaptive_cache_config.py +432 -432
- tapps_agents/core/agent_base.py +647 -647
- tapps_agents/core/agent_cache.py +466 -466
- tapps_agents/core/agent_learning.py +1865 -1865
- tapps_agents/core/analytics_dashboard.py +563 -563
- tapps_agents/core/analytics_enhancements.py +597 -597
- tapps_agents/core/anonymization.py +274 -274
- tapps_agents/core/artifact_context_builder.py +293 -0
- tapps_agents/core/ast_parser.py +228 -228
- tapps_agents/core/async_file_ops.py +402 -402
- tapps_agents/core/best_practice_consultant.py +299 -299
- tapps_agents/core/brownfield_analyzer.py +299 -299
- tapps_agents/core/brownfield_review.py +541 -541
- tapps_agents/core/browser_controller.py +513 -513
- tapps_agents/core/capability_registry.py +418 -418
- tapps_agents/core/change_impact_analyzer.py +190 -190
- tapps_agents/core/checkpoint_manager.py +377 -377
- tapps_agents/core/code_generator.py +329 -329
- tapps_agents/core/code_validator.py +276 -276
- tapps_agents/core/command_registry.py +327 -327
- tapps_agents/core/config.py +33 -0
- tapps_agents/core/context_gathering/__init__.py +2 -2
- tapps_agents/core/context_gathering/repository_explorer.py +28 -28
- tapps_agents/core/context_intelligence/__init__.py +2 -2
- tapps_agents/core/context_intelligence/relevance_scorer.py +24 -24
- tapps_agents/core/context_intelligence/token_budget_manager.py +27 -27
- tapps_agents/core/context_manager.py +240 -240
- tapps_agents/core/cursor_feedback_monitor.py +146 -146
- tapps_agents/core/cursor_verification.py +290 -290
- tapps_agents/core/customization_loader.py +280 -280
- tapps_agents/core/customization_schema.py +260 -260
- tapps_agents/core/customization_template.py +238 -238
- tapps_agents/core/debug_logger.py +124 -124
- tapps_agents/core/design_validator.py +298 -298
- tapps_agents/core/diagram_generator.py +226 -226
- tapps_agents/core/docker_utils.py +232 -232
- tapps_agents/core/document_generator.py +617 -617
- tapps_agents/core/domain_detector.py +30 -30
- tapps_agents/core/error_envelope.py +454 -454
- tapps_agents/core/error_handler.py +270 -270
- tapps_agents/core/estimation_tracker.py +189 -189
- tapps_agents/core/eval_prompt_engine.py +116 -116
- tapps_agents/core/evaluation_base.py +119 -119
- tapps_agents/core/evaluation_models.py +320 -320
- tapps_agents/core/evaluation_orchestrator.py +225 -225
- tapps_agents/core/evaluators/__init__.py +7 -7
- tapps_agents/core/evaluators/architectural_evaluator.py +205 -205
- tapps_agents/core/evaluators/behavioral_evaluator.py +160 -160
- tapps_agents/core/evaluators/performance_profile_evaluator.py +160 -160
- tapps_agents/core/evaluators/security_posture_evaluator.py +148 -148
- tapps_agents/core/evaluators/spec_compliance_evaluator.py +181 -181
- tapps_agents/core/exceptions.py +107 -107
- tapps_agents/core/expert_config_generator.py +293 -293
- tapps_agents/core/export_schema.py +202 -202
- tapps_agents/core/external_feedback_models.py +102 -102
- tapps_agents/core/external_feedback_storage.py +213 -213
- tapps_agents/core/fallback_strategy.py +314 -314
- tapps_agents/core/feedback_analyzer.py +162 -162
- tapps_agents/core/feedback_collector.py +178 -178
- tapps_agents/core/git_operations.py +445 -445
- tapps_agents/core/hardware_profiler.py +151 -151
- tapps_agents/core/instructions.py +324 -324
- tapps_agents/core/io_guardrails.py +69 -69
- tapps_agents/core/issue_manifest.py +249 -249
- tapps_agents/core/issue_schema.py +139 -139
- tapps_agents/core/json_utils.py +128 -128
- tapps_agents/core/knowledge_graph.py +446 -446
- tapps_agents/core/language_detector.py +296 -296
- tapps_agents/core/learning_confidence.py +242 -242
- tapps_agents/core/learning_dashboard.py +246 -246
- tapps_agents/core/learning_decision.py +384 -384
- tapps_agents/core/learning_explainability.py +578 -578
- tapps_agents/core/learning_export.py +287 -287
- tapps_agents/core/learning_integration.py +228 -228
- tapps_agents/core/llm_behavior.py +232 -232
- tapps_agents/core/long_duration_support.py +786 -786
- tapps_agents/core/mcp_setup.py +106 -106
- tapps_agents/core/memory_integration.py +396 -396
- tapps_agents/core/meta_learning.py +666 -666
- tapps_agents/core/module_path_sanitizer.py +199 -199
- tapps_agents/core/multi_agent_orchestrator.py +382 -382
- tapps_agents/core/network_errors.py +125 -125
- tapps_agents/core/nfr_validator.py +336 -336
- tapps_agents/core/offline_mode.py +158 -158
- tapps_agents/core/output_contracts.py +300 -300
- tapps_agents/core/output_formatter.py +300 -300
- tapps_agents/core/path_normalizer.py +174 -174
- tapps_agents/core/path_validator.py +322 -322
- tapps_agents/core/pattern_library.py +250 -250
- tapps_agents/core/performance_benchmark.py +301 -301
- tapps_agents/core/performance_monitor.py +184 -184
- tapps_agents/core/playwright_mcp_controller.py +771 -771
- tapps_agents/core/policy_loader.py +135 -135
- tapps_agents/core/progress.py +166 -166
- tapps_agents/core/project_profile.py +354 -354
- tapps_agents/core/project_type_detector.py +454 -454
- tapps_agents/core/prompt_base.py +223 -223
- tapps_agents/core/prompt_learning/__init__.py +2 -2
- tapps_agents/core/prompt_learning/learning_loop.py +24 -24
- tapps_agents/core/prompt_learning/project_prompt_store.py +25 -25
- tapps_agents/core/prompt_learning/skills_prompt_analyzer.py +35 -35
- tapps_agents/core/prompt_optimization/__init__.py +6 -6
- tapps_agents/core/prompt_optimization/ab_tester.py +114 -114
- tapps_agents/core/prompt_optimization/correlation_analyzer.py +160 -160
- tapps_agents/core/prompt_optimization/progressive_refiner.py +129 -129
- tapps_agents/core/prompt_optimization/prompt_library.py +37 -37
- tapps_agents/core/requirements_evaluator.py +431 -431
- tapps_agents/core/resource_aware_executor.py +449 -449
- tapps_agents/core/resource_monitor.py +343 -343
- tapps_agents/core/resume_handler.py +298 -298
- tapps_agents/core/retry_handler.py +197 -197
- tapps_agents/core/review_checklists.py +479 -479
- tapps_agents/core/role_loader.py +201 -201
- tapps_agents/core/role_template_loader.py +201 -201
- tapps_agents/core/runtime_mode.py +60 -60
- tapps_agents/core/security_scanner.py +342 -342
- tapps_agents/core/skill_agent_registry.py +194 -194
- tapps_agents/core/skill_integration.py +208 -208
- tapps_agents/core/skill_loader.py +492 -492
- tapps_agents/core/skill_template.py +341 -341
- tapps_agents/core/skill_validator.py +478 -478
- tapps_agents/core/stack_analyzer.py +35 -35
- tapps_agents/core/startup.py +174 -174
- tapps_agents/core/storage_manager.py +397 -397
- tapps_agents/core/storage_models.py +166 -166
- tapps_agents/core/story_evaluator.py +410 -410
- tapps_agents/core/subprocess_utils.py +170 -170
- tapps_agents/core/task_duration.py +296 -296
- tapps_agents/core/task_memory.py +582 -582
- tapps_agents/core/task_state.py +226 -226
- tapps_agents/core/tech_stack_priorities.py +208 -208
- tapps_agents/core/temp_directory.py +194 -194
- tapps_agents/core/template_merger.py +600 -600
- tapps_agents/core/template_selector.py +280 -280
- tapps_agents/core/test_generator.py +286 -286
- tapps_agents/core/tiered_context.py +253 -253
- tapps_agents/core/token_monitor.py +345 -345
- tapps_agents/core/traceability.py +254 -254
- tapps_agents/core/trajectory_tracker.py +50 -50
- tapps_agents/core/unicode_safe.py +143 -143
- tapps_agents/core/unified_cache_config.py +170 -170
- tapps_agents/core/unified_state.py +324 -324
- tapps_agents/core/validate_cursor_setup.py +237 -237
- tapps_agents/core/validation_registry.py +136 -136
- tapps_agents/core/validators/__init__.py +4 -4
- tapps_agents/core/validators/python_validator.py +87 -87
- tapps_agents/core/verification_agent.py +90 -90
- tapps_agents/core/visual_feedback.py +644 -644
- tapps_agents/core/workflow_validator.py +197 -197
- tapps_agents/core/worktree.py +367 -367
- tapps_agents/docker/__init__.py +10 -10
- tapps_agents/docker/analyzer.py +186 -186
- tapps_agents/docker/debugger.py +229 -229
- tapps_agents/docker/error_patterns.py +216 -216
- tapps_agents/epic/__init__.py +22 -22
- tapps_agents/epic/beads_sync.py +115 -115
- tapps_agents/epic/markdown_sync.py +105 -105
- tapps_agents/epic/models.py +96 -96
- tapps_agents/experts/__init__.py +163 -163
- tapps_agents/experts/agent_integration.py +243 -243
- tapps_agents/experts/auto_generator.py +331 -331
- tapps_agents/experts/base_expert.py +536 -536
- tapps_agents/experts/builtin_registry.py +261 -261
- tapps_agents/experts/business_metrics.py +565 -565
- tapps_agents/experts/cache.py +266 -266
- tapps_agents/experts/confidence_breakdown.py +306 -306
- tapps_agents/experts/confidence_calculator.py +336 -336
- tapps_agents/experts/confidence_metrics.py +236 -236
- tapps_agents/experts/domain_config.py +311 -311
- tapps_agents/experts/domain_detector.py +550 -550
- tapps_agents/experts/domain_utils.py +84 -84
- tapps_agents/experts/expert_config.py +113 -113
- tapps_agents/experts/expert_engine.py +465 -465
- tapps_agents/experts/expert_registry.py +744 -744
- tapps_agents/experts/expert_synthesizer.py +70 -70
- tapps_agents/experts/governance.py +197 -197
- tapps_agents/experts/history_logger.py +312 -312
- tapps_agents/experts/knowledge/README.md +180 -180
- tapps_agents/experts/knowledge/accessibility/accessible-forms.md +331 -331
- tapps_agents/experts/knowledge/accessibility/aria-patterns.md +344 -344
- tapps_agents/experts/knowledge/accessibility/color-contrast.md +285 -285
- tapps_agents/experts/knowledge/accessibility/keyboard-navigation.md +332 -332
- tapps_agents/experts/knowledge/accessibility/screen-readers.md +282 -282
- tapps_agents/experts/knowledge/accessibility/semantic-html.md +355 -355
- tapps_agents/experts/knowledge/accessibility/testing-accessibility.md +369 -369
- tapps_agents/experts/knowledge/accessibility/wcag-2.1.md +296 -296
- tapps_agents/experts/knowledge/accessibility/wcag-2.2.md +211 -211
- tapps_agents/experts/knowledge/agent-learning/best-practices.md +715 -715
- tapps_agents/experts/knowledge/agent-learning/pattern-extraction.md +282 -282
- tapps_agents/experts/knowledge/agent-learning/prompt-optimization.md +320 -320
- tapps_agents/experts/knowledge/ai-frameworks/model-optimization.md +90 -90
- tapps_agents/experts/knowledge/ai-frameworks/openvino-patterns.md +260 -260
- tapps_agents/experts/knowledge/api-design-integration/api-gateway-patterns.md +309 -309
- tapps_agents/experts/knowledge/api-design-integration/api-security-patterns.md +521 -521
- tapps_agents/experts/knowledge/api-design-integration/api-versioning.md +421 -421
- tapps_agents/experts/knowledge/api-design-integration/async-protocol-patterns.md +61 -61
- tapps_agents/experts/knowledge/api-design-integration/contract-testing.md +221 -221
- tapps_agents/experts/knowledge/api-design-integration/external-api-integration.md +489 -489
- tapps_agents/experts/knowledge/api-design-integration/fastapi-patterns.md +360 -360
- tapps_agents/experts/knowledge/api-design-integration/fastapi-testing.md +262 -262
- tapps_agents/experts/knowledge/api-design-integration/graphql-patterns.md +582 -582
- tapps_agents/experts/knowledge/api-design-integration/grpc-best-practices.md +499 -499
- tapps_agents/experts/knowledge/api-design-integration/mqtt-patterns.md +455 -455
- tapps_agents/experts/knowledge/api-design-integration/rate-limiting.md +507 -507
- tapps_agents/experts/knowledge/api-design-integration/restful-api-design.md +618 -618
- tapps_agents/experts/knowledge/api-design-integration/websocket-patterns.md +480 -480
- tapps_agents/experts/knowledge/cloud-infrastructure/cloud-native-patterns.md +175 -175
- tapps_agents/experts/knowledge/cloud-infrastructure/container-health-checks.md +261 -261
- tapps_agents/experts/knowledge/cloud-infrastructure/containerization.md +222 -222
- tapps_agents/experts/knowledge/cloud-infrastructure/cost-optimization.md +122 -122
- tapps_agents/experts/knowledge/cloud-infrastructure/disaster-recovery.md +153 -153
- tapps_agents/experts/knowledge/cloud-infrastructure/dockerfile-patterns.md +285 -285
- tapps_agents/experts/knowledge/cloud-infrastructure/infrastructure-as-code.md +187 -187
- tapps_agents/experts/knowledge/cloud-infrastructure/kubernetes-patterns.md +253 -253
- tapps_agents/experts/knowledge/cloud-infrastructure/multi-cloud-strategies.md +155 -155
- tapps_agents/experts/knowledge/cloud-infrastructure/serverless-architecture.md +200 -200
- tapps_agents/experts/knowledge/code-quality-analysis/README.md +16 -16
- tapps_agents/experts/knowledge/code-quality-analysis/code-metrics.md +137 -137
- tapps_agents/experts/knowledge/code-quality-analysis/complexity-analysis.md +181 -181
- tapps_agents/experts/knowledge/code-quality-analysis/technical-debt-patterns.md +191 -191
- tapps_agents/experts/knowledge/data-privacy-compliance/anonymization.md +313 -313
- tapps_agents/experts/knowledge/data-privacy-compliance/ccpa.md +255 -255
- tapps_agents/experts/knowledge/data-privacy-compliance/consent-management.md +282 -282
- tapps_agents/experts/knowledge/data-privacy-compliance/data-minimization.md +275 -275
- tapps_agents/experts/knowledge/data-privacy-compliance/data-retention.md +297 -297
- tapps_agents/experts/knowledge/data-privacy-compliance/data-subject-rights.md +383 -383
- tapps_agents/experts/knowledge/data-privacy-compliance/encryption-privacy.md +285 -285
- tapps_agents/experts/knowledge/data-privacy-compliance/gdpr.md +344 -344
- tapps_agents/experts/knowledge/data-privacy-compliance/hipaa.md +385 -385
- tapps_agents/experts/knowledge/data-privacy-compliance/privacy-by-design.md +280 -280
- tapps_agents/experts/knowledge/database-data-management/acid-vs-cap.md +164 -164
- tapps_agents/experts/knowledge/database-data-management/backup-and-recovery.md +182 -182
- tapps_agents/experts/knowledge/database-data-management/data-modeling.md +172 -172
- tapps_agents/experts/knowledge/database-data-management/database-design.md +187 -187
- tapps_agents/experts/knowledge/database-data-management/flux-query-optimization.md +342 -342
- tapps_agents/experts/knowledge/database-data-management/influxdb-connection-patterns.md +432 -432
- tapps_agents/experts/knowledge/database-data-management/influxdb-patterns.md +442 -442
- tapps_agents/experts/knowledge/database-data-management/migration-strategies.md +216 -216
- tapps_agents/experts/knowledge/database-data-management/nosql-patterns.md +259 -259
- tapps_agents/experts/knowledge/database-data-management/scalability-patterns.md +184 -184
- tapps_agents/experts/knowledge/database-data-management/sql-optimization.md +175 -175
- tapps_agents/experts/knowledge/database-data-management/time-series-modeling.md +444 -444
- tapps_agents/experts/knowledge/development-workflow/README.md +16 -16
- tapps_agents/experts/knowledge/development-workflow/automation-best-practices.md +216 -216
- tapps_agents/experts/knowledge/development-workflow/build-strategies.md +198 -198
- tapps_agents/experts/knowledge/development-workflow/deployment-patterns.md +205 -205
- tapps_agents/experts/knowledge/development-workflow/git-workflows.md +205 -205
- tapps_agents/experts/knowledge/documentation-knowledge-management/README.md +16 -16
- tapps_agents/experts/knowledge/documentation-knowledge-management/api-documentation-patterns.md +231 -231
- tapps_agents/experts/knowledge/documentation-knowledge-management/documentation-standards.md +191 -191
- tapps_agents/experts/knowledge/documentation-knowledge-management/knowledge-management.md +171 -171
- tapps_agents/experts/knowledge/documentation-knowledge-management/technical-writing-guide.md +192 -192
- tapps_agents/experts/knowledge/observability-monitoring/alerting-patterns.md +461 -461
- tapps_agents/experts/knowledge/observability-monitoring/apm-tools.md +459 -459
- tapps_agents/experts/knowledge/observability-monitoring/distributed-tracing.md +367 -367
- tapps_agents/experts/knowledge/observability-monitoring/logging-strategies.md +478 -478
- tapps_agents/experts/knowledge/observability-monitoring/metrics-and-monitoring.md +510 -510
- tapps_agents/experts/knowledge/observability-monitoring/observability-best-practices.md +492 -492
- tapps_agents/experts/knowledge/observability-monitoring/open-telemetry.md +573 -573
- tapps_agents/experts/knowledge/observability-monitoring/slo-sli-sla.md +419 -419
- tapps_agents/experts/knowledge/performance/anti-patterns.md +284 -284
- tapps_agents/experts/knowledge/performance/api-performance.md +256 -256
- tapps_agents/experts/knowledge/performance/caching.md +327 -327
- tapps_agents/experts/knowledge/performance/database-performance.md +252 -252
- tapps_agents/experts/knowledge/performance/optimization-patterns.md +327 -327
- tapps_agents/experts/knowledge/performance/profiling.md +297 -297
- tapps_agents/experts/knowledge/performance/resource-management.md +293 -293
- tapps_agents/experts/knowledge/performance/scalability.md +306 -306
- tapps_agents/experts/knowledge/security/owasp-top10.md +209 -209
- tapps_agents/experts/knowledge/security/secure-coding-practices.md +207 -207
- tapps_agents/experts/knowledge/security/threat-modeling.md +220 -220
- tapps_agents/experts/knowledge/security/vulnerability-patterns.md +342 -342
- tapps_agents/experts/knowledge/software-architecture/docker-compose-patterns.md +314 -314
- tapps_agents/experts/knowledge/software-architecture/microservices-patterns.md +379 -379
- tapps_agents/experts/knowledge/software-architecture/service-communication.md +316 -316
- tapps_agents/experts/knowledge/testing/best-practices.md +310 -310
- tapps_agents/experts/knowledge/testing/coverage-analysis.md +293 -293
- tapps_agents/experts/knowledge/testing/mocking.md +256 -256
- tapps_agents/experts/knowledge/testing/test-automation.md +276 -276
- tapps_agents/experts/knowledge/testing/test-data.md +271 -271
- tapps_agents/experts/knowledge/testing/test-design-patterns.md +280 -280
- tapps_agents/experts/knowledge/testing/test-maintenance.md +236 -236
- tapps_agents/experts/knowledge/testing/test-strategies.md +311 -311
- tapps_agents/experts/knowledge/user-experience/information-architecture.md +325 -325
- tapps_agents/experts/knowledge/user-experience/interaction-design.md +363 -363
- tapps_agents/experts/knowledge/user-experience/prototyping.md +293 -293
- tapps_agents/experts/knowledge/user-experience/usability-heuristics.md +337 -337
- tapps_agents/experts/knowledge/user-experience/usability-testing.md +311 -311
- tapps_agents/experts/knowledge/user-experience/user-journeys.md +296 -296
- tapps_agents/experts/knowledge/user-experience/user-research.md +373 -373
- tapps_agents/experts/knowledge/user-experience/ux-principles.md +340 -340
- tapps_agents/experts/knowledge_freshness.py +321 -321
- tapps_agents/experts/knowledge_ingestion.py +438 -438
- tapps_agents/experts/knowledge_need_detector.py +93 -93
- tapps_agents/experts/knowledge_validator.py +382 -382
- tapps_agents/experts/observability.py +440 -440
- tapps_agents/experts/passive_notifier.py +238 -238
- tapps_agents/experts/proactive_orchestrator.py +32 -32
- tapps_agents/experts/rag_chunker.py +205 -205
- tapps_agents/experts/rag_embedder.py +152 -152
- tapps_agents/experts/rag_evaluation.py +299 -299
- tapps_agents/experts/rag_index.py +303 -303
- tapps_agents/experts/rag_metrics.py +293 -293
- tapps_agents/experts/rag_safety.py +263 -263
- tapps_agents/experts/report_generator.py +296 -296
- tapps_agents/experts/setup_wizard.py +441 -441
- tapps_agents/experts/simple_rag.py +431 -431
- tapps_agents/experts/vector_rag.py +354 -354
- tapps_agents/experts/weight_distributor.py +304 -304
- tapps_agents/health/__init__.py +24 -24
- tapps_agents/health/base.py +75 -75
- tapps_agents/health/checks/__init__.py +22 -22
- tapps_agents/health/checks/automation.py +127 -127
- tapps_agents/health/checks/context7_cache.py +210 -210
- tapps_agents/health/checks/environment.py +116 -116
- tapps_agents/health/checks/execution.py +170 -170
- tapps_agents/health/checks/knowledge_base.py +187 -187
- tapps_agents/health/checks/outcomes.py +324 -324
- tapps_agents/health/collector.py +280 -280
- tapps_agents/health/dashboard.py +137 -137
- tapps_agents/health/metrics.py +151 -151
- tapps_agents/health/orchestrator.py +271 -271
- tapps_agents/health/registry.py +166 -166
- tapps_agents/hooks/__init__.py +33 -33
- tapps_agents/hooks/config.py +140 -140
- tapps_agents/hooks/events.py +135 -135
- tapps_agents/hooks/executor.py +128 -128
- tapps_agents/hooks/manager.py +143 -143
- tapps_agents/integration/__init__.py +8 -8
- tapps_agents/integration/service_integrator.py +121 -121
- tapps_agents/integrations/__init__.py +10 -10
- tapps_agents/integrations/clawdbot.py +525 -525
- tapps_agents/integrations/memory_bridge.py +356 -356
- tapps_agents/mcp/__init__.py +18 -18
- tapps_agents/mcp/gateway.py +112 -112
- tapps_agents/mcp/servers/__init__.py +13 -13
- tapps_agents/mcp/servers/analysis.py +204 -204
- tapps_agents/mcp/servers/context7.py +198 -198
- tapps_agents/mcp/servers/filesystem.py +218 -218
- tapps_agents/mcp/servers/git.py +201 -201
- tapps_agents/mcp/tool_registry.py +115 -115
- tapps_agents/quality/__init__.py +54 -54
- tapps_agents/quality/coverage_analyzer.py +379 -379
- tapps_agents/quality/enforcement.py +82 -82
- tapps_agents/quality/gates/__init__.py +37 -37
- tapps_agents/quality/gates/approval_gate.py +255 -255
- tapps_agents/quality/gates/base.py +84 -84
- tapps_agents/quality/gates/exceptions.py +43 -43
- tapps_agents/quality/gates/policy_gate.py +195 -195
- tapps_agents/quality/gates/registry.py +239 -239
- tapps_agents/quality/gates/security_gate.py +156 -156
- tapps_agents/quality/quality_gates.py +369 -369
- tapps_agents/quality/secret_scanner.py +335 -335
- tapps_agents/session/__init__.py +19 -19
- tapps_agents/session/manager.py +256 -256
- tapps_agents/simple_mode/__init__.py +66 -66
- tapps_agents/simple_mode/agent_contracts.py +357 -357
- tapps_agents/simple_mode/beads_hooks.py +151 -151
- tapps_agents/simple_mode/code_snippet_handler.py +382 -382
- tapps_agents/simple_mode/documentation_manager.py +395 -395
- tapps_agents/simple_mode/documentation_reader.py +187 -187
- tapps_agents/simple_mode/file_inference.py +292 -292
- tapps_agents/simple_mode/framework_change_detector.py +268 -268
- tapps_agents/simple_mode/intent_parser.py +510 -510
- tapps_agents/simple_mode/learning_progression.py +358 -358
- tapps_agents/simple_mode/nl_handler.py +700 -700
- tapps_agents/simple_mode/onboarding.py +253 -253
- tapps_agents/simple_mode/orchestrators/__init__.py +38 -38
- tapps_agents/simple_mode/orchestrators/base.py +185 -185
- tapps_agents/simple_mode/orchestrators/breakdown_orchestrator.py +49 -49
- tapps_agents/simple_mode/orchestrators/brownfield_orchestrator.py +135 -135
- tapps_agents/simple_mode/orchestrators/build_orchestrator.py +2700 -2667
- tapps_agents/simple_mode/orchestrators/deliverable_checklist.py +349 -349
- tapps_agents/simple_mode/orchestrators/enhance_orchestrator.py +53 -53
- tapps_agents/simple_mode/orchestrators/epic_orchestrator.py +122 -122
- tapps_agents/simple_mode/orchestrators/explore_orchestrator.py +184 -184
- tapps_agents/simple_mode/orchestrators/fix_orchestrator.py +723 -723
- tapps_agents/simple_mode/orchestrators/plan_analysis_orchestrator.py +206 -206
- tapps_agents/simple_mode/orchestrators/pr_orchestrator.py +237 -237
- tapps_agents/simple_mode/orchestrators/refactor_orchestrator.py +222 -222
- tapps_agents/simple_mode/orchestrators/requirements_tracer.py +262 -262
- tapps_agents/simple_mode/orchestrators/resume_orchestrator.py +210 -210
- tapps_agents/simple_mode/orchestrators/review_orchestrator.py +161 -161
- tapps_agents/simple_mode/orchestrators/test_orchestrator.py +82 -82
- tapps_agents/simple_mode/output_aggregator.py +340 -340
- tapps_agents/simple_mode/result_formatters.py +598 -598
- tapps_agents/simple_mode/step_dependencies.py +382 -382
- tapps_agents/simple_mode/step_results.py +276 -276
- tapps_agents/simple_mode/streaming.py +388 -388
- tapps_agents/simple_mode/variations.py +129 -129
- tapps_agents/simple_mode/visual_feedback.py +238 -238
- tapps_agents/simple_mode/zero_config.py +274 -274
- tapps_agents/suggestions/__init__.py +8 -8
- tapps_agents/suggestions/inline_suggester.py +52 -52
- tapps_agents/templates/__init__.py +8 -8
- tapps_agents/templates/microservice_generator.py +274 -274
- tapps_agents/utils/env_validator.py +291 -291
- tapps_agents/workflow/__init__.py +171 -171
- tapps_agents/workflow/acceptance_verifier.py +132 -132
- tapps_agents/workflow/agent_handlers/__init__.py +41 -41
- tapps_agents/workflow/agent_handlers/analyst_handler.py +75 -75
- tapps_agents/workflow/agent_handlers/architect_handler.py +107 -107
- tapps_agents/workflow/agent_handlers/base.py +84 -84
- tapps_agents/workflow/agent_handlers/debugger_handler.py +100 -100
- tapps_agents/workflow/agent_handlers/designer_handler.py +110 -110
- tapps_agents/workflow/agent_handlers/documenter_handler.py +94 -94
- tapps_agents/workflow/agent_handlers/implementer_handler.py +235 -235
- tapps_agents/workflow/agent_handlers/ops_handler.py +62 -62
- tapps_agents/workflow/agent_handlers/orchestrator_handler.py +43 -43
- tapps_agents/workflow/agent_handlers/planner_handler.py +98 -98
- tapps_agents/workflow/agent_handlers/registry.py +119 -119
- tapps_agents/workflow/agent_handlers/reviewer_handler.py +119 -119
- tapps_agents/workflow/agent_handlers/tester_handler.py +69 -69
- tapps_agents/workflow/analytics_accessor.py +337 -337
- tapps_agents/workflow/analytics_alerts.py +416 -416
- tapps_agents/workflow/analytics_dashboard_cursor.py +281 -281
- tapps_agents/workflow/analytics_dual_write.py +103 -103
- tapps_agents/workflow/analytics_integration.py +119 -119
- tapps_agents/workflow/analytics_query_parser.py +278 -278
- tapps_agents/workflow/analytics_visualizer.py +259 -259
- tapps_agents/workflow/artifact_helper.py +204 -204
- tapps_agents/workflow/audit_logger.py +263 -263
- tapps_agents/workflow/auto_execution_config.py +340 -340
- tapps_agents/workflow/auto_progression.py +586 -586
- tapps_agents/workflow/branch_cleanup.py +349 -349
- tapps_agents/workflow/checkpoint.py +256 -256
- tapps_agents/workflow/checkpoint_manager.py +178 -178
- tapps_agents/workflow/code_artifact.py +179 -179
- tapps_agents/workflow/common_enums.py +96 -96
- tapps_agents/workflow/confirmation_handler.py +130 -130
- tapps_agents/workflow/context_analyzer.py +222 -222
- tapps_agents/workflow/context_artifact.py +230 -230
- tapps_agents/workflow/cursor_chat.py +94 -94
- tapps_agents/workflow/cursor_executor.py +2337 -2196
- tapps_agents/workflow/cursor_skill_helper.py +516 -516
- tapps_agents/workflow/dependency_resolver.py +244 -244
- tapps_agents/workflow/design_artifact.py +156 -156
- tapps_agents/workflow/detector.py +751 -751
- tapps_agents/workflow/direct_execution_fallback.py +301 -301
- tapps_agents/workflow/docs_artifact.py +168 -168
- tapps_agents/workflow/enforcer.py +389 -389
- tapps_agents/workflow/enhancement_artifact.py +142 -142
- tapps_agents/workflow/error_recovery.py +806 -806
- tapps_agents/workflow/event_bus.py +183 -183
- tapps_agents/workflow/event_log.py +612 -612
- tapps_agents/workflow/events.py +63 -63
- tapps_agents/workflow/exceptions.py +43 -43
- tapps_agents/workflow/execution_graph.py +498 -498
- tapps_agents/workflow/execution_plan.py +126 -126
- tapps_agents/workflow/file_utils.py +186 -186
- tapps_agents/workflow/gate_evaluator.py +182 -182
- tapps_agents/workflow/gate_integration.py +200 -200
- tapps_agents/workflow/graph_visualizer.py +130 -130
- tapps_agents/workflow/health_checker.py +206 -206
- tapps_agents/workflow/logging_helper.py +243 -243
- tapps_agents/workflow/manifest.py +582 -582
- tapps_agents/workflow/marker_writer.py +250 -250
- tapps_agents/workflow/message_formatter.py +188 -188
- tapps_agents/workflow/messaging.py +325 -325
- tapps_agents/workflow/metadata_models.py +91 -91
- tapps_agents/workflow/metrics_integration.py +226 -226
- tapps_agents/workflow/migration_utils.py +116 -116
- tapps_agents/workflow/models.py +148 -111
- tapps_agents/workflow/nlp_config.py +198 -198
- tapps_agents/workflow/nlp_error_handler.py +207 -207
- tapps_agents/workflow/nlp_executor.py +163 -163
- tapps_agents/workflow/nlp_parser.py +528 -528
- tapps_agents/workflow/observability_dashboard.py +451 -451
- tapps_agents/workflow/observer.py +170 -170
- tapps_agents/workflow/ops_artifact.py +257 -257
- tapps_agents/workflow/output_passing.py +214 -214
- tapps_agents/workflow/parallel_executor.py +463 -463
- tapps_agents/workflow/planning_artifact.py +179 -179
- tapps_agents/workflow/preset_loader.py +285 -285
- tapps_agents/workflow/preset_recommender.py +270 -270
- tapps_agents/workflow/progress_logger.py +145 -145
- tapps_agents/workflow/progress_manager.py +303 -303
- tapps_agents/workflow/progress_monitor.py +186 -186
- tapps_agents/workflow/progress_updates.py +423 -423
- tapps_agents/workflow/quality_artifact.py +158 -158
- tapps_agents/workflow/quality_loopback.py +101 -101
- tapps_agents/workflow/recommender.py +387 -387
- tapps_agents/workflow/remediation_loop.py +166 -166
- tapps_agents/workflow/result_aggregator.py +300 -300
- tapps_agents/workflow/review_artifact.py +185 -185
- tapps_agents/workflow/schema_validator.py +522 -522
- tapps_agents/workflow/session_handoff.py +178 -178
- tapps_agents/workflow/skill_invoker.py +648 -648
- tapps_agents/workflow/state_manager.py +756 -756
- tapps_agents/workflow/state_persistence_config.py +331 -331
- tapps_agents/workflow/status_monitor.py +449 -449
- tapps_agents/workflow/step_checkpoint.py +314 -314
- tapps_agents/workflow/step_details.py +201 -201
- tapps_agents/workflow/story_models.py +147 -147
- tapps_agents/workflow/streaming.py +416 -416
- tapps_agents/workflow/suggestion_engine.py +552 -552
- tapps_agents/workflow/testing_artifact.py +186 -186
- tapps_agents/workflow/timeline.py +158 -158
- tapps_agents/workflow/token_integration.py +209 -209
- tapps_agents/workflow/validation.py +217 -217
- tapps_agents/workflow/visual_feedback.py +391 -391
- tapps_agents/workflow/workflow_chain.py +95 -95
- tapps_agents/workflow/workflow_summary.py +219 -219
- tapps_agents/workflow/worktree_manager.py +724 -724
- {tapps_agents-3.5.40.dist-info → tapps_agents-3.6.0.dist-info}/METADATA +672 -672
- tapps_agents-3.6.0.dist-info/RECORD +758 -0
- {tapps_agents-3.5.40.dist-info → tapps_agents-3.6.0.dist-info}/licenses/LICENSE +22 -22
- tapps_agents/health/checks/outcomes.backup_20260204_064058.py +0 -324
- tapps_agents/health/checks/outcomes.backup_20260204_064256.py +0 -324
- tapps_agents/health/checks/outcomes.backup_20260204_064600.py +0 -324
- tapps_agents-3.5.40.dist-info/RECORD +0 -760
- {tapps_agents-3.5.40.dist-info → tapps_agents-3.6.0.dist-info}/WHEEL +0 -0
- {tapps_agents-3.5.40.dist-info → tapps_agents-3.6.0.dist-info}/entry_points.txt +0 -0
- {tapps_agents-3.5.40.dist-info → tapps_agents-3.6.0.dist-info}/top_level.txt +0 -0
|
@@ -1,1566 +1,1566 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Code Scoring System - Calculates objective quality metrics
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
import ast
|
|
6
|
-
import json as json_lib
|
|
7
|
-
import logging
|
|
8
|
-
import shutil
|
|
9
|
-
import subprocess # nosec B404 - used with fixed args, no shell
|
|
10
|
-
import sys
|
|
11
|
-
from pathlib import Path
|
|
12
|
-
from typing import Any, Protocol
|
|
13
|
-
|
|
14
|
-
logger = logging.getLogger(__name__)
|
|
15
|
-
|
|
16
|
-
from ...core.config import ProjectConfig, ScoringWeightsConfig
|
|
17
|
-
from ...core.language_detector import Language
|
|
18
|
-
from ...core.subprocess_utils import wrap_windows_cmd_shim
|
|
19
|
-
from .score_constants import ComplexityConstants, SecurityConstants
|
|
20
|
-
from .validation import validate_code_input
|
|
21
|
-
|
|
22
|
-
# Import analysis libraries
|
|
23
|
-
try:
|
|
24
|
-
from radon.complexity import cc_visit
|
|
25
|
-
from radon.metrics import mi_visit
|
|
26
|
-
|
|
27
|
-
HAS_RADON = True
|
|
28
|
-
except ImportError:
|
|
29
|
-
HAS_RADON = False
|
|
30
|
-
|
|
31
|
-
try:
|
|
32
|
-
import bandit
|
|
33
|
-
from bandit.core import config as bandit_config
|
|
34
|
-
from bandit.core import manager
|
|
35
|
-
|
|
36
|
-
HAS_BANDIT = True
|
|
37
|
-
except ImportError:
|
|
38
|
-
HAS_BANDIT = False
|
|
39
|
-
|
|
40
|
-
# Check if ruff is available in PATH or via python -m ruff
|
|
41
|
-
def _check_ruff_available() -> bool:
|
|
42
|
-
"""Check if ruff is available via 'ruff' command or 'python -m ruff'"""
|
|
43
|
-
# Check for ruff command directly
|
|
44
|
-
if shutil.which("ruff"):
|
|
45
|
-
return True
|
|
46
|
-
# Check for python -m ruff
|
|
47
|
-
try:
|
|
48
|
-
result = subprocess.run( # nosec B603 - fixed args
|
|
49
|
-
[sys.executable, "-m", "ruff", "--version"],
|
|
50
|
-
capture_output=True,
|
|
51
|
-
timeout=5,
|
|
52
|
-
check=False,
|
|
53
|
-
)
|
|
54
|
-
return result.returncode == 0
|
|
55
|
-
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
56
|
-
return False
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
HAS_RUFF = _check_ruff_available()
|
|
60
|
-
|
|
61
|
-
# Check if mypy is available in PATH
|
|
62
|
-
HAS_MYPY = shutil.which("mypy") is not None
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
# Check if jscpd is available (via npm/npx)
|
|
66
|
-
def _check_jscpd_available() -> bool:
|
|
67
|
-
"""Check if jscpd is available via jscpd command or npx jscpd"""
|
|
68
|
-
# Check for jscpd command directly
|
|
69
|
-
if shutil.which("jscpd"):
|
|
70
|
-
return True
|
|
71
|
-
# Check for npx (Node.js package runner)
|
|
72
|
-
npx_path = shutil.which("npx")
|
|
73
|
-
if npx_path:
|
|
74
|
-
try:
|
|
75
|
-
cmd = wrap_windows_cmd_shim([npx_path, "--yes", "jscpd", "--version"])
|
|
76
|
-
result = subprocess.run( # nosec B603 - fixed args
|
|
77
|
-
cmd,
|
|
78
|
-
capture_output=True,
|
|
79
|
-
timeout=5,
|
|
80
|
-
check=False,
|
|
81
|
-
)
|
|
82
|
-
return result.returncode == 0
|
|
83
|
-
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
84
|
-
return False
|
|
85
|
-
return False
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
HAS_JSCPD = _check_jscpd_available()
|
|
89
|
-
|
|
90
|
-
# Import coverage tools
|
|
91
|
-
try:
|
|
92
|
-
from coverage import Coverage
|
|
93
|
-
|
|
94
|
-
HAS_COVERAGE = True
|
|
95
|
-
except ImportError:
|
|
96
|
-
HAS_COVERAGE = False
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
class BaseScorer:
|
|
100
|
-
"""
|
|
101
|
-
Base class for all scorers.
|
|
102
|
-
Defines the interface and shared helpers: _find_project_root, _calculate_structure_score, _calculate_devex_score (7-category §3.2).
|
|
103
|
-
"""
|
|
104
|
-
|
|
105
|
-
def score_file(self, file_path: Path, code: str) -> dict[str, Any]:
|
|
106
|
-
"""Score a file and return quality metrics. Subclasses must implement."""
|
|
107
|
-
raise NotImplementedError("Subclasses must implement score_file")
|
|
108
|
-
|
|
109
|
-
@staticmethod
|
|
110
|
-
def _find_project_root(file_path: Path) -> Path | None:
|
|
111
|
-
"""Find project root by common markers (.git, pyproject.toml, package.json, .tapps-agents, etc.)."""
|
|
112
|
-
current = file_path.resolve().parent
|
|
113
|
-
markers = [".git", "pyproject.toml", "setup.py", "requirements.txt", ".tapps-agents", "package.json"]
|
|
114
|
-
for _ in range(10):
|
|
115
|
-
for m in markers:
|
|
116
|
-
if (current / m).exists():
|
|
117
|
-
return current
|
|
118
|
-
if current.parent == current:
|
|
119
|
-
break
|
|
120
|
-
current = current.parent
|
|
121
|
-
return None
|
|
122
|
-
|
|
123
|
-
@classmethod
|
|
124
|
-
def _calculate_structure_score(cls, file_path: Path) -> float:
|
|
125
|
-
"""Structure score (0-10). Project layout, key files. 7-category §3.2."""
|
|
126
|
-
root = BaseScorer._find_project_root(file_path)
|
|
127
|
-
if root is None:
|
|
128
|
-
return 5.0
|
|
129
|
-
pts = 0.0
|
|
130
|
-
if (root / "pyproject.toml").exists() or (root / "package.json").exists():
|
|
131
|
-
pts += 2.5
|
|
132
|
-
if (root / "README").exists() or (root / "README.md").exists() or (root / "README.rst").exists():
|
|
133
|
-
pts += 2.0
|
|
134
|
-
if (root / "tests").exists() or (root / "test").exists():
|
|
135
|
-
pts += 2.0
|
|
136
|
-
if (root / ".tapps-agents").exists() or (root / ".git").exists():
|
|
137
|
-
pts += 1.0
|
|
138
|
-
if (root / "setup.py").exists() or (root / "requirements.txt").exists() or (root / "package-lock.json").exists():
|
|
139
|
-
pts += 1.5
|
|
140
|
-
return min(10.0, pts * 2.0)
|
|
141
|
-
|
|
142
|
-
@classmethod
|
|
143
|
-
def _calculate_devex_score(cls, file_path: Path) -> float:
|
|
144
|
-
"""DevEx score (0-10). Docs, config, tooling. 7-category §3.2."""
|
|
145
|
-
root = BaseScorer._find_project_root(file_path)
|
|
146
|
-
if root is None:
|
|
147
|
-
return 5.0
|
|
148
|
-
pts = 0.0
|
|
149
|
-
if (root / "AGENTS.md").exists() or (root / "CLAUDE.md").exists():
|
|
150
|
-
pts += 3.0
|
|
151
|
-
if (root / "docs").exists() and (root / "docs").is_dir():
|
|
152
|
-
pts += 2.0
|
|
153
|
-
if (root / ".tapps-agents").exists() or (root / ".cursor").exists():
|
|
154
|
-
pts += 2.0
|
|
155
|
-
pyproject, pkg = root / "pyproject.toml", root / "package.json"
|
|
156
|
-
if pyproject.exists():
|
|
157
|
-
try:
|
|
158
|
-
t = pyproject.read_text(encoding="utf-8", errors="replace")
|
|
159
|
-
if "[tool.ruff]" in t or "[tool.mypy]" in t or "pytest" in t:
|
|
160
|
-
pts += 1.5
|
|
161
|
-
except Exception:
|
|
162
|
-
pass
|
|
163
|
-
if pkg.exists():
|
|
164
|
-
try:
|
|
165
|
-
import json as _j
|
|
166
|
-
d = _j.loads(pkg.read_text(encoding="utf-8", errors="replace"))
|
|
167
|
-
dev = (d.get("devDependencies") or {}) if isinstance(d, dict) else {}
|
|
168
|
-
if any(k in dev for k in ("eslint", "jest", "vitest", "mypy")):
|
|
169
|
-
pts += 1.5
|
|
170
|
-
except Exception:
|
|
171
|
-
pass
|
|
172
|
-
return min(10.0, pts * 2.0)
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
class CodeScorer(BaseScorer):
|
|
176
|
-
"""Calculate code quality scores for Python files"""
|
|
177
|
-
|
|
178
|
-
def __init__(
|
|
179
|
-
self,
|
|
180
|
-
weights: ScoringWeightsConfig | None = None,
|
|
181
|
-
ruff_enabled: bool = True,
|
|
182
|
-
mypy_enabled: bool = True,
|
|
183
|
-
jscpd_enabled: bool = True,
|
|
184
|
-
duplication_threshold: float = 3.0,
|
|
185
|
-
min_duplication_lines: int = 5,
|
|
186
|
-
):
|
|
187
|
-
self.has_radon = HAS_RADON
|
|
188
|
-
self.has_bandit = HAS_BANDIT
|
|
189
|
-
self.has_coverage = HAS_COVERAGE
|
|
190
|
-
self.has_ruff = HAS_RUFF and ruff_enabled
|
|
191
|
-
self.has_mypy = HAS_MYPY and mypy_enabled
|
|
192
|
-
self.has_jscpd = HAS_JSCPD and jscpd_enabled
|
|
193
|
-
self.duplication_threshold = duplication_threshold
|
|
194
|
-
self.min_duplication_lines = min_duplication_lines
|
|
195
|
-
self.weights = weights # Will use defaults if None
|
|
196
|
-
|
|
197
|
-
def score_file(self, file_path: Path, code: str) -> dict[str, Any]:
|
|
198
|
-
"""
|
|
199
|
-
Calculate scores for a code file.
|
|
200
|
-
|
|
201
|
-
Returns:
|
|
202
|
-
{
|
|
203
|
-
"complexity_score": float (0-10),
|
|
204
|
-
"security_score": float (0-10),
|
|
205
|
-
"maintainability_score": float (0-10),
|
|
206
|
-
"overall_score": float (0-100),
|
|
207
|
-
"metrics": {...}
|
|
208
|
-
}
|
|
209
|
-
"""
|
|
210
|
-
metrics: dict[str, float] = {}
|
|
211
|
-
scores: dict[str, Any] = {
|
|
212
|
-
"complexity_score": 0.0,
|
|
213
|
-
"security_score": 0.0,
|
|
214
|
-
"maintainability_score": 0.0,
|
|
215
|
-
"test_coverage_score": 0.0,
|
|
216
|
-
"performance_score": 0.0,
|
|
217
|
-
"structure_score": 0.0, # 7-category: project layout, key files (MCP_SYSTEMS_IMPROVEMENT_RECOMMENDATIONS §3.2)
|
|
218
|
-
"devex_score": 0.0, # 7-category: docs, config, tooling (MCP_SYSTEMS_IMPROVEMENT_RECOMMENDATIONS §3.2)
|
|
219
|
-
"linting_score": 0.0, # Phase 6.1: Ruff linting score
|
|
220
|
-
"type_checking_score": 0.0, # Phase 6.2: mypy type checking score
|
|
221
|
-
"duplication_score": 0.0, # Phase 6.4: jscpd duplication score
|
|
222
|
-
"metrics": metrics,
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
# Complexity Score (0-10, lower is better)
|
|
226
|
-
scores["complexity_score"] = self._calculate_complexity(code)
|
|
227
|
-
metrics["complexity"] = float(scores["complexity_score"])
|
|
228
|
-
|
|
229
|
-
# Security Score (0-10, higher is better)
|
|
230
|
-
scores["security_score"] = self._calculate_security(file_path, code)
|
|
231
|
-
metrics["security"] = float(scores["security_score"])
|
|
232
|
-
|
|
233
|
-
# Maintainability Score (0-10, higher is better)
|
|
234
|
-
scores["maintainability_score"] = self._calculate_maintainability(code)
|
|
235
|
-
metrics["maintainability"] = float(scores["maintainability_score"])
|
|
236
|
-
|
|
237
|
-
# Test Coverage Score (0-10, higher is better)
|
|
238
|
-
scores["test_coverage_score"] = self._calculate_test_coverage(file_path)
|
|
239
|
-
metrics["test_coverage"] = float(scores["test_coverage_score"])
|
|
240
|
-
|
|
241
|
-
# Performance Score (0-10, higher is better)
|
|
242
|
-
# Phase 3.2: Use context-aware performance scorer
|
|
243
|
-
from .performance_scorer import PerformanceScorer
|
|
244
|
-
from ...core.language_detector import Language
|
|
245
|
-
|
|
246
|
-
performance_scorer = PerformanceScorer()
|
|
247
|
-
scores["performance_score"] = performance_scorer.calculate(
|
|
248
|
-
code, Language.PYTHON, file_path, context=None
|
|
249
|
-
)
|
|
250
|
-
metrics["performance"] = float(scores["performance_score"])
|
|
251
|
-
|
|
252
|
-
# Linting Score (0-10, higher is better) - Phase 6.1
|
|
253
|
-
scores["linting_score"] = self._calculate_linting_score(file_path)
|
|
254
|
-
metrics["linting"] = float(scores["linting_score"])
|
|
255
|
-
|
|
256
|
-
# Get actual linting issues for transparency (P1 Improvement)
|
|
257
|
-
linting_issues = self.get_ruff_issues(file_path)
|
|
258
|
-
scores["linting_issues"] = self._format_ruff_issues(linting_issues)
|
|
259
|
-
scores["linting_issue_count"] = len(linting_issues)
|
|
260
|
-
|
|
261
|
-
# Type Checking Score (0-10, higher is better) - Phase 6.2
|
|
262
|
-
scores["type_checking_score"] = self._calculate_type_checking_score(file_path)
|
|
263
|
-
metrics["type_checking"] = float(scores["type_checking_score"])
|
|
264
|
-
|
|
265
|
-
# Get actual type checking issues for transparency (P1 Improvement)
|
|
266
|
-
type_issues = self.get_mypy_errors(file_path)
|
|
267
|
-
scores["type_issues"] = type_issues # Already formatted
|
|
268
|
-
scores["type_issue_count"] = len(type_issues)
|
|
269
|
-
|
|
270
|
-
# Duplication Score (0-10, higher is better) - Phase 6.4
|
|
271
|
-
scores["duplication_score"] = self._calculate_duplication_score(file_path)
|
|
272
|
-
metrics["duplication"] = float(scores["duplication_score"])
|
|
273
|
-
|
|
274
|
-
# Structure Score (0-10, higher is better) - 7-category §3.2
|
|
275
|
-
scores["structure_score"] = self._calculate_structure_score(file_path)
|
|
276
|
-
metrics["structure"] = float(scores["structure_score"])
|
|
277
|
-
|
|
278
|
-
# DevEx Score (0-10, higher is better) - 7-category §3.2
|
|
279
|
-
scores["devex_score"] = self._calculate_devex_score(file_path)
|
|
280
|
-
metrics["devex"] = float(scores["devex_score"])
|
|
281
|
-
|
|
282
|
-
class _Weights(Protocol):
|
|
283
|
-
complexity: float
|
|
284
|
-
security: float
|
|
285
|
-
maintainability: float
|
|
286
|
-
test_coverage: float
|
|
287
|
-
performance: float
|
|
288
|
-
structure: float
|
|
289
|
-
devex: float
|
|
290
|
-
|
|
291
|
-
# Overall Score (weighted average, 7-category)
|
|
292
|
-
if self.weights is not None:
|
|
293
|
-
w: _Weights = self.weights
|
|
294
|
-
else:
|
|
295
|
-
class DefaultWeights:
|
|
296
|
-
complexity = 0.18
|
|
297
|
-
security = 0.27
|
|
298
|
-
maintainability = 0.24
|
|
299
|
-
test_coverage = 0.13
|
|
300
|
-
performance = 0.08
|
|
301
|
-
structure = 0.05
|
|
302
|
-
devex = 0.05
|
|
303
|
-
|
|
304
|
-
w = DefaultWeights()
|
|
305
|
-
|
|
306
|
-
scores["overall_score"] = (
|
|
307
|
-
(10 - scores["complexity_score"]) * w.complexity
|
|
308
|
-
+ scores["security_score"] * w.security
|
|
309
|
-
+ scores["maintainability_score"] * w.maintainability
|
|
310
|
-
+ scores["test_coverage_score"] * w.test_coverage
|
|
311
|
-
+ scores["performance_score"] * w.performance
|
|
312
|
-
+ scores["structure_score"] * w.structure
|
|
313
|
-
+ scores["devex_score"] * w.devex
|
|
314
|
-
) * 10 # Scale from 0-10 weighted sum to 0-100
|
|
315
|
-
|
|
316
|
-
# Phase 3.3: Validate all scores before returning
|
|
317
|
-
from .score_validator import ScoreValidator
|
|
318
|
-
from ...core.language_detector import Language
|
|
319
|
-
|
|
320
|
-
validator = ScoreValidator()
|
|
321
|
-
validation_results = validator.validate_all_scores(
|
|
322
|
-
scores, language=Language.PYTHON, context=None
|
|
323
|
-
)
|
|
324
|
-
|
|
325
|
-
# Update scores with validated/clamped values and add explanations
|
|
326
|
-
validated_scores = {}
|
|
327
|
-
score_explanations = {}
|
|
328
|
-
for category, result in validation_results.items():
|
|
329
|
-
if result.valid and result.calibrated_score is not None:
|
|
330
|
-
validated_scores[category] = result.calibrated_score
|
|
331
|
-
if result.explanation:
|
|
332
|
-
score_explanations[category] = {
|
|
333
|
-
"explanation": result.explanation,
|
|
334
|
-
"suggestions": result.suggestions,
|
|
335
|
-
}
|
|
336
|
-
else:
|
|
337
|
-
validated_scores[category] = scores.get(category, 0.0)
|
|
338
|
-
|
|
339
|
-
# Add explanations to result if any
|
|
340
|
-
if score_explanations:
|
|
341
|
-
validated_scores["_explanations"] = score_explanations
|
|
342
|
-
|
|
343
|
-
# Merge: keep all scores (incl. structure_score, devex_score), overlay validated
|
|
344
|
-
merged = {**scores, **validated_scores}
|
|
345
|
-
return merged
|
|
346
|
-
|
|
347
|
-
def _calculate_complexity(self, code: str) -> float:
|
|
348
|
-
"""Calculate cyclomatic complexity (0-10 scale)"""
|
|
349
|
-
# Validate input
|
|
350
|
-
validate_code_input(code, method_name="_calculate_complexity")
|
|
351
|
-
|
|
352
|
-
if not self.has_radon:
|
|
353
|
-
return 5.0 # Default neutral score
|
|
354
|
-
|
|
355
|
-
try:
|
|
356
|
-
tree = ast.parse(code)
|
|
357
|
-
complexities = cc_visit(tree)
|
|
358
|
-
|
|
359
|
-
if not complexities:
|
|
360
|
-
return 1.0
|
|
361
|
-
|
|
362
|
-
# Get max complexity
|
|
363
|
-
max_complexity = max(cc.complexity for cc in complexities)
|
|
364
|
-
|
|
365
|
-
# Scale to 0-10 using constants
|
|
366
|
-
return min(
|
|
367
|
-
max_complexity / ComplexityConstants.SCALING_FACTOR,
|
|
368
|
-
ComplexityConstants.MAX_SCORE
|
|
369
|
-
)
|
|
370
|
-
except SyntaxError:
|
|
371
|
-
return 10.0 # Syntax errors = max complexity
|
|
372
|
-
|
|
373
|
-
def _calculate_security(self, file_path: Path | None, code: str) -> float:
|
|
374
|
-
"""Calculate security score (0-10 scale, higher is better)"""
|
|
375
|
-
# Validate inputs
|
|
376
|
-
validate_code_input(code, method_name="_calculate_security")
|
|
377
|
-
if file_path is not None and not isinstance(file_path, Path):
|
|
378
|
-
raise ValueError(f"_calculate_security: file_path must be Path or None, got {type(file_path).__name__}")
|
|
379
|
-
|
|
380
|
-
if not self.has_bandit:
|
|
381
|
-
# Basic heuristic check
|
|
382
|
-
insecure_patterns = [
|
|
383
|
-
"eval(",
|
|
384
|
-
"exec(",
|
|
385
|
-
"__import__",
|
|
386
|
-
"pickle.loads",
|
|
387
|
-
"subprocess.call",
|
|
388
|
-
"os.system",
|
|
389
|
-
]
|
|
390
|
-
issues = sum(1 for pattern in insecure_patterns if pattern in code)
|
|
391
|
-
return max(
|
|
392
|
-
0.0,
|
|
393
|
-
SecurityConstants.MAX_SCORE - (issues * SecurityConstants.INSECURE_PATTERN_PENALTY)
|
|
394
|
-
)
|
|
395
|
-
|
|
396
|
-
try:
|
|
397
|
-
# Use bandit for proper security analysis
|
|
398
|
-
# BanditManager expects a BanditConfig, not a dict. Passing a dict can raise ValueError,
|
|
399
|
-
# which would silently degrade scoring to a neutral 5.0.
|
|
400
|
-
b_conf = bandit_config.BanditConfig()
|
|
401
|
-
b_mgr = manager.BanditManager(
|
|
402
|
-
config=b_conf,
|
|
403
|
-
agg_type="file",
|
|
404
|
-
debug=False,
|
|
405
|
-
verbose=False,
|
|
406
|
-
quiet=True,
|
|
407
|
-
profile=None,
|
|
408
|
-
ignore_nosec=False,
|
|
409
|
-
)
|
|
410
|
-
b_mgr.discover_files([str(file_path)], False)
|
|
411
|
-
b_mgr.run_tests()
|
|
412
|
-
|
|
413
|
-
# Count high/medium severity issues
|
|
414
|
-
issues = b_mgr.get_issue_list()
|
|
415
|
-
high_severity = sum(1 for i in issues if i.severity == bandit.HIGH)
|
|
416
|
-
medium_severity = sum(1 for i in issues if i.severity == bandit.MEDIUM)
|
|
417
|
-
|
|
418
|
-
# Score: 10 - (high*3 + medium*1)
|
|
419
|
-
score = 10.0 - (high_severity * 3.0 + medium_severity * 1.0)
|
|
420
|
-
return max(0.0, score)
|
|
421
|
-
except (FileNotFoundError, PermissionError, ValueError) as e:
|
|
422
|
-
# Specific exceptions for file/system errors
|
|
423
|
-
logger.warning(f"Security scoring failed for {file_path}: {e}")
|
|
424
|
-
return 5.0 # Default neutral on error
|
|
425
|
-
except Exception as e:
|
|
426
|
-
# Catch-all for unexpected errors (should be rare)
|
|
427
|
-
logger.warning(f"Unexpected error during security scoring for {file_path}: {e}", exc_info=True)
|
|
428
|
-
return 5.0 # Default neutral on error
|
|
429
|
-
|
|
430
|
-
def _calculate_maintainability(self, code: str) -> float:
|
|
431
|
-
"""
|
|
432
|
-
Calculate maintainability index (0-10 scale, higher is better).
|
|
433
|
-
|
|
434
|
-
Phase 3.1: Enhanced with context-aware scoring using MaintainabilityScorer.
|
|
435
|
-
Phase 2 (P0): Maintainability issues are captured separately via get_maintainability_issues().
|
|
436
|
-
"""
|
|
437
|
-
from .maintainability_scorer import MaintainabilityScorer
|
|
438
|
-
from ...core.language_detector import Language
|
|
439
|
-
|
|
440
|
-
# Use context-aware maintainability scorer
|
|
441
|
-
scorer = MaintainabilityScorer()
|
|
442
|
-
return scorer.calculate(code, Language.PYTHON, file_path=None, context=None)
|
|
443
|
-
|
|
444
|
-
def get_maintainability_issues(
|
|
445
|
-
self, code: str, file_path: Path | None = None
|
|
446
|
-
) -> list[dict[str, Any]]:
|
|
447
|
-
"""
|
|
448
|
-
Get specific maintainability issues (Phase 2 - P0).
|
|
449
|
-
|
|
450
|
-
Returns list of issues with details like missing docstrings, long functions, etc.
|
|
451
|
-
|
|
452
|
-
Args:
|
|
453
|
-
code: Source code content
|
|
454
|
-
file_path: Optional path to the file
|
|
455
|
-
|
|
456
|
-
Returns:
|
|
457
|
-
List of maintainability issues with details
|
|
458
|
-
"""
|
|
459
|
-
from .maintainability_scorer import MaintainabilityScorer
|
|
460
|
-
from ...core.language_detector import Language
|
|
461
|
-
|
|
462
|
-
scorer = MaintainabilityScorer()
|
|
463
|
-
return scorer.get_issues(code, Language.PYTHON, file_path=file_path, context=None)
|
|
464
|
-
|
|
465
|
-
def _calculate_test_coverage(self, file_path: Path) -> float:
|
|
466
|
-
"""
|
|
467
|
-
Calculate test coverage score (0-10 scale, higher is better).
|
|
468
|
-
|
|
469
|
-
Attempts to read coverage data from:
|
|
470
|
-
1. coverage.xml file in project root or .coverage file
|
|
471
|
-
2. Falls back to heuristic if no coverage data available
|
|
472
|
-
|
|
473
|
-
Args:
|
|
474
|
-
file_path: Path to the file being scored
|
|
475
|
-
|
|
476
|
-
Returns:
|
|
477
|
-
Coverage score (0-10 scale)
|
|
478
|
-
"""
|
|
479
|
-
if not self.has_coverage:
|
|
480
|
-
# No coverage tool available, use heuristic
|
|
481
|
-
return self._coverage_heuristic(file_path)
|
|
482
|
-
|
|
483
|
-
try:
|
|
484
|
-
# Try to find and parse coverage data
|
|
485
|
-
project_root = self._find_project_root(file_path)
|
|
486
|
-
if project_root is None:
|
|
487
|
-
return 5.0 # Neutral if can't find project root
|
|
488
|
-
|
|
489
|
-
# Look for coverage.xml first (pytest-cov output)
|
|
490
|
-
coverage_xml = project_root / "coverage.xml"
|
|
491
|
-
if coverage_xml.exists():
|
|
492
|
-
return self._parse_coverage_xml(coverage_xml, file_path)
|
|
493
|
-
|
|
494
|
-
# Look for .coverage database file
|
|
495
|
-
coverage_db = project_root / ".coverage"
|
|
496
|
-
if coverage_db.exists():
|
|
497
|
-
return self._parse_coverage_db(coverage_db, file_path)
|
|
498
|
-
|
|
499
|
-
# No coverage data found, use heuristic
|
|
500
|
-
return self._coverage_heuristic(file_path)
|
|
501
|
-
|
|
502
|
-
except Exception:
|
|
503
|
-
# Fallback to heuristic on any error
|
|
504
|
-
return self._coverage_heuristic(file_path)
|
|
505
|
-
|
|
506
|
-
def _parse_coverage_xml(self, coverage_xml: Path, file_path: Path) -> float:
|
|
507
|
-
"""Parse coverage.xml and return coverage percentage for file_path"""
|
|
508
|
-
try:
|
|
509
|
-
# coverage.xml is locally generated, but use defusedxml to reduce XML attack risk.
|
|
510
|
-
from defusedxml import ElementTree as ET
|
|
511
|
-
|
|
512
|
-
tree = ET.parse(coverage_xml)
|
|
513
|
-
root = tree.getroot()
|
|
514
|
-
|
|
515
|
-
# Get relative path from project root
|
|
516
|
-
project_root = coverage_xml.parent
|
|
517
|
-
try:
|
|
518
|
-
rel_path = file_path.relative_to(project_root)
|
|
519
|
-
file_path_str = str(rel_path).replace("\\", "/")
|
|
520
|
-
except ValueError:
|
|
521
|
-
# File not in project root
|
|
522
|
-
return 5.0
|
|
523
|
-
|
|
524
|
-
# Find coverage for this file
|
|
525
|
-
for package in root.findall(".//package"):
|
|
526
|
-
for class_elem in package.findall(".//class"):
|
|
527
|
-
file_name = class_elem.get("filename", "")
|
|
528
|
-
if file_name == file_path_str or file_path.name in file_name:
|
|
529
|
-
# Get line-rate (coverage percentage)
|
|
530
|
-
line_rate = float(class_elem.get("line-rate", "0.0"))
|
|
531
|
-
# Convert 0-1 scale to 0-10 scale
|
|
532
|
-
return line_rate * 10.0
|
|
533
|
-
|
|
534
|
-
# File not found in coverage report
|
|
535
|
-
return 0.0
|
|
536
|
-
except Exception:
|
|
537
|
-
return 5.0 # Default on error
|
|
538
|
-
|
|
539
|
-
def _parse_coverage_db(self, coverage_db: Path, file_path: Path) -> float:
|
|
540
|
-
"""Parse .coverage database and return coverage percentage"""
|
|
541
|
-
try:
|
|
542
|
-
cov = Coverage()
|
|
543
|
-
cov.load()
|
|
544
|
-
|
|
545
|
-
# Get coverage data
|
|
546
|
-
data = cov.get_data()
|
|
547
|
-
|
|
548
|
-
# Try to find file in coverage data
|
|
549
|
-
try:
|
|
550
|
-
rel_path = file_path.relative_to(coverage_db.parent)
|
|
551
|
-
file_path_str = str(rel_path).replace("\\", "/")
|
|
552
|
-
except ValueError:
|
|
553
|
-
return 5.0
|
|
554
|
-
|
|
555
|
-
# Get coverage for this file
|
|
556
|
-
if file_path_str in data.measured_files():
|
|
557
|
-
# Calculate coverage percentage
|
|
558
|
-
lines = data.lines(file_path_str)
|
|
559
|
-
if not lines:
|
|
560
|
-
return 0.0
|
|
561
|
-
|
|
562
|
-
# Count covered vs total lines (simplified)
|
|
563
|
-
# In practice, we'd need to check which lines are executable
|
|
564
|
-
# For now, return neutral score
|
|
565
|
-
return 5.0
|
|
566
|
-
|
|
567
|
-
return 0.0 # File not covered
|
|
568
|
-
except Exception:
|
|
569
|
-
return 5.0
|
|
570
|
-
|
|
571
|
-
def _coverage_heuristic(self, file_path: Path) -> float:
|
|
572
|
-
"""
|
|
573
|
-
Heuristic-based coverage estimate.
|
|
574
|
-
|
|
575
|
-
Phase 1 (P0): Fixed to return 0.0 when no test files exist.
|
|
576
|
-
|
|
577
|
-
Checks for:
|
|
578
|
-
- Test file existence (actual test files, not just directories)
|
|
579
|
-
- Test directory structure
|
|
580
|
-
- Test naming patterns
|
|
581
|
-
|
|
582
|
-
Returns:
|
|
583
|
-
- 0.0 if no test files exist (no tests written yet)
|
|
584
|
-
- 5.0 if test files exist but no coverage data (tests exist but not run)
|
|
585
|
-
- 10.0 if both test files and coverage data exist (not used here, handled by caller)
|
|
586
|
-
"""
|
|
587
|
-
project_root = self._find_project_root(file_path)
|
|
588
|
-
if project_root is None:
|
|
589
|
-
return 0.0 # No project root = assume no tests (Phase 1 fix)
|
|
590
|
-
|
|
591
|
-
# Look for test files with comprehensive patterns
|
|
592
|
-
test_dirs = ["tests", "test", "tests/unit", "tests/integration", "tests/test", "test/test"]
|
|
593
|
-
test_patterns = [
|
|
594
|
-
f"test_{file_path.stem}.py",
|
|
595
|
-
f"{file_path.stem}_test.py",
|
|
596
|
-
f"test_{file_path.name}",
|
|
597
|
-
# Also check for module-style test files
|
|
598
|
-
f"test_{file_path.stem.replace('_', '')}.py",
|
|
599
|
-
f"test_{file_path.stem.replace('-', '_')}.py",
|
|
600
|
-
]
|
|
601
|
-
|
|
602
|
-
# Also check if the file itself is a test file
|
|
603
|
-
if file_path.name.startswith("test_") or file_path.name.endswith("_test.py"):
|
|
604
|
-
# File is a test file, assume it has coverage if it exists
|
|
605
|
-
return 5.0 # Tests exist but no coverage data available
|
|
606
|
-
|
|
607
|
-
# Check if any test files actually exist
|
|
608
|
-
test_file_found = False
|
|
609
|
-
for test_dir in test_dirs:
|
|
610
|
-
test_dir_path = project_root / test_dir
|
|
611
|
-
if test_dir_path.exists() and test_dir_path.is_dir():
|
|
612
|
-
for pattern in test_patterns:
|
|
613
|
-
test_file = test_dir_path / pattern
|
|
614
|
-
if test_file.exists() and test_file.is_file():
|
|
615
|
-
test_file_found = True
|
|
616
|
-
break
|
|
617
|
-
if test_file_found:
|
|
618
|
-
break
|
|
619
|
-
|
|
620
|
-
# Phase 1 fix: Return 0.0 if no test files exist (no tests written yet)
|
|
621
|
-
if not test_file_found:
|
|
622
|
-
return 0.0
|
|
623
|
-
|
|
624
|
-
# Test files exist but not run yet
|
|
625
|
-
return 5.0
|
|
626
|
-
|
|
627
|
-
def _find_project_root(self, file_path: Path) -> Path | None:
|
|
628
|
-
"""Delegate to BaseScorer. Override for CodeScorer-specific markers if needed."""
|
|
629
|
-
return BaseScorer._find_project_root(file_path)
|
|
630
|
-
|
|
631
|
-
def get_performance_issues(
|
|
632
|
-
self, code: str, file_path: Path | None = None
|
|
633
|
-
) -> list[dict[str, Any]]:
|
|
634
|
-
"""
|
|
635
|
-
Get specific performance issues with line numbers (Phase 4 - P1).
|
|
636
|
-
|
|
637
|
-
Returns list of performance bottlenecks with details like nested loops, expensive operations, etc.
|
|
638
|
-
|
|
639
|
-
Args:
|
|
640
|
-
code: Source code content
|
|
641
|
-
file_path: Optional path to the file
|
|
642
|
-
|
|
643
|
-
Returns:
|
|
644
|
-
List of performance issues with line numbers
|
|
645
|
-
"""
|
|
646
|
-
from .issue_tracking import PerformanceIssue
|
|
647
|
-
import ast
|
|
648
|
-
|
|
649
|
-
issues: list[PerformanceIssue] = []
|
|
650
|
-
code_lines = code.splitlines()
|
|
651
|
-
|
|
652
|
-
try:
|
|
653
|
-
tree = ast.parse(code)
|
|
654
|
-
except SyntaxError:
|
|
655
|
-
return [PerformanceIssue(
|
|
656
|
-
issue_type="syntax_error",
|
|
657
|
-
message="File contains syntax errors - cannot analyze performance",
|
|
658
|
-
severity="high"
|
|
659
|
-
).__dict__]
|
|
660
|
-
|
|
661
|
-
# Analyze functions for performance issues
|
|
662
|
-
for node in ast.walk(tree):
|
|
663
|
-
if isinstance(node, ast.FunctionDef):
|
|
664
|
-
# Check for nested loops
|
|
665
|
-
for child in ast.walk(node):
|
|
666
|
-
if isinstance(child, ast.For):
|
|
667
|
-
# Check if this loop contains another loop
|
|
668
|
-
for nested_child in ast.walk(child):
|
|
669
|
-
if isinstance(nested_child, ast.For) and nested_child != child:
|
|
670
|
-
# Found nested loop
|
|
671
|
-
issues.append(PerformanceIssue(
|
|
672
|
-
issue_type="nested_loops",
|
|
673
|
-
message=f"Nested for loops detected in function '{node.name}' - potential O(n²) complexity",
|
|
674
|
-
line_number=child.lineno,
|
|
675
|
-
severity="high",
|
|
676
|
-
operation_type="loop",
|
|
677
|
-
context=f"Nested in function '{node.name}'",
|
|
678
|
-
suggestion="Consider using itertools.product() or list comprehensions to flatten nested loops"
|
|
679
|
-
))
|
|
680
|
-
|
|
681
|
-
# Check for expensive operations in loops
|
|
682
|
-
if isinstance(child, ast.For):
|
|
683
|
-
for loop_child in ast.walk(child):
|
|
684
|
-
if isinstance(loop_child, ast.Call):
|
|
685
|
-
# Check for expensive function calls in loops
|
|
686
|
-
if isinstance(loop_child.func, ast.Name):
|
|
687
|
-
func_name = loop_child.func.id
|
|
688
|
-
expensive_operations = ["time.fromisoformat", "datetime.fromisoformat", "re.compile", "json.loads"]
|
|
689
|
-
if any(exp_op in func_name for exp_op in expensive_operations):
|
|
690
|
-
issues.append(PerformanceIssue(
|
|
691
|
-
issue_type="expensive_operation_in_loop",
|
|
692
|
-
message=f"Expensive operation '{func_name}' called in loop at line {loop_child.lineno} - parse once before loop",
|
|
693
|
-
line_number=loop_child.lineno,
|
|
694
|
-
severity="medium",
|
|
695
|
-
operation_type=func_name,
|
|
696
|
-
context=f"In loop at line {child.lineno}",
|
|
697
|
-
suggestion=f"Move '{func_name}' call outside the loop and cache the result"
|
|
698
|
-
))
|
|
699
|
-
|
|
700
|
-
# Check for list comprehensions with function calls
|
|
701
|
-
if isinstance(node, ast.ListComp):
|
|
702
|
-
func_calls_in_comp = sum(1 for n in ast.walk(node) if isinstance(n, ast.Call))
|
|
703
|
-
if func_calls_in_comp > 5:
|
|
704
|
-
issues.append(PerformanceIssue(
|
|
705
|
-
issue_type="expensive_comprehension",
|
|
706
|
-
message=f"List comprehension at line {node.lineno} contains {func_calls_in_comp} function calls - consider using generator or loop",
|
|
707
|
-
line_number=node.lineno,
|
|
708
|
-
severity="medium",
|
|
709
|
-
operation_type="comprehension",
|
|
710
|
-
suggestion="Consider using a generator expression or a loop for better performance"
|
|
711
|
-
))
|
|
712
|
-
|
|
713
|
-
# Convert to dict format
|
|
714
|
-
return [
|
|
715
|
-
{
|
|
716
|
-
"issue_type": issue.issue_type,
|
|
717
|
-
"message": issue.message,
|
|
718
|
-
"line_number": issue.line_number,
|
|
719
|
-
"severity": issue.severity,
|
|
720
|
-
"suggestion": issue.suggestion,
|
|
721
|
-
"operation_type": issue.operation_type,
|
|
722
|
-
"context": issue.context,
|
|
723
|
-
}
|
|
724
|
-
for issue in issues
|
|
725
|
-
]
|
|
726
|
-
|
|
727
|
-
def _calculate_performance(self, code: str) -> float:
|
|
728
|
-
"""
|
|
729
|
-
Calculate performance score using static analysis (0-10 scale, higher is better).
|
|
730
|
-
|
|
731
|
-
Checks for:
|
|
732
|
-
- Function size (number of lines)
|
|
733
|
-
- Nesting depth
|
|
734
|
-
- Inefficient patterns (N+1 queries, nested loops, etc.)
|
|
735
|
-
- Large list/dict comprehensions
|
|
736
|
-
"""
|
|
737
|
-
try:
|
|
738
|
-
tree = ast.parse(code)
|
|
739
|
-
issues = []
|
|
740
|
-
|
|
741
|
-
# Analyze functions
|
|
742
|
-
for node in ast.walk(tree):
|
|
743
|
-
if isinstance(node, ast.FunctionDef):
|
|
744
|
-
# Check function size
|
|
745
|
-
# Use end_lineno if available (Python 3.8+), otherwise estimate
|
|
746
|
-
if hasattr(node, "end_lineno") and node.end_lineno is not None:
|
|
747
|
-
func_lines = node.end_lineno - node.lineno
|
|
748
|
-
else:
|
|
749
|
-
# Estimate: count lines in function body
|
|
750
|
-
func_lines = (
|
|
751
|
-
len(code.split("\n")[node.lineno - 1 : node.lineno + 49])
|
|
752
|
-
if len(code.split("\n")) > node.lineno
|
|
753
|
-
else 50
|
|
754
|
-
)
|
|
755
|
-
|
|
756
|
-
if func_lines > 50:
|
|
757
|
-
issues.append("large_function") # > 50 lines
|
|
758
|
-
if func_lines > 100:
|
|
759
|
-
issues.append("very_large_function") # > 100 lines
|
|
760
|
-
|
|
761
|
-
# Check nesting depth
|
|
762
|
-
max_depth = self._get_max_nesting_depth(node)
|
|
763
|
-
if max_depth > 4:
|
|
764
|
-
issues.append("deep_nesting") # > 4 levels
|
|
765
|
-
if max_depth > 6:
|
|
766
|
-
issues.append("very_deep_nesting") # > 6 levels
|
|
767
|
-
|
|
768
|
-
# Check for nested loops (potential N^2 complexity)
|
|
769
|
-
if isinstance(node, ast.For):
|
|
770
|
-
for child in ast.walk(node):
|
|
771
|
-
if isinstance(child, ast.For) and child != node:
|
|
772
|
-
issues.append("nested_loops")
|
|
773
|
-
break
|
|
774
|
-
|
|
775
|
-
# Check for list comprehensions with function calls
|
|
776
|
-
if isinstance(node, ast.ListComp):
|
|
777
|
-
# Count function calls in comprehension
|
|
778
|
-
func_calls = sum(
|
|
779
|
-
1 for n in ast.walk(node) if isinstance(n, ast.Call)
|
|
780
|
-
)
|
|
781
|
-
if func_calls > 5:
|
|
782
|
-
issues.append("expensive_comprehension")
|
|
783
|
-
|
|
784
|
-
# Calculate score based on issues
|
|
785
|
-
# Start with 10, deduct points for issues
|
|
786
|
-
score = 10.0
|
|
787
|
-
penalty_map = {
|
|
788
|
-
"large_function": 0.5,
|
|
789
|
-
"very_large_function": 1.5,
|
|
790
|
-
"deep_nesting": 1.0,
|
|
791
|
-
"very_deep_nesting": 2.0,
|
|
792
|
-
"nested_loops": 1.5,
|
|
793
|
-
"expensive_comprehension": 0.5,
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
seen_issues = set()
|
|
797
|
-
for issue in issues:
|
|
798
|
-
if issue not in seen_issues:
|
|
799
|
-
score -= penalty_map.get(issue, 0.5)
|
|
800
|
-
seen_issues.add(issue)
|
|
801
|
-
|
|
802
|
-
return max(0.0, min(10.0, score))
|
|
803
|
-
|
|
804
|
-
except SyntaxError:
|
|
805
|
-
return 0.0 # Syntax errors = worst performance score
|
|
806
|
-
except Exception:
|
|
807
|
-
return 5.0 # Default on error
|
|
808
|
-
|
|
809
|
-
def _get_max_nesting_depth(self, node: ast.AST, current_depth: int = 0) -> int:
|
|
810
|
-
"""Calculate maximum nesting depth in an AST node"""
|
|
811
|
-
max_depth = current_depth
|
|
812
|
-
|
|
813
|
-
for child in ast.iter_child_nodes(node):
|
|
814
|
-
# Count nesting for control structures
|
|
815
|
-
if isinstance(child, (ast.If, ast.For, ast.While, ast.Try, ast.With)):
|
|
816
|
-
child_depth = self._get_max_nesting_depth(child, current_depth + 1)
|
|
817
|
-
max_depth = max(max_depth, child_depth)
|
|
818
|
-
else:
|
|
819
|
-
child_depth = self._get_max_nesting_depth(child, current_depth)
|
|
820
|
-
max_depth = max(max_depth, child_depth)
|
|
821
|
-
|
|
822
|
-
return max_depth
|
|
823
|
-
|
|
824
|
-
def _calculate_linting_score(self, file_path: Path) -> float:
|
|
825
|
-
"""
|
|
826
|
-
Calculate linting score using Ruff (0-10 scale, higher is better).
|
|
827
|
-
|
|
828
|
-
Phase 6: Modern Quality Analysis - Ruff Integration
|
|
829
|
-
|
|
830
|
-
Returns:
|
|
831
|
-
Linting score (0-10), where 10 = no issues, 0 = many issues
|
|
832
|
-
"""
|
|
833
|
-
if not self.has_ruff:
|
|
834
|
-
return 5.0 # Neutral score if Ruff not available
|
|
835
|
-
|
|
836
|
-
# Only check Python files
|
|
837
|
-
if file_path.suffix != ".py":
|
|
838
|
-
return 10.0 # Perfect score for non-Python files (can't lint)
|
|
839
|
-
|
|
840
|
-
try:
|
|
841
|
-
# Run ruff check with JSON output
|
|
842
|
-
result = subprocess.run( # nosec B603
|
|
843
|
-
[
|
|
844
|
-
sys.executable,
|
|
845
|
-
"-m",
|
|
846
|
-
"ruff",
|
|
847
|
-
"check",
|
|
848
|
-
"--output-format=json",
|
|
849
|
-
str(file_path),
|
|
850
|
-
],
|
|
851
|
-
capture_output=True,
|
|
852
|
-
text=True,
|
|
853
|
-
encoding="utf-8",
|
|
854
|
-
errors="replace",
|
|
855
|
-
timeout=30, # 30 second timeout
|
|
856
|
-
cwd=file_path.parent if file_path.parent.exists() else None,
|
|
857
|
-
)
|
|
858
|
-
|
|
859
|
-
# Parse JSON output
|
|
860
|
-
if result.returncode == 0 and not result.stdout.strip():
|
|
861
|
-
# No issues found
|
|
862
|
-
return 10.0
|
|
863
|
-
|
|
864
|
-
try:
|
|
865
|
-
# Ruff JSON format: list of diagnostic objects
|
|
866
|
-
diagnostics = (
|
|
867
|
-
json_lib.loads(result.stdout) if result.stdout.strip() else []
|
|
868
|
-
)
|
|
869
|
-
|
|
870
|
-
if not diagnostics:
|
|
871
|
-
return 10.0
|
|
872
|
-
|
|
873
|
-
# Count issues by severity
|
|
874
|
-
# Ruff severity levels: E (Error), W (Warning), F (Fatal), I (Info)
|
|
875
|
-
error_count = sum(
|
|
876
|
-
1
|
|
877
|
-
for d in diagnostics
|
|
878
|
-
if d.get("code", {}).get("name", "").startswith("E")
|
|
879
|
-
)
|
|
880
|
-
warning_count = sum(
|
|
881
|
-
1
|
|
882
|
-
for d in diagnostics
|
|
883
|
-
if d.get("code", {}).get("name", "").startswith("W")
|
|
884
|
-
)
|
|
885
|
-
fatal_count = sum(
|
|
886
|
-
1
|
|
887
|
-
for d in diagnostics
|
|
888
|
-
if d.get("code", {}).get("name", "").startswith("F")
|
|
889
|
-
)
|
|
890
|
-
|
|
891
|
-
# Calculate score: Start at 10, deduct points
|
|
892
|
-
# Errors (E): -2 points each
|
|
893
|
-
# Fatal (F): -3 points each
|
|
894
|
-
# Warnings (W): -0.5 points each
|
|
895
|
-
score = 10.0
|
|
896
|
-
score -= error_count * 2.0
|
|
897
|
-
score -= fatal_count * 3.0
|
|
898
|
-
score -= warning_count * 0.5
|
|
899
|
-
|
|
900
|
-
return max(0.0, min(10.0, score))
|
|
901
|
-
|
|
902
|
-
except json_lib.JSONDecodeError:
|
|
903
|
-
# If JSON parsing fails, check stderr for errors
|
|
904
|
-
if result.stderr:
|
|
905
|
-
return 5.0 # Neutral on parsing error
|
|
906
|
-
return 10.0 # No output = no issues
|
|
907
|
-
|
|
908
|
-
except subprocess.TimeoutExpired:
|
|
909
|
-
return 5.0 # Neutral on timeout
|
|
910
|
-
except FileNotFoundError:
|
|
911
|
-
# Ruff not found in PATH
|
|
912
|
-
return 5.0
|
|
913
|
-
except Exception:
|
|
914
|
-
# Any other error
|
|
915
|
-
return 5.0
|
|
916
|
-
|
|
917
|
-
def get_ruff_issues(self, file_path: Path) -> list[dict[str, Any]]:
|
|
918
|
-
"""
|
|
919
|
-
Get detailed Ruff linting issues for a file.
|
|
920
|
-
|
|
921
|
-
Phase 6: Modern Quality Analysis - Ruff Integration
|
|
922
|
-
|
|
923
|
-
Returns:
|
|
924
|
-
List of diagnostic dictionaries with code, message, location, etc.
|
|
925
|
-
"""
|
|
926
|
-
if not self.has_ruff or file_path.suffix != ".py":
|
|
927
|
-
return []
|
|
928
|
-
|
|
929
|
-
try:
|
|
930
|
-
result = subprocess.run( # nosec B603
|
|
931
|
-
[
|
|
932
|
-
sys.executable,
|
|
933
|
-
"-m",
|
|
934
|
-
"ruff",
|
|
935
|
-
"check",
|
|
936
|
-
"--output-format=json",
|
|
937
|
-
str(file_path),
|
|
938
|
-
],
|
|
939
|
-
capture_output=True,
|
|
940
|
-
text=True,
|
|
941
|
-
encoding="utf-8",
|
|
942
|
-
errors="replace",
|
|
943
|
-
timeout=30,
|
|
944
|
-
cwd=file_path.parent if file_path.parent.exists() else None,
|
|
945
|
-
)
|
|
946
|
-
|
|
947
|
-
if result.returncode == 0 and not result.stdout.strip():
|
|
948
|
-
return []
|
|
949
|
-
|
|
950
|
-
try:
|
|
951
|
-
diagnostics = (
|
|
952
|
-
json_lib.loads(result.stdout) if result.stdout.strip() else []
|
|
953
|
-
)
|
|
954
|
-
return diagnostics
|
|
955
|
-
except json_lib.JSONDecodeError:
|
|
956
|
-
return []
|
|
957
|
-
|
|
958
|
-
except (subprocess.TimeoutExpired, FileNotFoundError, Exception):
|
|
959
|
-
return []
|
|
960
|
-
|
|
961
|
-
def _calculate_type_checking_score(self, file_path: Path) -> float:
|
|
962
|
-
"""
|
|
963
|
-
Calculate type checking score using mypy (0-10 scale, higher is better).
|
|
964
|
-
|
|
965
|
-
Phase 6.2: Modern Quality Analysis - mypy Integration
|
|
966
|
-
Phase 5 (P1): Fixed to actually run mypy and return real scores (not static 5.0).
|
|
967
|
-
ENH-002-S2: Prefer ScopedMypyExecutor (--follow-imports=skip, <10s) with fallback to full mypy.
|
|
968
|
-
"""
|
|
969
|
-
if not self.has_mypy:
|
|
970
|
-
logger.debug("mypy not available - returning neutral score")
|
|
971
|
-
return 5.0 # Neutral score if mypy not available
|
|
972
|
-
|
|
973
|
-
# Only check Python files
|
|
974
|
-
if file_path.suffix != ".py":
|
|
975
|
-
return 10.0 # Perfect score for non-Python files (can't type check)
|
|
976
|
-
|
|
977
|
-
# ENH-002-S2: Try scoped mypy first (faster)
|
|
978
|
-
try:
|
|
979
|
-
from .tools.scoped_mypy import ScopedMypyExecutor
|
|
980
|
-
executor = ScopedMypyExecutor()
|
|
981
|
-
result = executor.run_scoped_sync(file_path, timeout=10)
|
|
982
|
-
if result.files_checked == 1 or result.issues:
|
|
983
|
-
error_count = len(result.issues)
|
|
984
|
-
if error_count == 0:
|
|
985
|
-
return 10.0
|
|
986
|
-
score = 10.0 - (error_count * 0.5)
|
|
987
|
-
logger.debug(
|
|
988
|
-
"mypy (scoped) found %s errors for %s, score: %s/10",
|
|
989
|
-
error_count, file_path, score,
|
|
990
|
-
)
|
|
991
|
-
return max(0.0, min(10.0, score))
|
|
992
|
-
except Exception as e:
|
|
993
|
-
logger.debug("scoped mypy not used, falling back to full mypy: %s", e)
|
|
994
|
-
|
|
995
|
-
try:
|
|
996
|
-
result = subprocess.run( # nosec B603
|
|
997
|
-
[
|
|
998
|
-
sys.executable,
|
|
999
|
-
"-m",
|
|
1000
|
-
"mypy",
|
|
1001
|
-
"--show-error-codes",
|
|
1002
|
-
"--no-error-summary",
|
|
1003
|
-
"--no-color-output",
|
|
1004
|
-
"--no-incremental",
|
|
1005
|
-
str(file_path),
|
|
1006
|
-
],
|
|
1007
|
-
capture_output=True,
|
|
1008
|
-
text=True,
|
|
1009
|
-
encoding="utf-8",
|
|
1010
|
-
errors="replace",
|
|
1011
|
-
timeout=30,
|
|
1012
|
-
cwd=file_path.parent if file_path.parent.exists() else None,
|
|
1013
|
-
)
|
|
1014
|
-
if result.returncode == 0:
|
|
1015
|
-
logger.debug("mypy found no errors for %s", file_path)
|
|
1016
|
-
return 10.0
|
|
1017
|
-
output = result.stdout.strip()
|
|
1018
|
-
if not output:
|
|
1019
|
-
logger.debug("mypy returned non-zero but no output for %s", file_path)
|
|
1020
|
-
return 10.0
|
|
1021
|
-
error_lines = [
|
|
1022
|
-
line
|
|
1023
|
-
for line in output.split("\n")
|
|
1024
|
-
if "error:" in line.lower() and file_path.name in line
|
|
1025
|
-
]
|
|
1026
|
-
error_count = len(error_lines)
|
|
1027
|
-
if error_count == 0:
|
|
1028
|
-
logger.debug("mypy returned non-zero but no parseable errors for %s", file_path)
|
|
1029
|
-
return 10.0
|
|
1030
|
-
score = 10.0 - (error_count * 0.5)
|
|
1031
|
-
logger.debug("mypy found %s errors for %s, score: %s/10", error_count, file_path, score)
|
|
1032
|
-
return max(0.0, min(10.0, score))
|
|
1033
|
-
except subprocess.TimeoutExpired:
|
|
1034
|
-
logger.warning("mypy timed out for %s", file_path)
|
|
1035
|
-
return 5.0
|
|
1036
|
-
except FileNotFoundError:
|
|
1037
|
-
logger.debug("mypy not found in PATH for %s", file_path)
|
|
1038
|
-
self.has_mypy = False
|
|
1039
|
-
return 5.0
|
|
1040
|
-
except Exception as e:
|
|
1041
|
-
logger.warning("mypy failed for %s: %s", file_path, e, exc_info=True)
|
|
1042
|
-
return 5.0
|
|
1043
|
-
|
|
1044
|
-
def get_mypy_errors(self, file_path: Path) -> list[dict[str, Any]]:
|
|
1045
|
-
"""
|
|
1046
|
-
Get detailed mypy type checking errors for a file.
|
|
1047
|
-
|
|
1048
|
-
Phase 6.2: Modern Quality Analysis - mypy Integration
|
|
1049
|
-
ENH-002-S2: Prefer ScopedMypyExecutor with fallback to full mypy.
|
|
1050
|
-
"""
|
|
1051
|
-
if not self.has_mypy or file_path.suffix != ".py":
|
|
1052
|
-
return []
|
|
1053
|
-
|
|
1054
|
-
try:
|
|
1055
|
-
from .tools.scoped_mypy import ScopedMypyExecutor
|
|
1056
|
-
executor = ScopedMypyExecutor()
|
|
1057
|
-
result = executor.run_scoped_sync(file_path, timeout=10)
|
|
1058
|
-
if result.files_checked == 1 or result.issues:
|
|
1059
|
-
return [
|
|
1060
|
-
{
|
|
1061
|
-
"filename": str(i.file_path),
|
|
1062
|
-
"line": i.line,
|
|
1063
|
-
"message": i.message,
|
|
1064
|
-
"error_code": i.error_code,
|
|
1065
|
-
"severity": i.severity,
|
|
1066
|
-
}
|
|
1067
|
-
for i in result.issues
|
|
1068
|
-
]
|
|
1069
|
-
except Exception:
|
|
1070
|
-
pass
|
|
1071
|
-
|
|
1072
|
-
try:
|
|
1073
|
-
result = subprocess.run( # nosec B603
|
|
1074
|
-
[
|
|
1075
|
-
sys.executable,
|
|
1076
|
-
"-m",
|
|
1077
|
-
"mypy",
|
|
1078
|
-
"--show-error-codes",
|
|
1079
|
-
"--no-error-summary",
|
|
1080
|
-
"--no-incremental",
|
|
1081
|
-
str(file_path),
|
|
1082
|
-
],
|
|
1083
|
-
capture_output=True,
|
|
1084
|
-
text=True,
|
|
1085
|
-
encoding="utf-8",
|
|
1086
|
-
errors="replace",
|
|
1087
|
-
timeout=30,
|
|
1088
|
-
cwd=file_path.parent if file_path.parent.exists() else None,
|
|
1089
|
-
)
|
|
1090
|
-
if result.returncode == 0 or not result.stdout.strip():
|
|
1091
|
-
return []
|
|
1092
|
-
errors = []
|
|
1093
|
-
for line in result.stdout.strip().split("\n"):
|
|
1094
|
-
if "error:" not in line.lower():
|
|
1095
|
-
continue
|
|
1096
|
-
parts = line.split(":", 3)
|
|
1097
|
-
if len(parts) >= 4:
|
|
1098
|
-
filename = parts[0]
|
|
1099
|
-
try:
|
|
1100
|
-
line_num = int(parts[1])
|
|
1101
|
-
except ValueError:
|
|
1102
|
-
continue
|
|
1103
|
-
error_msg = parts[3].strip()
|
|
1104
|
-
error_code = None
|
|
1105
|
-
if "[" in error_msg and "]" in error_msg:
|
|
1106
|
-
start = error_msg.rfind("[")
|
|
1107
|
-
end = error_msg.rfind("]")
|
|
1108
|
-
if start < end:
|
|
1109
|
-
error_code = error_msg[start + 1 : end]
|
|
1110
|
-
error_msg = error_msg[:start].strip()
|
|
1111
|
-
errors.append({
|
|
1112
|
-
"filename": filename,
|
|
1113
|
-
"line": line_num,
|
|
1114
|
-
"message": error_msg,
|
|
1115
|
-
"error_code": error_code,
|
|
1116
|
-
"severity": "error",
|
|
1117
|
-
})
|
|
1118
|
-
return errors
|
|
1119
|
-
except (subprocess.TimeoutExpired, FileNotFoundError, Exception):
|
|
1120
|
-
return []
|
|
1121
|
-
|
|
1122
|
-
def _format_ruff_issues(self, diagnostics: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
1123
|
-
"""
|
|
1124
|
-
Format raw ruff diagnostics into a cleaner structure for output.
|
|
1125
|
-
|
|
1126
|
-
P1 Improvement: Include actual lint errors in score output.
|
|
1127
|
-
|
|
1128
|
-
Args:
|
|
1129
|
-
diagnostics: Raw ruff JSON diagnostics
|
|
1130
|
-
|
|
1131
|
-
Returns:
|
|
1132
|
-
List of formatted issues with line, code, message, severity
|
|
1133
|
-
"""
|
|
1134
|
-
formatted = []
|
|
1135
|
-
for diag in diagnostics:
|
|
1136
|
-
# Extract code info (ruff format: {"code": {"name": "F401", ...}})
|
|
1137
|
-
code_info = diag.get("code", {})
|
|
1138
|
-
if isinstance(code_info, dict):
|
|
1139
|
-
code = code_info.get("name", "")
|
|
1140
|
-
else:
|
|
1141
|
-
code = str(code_info)
|
|
1142
|
-
|
|
1143
|
-
# Determine severity from code prefix
|
|
1144
|
-
severity = "warning"
|
|
1145
|
-
if code.startswith("E") or code.startswith("F"):
|
|
1146
|
-
severity = "error"
|
|
1147
|
-
elif code.startswith("W"):
|
|
1148
|
-
severity = "warning"
|
|
1149
|
-
elif code.startswith("I"):
|
|
1150
|
-
severity = "info"
|
|
1151
|
-
|
|
1152
|
-
# Get location info
|
|
1153
|
-
location = diag.get("location", {})
|
|
1154
|
-
line = location.get("row", 0) if isinstance(location, dict) else 0
|
|
1155
|
-
column = location.get("column", 0) if isinstance(location, dict) else 0
|
|
1156
|
-
|
|
1157
|
-
formatted.append({
|
|
1158
|
-
"code": code,
|
|
1159
|
-
"message": diag.get("message", ""),
|
|
1160
|
-
"line": line,
|
|
1161
|
-
"column": column,
|
|
1162
|
-
"severity": severity,
|
|
1163
|
-
})
|
|
1164
|
-
|
|
1165
|
-
# Sort by line number
|
|
1166
|
-
formatted.sort(key=lambda x: (x.get("line", 0), x.get("column", 0)))
|
|
1167
|
-
|
|
1168
|
-
return formatted
|
|
1169
|
-
|
|
1170
|
-
def _group_ruff_issues_by_code(self, issues: list[dict[str, Any]]) -> dict[str, Any]:
|
|
1171
|
-
"""
|
|
1172
|
-
Group ruff issues by rule code for cleaner, more actionable reports.
|
|
1173
|
-
|
|
1174
|
-
ENH-002 Story #18: Ruff Output Grouping
|
|
1175
|
-
|
|
1176
|
-
Args:
|
|
1177
|
-
issues: List of ruff diagnostic dictionaries
|
|
1178
|
-
|
|
1179
|
-
Returns:
|
|
1180
|
-
Dictionary with grouped issues:
|
|
1181
|
-
{
|
|
1182
|
-
"total_count": int,
|
|
1183
|
-
"groups": [
|
|
1184
|
-
{
|
|
1185
|
-
"code": "UP006",
|
|
1186
|
-
"count": 17,
|
|
1187
|
-
"description": "Use dict/list instead of Dict/List",
|
|
1188
|
-
"severity": "info",
|
|
1189
|
-
"issues": [...]
|
|
1190
|
-
},
|
|
1191
|
-
...
|
|
1192
|
-
],
|
|
1193
|
-
"summary": "UP006 (17), UP045 (10), UP007 (2), F401 (1)"
|
|
1194
|
-
}
|
|
1195
|
-
"""
|
|
1196
|
-
if not issues:
|
|
1197
|
-
return {
|
|
1198
|
-
"total_count": 0,
|
|
1199
|
-
"groups": [],
|
|
1200
|
-
"summary": "No issues found"
|
|
1201
|
-
}
|
|
1202
|
-
|
|
1203
|
-
# Group issues by code
|
|
1204
|
-
groups_dict: dict[str, list[dict[str, Any]]] = {}
|
|
1205
|
-
for issue in issues:
|
|
1206
|
-
code_info = issue.get("code", {})
|
|
1207
|
-
if isinstance(code_info, dict):
|
|
1208
|
-
code = code_info.get("name", "UNKNOWN")
|
|
1209
|
-
else:
|
|
1210
|
-
code = str(code_info) if code_info else "UNKNOWN"
|
|
1211
|
-
|
|
1212
|
-
if code not in groups_dict:
|
|
1213
|
-
groups_dict[code] = []
|
|
1214
|
-
groups_dict[code].append(issue)
|
|
1215
|
-
|
|
1216
|
-
# Create grouped structure with metadata
|
|
1217
|
-
groups = []
|
|
1218
|
-
for code, code_issues in groups_dict.items():
|
|
1219
|
-
# Get first message as description (they're usually the same for same code)
|
|
1220
|
-
description = code_issues[0].get("message", "") if code_issues else ""
|
|
1221
|
-
|
|
1222
|
-
# Determine severity from code
|
|
1223
|
-
severity = "info"
|
|
1224
|
-
if code.startswith("E") or code.startswith("F"):
|
|
1225
|
-
severity = "error"
|
|
1226
|
-
elif code.startswith("W"):
|
|
1227
|
-
severity = "warning"
|
|
1228
|
-
|
|
1229
|
-
groups.append({
|
|
1230
|
-
"code": code,
|
|
1231
|
-
"count": len(code_issues),
|
|
1232
|
-
"description": description,
|
|
1233
|
-
"severity": severity,
|
|
1234
|
-
"issues": code_issues
|
|
1235
|
-
})
|
|
1236
|
-
|
|
1237
|
-
# Sort by count (descending) then by code
|
|
1238
|
-
groups.sort(key=lambda x: (-x["count"], x["code"]))
|
|
1239
|
-
|
|
1240
|
-
# Create summary string: "UP006 (17), UP045 (10), ..."
|
|
1241
|
-
summary_parts = [f"{g['code']} ({g['count']})" for g in groups]
|
|
1242
|
-
summary = ", ".join(summary_parts)
|
|
1243
|
-
|
|
1244
|
-
return {
|
|
1245
|
-
"total_count": len(issues),
|
|
1246
|
-
"groups": groups,
|
|
1247
|
-
"summary": summary
|
|
1248
|
-
}
|
|
1249
|
-
|
|
1250
|
-
def _calculate_duplication_score(self, file_path: Path) -> float:
|
|
1251
|
-
"""
|
|
1252
|
-
Calculate duplication score using jscpd (0-10 scale, higher is better).
|
|
1253
|
-
|
|
1254
|
-
Phase 6.4: Modern Quality Analysis - jscpd Integration
|
|
1255
|
-
|
|
1256
|
-
Note: jscpd works on directories/files, so we analyze the parent directory
|
|
1257
|
-
or file directly. For single file, we analyze just that file.
|
|
1258
|
-
|
|
1259
|
-
Returns:
|
|
1260
|
-
Duplication score (0-10), where 10 = no duplication, 0 = high duplication
|
|
1261
|
-
Score formula: 10 - (duplication_pct / 10)
|
|
1262
|
-
"""
|
|
1263
|
-
if not self.has_jscpd:
|
|
1264
|
-
return 5.0 # Neutral score if jscpd not available
|
|
1265
|
-
|
|
1266
|
-
# jscpd works best on directories or multiple files
|
|
1267
|
-
# For single file analysis, we'll analyze the file directly
|
|
1268
|
-
try:
|
|
1269
|
-
# Determine target (file or directory)
|
|
1270
|
-
target = str(file_path)
|
|
1271
|
-
if file_path.is_dir():
|
|
1272
|
-
target_dir = str(file_path)
|
|
1273
|
-
else:
|
|
1274
|
-
target_dir = str(file_path.parent)
|
|
1275
|
-
|
|
1276
|
-
# Build jscpd command
|
|
1277
|
-
# Use npx if jscpd not directly available
|
|
1278
|
-
jscpd_path = shutil.which("jscpd")
|
|
1279
|
-
if jscpd_path:
|
|
1280
|
-
cmd = [jscpd_path]
|
|
1281
|
-
else:
|
|
1282
|
-
npx_path = shutil.which("npx")
|
|
1283
|
-
if not npx_path:
|
|
1284
|
-
return 5.0 # jscpd not available
|
|
1285
|
-
cmd = [npx_path, "--yes", "jscpd"]
|
|
1286
|
-
|
|
1287
|
-
# Add jscpd arguments
|
|
1288
|
-
cmd.extend(
|
|
1289
|
-
[
|
|
1290
|
-
target,
|
|
1291
|
-
"--format",
|
|
1292
|
-
"json",
|
|
1293
|
-
"--min-lines",
|
|
1294
|
-
str(self.min_duplication_lines),
|
|
1295
|
-
"--reporters",
|
|
1296
|
-
"json",
|
|
1297
|
-
"--output",
|
|
1298
|
-
".", # Output to current directory
|
|
1299
|
-
]
|
|
1300
|
-
)
|
|
1301
|
-
|
|
1302
|
-
# Run jscpd
|
|
1303
|
-
result = subprocess.run( # nosec B603 - fixed args
|
|
1304
|
-
wrap_windows_cmd_shim(cmd),
|
|
1305
|
-
capture_output=True,
|
|
1306
|
-
text=True,
|
|
1307
|
-
encoding="utf-8",
|
|
1308
|
-
errors="replace",
|
|
1309
|
-
timeout=120, # 2 minute timeout (jscpd can be slow on large codebases)
|
|
1310
|
-
cwd=target_dir if Path(target_dir).exists() else None,
|
|
1311
|
-
)
|
|
1312
|
-
|
|
1313
|
-
# jscpd outputs JSON to stdout when using --reporters json
|
|
1314
|
-
# But it might also create a file, so check both
|
|
1315
|
-
json_output = result.stdout.strip()
|
|
1316
|
-
|
|
1317
|
-
# Try to parse JSON from stdout
|
|
1318
|
-
try:
|
|
1319
|
-
if json_output:
|
|
1320
|
-
report_data = json_lib.loads(json_output)
|
|
1321
|
-
else:
|
|
1322
|
-
# Check for jscpd-report.json in output directory
|
|
1323
|
-
output_file = Path(target_dir) / "jscpd-report.json"
|
|
1324
|
-
if output_file.exists():
|
|
1325
|
-
with open(output_file, encoding="utf-8") as f:
|
|
1326
|
-
report_data = json_lib.load(f)
|
|
1327
|
-
else:
|
|
1328
|
-
# No duplication found (exit code 0 typically means no issues or success)
|
|
1329
|
-
if result.returncode == 0:
|
|
1330
|
-
return 10.0 # Perfect score (no duplication)
|
|
1331
|
-
return 5.0 # Neutral on parse failure
|
|
1332
|
-
except json_lib.JSONDecodeError:
|
|
1333
|
-
# JSON parse error - might be text output
|
|
1334
|
-
# Try to extract duplication percentage from text output
|
|
1335
|
-
# Format: "Found X% duplicated lines out of Y total lines"
|
|
1336
|
-
lines = result.stdout.split("\n") + result.stderr.split("\n")
|
|
1337
|
-
for line in lines:
|
|
1338
|
-
if "%" in line and "duplicate" in line.lower():
|
|
1339
|
-
# Try to extract percentage
|
|
1340
|
-
try:
|
|
1341
|
-
pct_str = line.split("%")[0].split()[-1]
|
|
1342
|
-
duplication_pct = float(pct_str)
|
|
1343
|
-
score = 10.0 - (duplication_pct / 10.0)
|
|
1344
|
-
return max(0.0, min(10.0, score))
|
|
1345
|
-
except (ValueError, IndexError):
|
|
1346
|
-
pass
|
|
1347
|
-
|
|
1348
|
-
# If we can't parse, default behavior
|
|
1349
|
-
if result.returncode == 0:
|
|
1350
|
-
return 10.0 # No duplication found
|
|
1351
|
-
return 5.0 # Neutral on parse failure
|
|
1352
|
-
|
|
1353
|
-
# Extract duplication percentage from JSON report
|
|
1354
|
-
# jscpd JSON structure: { "percentage": X.X, ... }
|
|
1355
|
-
duplication_pct = report_data.get("percentage", 0.0)
|
|
1356
|
-
|
|
1357
|
-
# Calculate score: 10 - (duplication_pct / 10)
|
|
1358
|
-
# This means:
|
|
1359
|
-
# - 0% duplication = 10.0 score
|
|
1360
|
-
# - 3% duplication (threshold) = 9.7 score
|
|
1361
|
-
# - 10% duplication = 9.0 score
|
|
1362
|
-
# - 30% duplication = 7.0 score
|
|
1363
|
-
# - 100% duplication = 0.0 score
|
|
1364
|
-
score = 10.0 - (duplication_pct / 10.0)
|
|
1365
|
-
return max(0.0, min(10.0, score))
|
|
1366
|
-
|
|
1367
|
-
except subprocess.TimeoutExpired:
|
|
1368
|
-
return 5.0 # Neutral on timeout
|
|
1369
|
-
except FileNotFoundError:
|
|
1370
|
-
# jscpd not found
|
|
1371
|
-
return 5.0
|
|
1372
|
-
except Exception:
|
|
1373
|
-
# Any other error
|
|
1374
|
-
return 5.0
|
|
1375
|
-
|
|
1376
|
-
def get_duplication_report(self, file_path: Path) -> dict[str, Any]:
|
|
1377
|
-
"""
|
|
1378
|
-
Get detailed duplication report from jscpd.
|
|
1379
|
-
|
|
1380
|
-
Phase 6.4: Modern Quality Analysis - jscpd Integration
|
|
1381
|
-
|
|
1382
|
-
Returns:
|
|
1383
|
-
Dictionary with duplication report data including:
|
|
1384
|
-
- percentage: Duplication percentage
|
|
1385
|
-
- duplicates: List of duplicate code blocks
|
|
1386
|
-
- files: File-level duplication stats
|
|
1387
|
-
"""
|
|
1388
|
-
if not self.has_jscpd:
|
|
1389
|
-
return {
|
|
1390
|
-
"available": False,
|
|
1391
|
-
"percentage": 0.0,
|
|
1392
|
-
"duplicates": [],
|
|
1393
|
-
"files": [],
|
|
1394
|
-
}
|
|
1395
|
-
|
|
1396
|
-
try:
|
|
1397
|
-
# Determine target
|
|
1398
|
-
if file_path.is_dir():
|
|
1399
|
-
target_dir = str(file_path)
|
|
1400
|
-
target = str(file_path)
|
|
1401
|
-
else:
|
|
1402
|
-
target_dir = str(file_path.parent)
|
|
1403
|
-
target = str(file_path)
|
|
1404
|
-
|
|
1405
|
-
# Build jscpd command
|
|
1406
|
-
jscpd_path = shutil.which("jscpd")
|
|
1407
|
-
if jscpd_path:
|
|
1408
|
-
cmd = [jscpd_path]
|
|
1409
|
-
else:
|
|
1410
|
-
npx_path = shutil.which("npx")
|
|
1411
|
-
if not npx_path:
|
|
1412
|
-
return {
|
|
1413
|
-
"available": False,
|
|
1414
|
-
"percentage": 0.0,
|
|
1415
|
-
"duplicates": [],
|
|
1416
|
-
"files": [],
|
|
1417
|
-
}
|
|
1418
|
-
cmd = [npx_path, "--yes", "jscpd"]
|
|
1419
|
-
|
|
1420
|
-
cmd.extend(
|
|
1421
|
-
[
|
|
1422
|
-
target,
|
|
1423
|
-
"--format",
|
|
1424
|
-
"json",
|
|
1425
|
-
"--min-lines",
|
|
1426
|
-
str(self.min_duplication_lines),
|
|
1427
|
-
"--reporters",
|
|
1428
|
-
"json",
|
|
1429
|
-
]
|
|
1430
|
-
)
|
|
1431
|
-
|
|
1432
|
-
# Run jscpd
|
|
1433
|
-
result = subprocess.run( # nosec B603 - fixed args
|
|
1434
|
-
wrap_windows_cmd_shim(cmd),
|
|
1435
|
-
capture_output=True,
|
|
1436
|
-
text=True,
|
|
1437
|
-
encoding="utf-8",
|
|
1438
|
-
errors="replace",
|
|
1439
|
-
timeout=120,
|
|
1440
|
-
cwd=target_dir if Path(target_dir).exists() else None,
|
|
1441
|
-
)
|
|
1442
|
-
|
|
1443
|
-
# Parse JSON output
|
|
1444
|
-
json_output = result.stdout.strip()
|
|
1445
|
-
try:
|
|
1446
|
-
if json_output:
|
|
1447
|
-
report_data = json_lib.loads(json_output)
|
|
1448
|
-
else:
|
|
1449
|
-
# Check for output file
|
|
1450
|
-
output_file = Path(target_dir) / "jscpd-report.json"
|
|
1451
|
-
if output_file.exists():
|
|
1452
|
-
with open(output_file, encoding="utf-8") as f:
|
|
1453
|
-
report_data = json_lib.load(f)
|
|
1454
|
-
else:
|
|
1455
|
-
return {
|
|
1456
|
-
"available": True,
|
|
1457
|
-
"percentage": 0.0,
|
|
1458
|
-
"duplicates": [],
|
|
1459
|
-
"files": [],
|
|
1460
|
-
}
|
|
1461
|
-
except json_lib.JSONDecodeError:
|
|
1462
|
-
return {
|
|
1463
|
-
"available": True,
|
|
1464
|
-
"error": "Failed to parse jscpd output",
|
|
1465
|
-
"percentage": 0.0,
|
|
1466
|
-
"duplicates": [],
|
|
1467
|
-
"files": [],
|
|
1468
|
-
}
|
|
1469
|
-
|
|
1470
|
-
# Extract relevant data from jscpd report
|
|
1471
|
-
# jscpd JSON format varies, but typically has:
|
|
1472
|
-
# - percentage: overall duplication percentage
|
|
1473
|
-
# - duplicates: array of duplicate pairs
|
|
1474
|
-
# - files: file-level statistics
|
|
1475
|
-
|
|
1476
|
-
return {
|
|
1477
|
-
"available": True,
|
|
1478
|
-
"percentage": report_data.get("percentage", 0.0),
|
|
1479
|
-
"duplicates": report_data.get("duplicates", []),
|
|
1480
|
-
"files": (
|
|
1481
|
-
report_data.get("statistics", {}).get("files", [])
|
|
1482
|
-
if "statistics" in report_data
|
|
1483
|
-
else []
|
|
1484
|
-
),
|
|
1485
|
-
"total_lines": (
|
|
1486
|
-
report_data.get("statistics", {}).get("total", {}).get("lines", 0)
|
|
1487
|
-
if "statistics" in report_data
|
|
1488
|
-
else 0
|
|
1489
|
-
),
|
|
1490
|
-
"duplicated_lines": (
|
|
1491
|
-
report_data.get("statistics", {})
|
|
1492
|
-
.get("duplicated", {})
|
|
1493
|
-
.get("lines", 0)
|
|
1494
|
-
if "statistics" in report_data
|
|
1495
|
-
else 0
|
|
1496
|
-
),
|
|
1497
|
-
}
|
|
1498
|
-
|
|
1499
|
-
except subprocess.TimeoutExpired:
|
|
1500
|
-
return {
|
|
1501
|
-
"available": True,
|
|
1502
|
-
"error": "jscpd timeout",
|
|
1503
|
-
"percentage": 0.0,
|
|
1504
|
-
"duplicates": [],
|
|
1505
|
-
"files": [],
|
|
1506
|
-
}
|
|
1507
|
-
except FileNotFoundError:
|
|
1508
|
-
return {
|
|
1509
|
-
"available": False,
|
|
1510
|
-
"percentage": 0.0,
|
|
1511
|
-
"duplicates": [],
|
|
1512
|
-
"files": [],
|
|
1513
|
-
}
|
|
1514
|
-
except Exception as e:
|
|
1515
|
-
return {
|
|
1516
|
-
"available": True,
|
|
1517
|
-
"error": str(e),
|
|
1518
|
-
"percentage": 0.0,
|
|
1519
|
-
"duplicates": [],
|
|
1520
|
-
"files": [],
|
|
1521
|
-
}
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
class ScorerFactory:
|
|
1525
|
-
"""
|
|
1526
|
-
Factory to provide appropriate scorer based on language (Strategy Pattern).
|
|
1527
|
-
|
|
1528
|
-
Phase 1.2: Language-Specific Scorers
|
|
1529
|
-
|
|
1530
|
-
Now uses ScorerRegistry for extensible language support.
|
|
1531
|
-
"""
|
|
1532
|
-
|
|
1533
|
-
@staticmethod
|
|
1534
|
-
def get_scorer(language: Language, config: ProjectConfig | None = None) -> BaseScorer:
|
|
1535
|
-
"""
|
|
1536
|
-
Get the appropriate scorer for a given language.
|
|
1537
|
-
|
|
1538
|
-
Uses ScorerRegistry for extensible language support with fallback chains.
|
|
1539
|
-
|
|
1540
|
-
Args:
|
|
1541
|
-
language: Detected language enum
|
|
1542
|
-
config: Optional project configuration
|
|
1543
|
-
|
|
1544
|
-
Returns:
|
|
1545
|
-
BaseScorer instance appropriate for the language
|
|
1546
|
-
|
|
1547
|
-
Raises:
|
|
1548
|
-
ValueError: If no scorer is available for the language (even with fallbacks)
|
|
1549
|
-
"""
|
|
1550
|
-
from .scorer_registry import ScorerRegistry
|
|
1551
|
-
|
|
1552
|
-
try:
|
|
1553
|
-
return ScorerRegistry.get_scorer(language, config)
|
|
1554
|
-
except ValueError:
|
|
1555
|
-
# If no scorer found, fall back to Python scorer as last resort
|
|
1556
|
-
# This maintains backward compatibility but may not work well for non-Python code
|
|
1557
|
-
# TODO: In the future, create a GenericScorer that uses metric strategies
|
|
1558
|
-
if language != Language.PYTHON:
|
|
1559
|
-
# Try Python scorer as absolute last resort
|
|
1560
|
-
try:
|
|
1561
|
-
return ScorerRegistry.get_scorer(Language.PYTHON, config)
|
|
1562
|
-
except ValueError:
|
|
1563
|
-
pass
|
|
1564
|
-
|
|
1565
|
-
# If even Python scorer isn't available, raise the original error
|
|
1566
|
-
raise
|
|
1
|
+
"""
|
|
2
|
+
Code Scoring System - Calculates objective quality metrics
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import json as json_lib
|
|
7
|
+
import logging
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess # nosec B404 - used with fixed args, no shell
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Protocol
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
from ...core.config import ProjectConfig, ScoringWeightsConfig
|
|
17
|
+
from ...core.language_detector import Language
|
|
18
|
+
from ...core.subprocess_utils import wrap_windows_cmd_shim
|
|
19
|
+
from .score_constants import ComplexityConstants, SecurityConstants
|
|
20
|
+
from .validation import validate_code_input
|
|
21
|
+
|
|
22
|
+
# Import analysis libraries
|
|
23
|
+
try:
|
|
24
|
+
from radon.complexity import cc_visit
|
|
25
|
+
from radon.metrics import mi_visit
|
|
26
|
+
|
|
27
|
+
HAS_RADON = True
|
|
28
|
+
except ImportError:
|
|
29
|
+
HAS_RADON = False
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
import bandit
|
|
33
|
+
from bandit.core import config as bandit_config
|
|
34
|
+
from bandit.core import manager
|
|
35
|
+
|
|
36
|
+
HAS_BANDIT = True
|
|
37
|
+
except ImportError:
|
|
38
|
+
HAS_BANDIT = False
|
|
39
|
+
|
|
40
|
+
# Check if ruff is available in PATH or via python -m ruff
|
|
41
|
+
def _check_ruff_available() -> bool:
|
|
42
|
+
"""Check if ruff is available via 'ruff' command or 'python -m ruff'"""
|
|
43
|
+
# Check for ruff command directly
|
|
44
|
+
if shutil.which("ruff"):
|
|
45
|
+
return True
|
|
46
|
+
# Check for python -m ruff
|
|
47
|
+
try:
|
|
48
|
+
result = subprocess.run( # nosec B603 - fixed args
|
|
49
|
+
[sys.executable, "-m", "ruff", "--version"],
|
|
50
|
+
capture_output=True,
|
|
51
|
+
timeout=5,
|
|
52
|
+
check=False,
|
|
53
|
+
)
|
|
54
|
+
return result.returncode == 0
|
|
55
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
HAS_RUFF = _check_ruff_available()
|
|
60
|
+
|
|
61
|
+
# Check if mypy is available in PATH
|
|
62
|
+
HAS_MYPY = shutil.which("mypy") is not None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# Check if jscpd is available (via npm/npx)
|
|
66
|
+
def _check_jscpd_available() -> bool:
|
|
67
|
+
"""Check if jscpd is available via jscpd command or npx jscpd"""
|
|
68
|
+
# Check for jscpd command directly
|
|
69
|
+
if shutil.which("jscpd"):
|
|
70
|
+
return True
|
|
71
|
+
# Check for npx (Node.js package runner)
|
|
72
|
+
npx_path = shutil.which("npx")
|
|
73
|
+
if npx_path:
|
|
74
|
+
try:
|
|
75
|
+
cmd = wrap_windows_cmd_shim([npx_path, "--yes", "jscpd", "--version"])
|
|
76
|
+
result = subprocess.run( # nosec B603 - fixed args
|
|
77
|
+
cmd,
|
|
78
|
+
capture_output=True,
|
|
79
|
+
timeout=5,
|
|
80
|
+
check=False,
|
|
81
|
+
)
|
|
82
|
+
return result.returncode == 0
|
|
83
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
84
|
+
return False
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
HAS_JSCPD = _check_jscpd_available()
|
|
89
|
+
|
|
90
|
+
# Import coverage tools
|
|
91
|
+
try:
|
|
92
|
+
from coverage import Coverage
|
|
93
|
+
|
|
94
|
+
HAS_COVERAGE = True
|
|
95
|
+
except ImportError:
|
|
96
|
+
HAS_COVERAGE = False
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class BaseScorer:
|
|
100
|
+
"""
|
|
101
|
+
Base class for all scorers.
|
|
102
|
+
Defines the interface and shared helpers: _find_project_root, _calculate_structure_score, _calculate_devex_score (7-category §3.2).
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def score_file(self, file_path: Path, code: str) -> dict[str, Any]:
|
|
106
|
+
"""Score a file and return quality metrics. Subclasses must implement."""
|
|
107
|
+
raise NotImplementedError("Subclasses must implement score_file")
|
|
108
|
+
|
|
109
|
+
@staticmethod
|
|
110
|
+
def _find_project_root(file_path: Path) -> Path | None:
|
|
111
|
+
"""Find project root by common markers (.git, pyproject.toml, package.json, .tapps-agents, etc.)."""
|
|
112
|
+
current = file_path.resolve().parent
|
|
113
|
+
markers = [".git", "pyproject.toml", "setup.py", "requirements.txt", ".tapps-agents", "package.json"]
|
|
114
|
+
for _ in range(10):
|
|
115
|
+
for m in markers:
|
|
116
|
+
if (current / m).exists():
|
|
117
|
+
return current
|
|
118
|
+
if current.parent == current:
|
|
119
|
+
break
|
|
120
|
+
current = current.parent
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
@classmethod
|
|
124
|
+
def _calculate_structure_score(cls, file_path: Path) -> float:
|
|
125
|
+
"""Structure score (0-10). Project layout, key files. 7-category §3.2."""
|
|
126
|
+
root = BaseScorer._find_project_root(file_path)
|
|
127
|
+
if root is None:
|
|
128
|
+
return 5.0
|
|
129
|
+
pts = 0.0
|
|
130
|
+
if (root / "pyproject.toml").exists() or (root / "package.json").exists():
|
|
131
|
+
pts += 2.5
|
|
132
|
+
if (root / "README").exists() or (root / "README.md").exists() or (root / "README.rst").exists():
|
|
133
|
+
pts += 2.0
|
|
134
|
+
if (root / "tests").exists() or (root / "test").exists():
|
|
135
|
+
pts += 2.0
|
|
136
|
+
if (root / ".tapps-agents").exists() or (root / ".git").exists():
|
|
137
|
+
pts += 1.0
|
|
138
|
+
if (root / "setup.py").exists() or (root / "requirements.txt").exists() or (root / "package-lock.json").exists():
|
|
139
|
+
pts += 1.5
|
|
140
|
+
return min(10.0, pts * 2.0)
|
|
141
|
+
|
|
142
|
+
@classmethod
|
|
143
|
+
def _calculate_devex_score(cls, file_path: Path) -> float:
|
|
144
|
+
"""DevEx score (0-10). Docs, config, tooling. 7-category §3.2."""
|
|
145
|
+
root = BaseScorer._find_project_root(file_path)
|
|
146
|
+
if root is None:
|
|
147
|
+
return 5.0
|
|
148
|
+
pts = 0.0
|
|
149
|
+
if (root / "AGENTS.md").exists() or (root / "CLAUDE.md").exists():
|
|
150
|
+
pts += 3.0
|
|
151
|
+
if (root / "docs").exists() and (root / "docs").is_dir():
|
|
152
|
+
pts += 2.0
|
|
153
|
+
if (root / ".tapps-agents").exists() or (root / ".cursor").exists():
|
|
154
|
+
pts += 2.0
|
|
155
|
+
pyproject, pkg = root / "pyproject.toml", root / "package.json"
|
|
156
|
+
if pyproject.exists():
|
|
157
|
+
try:
|
|
158
|
+
t = pyproject.read_text(encoding="utf-8", errors="replace")
|
|
159
|
+
if "[tool.ruff]" in t or "[tool.mypy]" in t or "pytest" in t:
|
|
160
|
+
pts += 1.5
|
|
161
|
+
except Exception:
|
|
162
|
+
pass
|
|
163
|
+
if pkg.exists():
|
|
164
|
+
try:
|
|
165
|
+
import json as _j
|
|
166
|
+
d = _j.loads(pkg.read_text(encoding="utf-8", errors="replace"))
|
|
167
|
+
dev = (d.get("devDependencies") or {}) if isinstance(d, dict) else {}
|
|
168
|
+
if any(k in dev for k in ("eslint", "jest", "vitest", "mypy")):
|
|
169
|
+
pts += 1.5
|
|
170
|
+
except Exception:
|
|
171
|
+
pass
|
|
172
|
+
return min(10.0, pts * 2.0)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class CodeScorer(BaseScorer):
|
|
176
|
+
"""Calculate code quality scores for Python files"""
|
|
177
|
+
|
|
178
|
+
def __init__(
|
|
179
|
+
self,
|
|
180
|
+
weights: ScoringWeightsConfig | None = None,
|
|
181
|
+
ruff_enabled: bool = True,
|
|
182
|
+
mypy_enabled: bool = True,
|
|
183
|
+
jscpd_enabled: bool = True,
|
|
184
|
+
duplication_threshold: float = 3.0,
|
|
185
|
+
min_duplication_lines: int = 5,
|
|
186
|
+
):
|
|
187
|
+
self.has_radon = HAS_RADON
|
|
188
|
+
self.has_bandit = HAS_BANDIT
|
|
189
|
+
self.has_coverage = HAS_COVERAGE
|
|
190
|
+
self.has_ruff = HAS_RUFF and ruff_enabled
|
|
191
|
+
self.has_mypy = HAS_MYPY and mypy_enabled
|
|
192
|
+
self.has_jscpd = HAS_JSCPD and jscpd_enabled
|
|
193
|
+
self.duplication_threshold = duplication_threshold
|
|
194
|
+
self.min_duplication_lines = min_duplication_lines
|
|
195
|
+
self.weights = weights # Will use defaults if None
|
|
196
|
+
|
|
197
|
+
def score_file(self, file_path: Path, code: str) -> dict[str, Any]:
|
|
198
|
+
"""
|
|
199
|
+
Calculate scores for a code file.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
{
|
|
203
|
+
"complexity_score": float (0-10),
|
|
204
|
+
"security_score": float (0-10),
|
|
205
|
+
"maintainability_score": float (0-10),
|
|
206
|
+
"overall_score": float (0-100),
|
|
207
|
+
"metrics": {...}
|
|
208
|
+
}
|
|
209
|
+
"""
|
|
210
|
+
metrics: dict[str, float] = {}
|
|
211
|
+
scores: dict[str, Any] = {
|
|
212
|
+
"complexity_score": 0.0,
|
|
213
|
+
"security_score": 0.0,
|
|
214
|
+
"maintainability_score": 0.0,
|
|
215
|
+
"test_coverage_score": 0.0,
|
|
216
|
+
"performance_score": 0.0,
|
|
217
|
+
"structure_score": 0.0, # 7-category: project layout, key files (MCP_SYSTEMS_IMPROVEMENT_RECOMMENDATIONS §3.2)
|
|
218
|
+
"devex_score": 0.0, # 7-category: docs, config, tooling (MCP_SYSTEMS_IMPROVEMENT_RECOMMENDATIONS §3.2)
|
|
219
|
+
"linting_score": 0.0, # Phase 6.1: Ruff linting score
|
|
220
|
+
"type_checking_score": 0.0, # Phase 6.2: mypy type checking score
|
|
221
|
+
"duplication_score": 0.0, # Phase 6.4: jscpd duplication score
|
|
222
|
+
"metrics": metrics,
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
# Complexity Score (0-10, lower is better)
|
|
226
|
+
scores["complexity_score"] = self._calculate_complexity(code)
|
|
227
|
+
metrics["complexity"] = float(scores["complexity_score"])
|
|
228
|
+
|
|
229
|
+
# Security Score (0-10, higher is better)
|
|
230
|
+
scores["security_score"] = self._calculate_security(file_path, code)
|
|
231
|
+
metrics["security"] = float(scores["security_score"])
|
|
232
|
+
|
|
233
|
+
# Maintainability Score (0-10, higher is better)
|
|
234
|
+
scores["maintainability_score"] = self._calculate_maintainability(code)
|
|
235
|
+
metrics["maintainability"] = float(scores["maintainability_score"])
|
|
236
|
+
|
|
237
|
+
# Test Coverage Score (0-10, higher is better)
|
|
238
|
+
scores["test_coverage_score"] = self._calculate_test_coverage(file_path)
|
|
239
|
+
metrics["test_coverage"] = float(scores["test_coverage_score"])
|
|
240
|
+
|
|
241
|
+
# Performance Score (0-10, higher is better)
|
|
242
|
+
# Phase 3.2: Use context-aware performance scorer
|
|
243
|
+
from .performance_scorer import PerformanceScorer
|
|
244
|
+
from ...core.language_detector import Language
|
|
245
|
+
|
|
246
|
+
performance_scorer = PerformanceScorer()
|
|
247
|
+
scores["performance_score"] = performance_scorer.calculate(
|
|
248
|
+
code, Language.PYTHON, file_path, context=None
|
|
249
|
+
)
|
|
250
|
+
metrics["performance"] = float(scores["performance_score"])
|
|
251
|
+
|
|
252
|
+
# Linting Score (0-10, higher is better) - Phase 6.1
|
|
253
|
+
scores["linting_score"] = self._calculate_linting_score(file_path)
|
|
254
|
+
metrics["linting"] = float(scores["linting_score"])
|
|
255
|
+
|
|
256
|
+
# Get actual linting issues for transparency (P1 Improvement)
|
|
257
|
+
linting_issues = self.get_ruff_issues(file_path)
|
|
258
|
+
scores["linting_issues"] = self._format_ruff_issues(linting_issues)
|
|
259
|
+
scores["linting_issue_count"] = len(linting_issues)
|
|
260
|
+
|
|
261
|
+
# Type Checking Score (0-10, higher is better) - Phase 6.2
|
|
262
|
+
scores["type_checking_score"] = self._calculate_type_checking_score(file_path)
|
|
263
|
+
metrics["type_checking"] = float(scores["type_checking_score"])
|
|
264
|
+
|
|
265
|
+
# Get actual type checking issues for transparency (P1 Improvement)
|
|
266
|
+
type_issues = self.get_mypy_errors(file_path)
|
|
267
|
+
scores["type_issues"] = type_issues # Already formatted
|
|
268
|
+
scores["type_issue_count"] = len(type_issues)
|
|
269
|
+
|
|
270
|
+
# Duplication Score (0-10, higher is better) - Phase 6.4
|
|
271
|
+
scores["duplication_score"] = self._calculate_duplication_score(file_path)
|
|
272
|
+
metrics["duplication"] = float(scores["duplication_score"])
|
|
273
|
+
|
|
274
|
+
# Structure Score (0-10, higher is better) - 7-category §3.2
|
|
275
|
+
scores["structure_score"] = self._calculate_structure_score(file_path)
|
|
276
|
+
metrics["structure"] = float(scores["structure_score"])
|
|
277
|
+
|
|
278
|
+
# DevEx Score (0-10, higher is better) - 7-category §3.2
|
|
279
|
+
scores["devex_score"] = self._calculate_devex_score(file_path)
|
|
280
|
+
metrics["devex"] = float(scores["devex_score"])
|
|
281
|
+
|
|
282
|
+
class _Weights(Protocol):
|
|
283
|
+
complexity: float
|
|
284
|
+
security: float
|
|
285
|
+
maintainability: float
|
|
286
|
+
test_coverage: float
|
|
287
|
+
performance: float
|
|
288
|
+
structure: float
|
|
289
|
+
devex: float
|
|
290
|
+
|
|
291
|
+
# Overall Score (weighted average, 7-category)
|
|
292
|
+
if self.weights is not None:
|
|
293
|
+
w: _Weights = self.weights
|
|
294
|
+
else:
|
|
295
|
+
class DefaultWeights:
|
|
296
|
+
complexity = 0.18
|
|
297
|
+
security = 0.27
|
|
298
|
+
maintainability = 0.24
|
|
299
|
+
test_coverage = 0.13
|
|
300
|
+
performance = 0.08
|
|
301
|
+
structure = 0.05
|
|
302
|
+
devex = 0.05
|
|
303
|
+
|
|
304
|
+
w = DefaultWeights()
|
|
305
|
+
|
|
306
|
+
scores["overall_score"] = (
|
|
307
|
+
(10 - scores["complexity_score"]) * w.complexity
|
|
308
|
+
+ scores["security_score"] * w.security
|
|
309
|
+
+ scores["maintainability_score"] * w.maintainability
|
|
310
|
+
+ scores["test_coverage_score"] * w.test_coverage
|
|
311
|
+
+ scores["performance_score"] * w.performance
|
|
312
|
+
+ scores["structure_score"] * w.structure
|
|
313
|
+
+ scores["devex_score"] * w.devex
|
|
314
|
+
) * 10 # Scale from 0-10 weighted sum to 0-100
|
|
315
|
+
|
|
316
|
+
# Phase 3.3: Validate all scores before returning
|
|
317
|
+
from .score_validator import ScoreValidator
|
|
318
|
+
from ...core.language_detector import Language
|
|
319
|
+
|
|
320
|
+
validator = ScoreValidator()
|
|
321
|
+
validation_results = validator.validate_all_scores(
|
|
322
|
+
scores, language=Language.PYTHON, context=None
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# Update scores with validated/clamped values and add explanations
|
|
326
|
+
validated_scores = {}
|
|
327
|
+
score_explanations = {}
|
|
328
|
+
for category, result in validation_results.items():
|
|
329
|
+
if result.valid and result.calibrated_score is not None:
|
|
330
|
+
validated_scores[category] = result.calibrated_score
|
|
331
|
+
if result.explanation:
|
|
332
|
+
score_explanations[category] = {
|
|
333
|
+
"explanation": result.explanation,
|
|
334
|
+
"suggestions": result.suggestions,
|
|
335
|
+
}
|
|
336
|
+
else:
|
|
337
|
+
validated_scores[category] = scores.get(category, 0.0)
|
|
338
|
+
|
|
339
|
+
# Add explanations to result if any
|
|
340
|
+
if score_explanations:
|
|
341
|
+
validated_scores["_explanations"] = score_explanations
|
|
342
|
+
|
|
343
|
+
# Merge: keep all scores (incl. structure_score, devex_score), overlay validated
|
|
344
|
+
merged = {**scores, **validated_scores}
|
|
345
|
+
return merged
|
|
346
|
+
|
|
347
|
+
def _calculate_complexity(self, code: str) -> float:
|
|
348
|
+
"""Calculate cyclomatic complexity (0-10 scale)"""
|
|
349
|
+
# Validate input
|
|
350
|
+
validate_code_input(code, method_name="_calculate_complexity")
|
|
351
|
+
|
|
352
|
+
if not self.has_radon:
|
|
353
|
+
return 5.0 # Default neutral score
|
|
354
|
+
|
|
355
|
+
try:
|
|
356
|
+
tree = ast.parse(code)
|
|
357
|
+
complexities = cc_visit(tree)
|
|
358
|
+
|
|
359
|
+
if not complexities:
|
|
360
|
+
return 1.0
|
|
361
|
+
|
|
362
|
+
# Get max complexity
|
|
363
|
+
max_complexity = max(cc.complexity for cc in complexities)
|
|
364
|
+
|
|
365
|
+
# Scale to 0-10 using constants
|
|
366
|
+
return min(
|
|
367
|
+
max_complexity / ComplexityConstants.SCALING_FACTOR,
|
|
368
|
+
ComplexityConstants.MAX_SCORE
|
|
369
|
+
)
|
|
370
|
+
except SyntaxError:
|
|
371
|
+
return 10.0 # Syntax errors = max complexity
|
|
372
|
+
|
|
373
|
+
def _calculate_security(self, file_path: Path | None, code: str) -> float:
|
|
374
|
+
"""Calculate security score (0-10 scale, higher is better)"""
|
|
375
|
+
# Validate inputs
|
|
376
|
+
validate_code_input(code, method_name="_calculate_security")
|
|
377
|
+
if file_path is not None and not isinstance(file_path, Path):
|
|
378
|
+
raise ValueError(f"_calculate_security: file_path must be Path or None, got {type(file_path).__name__}")
|
|
379
|
+
|
|
380
|
+
if not self.has_bandit:
|
|
381
|
+
# Basic heuristic check
|
|
382
|
+
insecure_patterns = [
|
|
383
|
+
"eval(",
|
|
384
|
+
"exec(",
|
|
385
|
+
"__import__",
|
|
386
|
+
"pickle.loads",
|
|
387
|
+
"subprocess.call",
|
|
388
|
+
"os.system",
|
|
389
|
+
]
|
|
390
|
+
issues = sum(1 for pattern in insecure_patterns if pattern in code)
|
|
391
|
+
return max(
|
|
392
|
+
0.0,
|
|
393
|
+
SecurityConstants.MAX_SCORE - (issues * SecurityConstants.INSECURE_PATTERN_PENALTY)
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
try:
|
|
397
|
+
# Use bandit for proper security analysis
|
|
398
|
+
# BanditManager expects a BanditConfig, not a dict. Passing a dict can raise ValueError,
|
|
399
|
+
# which would silently degrade scoring to a neutral 5.0.
|
|
400
|
+
b_conf = bandit_config.BanditConfig()
|
|
401
|
+
b_mgr = manager.BanditManager(
|
|
402
|
+
config=b_conf,
|
|
403
|
+
agg_type="file",
|
|
404
|
+
debug=False,
|
|
405
|
+
verbose=False,
|
|
406
|
+
quiet=True,
|
|
407
|
+
profile=None,
|
|
408
|
+
ignore_nosec=False,
|
|
409
|
+
)
|
|
410
|
+
b_mgr.discover_files([str(file_path)], False)
|
|
411
|
+
b_mgr.run_tests()
|
|
412
|
+
|
|
413
|
+
# Count high/medium severity issues
|
|
414
|
+
issues = b_mgr.get_issue_list()
|
|
415
|
+
high_severity = sum(1 for i in issues if i.severity == bandit.HIGH)
|
|
416
|
+
medium_severity = sum(1 for i in issues if i.severity == bandit.MEDIUM)
|
|
417
|
+
|
|
418
|
+
# Score: 10 - (high*3 + medium*1)
|
|
419
|
+
score = 10.0 - (high_severity * 3.0 + medium_severity * 1.0)
|
|
420
|
+
return max(0.0, score)
|
|
421
|
+
except (FileNotFoundError, PermissionError, ValueError) as e:
|
|
422
|
+
# Specific exceptions for file/system errors
|
|
423
|
+
logger.warning(f"Security scoring failed for {file_path}: {e}")
|
|
424
|
+
return 5.0 # Default neutral on error
|
|
425
|
+
except Exception as e:
|
|
426
|
+
# Catch-all for unexpected errors (should be rare)
|
|
427
|
+
logger.warning(f"Unexpected error during security scoring for {file_path}: {e}", exc_info=True)
|
|
428
|
+
return 5.0 # Default neutral on error
|
|
429
|
+
|
|
430
|
+
def _calculate_maintainability(self, code: str) -> float:
|
|
431
|
+
"""
|
|
432
|
+
Calculate maintainability index (0-10 scale, higher is better).
|
|
433
|
+
|
|
434
|
+
Phase 3.1: Enhanced with context-aware scoring using MaintainabilityScorer.
|
|
435
|
+
Phase 2 (P0): Maintainability issues are captured separately via get_maintainability_issues().
|
|
436
|
+
"""
|
|
437
|
+
from .maintainability_scorer import MaintainabilityScorer
|
|
438
|
+
from ...core.language_detector import Language
|
|
439
|
+
|
|
440
|
+
# Use context-aware maintainability scorer
|
|
441
|
+
scorer = MaintainabilityScorer()
|
|
442
|
+
return scorer.calculate(code, Language.PYTHON, file_path=None, context=None)
|
|
443
|
+
|
|
444
|
+
def get_maintainability_issues(
|
|
445
|
+
self, code: str, file_path: Path | None = None
|
|
446
|
+
) -> list[dict[str, Any]]:
|
|
447
|
+
"""
|
|
448
|
+
Get specific maintainability issues (Phase 2 - P0).
|
|
449
|
+
|
|
450
|
+
Returns list of issues with details like missing docstrings, long functions, etc.
|
|
451
|
+
|
|
452
|
+
Args:
|
|
453
|
+
code: Source code content
|
|
454
|
+
file_path: Optional path to the file
|
|
455
|
+
|
|
456
|
+
Returns:
|
|
457
|
+
List of maintainability issues with details
|
|
458
|
+
"""
|
|
459
|
+
from .maintainability_scorer import MaintainabilityScorer
|
|
460
|
+
from ...core.language_detector import Language
|
|
461
|
+
|
|
462
|
+
scorer = MaintainabilityScorer()
|
|
463
|
+
return scorer.get_issues(code, Language.PYTHON, file_path=file_path, context=None)
|
|
464
|
+
|
|
465
|
+
def _calculate_test_coverage(self, file_path: Path) -> float:
|
|
466
|
+
"""
|
|
467
|
+
Calculate test coverage score (0-10 scale, higher is better).
|
|
468
|
+
|
|
469
|
+
Attempts to read coverage data from:
|
|
470
|
+
1. coverage.xml file in project root or .coverage file
|
|
471
|
+
2. Falls back to heuristic if no coverage data available
|
|
472
|
+
|
|
473
|
+
Args:
|
|
474
|
+
file_path: Path to the file being scored
|
|
475
|
+
|
|
476
|
+
Returns:
|
|
477
|
+
Coverage score (0-10 scale)
|
|
478
|
+
"""
|
|
479
|
+
if not self.has_coverage:
|
|
480
|
+
# No coverage tool available, use heuristic
|
|
481
|
+
return self._coverage_heuristic(file_path)
|
|
482
|
+
|
|
483
|
+
try:
|
|
484
|
+
# Try to find and parse coverage data
|
|
485
|
+
project_root = self._find_project_root(file_path)
|
|
486
|
+
if project_root is None:
|
|
487
|
+
return 5.0 # Neutral if can't find project root
|
|
488
|
+
|
|
489
|
+
# Look for coverage.xml first (pytest-cov output)
|
|
490
|
+
coverage_xml = project_root / "coverage.xml"
|
|
491
|
+
if coverage_xml.exists():
|
|
492
|
+
return self._parse_coverage_xml(coverage_xml, file_path)
|
|
493
|
+
|
|
494
|
+
# Look for .coverage database file
|
|
495
|
+
coverage_db = project_root / ".coverage"
|
|
496
|
+
if coverage_db.exists():
|
|
497
|
+
return self._parse_coverage_db(coverage_db, file_path)
|
|
498
|
+
|
|
499
|
+
# No coverage data found, use heuristic
|
|
500
|
+
return self._coverage_heuristic(file_path)
|
|
501
|
+
|
|
502
|
+
except Exception:
|
|
503
|
+
# Fallback to heuristic on any error
|
|
504
|
+
return self._coverage_heuristic(file_path)
|
|
505
|
+
|
|
506
|
+
def _parse_coverage_xml(self, coverage_xml: Path, file_path: Path) -> float:
|
|
507
|
+
"""Parse coverage.xml and return coverage percentage for file_path"""
|
|
508
|
+
try:
|
|
509
|
+
# coverage.xml is locally generated, but use defusedxml to reduce XML attack risk.
|
|
510
|
+
from defusedxml import ElementTree as ET
|
|
511
|
+
|
|
512
|
+
tree = ET.parse(coverage_xml)
|
|
513
|
+
root = tree.getroot()
|
|
514
|
+
|
|
515
|
+
# Get relative path from project root
|
|
516
|
+
project_root = coverage_xml.parent
|
|
517
|
+
try:
|
|
518
|
+
rel_path = file_path.relative_to(project_root)
|
|
519
|
+
file_path_str = str(rel_path).replace("\\", "/")
|
|
520
|
+
except ValueError:
|
|
521
|
+
# File not in project root
|
|
522
|
+
return 5.0
|
|
523
|
+
|
|
524
|
+
# Find coverage for this file
|
|
525
|
+
for package in root.findall(".//package"):
|
|
526
|
+
for class_elem in package.findall(".//class"):
|
|
527
|
+
file_name = class_elem.get("filename", "")
|
|
528
|
+
if file_name == file_path_str or file_path.name in file_name:
|
|
529
|
+
# Get line-rate (coverage percentage)
|
|
530
|
+
line_rate = float(class_elem.get("line-rate", "0.0"))
|
|
531
|
+
# Convert 0-1 scale to 0-10 scale
|
|
532
|
+
return line_rate * 10.0
|
|
533
|
+
|
|
534
|
+
# File not found in coverage report
|
|
535
|
+
return 0.0
|
|
536
|
+
except Exception:
|
|
537
|
+
return 5.0 # Default on error
|
|
538
|
+
|
|
539
|
+
def _parse_coverage_db(self, coverage_db: Path, file_path: Path) -> float:
|
|
540
|
+
"""Parse .coverage database and return coverage percentage"""
|
|
541
|
+
try:
|
|
542
|
+
cov = Coverage()
|
|
543
|
+
cov.load()
|
|
544
|
+
|
|
545
|
+
# Get coverage data
|
|
546
|
+
data = cov.get_data()
|
|
547
|
+
|
|
548
|
+
# Try to find file in coverage data
|
|
549
|
+
try:
|
|
550
|
+
rel_path = file_path.relative_to(coverage_db.parent)
|
|
551
|
+
file_path_str = str(rel_path).replace("\\", "/")
|
|
552
|
+
except ValueError:
|
|
553
|
+
return 5.0
|
|
554
|
+
|
|
555
|
+
# Get coverage for this file
|
|
556
|
+
if file_path_str in data.measured_files():
|
|
557
|
+
# Calculate coverage percentage
|
|
558
|
+
lines = data.lines(file_path_str)
|
|
559
|
+
if not lines:
|
|
560
|
+
return 0.0
|
|
561
|
+
|
|
562
|
+
# Count covered vs total lines (simplified)
|
|
563
|
+
# In practice, we'd need to check which lines are executable
|
|
564
|
+
# For now, return neutral score
|
|
565
|
+
return 5.0
|
|
566
|
+
|
|
567
|
+
return 0.0 # File not covered
|
|
568
|
+
except Exception:
|
|
569
|
+
return 5.0
|
|
570
|
+
|
|
571
|
+
def _coverage_heuristic(self, file_path: Path) -> float:
|
|
572
|
+
"""
|
|
573
|
+
Heuristic-based coverage estimate.
|
|
574
|
+
|
|
575
|
+
Phase 1 (P0): Fixed to return 0.0 when no test files exist.
|
|
576
|
+
|
|
577
|
+
Checks for:
|
|
578
|
+
- Test file existence (actual test files, not just directories)
|
|
579
|
+
- Test directory structure
|
|
580
|
+
- Test naming patterns
|
|
581
|
+
|
|
582
|
+
Returns:
|
|
583
|
+
- 0.0 if no test files exist (no tests written yet)
|
|
584
|
+
- 5.0 if test files exist but no coverage data (tests exist but not run)
|
|
585
|
+
- 10.0 if both test files and coverage data exist (not used here, handled by caller)
|
|
586
|
+
"""
|
|
587
|
+
project_root = self._find_project_root(file_path)
|
|
588
|
+
if project_root is None:
|
|
589
|
+
return 0.0 # No project root = assume no tests (Phase 1 fix)
|
|
590
|
+
|
|
591
|
+
# Look for test files with comprehensive patterns
|
|
592
|
+
test_dirs = ["tests", "test", "tests/unit", "tests/integration", "tests/test", "test/test"]
|
|
593
|
+
test_patterns = [
|
|
594
|
+
f"test_{file_path.stem}.py",
|
|
595
|
+
f"{file_path.stem}_test.py",
|
|
596
|
+
f"test_{file_path.name}",
|
|
597
|
+
# Also check for module-style test files
|
|
598
|
+
f"test_{file_path.stem.replace('_', '')}.py",
|
|
599
|
+
f"test_{file_path.stem.replace('-', '_')}.py",
|
|
600
|
+
]
|
|
601
|
+
|
|
602
|
+
# Also check if the file itself is a test file
|
|
603
|
+
if file_path.name.startswith("test_") or file_path.name.endswith("_test.py"):
|
|
604
|
+
# File is a test file, assume it has coverage if it exists
|
|
605
|
+
return 5.0 # Tests exist but no coverage data available
|
|
606
|
+
|
|
607
|
+
# Check if any test files actually exist
|
|
608
|
+
test_file_found = False
|
|
609
|
+
for test_dir in test_dirs:
|
|
610
|
+
test_dir_path = project_root / test_dir
|
|
611
|
+
if test_dir_path.exists() and test_dir_path.is_dir():
|
|
612
|
+
for pattern in test_patterns:
|
|
613
|
+
test_file = test_dir_path / pattern
|
|
614
|
+
if test_file.exists() and test_file.is_file():
|
|
615
|
+
test_file_found = True
|
|
616
|
+
break
|
|
617
|
+
if test_file_found:
|
|
618
|
+
break
|
|
619
|
+
|
|
620
|
+
# Phase 1 fix: Return 0.0 if no test files exist (no tests written yet)
|
|
621
|
+
if not test_file_found:
|
|
622
|
+
return 0.0
|
|
623
|
+
|
|
624
|
+
# Test files exist but not run yet
|
|
625
|
+
return 5.0
|
|
626
|
+
|
|
627
|
+
def _find_project_root(self, file_path: Path) -> Path | None:
|
|
628
|
+
"""Delegate to BaseScorer. Override for CodeScorer-specific markers if needed."""
|
|
629
|
+
return BaseScorer._find_project_root(file_path)
|
|
630
|
+
|
|
631
|
+
def get_performance_issues(
|
|
632
|
+
self, code: str, file_path: Path | None = None
|
|
633
|
+
) -> list[dict[str, Any]]:
|
|
634
|
+
"""
|
|
635
|
+
Get specific performance issues with line numbers (Phase 4 - P1).
|
|
636
|
+
|
|
637
|
+
Returns list of performance bottlenecks with details like nested loops, expensive operations, etc.
|
|
638
|
+
|
|
639
|
+
Args:
|
|
640
|
+
code: Source code content
|
|
641
|
+
file_path: Optional path to the file
|
|
642
|
+
|
|
643
|
+
Returns:
|
|
644
|
+
List of performance issues with line numbers
|
|
645
|
+
"""
|
|
646
|
+
from .issue_tracking import PerformanceIssue
|
|
647
|
+
import ast
|
|
648
|
+
|
|
649
|
+
issues: list[PerformanceIssue] = []
|
|
650
|
+
code_lines = code.splitlines()
|
|
651
|
+
|
|
652
|
+
try:
|
|
653
|
+
tree = ast.parse(code)
|
|
654
|
+
except SyntaxError:
|
|
655
|
+
return [PerformanceIssue(
|
|
656
|
+
issue_type="syntax_error",
|
|
657
|
+
message="File contains syntax errors - cannot analyze performance",
|
|
658
|
+
severity="high"
|
|
659
|
+
).__dict__]
|
|
660
|
+
|
|
661
|
+
# Analyze functions for performance issues
|
|
662
|
+
for node in ast.walk(tree):
|
|
663
|
+
if isinstance(node, ast.FunctionDef):
|
|
664
|
+
# Check for nested loops
|
|
665
|
+
for child in ast.walk(node):
|
|
666
|
+
if isinstance(child, ast.For):
|
|
667
|
+
# Check if this loop contains another loop
|
|
668
|
+
for nested_child in ast.walk(child):
|
|
669
|
+
if isinstance(nested_child, ast.For) and nested_child != child:
|
|
670
|
+
# Found nested loop
|
|
671
|
+
issues.append(PerformanceIssue(
|
|
672
|
+
issue_type="nested_loops",
|
|
673
|
+
message=f"Nested for loops detected in function '{node.name}' - potential O(n²) complexity",
|
|
674
|
+
line_number=child.lineno,
|
|
675
|
+
severity="high",
|
|
676
|
+
operation_type="loop",
|
|
677
|
+
context=f"Nested in function '{node.name}'",
|
|
678
|
+
suggestion="Consider using itertools.product() or list comprehensions to flatten nested loops"
|
|
679
|
+
))
|
|
680
|
+
|
|
681
|
+
# Check for expensive operations in loops
|
|
682
|
+
if isinstance(child, ast.For):
|
|
683
|
+
for loop_child in ast.walk(child):
|
|
684
|
+
if isinstance(loop_child, ast.Call):
|
|
685
|
+
# Check for expensive function calls in loops
|
|
686
|
+
if isinstance(loop_child.func, ast.Name):
|
|
687
|
+
func_name = loop_child.func.id
|
|
688
|
+
expensive_operations = ["time.fromisoformat", "datetime.fromisoformat", "re.compile", "json.loads"]
|
|
689
|
+
if any(exp_op in func_name for exp_op in expensive_operations):
|
|
690
|
+
issues.append(PerformanceIssue(
|
|
691
|
+
issue_type="expensive_operation_in_loop",
|
|
692
|
+
message=f"Expensive operation '{func_name}' called in loop at line {loop_child.lineno} - parse once before loop",
|
|
693
|
+
line_number=loop_child.lineno,
|
|
694
|
+
severity="medium",
|
|
695
|
+
operation_type=func_name,
|
|
696
|
+
context=f"In loop at line {child.lineno}",
|
|
697
|
+
suggestion=f"Move '{func_name}' call outside the loop and cache the result"
|
|
698
|
+
))
|
|
699
|
+
|
|
700
|
+
# Check for list comprehensions with function calls
|
|
701
|
+
if isinstance(node, ast.ListComp):
|
|
702
|
+
func_calls_in_comp = sum(1 for n in ast.walk(node) if isinstance(n, ast.Call))
|
|
703
|
+
if func_calls_in_comp > 5:
|
|
704
|
+
issues.append(PerformanceIssue(
|
|
705
|
+
issue_type="expensive_comprehension",
|
|
706
|
+
message=f"List comprehension at line {node.lineno} contains {func_calls_in_comp} function calls - consider using generator or loop",
|
|
707
|
+
line_number=node.lineno,
|
|
708
|
+
severity="medium",
|
|
709
|
+
operation_type="comprehension",
|
|
710
|
+
suggestion="Consider using a generator expression or a loop for better performance"
|
|
711
|
+
))
|
|
712
|
+
|
|
713
|
+
# Convert to dict format
|
|
714
|
+
return [
|
|
715
|
+
{
|
|
716
|
+
"issue_type": issue.issue_type,
|
|
717
|
+
"message": issue.message,
|
|
718
|
+
"line_number": issue.line_number,
|
|
719
|
+
"severity": issue.severity,
|
|
720
|
+
"suggestion": issue.suggestion,
|
|
721
|
+
"operation_type": issue.operation_type,
|
|
722
|
+
"context": issue.context,
|
|
723
|
+
}
|
|
724
|
+
for issue in issues
|
|
725
|
+
]
|
|
726
|
+
|
|
727
|
+
def _calculate_performance(self, code: str) -> float:
|
|
728
|
+
"""
|
|
729
|
+
Calculate performance score using static analysis (0-10 scale, higher is better).
|
|
730
|
+
|
|
731
|
+
Checks for:
|
|
732
|
+
- Function size (number of lines)
|
|
733
|
+
- Nesting depth
|
|
734
|
+
- Inefficient patterns (N+1 queries, nested loops, etc.)
|
|
735
|
+
- Large list/dict comprehensions
|
|
736
|
+
"""
|
|
737
|
+
try:
|
|
738
|
+
tree = ast.parse(code)
|
|
739
|
+
issues = []
|
|
740
|
+
|
|
741
|
+
# Analyze functions
|
|
742
|
+
for node in ast.walk(tree):
|
|
743
|
+
if isinstance(node, ast.FunctionDef):
|
|
744
|
+
# Check function size
|
|
745
|
+
# Use end_lineno if available (Python 3.8+), otherwise estimate
|
|
746
|
+
if hasattr(node, "end_lineno") and node.end_lineno is not None:
|
|
747
|
+
func_lines = node.end_lineno - node.lineno
|
|
748
|
+
else:
|
|
749
|
+
# Estimate: count lines in function body
|
|
750
|
+
func_lines = (
|
|
751
|
+
len(code.split("\n")[node.lineno - 1 : node.lineno + 49])
|
|
752
|
+
if len(code.split("\n")) > node.lineno
|
|
753
|
+
else 50
|
|
754
|
+
)
|
|
755
|
+
|
|
756
|
+
if func_lines > 50:
|
|
757
|
+
issues.append("large_function") # > 50 lines
|
|
758
|
+
if func_lines > 100:
|
|
759
|
+
issues.append("very_large_function") # > 100 lines
|
|
760
|
+
|
|
761
|
+
# Check nesting depth
|
|
762
|
+
max_depth = self._get_max_nesting_depth(node)
|
|
763
|
+
if max_depth > 4:
|
|
764
|
+
issues.append("deep_nesting") # > 4 levels
|
|
765
|
+
if max_depth > 6:
|
|
766
|
+
issues.append("very_deep_nesting") # > 6 levels
|
|
767
|
+
|
|
768
|
+
# Check for nested loops (potential N^2 complexity)
|
|
769
|
+
if isinstance(node, ast.For):
|
|
770
|
+
for child in ast.walk(node):
|
|
771
|
+
if isinstance(child, ast.For) and child != node:
|
|
772
|
+
issues.append("nested_loops")
|
|
773
|
+
break
|
|
774
|
+
|
|
775
|
+
# Check for list comprehensions with function calls
|
|
776
|
+
if isinstance(node, ast.ListComp):
|
|
777
|
+
# Count function calls in comprehension
|
|
778
|
+
func_calls = sum(
|
|
779
|
+
1 for n in ast.walk(node) if isinstance(n, ast.Call)
|
|
780
|
+
)
|
|
781
|
+
if func_calls > 5:
|
|
782
|
+
issues.append("expensive_comprehension")
|
|
783
|
+
|
|
784
|
+
# Calculate score based on issues
|
|
785
|
+
# Start with 10, deduct points for issues
|
|
786
|
+
score = 10.0
|
|
787
|
+
penalty_map = {
|
|
788
|
+
"large_function": 0.5,
|
|
789
|
+
"very_large_function": 1.5,
|
|
790
|
+
"deep_nesting": 1.0,
|
|
791
|
+
"very_deep_nesting": 2.0,
|
|
792
|
+
"nested_loops": 1.5,
|
|
793
|
+
"expensive_comprehension": 0.5,
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
seen_issues = set()
|
|
797
|
+
for issue in issues:
|
|
798
|
+
if issue not in seen_issues:
|
|
799
|
+
score -= penalty_map.get(issue, 0.5)
|
|
800
|
+
seen_issues.add(issue)
|
|
801
|
+
|
|
802
|
+
return max(0.0, min(10.0, score))
|
|
803
|
+
|
|
804
|
+
except SyntaxError:
|
|
805
|
+
return 0.0 # Syntax errors = worst performance score
|
|
806
|
+
except Exception:
|
|
807
|
+
return 5.0 # Default on error
|
|
808
|
+
|
|
809
|
+
def _get_max_nesting_depth(self, node: ast.AST, current_depth: int = 0) -> int:
|
|
810
|
+
"""Calculate maximum nesting depth in an AST node"""
|
|
811
|
+
max_depth = current_depth
|
|
812
|
+
|
|
813
|
+
for child in ast.iter_child_nodes(node):
|
|
814
|
+
# Count nesting for control structures
|
|
815
|
+
if isinstance(child, (ast.If, ast.For, ast.While, ast.Try, ast.With)):
|
|
816
|
+
child_depth = self._get_max_nesting_depth(child, current_depth + 1)
|
|
817
|
+
max_depth = max(max_depth, child_depth)
|
|
818
|
+
else:
|
|
819
|
+
child_depth = self._get_max_nesting_depth(child, current_depth)
|
|
820
|
+
max_depth = max(max_depth, child_depth)
|
|
821
|
+
|
|
822
|
+
return max_depth
|
|
823
|
+
|
|
824
|
+
def _calculate_linting_score(self, file_path: Path) -> float:
|
|
825
|
+
"""
|
|
826
|
+
Calculate linting score using Ruff (0-10 scale, higher is better).
|
|
827
|
+
|
|
828
|
+
Phase 6: Modern Quality Analysis - Ruff Integration
|
|
829
|
+
|
|
830
|
+
Returns:
|
|
831
|
+
Linting score (0-10), where 10 = no issues, 0 = many issues
|
|
832
|
+
"""
|
|
833
|
+
if not self.has_ruff:
|
|
834
|
+
return 5.0 # Neutral score if Ruff not available
|
|
835
|
+
|
|
836
|
+
# Only check Python files
|
|
837
|
+
if file_path.suffix != ".py":
|
|
838
|
+
return 10.0 # Perfect score for non-Python files (can't lint)
|
|
839
|
+
|
|
840
|
+
try:
|
|
841
|
+
# Run ruff check with JSON output
|
|
842
|
+
result = subprocess.run( # nosec B603
|
|
843
|
+
[
|
|
844
|
+
sys.executable,
|
|
845
|
+
"-m",
|
|
846
|
+
"ruff",
|
|
847
|
+
"check",
|
|
848
|
+
"--output-format=json",
|
|
849
|
+
str(file_path),
|
|
850
|
+
],
|
|
851
|
+
capture_output=True,
|
|
852
|
+
text=True,
|
|
853
|
+
encoding="utf-8",
|
|
854
|
+
errors="replace",
|
|
855
|
+
timeout=30, # 30 second timeout
|
|
856
|
+
cwd=file_path.parent if file_path.parent.exists() else None,
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
# Parse JSON output
|
|
860
|
+
if result.returncode == 0 and not result.stdout.strip():
|
|
861
|
+
# No issues found
|
|
862
|
+
return 10.0
|
|
863
|
+
|
|
864
|
+
try:
|
|
865
|
+
# Ruff JSON format: list of diagnostic objects
|
|
866
|
+
diagnostics = (
|
|
867
|
+
json_lib.loads(result.stdout) if result.stdout.strip() else []
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
if not diagnostics:
|
|
871
|
+
return 10.0
|
|
872
|
+
|
|
873
|
+
# Count issues by severity
|
|
874
|
+
# Ruff severity levels: E (Error), W (Warning), F (Fatal), I (Info)
|
|
875
|
+
error_count = sum(
|
|
876
|
+
1
|
|
877
|
+
for d in diagnostics
|
|
878
|
+
if d.get("code", {}).get("name", "").startswith("E")
|
|
879
|
+
)
|
|
880
|
+
warning_count = sum(
|
|
881
|
+
1
|
|
882
|
+
for d in diagnostics
|
|
883
|
+
if d.get("code", {}).get("name", "").startswith("W")
|
|
884
|
+
)
|
|
885
|
+
fatal_count = sum(
|
|
886
|
+
1
|
|
887
|
+
for d in diagnostics
|
|
888
|
+
if d.get("code", {}).get("name", "").startswith("F")
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
# Calculate score: Start at 10, deduct points
|
|
892
|
+
# Errors (E): -2 points each
|
|
893
|
+
# Fatal (F): -3 points each
|
|
894
|
+
# Warnings (W): -0.5 points each
|
|
895
|
+
score = 10.0
|
|
896
|
+
score -= error_count * 2.0
|
|
897
|
+
score -= fatal_count * 3.0
|
|
898
|
+
score -= warning_count * 0.5
|
|
899
|
+
|
|
900
|
+
return max(0.0, min(10.0, score))
|
|
901
|
+
|
|
902
|
+
except json_lib.JSONDecodeError:
|
|
903
|
+
# If JSON parsing fails, check stderr for errors
|
|
904
|
+
if result.stderr:
|
|
905
|
+
return 5.0 # Neutral on parsing error
|
|
906
|
+
return 10.0 # No output = no issues
|
|
907
|
+
|
|
908
|
+
except subprocess.TimeoutExpired:
|
|
909
|
+
return 5.0 # Neutral on timeout
|
|
910
|
+
except FileNotFoundError:
|
|
911
|
+
# Ruff not found in PATH
|
|
912
|
+
return 5.0
|
|
913
|
+
except Exception:
|
|
914
|
+
# Any other error
|
|
915
|
+
return 5.0
|
|
916
|
+
|
|
917
|
+
def get_ruff_issues(self, file_path: Path) -> list[dict[str, Any]]:
|
|
918
|
+
"""
|
|
919
|
+
Get detailed Ruff linting issues for a file.
|
|
920
|
+
|
|
921
|
+
Phase 6: Modern Quality Analysis - Ruff Integration
|
|
922
|
+
|
|
923
|
+
Returns:
|
|
924
|
+
List of diagnostic dictionaries with code, message, location, etc.
|
|
925
|
+
"""
|
|
926
|
+
if not self.has_ruff or file_path.suffix != ".py":
|
|
927
|
+
return []
|
|
928
|
+
|
|
929
|
+
try:
|
|
930
|
+
result = subprocess.run( # nosec B603
|
|
931
|
+
[
|
|
932
|
+
sys.executable,
|
|
933
|
+
"-m",
|
|
934
|
+
"ruff",
|
|
935
|
+
"check",
|
|
936
|
+
"--output-format=json",
|
|
937
|
+
str(file_path),
|
|
938
|
+
],
|
|
939
|
+
capture_output=True,
|
|
940
|
+
text=True,
|
|
941
|
+
encoding="utf-8",
|
|
942
|
+
errors="replace",
|
|
943
|
+
timeout=30,
|
|
944
|
+
cwd=file_path.parent if file_path.parent.exists() else None,
|
|
945
|
+
)
|
|
946
|
+
|
|
947
|
+
if result.returncode == 0 and not result.stdout.strip():
|
|
948
|
+
return []
|
|
949
|
+
|
|
950
|
+
try:
|
|
951
|
+
diagnostics = (
|
|
952
|
+
json_lib.loads(result.stdout) if result.stdout.strip() else []
|
|
953
|
+
)
|
|
954
|
+
return diagnostics
|
|
955
|
+
except json_lib.JSONDecodeError:
|
|
956
|
+
return []
|
|
957
|
+
|
|
958
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, Exception):
|
|
959
|
+
return []
|
|
960
|
+
|
|
961
|
+
def _calculate_type_checking_score(self, file_path: Path) -> float:
|
|
962
|
+
"""
|
|
963
|
+
Calculate type checking score using mypy (0-10 scale, higher is better).
|
|
964
|
+
|
|
965
|
+
Phase 6.2: Modern Quality Analysis - mypy Integration
|
|
966
|
+
Phase 5 (P1): Fixed to actually run mypy and return real scores (not static 5.0).
|
|
967
|
+
ENH-002-S2: Prefer ScopedMypyExecutor (--follow-imports=skip, <10s) with fallback to full mypy.
|
|
968
|
+
"""
|
|
969
|
+
if not self.has_mypy:
|
|
970
|
+
logger.debug("mypy not available - returning neutral score")
|
|
971
|
+
return 5.0 # Neutral score if mypy not available
|
|
972
|
+
|
|
973
|
+
# Only check Python files
|
|
974
|
+
if file_path.suffix != ".py":
|
|
975
|
+
return 10.0 # Perfect score for non-Python files (can't type check)
|
|
976
|
+
|
|
977
|
+
# ENH-002-S2: Try scoped mypy first (faster)
|
|
978
|
+
try:
|
|
979
|
+
from .tools.scoped_mypy import ScopedMypyExecutor
|
|
980
|
+
executor = ScopedMypyExecutor()
|
|
981
|
+
result = executor.run_scoped_sync(file_path, timeout=10)
|
|
982
|
+
if result.files_checked == 1 or result.issues:
|
|
983
|
+
error_count = len(result.issues)
|
|
984
|
+
if error_count == 0:
|
|
985
|
+
return 10.0
|
|
986
|
+
score = 10.0 - (error_count * 0.5)
|
|
987
|
+
logger.debug(
|
|
988
|
+
"mypy (scoped) found %s errors for %s, score: %s/10",
|
|
989
|
+
error_count, file_path, score,
|
|
990
|
+
)
|
|
991
|
+
return max(0.0, min(10.0, score))
|
|
992
|
+
except Exception as e:
|
|
993
|
+
logger.debug("scoped mypy not used, falling back to full mypy: %s", e)
|
|
994
|
+
|
|
995
|
+
try:
|
|
996
|
+
result = subprocess.run( # nosec B603
|
|
997
|
+
[
|
|
998
|
+
sys.executable,
|
|
999
|
+
"-m",
|
|
1000
|
+
"mypy",
|
|
1001
|
+
"--show-error-codes",
|
|
1002
|
+
"--no-error-summary",
|
|
1003
|
+
"--no-color-output",
|
|
1004
|
+
"--no-incremental",
|
|
1005
|
+
str(file_path),
|
|
1006
|
+
],
|
|
1007
|
+
capture_output=True,
|
|
1008
|
+
text=True,
|
|
1009
|
+
encoding="utf-8",
|
|
1010
|
+
errors="replace",
|
|
1011
|
+
timeout=30,
|
|
1012
|
+
cwd=file_path.parent if file_path.parent.exists() else None,
|
|
1013
|
+
)
|
|
1014
|
+
if result.returncode == 0:
|
|
1015
|
+
logger.debug("mypy found no errors for %s", file_path)
|
|
1016
|
+
return 10.0
|
|
1017
|
+
output = result.stdout.strip()
|
|
1018
|
+
if not output:
|
|
1019
|
+
logger.debug("mypy returned non-zero but no output for %s", file_path)
|
|
1020
|
+
return 10.0
|
|
1021
|
+
error_lines = [
|
|
1022
|
+
line
|
|
1023
|
+
for line in output.split("\n")
|
|
1024
|
+
if "error:" in line.lower() and file_path.name in line
|
|
1025
|
+
]
|
|
1026
|
+
error_count = len(error_lines)
|
|
1027
|
+
if error_count == 0:
|
|
1028
|
+
logger.debug("mypy returned non-zero but no parseable errors for %s", file_path)
|
|
1029
|
+
return 10.0
|
|
1030
|
+
score = 10.0 - (error_count * 0.5)
|
|
1031
|
+
logger.debug("mypy found %s errors for %s, score: %s/10", error_count, file_path, score)
|
|
1032
|
+
return max(0.0, min(10.0, score))
|
|
1033
|
+
except subprocess.TimeoutExpired:
|
|
1034
|
+
logger.warning("mypy timed out for %s", file_path)
|
|
1035
|
+
return 5.0
|
|
1036
|
+
except FileNotFoundError:
|
|
1037
|
+
logger.debug("mypy not found in PATH for %s", file_path)
|
|
1038
|
+
self.has_mypy = False
|
|
1039
|
+
return 5.0
|
|
1040
|
+
except Exception as e:
|
|
1041
|
+
logger.warning("mypy failed for %s: %s", file_path, e, exc_info=True)
|
|
1042
|
+
return 5.0
|
|
1043
|
+
|
|
1044
|
+
def get_mypy_errors(self, file_path: Path) -> list[dict[str, Any]]:
|
|
1045
|
+
"""
|
|
1046
|
+
Get detailed mypy type checking errors for a file.
|
|
1047
|
+
|
|
1048
|
+
Phase 6.2: Modern Quality Analysis - mypy Integration
|
|
1049
|
+
ENH-002-S2: Prefer ScopedMypyExecutor with fallback to full mypy.
|
|
1050
|
+
"""
|
|
1051
|
+
if not self.has_mypy or file_path.suffix != ".py":
|
|
1052
|
+
return []
|
|
1053
|
+
|
|
1054
|
+
try:
|
|
1055
|
+
from .tools.scoped_mypy import ScopedMypyExecutor
|
|
1056
|
+
executor = ScopedMypyExecutor()
|
|
1057
|
+
result = executor.run_scoped_sync(file_path, timeout=10)
|
|
1058
|
+
if result.files_checked == 1 or result.issues:
|
|
1059
|
+
return [
|
|
1060
|
+
{
|
|
1061
|
+
"filename": str(i.file_path),
|
|
1062
|
+
"line": i.line,
|
|
1063
|
+
"message": i.message,
|
|
1064
|
+
"error_code": i.error_code,
|
|
1065
|
+
"severity": i.severity,
|
|
1066
|
+
}
|
|
1067
|
+
for i in result.issues
|
|
1068
|
+
]
|
|
1069
|
+
except Exception:
|
|
1070
|
+
pass
|
|
1071
|
+
|
|
1072
|
+
try:
|
|
1073
|
+
result = subprocess.run( # nosec B603
|
|
1074
|
+
[
|
|
1075
|
+
sys.executable,
|
|
1076
|
+
"-m",
|
|
1077
|
+
"mypy",
|
|
1078
|
+
"--show-error-codes",
|
|
1079
|
+
"--no-error-summary",
|
|
1080
|
+
"--no-incremental",
|
|
1081
|
+
str(file_path),
|
|
1082
|
+
],
|
|
1083
|
+
capture_output=True,
|
|
1084
|
+
text=True,
|
|
1085
|
+
encoding="utf-8",
|
|
1086
|
+
errors="replace",
|
|
1087
|
+
timeout=30,
|
|
1088
|
+
cwd=file_path.parent if file_path.parent.exists() else None,
|
|
1089
|
+
)
|
|
1090
|
+
if result.returncode == 0 or not result.stdout.strip():
|
|
1091
|
+
return []
|
|
1092
|
+
errors = []
|
|
1093
|
+
for line in result.stdout.strip().split("\n"):
|
|
1094
|
+
if "error:" not in line.lower():
|
|
1095
|
+
continue
|
|
1096
|
+
parts = line.split(":", 3)
|
|
1097
|
+
if len(parts) >= 4:
|
|
1098
|
+
filename = parts[0]
|
|
1099
|
+
try:
|
|
1100
|
+
line_num = int(parts[1])
|
|
1101
|
+
except ValueError:
|
|
1102
|
+
continue
|
|
1103
|
+
error_msg = parts[3].strip()
|
|
1104
|
+
error_code = None
|
|
1105
|
+
if "[" in error_msg and "]" in error_msg:
|
|
1106
|
+
start = error_msg.rfind("[")
|
|
1107
|
+
end = error_msg.rfind("]")
|
|
1108
|
+
if start < end:
|
|
1109
|
+
error_code = error_msg[start + 1 : end]
|
|
1110
|
+
error_msg = error_msg[:start].strip()
|
|
1111
|
+
errors.append({
|
|
1112
|
+
"filename": filename,
|
|
1113
|
+
"line": line_num,
|
|
1114
|
+
"message": error_msg,
|
|
1115
|
+
"error_code": error_code,
|
|
1116
|
+
"severity": "error",
|
|
1117
|
+
})
|
|
1118
|
+
return errors
|
|
1119
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, Exception):
|
|
1120
|
+
return []
|
|
1121
|
+
|
|
1122
|
+
def _format_ruff_issues(self, diagnostics: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
1123
|
+
"""
|
|
1124
|
+
Format raw ruff diagnostics into a cleaner structure for output.
|
|
1125
|
+
|
|
1126
|
+
P1 Improvement: Include actual lint errors in score output.
|
|
1127
|
+
|
|
1128
|
+
Args:
|
|
1129
|
+
diagnostics: Raw ruff JSON diagnostics
|
|
1130
|
+
|
|
1131
|
+
Returns:
|
|
1132
|
+
List of formatted issues with line, code, message, severity
|
|
1133
|
+
"""
|
|
1134
|
+
formatted = []
|
|
1135
|
+
for diag in diagnostics:
|
|
1136
|
+
# Extract code info (ruff format: {"code": {"name": "F401", ...}})
|
|
1137
|
+
code_info = diag.get("code", {})
|
|
1138
|
+
if isinstance(code_info, dict):
|
|
1139
|
+
code = code_info.get("name", "")
|
|
1140
|
+
else:
|
|
1141
|
+
code = str(code_info)
|
|
1142
|
+
|
|
1143
|
+
# Determine severity from code prefix
|
|
1144
|
+
severity = "warning"
|
|
1145
|
+
if code.startswith("E") or code.startswith("F"):
|
|
1146
|
+
severity = "error"
|
|
1147
|
+
elif code.startswith("W"):
|
|
1148
|
+
severity = "warning"
|
|
1149
|
+
elif code.startswith("I"):
|
|
1150
|
+
severity = "info"
|
|
1151
|
+
|
|
1152
|
+
# Get location info
|
|
1153
|
+
location = diag.get("location", {})
|
|
1154
|
+
line = location.get("row", 0) if isinstance(location, dict) else 0
|
|
1155
|
+
column = location.get("column", 0) if isinstance(location, dict) else 0
|
|
1156
|
+
|
|
1157
|
+
formatted.append({
|
|
1158
|
+
"code": code,
|
|
1159
|
+
"message": diag.get("message", ""),
|
|
1160
|
+
"line": line,
|
|
1161
|
+
"column": column,
|
|
1162
|
+
"severity": severity,
|
|
1163
|
+
})
|
|
1164
|
+
|
|
1165
|
+
# Sort by line number
|
|
1166
|
+
formatted.sort(key=lambda x: (x.get("line", 0), x.get("column", 0)))
|
|
1167
|
+
|
|
1168
|
+
return formatted
|
|
1169
|
+
|
|
1170
|
+
def _group_ruff_issues_by_code(self, issues: list[dict[str, Any]]) -> dict[str, Any]:
|
|
1171
|
+
"""
|
|
1172
|
+
Group ruff issues by rule code for cleaner, more actionable reports.
|
|
1173
|
+
|
|
1174
|
+
ENH-002 Story #18: Ruff Output Grouping
|
|
1175
|
+
|
|
1176
|
+
Args:
|
|
1177
|
+
issues: List of ruff diagnostic dictionaries
|
|
1178
|
+
|
|
1179
|
+
Returns:
|
|
1180
|
+
Dictionary with grouped issues:
|
|
1181
|
+
{
|
|
1182
|
+
"total_count": int,
|
|
1183
|
+
"groups": [
|
|
1184
|
+
{
|
|
1185
|
+
"code": "UP006",
|
|
1186
|
+
"count": 17,
|
|
1187
|
+
"description": "Use dict/list instead of Dict/List",
|
|
1188
|
+
"severity": "info",
|
|
1189
|
+
"issues": [...]
|
|
1190
|
+
},
|
|
1191
|
+
...
|
|
1192
|
+
],
|
|
1193
|
+
"summary": "UP006 (17), UP045 (10), UP007 (2), F401 (1)"
|
|
1194
|
+
}
|
|
1195
|
+
"""
|
|
1196
|
+
if not issues:
|
|
1197
|
+
return {
|
|
1198
|
+
"total_count": 0,
|
|
1199
|
+
"groups": [],
|
|
1200
|
+
"summary": "No issues found"
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
# Group issues by code
|
|
1204
|
+
groups_dict: dict[str, list[dict[str, Any]]] = {}
|
|
1205
|
+
for issue in issues:
|
|
1206
|
+
code_info = issue.get("code", {})
|
|
1207
|
+
if isinstance(code_info, dict):
|
|
1208
|
+
code = code_info.get("name", "UNKNOWN")
|
|
1209
|
+
else:
|
|
1210
|
+
code = str(code_info) if code_info else "UNKNOWN"
|
|
1211
|
+
|
|
1212
|
+
if code not in groups_dict:
|
|
1213
|
+
groups_dict[code] = []
|
|
1214
|
+
groups_dict[code].append(issue)
|
|
1215
|
+
|
|
1216
|
+
# Create grouped structure with metadata
|
|
1217
|
+
groups = []
|
|
1218
|
+
for code, code_issues in groups_dict.items():
|
|
1219
|
+
# Get first message as description (they're usually the same for same code)
|
|
1220
|
+
description = code_issues[0].get("message", "") if code_issues else ""
|
|
1221
|
+
|
|
1222
|
+
# Determine severity from code
|
|
1223
|
+
severity = "info"
|
|
1224
|
+
if code.startswith("E") or code.startswith("F"):
|
|
1225
|
+
severity = "error"
|
|
1226
|
+
elif code.startswith("W"):
|
|
1227
|
+
severity = "warning"
|
|
1228
|
+
|
|
1229
|
+
groups.append({
|
|
1230
|
+
"code": code,
|
|
1231
|
+
"count": len(code_issues),
|
|
1232
|
+
"description": description,
|
|
1233
|
+
"severity": severity,
|
|
1234
|
+
"issues": code_issues
|
|
1235
|
+
})
|
|
1236
|
+
|
|
1237
|
+
# Sort by count (descending) then by code
|
|
1238
|
+
groups.sort(key=lambda x: (-x["count"], x["code"]))
|
|
1239
|
+
|
|
1240
|
+
# Create summary string: "UP006 (17), UP045 (10), ..."
|
|
1241
|
+
summary_parts = [f"{g['code']} ({g['count']})" for g in groups]
|
|
1242
|
+
summary = ", ".join(summary_parts)
|
|
1243
|
+
|
|
1244
|
+
return {
|
|
1245
|
+
"total_count": len(issues),
|
|
1246
|
+
"groups": groups,
|
|
1247
|
+
"summary": summary
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
def _calculate_duplication_score(self, file_path: Path) -> float:
|
|
1251
|
+
"""
|
|
1252
|
+
Calculate duplication score using jscpd (0-10 scale, higher is better).
|
|
1253
|
+
|
|
1254
|
+
Phase 6.4: Modern Quality Analysis - jscpd Integration
|
|
1255
|
+
|
|
1256
|
+
Note: jscpd works on directories/files, so we analyze the parent directory
|
|
1257
|
+
or file directly. For single file, we analyze just that file.
|
|
1258
|
+
|
|
1259
|
+
Returns:
|
|
1260
|
+
Duplication score (0-10), where 10 = no duplication, 0 = high duplication
|
|
1261
|
+
Score formula: 10 - (duplication_pct / 10)
|
|
1262
|
+
"""
|
|
1263
|
+
if not self.has_jscpd:
|
|
1264
|
+
return 5.0 # Neutral score if jscpd not available
|
|
1265
|
+
|
|
1266
|
+
# jscpd works best on directories or multiple files
|
|
1267
|
+
# For single file analysis, we'll analyze the file directly
|
|
1268
|
+
try:
|
|
1269
|
+
# Determine target (file or directory)
|
|
1270
|
+
target = str(file_path)
|
|
1271
|
+
if file_path.is_dir():
|
|
1272
|
+
target_dir = str(file_path)
|
|
1273
|
+
else:
|
|
1274
|
+
target_dir = str(file_path.parent)
|
|
1275
|
+
|
|
1276
|
+
# Build jscpd command
|
|
1277
|
+
# Use npx if jscpd not directly available
|
|
1278
|
+
jscpd_path = shutil.which("jscpd")
|
|
1279
|
+
if jscpd_path:
|
|
1280
|
+
cmd = [jscpd_path]
|
|
1281
|
+
else:
|
|
1282
|
+
npx_path = shutil.which("npx")
|
|
1283
|
+
if not npx_path:
|
|
1284
|
+
return 5.0 # jscpd not available
|
|
1285
|
+
cmd = [npx_path, "--yes", "jscpd"]
|
|
1286
|
+
|
|
1287
|
+
# Add jscpd arguments
|
|
1288
|
+
cmd.extend(
|
|
1289
|
+
[
|
|
1290
|
+
target,
|
|
1291
|
+
"--format",
|
|
1292
|
+
"json",
|
|
1293
|
+
"--min-lines",
|
|
1294
|
+
str(self.min_duplication_lines),
|
|
1295
|
+
"--reporters",
|
|
1296
|
+
"json",
|
|
1297
|
+
"--output",
|
|
1298
|
+
".", # Output to current directory
|
|
1299
|
+
]
|
|
1300
|
+
)
|
|
1301
|
+
|
|
1302
|
+
# Run jscpd
|
|
1303
|
+
result = subprocess.run( # nosec B603 - fixed args
|
|
1304
|
+
wrap_windows_cmd_shim(cmd),
|
|
1305
|
+
capture_output=True,
|
|
1306
|
+
text=True,
|
|
1307
|
+
encoding="utf-8",
|
|
1308
|
+
errors="replace",
|
|
1309
|
+
timeout=120, # 2 minute timeout (jscpd can be slow on large codebases)
|
|
1310
|
+
cwd=target_dir if Path(target_dir).exists() else None,
|
|
1311
|
+
)
|
|
1312
|
+
|
|
1313
|
+
# jscpd outputs JSON to stdout when using --reporters json
|
|
1314
|
+
# But it might also create a file, so check both
|
|
1315
|
+
json_output = result.stdout.strip()
|
|
1316
|
+
|
|
1317
|
+
# Try to parse JSON from stdout
|
|
1318
|
+
try:
|
|
1319
|
+
if json_output:
|
|
1320
|
+
report_data = json_lib.loads(json_output)
|
|
1321
|
+
else:
|
|
1322
|
+
# Check for jscpd-report.json in output directory
|
|
1323
|
+
output_file = Path(target_dir) / "jscpd-report.json"
|
|
1324
|
+
if output_file.exists():
|
|
1325
|
+
with open(output_file, encoding="utf-8") as f:
|
|
1326
|
+
report_data = json_lib.load(f)
|
|
1327
|
+
else:
|
|
1328
|
+
# No duplication found (exit code 0 typically means no issues or success)
|
|
1329
|
+
if result.returncode == 0:
|
|
1330
|
+
return 10.0 # Perfect score (no duplication)
|
|
1331
|
+
return 5.0 # Neutral on parse failure
|
|
1332
|
+
except json_lib.JSONDecodeError:
|
|
1333
|
+
# JSON parse error - might be text output
|
|
1334
|
+
# Try to extract duplication percentage from text output
|
|
1335
|
+
# Format: "Found X% duplicated lines out of Y total lines"
|
|
1336
|
+
lines = result.stdout.split("\n") + result.stderr.split("\n")
|
|
1337
|
+
for line in lines:
|
|
1338
|
+
if "%" in line and "duplicate" in line.lower():
|
|
1339
|
+
# Try to extract percentage
|
|
1340
|
+
try:
|
|
1341
|
+
pct_str = line.split("%")[0].split()[-1]
|
|
1342
|
+
duplication_pct = float(pct_str)
|
|
1343
|
+
score = 10.0 - (duplication_pct / 10.0)
|
|
1344
|
+
return max(0.0, min(10.0, score))
|
|
1345
|
+
except (ValueError, IndexError):
|
|
1346
|
+
pass
|
|
1347
|
+
|
|
1348
|
+
# If we can't parse, default behavior
|
|
1349
|
+
if result.returncode == 0:
|
|
1350
|
+
return 10.0 # No duplication found
|
|
1351
|
+
return 5.0 # Neutral on parse failure
|
|
1352
|
+
|
|
1353
|
+
# Extract duplication percentage from JSON report
|
|
1354
|
+
# jscpd JSON structure: { "percentage": X.X, ... }
|
|
1355
|
+
duplication_pct = report_data.get("percentage", 0.0)
|
|
1356
|
+
|
|
1357
|
+
# Calculate score: 10 - (duplication_pct / 10)
|
|
1358
|
+
# This means:
|
|
1359
|
+
# - 0% duplication = 10.0 score
|
|
1360
|
+
# - 3% duplication (threshold) = 9.7 score
|
|
1361
|
+
# - 10% duplication = 9.0 score
|
|
1362
|
+
# - 30% duplication = 7.0 score
|
|
1363
|
+
# - 100% duplication = 0.0 score
|
|
1364
|
+
score = 10.0 - (duplication_pct / 10.0)
|
|
1365
|
+
return max(0.0, min(10.0, score))
|
|
1366
|
+
|
|
1367
|
+
except subprocess.TimeoutExpired:
|
|
1368
|
+
return 5.0 # Neutral on timeout
|
|
1369
|
+
except FileNotFoundError:
|
|
1370
|
+
# jscpd not found
|
|
1371
|
+
return 5.0
|
|
1372
|
+
except Exception:
|
|
1373
|
+
# Any other error
|
|
1374
|
+
return 5.0
|
|
1375
|
+
|
|
1376
|
+
def get_duplication_report(self, file_path: Path) -> dict[str, Any]:
|
|
1377
|
+
"""
|
|
1378
|
+
Get detailed duplication report from jscpd.
|
|
1379
|
+
|
|
1380
|
+
Phase 6.4: Modern Quality Analysis - jscpd Integration
|
|
1381
|
+
|
|
1382
|
+
Returns:
|
|
1383
|
+
Dictionary with duplication report data including:
|
|
1384
|
+
- percentage: Duplication percentage
|
|
1385
|
+
- duplicates: List of duplicate code blocks
|
|
1386
|
+
- files: File-level duplication stats
|
|
1387
|
+
"""
|
|
1388
|
+
if not self.has_jscpd:
|
|
1389
|
+
return {
|
|
1390
|
+
"available": False,
|
|
1391
|
+
"percentage": 0.0,
|
|
1392
|
+
"duplicates": [],
|
|
1393
|
+
"files": [],
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
try:
|
|
1397
|
+
# Determine target
|
|
1398
|
+
if file_path.is_dir():
|
|
1399
|
+
target_dir = str(file_path)
|
|
1400
|
+
target = str(file_path)
|
|
1401
|
+
else:
|
|
1402
|
+
target_dir = str(file_path.parent)
|
|
1403
|
+
target = str(file_path)
|
|
1404
|
+
|
|
1405
|
+
# Build jscpd command
|
|
1406
|
+
jscpd_path = shutil.which("jscpd")
|
|
1407
|
+
if jscpd_path:
|
|
1408
|
+
cmd = [jscpd_path]
|
|
1409
|
+
else:
|
|
1410
|
+
npx_path = shutil.which("npx")
|
|
1411
|
+
if not npx_path:
|
|
1412
|
+
return {
|
|
1413
|
+
"available": False,
|
|
1414
|
+
"percentage": 0.0,
|
|
1415
|
+
"duplicates": [],
|
|
1416
|
+
"files": [],
|
|
1417
|
+
}
|
|
1418
|
+
cmd = [npx_path, "--yes", "jscpd"]
|
|
1419
|
+
|
|
1420
|
+
cmd.extend(
|
|
1421
|
+
[
|
|
1422
|
+
target,
|
|
1423
|
+
"--format",
|
|
1424
|
+
"json",
|
|
1425
|
+
"--min-lines",
|
|
1426
|
+
str(self.min_duplication_lines),
|
|
1427
|
+
"--reporters",
|
|
1428
|
+
"json",
|
|
1429
|
+
]
|
|
1430
|
+
)
|
|
1431
|
+
|
|
1432
|
+
# Run jscpd
|
|
1433
|
+
result = subprocess.run( # nosec B603 - fixed args
|
|
1434
|
+
wrap_windows_cmd_shim(cmd),
|
|
1435
|
+
capture_output=True,
|
|
1436
|
+
text=True,
|
|
1437
|
+
encoding="utf-8",
|
|
1438
|
+
errors="replace",
|
|
1439
|
+
timeout=120,
|
|
1440
|
+
cwd=target_dir if Path(target_dir).exists() else None,
|
|
1441
|
+
)
|
|
1442
|
+
|
|
1443
|
+
# Parse JSON output
|
|
1444
|
+
json_output = result.stdout.strip()
|
|
1445
|
+
try:
|
|
1446
|
+
if json_output:
|
|
1447
|
+
report_data = json_lib.loads(json_output)
|
|
1448
|
+
else:
|
|
1449
|
+
# Check for output file
|
|
1450
|
+
output_file = Path(target_dir) / "jscpd-report.json"
|
|
1451
|
+
if output_file.exists():
|
|
1452
|
+
with open(output_file, encoding="utf-8") as f:
|
|
1453
|
+
report_data = json_lib.load(f)
|
|
1454
|
+
else:
|
|
1455
|
+
return {
|
|
1456
|
+
"available": True,
|
|
1457
|
+
"percentage": 0.0,
|
|
1458
|
+
"duplicates": [],
|
|
1459
|
+
"files": [],
|
|
1460
|
+
}
|
|
1461
|
+
except json_lib.JSONDecodeError:
|
|
1462
|
+
return {
|
|
1463
|
+
"available": True,
|
|
1464
|
+
"error": "Failed to parse jscpd output",
|
|
1465
|
+
"percentage": 0.0,
|
|
1466
|
+
"duplicates": [],
|
|
1467
|
+
"files": [],
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
# Extract relevant data from jscpd report
|
|
1471
|
+
# jscpd JSON format varies, but typically has:
|
|
1472
|
+
# - percentage: overall duplication percentage
|
|
1473
|
+
# - duplicates: array of duplicate pairs
|
|
1474
|
+
# - files: file-level statistics
|
|
1475
|
+
|
|
1476
|
+
return {
|
|
1477
|
+
"available": True,
|
|
1478
|
+
"percentage": report_data.get("percentage", 0.0),
|
|
1479
|
+
"duplicates": report_data.get("duplicates", []),
|
|
1480
|
+
"files": (
|
|
1481
|
+
report_data.get("statistics", {}).get("files", [])
|
|
1482
|
+
if "statistics" in report_data
|
|
1483
|
+
else []
|
|
1484
|
+
),
|
|
1485
|
+
"total_lines": (
|
|
1486
|
+
report_data.get("statistics", {}).get("total", {}).get("lines", 0)
|
|
1487
|
+
if "statistics" in report_data
|
|
1488
|
+
else 0
|
|
1489
|
+
),
|
|
1490
|
+
"duplicated_lines": (
|
|
1491
|
+
report_data.get("statistics", {})
|
|
1492
|
+
.get("duplicated", {})
|
|
1493
|
+
.get("lines", 0)
|
|
1494
|
+
if "statistics" in report_data
|
|
1495
|
+
else 0
|
|
1496
|
+
),
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
except subprocess.TimeoutExpired:
|
|
1500
|
+
return {
|
|
1501
|
+
"available": True,
|
|
1502
|
+
"error": "jscpd timeout",
|
|
1503
|
+
"percentage": 0.0,
|
|
1504
|
+
"duplicates": [],
|
|
1505
|
+
"files": [],
|
|
1506
|
+
}
|
|
1507
|
+
except FileNotFoundError:
|
|
1508
|
+
return {
|
|
1509
|
+
"available": False,
|
|
1510
|
+
"percentage": 0.0,
|
|
1511
|
+
"duplicates": [],
|
|
1512
|
+
"files": [],
|
|
1513
|
+
}
|
|
1514
|
+
except Exception as e:
|
|
1515
|
+
return {
|
|
1516
|
+
"available": True,
|
|
1517
|
+
"error": str(e),
|
|
1518
|
+
"percentage": 0.0,
|
|
1519
|
+
"duplicates": [],
|
|
1520
|
+
"files": [],
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
|
|
1524
|
+
class ScorerFactory:
|
|
1525
|
+
"""
|
|
1526
|
+
Factory to provide appropriate scorer based on language (Strategy Pattern).
|
|
1527
|
+
|
|
1528
|
+
Phase 1.2: Language-Specific Scorers
|
|
1529
|
+
|
|
1530
|
+
Now uses ScorerRegistry for extensible language support.
|
|
1531
|
+
"""
|
|
1532
|
+
|
|
1533
|
+
@staticmethod
|
|
1534
|
+
def get_scorer(language: Language, config: ProjectConfig | None = None) -> BaseScorer:
|
|
1535
|
+
"""
|
|
1536
|
+
Get the appropriate scorer for a given language.
|
|
1537
|
+
|
|
1538
|
+
Uses ScorerRegistry for extensible language support with fallback chains.
|
|
1539
|
+
|
|
1540
|
+
Args:
|
|
1541
|
+
language: Detected language enum
|
|
1542
|
+
config: Optional project configuration
|
|
1543
|
+
|
|
1544
|
+
Returns:
|
|
1545
|
+
BaseScorer instance appropriate for the language
|
|
1546
|
+
|
|
1547
|
+
Raises:
|
|
1548
|
+
ValueError: If no scorer is available for the language (even with fallbacks)
|
|
1549
|
+
"""
|
|
1550
|
+
from .scorer_registry import ScorerRegistry
|
|
1551
|
+
|
|
1552
|
+
try:
|
|
1553
|
+
return ScorerRegistry.get_scorer(language, config)
|
|
1554
|
+
except ValueError:
|
|
1555
|
+
# If no scorer found, fall back to Python scorer as last resort
|
|
1556
|
+
# This maintains backward compatibility but may not work well for non-Python code
|
|
1557
|
+
# TODO: In the future, create a GenericScorer that uses metric strategies
|
|
1558
|
+
if language != Language.PYTHON:
|
|
1559
|
+
# Try Python scorer as absolute last resort
|
|
1560
|
+
try:
|
|
1561
|
+
return ScorerRegistry.get_scorer(Language.PYTHON, config)
|
|
1562
|
+
except ValueError:
|
|
1563
|
+
pass
|
|
1564
|
+
|
|
1565
|
+
# If even Python scorer isn't available, raise the original error
|
|
1566
|
+
raise
|