attocode-code-intel 0.1.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.
- attocode_code_intel-0.1.0.dist-info/METADATA +221 -0
- attocode_code_intel-0.1.0.dist-info/RECORD +345 -0
- attocode_code_intel-0.1.0.dist-info/WHEEL +4 -0
- attocode_code_intel-0.1.0.dist-info/entry_points.txt +2 -0
- attocode_code_intel-0.1.0.dist-info/licenses/LICENSE +21 -0
- attocode_intel/GUIDELINES.md +432 -0
- attocode_intel/__init__.py +52 -0
- attocode_intel/_internal/__init__.py +1 -0
- attocode_intel/_internal/errors/__init__.py +126 -0
- attocode_intel/_internal/integrations/__init__.py +1 -0
- attocode_intel/_internal/integrations/context/__init__.py +1 -0
- attocode_intel/_internal/integrations/context/architecture_drift.py +354 -0
- attocode_intel/_internal/integrations/context/ast_chunker.py +202 -0
- attocode_intel/_internal/integrations/context/ast_service.py +1483 -0
- attocode_intel/_internal/integrations/context/blob_oid.py +423 -0
- attocode_intel/_internal/integrations/context/cache_manifest.py +221 -0
- attocode_intel/_internal/integrations/context/cas.py +466 -0
- attocode_intel/_internal/integrations/context/code_analyzer.py +528 -0
- attocode_intel/_internal/integrations/context/codebase_ast.py +1461 -0
- attocode_intel/_internal/integrations/context/codebase_context.py +1736 -0
- attocode_intel/_internal/integrations/context/cross_mode.py +263 -0
- attocode_intel/_internal/integrations/context/cross_references.py +616 -0
- attocode_intel/_internal/integrations/context/embedding_rotation.py +597 -0
- attocode_intel/_internal/integrations/context/embeddings.py +282 -0
- attocode_intel/_internal/integrations/context/frecency.py +427 -0
- attocode_intel/_internal/integrations/context/fuzzy.py +320 -0
- attocode_intel/_internal/integrations/context/hierarchical_explorer.py +331 -0
- attocode_intel/_internal/integrations/context/hydration.py +90 -0
- attocode_intel/_internal/integrations/context/index_repair.py +87 -0
- attocode_intel/_internal/integrations/context/index_store.py +586 -0
- attocode_intel/_internal/integrations/context/memory_store.py +602 -0
- attocode_intel/_internal/integrations/context/module_resolution.py +52 -0
- attocode_intel/_internal/integrations/context/query_constraints.py +361 -0
- attocode_intel/_internal/integrations/context/query_history.py +417 -0
- attocode_intel/_internal/integrations/context/reranker.py +351 -0
- attocode_intel/_internal/integrations/context/semantic_search.py +2931 -0
- attocode_intel/_internal/integrations/context/syntax_evidence.py +94 -0
- attocode_intel/_internal/integrations/context/systemone_ranker.py +245 -0
- attocode_intel/_internal/integrations/context/temporal_coupling.py +411 -0
- attocode_intel/_internal/integrations/context/trigram_index.py +820 -0
- attocode_intel/_internal/integrations/context/trigram_regex.py +171 -0
- attocode_intel/_internal/integrations/context/ts_parser.py +2199 -0
- attocode_intel/_internal/integrations/context/vector_store.py +795 -0
- attocode_intel/_internal/integrations/feature_flags/__init__.py +357 -0
- attocode_intel/_internal/integrations/lsp/__init__.py +1 -0
- attocode_intel/_internal/integrations/lsp/client.py +1140 -0
- attocode_intel/_internal/integrations/mcp/__init__.py +1 -0
- attocode_intel/_internal/integrations/mcp/client.py +253 -0
- attocode_intel/_internal/integrations/security/__init__.py +1 -0
- attocode_intel/_internal/integrations/security/custom_rules.py +176 -0
- attocode_intel/_internal/integrations/security/dataflow.py +522 -0
- attocode_intel/_internal/integrations/security/dependency_audit.py +303 -0
- attocode_intel/_internal/integrations/security/matcher.py +44 -0
- attocode_intel/_internal/integrations/security/patterns.py +724 -0
- attocode_intel/_internal/integrations/security/scanner.py +351 -0
- attocode_intel/_internal/integrations/utilities/__init__.py +1 -0
- attocode_intel/_internal/integrations/utilities/ignore.py +64 -0
- attocode_intel/_internal/integrations/utilities/token_estimate.py +51 -0
- attocode_intel/_internal/tricks/__init__.py +1 -0
- attocode_intel/_internal/tricks/recursive_context.py +357 -0
- attocode_intel/_internal/types/__init__.py +1 -0
- attocode_intel/_internal/types/events.py +289 -0
- attocode_intel/_internal/types/severity.py +15 -0
- attocode_intel/_shared.py +341 -0
- attocode_intel/analysis_coverage.py +56 -0
- attocode_intel/api/__init__.py +1 -0
- attocode_intel/api/access.py +32 -0
- attocode_intel/api/app.py +247 -0
- attocode_intel/api/auth/__init__.py +128 -0
- attocode_intel/api/auth/api_keys.py +61 -0
- attocode_intel/api/auth/context.py +34 -0
- attocode_intel/api/auth/jwt.py +66 -0
- attocode_intel/api/auth/oauth.py +146 -0
- attocode_intel/api/auth/passwords.py +15 -0
- attocode_intel/api/deps.py +351 -0
- attocode_intel/api/knowledge_bridge.py +13 -0
- attocode_intel/api/mcp_transport.py +51 -0
- attocode_intel/api/middleware.py +411 -0
- attocode_intel/api/models.py +698 -0
- attocode_intel/api/providers/__init__.py +1 -0
- attocode_intel/api/providers/db_provider.py +1106 -0
- attocode_intel/api/providers/local_provider.py +476 -0
- attocode_intel/api/providers/remote_provider.py +740 -0
- attocode_intel/api/repository_source.py +21 -0
- attocode_intel/api/routes/__init__.py +1 -0
- attocode_intel/api/routes/_pin_helper.py +158 -0
- attocode_intel/api/routes/activity.py +148 -0
- attocode_intel/api/routes/adr.py +102 -0
- attocode_intel/api/routes/analysis.py +515 -0
- attocode_intel/api/routes/api_keys.py +170 -0
- attocode_intel/api/routes/auth.py +428 -0
- attocode_intel/api/routes/branches.py +262 -0
- attocode_intel/api/routes/cross_repo_search.py +198 -0
- attocode_intel/api/routes/embeddings.py +386 -0
- attocode_intel/api/routes/files.py +132 -0
- attocode_intel/api/routes/files_v2.py +388 -0
- attocode_intel/api/routes/gc.py +322 -0
- attocode_intel/api/routes/git_v2.py +433 -0
- attocode_intel/api/routes/graph.py +174 -0
- attocode_intel/api/routes/graph_viz.py +160 -0
- attocode_intel/api/routes/health.py +62 -0
- attocode_intel/api/routes/history.py +175 -0
- attocode_intel/api/routes/jobs.py +84 -0
- attocode_intel/api/routes/learning.py +205 -0
- attocode_intel/api/routes/lsp.py +272 -0
- attocode_intel/api/routes/notify.py +623 -0
- attocode_intel/api/routes/operations.py +27 -0
- attocode_intel/api/routes/orgs.py +924 -0
- attocode_intel/api/routes/preferences.py +100 -0
- attocode_intel/api/routes/presence.py +42 -0
- attocode_intel/api/routes/projects.py +116 -0
- attocode_intel/api/routes/repos.py +47 -0
- attocode_intel/api/routes/search.py +180 -0
- attocode_intel/api/routes/snapshots.py +614 -0
- attocode_intel/api/routes/webhooks.py +313 -0
- attocode_intel/api/routes/websocket.py +116 -0
- attocode_intel/api/static/graph.html +239 -0
- attocode_intel/api/utils.py +96 -0
- attocode_intel/artifacts/__init__.py +45 -0
- attocode_intel/artifacts/hashing.py +230 -0
- attocode_intel/artifacts/provenance.py +190 -0
- attocode_intel/artifacts/retrieval_pin.py +159 -0
- attocode_intel/audit.py +59 -0
- attocode_intel/bug_finder.py +206 -0
- attocode_intel/bundle.py +80 -0
- attocode_intel/catalog.py +242 -0
- attocode_intel/change_review.py +75 -0
- attocode_intel/cli.py +3062 -0
- attocode_intel/community.py +63 -0
- attocode_intel/confidence/__init__.py +144 -0
- attocode_intel/confidence/jev.py +234 -0
- attocode_intel/confidence/llm.py +330 -0
- attocode_intel/confidence/redact.py +44 -0
- attocode_intel/confidence/settings.py +74 -0
- attocode_intel/config.py +256 -0
- attocode_intel/crypto.py +31 -0
- attocode_intel/db/__init__.py +7 -0
- attocode_intel/db/base.py +33 -0
- attocode_intel/db/engine.py +61 -0
- attocode_intel/db/models.py +529 -0
- attocode_intel/entrypoint.py +185 -0
- attocode_intel/focused_evidence.py +130 -0
- attocode_intel/freshness.py +154 -0
- attocode_intel/gateway.py +734 -0
- attocode_intel/git/__init__.py +7 -0
- attocode_intel/git/credentials.py +75 -0
- attocode_intel/git/manager.py +493 -0
- attocode_intel/git/models.py +96 -0
- attocode_intel/git/storage.py +98 -0
- attocode_intel/graph_query_parser.py +761 -0
- attocode_intel/helpers.py +712 -0
- attocode_intel/indexing/__init__.py +29 -0
- attocode_intel/indexing/debouncer.py +136 -0
- attocode_intel/indexing/delta_indexer.py +151 -0
- attocode_intel/indexing/full_indexer.py +323 -0
- attocode_intel/indexing/incremental.py +240 -0
- attocode_intel/indexing/parser.py +403 -0
- attocode_intel/indexing/summarizer.py +264 -0
- attocode_intel/installer.py +1896 -0
- attocode_intel/knowledge.py +269 -0
- attocode_intel/local_knowledge.py +77 -0
- attocode_intel/migrations/alembic.ini +36 -0
- attocode_intel/migrations/env.py +71 -0
- attocode_intel/migrations/script.py.mako +27 -0
- attocode_intel/migrations/versions/001_initial_schema.py +219 -0
- attocode_intel/migrations/versions/002_branch_version_and_embeddings.py +61 -0
- attocode_intel/migrations/versions/003_embedding_uniqueness_and_cascade.py +51 -0
- attocode_intel/migrations/versions/004_audit_events.py +38 -0
- attocode_intel/migrations/versions/005_user_preferences.py +30 -0
- attocode_intel/migrations/versions/006_google_oauth.py +27 -0
- attocode_intel/migrations/versions/007_pgvector_embeddings.py +28 -0
- attocode_intel/migrations/versions/008_symbol_references.py +37 -0
- attocode_intel/migrations/versions/009_webhook_secret_encryption.py +25 -0
- attocode_intel/migrations/versions/010_add_commits_table.py +40 -0
- attocode_intel/migrations/versions/011_commit_changed_files.py +25 -0
- attocode_intel/migrations/versions/012_branch_merged_at.py +25 -0
- attocode_intel/migrations/versions/013_revoked_tokens.py +32 -0
- attocode_intel/migrations/versions/014_blame_hunks.py +40 -0
- attocode_intel/migrations/versions/015_commit_file_stats.py +40 -0
- attocode_intel/migrations/versions/016_embedding_provenance_columns.py +179 -0
- attocode_intel/migrations/versions/017_provenance_table.py +102 -0
- attocode_intel/migrations/versions/018_repo_snapshots.py +178 -0
- attocode_intel/migrations/versions/019_symref_caller.py +42 -0
- attocode_intel/migrations/versions/020_shared_knowledge.py +29 -0
- attocode_intel/onboarding.py +258 -0
- attocode_intel/output.py +178 -0
- attocode_intel/precision.py +133 -0
- attocode_intel/presence.py +160 -0
- attocode_intel/probe.py +110 -0
- attocode_intel/project_dir.py +97 -0
- attocode_intel/pubsub.py +113 -0
- attocode_intel/query_ranking.py +242 -0
- attocode_intel/readiness.py +972 -0
- attocode_intel/redis.py +51 -0
- attocode_intel/reference_pages.py +60 -0
- attocode_intel/remote.py +131 -0
- attocode_intel/repo_ranker.py +246 -0
- attocode_intel/request_context.py +82 -0
- attocode_intel/rules/__init__.py +28 -0
- attocode_intel/rules/ci.py +320 -0
- attocode_intel/rules/combinators.py +293 -0
- attocode_intel/rules/enricher.py +127 -0
- attocode_intel/rules/evolution.py +452 -0
- attocode_intel/rules/executor.py +531 -0
- attocode_intel/rules/filters/__init__.py +5 -0
- attocode_intel/rules/filters/pipeline.py +151 -0
- attocode_intel/rules/formatter.py +198 -0
- attocode_intel/rules/hygiene.py +242 -0
- attocode_intel/rules/importers/__init__.py +1 -0
- attocode_intel/rules/importers/semgrep.py +382 -0
- attocode_intel/rules/loader.py +292 -0
- attocode_intel/rules/marketplace.py +375 -0
- attocode_intel/rules/metavar.py +181 -0
- attocode_intel/rules/model.py +317 -0
- attocode_intel/rules/packs/__init__.py +1 -0
- attocode_intel/rules/packs/community/_license_templates/Apache-2.0.txt +176 -0
- attocode_intel/rules/packs/community/_license_templates/MIT.txt +21 -0
- attocode_intel/rules/packs/community/bandit-python/LICENSE +176 -0
- attocode_intel/rules/packs/community/bandit-python/NOTICE +13 -0
- attocode_intel/rules/packs/community/bandit-python/PORTING.md +23 -0
- attocode_intel/rules/packs/community/bandit-python/manifest.yaml +14 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B105-hardcoded-password.yaml +23 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B303-weak-md5.yaml +21 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B304-weak-des.yaml +20 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B307-use-of-eval.yaml +22 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B324-weak-sha1.yaml +21 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B501-no-cert-validation.yaml +23 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B602-shell-true.yaml +22 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B608-sqli.yaml +22 -0
- attocode_intel/rules/packs/community/eslint-typescript/LICENSE +21 -0
- attocode_intel/rules/packs/community/eslint-typescript/NOTICE +13 -0
- attocode_intel/rules/packs/community/eslint-typescript/PORTING.md +23 -0
- attocode_intel/rules/packs/community/eslint-typescript/manifest.yaml +14 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/eqeqeq.yaml +20 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-eval.yaml +22 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-implied-eval.yaml +20 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-new-func.yaml +21 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-throw-literal.yaml +19 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-var.yaml +20 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-with.yaml +18 -0
- attocode_intel/rules/packs/community/gosec-go/LICENSE +176 -0
- attocode_intel/rules/packs/community/gosec-go/NOTICE +13 -0
- attocode_intel/rules/packs/community/gosec-go/PORTING.md +23 -0
- attocode_intel/rules/packs/community/gosec-go/manifest.yaml +14 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G101-hardcoded-credentials.yaml +22 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G102-bind-all-interfaces.yaml +21 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G103-unsafe-block.yaml +21 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G201-sql-format.yaml +21 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G304-file-inclusion.yaml +23 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G401-weak-cipher.yaml +20 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G402-tls-min-version.yaml +20 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G501-md5-import.yaml +21 -0
- attocode_intel/rules/packs/examples/cpp/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/cpp/rules/security.yaml +90 -0
- attocode_intel/rules/packs/examples/cpp/rules/structural.yaml +42 -0
- attocode_intel/rules/packs/examples/csharp/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/csharp/rules/security.yaml +69 -0
- attocode_intel/rules/packs/examples/csharp/rules/structural.yaml +42 -0
- attocode_intel/rules/packs/examples/go/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/go/rules/performance.yaml +136 -0
- attocode_intel/rules/packs/examples/go/rules/security.yaml +48 -0
- attocode_intel/rules/packs/examples/go/rules/structural.yaml +58 -0
- attocode_intel/rules/packs/examples/go/taint/sanitizers.yaml +24 -0
- attocode_intel/rules/packs/examples/go/taint/sinks.yaml +51 -0
- attocode_intel/rules/packs/examples/go/taint/sources.yaml +26 -0
- attocode_intel/rules/packs/examples/java/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/java/rules/correctness.yaml +55 -0
- attocode_intel/rules/packs/examples/java/rules/structural.yaml +48 -0
- attocode_intel/rules/packs/examples/java/taint/sinks.yaml +37 -0
- attocode_intel/rules/packs/examples/java/taint/sources.yaml +21 -0
- attocode_intel/rules/packs/examples/kotlin/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/kotlin/rules/correctness.yaml +50 -0
- attocode_intel/rules/packs/examples/kotlin/rules/structural.yaml +38 -0
- attocode_intel/rules/packs/examples/php/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/php/rules/security.yaml +66 -0
- attocode_intel/rules/packs/examples/php/rules/structural.yaml +41 -0
- attocode_intel/rules/packs/examples/python/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/python/rules/performance.yaml +90 -0
- attocode_intel/rules/packs/examples/python/rules/structural.yaml +55 -0
- attocode_intel/rules/packs/examples/ruby/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/ruby/rules/security.yaml +73 -0
- attocode_intel/rules/packs/examples/ruby/rules/structural.yaml +41 -0
- attocode_intel/rules/packs/examples/rust/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/rust/rules/correctness.yaml +51 -0
- attocode_intel/rules/packs/examples/rust/rules/structural.yaml +49 -0
- attocode_intel/rules/packs/examples/typescript/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/typescript/rules/performance.yaml +70 -0
- attocode_intel/rules/packs/examples/typescript/rules/structural.yaml +48 -0
- attocode_intel/rules/packs/pack_loader.py +244 -0
- attocode_intel/rules/plugins/__init__.py +1 -0
- attocode_intel/rules/plugins/plugin_loader.py +132 -0
- attocode_intel/rules/profiling.py +332 -0
- attocode_intel/rules/registry.py +213 -0
- attocode_intel/rules/sarif.py +187 -0
- attocode_intel/rules/synthesis.py +420 -0
- attocode_intel/rules/testing.py +326 -0
- attocode_intel/server.py +734 -0
- attocode_intel/service.py +3416 -0
- attocode_intel/sharing.py +57 -0
- attocode_intel/storage/__init__.py +6 -0
- attocode_intel/storage/branch_overlay.py +352 -0
- attocode_intel/storage/content_store.py +220 -0
- attocode_intel/storage/dependency_store.py +143 -0
- attocode_intel/storage/diff_engine.py +187 -0
- attocode_intel/storage/embedding_store.py +594 -0
- attocode_intel/storage/provenance_store.py +137 -0
- attocode_intel/storage/security_scanner_db.py +164 -0
- attocode_intel/storage/symbol_store.py +160 -0
- attocode_intel/structural_evidence.py +144 -0
- attocode_intel/symbol_inspection.py +141 -0
- attocode_intel/symbol_links.py +167 -0
- attocode_intel/telemetry.py +42 -0
- attocode_intel/test_ranking.py +73 -0
- attocode_intel/testing/__init__.py +43 -0
- attocode_intel/testing/fixtures.py +122 -0
- attocode_intel/testing/helpers.py +75 -0
- attocode_intel/testing/mocks.py +99 -0
- attocode_intel/tools/__init__.py +2 -0
- attocode_intel/tools/adr_tools.py +628 -0
- attocode_intel/tools/analysis_tools.py +382 -0
- attocode_intel/tools/composite_tools.py +695 -0
- attocode_intel/tools/cross_mode_tools.py +173 -0
- attocode_intel/tools/dead_code_tools.py +582 -0
- attocode_intel/tools/distill_tools.py +353 -0
- attocode_intel/tools/frecency_tools.py +220 -0
- attocode_intel/tools/fuzzy_tools.py +255 -0
- attocode_intel/tools/history_tools.py +486 -0
- attocode_intel/tools/learning_tools.py +222 -0
- attocode_intel/tools/lsp_tools.py +218 -0
- attocode_intel/tools/maintenance_tools.py +1662 -0
- attocode_intel/tools/navigation_tools.py +220 -0
- attocode_intel/tools/overlay_tools.py +540 -0
- attocode_intel/tools/pin_store.py +432 -0
- attocode_intel/tools/pin_tools.py +266 -0
- attocode_intel/tools/query_constraints_tools.py +262 -0
- attocode_intel/tools/query_history_tools.py +158 -0
- attocode_intel/tools/readiness_tools.py +44 -0
- attocode_intel/tools/rule_tools.py +992 -0
- attocode_intel/tools/search_tools.py +770 -0
- attocode_intel/tools/snapshot_tools.py +624 -0
- attocode_intel/workers/__init__.py +1 -0
- attocode_intel/workers/job_utils.py +111 -0
- attocode_intel/workers/jobs.py +573 -0
- attocode_intel/workers/run.py +30 -0
- attocode_intel/workers/settings.py +61 -0
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
"""Composite tools for the code-intel MCP server.
|
|
2
|
+
|
|
3
|
+
Tools: review_change, explain_impact, suggest_tests.
|
|
4
|
+
|
|
5
|
+
These tools combine multiple analysis passes into single, agent-optimized
|
|
6
|
+
calls that reduce round-trips and produce richer context than calling
|
|
7
|
+
individual tools sequentially.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
|
|
16
|
+
from attocode_intel._shared import (
|
|
17
|
+
_get_context_mgr,
|
|
18
|
+
_get_project_dir,
|
|
19
|
+
_get_service,
|
|
20
|
+
mcp,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Helpers
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _git_modified_files(project_dir: str) -> list[str]:
|
|
32
|
+
"""Return list of git-modified file paths (relative to project root)."""
|
|
33
|
+
try:
|
|
34
|
+
result = subprocess.run(
|
|
35
|
+
["git", "diff", "--name-only", "HEAD"],
|
|
36
|
+
cwd=project_dir,
|
|
37
|
+
capture_output=True,
|
|
38
|
+
text=True,
|
|
39
|
+
timeout=15,
|
|
40
|
+
)
|
|
41
|
+
files = [f.strip() for f in result.stdout.splitlines() if f.strip()]
|
|
42
|
+
if not files:
|
|
43
|
+
# Also check staged files
|
|
44
|
+
result = subprocess.run(
|
|
45
|
+
["git", "diff", "--name-only", "--cached"],
|
|
46
|
+
cwd=project_dir,
|
|
47
|
+
capture_output=True,
|
|
48
|
+
text=True,
|
|
49
|
+
timeout=15,
|
|
50
|
+
)
|
|
51
|
+
files = [f.strip() for f in result.stdout.splitlines() if f.strip()]
|
|
52
|
+
return files
|
|
53
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as exc:
|
|
54
|
+
logger.debug("Failed to get git-modified files: %s", exc)
|
|
55
|
+
return []
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _find_test_files_by_convention(file_path: str, project_dir: str) -> list[str]:
|
|
59
|
+
"""Find test files matching naming conventions for a source file.
|
|
60
|
+
|
|
61
|
+
Checks for: test_X.py, X_test.py, __tests__/X.test.ts, X.spec.ts, etc.
|
|
62
|
+
"""
|
|
63
|
+
from pathlib import Path
|
|
64
|
+
|
|
65
|
+
basename = Path(file_path).stem
|
|
66
|
+
ext = Path(file_path).suffix
|
|
67
|
+
parent = str(Path(file_path).parent)
|
|
68
|
+
|
|
69
|
+
candidates = []
|
|
70
|
+
|
|
71
|
+
if ext in (".py",):
|
|
72
|
+
# Python conventions: test_X.py, X_test.py
|
|
73
|
+
candidates.extend([
|
|
74
|
+
os.path.join(parent, f"test_{basename}.py"),
|
|
75
|
+
os.path.join(parent, f"{basename}_test.py"),
|
|
76
|
+
# tests/ mirror directory
|
|
77
|
+
file_path.replace("src/", "tests/", 1).replace(
|
|
78
|
+
f"{basename}.py", f"test_{basename}.py"
|
|
79
|
+
),
|
|
80
|
+
# tests/unit/ mirror
|
|
81
|
+
"tests/unit/" + file_path.removeprefix("src/").replace(
|
|
82
|
+
f"{basename}.py", f"test_{basename}.py"
|
|
83
|
+
),
|
|
84
|
+
# tests/test_X.py flat layout
|
|
85
|
+
f"tests/test_{basename}.py",
|
|
86
|
+
])
|
|
87
|
+
elif ext in (".ts", ".tsx", ".js", ".jsx"):
|
|
88
|
+
# JS/TS conventions
|
|
89
|
+
test_ext = ".test" + ext
|
|
90
|
+
spec_ext = ".spec" + ext
|
|
91
|
+
candidates.extend([
|
|
92
|
+
os.path.join(parent, f"{basename}{test_ext}"),
|
|
93
|
+
os.path.join(parent, f"{basename}{spec_ext}"),
|
|
94
|
+
os.path.join(parent, "__tests__", f"{basename}{test_ext}"),
|
|
95
|
+
os.path.join(parent, "__tests__", f"{basename}{spec_ext}"),
|
|
96
|
+
])
|
|
97
|
+
elif ext in (".go",):
|
|
98
|
+
# Go convention: X_test.go in same directory
|
|
99
|
+
candidates.append(os.path.join(parent, f"{basename}_test.go"))
|
|
100
|
+
elif ext in (".rs",):
|
|
101
|
+
# Rust: tests/X.rs or inline #[cfg(test)] (can't find inline from path)
|
|
102
|
+
candidates.append(os.path.join(parent, "tests", f"{basename}.rs"))
|
|
103
|
+
elif ext in (".java", ".kt"):
|
|
104
|
+
# Java/Kotlin: mirror src/main -> src/test
|
|
105
|
+
candidates.append(
|
|
106
|
+
file_path.replace("src/main/", "src/test/", 1).replace(
|
|
107
|
+
f"{basename}{ext}", f"{basename}Test{ext}"
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Deduplicate and filter to existing files
|
|
112
|
+
seen: set[str] = set()
|
|
113
|
+
existing: list[str] = []
|
|
114
|
+
for c in candidates:
|
|
115
|
+
c = os.path.normpath(c)
|
|
116
|
+
if c in seen:
|
|
117
|
+
continue
|
|
118
|
+
seen.add(c)
|
|
119
|
+
full = os.path.join(project_dir, c)
|
|
120
|
+
if os.path.isfile(full):
|
|
121
|
+
existing.append(c)
|
|
122
|
+
|
|
123
|
+
return existing
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
# Tools
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@mcp.tool()
|
|
132
|
+
def review_change(
|
|
133
|
+
files: list[str] | None = None,
|
|
134
|
+
mode: str = "full",
|
|
135
|
+
) -> str:
|
|
136
|
+
"""Review changed files for security issues, rule violations and convention problems in one report.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
files: Files to review (default: the files that git reports as changed).
|
|
140
|
+
mode: "quick" (security only) or "full".
|
|
141
|
+
"""
|
|
142
|
+
project_dir = _get_project_dir()
|
|
143
|
+
|
|
144
|
+
# Resolve files to review
|
|
145
|
+
if files is None:
|
|
146
|
+
files = _git_modified_files(project_dir)
|
|
147
|
+
if not files:
|
|
148
|
+
return "No files to review. Provide file paths or ensure git has modified files."
|
|
149
|
+
|
|
150
|
+
if mode not in ("quick", "full"):
|
|
151
|
+
return f"Error: Invalid mode '{mode}'. Use 'quick' or 'full'."
|
|
152
|
+
|
|
153
|
+
svc = _get_service()
|
|
154
|
+
report_sections: list[str] = []
|
|
155
|
+
total_findings = 0
|
|
156
|
+
|
|
157
|
+
# --- Security scan ---
|
|
158
|
+
security_text = ""
|
|
159
|
+
try:
|
|
160
|
+
# Scan each file's directory to scope the results
|
|
161
|
+
scanned_paths: set[str] = set()
|
|
162
|
+
for f in files:
|
|
163
|
+
scan_path = os.path.dirname(f) or ""
|
|
164
|
+
if scan_path not in scanned_paths:
|
|
165
|
+
scanned_paths.add(scan_path)
|
|
166
|
+
|
|
167
|
+
# Run a single security scan (scoped to project)
|
|
168
|
+
security_text = svc.security_scan(mode="full", path="")
|
|
169
|
+
except Exception as exc:
|
|
170
|
+
security_text = f"Security scan error: {exc}"
|
|
171
|
+
|
|
172
|
+
if security_text:
|
|
173
|
+
# Filter findings to only mention changed files
|
|
174
|
+
relevant_lines: list[str] = []
|
|
175
|
+
for line in security_text.splitlines():
|
|
176
|
+
# Include header/summary lines and lines mentioning changed files
|
|
177
|
+
if any(f in line for f in files) or not line.startswith(" "):
|
|
178
|
+
relevant_lines.append(line)
|
|
179
|
+
filtered_security = "\n".join(relevant_lines) if relevant_lines else security_text
|
|
180
|
+
|
|
181
|
+
# Count findings (lines with severity indicators)
|
|
182
|
+
finding_markers = ("HIGH", "MEDIUM", "LOW", "CRITICAL", "WARNING")
|
|
183
|
+
sec_findings = sum(
|
|
184
|
+
1 for line in filtered_security.splitlines()
|
|
185
|
+
if any(m in line.upper() for m in finding_markers)
|
|
186
|
+
)
|
|
187
|
+
total_findings += sec_findings
|
|
188
|
+
report_sections.append(f"## Security ({sec_findings} finding(s))\n\n{filtered_security}")
|
|
189
|
+
|
|
190
|
+
# --- Rule-based analysis (full mode only) ---
|
|
191
|
+
if mode == "full":
|
|
192
|
+
rules_text = ""
|
|
193
|
+
try:
|
|
194
|
+
rules_text = svc.analyze(
|
|
195
|
+
files=files, min_confidence=0.5, max_findings=20,
|
|
196
|
+
)
|
|
197
|
+
except Exception as exc:
|
|
198
|
+
rules_text = f"Rule analysis error: {exc}"
|
|
199
|
+
|
|
200
|
+
if rules_text and "No findings" not in rules_text:
|
|
201
|
+
rule_findings = sum(
|
|
202
|
+
1 for line in rules_text.splitlines()
|
|
203
|
+
if any(m in line for m in ("[CRITICAL]", "[HIGH]", "[MEDIUM]", "[LOW]", "[INFO]"))
|
|
204
|
+
)
|
|
205
|
+
total_findings += rule_findings
|
|
206
|
+
report_sections.append(f"## Rule Analysis ({rule_findings} finding(s))\n\n{rules_text}")
|
|
207
|
+
|
|
208
|
+
# --- Conventions check (full mode only) ---
|
|
209
|
+
if mode == "full":
|
|
210
|
+
conventions_text = ""
|
|
211
|
+
try:
|
|
212
|
+
conventions_text = svc.conventions(sample_size=25, path="")
|
|
213
|
+
except Exception as exc:
|
|
214
|
+
conventions_text = f"Conventions check error: {exc}"
|
|
215
|
+
|
|
216
|
+
if conventions_text:
|
|
217
|
+
report_sections.append(f"## Conventions\n\n{conventions_text}")
|
|
218
|
+
|
|
219
|
+
# --- Build report ---
|
|
220
|
+
file_list = "\n".join(f" - {f}" for f in files)
|
|
221
|
+
header = (
|
|
222
|
+
f"# Change Review Report ({mode} mode)\n\n"
|
|
223
|
+
f"Files reviewed ({len(files)}):\n{file_list}\n"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
body = "\n\n".join(report_sections) if report_sections else "No findings."
|
|
227
|
+
|
|
228
|
+
# Analysis failures must not be presented as successful verification.
|
|
229
|
+
incomplete = any("error:" in section.lower() for section in report_sections)
|
|
230
|
+
# Assessment
|
|
231
|
+
if incomplete:
|
|
232
|
+
assessment = "Review incomplete: one or more analysis passes failed. Resolve the reported errors before relying on this review."
|
|
233
|
+
elif total_findings == 0:
|
|
234
|
+
assessment = "No findings were reported by the completed checks. This does not establish that the change is safe."
|
|
235
|
+
elif total_findings <= 3:
|
|
236
|
+
assessment = f"{total_findings} finding(s) detected. Review recommended before merging."
|
|
237
|
+
else:
|
|
238
|
+
assessment = (
|
|
239
|
+
f"{total_findings} finding(s) detected. "
|
|
240
|
+
"Careful review required -- multiple issues found."
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
summary = f"\n\n## Summary\n\n{assessment}"
|
|
244
|
+
|
|
245
|
+
return header + "\n" + body + summary
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@mcp.tool()
|
|
249
|
+
def explain_impact(
|
|
250
|
+
files: list[str],
|
|
251
|
+
depth: int = 3,
|
|
252
|
+
) -> str:
|
|
253
|
+
"""Explain the blast radius of changing files with rich context.
|
|
254
|
+
|
|
255
|
+
Combines impact analysis, community detection, and temporal coupling
|
|
256
|
+
to provide a comprehensive understanding of what would be affected
|
|
257
|
+
by changes to the specified files.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
files: File paths to analyze (relative to project root).
|
|
261
|
+
depth: Maximum depth for dependency traversal (default 3).
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
Narrative explanation of the change impact.
|
|
265
|
+
"""
|
|
266
|
+
if not files:
|
|
267
|
+
return "Error: No files specified for impact analysis."
|
|
268
|
+
|
|
269
|
+
if depth < 1:
|
|
270
|
+
depth = 1
|
|
271
|
+
elif depth > 5:
|
|
272
|
+
depth = 5
|
|
273
|
+
|
|
274
|
+
svc = _get_service()
|
|
275
|
+
sections: list[str] = []
|
|
276
|
+
|
|
277
|
+
# --- Impact analysis ---
|
|
278
|
+
impact_text = ""
|
|
279
|
+
try:
|
|
280
|
+
impact_text = svc.impact_analysis(files)
|
|
281
|
+
except Exception as exc:
|
|
282
|
+
impact_text = f"Impact analysis error: {exc}"
|
|
283
|
+
|
|
284
|
+
if impact_text:
|
|
285
|
+
sections.append(f"## Direct Impact\n\n{impact_text}")
|
|
286
|
+
|
|
287
|
+
# --- Community detection ---
|
|
288
|
+
community_text = ""
|
|
289
|
+
try:
|
|
290
|
+
community_text = svc.community_detection(
|
|
291
|
+
min_community_size=2, max_communities=10,
|
|
292
|
+
)
|
|
293
|
+
except Exception as exc:
|
|
294
|
+
community_text = f"Community detection error: {exc}"
|
|
295
|
+
|
|
296
|
+
if community_text:
|
|
297
|
+
# Extract relevant community info for the target files
|
|
298
|
+
relevant_community_lines: list[str] = []
|
|
299
|
+
in_relevant_community = False
|
|
300
|
+
for line in community_text.splitlines():
|
|
301
|
+
if any(f in line for f in files):
|
|
302
|
+
in_relevant_community = True
|
|
303
|
+
if in_relevant_community:
|
|
304
|
+
relevant_community_lines.append(line)
|
|
305
|
+
# Reset at community boundaries
|
|
306
|
+
if in_relevant_community and line.strip() == "":
|
|
307
|
+
in_relevant_community = False
|
|
308
|
+
|
|
309
|
+
if relevant_community_lines:
|
|
310
|
+
filtered_community = "\n".join(relevant_community_lines)
|
|
311
|
+
sections.append(
|
|
312
|
+
f"## Module Community\n\n"
|
|
313
|
+
f"The changed files belong to these communities:\n\n"
|
|
314
|
+
f"{filtered_community}"
|
|
315
|
+
)
|
|
316
|
+
else:
|
|
317
|
+
sections.append(f"## Module Community\n\n{community_text}")
|
|
318
|
+
|
|
319
|
+
# --- Temporal coupling ---
|
|
320
|
+
coupling_sections: list[str] = []
|
|
321
|
+
high_coupling_files: list[tuple[str, str]] = [] # (file, coupling_info)
|
|
322
|
+
for f in files:
|
|
323
|
+
try:
|
|
324
|
+
coupling_text = svc.change_coupling(
|
|
325
|
+
file=f, days=90, min_coupling=0.3, top_k=10,
|
|
326
|
+
)
|
|
327
|
+
if coupling_text and "no " not in coupling_text.lower():
|
|
328
|
+
coupling_sections.append(f"### {f}\n{coupling_text}")
|
|
329
|
+
# Track high-coupling pairs for risk assessment
|
|
330
|
+
for line in coupling_text.splitlines():
|
|
331
|
+
if any(
|
|
332
|
+
marker in line
|
|
333
|
+
for marker in ("0.8", "0.9", "1.0", "score: 0.7")
|
|
334
|
+
):
|
|
335
|
+
high_coupling_files.append((f, line.strip()))
|
|
336
|
+
except Exception as exc:
|
|
337
|
+
coupling_sections.append(f"### {f}\nTemporal coupling error: {exc}")
|
|
338
|
+
|
|
339
|
+
if coupling_sections:
|
|
340
|
+
sections.append(
|
|
341
|
+
"## Temporal Coupling (git co-change history)\n\n"
|
|
342
|
+
+ "\n\n".join(coupling_sections)
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
# --- Risk assessment ---
|
|
346
|
+
risk_level = "LOW"
|
|
347
|
+
risk_reasons: list[str] = []
|
|
348
|
+
|
|
349
|
+
# Check impact breadth
|
|
350
|
+
if impact_text:
|
|
351
|
+
affected_count = impact_text.count("\n")
|
|
352
|
+
if affected_count > 20:
|
|
353
|
+
risk_level = "HIGH"
|
|
354
|
+
risk_reasons.append(
|
|
355
|
+
f"Large blast radius: {affected_count}+ files potentially affected"
|
|
356
|
+
)
|
|
357
|
+
elif affected_count > 10:
|
|
358
|
+
risk_level = "MEDIUM"
|
|
359
|
+
risk_reasons.append(
|
|
360
|
+
f"Moderate blast radius: ~{affected_count} files potentially affected"
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
# Check temporal coupling
|
|
364
|
+
if high_coupling_files:
|
|
365
|
+
if risk_level == "LOW":
|
|
366
|
+
risk_level = "MEDIUM"
|
|
367
|
+
risk_reasons.append(
|
|
368
|
+
f"{len(high_coupling_files)} file(s) with high temporal coupling "
|
|
369
|
+
"(often change together -- verify they don't need updates too)"
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
# Check if multiple communities are affected
|
|
373
|
+
if len(files) > 1:
|
|
374
|
+
risk_reasons.append(
|
|
375
|
+
f"Changes span {len(files)} files -- cross-cutting changes "
|
|
376
|
+
"may affect multiple modules"
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
risk_summary = (
|
|
380
|
+
f"## Risk Assessment: {risk_level}\n\n"
|
|
381
|
+
+ ("\n".join(f" - {r}" for r in risk_reasons) if risk_reasons else " - No elevated risk factors detected.")
|
|
382
|
+
)
|
|
383
|
+
sections.append(risk_summary)
|
|
384
|
+
|
|
385
|
+
# --- Compose narrative ---
|
|
386
|
+
file_list = ", ".join(f"`{f}`" for f in files)
|
|
387
|
+
header = f"# Impact Analysis for {file_list}\n"
|
|
388
|
+
|
|
389
|
+
return header + "\n\n".join(sections)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@mcp.tool()
|
|
393
|
+
def suggest_tests(files: list[str], symbol_name: str | None = None, task_hint: str | None = None) -> str:
|
|
394
|
+
"""Suggest candidate tests using imports and syntax; optionally focus on a symbol and task."""
|
|
395
|
+
if not files:
|
|
396
|
+
return "Error: No files specified. Provide a list of changed files."
|
|
397
|
+
return format_test_suggestions(suggest_tests_data(files, symbol_name=symbol_name, task_hint=task_hint))
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def suggest_tests_data(files: list[str], *, symbol_name: str | None = None,
|
|
401
|
+
task_hint: str | None = None, linked_reference_files=None) -> dict:
|
|
402
|
+
"""Reusable evidence; callers format or budget it without repeating graph traversal."""
|
|
403
|
+
project_dir = _get_project_dir()
|
|
404
|
+
|
|
405
|
+
# Collect test suggestions with priorities
|
|
406
|
+
# Priority 1: Direct test files (by naming convention)
|
|
407
|
+
# Priority 2: Test files that import the changed module
|
|
408
|
+
# Priority 3: Tests for dependent modules
|
|
409
|
+
suggestions: dict[str, dict] = {} # path -> {priority, reasons}
|
|
410
|
+
distances: dict[str, int] = {}
|
|
411
|
+
|
|
412
|
+
def _add_suggestion(path: str, priority: int, reason: str) -> None:
|
|
413
|
+
"""Add or update a test suggestion."""
|
|
414
|
+
if path in suggestions:
|
|
415
|
+
existing = suggestions[path]
|
|
416
|
+
existing["priority"] = min(existing["priority"], priority)
|
|
417
|
+
if reason not in existing["reasons"]:
|
|
418
|
+
existing["reasons"].append(reason)
|
|
419
|
+
else:
|
|
420
|
+
suggestions[path] = {"priority": priority, "reasons": [reason]}
|
|
421
|
+
|
|
422
|
+
# --- Priority 1: Convention-based test files ---
|
|
423
|
+
for f in files:
|
|
424
|
+
convention_tests = _find_test_files_by_convention(f, project_dir)
|
|
425
|
+
for test_file in convention_tests:
|
|
426
|
+
_add_suggestion(
|
|
427
|
+
test_file,
|
|
428
|
+
priority=1,
|
|
429
|
+
reason=f"Direct test file for `{f}`",
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
# --- Priority 2: Import-based discovery ---
|
|
433
|
+
try:
|
|
434
|
+
ctx = _get_context_mgr()
|
|
435
|
+
dep_graph = getattr(ctx, "dependency_graph", None) or getattr(ctx, "_dep_graph", None)
|
|
436
|
+
|
|
437
|
+
if dep_graph is not None:
|
|
438
|
+
for f in files:
|
|
439
|
+
# Find files that import the changed file (dependents)
|
|
440
|
+
try:
|
|
441
|
+
importers = dep_graph.get_importers(f)
|
|
442
|
+
except (AttributeError, KeyError):
|
|
443
|
+
try:
|
|
444
|
+
importers = dep_graph.get_reverse_deps(f)
|
|
445
|
+
except (AttributeError, KeyError):
|
|
446
|
+
importers = set()
|
|
447
|
+
|
|
448
|
+
for importer in importers:
|
|
449
|
+
# Check if the importer is a test file
|
|
450
|
+
importer_lower = importer.lower()
|
|
451
|
+
is_test = (
|
|
452
|
+
"test" in importer_lower
|
|
453
|
+
or "spec" in importer_lower
|
|
454
|
+
or "__tests__" in importer_lower
|
|
455
|
+
)
|
|
456
|
+
if is_test:
|
|
457
|
+
_add_suggestion(
|
|
458
|
+
importer,
|
|
459
|
+
priority=2,
|
|
460
|
+
reason=f"Imports changed module `{f}`",
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
# --- Priority 3: Tests for dependent modules ---
|
|
464
|
+
for f in files:
|
|
465
|
+
try:
|
|
466
|
+
importers = dep_graph.get_importers(f)
|
|
467
|
+
except (AttributeError, KeyError):
|
|
468
|
+
try:
|
|
469
|
+
importers = dep_graph.get_reverse_deps(f)
|
|
470
|
+
except (AttributeError, KeyError):
|
|
471
|
+
importers = set()
|
|
472
|
+
|
|
473
|
+
for importer in importers:
|
|
474
|
+
importer_lower = importer.lower()
|
|
475
|
+
is_test = (
|
|
476
|
+
"test" in importer_lower
|
|
477
|
+
or "spec" in importer_lower
|
|
478
|
+
or "__tests__" in importer_lower
|
|
479
|
+
)
|
|
480
|
+
if not is_test:
|
|
481
|
+
# Find tests for this dependent module
|
|
482
|
+
dep_tests = _find_test_files_by_convention(
|
|
483
|
+
importer, project_dir
|
|
484
|
+
)
|
|
485
|
+
for test_file in dep_tests:
|
|
486
|
+
_add_suggestion(
|
|
487
|
+
test_file,
|
|
488
|
+
priority=3,
|
|
489
|
+
reason=f"Tests dependent module `{importer}` (which imports `{f}`)",
|
|
490
|
+
)
|
|
491
|
+
except Exception as exc:
|
|
492
|
+
logger.debug("Import-based test discovery failed: %s", exc)
|
|
493
|
+
|
|
494
|
+
# Follow actual reverse-import paths beyond immediate importers.
|
|
495
|
+
try:
|
|
496
|
+
from collections import deque
|
|
497
|
+
from pathlib import Path
|
|
498
|
+
ctx = _get_context_mgr()
|
|
499
|
+
graph = ctx.dependency_graph
|
|
500
|
+
for changed in files:
|
|
501
|
+
queue = deque([(changed, 0)])
|
|
502
|
+
seen = {changed}
|
|
503
|
+
while queue:
|
|
504
|
+
source, depth = queue.popleft()
|
|
505
|
+
for target in sorted(graph.get_importers(source)) if graph else []:
|
|
506
|
+
if target in seen:
|
|
507
|
+
continue
|
|
508
|
+
seen.add(target)
|
|
509
|
+
queue.append((target, depth + 1))
|
|
510
|
+
parts = Path(target).parts
|
|
511
|
+
filename = Path(target).name
|
|
512
|
+
if (any(p in {"test", "tests", "__tests__", "spec"} for p in parts)
|
|
513
|
+
or filename.startswith("test_") or ".test." in filename
|
|
514
|
+
or ".spec." in filename or filename.endswith("_test.go")):
|
|
515
|
+
_add_suggestion(target, 2 if depth == 0 else 3,
|
|
516
|
+
f"Reverse import path from `{changed}` ({depth + 1} edges)")
|
|
517
|
+
distances[target] = min(distances.get(target, depth + 1), depth + 1)
|
|
518
|
+
path = Path(project_dir) / changed
|
|
519
|
+
if path.suffix == ".rs" and path.is_file():
|
|
520
|
+
content = path.read_text(encoding="utf-8")
|
|
521
|
+
if "#[test]" in content or "#[cfg(test)]" in content:
|
|
522
|
+
_add_suggestion(changed, 1, "Contains Rust inline test declarations; select the owning Cargo package")
|
|
523
|
+
except (OSError, AttributeError) as exc:
|
|
524
|
+
logger.debug("Indirect test discovery incomplete: %s", exc)
|
|
525
|
+
|
|
526
|
+
if symbol_name or task_hint:
|
|
527
|
+
from attocode_intel._shared import _get_ast_service, _get_service
|
|
528
|
+
from attocode_intel.test_ranking import rank_symbol_tests
|
|
529
|
+
service = _get_service()
|
|
530
|
+
task_scores = (service._task_file_scores(task_hint)
|
|
531
|
+
if task_hint and hasattr(service, "_task_file_scores") else {})
|
|
532
|
+
return {"files": files, "symbol_name": symbol_name, "task_hint": task_hint,
|
|
533
|
+
"candidates": rank_symbol_tests(_get_ast_service(), suggestions, files, symbol_name,
|
|
534
|
+
task_hint, distances, linked_reference_files,
|
|
535
|
+
relevance_by_file=task_scores),
|
|
536
|
+
"ranking": "Selected-symbol links, reverse-import distance, then lexical test names; verify candidates",
|
|
537
|
+
"absence_proven": False}
|
|
538
|
+
|
|
539
|
+
# Prefer tests with concrete syntax references to changed definitions.
|
|
540
|
+
symbol_hits: dict[str, int] = {}
|
|
541
|
+
representatives: set[str] = set()
|
|
542
|
+
try:
|
|
543
|
+
from attocode_intel._shared import _get_ast_service
|
|
544
|
+
ast = _get_ast_service()
|
|
545
|
+
for changed in files:
|
|
546
|
+
for symbol in ast.get_file_symbols(changed):
|
|
547
|
+
if symbol.kind == "variable":
|
|
548
|
+
continue
|
|
549
|
+
matches = {ref.file_path for ref in ast.get_callers(symbol.qualified_name)
|
|
550
|
+
if ref.file_path in suggestions}
|
|
551
|
+
if matches:
|
|
552
|
+
representatives.add(min(matches, key=lambda path: (
|
|
553
|
+
symbol.name.lower() not in path.lower(), len(path), path)))
|
|
554
|
+
for path in matches:
|
|
555
|
+
symbol_hits[path] = symbol_hits.get(path, 0) + 1
|
|
556
|
+
_add_suggestion(path, suggestions[path]["priority"],
|
|
557
|
+
f"Syntax reference to `{symbol.qualified_name}`; verify ambiguous matches")
|
|
558
|
+
except (AttributeError, OSError) as exc:
|
|
559
|
+
logger.debug("Test reference ranking incomplete: %s", exc)
|
|
560
|
+
|
|
561
|
+
# Sort by evidence, then put repository tests before documentation examples.
|
|
562
|
+
sorted_suggestions = sorted(
|
|
563
|
+
suggestions.items(),
|
|
564
|
+
key=lambda kv: (kv[1]["priority"], kv[0] not in representatives, -symbol_hits.get(kv[0], 0), kv[0].startswith("docs"), kv[0]),
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
return {"files": files, "candidates": [
|
|
568
|
+
{"file_path": path, "priority": info["priority"], "reasons": info["reasons"][:3]}
|
|
569
|
+
for path, info in sorted_suggestions
|
|
570
|
+
], "absence_proven": False}
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def format_test_suggestions(data: dict) -> str:
|
|
574
|
+
files = data["files"]
|
|
575
|
+
suggestions = {row["file_path"]: row for row in data["candidates"]}
|
|
576
|
+
sorted_suggestions = list(suggestions.items())
|
|
577
|
+
# --- Format output ---
|
|
578
|
+
if not suggestions:
|
|
579
|
+
# Fallback: suggest running all tests
|
|
580
|
+
lines = [
|
|
581
|
+
"# Test Suggestions\n",
|
|
582
|
+
"No specific test files found for the changed files.",
|
|
583
|
+
"",
|
|
584
|
+
"Changed files:",
|
|
585
|
+
]
|
|
586
|
+
for f in files:
|
|
587
|
+
lines.append(f" - {f}")
|
|
588
|
+
lines.extend([
|
|
589
|
+
"",
|
|
590
|
+
"Recommendations:",
|
|
591
|
+
" - Run the full test suite",
|
|
592
|
+
" - Check if these files have inline tests (e.g., Rust #[cfg(test)])",
|
|
593
|
+
" - Consider creating test files for untested modules",
|
|
594
|
+
])
|
|
595
|
+
return "\n".join(lines)
|
|
596
|
+
|
|
597
|
+
priority_labels = {
|
|
598
|
+
1: "DIRECT",
|
|
599
|
+
2: "IMPORTS",
|
|
600
|
+
3: "INDIRECT",
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
lines = [
|
|
604
|
+
"# Test Suggestions\n",
|
|
605
|
+
f"Found {len(sorted_suggestions)} test file(s) for "
|
|
606
|
+
f"{len(files)} changed file(s).\n",
|
|
607
|
+
]
|
|
608
|
+
|
|
609
|
+
current_priority = 0
|
|
610
|
+
for test_path, info in sorted_suggestions:
|
|
611
|
+
priority = info["priority"]
|
|
612
|
+
if priority != current_priority:
|
|
613
|
+
current_priority = priority
|
|
614
|
+
label = priority_labels.get(priority, f"P{priority}")
|
|
615
|
+
lines.append(f"\n## Priority {priority} ({label})\n")
|
|
616
|
+
|
|
617
|
+
lines.append(f" {test_path}")
|
|
618
|
+
for reason in info["reasons"][:3]:
|
|
619
|
+
lines.append(f" - {reason}")
|
|
620
|
+
|
|
621
|
+
# Summary
|
|
622
|
+
by_priority: dict[int, int] = {}
|
|
623
|
+
for _, info in sorted_suggestions:
|
|
624
|
+
by_priority[info["priority"]] = by_priority.get(info["priority"], 0) + 1
|
|
625
|
+
|
|
626
|
+
lines.append("\n## Summary\n")
|
|
627
|
+
for p in sorted(by_priority):
|
|
628
|
+
label = priority_labels.get(p, f"P{p}")
|
|
629
|
+
lines.append(f" {label}: {by_priority[p]} test file(s)")
|
|
630
|
+
|
|
631
|
+
lines.append(
|
|
632
|
+
"\nThese are test candidates based on naming and static relationships. "
|
|
633
|
+
"They are not complete coverage or proof that a change is safe."
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
return "\n".join(lines)
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
@mcp.tool()
|
|
640
|
+
def architecture_drift(
|
|
641
|
+
config_path: str = "",
|
|
642
|
+
) -> str:
|
|
643
|
+
"""Detect architecture boundary violations in the codebase.
|
|
644
|
+
|
|
645
|
+
Compares actual file dependencies against rules defined in
|
|
646
|
+
.attocode/architecture.yaml. Reports layering violations,
|
|
647
|
+
unauthorized imports, and circular dependencies.
|
|
648
|
+
|
|
649
|
+
Args:
|
|
650
|
+
config_path: Path to architecture config (default: .attocode/architecture.yaml).
|
|
651
|
+
"""
|
|
652
|
+
from attocode_intel._internal.integrations.context.architecture_drift import (
|
|
653
|
+
check_drift,
|
|
654
|
+
format_report,
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
project_dir = _get_project_dir()
|
|
658
|
+
|
|
659
|
+
# If a custom config_path is given, verify it exists
|
|
660
|
+
if config_path and not os.path.isfile(config_path):
|
|
661
|
+
return (
|
|
662
|
+
f"Error: Architecture config not found at '{config_path}'.\n"
|
|
663
|
+
"Create a .attocode/architecture.yaml file defining your layers and rules.\n"
|
|
664
|
+
"See documentation for the expected YAML format."
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
# Check that the default config exists when no override is given
|
|
668
|
+
if not config_path:
|
|
669
|
+
default_path = os.path.join(project_dir, ".attocode", "architecture.yaml")
|
|
670
|
+
if not os.path.isfile(default_path):
|
|
671
|
+
return (
|
|
672
|
+
"No architecture config found at .attocode/architecture.yaml.\n\n"
|
|
673
|
+
"Create this file to define your architecture boundaries. Example:\n\n"
|
|
674
|
+
" layers:\n"
|
|
675
|
+
' - name: presentation\n'
|
|
676
|
+
' paths: ["src/api/", "src/routes/"]\n'
|
|
677
|
+
' - name: business\n'
|
|
678
|
+
' paths: ["src/services/", "src/domain/"]\n'
|
|
679
|
+
' - name: data\n'
|
|
680
|
+
' paths: ["src/models/", "src/repositories/"]\n\n'
|
|
681
|
+
" rules:\n"
|
|
682
|
+
" - from: presentation\n"
|
|
683
|
+
" to: [business]\n"
|
|
684
|
+
" deny: [data]\n"
|
|
685
|
+
" - from: business\n"
|
|
686
|
+
" to: [data]\n"
|
|
687
|
+
" deny: [presentation]\n"
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
try:
|
|
691
|
+
report = check_drift(project_dir)
|
|
692
|
+
return format_report(report)
|
|
693
|
+
except Exception as exc:
|
|
694
|
+
logger.exception("Architecture drift check failed")
|
|
695
|
+
return f"Error running architecture drift check: {exc}"
|