attune-ai 2.0.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.
- attune/__init__.py +358 -0
- attune/adaptive/__init__.py +13 -0
- attune/adaptive/task_complexity.py +127 -0
- attune/agent_monitoring.py +414 -0
- attune/cache/__init__.py +117 -0
- attune/cache/base.py +166 -0
- attune/cache/dependency_manager.py +256 -0
- attune/cache/hash_only.py +251 -0
- attune/cache/hybrid.py +457 -0
- attune/cache/storage.py +285 -0
- attune/cache_monitor.py +356 -0
- attune/cache_stats.py +298 -0
- attune/cli/__init__.py +152 -0
- attune/cli/__main__.py +12 -0
- attune/cli/commands/__init__.py +1 -0
- attune/cli/commands/batch.py +264 -0
- attune/cli/commands/cache.py +248 -0
- attune/cli/commands/help.py +331 -0
- attune/cli/commands/info.py +140 -0
- attune/cli/commands/inspect.py +436 -0
- attune/cli/commands/inspection.py +57 -0
- attune/cli/commands/memory.py +48 -0
- attune/cli/commands/metrics.py +92 -0
- attune/cli/commands/orchestrate.py +184 -0
- attune/cli/commands/patterns.py +207 -0
- attune/cli/commands/profiling.py +202 -0
- attune/cli/commands/provider.py +98 -0
- attune/cli/commands/routing.py +285 -0
- attune/cli/commands/setup.py +96 -0
- attune/cli/commands/status.py +235 -0
- attune/cli/commands/sync.py +166 -0
- attune/cli/commands/tier.py +121 -0
- attune/cli/commands/utilities.py +114 -0
- attune/cli/commands/workflow.py +579 -0
- attune/cli/core.py +32 -0
- attune/cli/parsers/__init__.py +68 -0
- attune/cli/parsers/batch.py +118 -0
- attune/cli/parsers/cache.py +65 -0
- attune/cli/parsers/help.py +41 -0
- attune/cli/parsers/info.py +26 -0
- attune/cli/parsers/inspect.py +66 -0
- attune/cli/parsers/metrics.py +42 -0
- attune/cli/parsers/orchestrate.py +61 -0
- attune/cli/parsers/patterns.py +54 -0
- attune/cli/parsers/provider.py +40 -0
- attune/cli/parsers/routing.py +110 -0
- attune/cli/parsers/setup.py +42 -0
- attune/cli/parsers/status.py +47 -0
- attune/cli/parsers/sync.py +31 -0
- attune/cli/parsers/tier.py +33 -0
- attune/cli/parsers/workflow.py +77 -0
- attune/cli/utils/__init__.py +1 -0
- attune/cli/utils/data.py +242 -0
- attune/cli/utils/helpers.py +68 -0
- attune/cli_legacy.py +3957 -0
- attune/cli_minimal.py +1159 -0
- attune/cli_router.py +437 -0
- attune/cli_unified.py +814 -0
- attune/config/__init__.py +66 -0
- attune/config/xml_config.py +286 -0
- attune/config.py +545 -0
- attune/coordination.py +870 -0
- attune/core.py +1511 -0
- attune/core_modules/__init__.py +15 -0
- attune/cost_tracker.py +626 -0
- attune/dashboard/__init__.py +41 -0
- attune/dashboard/app.py +512 -0
- attune/dashboard/simple_server.py +435 -0
- attune/dashboard/standalone_server.py +547 -0
- attune/discovery.py +306 -0
- attune/emergence.py +306 -0
- attune/exceptions.py +123 -0
- attune/feedback_loops.py +373 -0
- attune/hot_reload/README.md +473 -0
- attune/hot_reload/__init__.py +62 -0
- attune/hot_reload/config.py +83 -0
- attune/hot_reload/integration.py +229 -0
- attune/hot_reload/reloader.py +298 -0
- attune/hot_reload/watcher.py +183 -0
- attune/hot_reload/websocket.py +177 -0
- attune/levels.py +577 -0
- attune/leverage_points.py +441 -0
- attune/logging_config.py +261 -0
- attune/mcp/__init__.py +10 -0
- attune/mcp/server.py +506 -0
- attune/memory/__init__.py +237 -0
- attune/memory/claude_memory.py +469 -0
- attune/memory/config.py +224 -0
- attune/memory/control_panel.py +1290 -0
- attune/memory/control_panel_support.py +145 -0
- attune/memory/cross_session.py +845 -0
- attune/memory/edges.py +179 -0
- attune/memory/encryption.py +159 -0
- attune/memory/file_session.py +770 -0
- attune/memory/graph.py +570 -0
- attune/memory/long_term.py +913 -0
- attune/memory/long_term_types.py +99 -0
- attune/memory/mixins/__init__.py +25 -0
- attune/memory/mixins/backend_init_mixin.py +249 -0
- attune/memory/mixins/capabilities_mixin.py +208 -0
- attune/memory/mixins/handoff_mixin.py +208 -0
- attune/memory/mixins/lifecycle_mixin.py +49 -0
- attune/memory/mixins/long_term_mixin.py +352 -0
- attune/memory/mixins/promotion_mixin.py +109 -0
- attune/memory/mixins/short_term_mixin.py +182 -0
- attune/memory/nodes.py +179 -0
- attune/memory/redis_bootstrap.py +540 -0
- attune/memory/security/__init__.py +31 -0
- attune/memory/security/audit_logger.py +932 -0
- attune/memory/security/pii_scrubber.py +640 -0
- attune/memory/security/secrets_detector.py +678 -0
- attune/memory/short_term.py +2192 -0
- attune/memory/simple_storage.py +302 -0
- attune/memory/storage/__init__.py +15 -0
- attune/memory/storage_backend.py +167 -0
- attune/memory/summary_index.py +583 -0
- attune/memory/types.py +446 -0
- attune/memory/unified.py +182 -0
- attune/meta_workflows/__init__.py +74 -0
- attune/meta_workflows/agent_creator.py +248 -0
- attune/meta_workflows/builtin_templates.py +567 -0
- attune/meta_workflows/cli_commands/__init__.py +56 -0
- attune/meta_workflows/cli_commands/agent_commands.py +321 -0
- attune/meta_workflows/cli_commands/analytics_commands.py +442 -0
- attune/meta_workflows/cli_commands/config_commands.py +232 -0
- attune/meta_workflows/cli_commands/memory_commands.py +182 -0
- attune/meta_workflows/cli_commands/template_commands.py +354 -0
- attune/meta_workflows/cli_commands/workflow_commands.py +382 -0
- attune/meta_workflows/cli_meta_workflows.py +59 -0
- attune/meta_workflows/form_engine.py +292 -0
- attune/meta_workflows/intent_detector.py +409 -0
- attune/meta_workflows/models.py +569 -0
- attune/meta_workflows/pattern_learner.py +738 -0
- attune/meta_workflows/plan_generator.py +384 -0
- attune/meta_workflows/session_context.py +397 -0
- attune/meta_workflows/template_registry.py +229 -0
- attune/meta_workflows/workflow.py +984 -0
- attune/metrics/__init__.py +12 -0
- attune/metrics/collector.py +31 -0
- attune/metrics/prompt_metrics.py +194 -0
- attune/models/__init__.py +172 -0
- attune/models/__main__.py +13 -0
- attune/models/adaptive_routing.py +437 -0
- attune/models/auth_cli.py +444 -0
- attune/models/auth_strategy.py +450 -0
- attune/models/cli.py +655 -0
- attune/models/empathy_executor.py +354 -0
- attune/models/executor.py +257 -0
- attune/models/fallback.py +762 -0
- attune/models/provider_config.py +282 -0
- attune/models/registry.py +472 -0
- attune/models/tasks.py +359 -0
- attune/models/telemetry/__init__.py +71 -0
- attune/models/telemetry/analytics.py +594 -0
- attune/models/telemetry/backend.py +196 -0
- attune/models/telemetry/data_models.py +431 -0
- attune/models/telemetry/storage.py +489 -0
- attune/models/token_estimator.py +420 -0
- attune/models/validation.py +280 -0
- attune/monitoring/__init__.py +52 -0
- attune/monitoring/alerts.py +946 -0
- attune/monitoring/alerts_cli.py +448 -0
- attune/monitoring/multi_backend.py +271 -0
- attune/monitoring/otel_backend.py +362 -0
- attune/optimization/__init__.py +19 -0
- attune/optimization/context_optimizer.py +272 -0
- attune/orchestration/__init__.py +67 -0
- attune/orchestration/agent_templates.py +707 -0
- attune/orchestration/config_store.py +499 -0
- attune/orchestration/execution_strategies.py +2111 -0
- attune/orchestration/meta_orchestrator.py +1168 -0
- attune/orchestration/pattern_learner.py +696 -0
- attune/orchestration/real_tools.py +931 -0
- attune/pattern_cache.py +187 -0
- attune/pattern_library.py +542 -0
- attune/patterns/debugging/all_patterns.json +81 -0
- attune/patterns/debugging/workflow_20260107_1770825e.json +77 -0
- attune/patterns/refactoring_memory.json +89 -0
- attune/persistence.py +564 -0
- attune/platform_utils.py +265 -0
- attune/plugins/__init__.py +28 -0
- attune/plugins/base.py +361 -0
- attune/plugins/registry.py +268 -0
- attune/project_index/__init__.py +32 -0
- attune/project_index/cli.py +335 -0
- attune/project_index/index.py +667 -0
- attune/project_index/models.py +504 -0
- attune/project_index/reports.py +474 -0
- attune/project_index/scanner.py +777 -0
- attune/project_index/scanner_parallel.py +291 -0
- attune/prompts/__init__.py +61 -0
- attune/prompts/config.py +77 -0
- attune/prompts/context.py +177 -0
- attune/prompts/parser.py +285 -0
- attune/prompts/registry.py +313 -0
- attune/prompts/templates.py +208 -0
- attune/redis_config.py +302 -0
- attune/redis_memory.py +799 -0
- attune/resilience/__init__.py +56 -0
- attune/resilience/circuit_breaker.py +256 -0
- attune/resilience/fallback.py +179 -0
- attune/resilience/health.py +300 -0
- attune/resilience/retry.py +209 -0
- attune/resilience/timeout.py +135 -0
- attune/routing/__init__.py +43 -0
- attune/routing/chain_executor.py +433 -0
- attune/routing/classifier.py +217 -0
- attune/routing/smart_router.py +234 -0
- attune/routing/workflow_registry.py +343 -0
- attune/scaffolding/README.md +589 -0
- attune/scaffolding/__init__.py +35 -0
- attune/scaffolding/__main__.py +14 -0
- attune/scaffolding/cli.py +240 -0
- attune/scaffolding/templates/base_wizard.py.jinja2 +121 -0
- attune/scaffolding/templates/coach_wizard.py.jinja2 +321 -0
- attune/scaffolding/templates/domain_wizard.py.jinja2 +408 -0
- attune/scaffolding/templates/linear_flow_wizard.py.jinja2 +203 -0
- attune/socratic/__init__.py +256 -0
- attune/socratic/ab_testing.py +958 -0
- attune/socratic/blueprint.py +533 -0
- attune/socratic/cli.py +703 -0
- attune/socratic/collaboration.py +1114 -0
- attune/socratic/domain_templates.py +924 -0
- attune/socratic/embeddings.py +738 -0
- attune/socratic/engine.py +794 -0
- attune/socratic/explainer.py +682 -0
- attune/socratic/feedback.py +772 -0
- attune/socratic/forms.py +629 -0
- attune/socratic/generator.py +732 -0
- attune/socratic/llm_analyzer.py +637 -0
- attune/socratic/mcp_server.py +702 -0
- attune/socratic/session.py +312 -0
- attune/socratic/storage.py +667 -0
- attune/socratic/success.py +730 -0
- attune/socratic/visual_editor.py +860 -0
- attune/socratic/web_ui.py +958 -0
- attune/telemetry/__init__.py +39 -0
- attune/telemetry/agent_coordination.py +475 -0
- attune/telemetry/agent_tracking.py +367 -0
- attune/telemetry/approval_gates.py +545 -0
- attune/telemetry/cli.py +1231 -0
- attune/telemetry/commands/__init__.py +14 -0
- attune/telemetry/commands/dashboard_commands.py +696 -0
- attune/telemetry/event_streaming.py +409 -0
- attune/telemetry/feedback_loop.py +567 -0
- attune/telemetry/usage_tracker.py +591 -0
- attune/templates.py +754 -0
- attune/test_generator/__init__.py +38 -0
- attune/test_generator/__main__.py +14 -0
- attune/test_generator/cli.py +234 -0
- attune/test_generator/generator.py +355 -0
- attune/test_generator/risk_analyzer.py +216 -0
- attune/test_generator/templates/unit_test.py.jinja2 +272 -0
- attune/tier_recommender.py +384 -0
- attune/tools.py +183 -0
- attune/trust/__init__.py +28 -0
- attune/trust/circuit_breaker.py +579 -0
- attune/trust_building.py +527 -0
- attune/validation/__init__.py +19 -0
- attune/validation/xml_validator.py +281 -0
- attune/vscode_bridge.py +173 -0
- attune/workflow_commands.py +780 -0
- attune/workflow_patterns/__init__.py +33 -0
- attune/workflow_patterns/behavior.py +249 -0
- attune/workflow_patterns/core.py +76 -0
- attune/workflow_patterns/output.py +99 -0
- attune/workflow_patterns/registry.py +255 -0
- attune/workflow_patterns/structural.py +288 -0
- attune/workflows/__init__.py +539 -0
- attune/workflows/autonomous_test_gen.py +1268 -0
- attune/workflows/base.py +2667 -0
- attune/workflows/batch_processing.py +342 -0
- attune/workflows/bug_predict.py +1084 -0
- attune/workflows/builder.py +273 -0
- attune/workflows/caching.py +253 -0
- attune/workflows/code_review.py +1048 -0
- attune/workflows/code_review_adapters.py +312 -0
- attune/workflows/code_review_pipeline.py +722 -0
- attune/workflows/config.py +645 -0
- attune/workflows/dependency_check.py +644 -0
- attune/workflows/document_gen/__init__.py +25 -0
- attune/workflows/document_gen/config.py +30 -0
- attune/workflows/document_gen/report_formatter.py +162 -0
- attune/workflows/document_gen/workflow.py +1426 -0
- attune/workflows/document_manager.py +216 -0
- attune/workflows/document_manager_README.md +134 -0
- attune/workflows/documentation_orchestrator.py +1205 -0
- attune/workflows/history.py +510 -0
- attune/workflows/keyboard_shortcuts/__init__.py +39 -0
- attune/workflows/keyboard_shortcuts/generators.py +391 -0
- attune/workflows/keyboard_shortcuts/parsers.py +416 -0
- attune/workflows/keyboard_shortcuts/prompts.py +295 -0
- attune/workflows/keyboard_shortcuts/schema.py +193 -0
- attune/workflows/keyboard_shortcuts/workflow.py +509 -0
- attune/workflows/llm_base.py +363 -0
- attune/workflows/manage_docs.py +87 -0
- attune/workflows/manage_docs_README.md +134 -0
- attune/workflows/manage_documentation.py +821 -0
- attune/workflows/new_sample_workflow1.py +149 -0
- attune/workflows/new_sample_workflow1_README.md +150 -0
- attune/workflows/orchestrated_health_check.py +849 -0
- attune/workflows/orchestrated_release_prep.py +600 -0
- attune/workflows/output.py +413 -0
- attune/workflows/perf_audit.py +863 -0
- attune/workflows/pr_review.py +762 -0
- attune/workflows/progress.py +785 -0
- attune/workflows/progress_server.py +322 -0
- attune/workflows/progressive/README 2.md +454 -0
- attune/workflows/progressive/README.md +454 -0
- attune/workflows/progressive/__init__.py +82 -0
- attune/workflows/progressive/cli.py +219 -0
- attune/workflows/progressive/core.py +488 -0
- attune/workflows/progressive/orchestrator.py +723 -0
- attune/workflows/progressive/reports.py +520 -0
- attune/workflows/progressive/telemetry.py +274 -0
- attune/workflows/progressive/test_gen.py +495 -0
- attune/workflows/progressive/workflow.py +589 -0
- attune/workflows/refactor_plan.py +694 -0
- attune/workflows/release_prep.py +895 -0
- attune/workflows/release_prep_crew.py +969 -0
- attune/workflows/research_synthesis.py +404 -0
- attune/workflows/routing.py +168 -0
- attune/workflows/secure_release.py +593 -0
- attune/workflows/security_adapters.py +297 -0
- attune/workflows/security_audit.py +1329 -0
- attune/workflows/security_audit_phase3.py +355 -0
- attune/workflows/seo_optimization.py +633 -0
- attune/workflows/step_config.py +234 -0
- attune/workflows/telemetry_mixin.py +269 -0
- attune/workflows/test5.py +125 -0
- attune/workflows/test5_README.md +158 -0
- attune/workflows/test_coverage_boost_crew.py +849 -0
- attune/workflows/test_gen/__init__.py +52 -0
- attune/workflows/test_gen/ast_analyzer.py +249 -0
- attune/workflows/test_gen/config.py +88 -0
- attune/workflows/test_gen/data_models.py +38 -0
- attune/workflows/test_gen/report_formatter.py +289 -0
- attune/workflows/test_gen/test_templates.py +381 -0
- attune/workflows/test_gen/workflow.py +655 -0
- attune/workflows/test_gen.py +54 -0
- attune/workflows/test_gen_behavioral.py +477 -0
- attune/workflows/test_gen_parallel.py +341 -0
- attune/workflows/test_lifecycle.py +526 -0
- attune/workflows/test_maintenance.py +627 -0
- attune/workflows/test_maintenance_cli.py +590 -0
- attune/workflows/test_maintenance_crew.py +840 -0
- attune/workflows/test_runner.py +622 -0
- attune/workflows/tier_tracking.py +531 -0
- attune/workflows/xml_enhanced_crew.py +285 -0
- attune_ai-2.0.0.dist-info/METADATA +1026 -0
- attune_ai-2.0.0.dist-info/RECORD +457 -0
- attune_ai-2.0.0.dist-info/WHEEL +5 -0
- attune_ai-2.0.0.dist-info/entry_points.txt +26 -0
- attune_ai-2.0.0.dist-info/licenses/LICENSE +201 -0
- attune_ai-2.0.0.dist-info/licenses/LICENSE_CHANGE_ANNOUNCEMENT.md +101 -0
- attune_ai-2.0.0.dist-info/top_level.txt +5 -0
- attune_healthcare/__init__.py +13 -0
- attune_healthcare/monitors/__init__.py +9 -0
- attune_healthcare/monitors/clinical_protocol_monitor.py +315 -0
- attune_healthcare/monitors/monitoring/__init__.py +44 -0
- attune_healthcare/monitors/monitoring/protocol_checker.py +300 -0
- attune_healthcare/monitors/monitoring/protocol_loader.py +214 -0
- attune_healthcare/monitors/monitoring/sensor_parsers.py +306 -0
- attune_healthcare/monitors/monitoring/trajectory_analyzer.py +389 -0
- attune_llm/README.md +553 -0
- attune_llm/__init__.py +28 -0
- attune_llm/agent_factory/__init__.py +53 -0
- attune_llm/agent_factory/adapters/__init__.py +85 -0
- attune_llm/agent_factory/adapters/autogen_adapter.py +312 -0
- attune_llm/agent_factory/adapters/crewai_adapter.py +483 -0
- attune_llm/agent_factory/adapters/haystack_adapter.py +298 -0
- attune_llm/agent_factory/adapters/langchain_adapter.py +362 -0
- attune_llm/agent_factory/adapters/langgraph_adapter.py +333 -0
- attune_llm/agent_factory/adapters/native.py +228 -0
- attune_llm/agent_factory/adapters/wizard_adapter.py +423 -0
- attune_llm/agent_factory/base.py +305 -0
- attune_llm/agent_factory/crews/__init__.py +67 -0
- attune_llm/agent_factory/crews/code_review.py +1113 -0
- attune_llm/agent_factory/crews/health_check.py +1262 -0
- attune_llm/agent_factory/crews/refactoring.py +1128 -0
- attune_llm/agent_factory/crews/security_audit.py +1018 -0
- attune_llm/agent_factory/decorators.py +287 -0
- attune_llm/agent_factory/factory.py +558 -0
- attune_llm/agent_factory/framework.py +193 -0
- attune_llm/agent_factory/memory_integration.py +328 -0
- attune_llm/agent_factory/resilient.py +320 -0
- attune_llm/agents_md/__init__.py +22 -0
- attune_llm/agents_md/loader.py +218 -0
- attune_llm/agents_md/parser.py +271 -0
- attune_llm/agents_md/registry.py +307 -0
- attune_llm/claude_memory.py +466 -0
- attune_llm/cli/__init__.py +8 -0
- attune_llm/cli/sync_claude.py +487 -0
- attune_llm/code_health.py +1313 -0
- attune_llm/commands/__init__.py +51 -0
- attune_llm/commands/context.py +375 -0
- attune_llm/commands/loader.py +301 -0
- attune_llm/commands/models.py +231 -0
- attune_llm/commands/parser.py +371 -0
- attune_llm/commands/registry.py +429 -0
- attune_llm/config/__init__.py +29 -0
- attune_llm/config/unified.py +291 -0
- attune_llm/context/__init__.py +22 -0
- attune_llm/context/compaction.py +455 -0
- attune_llm/context/manager.py +434 -0
- attune_llm/contextual_patterns.py +361 -0
- attune_llm/core.py +907 -0
- attune_llm/git_pattern_extractor.py +435 -0
- attune_llm/hooks/__init__.py +24 -0
- attune_llm/hooks/config.py +306 -0
- attune_llm/hooks/executor.py +289 -0
- attune_llm/hooks/registry.py +302 -0
- attune_llm/hooks/scripts/__init__.py +39 -0
- attune_llm/hooks/scripts/evaluate_session.py +201 -0
- attune_llm/hooks/scripts/first_time_init.py +285 -0
- attune_llm/hooks/scripts/pre_compact.py +207 -0
- attune_llm/hooks/scripts/session_end.py +183 -0
- attune_llm/hooks/scripts/session_start.py +163 -0
- attune_llm/hooks/scripts/suggest_compact.py +225 -0
- attune_llm/learning/__init__.py +30 -0
- attune_llm/learning/evaluator.py +438 -0
- attune_llm/learning/extractor.py +514 -0
- attune_llm/learning/storage.py +560 -0
- attune_llm/levels.py +227 -0
- attune_llm/pattern_confidence.py +414 -0
- attune_llm/pattern_resolver.py +272 -0
- attune_llm/pattern_summary.py +350 -0
- attune_llm/providers.py +967 -0
- attune_llm/routing/__init__.py +32 -0
- attune_llm/routing/model_router.py +362 -0
- attune_llm/security/IMPLEMENTATION_SUMMARY.md +413 -0
- attune_llm/security/PHASE2_COMPLETE.md +384 -0
- attune_llm/security/PHASE2_SECRETS_DETECTOR_COMPLETE.md +271 -0
- attune_llm/security/QUICK_REFERENCE.md +316 -0
- attune_llm/security/README.md +262 -0
- attune_llm/security/__init__.py +62 -0
- attune_llm/security/audit_logger.py +929 -0
- attune_llm/security/audit_logger_example.py +152 -0
- attune_llm/security/pii_scrubber.py +640 -0
- attune_llm/security/secrets_detector.py +678 -0
- attune_llm/security/secrets_detector_example.py +304 -0
- attune_llm/security/secure_memdocs.py +1192 -0
- attune_llm/security/secure_memdocs_example.py +278 -0
- attune_llm/session_status.py +745 -0
- attune_llm/state.py +246 -0
- attune_llm/utils/__init__.py +5 -0
- attune_llm/utils/tokens.py +349 -0
- attune_software/SOFTWARE_PLUGIN_README.md +57 -0
- attune_software/__init__.py +13 -0
- attune_software/cli/__init__.py +120 -0
- attune_software/cli/inspect.py +362 -0
- attune_software/cli.py +574 -0
- attune_software/plugin.py +188 -0
- workflow_scaffolding/__init__.py +11 -0
- workflow_scaffolding/__main__.py +12 -0
- workflow_scaffolding/cli.py +206 -0
- workflow_scaffolding/generator.py +265 -0
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
"""Project Scanner - Scans codebase to build file index.
|
|
2
|
+
|
|
3
|
+
Analyzes source files, matches them to tests, calculates metrics.
|
|
4
|
+
|
|
5
|
+
Copyright 2025 Smart AI Memory, LLC
|
|
6
|
+
Licensed under Fair Source 0.9
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
import fnmatch
|
|
11
|
+
import hashlib
|
|
12
|
+
import heapq
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from functools import lru_cache
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from .models import FileCategory, FileRecord, IndexConfig, ProjectSummary, TestRequirement
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ProjectScanner:
|
|
24
|
+
"""Scans a project directory and builds file metadata.
|
|
25
|
+
|
|
26
|
+
Used by ProjectIndex to populate and update the index.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
# Optimization: Use frozensets for O(1) membership testing (vs O(n) with lists)
|
|
30
|
+
# These are used on every file during categorization (thousands of files)
|
|
31
|
+
CONFIG_SUFFIXES = frozenset({".yml", ".yaml", ".toml", ".ini", ".cfg", ".json"})
|
|
32
|
+
DOC_SUFFIXES = frozenset({".md", ".rst", ".txt"})
|
|
33
|
+
DOC_NAMES = frozenset({"README", "CHANGELOG", "LICENSE"})
|
|
34
|
+
ASSET_SUFFIXES = frozenset({".css", ".scss", ".html", ".svg", ".png", ".jpg", ".gif"})
|
|
35
|
+
SOURCE_SUFFIXES = frozenset({".py", ".js", ".ts", ".tsx", ".jsx", ".go", ".rs", ".java"})
|
|
36
|
+
|
|
37
|
+
def __init__(self, project_root: str, config: IndexConfig | None = None):
|
|
38
|
+
self.project_root = Path(project_root)
|
|
39
|
+
self.config = config or IndexConfig()
|
|
40
|
+
self._test_file_map: dict[str, str] = {} # source -> test mapping
|
|
41
|
+
# Pre-compile glob patterns for O(1) matching (vs recompiling on every call)
|
|
42
|
+
# This optimization reduces _matches_glob_pattern() time by ~70%
|
|
43
|
+
self._compiled_patterns: dict[str, tuple[re.Pattern, str | None]] = {}
|
|
44
|
+
self._compile_glob_patterns()
|
|
45
|
+
|
|
46
|
+
def _compile_glob_patterns(self) -> None:
|
|
47
|
+
"""Pre-compile glob patterns for faster matching.
|
|
48
|
+
|
|
49
|
+
Called once at init to avoid recompiling patterns on every file check.
|
|
50
|
+
Profiling showed fnmatch.fnmatch() called 823,433 times - this optimization
|
|
51
|
+
reduces that overhead by ~70% by using pre-compiled regex patterns.
|
|
52
|
+
"""
|
|
53
|
+
all_patterns = list(self.config.exclude_patterns) + list(self.config.no_test_patterns)
|
|
54
|
+
|
|
55
|
+
for pattern in all_patterns:
|
|
56
|
+
if pattern in self._compiled_patterns:
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
# Extract directory name for ** patterns
|
|
60
|
+
dir_name = None
|
|
61
|
+
if "**" in pattern:
|
|
62
|
+
if pattern.startswith("**/") and pattern.endswith("/**"):
|
|
63
|
+
dir_name = pattern[3:-3] # e.g., "**/node_modules/**" -> "node_modules"
|
|
64
|
+
elif pattern.endswith("/**"):
|
|
65
|
+
dir_name = pattern.replace("**/", "").replace("/**", "")
|
|
66
|
+
|
|
67
|
+
# Compile simple pattern (without **) for fnmatch-style matching
|
|
68
|
+
simple_pattern = pattern.replace("**/", "")
|
|
69
|
+
try:
|
|
70
|
+
regex_pattern = fnmatch.translate(simple_pattern)
|
|
71
|
+
compiled = re.compile(regex_pattern)
|
|
72
|
+
except re.error:
|
|
73
|
+
# Fallback for invalid patterns
|
|
74
|
+
compiled = re.compile(re.escape(simple_pattern))
|
|
75
|
+
|
|
76
|
+
self._compiled_patterns[pattern] = (compiled, dir_name)
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
@lru_cache(maxsize=1000)
|
|
80
|
+
def _hash_file(file_path: str) -> str:
|
|
81
|
+
"""Cache file content hashes for invalidation.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
file_path: Path to file as string (for hashability)
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
SHA256 hash of file contents
|
|
88
|
+
|
|
89
|
+
Note:
|
|
90
|
+
Uses LRU cache with 1000 entries (~64KB memory).
|
|
91
|
+
Hit rate expected: 80%+ for incremental scans.
|
|
92
|
+
"""
|
|
93
|
+
try:
|
|
94
|
+
return hashlib.sha256(Path(file_path).read_bytes()).hexdigest()
|
|
95
|
+
except OSError:
|
|
96
|
+
# Return timestamp-based hash if file unreadable
|
|
97
|
+
return str(Path(file_path).stat().st_mtime)
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
@lru_cache(maxsize=2000)
|
|
101
|
+
def _parse_python_cached(file_path: str, file_hash: str) -> ast.Module | None:
|
|
102
|
+
"""Cache AST parsing results (expensive CPU operation).
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
file_path: Path to Python file
|
|
106
|
+
file_hash: Hash of file contents (for cache invalidation)
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Parsed AST or None if parsing fails
|
|
110
|
+
|
|
111
|
+
Note:
|
|
112
|
+
Uses LRU cache with 2000 entries (~20MB memory).
|
|
113
|
+
Hit rate expected: 90%+ for incremental operations.
|
|
114
|
+
Cache invalidates automatically when file_hash changes.
|
|
115
|
+
"""
|
|
116
|
+
try:
|
|
117
|
+
content = Path(file_path).read_text(encoding="utf-8", errors="ignore")
|
|
118
|
+
return ast.parse(content)
|
|
119
|
+
except (SyntaxError, ValueError, OSError):
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
def scan(self, analyze_dependencies: bool = True) -> tuple[list[FileRecord], ProjectSummary]:
|
|
123
|
+
"""Scan the entire project and return file records and summary.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
analyze_dependencies: Whether to analyze import dependencies.
|
|
127
|
+
Set to False to skip expensive dependency graph analysis (saves ~2s).
|
|
128
|
+
Default: True for backwards compatibility.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
Tuple of (list of FileRecords, ProjectSummary)
|
|
132
|
+
|
|
133
|
+
"""
|
|
134
|
+
records: list[FileRecord] = []
|
|
135
|
+
|
|
136
|
+
# First pass: discover all files
|
|
137
|
+
all_files = self._discover_files()
|
|
138
|
+
|
|
139
|
+
# Build test file mapping
|
|
140
|
+
self._build_test_mapping(all_files)
|
|
141
|
+
|
|
142
|
+
# Second pass: analyze each file
|
|
143
|
+
for file_path in all_files:
|
|
144
|
+
record = self._analyze_file(file_path)
|
|
145
|
+
if record:
|
|
146
|
+
records.append(record)
|
|
147
|
+
|
|
148
|
+
# Third pass: build dependency graph (optional - saves ~2s when skipped)
|
|
149
|
+
if analyze_dependencies:
|
|
150
|
+
self._analyze_dependencies(records)
|
|
151
|
+
|
|
152
|
+
# Calculate impact scores (depends on dependency graph)
|
|
153
|
+
self._calculate_impact_scores(records)
|
|
154
|
+
|
|
155
|
+
# Determine attention needs
|
|
156
|
+
self._determine_attention_needs(records)
|
|
157
|
+
|
|
158
|
+
# Build summary
|
|
159
|
+
summary = self._build_summary(records)
|
|
160
|
+
|
|
161
|
+
return records, summary
|
|
162
|
+
|
|
163
|
+
def _discover_files(self) -> list[Path]:
|
|
164
|
+
"""Discover all relevant files in the project."""
|
|
165
|
+
files = []
|
|
166
|
+
|
|
167
|
+
for root, dirs, filenames in os.walk(self.project_root):
|
|
168
|
+
# Filter out excluded directories
|
|
169
|
+
dirs[:] = [d for d in dirs if not self._is_excluded(Path(root) / d)]
|
|
170
|
+
|
|
171
|
+
for filename in filenames:
|
|
172
|
+
file_path = Path(root) / filename
|
|
173
|
+
rel_path = file_path.relative_to(self.project_root)
|
|
174
|
+
|
|
175
|
+
if not self._is_excluded(rel_path):
|
|
176
|
+
files.append(file_path)
|
|
177
|
+
|
|
178
|
+
return files
|
|
179
|
+
|
|
180
|
+
def _matches_glob_pattern(self, path: Path, pattern: str) -> bool:
|
|
181
|
+
"""Check if a path matches a glob pattern (handles ** patterns).
|
|
182
|
+
|
|
183
|
+
Uses pre-compiled regex patterns for performance. This method is called
|
|
184
|
+
~800K+ times during a full scan, so caching the compiled patterns
|
|
185
|
+
provides significant speedup.
|
|
186
|
+
"""
|
|
187
|
+
rel_str = str(path)
|
|
188
|
+
path_parts = path.parts
|
|
189
|
+
|
|
190
|
+
# Get pre-compiled pattern (or compile on-demand if not cached)
|
|
191
|
+
if pattern not in self._compiled_patterns:
|
|
192
|
+
# Lazily compile patterns not seen at init time
|
|
193
|
+
dir_name = None
|
|
194
|
+
if "**" in pattern:
|
|
195
|
+
if pattern.startswith("**/") and pattern.endswith("/**"):
|
|
196
|
+
dir_name = pattern[3:-3]
|
|
197
|
+
elif pattern.endswith("/**"):
|
|
198
|
+
dir_name = pattern.replace("**/", "").replace("/**", "")
|
|
199
|
+
|
|
200
|
+
simple_pattern = pattern.replace("**/", "")
|
|
201
|
+
try:
|
|
202
|
+
regex_pattern = fnmatch.translate(simple_pattern)
|
|
203
|
+
compiled = re.compile(regex_pattern)
|
|
204
|
+
except re.error:
|
|
205
|
+
compiled = re.compile(re.escape(simple_pattern))
|
|
206
|
+
self._compiled_patterns[pattern] = (compiled, dir_name)
|
|
207
|
+
|
|
208
|
+
compiled_regex, dir_name = self._compiled_patterns[pattern]
|
|
209
|
+
|
|
210
|
+
# Handle ** glob patterns
|
|
211
|
+
if "**" in pattern:
|
|
212
|
+
# Check if the pattern matches the path or filename using compiled regex
|
|
213
|
+
if compiled_regex.match(rel_str):
|
|
214
|
+
return True
|
|
215
|
+
if compiled_regex.match(path.name):
|
|
216
|
+
return True
|
|
217
|
+
|
|
218
|
+
# Check directory-based exclusions (fast path check)
|
|
219
|
+
if dir_name and dir_name in path_parts:
|
|
220
|
+
return True
|
|
221
|
+
else:
|
|
222
|
+
# Use compiled regex instead of fnmatch.fnmatch()
|
|
223
|
+
if compiled_regex.match(rel_str):
|
|
224
|
+
return True
|
|
225
|
+
if compiled_regex.match(path.name):
|
|
226
|
+
return True
|
|
227
|
+
|
|
228
|
+
return False
|
|
229
|
+
|
|
230
|
+
def _is_excluded(self, path: Path) -> bool:
|
|
231
|
+
"""Check if a path should be excluded."""
|
|
232
|
+
for pattern in self.config.exclude_patterns:
|
|
233
|
+
if self._matches_glob_pattern(path, pattern):
|
|
234
|
+
return True
|
|
235
|
+
return False
|
|
236
|
+
|
|
237
|
+
def _build_test_mapping(self, files: list[Path]) -> None:
|
|
238
|
+
"""Build mapping from source files to their test files.
|
|
239
|
+
|
|
240
|
+
Optimized to use O(1) dict lookups instead of O(n) linear search.
|
|
241
|
+
Previous implementation was O(n*m), now O(n+m).
|
|
242
|
+
"""
|
|
243
|
+
# Build index of non-test files by stem name for O(1) lookups
|
|
244
|
+
# This replaces the inner loop that searched all files
|
|
245
|
+
source_files_by_stem: dict[str, list[Path]] = {}
|
|
246
|
+
for f in files:
|
|
247
|
+
if not self._is_test_file(f):
|
|
248
|
+
stem = f.stem
|
|
249
|
+
if stem not in source_files_by_stem:
|
|
250
|
+
source_files_by_stem[stem] = []
|
|
251
|
+
source_files_by_stem[stem].append(f)
|
|
252
|
+
|
|
253
|
+
# Now match test files to source files with O(1) lookups
|
|
254
|
+
for f in files:
|
|
255
|
+
if not self._is_test_file(f):
|
|
256
|
+
continue
|
|
257
|
+
|
|
258
|
+
test_name = f.stem # e.g., "test_core"
|
|
259
|
+
|
|
260
|
+
# Common patterns: test_foo.py -> foo.py
|
|
261
|
+
if test_name.startswith("test_"):
|
|
262
|
+
source_name = test_name[5:] # Remove "test_" prefix
|
|
263
|
+
elif test_name.endswith("_test"):
|
|
264
|
+
source_name = test_name[:-5] # Remove "_test" suffix
|
|
265
|
+
else:
|
|
266
|
+
continue
|
|
267
|
+
|
|
268
|
+
# O(1) lookup instead of O(n) linear search
|
|
269
|
+
matching_sources = source_files_by_stem.get(source_name, [])
|
|
270
|
+
if matching_sources:
|
|
271
|
+
# Use first match (typically there's only one)
|
|
272
|
+
source_file = matching_sources[0]
|
|
273
|
+
rel_source = str(source_file.relative_to(self.project_root))
|
|
274
|
+
rel_test = str(f.relative_to(self.project_root))
|
|
275
|
+
self._test_file_map[rel_source] = rel_test
|
|
276
|
+
|
|
277
|
+
def _is_test_file(self, path: Path) -> bool:
|
|
278
|
+
"""Check if a file is a test file."""
|
|
279
|
+
name = path.stem
|
|
280
|
+
return (
|
|
281
|
+
name.startswith("test_")
|
|
282
|
+
or name.endswith("_test")
|
|
283
|
+
or "tests" in path.parts
|
|
284
|
+
or path.parent.name == "test"
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
def _analyze_file(self, file_path: Path) -> FileRecord | None:
|
|
288
|
+
"""Analyze a single file and create its record."""
|
|
289
|
+
rel_path = str(file_path.relative_to(self.project_root))
|
|
290
|
+
|
|
291
|
+
# Determine category
|
|
292
|
+
category = self._determine_category(file_path)
|
|
293
|
+
|
|
294
|
+
# Determine language
|
|
295
|
+
language = self._determine_language(file_path)
|
|
296
|
+
|
|
297
|
+
# Get file stats
|
|
298
|
+
try:
|
|
299
|
+
stat = file_path.stat()
|
|
300
|
+
last_modified = datetime.fromtimestamp(stat.st_mtime)
|
|
301
|
+
except OSError:
|
|
302
|
+
last_modified = None
|
|
303
|
+
|
|
304
|
+
# Determine test requirement
|
|
305
|
+
test_requirement = self._determine_test_requirement(file_path, category)
|
|
306
|
+
|
|
307
|
+
# Find associated test file
|
|
308
|
+
test_file_path = self._test_file_map.get(rel_path)
|
|
309
|
+
tests_exist = test_file_path is not None
|
|
310
|
+
|
|
311
|
+
# Get test file modification time
|
|
312
|
+
tests_last_modified = None
|
|
313
|
+
if test_file_path:
|
|
314
|
+
test_full_path = self.project_root / test_file_path
|
|
315
|
+
if test_full_path.exists():
|
|
316
|
+
try:
|
|
317
|
+
tests_last_modified = datetime.fromtimestamp(test_full_path.stat().st_mtime)
|
|
318
|
+
except OSError:
|
|
319
|
+
pass
|
|
320
|
+
|
|
321
|
+
# Calculate staleness
|
|
322
|
+
staleness_days = 0
|
|
323
|
+
is_stale = False
|
|
324
|
+
if last_modified and tests_last_modified:
|
|
325
|
+
if last_modified > tests_last_modified:
|
|
326
|
+
staleness_days = (last_modified - tests_last_modified).days
|
|
327
|
+
is_stale = staleness_days >= self.config.staleness_threshold_days
|
|
328
|
+
|
|
329
|
+
# Analyze code metrics (skip expensive AST analysis for test files)
|
|
330
|
+
metrics = self._analyze_code_metrics(file_path, language, category)
|
|
331
|
+
|
|
332
|
+
return FileRecord(
|
|
333
|
+
path=rel_path,
|
|
334
|
+
name=file_path.name,
|
|
335
|
+
category=category,
|
|
336
|
+
language=language,
|
|
337
|
+
test_requirement=test_requirement,
|
|
338
|
+
test_file_path=test_file_path,
|
|
339
|
+
tests_exist=tests_exist,
|
|
340
|
+
test_count=metrics.get("test_count", 0),
|
|
341
|
+
coverage_percent=0.0, # Will be populated from coverage data
|
|
342
|
+
last_modified=last_modified,
|
|
343
|
+
tests_last_modified=tests_last_modified,
|
|
344
|
+
last_indexed=datetime.now(),
|
|
345
|
+
staleness_days=staleness_days,
|
|
346
|
+
is_stale=is_stale,
|
|
347
|
+
lines_of_code=metrics.get("lines_of_code", 0),
|
|
348
|
+
lines_of_test=metrics.get("lines_of_test", 0),
|
|
349
|
+
complexity_score=metrics.get("complexity", 0.0),
|
|
350
|
+
has_docstrings=metrics.get("has_docstrings", False),
|
|
351
|
+
has_type_hints=metrics.get("has_type_hints", False),
|
|
352
|
+
lint_issues=0, # Will be populated from linter
|
|
353
|
+
imports=metrics.get("imports", []),
|
|
354
|
+
imported_by=[], # Populated in dependency analysis
|
|
355
|
+
import_count=len(metrics.get("imports", [])),
|
|
356
|
+
imported_by_count=0,
|
|
357
|
+
impact_score=0.0, # Calculated later
|
|
358
|
+
metadata={},
|
|
359
|
+
needs_attention=False,
|
|
360
|
+
attention_reasons=[],
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
def _determine_category(self, path: Path) -> FileCategory:
|
|
364
|
+
"""Determine the category of a file."""
|
|
365
|
+
if self._is_test_file(path):
|
|
366
|
+
return FileCategory.TEST
|
|
367
|
+
|
|
368
|
+
suffix = path.suffix.lower()
|
|
369
|
+
|
|
370
|
+
# Optimization: Use frozensets for O(1) lookup (called for every file)
|
|
371
|
+
if suffix in self.CONFIG_SUFFIXES:
|
|
372
|
+
return FileCategory.CONFIG
|
|
373
|
+
|
|
374
|
+
if suffix in self.DOC_SUFFIXES or path.name in self.DOC_NAMES:
|
|
375
|
+
return FileCategory.DOCS
|
|
376
|
+
|
|
377
|
+
if suffix in self.ASSET_SUFFIXES:
|
|
378
|
+
return FileCategory.ASSET
|
|
379
|
+
|
|
380
|
+
if suffix in self.SOURCE_SUFFIXES:
|
|
381
|
+
return FileCategory.SOURCE
|
|
382
|
+
|
|
383
|
+
return FileCategory.UNKNOWN
|
|
384
|
+
|
|
385
|
+
def _determine_language(self, path: Path) -> str:
|
|
386
|
+
"""Determine the programming language of a file."""
|
|
387
|
+
suffix_map = {
|
|
388
|
+
".py": "python",
|
|
389
|
+
".js": "javascript",
|
|
390
|
+
".ts": "typescript",
|
|
391
|
+
".tsx": "typescript",
|
|
392
|
+
".jsx": "javascript",
|
|
393
|
+
".go": "go",
|
|
394
|
+
".rs": "rust",
|
|
395
|
+
".java": "java",
|
|
396
|
+
".rb": "ruby",
|
|
397
|
+
".php": "php",
|
|
398
|
+
".cs": "csharp",
|
|
399
|
+
".cpp": "cpp",
|
|
400
|
+
".c": "c",
|
|
401
|
+
".h": "c",
|
|
402
|
+
".hpp": "cpp",
|
|
403
|
+
}
|
|
404
|
+
return suffix_map.get(path.suffix.lower(), "")
|
|
405
|
+
|
|
406
|
+
def _determine_test_requirement(self, path: Path, category: FileCategory) -> TestRequirement:
|
|
407
|
+
"""Determine if a file requires tests."""
|
|
408
|
+
rel_path = path.relative_to(self.project_root)
|
|
409
|
+
|
|
410
|
+
# Test files don't need tests
|
|
411
|
+
if category == FileCategory.TEST:
|
|
412
|
+
return TestRequirement.NOT_APPLICABLE
|
|
413
|
+
|
|
414
|
+
# Config, docs, assets don't need tests
|
|
415
|
+
if category in [FileCategory.CONFIG, FileCategory.DOCS, FileCategory.ASSET]:
|
|
416
|
+
return TestRequirement.NOT_APPLICABLE
|
|
417
|
+
|
|
418
|
+
# Check exclusion patterns using glob matching
|
|
419
|
+
for pattern in self.config.no_test_patterns:
|
|
420
|
+
if self._matches_glob_pattern(rel_path, pattern):
|
|
421
|
+
return TestRequirement.NOT_APPLICABLE
|
|
422
|
+
|
|
423
|
+
# __init__.py files usually don't need tests unless they have logic
|
|
424
|
+
if path.name == "__init__.py":
|
|
425
|
+
try:
|
|
426
|
+
content = path.read_text(encoding="utf-8", errors="ignore")
|
|
427
|
+
# If it's just imports/exports, no tests needed
|
|
428
|
+
if len(content.strip().split("\n")) < 20:
|
|
429
|
+
return TestRequirement.OPTIONAL
|
|
430
|
+
except OSError:
|
|
431
|
+
pass
|
|
432
|
+
|
|
433
|
+
return TestRequirement.REQUIRED
|
|
434
|
+
|
|
435
|
+
def _analyze_code_metrics(
|
|
436
|
+
self, path: Path, language: str, category: FileCategory = FileCategory.SOURCE
|
|
437
|
+
) -> dict[str, Any]:
|
|
438
|
+
"""Analyze code metrics for a file with caching.
|
|
439
|
+
|
|
440
|
+
Uses cached AST parsing for Python files to avoid re-parsing
|
|
441
|
+
unchanged files during incremental scans.
|
|
442
|
+
|
|
443
|
+
Optimization: Skips expensive AST analysis for test files since they
|
|
444
|
+
don't need complexity scoring (saves ~30% of AST traversal time).
|
|
445
|
+
|
|
446
|
+
Args:
|
|
447
|
+
path: Path to file to analyze
|
|
448
|
+
language: Programming language of the file
|
|
449
|
+
category: File category (SOURCE, TEST, etc.)
|
|
450
|
+
"""
|
|
451
|
+
metrics: dict[str, Any] = {
|
|
452
|
+
"lines_of_code": 0,
|
|
453
|
+
"lines_of_test": 0,
|
|
454
|
+
"complexity": 0.0,
|
|
455
|
+
"has_docstrings": False,
|
|
456
|
+
"has_type_hints": False,
|
|
457
|
+
"imports": [],
|
|
458
|
+
"test_count": 0,
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if language != "python":
|
|
462
|
+
# For now, just count lines for non-Python
|
|
463
|
+
try:
|
|
464
|
+
content = path.read_text(encoding="utf-8", errors="ignore")
|
|
465
|
+
metrics["lines_of_code"] = len(content.split("\n"))
|
|
466
|
+
except OSError:
|
|
467
|
+
pass
|
|
468
|
+
return metrics
|
|
469
|
+
|
|
470
|
+
try:
|
|
471
|
+
content = path.read_text(encoding="utf-8", errors="ignore")
|
|
472
|
+
lines = content.split("\n")
|
|
473
|
+
# Use generator expression for memory efficiency (no intermediate list)
|
|
474
|
+
metrics["lines_of_code"] = sum(
|
|
475
|
+
1 for line in lines if line.strip() and not line.strip().startswith("#")
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
# Optimization: Skip expensive AST analysis for test files
|
|
479
|
+
# Test files don't need complexity scoring, docstring/type hint checks
|
|
480
|
+
# This saves ~30% of AST traversal time (1+ seconds on large codebases)
|
|
481
|
+
if category == FileCategory.TEST:
|
|
482
|
+
# For test files, just count test functions with simple regex
|
|
483
|
+
import re
|
|
484
|
+
|
|
485
|
+
test_func_pattern = re.compile(r"^\s*def\s+test_\w+\(")
|
|
486
|
+
metrics["test_count"] = sum(
|
|
487
|
+
1 for line in lines if test_func_pattern.match(line)
|
|
488
|
+
)
|
|
489
|
+
# Mark as having test functions (for test file records)
|
|
490
|
+
if metrics["test_count"] > 0:
|
|
491
|
+
metrics["lines_of_test"] = metrics["lines_of_code"]
|
|
492
|
+
else:
|
|
493
|
+
# Use cached AST parsing for source files only
|
|
494
|
+
file_path_str = str(path)
|
|
495
|
+
file_hash = self._hash_file(file_path_str)
|
|
496
|
+
tree = self._parse_python_cached(file_path_str, file_hash)
|
|
497
|
+
|
|
498
|
+
if tree:
|
|
499
|
+
metrics.update(self._analyze_python_ast(tree))
|
|
500
|
+
|
|
501
|
+
except OSError:
|
|
502
|
+
pass
|
|
503
|
+
|
|
504
|
+
return metrics
|
|
505
|
+
|
|
506
|
+
def _analyze_python_ast(self, tree: ast.AST) -> dict[str, Any]:
|
|
507
|
+
"""Analyze Python AST for metrics.
|
|
508
|
+
|
|
509
|
+
Optimized to use single-pass traversal with NodeVisitor instead of
|
|
510
|
+
nested ast.walk() calls. Previous implementation was O(n²) due to
|
|
511
|
+
walking each function's subtree separately. This version is O(n).
|
|
512
|
+
"""
|
|
513
|
+
|
|
514
|
+
# Use inner class to maintain state during traversal
|
|
515
|
+
class MetricsVisitor(ast.NodeVisitor):
|
|
516
|
+
def __init__(self) -> None:
|
|
517
|
+
self.result: dict[str, Any] = {
|
|
518
|
+
"has_docstrings": False,
|
|
519
|
+
"has_type_hints": False,
|
|
520
|
+
"imports": [],
|
|
521
|
+
"test_count": 0,
|
|
522
|
+
"complexity": 0.0,
|
|
523
|
+
}
|
|
524
|
+
self.function_depth = 0 # Track if we're inside a function
|
|
525
|
+
|
|
526
|
+
def visit_Module(self, node: ast.Module) -> None:
|
|
527
|
+
if ast.get_docstring(node):
|
|
528
|
+
self.result["has_docstrings"] = True
|
|
529
|
+
self.generic_visit(node)
|
|
530
|
+
|
|
531
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
532
|
+
if ast.get_docstring(node):
|
|
533
|
+
self.result["has_docstrings"] = True
|
|
534
|
+
self.generic_visit(node)
|
|
535
|
+
|
|
536
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
537
|
+
self._handle_function(node)
|
|
538
|
+
|
|
539
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
540
|
+
self._handle_function(node)
|
|
541
|
+
|
|
542
|
+
def _handle_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
|
|
543
|
+
# Check for docstrings
|
|
544
|
+
if ast.get_docstring(node):
|
|
545
|
+
self.result["has_docstrings"] = True
|
|
546
|
+
|
|
547
|
+
# Check for type hints
|
|
548
|
+
if node.returns or any(arg.annotation for arg in node.args.args):
|
|
549
|
+
self.result["has_type_hints"] = True
|
|
550
|
+
|
|
551
|
+
# Count test functions
|
|
552
|
+
if node.name.startswith("test_"):
|
|
553
|
+
self.result["test_count"] += 1
|
|
554
|
+
|
|
555
|
+
# Enter function scope for complexity counting
|
|
556
|
+
self.function_depth += 1
|
|
557
|
+
self.generic_visit(node)
|
|
558
|
+
self.function_depth -= 1
|
|
559
|
+
|
|
560
|
+
def visit_If(self, node: ast.If) -> None:
|
|
561
|
+
if self.function_depth > 0:
|
|
562
|
+
self.result["complexity"] += 1.0
|
|
563
|
+
self.generic_visit(node)
|
|
564
|
+
|
|
565
|
+
def visit_For(self, node: ast.For) -> None:
|
|
566
|
+
if self.function_depth > 0:
|
|
567
|
+
self.result["complexity"] += 1.0
|
|
568
|
+
self.generic_visit(node)
|
|
569
|
+
|
|
570
|
+
def visit_While(self, node: ast.While) -> None:
|
|
571
|
+
if self.function_depth > 0:
|
|
572
|
+
self.result["complexity"] += 1.0
|
|
573
|
+
self.generic_visit(node)
|
|
574
|
+
|
|
575
|
+
def visit_Try(self, node: ast.Try) -> None:
|
|
576
|
+
if self.function_depth > 0:
|
|
577
|
+
self.result["complexity"] += 1.0
|
|
578
|
+
self.generic_visit(node)
|
|
579
|
+
|
|
580
|
+
def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
|
|
581
|
+
if self.function_depth > 0:
|
|
582
|
+
self.result["complexity"] += 1.0
|
|
583
|
+
self.generic_visit(node)
|
|
584
|
+
|
|
585
|
+
def visit_Import(self, node: ast.Import) -> None:
|
|
586
|
+
for alias in node.names:
|
|
587
|
+
self.result["imports"].append(alias.name)
|
|
588
|
+
self.generic_visit(node)
|
|
589
|
+
|
|
590
|
+
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
591
|
+
if node.module:
|
|
592
|
+
self.result["imports"].append(node.module)
|
|
593
|
+
self.generic_visit(node)
|
|
594
|
+
|
|
595
|
+
visitor = MetricsVisitor()
|
|
596
|
+
visitor.visit(tree)
|
|
597
|
+
return visitor.result
|
|
598
|
+
|
|
599
|
+
def _analyze_dependencies(self, records: list[FileRecord]) -> None:
|
|
600
|
+
"""Build dependency graph between files.
|
|
601
|
+
|
|
602
|
+
Optimized from O(n³) to O(n*m) where n=records, m=avg imports per file.
|
|
603
|
+
Uses dict lookups instead of nested loops for finding modules and records.
|
|
604
|
+
"""
|
|
605
|
+
# Build record lookup by path for O(1) access (eliminates innermost loop)
|
|
606
|
+
records_by_path: dict[str, FileRecord] = {r.path: r for r in records}
|
|
607
|
+
|
|
608
|
+
# Build multiple module indexes for flexible matching
|
|
609
|
+
# Key: module name or suffix -> Value: path
|
|
610
|
+
module_to_path: dict[str, str] = {}
|
|
611
|
+
module_suffix_to_path: dict[str, str] = {} # For "endswith" matching
|
|
612
|
+
|
|
613
|
+
for record in records:
|
|
614
|
+
if record.language == "python":
|
|
615
|
+
# Convert path to module name: src/attune/core.py -> src.attune.core
|
|
616
|
+
module_name = record.path.replace("/", ".").replace("\\", ".")
|
|
617
|
+
if module_name.endswith(".py"):
|
|
618
|
+
module_name = module_name[:-3]
|
|
619
|
+
|
|
620
|
+
module_to_path[module_name] = record.path
|
|
621
|
+
|
|
622
|
+
# Also index by module suffix parts for partial matching
|
|
623
|
+
# e.g., "attune.core" and "core" for "src.attune.core"
|
|
624
|
+
parts = module_name.split(".")
|
|
625
|
+
for i in range(len(parts)):
|
|
626
|
+
suffix = ".".join(parts[i:])
|
|
627
|
+
if suffix not in module_suffix_to_path:
|
|
628
|
+
module_suffix_to_path[suffix] = record.path
|
|
629
|
+
|
|
630
|
+
# Track which records have been updated (for imported_by deduplication)
|
|
631
|
+
imported_by_sets: dict[str, set[str]] = {r.path: set() for r in records}
|
|
632
|
+
|
|
633
|
+
# Update imported_by relationships with O(1) lookups
|
|
634
|
+
for record in records:
|
|
635
|
+
for imp in record.imports:
|
|
636
|
+
# Try exact match first
|
|
637
|
+
target_path = module_to_path.get(imp)
|
|
638
|
+
|
|
639
|
+
# Try suffix match if no exact match
|
|
640
|
+
if not target_path:
|
|
641
|
+
target_path = module_suffix_to_path.get(imp)
|
|
642
|
+
|
|
643
|
+
# Try partial suffix matching as fallback
|
|
644
|
+
if not target_path:
|
|
645
|
+
# Check if import is a suffix of any module
|
|
646
|
+
for suffix, path in module_suffix_to_path.items():
|
|
647
|
+
if suffix.endswith(imp) or imp in suffix:
|
|
648
|
+
target_path = path
|
|
649
|
+
break
|
|
650
|
+
|
|
651
|
+
if target_path and target_path in records_by_path:
|
|
652
|
+
# Use set for O(1) deduplication check
|
|
653
|
+
if record.path not in imported_by_sets[target_path]:
|
|
654
|
+
imported_by_sets[target_path].add(record.path)
|
|
655
|
+
target_record = records_by_path[target_path]
|
|
656
|
+
target_record.imported_by.append(record.path)
|
|
657
|
+
target_record.imported_by_count = len(target_record.imported_by)
|
|
658
|
+
|
|
659
|
+
def _calculate_impact_scores(self, records: list[FileRecord]) -> None:
|
|
660
|
+
"""Calculate impact score for each file."""
|
|
661
|
+
for record in records:
|
|
662
|
+
# Impact = imported_by_count * 2 + complexity * 0.5 + lines_of_code * 0.01
|
|
663
|
+
record.impact_score = (
|
|
664
|
+
record.imported_by_count * 2.0
|
|
665
|
+
+ record.complexity_score * 0.5
|
|
666
|
+
+ record.lines_of_code * 0.01
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
def _determine_attention_needs(self, records: list[FileRecord]) -> None:
|
|
670
|
+
"""Determine which files need attention."""
|
|
671
|
+
for record in records:
|
|
672
|
+
reasons = []
|
|
673
|
+
|
|
674
|
+
# Stale tests
|
|
675
|
+
if record.is_stale:
|
|
676
|
+
reasons.append(f"Tests are {record.staleness_days} days stale")
|
|
677
|
+
|
|
678
|
+
# No tests but required
|
|
679
|
+
if record.test_requirement == TestRequirement.REQUIRED and not record.tests_exist:
|
|
680
|
+
reasons.append("Missing tests")
|
|
681
|
+
|
|
682
|
+
# Low coverage (if we have coverage data)
|
|
683
|
+
if (
|
|
684
|
+
record.coverage_percent > 0
|
|
685
|
+
and record.coverage_percent < self.config.low_coverage_threshold
|
|
686
|
+
):
|
|
687
|
+
reasons.append(f"Low coverage ({record.coverage_percent:.1f}%)")
|
|
688
|
+
|
|
689
|
+
# High impact but no tests
|
|
690
|
+
if record.impact_score >= self.config.high_impact_threshold:
|
|
691
|
+
if not record.tests_exist and record.test_requirement == TestRequirement.REQUIRED:
|
|
692
|
+
reasons.append(f"High impact ({record.impact_score:.1f}) without tests")
|
|
693
|
+
|
|
694
|
+
record.attention_reasons = reasons
|
|
695
|
+
record.needs_attention = len(reasons) > 0
|
|
696
|
+
|
|
697
|
+
def _build_summary(self, records: list[FileRecord]) -> ProjectSummary:
|
|
698
|
+
"""Build project summary from records."""
|
|
699
|
+
summary = ProjectSummary()
|
|
700
|
+
|
|
701
|
+
summary.total_files = len(records)
|
|
702
|
+
summary.source_files = sum(1 for r in records if r.category == FileCategory.SOURCE)
|
|
703
|
+
summary.test_files = sum(1 for r in records if r.category == FileCategory.TEST)
|
|
704
|
+
summary.config_files = sum(1 for r in records if r.category == FileCategory.CONFIG)
|
|
705
|
+
summary.doc_files = sum(1 for r in records if r.category == FileCategory.DOCS)
|
|
706
|
+
|
|
707
|
+
# Testing health
|
|
708
|
+
requiring_tests = [r for r in records if r.test_requirement == TestRequirement.REQUIRED]
|
|
709
|
+
summary.files_requiring_tests = len(requiring_tests)
|
|
710
|
+
summary.files_with_tests = sum(1 for r in requiring_tests if r.tests_exist)
|
|
711
|
+
summary.files_without_tests = summary.files_requiring_tests - summary.files_with_tests
|
|
712
|
+
summary.total_test_count = sum(
|
|
713
|
+
r.test_count for r in records if r.category == FileCategory.TEST
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
# Coverage average
|
|
717
|
+
covered = [r for r in records if r.coverage_percent > 0]
|
|
718
|
+
if covered:
|
|
719
|
+
summary.test_coverage_avg = sum(r.coverage_percent for r in covered) / len(covered)
|
|
720
|
+
|
|
721
|
+
# Staleness
|
|
722
|
+
stale = [r for r in records if r.is_stale]
|
|
723
|
+
summary.stale_file_count = len(stale)
|
|
724
|
+
if stale:
|
|
725
|
+
summary.avg_staleness_days = sum(r.staleness_days for r in stale) / len(stale)
|
|
726
|
+
top_stale = heapq.nlargest(5, stale, key=lambda r: r.staleness_days)
|
|
727
|
+
summary.most_stale_files = [r.path for r in top_stale]
|
|
728
|
+
|
|
729
|
+
# Code metrics
|
|
730
|
+
source_records = [r for r in records if r.category == FileCategory.SOURCE]
|
|
731
|
+
summary.total_lines_of_code = sum(r.lines_of_code for r in source_records)
|
|
732
|
+
summary.total_lines_of_test = sum(
|
|
733
|
+
r.lines_of_code for r in records if r.category == FileCategory.TEST
|
|
734
|
+
)
|
|
735
|
+
if summary.total_lines_of_code > 0:
|
|
736
|
+
summary.test_to_code_ratio = summary.total_lines_of_test / summary.total_lines_of_code
|
|
737
|
+
if source_records:
|
|
738
|
+
summary.avg_complexity = sum(r.complexity_score for r in source_records) / len(
|
|
739
|
+
source_records,
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
# Quality
|
|
743
|
+
if source_records:
|
|
744
|
+
summary.files_with_docstrings_pct = (
|
|
745
|
+
sum(1 for r in source_records if r.has_docstrings) / len(source_records) * 100
|
|
746
|
+
)
|
|
747
|
+
summary.files_with_type_hints_pct = (
|
|
748
|
+
sum(1 for r in source_records if r.has_type_hints) / len(source_records) * 100
|
|
749
|
+
)
|
|
750
|
+
summary.total_lint_issues = sum(r.lint_issues for r in records)
|
|
751
|
+
|
|
752
|
+
# High impact files
|
|
753
|
+
high_impact = heapq.nlargest(10, records, key=lambda r: r.impact_score)
|
|
754
|
+
summary.high_impact_files = [
|
|
755
|
+
r.path for r in high_impact if r.impact_score >= self.config.high_impact_threshold
|
|
756
|
+
]
|
|
757
|
+
|
|
758
|
+
# Critical untested files (high impact + no tests)
|
|
759
|
+
critical = [
|
|
760
|
+
r
|
|
761
|
+
for r in records
|
|
762
|
+
if r.impact_score >= self.config.high_impact_threshold
|
|
763
|
+
and not r.tests_exist
|
|
764
|
+
and r.test_requirement == TestRequirement.REQUIRED
|
|
765
|
+
]
|
|
766
|
+
summary.critical_untested_files = [
|
|
767
|
+
r.path for r in heapq.nlargest(10, critical, key=lambda r: r.impact_score)
|
|
768
|
+
]
|
|
769
|
+
|
|
770
|
+
# Attention needed
|
|
771
|
+
needing_attention = [r for r in records if r.needs_attention]
|
|
772
|
+
summary.files_needing_attention = len(needing_attention)
|
|
773
|
+
summary.top_attention_files = [
|
|
774
|
+
r.path for r in heapq.nlargest(10, needing_attention, key=lambda r: r.impact_score)
|
|
775
|
+
]
|
|
776
|
+
|
|
777
|
+
return summary
|