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,3416 @@
|
|
|
1
|
+
"""Unified code intelligence service.
|
|
2
|
+
|
|
3
|
+
Used by both the MCP server (server.py) and HTTP API (api/app.py) as a
|
|
4
|
+
single source of truth for the shared code-intelligence surface.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from collections import Counter, deque
|
|
15
|
+
from contextvars import ContextVar
|
|
16
|
+
from dataclasses import replace
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import TYPE_CHECKING
|
|
19
|
+
|
|
20
|
+
from attocode_intel._internal.integrations.utilities.token_estimate import estimate_tokens
|
|
21
|
+
from attocode_intel.config import CodeIntelConfig
|
|
22
|
+
from attocode_intel.project_dir import cache_folder
|
|
23
|
+
from attocode_intel.query_ranking import (
|
|
24
|
+
broad_rank_enabled,
|
|
25
|
+
hit_evidence,
|
|
26
|
+
next_search_top_k,
|
|
27
|
+
query_diagnostics,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# Seconds that a search waits for warming indexes before it reports "warming". A search over
|
|
31
|
+
# many workspaces sets a short wait, so that one large workspace cannot stall the others.
|
|
32
|
+
SEARCH_WARMUP_WAIT: ContextVar[float] = ContextVar("search_warmup_wait", default=15.0)
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from collections.abc import Callable
|
|
36
|
+
|
|
37
|
+
from attocode_intel._internal.integrations.context.ast_service import ASTService
|
|
38
|
+
from attocode_intel._internal.integrations.context.code_analyzer import CodeAnalyzer
|
|
39
|
+
from attocode_intel._internal.integrations.context.codebase_context import (
|
|
40
|
+
CodebaseContextManager,
|
|
41
|
+
)
|
|
42
|
+
from attocode_intel._internal.integrations.context.hierarchical_explorer import (
|
|
43
|
+
HierarchicalExplorer,
|
|
44
|
+
)
|
|
45
|
+
from attocode_intel._internal.integrations.context.memory_store import MemoryStore
|
|
46
|
+
from attocode_intel._internal.integrations.context.reranker import RankingProvider
|
|
47
|
+
from attocode_intel._internal.integrations.context.semantic_search import (
|
|
48
|
+
ContextAssemblyConfig,
|
|
49
|
+
SearchScoringConfig,
|
|
50
|
+
SemanticSearchManager,
|
|
51
|
+
)
|
|
52
|
+
from attocode_intel._internal.integrations.context.systemone_ranker import (
|
|
53
|
+
SystemOneChoiceReranker,
|
|
54
|
+
)
|
|
55
|
+
from attocode_intel._internal.integrations.context.temporal_coupling import (
|
|
56
|
+
TemporalCouplingAnalyzer,
|
|
57
|
+
)
|
|
58
|
+
from attocode_intel._internal.integrations.lsp.client import LSPManager
|
|
59
|
+
from attocode_intel._internal.integrations.security.scanner import SecurityScanner
|
|
60
|
+
|
|
61
|
+
logger = logging.getLogger(__name__)
|
|
62
|
+
|
|
63
|
+
# Registry of service instances by project_dir
|
|
64
|
+
_instances: dict[str, CodeIntelService] = {}
|
|
65
|
+
_instances_lock = threading.Lock()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class CodeIntelService:
|
|
69
|
+
"""Unified code intelligence service wrapping all tool operations."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, project_dir: str, config: CodeIntelConfig | None = None) -> None:
|
|
72
|
+
self._project_dir = os.path.realpath(project_dir)
|
|
73
|
+
with contextlib.suppress(OSError): # A read-only project still answers.
|
|
74
|
+
cache_folder(self._project_dir)
|
|
75
|
+
self._config = config or CodeIntelConfig(project_dir=self._project_dir)
|
|
76
|
+
self._scoring_config: SearchScoringConfig | None = None # Optional override
|
|
77
|
+
self._context_config: ContextAssemblyConfig | None = None # Optional override
|
|
78
|
+
|
|
79
|
+
# Lazy singletons
|
|
80
|
+
self._init_lock = threading.Lock()
|
|
81
|
+
self._ast_service: ASTService | None = None
|
|
82
|
+
self._context_mgr: CodebaseContextManager | None = None
|
|
83
|
+
self._code_analyzer: CodeAnalyzer | None = None
|
|
84
|
+
self._lsp_manager: LSPManager | None = None
|
|
85
|
+
self._explorer: HierarchicalExplorer | None = None
|
|
86
|
+
self._security_scanner: SecurityScanner | None = None
|
|
87
|
+
self._semantic_search: SemanticSearchManager | None = None
|
|
88
|
+
self._local_reranker: RankingProvider | None = None
|
|
89
|
+
self._local_reranker_status = "not_configured"
|
|
90
|
+
self._systemone_ranker: SystemOneChoiceReranker | None = None
|
|
91
|
+
self._ranking_provider = (self._config.ranking_provider
|
|
92
|
+
if config is not None else
|
|
93
|
+
os.environ.get("ATTOCODE_INTEL_RANKING_PROVIDER", "")) or "auto"
|
|
94
|
+
self._systemone_status = "not_configured"
|
|
95
|
+
self._memory_store: MemoryStore | None = None
|
|
96
|
+
self._temporal_analyzer: TemporalCouplingAnalyzer | None = None
|
|
97
|
+
self._lsp_auto_started: bool = False
|
|
98
|
+
|
|
99
|
+
# Both an absolute local directory and its pinned fingerprint are
|
|
100
|
+
# required. Prewarming happens off the request thread; search never
|
|
101
|
+
# fetches weights or waits for cold model loading.
|
|
102
|
+
reranker_path = (self._config.local_reranker_path
|
|
103
|
+
or os.environ.get("ATTOCODE_INTEL_RERANKER_PATH", ""))
|
|
104
|
+
reranker_sha = (self._config.local_reranker_sha256
|
|
105
|
+
or os.environ.get("ATTOCODE_INTEL_RERANKER_SHA256", ""))
|
|
106
|
+
if self._ranking_provider not in {"auto", "off", "local_cross_encoder", "systemone"}:
|
|
107
|
+
self._systemone_status = "invalid_config"
|
|
108
|
+
logger.warning("Unknown ranking provider; deterministic ranking remains active")
|
|
109
|
+
elif self._ranking_provider == "systemone":
|
|
110
|
+
try:
|
|
111
|
+
from attocode_intel._internal.integrations.context.systemone_ranker import (
|
|
112
|
+
SystemOneChoiceReranker,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
environment = os.environ if config is None else {}
|
|
116
|
+
remote_opt_in = (self._config.ranking_allow_remote if config is not None else
|
|
117
|
+
environment.get("ATTOCODE_INTEL_RANKING_ALLOW_REMOTE", "").lower()
|
|
118
|
+
in {"1", "true", "yes", "on"})
|
|
119
|
+
remote_workspace = (self._config.ranking_remote_workspace or
|
|
120
|
+
environment.get("ATTOCODE_INTEL_RANKING_REMOTE_WORKSPACE", ""))
|
|
121
|
+
timeout_ms = (self._config.ranking_timeout_ms if config is not None else
|
|
122
|
+
int(environment.get("ATTOCODE_INTEL_RANKING_TIMEOUT_MS", "0")))
|
|
123
|
+
self._systemone_ranker = SystemOneChoiceReranker(
|
|
124
|
+
self._config.ranking_endpoint or environment.get("ATTOCODE_INTEL_RANKING_ENDPOINT", ""),
|
|
125
|
+
model=self._config.ranking_model or environment.get("ATTOCODE_INTEL_RANKING_MODEL", ""),
|
|
126
|
+
auth_env=self._config.ranking_auth_env or environment.get("ATTOCODE_INTEL_RANKING_AUTH_ENV", ""),
|
|
127
|
+
allow_remote=(remote_opt_in and os.path.isabs(remote_workspace) and
|
|
128
|
+
os.path.realpath(remote_workspace) == self._project_dir),
|
|
129
|
+
service_mode=self._config.is_service_mode,
|
|
130
|
+
timeout_seconds=None if timeout_ms == 0 else timeout_ms / 1000,
|
|
131
|
+
max_candidates=(self._config.ranking_max_candidates if config is not None else
|
|
132
|
+
int(environment.get("ATTOCODE_INTEL_RANKING_MAX_CANDIDATES", "24"))),
|
|
133
|
+
)
|
|
134
|
+
self._systemone_status = "configured"
|
|
135
|
+
except (ValueError, OverflowError):
|
|
136
|
+
self._systemone_status = "invalid_config"
|
|
137
|
+
logger.warning("Invalid SystemOne ranking configuration; deterministic ranking remains active")
|
|
138
|
+
if self._ranking_provider in {"auto", "local_cross_encoder"}:
|
|
139
|
+
if bool(reranker_path) != bool(reranker_sha):
|
|
140
|
+
self._local_reranker_status = "incomplete_config"
|
|
141
|
+
logger.warning("Local reranker requires both path and SHA-256; deterministic ranking remains active")
|
|
142
|
+
if reranker_path and reranker_sha:
|
|
143
|
+
try:
|
|
144
|
+
from attocode_intel._internal.integrations.context.reranker import (
|
|
145
|
+
LocalCrossEncoderReranker,
|
|
146
|
+
)
|
|
147
|
+
self._local_reranker = LocalCrossEncoderReranker(reranker_path, reranker_sha)
|
|
148
|
+
self._local_reranker.start_prewarm()
|
|
149
|
+
self._local_reranker_status = "loading"
|
|
150
|
+
except ValueError:
|
|
151
|
+
logger.warning("Invalid local reranker configuration; deterministic ranking remains active")
|
|
152
|
+
self._local_reranker_status = "invalid_config"
|
|
153
|
+
|
|
154
|
+
@classmethod
|
|
155
|
+
def get_instance(cls, project_dir: str, config: CodeIntelConfig | None = None) -> CodeIntelService:
|
|
156
|
+
"""Get or create a service instance for the given project directory."""
|
|
157
|
+
abs_dir = os.path.realpath(project_dir)
|
|
158
|
+
if abs_dir not in _instances:
|
|
159
|
+
with _instances_lock:
|
|
160
|
+
if abs_dir not in _instances:
|
|
161
|
+
_instances[abs_dir] = cls(abs_dir, config)
|
|
162
|
+
return _instances[abs_dir]
|
|
163
|
+
|
|
164
|
+
@classmethod
|
|
165
|
+
def _reset_instances(cls) -> None:
|
|
166
|
+
"""Clear all cached instances. For test isolation only."""
|
|
167
|
+
with _instances_lock:
|
|
168
|
+
_instances.clear()
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def project_dir(self) -> str:
|
|
172
|
+
return self._project_dir
|
|
173
|
+
|
|
174
|
+
# ------------------------------------------------------------------
|
|
175
|
+
# Lazy initializers
|
|
176
|
+
# ------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
def _get_ast_service(self, *, indexing_depth: str = "auto") -> ASTService:
|
|
179
|
+
if self._ast_service is None:
|
|
180
|
+
with self._init_lock:
|
|
181
|
+
if self._ast_service is None:
|
|
182
|
+
from attocode_intel._internal.integrations.context.ast_service import ASTService
|
|
183
|
+
from attocode_intel.request_context import current_request
|
|
184
|
+
svc = ASTService(self._project_dir) if current_request.get() else ASTService.get_instance(self._project_dir)
|
|
185
|
+
if current_request.get():
|
|
186
|
+
svc._context_mgr.max_files = int(os.environ.get("ATTOCODE_INTEL_MAX_FILES", "100000"))
|
|
187
|
+
if not svc.initialized:
|
|
188
|
+
logger.info("Initializing ASTService (skeleton)...")
|
|
189
|
+
from attocode_intel.telemetry import measure
|
|
190
|
+
with measure("indexing"):
|
|
191
|
+
svc.initialize_skeleton(indexing_depth=indexing_depth)
|
|
192
|
+
# Start background hydration for non-small repos
|
|
193
|
+
if (svc._hydration_state
|
|
194
|
+
and svc._hydration_state.phase != "ready"):
|
|
195
|
+
svc.start_hydration()
|
|
196
|
+
self._ast_service = svc
|
|
197
|
+
# Share the ASTService's context manager to avoid
|
|
198
|
+
# double file discovery on large repos
|
|
199
|
+
if self._context_mgr is None and svc._context_mgr._files:
|
|
200
|
+
self._context_mgr = svc._context_mgr
|
|
201
|
+
return self._ast_service
|
|
202
|
+
|
|
203
|
+
def _get_context_mgr(self) -> CodebaseContextManager:
|
|
204
|
+
if self._context_mgr is None:
|
|
205
|
+
# Trigger AST service init first — it does file discovery
|
|
206
|
+
# and we reuse its context manager
|
|
207
|
+
self._get_ast_service()
|
|
208
|
+
if self._context_mgr is None:
|
|
209
|
+
with self._init_lock:
|
|
210
|
+
if self._context_mgr is None:
|
|
211
|
+
from attocode_intel._internal.integrations.context.codebase_context import (
|
|
212
|
+
CodebaseContextManager,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
mgr = CodebaseContextManager(root_dir=self._project_dir)
|
|
216
|
+
mgr.discover_files()
|
|
217
|
+
self._context_mgr = mgr
|
|
218
|
+
return self._context_mgr
|
|
219
|
+
|
|
220
|
+
def _get_code_analyzer(self) -> CodeAnalyzer:
|
|
221
|
+
if self._code_analyzer is None:
|
|
222
|
+
with self._init_lock:
|
|
223
|
+
if self._code_analyzer is None:
|
|
224
|
+
from attocode_intel._internal.integrations.context.code_analyzer import (
|
|
225
|
+
CodeAnalyzer,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
self._code_analyzer = CodeAnalyzer()
|
|
229
|
+
return self._code_analyzer
|
|
230
|
+
|
|
231
|
+
def _get_lsp_manager(self) -> LSPManager:
|
|
232
|
+
if self._lsp_manager is None:
|
|
233
|
+
# AST initialization takes the same non-reentrant lock.
|
|
234
|
+
ast_svc = self._get_ast_service()
|
|
235
|
+
with self._init_lock:
|
|
236
|
+
if self._lsp_manager is None:
|
|
237
|
+
from attocode_intel._internal.integrations.lsp.client import (
|
|
238
|
+
LSPConfig,
|
|
239
|
+
LSPManager,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
config = LSPConfig(
|
|
243
|
+
enabled=True,
|
|
244
|
+
root_uri=Path(self._project_dir).resolve().as_uri(),
|
|
245
|
+
)
|
|
246
|
+
mgr = LSPManager(config=config)
|
|
247
|
+
# Wire LSP results back into the cross-ref index
|
|
248
|
+
try:
|
|
249
|
+
mgr.on_result_callback = ast_svc.ingest_lsp_results
|
|
250
|
+
except Exception:
|
|
251
|
+
pass # ASTService may not be ready yet
|
|
252
|
+
# auto_start is async, will be called on first LSP request
|
|
253
|
+
self._lsp_manager = mgr
|
|
254
|
+
self._lsp_auto_started = False
|
|
255
|
+
return self._lsp_manager
|
|
256
|
+
|
|
257
|
+
def _get_explorer(self) -> HierarchicalExplorer:
|
|
258
|
+
if self._explorer is None:
|
|
259
|
+
# Initialize deps outside the lock to avoid reentrant deadlock
|
|
260
|
+
ctx = self._get_context_mgr()
|
|
261
|
+
ast_svc = self._get_ast_service()
|
|
262
|
+
with self._init_lock:
|
|
263
|
+
if self._explorer is None:
|
|
264
|
+
from attocode_intel._internal.integrations.context.hierarchical_explorer import (
|
|
265
|
+
HierarchicalExplorer,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
self._explorer = HierarchicalExplorer(ctx, ast_service=ast_svc)
|
|
269
|
+
return self._explorer
|
|
270
|
+
|
|
271
|
+
def _get_security_scanner(self) -> SecurityScanner:
|
|
272
|
+
if self._security_scanner is None:
|
|
273
|
+
with self._init_lock:
|
|
274
|
+
if self._security_scanner is None:
|
|
275
|
+
from attocode_intel._internal.integrations.security.scanner import (
|
|
276
|
+
SecurityScanner,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
self._security_scanner = SecurityScanner(root_dir=self._project_dir)
|
|
280
|
+
return self._security_scanner
|
|
281
|
+
|
|
282
|
+
def _get_semantic_search(self) -> SemanticSearchManager:
|
|
283
|
+
if self._semantic_search is None:
|
|
284
|
+
with self._init_lock:
|
|
285
|
+
if self._semantic_search is None:
|
|
286
|
+
from attocode_intel._internal.integrations.context.semantic_search import (
|
|
287
|
+
SemanticSearchManager,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
kwargs: dict[str, object] = {"root_dir": self._project_dir}
|
|
291
|
+
if self._scoring_config is not None:
|
|
292
|
+
kwargs["scoring_config"] = self._scoring_config
|
|
293
|
+
self._semantic_search = SemanticSearchManager(**kwargs)
|
|
294
|
+
return self._semantic_search
|
|
295
|
+
|
|
296
|
+
def _task_file_scores(self, task_hint: str, top_k: int = 80) -> dict[str, float]:
|
|
297
|
+
"""Rank files by source-aware local candidates without requiring vectors.
|
|
298
|
+
|
|
299
|
+
These are presentation weights, never evidence that a graph edge or an
|
|
300
|
+
exact symbol definition does not exist.
|
|
301
|
+
"""
|
|
302
|
+
if not task_hint:
|
|
303
|
+
return {}
|
|
304
|
+
try:
|
|
305
|
+
results = self._get_semantic_search().search_candidates(
|
|
306
|
+
task_hint, top_k=self._retrieval_depth(top_k), tests_last=False)
|
|
307
|
+
if self._ranking_provider == "systemone":
|
|
308
|
+
results, _ranking = self._rank_search_results(task_hint, results, len(results))
|
|
309
|
+
except Exception:
|
|
310
|
+
logger.debug("Task-aware file scoring unavailable", exc_info=True)
|
|
311
|
+
return {}
|
|
312
|
+
scores: dict[str, float] = {}
|
|
313
|
+
for rank, result in enumerate(results):
|
|
314
|
+
scores[result.file_path] = max(
|
|
315
|
+
scores.get(result.file_path, 0.0), 1.0 / (1.0 + rank / 10.0),
|
|
316
|
+
)
|
|
317
|
+
return scores
|
|
318
|
+
|
|
319
|
+
def _rerank_excerpt(self, result, query: str) -> str:
|
|
320
|
+
"""Supply a bounded, current source span to an optional local model."""
|
|
321
|
+
header = f"{result.file_path} | {result.chunk_type} {result.name}\n"
|
|
322
|
+
root = Path(self._project_dir).resolve()
|
|
323
|
+
path = (root / result.file_path).resolve()
|
|
324
|
+
if not path.is_relative_to(root):
|
|
325
|
+
return header + result.text[:1800]
|
|
326
|
+
try:
|
|
327
|
+
before = path.stat()
|
|
328
|
+
if before.st_size > 2_000_000:
|
|
329
|
+
return header + result.text[:1800]
|
|
330
|
+
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
331
|
+
after = path.stat()
|
|
332
|
+
if (before.st_mtime_ns, before.st_ctime_ns, before.st_size) != (
|
|
333
|
+
after.st_mtime_ns, after.st_ctime_ns, after.st_size
|
|
334
|
+
):
|
|
335
|
+
return header + result.text[:1800]
|
|
336
|
+
except OSError:
|
|
337
|
+
return header + result.text[:1800]
|
|
338
|
+
start = max(1, result.start_line)
|
|
339
|
+
if not result.start_line:
|
|
340
|
+
try:
|
|
341
|
+
symbols = self._get_ast_service().get_file_symbols(result.file_path)
|
|
342
|
+
matching = [item for item in symbols
|
|
343
|
+
if item.name == result.name or item.qualified_name == result.name]
|
|
344
|
+
if matching:
|
|
345
|
+
start = matching[0].start_line
|
|
346
|
+
except Exception:
|
|
347
|
+
pass
|
|
348
|
+
if start == 1 and result.chunk_type == "file":
|
|
349
|
+
from attocode_intel.focused_evidence import task_terms, terms
|
|
350
|
+
positive, _ = task_terms(query)
|
|
351
|
+
for index, line in enumerate(lines):
|
|
352
|
+
if positive & terms(line):
|
|
353
|
+
start = max(1, index - 3)
|
|
354
|
+
break
|
|
355
|
+
return header + "\n".join(lines[start - 1:start + 39])[:1800]
|
|
356
|
+
|
|
357
|
+
def _rerank_file_excerpt(self, result, query: str) -> str | None:
|
|
358
|
+
"""Current, query-focused file evidence for a shortlist decision."""
|
|
359
|
+
from attocode_intel.focused_evidence import file_excerpt
|
|
360
|
+
|
|
361
|
+
root = Path(self._project_dir).resolve()
|
|
362
|
+
path = (root / result.file_path).resolve()
|
|
363
|
+
if not path.is_relative_to(root) or not path.is_file():
|
|
364
|
+
return None
|
|
365
|
+
try:
|
|
366
|
+
before = path.stat()
|
|
367
|
+
if before.st_size > 2_000_000:
|
|
368
|
+
return None
|
|
369
|
+
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
370
|
+
after = path.stat()
|
|
371
|
+
if (before.st_mtime_ns, before.st_ctime_ns, before.st_size) != (
|
|
372
|
+
after.st_mtime_ns, after.st_ctime_ns, after.st_size
|
|
373
|
+
):
|
|
374
|
+
return None
|
|
375
|
+
except OSError:
|
|
376
|
+
return None
|
|
377
|
+
return file_excerpt(lines, result.file_path, query)
|
|
378
|
+
|
|
379
|
+
def _rank_systemone_results(self, query: str, results: list, top_k: int,
|
|
380
|
+
ranking: dict) -> tuple[list, dict]:
|
|
381
|
+
"""Rank distinct files while keeping each file's best source hit."""
|
|
382
|
+
fallback = results[:top_k]
|
|
383
|
+
model = self._systemone_ranker
|
|
384
|
+
if model is None:
|
|
385
|
+
ranking["fallback_reason"] = self._systemone_status
|
|
386
|
+
return fallback, ranking
|
|
387
|
+
ranking["model_status"] = model.status
|
|
388
|
+
ranking["model"] = model.model
|
|
389
|
+
ranking["remote"] = model.remote
|
|
390
|
+
if not results or top_k <= 0:
|
|
391
|
+
return fallback, ranking
|
|
392
|
+
representatives = {}
|
|
393
|
+
for result in results:
|
|
394
|
+
representatives.setdefault(result.file_path, result)
|
|
395
|
+
if len(representatives) == model.max_candidates:
|
|
396
|
+
break
|
|
397
|
+
if len(representatives) < 2:
|
|
398
|
+
ranking["fallback_reason"] = "single_candidate"
|
|
399
|
+
return fallback, ranking
|
|
400
|
+
candidates = []
|
|
401
|
+
for index, result in enumerate(representatives.values()):
|
|
402
|
+
excerpt = self._rerank_file_excerpt(result, query)
|
|
403
|
+
if excerpt is None:
|
|
404
|
+
ranking["fallback_reason"] = "evidence_unavailable"
|
|
405
|
+
return fallback, ranking
|
|
406
|
+
candidates.append((str(index), excerpt, result.score))
|
|
407
|
+
started = time.monotonic()
|
|
408
|
+
outcome = model.rerank_result(query, candidates, top_k=len(candidates))
|
|
409
|
+
ranking["rerank_elapsed_ms"] = round((time.monotonic() - started) * 1000, 2)
|
|
410
|
+
if not outcome.reranked:
|
|
411
|
+
ranking["fallback_reason"] = outcome.fallback_reason
|
|
412
|
+
return fallback, ranking
|
|
413
|
+
source_rows = list(representatives.values())
|
|
414
|
+
ordered = [source_rows[int(candidate_id)] for candidate_id, _excerpt, _score
|
|
415
|
+
in outcome.candidates]
|
|
416
|
+
seen_files = set(representatives)
|
|
417
|
+
duplicates = []
|
|
418
|
+
for row in results:
|
|
419
|
+
if row is representatives.get(row.file_path):
|
|
420
|
+
continue
|
|
421
|
+
if row.file_path in seen_files:
|
|
422
|
+
duplicates.append(row)
|
|
423
|
+
else:
|
|
424
|
+
ordered.append(row)
|
|
425
|
+
seen_files.add(row.file_path)
|
|
426
|
+
ordered.extend(duplicates)
|
|
427
|
+
# File-level probabilities determine order, not result score. Keep the
|
|
428
|
+
# original retrieval scores rather than presenting choice probabilities
|
|
429
|
+
# as calibrated per-result confidence.
|
|
430
|
+
ranking.update({"method": "systemone_choice", "reranked_count": len(candidates),
|
|
431
|
+
"score_semantics": "retrieval_score_preserved"})
|
|
432
|
+
return ordered[:top_k], ranking
|
|
433
|
+
|
|
434
|
+
def _retrieval_depth(self, top_k: int) -> int:
|
|
435
|
+
"""Chunks to retrieve before ranking; a model ranker needs enough distinct files."""
|
|
436
|
+
if self._ranking_provider != "systemone" or self._systemone_ranker is None:
|
|
437
|
+
return max(top_k, 24)
|
|
438
|
+
# ponytail: ~8 chunks per distinct file on the 2026-10-08 blind pools
|
|
439
|
+
# (400 chunks -> median 48 files); lexical retrieval stayed under 150 ms.
|
|
440
|
+
return max(top_k, 24, 8 * self._systemone_ranker.max_candidates)
|
|
441
|
+
|
|
442
|
+
def _rank_search_results(self, query: str, results: list, top_k: int,
|
|
443
|
+
file_filter: str = "") -> tuple[list, dict]:
|
|
444
|
+
"""Optional local model order with a complete deterministic fallback."""
|
|
445
|
+
limited = results[:top_k]
|
|
446
|
+
ranking = {"method": "source_aware_deterministic", "returned_count": len(limited),
|
|
447
|
+
"model_status": self._local_reranker_status,
|
|
448
|
+
"broad_ranker": "experimental_on" if broad_rank_enabled() else "off",
|
|
449
|
+
"query": query_diagnostics(query, results, file_filter)}
|
|
450
|
+
if self._ranking_provider == "systemone":
|
|
451
|
+
ranking["provider"] = "systemone"
|
|
452
|
+
return self._rank_systemone_results(query, results, top_k, ranking)
|
|
453
|
+
if self._ranking_provider not in {"auto", "local_cross_encoder"}:
|
|
454
|
+
ranking["provider"] = self._ranking_provider
|
|
455
|
+
ranking["model_status"] = ("off" if self._ranking_provider == "off"
|
|
456
|
+
else self._systemone_status)
|
|
457
|
+
return limited, ranking
|
|
458
|
+
model = self._local_reranker
|
|
459
|
+
if model is None or not results:
|
|
460
|
+
return limited, ranking
|
|
461
|
+
ranking["model_status"] = model.status
|
|
462
|
+
if not model.is_available:
|
|
463
|
+
ranking["fallback_reason"] = model.status
|
|
464
|
+
return limited, ranking
|
|
465
|
+
candidates = [(str(index), self._rerank_excerpt(result, query), result.score)
|
|
466
|
+
for index, result in enumerate(results[:model.max_candidates])]
|
|
467
|
+
outcome = model.rerank_result(query, candidates, top_k=min(top_k, len(candidates)))
|
|
468
|
+
if not outcome.reranked:
|
|
469
|
+
ranking["fallback_reason"] = outcome.fallback_reason
|
|
470
|
+
return limited, ranking
|
|
471
|
+
ranked = [replace(results[int(item_id)], score=score)
|
|
472
|
+
for item_id, _excerpt, score in outcome.candidates]
|
|
473
|
+
if len(ranked) < top_k:
|
|
474
|
+
ranked.extend(results[len(candidates):top_k])
|
|
475
|
+
ranking.update({"method": "local_cross_encoder", "model_status": model.status,
|
|
476
|
+
"reranked_count": len(candidates)})
|
|
477
|
+
return ranked[:top_k], ranking
|
|
478
|
+
|
|
479
|
+
def set_scoring_config(self, config: SearchScoringConfig) -> None:
|
|
480
|
+
"""Set search scoring config for meta-harness optimization.
|
|
481
|
+
|
|
482
|
+
Must be called before the first search. If the SemanticSearchManager
|
|
483
|
+
is already initialized, updates it in place.
|
|
484
|
+
"""
|
|
485
|
+
with self._init_lock:
|
|
486
|
+
self._scoring_config = config
|
|
487
|
+
if self._semantic_search is not None:
|
|
488
|
+
self._semantic_search.scoring_config = config
|
|
489
|
+
|
|
490
|
+
def set_context_config(self, config: ContextAssemblyConfig) -> None:
|
|
491
|
+
"""Set context assembly config for meta-harness optimization."""
|
|
492
|
+
self._context_config = config
|
|
493
|
+
|
|
494
|
+
def _get_memory_store(self) -> MemoryStore:
|
|
495
|
+
if self._memory_store is None:
|
|
496
|
+
with self._init_lock:
|
|
497
|
+
if self._memory_store is None:
|
|
498
|
+
from attocode_intel._internal.integrations.context.memory_store import (
|
|
499
|
+
MemoryStore,
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
self._memory_store = MemoryStore(self._project_dir)
|
|
503
|
+
return self._memory_store
|
|
504
|
+
|
|
505
|
+
def _health_snapshot(self) -> dict[str, object]:
|
|
506
|
+
"""Return discovery/index health for status endpoints."""
|
|
507
|
+
discovered_files = 0
|
|
508
|
+
indexed_files = 0
|
|
509
|
+
degradation_reasons: list[str] = []
|
|
510
|
+
|
|
511
|
+
try:
|
|
512
|
+
ctx = self._get_context_mgr()
|
|
513
|
+
discovered = getattr(ctx, "_files", None)
|
|
514
|
+
if isinstance(discovered, list):
|
|
515
|
+
discovered_files = len(discovered)
|
|
516
|
+
if discovered_files == 0:
|
|
517
|
+
refreshed = ctx.discover_files()
|
|
518
|
+
discovered_files = len(refreshed)
|
|
519
|
+
except Exception as exc:
|
|
520
|
+
degradation_reasons.append(f"context_error:{type(exc).__name__}")
|
|
521
|
+
|
|
522
|
+
try:
|
|
523
|
+
svc = self._get_ast_service()
|
|
524
|
+
stats = svc._store.stats() if hasattr(svc, "_store") else {}
|
|
525
|
+
indexed_files = int(
|
|
526
|
+
stats.get("files", 0)
|
|
527
|
+
or stats.get("files_indexed", 0)
|
|
528
|
+
or len(getattr(svc, "_ast_cache", {}))
|
|
529
|
+
)
|
|
530
|
+
except Exception as exc:
|
|
531
|
+
degradation_reasons.append(f"ast_error:{type(exc).__name__}")
|
|
532
|
+
|
|
533
|
+
if discovered_files == 0:
|
|
534
|
+
degradation_reasons.append("no_files_discovered")
|
|
535
|
+
if indexed_files == 0:
|
|
536
|
+
degradation_reasons.append("no_files_indexed")
|
|
537
|
+
|
|
538
|
+
return {
|
|
539
|
+
"health_status": "degraded" if degradation_reasons else "healthy",
|
|
540
|
+
"discovery_count": discovered_files,
|
|
541
|
+
"ast_indexed_files": indexed_files,
|
|
542
|
+
"degradation_reason": ", ".join(degradation_reasons),
|
|
543
|
+
"active_backend": "local",
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
def hydration_status(self) -> dict:
|
|
547
|
+
"""Return current progressive hydration state."""
|
|
548
|
+
result: dict = {"phase": "unknown", "tier": "unknown"}
|
|
549
|
+
try:
|
|
550
|
+
ast_svc = self._get_ast_service()
|
|
551
|
+
result = ast_svc.hydration_snapshot()
|
|
552
|
+
except Exception:
|
|
553
|
+
pass
|
|
554
|
+
|
|
555
|
+
# Add semantic search coverage
|
|
556
|
+
try:
|
|
557
|
+
sem = self._get_semantic_search()
|
|
558
|
+
progress = sem.get_index_progress()
|
|
559
|
+
result["embedding_coverage"] = round(progress.coverage, 3)
|
|
560
|
+
result["embedding_status"] = progress.status
|
|
561
|
+
except Exception:
|
|
562
|
+
result["embedding_coverage"] = 0.0
|
|
563
|
+
|
|
564
|
+
return result
|
|
565
|
+
|
|
566
|
+
# ------------------------------------------------------------------
|
|
567
|
+
# Tool operations — same signatures as server.py tool functions
|
|
568
|
+
# ------------------------------------------------------------------
|
|
569
|
+
|
|
570
|
+
def reindex(self, *, force: bool = False, embeddings: bool = False) -> dict:
|
|
571
|
+
"""Re-index the codebase. Returns stats.
|
|
572
|
+
|
|
573
|
+
Rebuilds the AST/symbol index. When ``embeddings`` is True, also
|
|
574
|
+
synchronously rebuilds the semantic-search vector index (otherwise
|
|
575
|
+
embeddings are built lazily on first search). The synchronous build is
|
|
576
|
+
what callers like the search-quality eval need so results reflect the
|
|
577
|
+
current chunking/embedder config rather than a stale or absent index.
|
|
578
|
+
"""
|
|
579
|
+
svc = self._get_ast_service()
|
|
580
|
+
svc.initialize(force=force)
|
|
581
|
+
stats = svc._store.stats() if hasattr(svc, "_store") else {}
|
|
582
|
+
result = {"mode": "full" if force else "incremental", **stats}
|
|
583
|
+
if embeddings:
|
|
584
|
+
chunks = self._get_semantic_search().index()
|
|
585
|
+
result["embedded_chunks"] = chunks
|
|
586
|
+
return result
|
|
587
|
+
|
|
588
|
+
def readiness_report_data(
|
|
589
|
+
self,
|
|
590
|
+
phases: list[int] | None = None,
|
|
591
|
+
scope: str = "",
|
|
592
|
+
tracer_bullets: bool = True,
|
|
593
|
+
) -> dict:
|
|
594
|
+
"""Return structured readiness report data."""
|
|
595
|
+
from attocode_intel.readiness import ReadinessEngine
|
|
596
|
+
|
|
597
|
+
engine = ReadinessEngine(project_dir=self._project_dir)
|
|
598
|
+
report = engine.run(phases=phases, scope=scope, tracer_bullets=tracer_bullets)
|
|
599
|
+
return report.to_dict()
|
|
600
|
+
|
|
601
|
+
def readiness_report(
|
|
602
|
+
self,
|
|
603
|
+
*,
|
|
604
|
+
phases: list[int] | None = None,
|
|
605
|
+
scope: str = "",
|
|
606
|
+
tracer_bullets: bool = True,
|
|
607
|
+
min_severity: str = "info",
|
|
608
|
+
) -> str:
|
|
609
|
+
"""Return formatted text readiness report."""
|
|
610
|
+
from attocode_intel.readiness import ReadinessEngine, ReadinessSeverity
|
|
611
|
+
|
|
612
|
+
engine = ReadinessEngine(project_dir=self._project_dir)
|
|
613
|
+
report = engine.run(
|
|
614
|
+
phases=phases,
|
|
615
|
+
scope=scope,
|
|
616
|
+
tracer_bullets=tracer_bullets,
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
severity_order = [
|
|
620
|
+
ReadinessSeverity.PASS,
|
|
621
|
+
ReadinessSeverity.INFO,
|
|
622
|
+
ReadinessSeverity.WARNING,
|
|
623
|
+
ReadinessSeverity.CRITICAL,
|
|
624
|
+
]
|
|
625
|
+
try:
|
|
626
|
+
min_idx = severity_order.index(ReadinessSeverity(min_severity))
|
|
627
|
+
except (ValueError, KeyError):
|
|
628
|
+
min_idx = 0
|
|
629
|
+
|
|
630
|
+
for phase_result in report.phase_results:
|
|
631
|
+
phase_result.findings = [
|
|
632
|
+
finding
|
|
633
|
+
for finding in phase_result.findings
|
|
634
|
+
if severity_order.index(finding.severity) >= min_idx
|
|
635
|
+
]
|
|
636
|
+
return engine.format_report(report)
|
|
637
|
+
|
|
638
|
+
def _get_temporal_analyzer(self) -> TemporalCouplingAnalyzer:
|
|
639
|
+
if self._temporal_analyzer is None:
|
|
640
|
+
from attocode_intel._internal.integrations.context.temporal_coupling import (
|
|
641
|
+
TemporalCouplingAnalyzer,
|
|
642
|
+
)
|
|
643
|
+
self._temporal_analyzer = TemporalCouplingAnalyzer(
|
|
644
|
+
project_dir=self._project_dir,
|
|
645
|
+
)
|
|
646
|
+
return self._temporal_analyzer
|
|
647
|
+
|
|
648
|
+
def change_coupling_data(
|
|
649
|
+
self, file: str, *, days: int = 90, min_coupling: float = 0.3, top_k: int = 20,
|
|
650
|
+
) -> dict:
|
|
651
|
+
"""Get change coupling data for a file."""
|
|
652
|
+
analyzer = self._get_temporal_analyzer()
|
|
653
|
+
results = analyzer.get_change_coupling(
|
|
654
|
+
file, days=days, min_coupling=min_coupling, top_k=top_k,
|
|
655
|
+
)
|
|
656
|
+
return {
|
|
657
|
+
"file": file,
|
|
658
|
+
"days": days,
|
|
659
|
+
"results": [
|
|
660
|
+
{
|
|
661
|
+
"path": e.path,
|
|
662
|
+
"coupling_score": e.coupling_score,
|
|
663
|
+
"co_changes": e.co_changes,
|
|
664
|
+
"individual_changes": e.individual_changes,
|
|
665
|
+
}
|
|
666
|
+
for e in results
|
|
667
|
+
],
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
def churn_hotspots_data(self, *, days: int = 90, top_n: int = 20) -> dict:
|
|
671
|
+
"""Get churn hotspot data."""
|
|
672
|
+
analyzer = self._get_temporal_analyzer()
|
|
673
|
+
results = analyzer.get_churn_hotspots(days=days, top_n=top_n)
|
|
674
|
+
return {
|
|
675
|
+
"days": days,
|
|
676
|
+
"results": [
|
|
677
|
+
{
|
|
678
|
+
"path": e.path,
|
|
679
|
+
"commits": e.commits,
|
|
680
|
+
"authors": e.authors,
|
|
681
|
+
"lines_added": e.lines_added,
|
|
682
|
+
"lines_removed": e.lines_removed,
|
|
683
|
+
"churn_score": e.churn_score,
|
|
684
|
+
}
|
|
685
|
+
for e in results
|
|
686
|
+
],
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
def merge_risk_data(self, files: list[str], *, days: int = 90) -> dict:
|
|
690
|
+
"""Get merge risk predictions."""
|
|
691
|
+
analyzer = self._get_temporal_analyzer()
|
|
692
|
+
svc = self._get_ast_service()
|
|
693
|
+
results = analyzer.get_merge_risk(
|
|
694
|
+
files,
|
|
695
|
+
days=days,
|
|
696
|
+
dep_graph_forward=svc.index.file_dependencies,
|
|
697
|
+
dep_graph_reverse=svc.index.file_dependents,
|
|
698
|
+
)
|
|
699
|
+
max_conf = max((e.confidence for e in results), default=0.0)
|
|
700
|
+
risk_level = "high" if max_conf >= 0.7 else "medium" if max_conf >= 0.4 else "low"
|
|
701
|
+
return {
|
|
702
|
+
"files": files,
|
|
703
|
+
"days": days,
|
|
704
|
+
"risk_level": risk_level,
|
|
705
|
+
"predictions": [
|
|
706
|
+
{
|
|
707
|
+
"path": e.path,
|
|
708
|
+
"reason": e.reason,
|
|
709
|
+
"confidence": e.confidence,
|
|
710
|
+
"coupling_score": e.coupling_score,
|
|
711
|
+
"structural_distance": e.structural_distance,
|
|
712
|
+
}
|
|
713
|
+
for e in results
|
|
714
|
+
],
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
def code_evolution(self, path: str, symbol: str = "", since: str = "", max_results: int = 20) -> str:
|
|
718
|
+
from attocode_intel.tools.history_tools import code_evolution as _code_evolution
|
|
719
|
+
|
|
720
|
+
return _code_evolution(path=path, symbol=symbol, since=since, max_results=max_results)
|
|
721
|
+
|
|
722
|
+
def recent_changes(self, days: int = 7, path: str = "", top_n: int = 20) -> str:
|
|
723
|
+
from attocode_intel.tools.history_tools import recent_changes as _recent_changes
|
|
724
|
+
|
|
725
|
+
return _recent_changes(days=days, path=path, top_n=top_n)
|
|
726
|
+
|
|
727
|
+
def repo_map(self, *, include_symbols: bool = True, max_tokens: int = 6000) -> str:
|
|
728
|
+
ctx = self._get_context_mgr()
|
|
729
|
+
repo = ctx.get_repo_map(include_symbols=include_symbols, max_tokens=max_tokens)
|
|
730
|
+
lines = [repo.tree, ""]
|
|
731
|
+
lines.append(
|
|
732
|
+
f"({repo.total_files} files, {repo.total_lines:,} lines, "
|
|
733
|
+
f"{len(repo.languages)} languages)"
|
|
734
|
+
)
|
|
735
|
+
return "\n".join(lines)
|
|
736
|
+
|
|
737
|
+
# ------------------------------------------------------------------
|
|
738
|
+
# Structured data methods (v2 API — return dicts, not formatted text)
|
|
739
|
+
# ------------------------------------------------------------------
|
|
740
|
+
|
|
741
|
+
def symbols_data(self, path: str) -> list[dict]:
|
|
742
|
+
"""Return raw symbol locations for a file."""
|
|
743
|
+
svc = self._get_ast_service()
|
|
744
|
+
svc.ensure_file_parsed(path)
|
|
745
|
+
locs = svc.get_file_symbols(path)
|
|
746
|
+
return [
|
|
747
|
+
{
|
|
748
|
+
"kind": loc.kind, "name": loc.name,
|
|
749
|
+
"qualified_name": loc.qualified_name,
|
|
750
|
+
"file_path": loc.file_path,
|
|
751
|
+
"start_line": loc.start_line, "end_line": loc.end_line,
|
|
752
|
+
}
|
|
753
|
+
for loc in sorted(locs, key=lambda s: s.start_line)
|
|
754
|
+
]
|
|
755
|
+
|
|
756
|
+
def search_symbols_data(self, name: str, limit: int = 30, kind: str = "",
|
|
757
|
+
task_hint: str = "") -> list[dict]:
|
|
758
|
+
"""Return raw symbol search results with scores."""
|
|
759
|
+
svc = self._get_ast_service()
|
|
760
|
+
scored = svc.search_symbol(name, limit=max(limit * 4, 80) if task_hint else limit,
|
|
761
|
+
kind_filter=kind)
|
|
762
|
+
if task_hint:
|
|
763
|
+
task_scores = self._task_file_scores(task_hint)
|
|
764
|
+
# Exact/fuzzy match quality remains primary. Context only breaks
|
|
765
|
+
# equally matched definitions, never hides a valid definition.
|
|
766
|
+
scored = sorted(scored, key=lambda row: (
|
|
767
|
+
-row[1], -task_scores.get(row[0].file_path, 0.0),
|
|
768
|
+
row[0].file_path, row[0].start_line,
|
|
769
|
+
))[:limit]
|
|
770
|
+
return [
|
|
771
|
+
{
|
|
772
|
+
"kind": loc.kind, "name": loc.name,
|
|
773
|
+
"qualified_name": loc.qualified_name,
|
|
774
|
+
"file_path": loc.file_path,
|
|
775
|
+
"start_line": loc.start_line, "end_line": loc.end_line,
|
|
776
|
+
"score": round(score, 3),
|
|
777
|
+
}
|
|
778
|
+
for loc, score in scored
|
|
779
|
+
]
|
|
780
|
+
|
|
781
|
+
def dependencies_data(self, path: str) -> dict:
|
|
782
|
+
"""Return structured dependency data."""
|
|
783
|
+
svc = self._get_ast_service()
|
|
784
|
+
svc.ensure_file_parsed(path)
|
|
785
|
+
deps = svc.get_dependencies(path)
|
|
786
|
+
dependents = svc.get_dependents(path)
|
|
787
|
+
return {
|
|
788
|
+
"path": path,
|
|
789
|
+
"imports": sorted(deps),
|
|
790
|
+
"imported_by": sorted(dependents),
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
def impact_analysis_data(self, changed_files: list[str]) -> dict:
|
|
794
|
+
"""Return structured impact analysis with BFS depth layers."""
|
|
795
|
+
svc = self._get_ast_service()
|
|
796
|
+
# BFS with depth tracking
|
|
797
|
+
visited: set[str] = set(changed_files)
|
|
798
|
+
frontier = set(changed_files)
|
|
799
|
+
layers: list[dict] = []
|
|
800
|
+
current_depth = 0
|
|
801
|
+
while frontier:
|
|
802
|
+
current_depth += 1
|
|
803
|
+
next_frontier: set[str] = set()
|
|
804
|
+
for f in frontier:
|
|
805
|
+
for dep in svc.get_dependents(f):
|
|
806
|
+
if dep not in visited:
|
|
807
|
+
visited.add(dep)
|
|
808
|
+
next_frontier.add(dep)
|
|
809
|
+
if next_frontier:
|
|
810
|
+
layers.append({"depth": current_depth, "files": sorted(next_frontier)})
|
|
811
|
+
frontier = next_frontier
|
|
812
|
+
|
|
813
|
+
impacted = sorted(visited - set(changed_files))
|
|
814
|
+
return {
|
|
815
|
+
"changed_files": changed_files,
|
|
816
|
+
"impacted_files": impacted,
|
|
817
|
+
"total_impacted": len(impacted),
|
|
818
|
+
"interpretation": "potential_graph_reachability",
|
|
819
|
+
"absence_proven": False,
|
|
820
|
+
"layers": layers,
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
def cross_references_data(self, symbol_name: str) -> dict:
|
|
824
|
+
"""Return structured cross-reference data."""
|
|
825
|
+
svc = self._get_ast_service()
|
|
826
|
+
definitions = svc.find_symbol(symbol_name)
|
|
827
|
+
for definition in definitions:
|
|
828
|
+
svc.ensure_references_indexed(definition.file_path)
|
|
829
|
+
references = svc.get_callers(symbol_name)
|
|
830
|
+
return {
|
|
831
|
+
"symbol": symbol_name,
|
|
832
|
+
"definitions": [
|
|
833
|
+
{
|
|
834
|
+
"kind": loc.kind, "name": loc.name,
|
|
835
|
+
"qualified_name": loc.qualified_name,
|
|
836
|
+
"file_path": loc.file_path,
|
|
837
|
+
"start_line": loc.start_line, "end_line": loc.end_line,
|
|
838
|
+
}
|
|
839
|
+
for loc in definitions
|
|
840
|
+
],
|
|
841
|
+
"references": [
|
|
842
|
+
{"ref_kind": ref.ref_kind, "file_path": ref.file_path, "line": ref.line,
|
|
843
|
+
"source": ref.source, "symbol": ref.symbol_name}
|
|
844
|
+
for ref in references
|
|
845
|
+
],
|
|
846
|
+
"total_references": len(references),
|
|
847
|
+
"ambiguous": len({(d.file_path, d.qualified_name) for d in definitions}) > 1,
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
def call_graph_data(
|
|
851
|
+
self,
|
|
852
|
+
symbol: str,
|
|
853
|
+
*,
|
|
854
|
+
direction: str = "callees",
|
|
855
|
+
depth: int = 1,
|
|
856
|
+
) -> dict:
|
|
857
|
+
"""Return structured call-graph data for ``symbol``.
|
|
858
|
+
|
|
859
|
+
``direction`` is "callees" (forward) or "callers" (reverse).
|
|
860
|
+
``depth`` caps the BFS hops; results from intermediate hops are
|
|
861
|
+
included alongside the final frontier.
|
|
862
|
+
"""
|
|
863
|
+
if direction not in {"callers", "callees"} or depth < 1:
|
|
864
|
+
raise ValueError("call_graph requires callers/callees direction and depth >= 1")
|
|
865
|
+
svc = self._get_ast_service()
|
|
866
|
+
definitions = svc.find_symbol(symbol)
|
|
867
|
+
# Ensure the file(s) containing the symbol have references parsed
|
|
868
|
+
# so call-edges are populated on demand for the first invocation.
|
|
869
|
+
for loc in definitions:
|
|
870
|
+
try:
|
|
871
|
+
svc.ensure_references_indexed(loc.file_path)
|
|
872
|
+
except Exception:
|
|
873
|
+
pass
|
|
874
|
+
|
|
875
|
+
candidates: set[str] = {symbol}
|
|
876
|
+
for loc in definitions:
|
|
877
|
+
if loc.qualified_name:
|
|
878
|
+
candidates.add(loc.qualified_name)
|
|
879
|
+
|
|
880
|
+
edges: set[str] = set()
|
|
881
|
+
for caller_name in candidates:
|
|
882
|
+
if direction == "callers":
|
|
883
|
+
edges.update(svc.get_callers_of(caller_name, depth=depth))
|
|
884
|
+
else:
|
|
885
|
+
edges.update(svc.get_callees_of(caller_name, depth=depth))
|
|
886
|
+
|
|
887
|
+
return {
|
|
888
|
+
"symbol": symbol,
|
|
889
|
+
"direction": direction,
|
|
890
|
+
"depth": depth,
|
|
891
|
+
"definitions": [
|
|
892
|
+
{
|
|
893
|
+
"kind": loc.kind, "name": loc.name,
|
|
894
|
+
"qualified_name": loc.qualified_name,
|
|
895
|
+
"file_path": loc.file_path,
|
|
896
|
+
"start_line": loc.start_line, "end_line": loc.end_line,
|
|
897
|
+
}
|
|
898
|
+
for loc in definitions
|
|
899
|
+
],
|
|
900
|
+
"edges": sorted(edges),
|
|
901
|
+
"total_edges": len(edges),
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
def call_graph(
|
|
905
|
+
self,
|
|
906
|
+
symbol: str,
|
|
907
|
+
*,
|
|
908
|
+
direction: str = "callees",
|
|
909
|
+
depth: int = 1,
|
|
910
|
+
) -> str:
|
|
911
|
+
"""Human-readable call-graph traversal for ``symbol``."""
|
|
912
|
+
if direction not in ("callees", "callers"):
|
|
913
|
+
return f"Error: direction must be 'callees' or 'callers', got '{direction}'"
|
|
914
|
+
if depth < 1:
|
|
915
|
+
return "Error: depth must be >= 1"
|
|
916
|
+
|
|
917
|
+
data = self.call_graph_data(symbol, direction=direction, depth=depth)
|
|
918
|
+
|
|
919
|
+
verb = "calls" if direction == "callees" else "is called by"
|
|
920
|
+
lines = [f"Call graph for '{symbol}' (direction={direction}, depth={depth}):"]
|
|
921
|
+
|
|
922
|
+
defs = data["definitions"]
|
|
923
|
+
if defs:
|
|
924
|
+
lines.append(f"\n Definitions ({len(defs)}):")
|
|
925
|
+
for loc in defs:
|
|
926
|
+
lines.append(
|
|
927
|
+
f" {loc['kind']} {loc['qualified_name'] or loc['name']} "
|
|
928
|
+
f"in {loc['file_path']}:{loc['start_line']}"
|
|
929
|
+
)
|
|
930
|
+
else:
|
|
931
|
+
lines.append("\n Definitions: (none found — symbol may be external or unindexed)")
|
|
932
|
+
|
|
933
|
+
edges = data["edges"]
|
|
934
|
+
lines.append(f"\n {symbol} {verb} ({len(edges)}):")
|
|
935
|
+
if edges:
|
|
936
|
+
for edge in edges[:200]:
|
|
937
|
+
lines.append(f" {edge}")
|
|
938
|
+
if len(edges) > 200:
|
|
939
|
+
lines.append(f" ... and {len(edges) - 200} more")
|
|
940
|
+
else:
|
|
941
|
+
lines.append(" (none)")
|
|
942
|
+
return "\n".join(lines)
|
|
943
|
+
|
|
944
|
+
def file_analysis_data(self, path: str) -> dict:
|
|
945
|
+
"""Return structured file analysis."""
|
|
946
|
+
analyzer = self._get_code_analyzer()
|
|
947
|
+
if not os.path.isabs(path):
|
|
948
|
+
path = os.path.join(self._project_dir, path)
|
|
949
|
+
result = analyzer.analyze_file(path)
|
|
950
|
+
return {
|
|
951
|
+
"path": result.path,
|
|
952
|
+
"language": result.language,
|
|
953
|
+
"line_count": result.line_count,
|
|
954
|
+
"imports": list(result.imports),
|
|
955
|
+
"exports": list(result.exports),
|
|
956
|
+
"chunks": [
|
|
957
|
+
{
|
|
958
|
+
"kind": chunk.kind, "name": chunk.name,
|
|
959
|
+
"parent": chunk.parent, "signature": chunk.signature,
|
|
960
|
+
"start_line": chunk.start_line, "end_line": chunk.end_line,
|
|
961
|
+
}
|
|
962
|
+
for chunk in result.chunks
|
|
963
|
+
],
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
def hotspots_data(self, top_n: int = 15) -> dict:
|
|
967
|
+
"""Return structured hotspot data."""
|
|
968
|
+
from attocode_intel.helpers import (
|
|
969
|
+
_compute_file_metrics,
|
|
970
|
+
_compute_function_hotspots,
|
|
971
|
+
_get_churn_scores,
|
|
972
|
+
)
|
|
973
|
+
|
|
974
|
+
ctx = self._get_context_mgr()
|
|
975
|
+
files = ctx._files
|
|
976
|
+
if not files:
|
|
977
|
+
return {"file_hotspots": [], "function_hotspots": [], "orphan_files": []}
|
|
978
|
+
|
|
979
|
+
svc = self._get_ast_service()
|
|
980
|
+
index = svc._index
|
|
981
|
+
# Background hydration may add entries while analysis iterates the cache.
|
|
982
|
+
ast_cache = svc._ast_cache.copy()
|
|
983
|
+
churn_scores = _get_churn_scores(self._project_dir, files)
|
|
984
|
+
|
|
985
|
+
all_metrics = _compute_file_metrics(files, index, ast_cache, churn_scores)
|
|
986
|
+
all_metrics.sort(key=lambda m: m.composite, reverse=True)
|
|
987
|
+
|
|
988
|
+
def _fm_dict(m):
|
|
989
|
+
return {
|
|
990
|
+
"path": m.path, "line_count": m.line_count,
|
|
991
|
+
"symbol_count": m.symbol_count, "public_symbols": m.public_symbols,
|
|
992
|
+
"fan_in": m.fan_in, "fan_out": m.fan_out,
|
|
993
|
+
"density": m.density, "composite": m.composite,
|
|
994
|
+
"categories": m.categories,
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
fn_hotspots = _compute_function_hotspots(ast_cache, top_n=10)
|
|
998
|
+
orphans = [
|
|
999
|
+
m for m in all_metrics
|
|
1000
|
+
if m.fan_in == 0 and m.fan_out == 0 and m.line_count >= 20
|
|
1001
|
+
and not any(fi.is_test for fi in files if fi.relative_path == m.path)
|
|
1002
|
+
]
|
|
1003
|
+
|
|
1004
|
+
return {
|
|
1005
|
+
"file_hotspots": [_fm_dict(m) for m in all_metrics[:top_n]],
|
|
1006
|
+
"function_hotspots": [
|
|
1007
|
+
{
|
|
1008
|
+
"file_path": fm.file_path, "name": fm.name,
|
|
1009
|
+
"line_count": fm.line_count, "param_count": fm.param_count,
|
|
1010
|
+
"is_public": fm.is_public, "has_return_type": fm.has_return_type,
|
|
1011
|
+
}
|
|
1012
|
+
for fm in fn_hotspots
|
|
1013
|
+
],
|
|
1014
|
+
"orphan_files": [_fm_dict(m) for m in orphans[:10]],
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
def conventions_data(self, sample_size: int = 50, path: str = "") -> dict:
|
|
1018
|
+
"""Return structured convention stats."""
|
|
1019
|
+
from attocode_intel.helpers import _analyze_conventions
|
|
1020
|
+
|
|
1021
|
+
svc = self._get_ast_service()
|
|
1022
|
+
ast_cache = svc._ast_cache.copy()
|
|
1023
|
+
if not ast_cache:
|
|
1024
|
+
return {"sample_size": 0, "path": path, "stats": {}, "dir_stats": {}}
|
|
1025
|
+
|
|
1026
|
+
ctx = self._get_context_mgr()
|
|
1027
|
+
files = ctx._files
|
|
1028
|
+
path_prefix = path.rstrip("/") + "/" if path else ""
|
|
1029
|
+
|
|
1030
|
+
if path_prefix:
|
|
1031
|
+
candidates = sorted(
|
|
1032
|
+
[fi for fi in files if fi.relative_path in ast_cache
|
|
1033
|
+
and fi.relative_path.startswith(path_prefix)],
|
|
1034
|
+
key=lambda fi: fi.importance, reverse=True,
|
|
1035
|
+
)
|
|
1036
|
+
sample_rels = [fi.relative_path for fi in candidates[:sample_size]]
|
|
1037
|
+
else:
|
|
1038
|
+
candidates = sorted(
|
|
1039
|
+
[fi for fi in files if fi.relative_path in ast_cache],
|
|
1040
|
+
key=lambda fi: fi.importance, reverse=True,
|
|
1041
|
+
)
|
|
1042
|
+
sample_rels = [fi.relative_path for fi in candidates[:sample_size]]
|
|
1043
|
+
|
|
1044
|
+
if not sample_rels:
|
|
1045
|
+
return {"sample_size": 0, "path": path, "stats": {}, "dir_stats": {}}
|
|
1046
|
+
|
|
1047
|
+
stats = _analyze_conventions(ast_cache, sample_rels)
|
|
1048
|
+
# Convert Counter objects to plain dicts for serialization
|
|
1049
|
+
stats["decorator_counts"] = dict(stats["decorator_counts"])
|
|
1050
|
+
stats["base_classes"] = dict(stats["base_classes"])
|
|
1051
|
+
stats.pop("files_per_dir", None)
|
|
1052
|
+
# Convert tuple pairs to dicts for Pydantic v2
|
|
1053
|
+
stats["exception_classes"] = [
|
|
1054
|
+
{"name": name, "bases": bases}
|
|
1055
|
+
for name, bases in stats.get("exception_classes", [])
|
|
1056
|
+
]
|
|
1057
|
+
|
|
1058
|
+
dir_stats: dict[str, dict] = {}
|
|
1059
|
+
if not path_prefix:
|
|
1060
|
+
dir_groups: dict[str, list[str]] = {}
|
|
1061
|
+
for rel in sample_rels:
|
|
1062
|
+
parts = rel.split("/")
|
|
1063
|
+
dirname = parts[0] if len(parts) > 1 else "(root)"
|
|
1064
|
+
dir_groups.setdefault(dirname, []).append(rel)
|
|
1065
|
+
for dirname, dir_rels in dir_groups.items():
|
|
1066
|
+
if len(dir_rels) >= 3:
|
|
1067
|
+
ds = _analyze_conventions(ast_cache, dir_rels)
|
|
1068
|
+
ds["decorator_counts"] = dict(ds["decorator_counts"])
|
|
1069
|
+
ds["base_classes"] = dict(ds["base_classes"])
|
|
1070
|
+
ds.pop("files_per_dir", None)
|
|
1071
|
+
ds["exception_classes"] = [
|
|
1072
|
+
{"name": name, "bases": bases}
|
|
1073
|
+
for name, bases in ds.get("exception_classes", [])
|
|
1074
|
+
]
|
|
1075
|
+
dir_stats[dirname] = ds
|
|
1076
|
+
|
|
1077
|
+
return {
|
|
1078
|
+
"sample_size": len(sample_rels),
|
|
1079
|
+
"path": path,
|
|
1080
|
+
"stats": stats,
|
|
1081
|
+
"dir_stats": dir_stats,
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
def dependency_graph_data(self, start_file: str, depth: int = 2) -> dict:
|
|
1085
|
+
"""Return structured dependency graph."""
|
|
1086
|
+
svc = self._get_ast_service()
|
|
1087
|
+
rel = svc._to_rel(start_file)
|
|
1088
|
+
|
|
1089
|
+
def _bfs(get_neighbors):
|
|
1090
|
+
visited: set[str] = set()
|
|
1091
|
+
queue: deque[tuple[str, int]] = deque([(rel, 0)])
|
|
1092
|
+
nodes = []
|
|
1093
|
+
while queue:
|
|
1094
|
+
current, d = queue.popleft()
|
|
1095
|
+
if current in visited or d > depth:
|
|
1096
|
+
continue
|
|
1097
|
+
visited.add(current)
|
|
1098
|
+
if d > 0:
|
|
1099
|
+
nodes.append({"path": current, "depth": d})
|
|
1100
|
+
for n in sorted(get_neighbors(current)):
|
|
1101
|
+
if n not in visited:
|
|
1102
|
+
queue.append((n, d + 1))
|
|
1103
|
+
return nodes
|
|
1104
|
+
|
|
1105
|
+
return {
|
|
1106
|
+
"start_file": rel,
|
|
1107
|
+
"depth": depth,
|
|
1108
|
+
"forward": _bfs(svc.get_dependencies),
|
|
1109
|
+
"reverse": _bfs(svc.get_dependents),
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
def graph_query_data(
|
|
1113
|
+
self, file: str, edge_type: str = "IMPORTS",
|
|
1114
|
+
direction: str = "outbound", depth: int = 2,
|
|
1115
|
+
) -> dict:
|
|
1116
|
+
"""Return structured graph query result."""
|
|
1117
|
+
svc = self._get_ast_service()
|
|
1118
|
+
rel = svc._to_rel(file)
|
|
1119
|
+
depth = min(depth, 5)
|
|
1120
|
+
use_dependents = edge_type == "IMPORTED_BY" or direction == "inbound"
|
|
1121
|
+
|
|
1122
|
+
visited: set[str] = set()
|
|
1123
|
+
queue: deque[tuple[str, int]] = deque([(rel, 0)])
|
|
1124
|
+
hops_by_depth: dict[int, list[str]] = {}
|
|
1125
|
+
while queue:
|
|
1126
|
+
current, d = queue.popleft()
|
|
1127
|
+
if current in visited or d > depth:
|
|
1128
|
+
continue
|
|
1129
|
+
visited.add(current)
|
|
1130
|
+
if d > 0:
|
|
1131
|
+
hops_by_depth.setdefault(d, []).append(current)
|
|
1132
|
+
neighbors = svc.get_dependents(current) if use_dependents else svc.get_dependencies(current)
|
|
1133
|
+
for n in sorted(neighbors):
|
|
1134
|
+
if n not in visited:
|
|
1135
|
+
queue.append((n, d + 1))
|
|
1136
|
+
|
|
1137
|
+
return {
|
|
1138
|
+
"root": rel,
|
|
1139
|
+
"direction": "inbound" if use_dependents else "outbound",
|
|
1140
|
+
"depth": depth,
|
|
1141
|
+
"hops": [{"depth": d, "files": fs} for d, fs in sorted(hops_by_depth.items())],
|
|
1142
|
+
"total_reachable": len(visited) - 1,
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
def graph_dsl_data(self, query: str) -> dict:
|
|
1146
|
+
"""Execute a graph DSL query and return structured results.
|
|
1147
|
+
|
|
1148
|
+
Uses ``GraphQueryParser`` to parse the query and
|
|
1149
|
+
``GraphQueryExecutor`` to run it against the dependency graph.
|
|
1150
|
+
"""
|
|
1151
|
+
from attocode_intel.graph_query_parser import (
|
|
1152
|
+
GraphQueryExecutor,
|
|
1153
|
+
GraphQueryParser,
|
|
1154
|
+
)
|
|
1155
|
+
|
|
1156
|
+
parser = GraphQueryParser()
|
|
1157
|
+
try:
|
|
1158
|
+
ast = parser.parse(query)
|
|
1159
|
+
except ValueError as exc:
|
|
1160
|
+
return {"error": str(exc), "query": query, "results": []}
|
|
1161
|
+
|
|
1162
|
+
ctx = self._get_context_mgr()
|
|
1163
|
+
dep_graph = ctx.dependency_graph
|
|
1164
|
+
files = ctx._files
|
|
1165
|
+
|
|
1166
|
+
# If the context manager hasn't discovered files yet, initialize
|
|
1167
|
+
if not files:
|
|
1168
|
+
ctx.discover_files()
|
|
1169
|
+
files = ctx._files
|
|
1170
|
+
dep_graph = ctx.dependency_graph
|
|
1171
|
+
|
|
1172
|
+
executor = GraphQueryExecutor()
|
|
1173
|
+
try:
|
|
1174
|
+
results = executor.execute(ast, dep_graph, files)
|
|
1175
|
+
except Exception as exc:
|
|
1176
|
+
return {"error": f"Execution error: {exc}", "query": query, "results": []}
|
|
1177
|
+
|
|
1178
|
+
return {
|
|
1179
|
+
"query": query,
|
|
1180
|
+
"results": results,
|
|
1181
|
+
"total": len(results),
|
|
1182
|
+
"truncated": len(results) >= executor.MAX_RESULTS,
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
def find_related_data(self, file: str, top_k: int = 10,
|
|
1186
|
+
task_hint: str = "") -> dict:
|
|
1187
|
+
"""Return structured related-files result."""
|
|
1188
|
+
svc = self._get_ast_service()
|
|
1189
|
+
rel = svc._to_rel(file)
|
|
1190
|
+
idx = svc._index
|
|
1191
|
+
|
|
1192
|
+
neighbors: Counter[str] = Counter()
|
|
1193
|
+
direct_deps = idx.get_dependencies(rel)
|
|
1194
|
+
direct_importers = idx.get_dependents(rel)
|
|
1195
|
+
all_direct = direct_deps | direct_importers
|
|
1196
|
+
|
|
1197
|
+
for n in all_direct:
|
|
1198
|
+
neighbors[n] += 3
|
|
1199
|
+
for n in all_direct:
|
|
1200
|
+
for nn in idx.get_dependencies(n):
|
|
1201
|
+
if nn != rel:
|
|
1202
|
+
neighbors[nn] += 1
|
|
1203
|
+
for nn in idx.get_dependents(n):
|
|
1204
|
+
if nn != rel:
|
|
1205
|
+
neighbors[nn] += 1
|
|
1206
|
+
|
|
1207
|
+
my_deps = idx.get_dependencies(rel)
|
|
1208
|
+
if my_deps:
|
|
1209
|
+
for other_file in set(neighbors.keys()):
|
|
1210
|
+
other_deps_set = idx.file_dependencies.get(other_file, set())
|
|
1211
|
+
if other_deps_set:
|
|
1212
|
+
overlap = len(my_deps & other_deps_set)
|
|
1213
|
+
if overlap > 0:
|
|
1214
|
+
union = len(my_deps | other_deps_set)
|
|
1215
|
+
neighbors[other_file] += round((overlap / union if union else 0) * 5)
|
|
1216
|
+
|
|
1217
|
+
if task_hint:
|
|
1218
|
+
task_scores = self._task_file_scores(task_hint)
|
|
1219
|
+
top = sorted(neighbors.items(), key=lambda row: (
|
|
1220
|
+
-(row[1] + 3.0 * task_scores.get(row[0], 0.0)),
|
|
1221
|
+
-row[1], row[0],
|
|
1222
|
+
))[:top_k]
|
|
1223
|
+
else:
|
|
1224
|
+
top = neighbors.most_common(top_k)
|
|
1225
|
+
return {
|
|
1226
|
+
"file": rel,
|
|
1227
|
+
"related": [
|
|
1228
|
+
{
|
|
1229
|
+
"path": path,
|
|
1230
|
+
"score": score,
|
|
1231
|
+
**({"task_relevance": round(task_scores.get(path, 0.0), 3)}
|
|
1232
|
+
if task_hint else {}),
|
|
1233
|
+
"relation_type": "direct" if path in all_direct else "transitive",
|
|
1234
|
+
}
|
|
1235
|
+
for path, score in top
|
|
1236
|
+
],
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
def community_detection_data(
|
|
1240
|
+
self, min_community_size: int = 3, max_communities: int = 20,
|
|
1241
|
+
) -> dict:
|
|
1242
|
+
"""Return structured community detection result."""
|
|
1243
|
+
svc = self._get_ast_service()
|
|
1244
|
+
idx = svc._index
|
|
1245
|
+
|
|
1246
|
+
all_files: set[str] = set()
|
|
1247
|
+
all_files.update(idx.file_dependencies.keys())
|
|
1248
|
+
all_files.update(idx.file_dependents.keys())
|
|
1249
|
+
all_files.update(f for deps in idx.file_dependencies.values() for f in deps)
|
|
1250
|
+
|
|
1251
|
+
adj: dict[str, set[str]] = {f: set() for f in all_files}
|
|
1252
|
+
weights: dict[tuple[str, str], float] = {}
|
|
1253
|
+
for src, deps in idx.file_dependencies.items():
|
|
1254
|
+
for tgt in deps:
|
|
1255
|
+
adj.setdefault(src, set()).add(tgt)
|
|
1256
|
+
adj.setdefault(tgt, set()).add(src)
|
|
1257
|
+
key = (min(src, tgt), max(src, tgt))
|
|
1258
|
+
is_bidi = tgt in idx.file_dependencies and src in idx.file_dependencies.get(tgt, set())
|
|
1259
|
+
weights[key] = 2.0 if is_bidi else 1.0
|
|
1260
|
+
|
|
1261
|
+
try:
|
|
1262
|
+
from attocode_intel.community import louvain_communities as _louvain
|
|
1263
|
+
communities, modularity_score = _louvain(all_files, adj, weights)
|
|
1264
|
+
method = "louvain"
|
|
1265
|
+
except ImportError:
|
|
1266
|
+
from attocode_intel.community import bfs_connected_components
|
|
1267
|
+
communities, modularity_score = bfs_connected_components(all_files, adj)
|
|
1268
|
+
method = "connected-components"
|
|
1269
|
+
|
|
1270
|
+
communities.sort(key=len, reverse=True)
|
|
1271
|
+
communities = [c for c in communities if len(c) >= min_community_size][:max_communities]
|
|
1272
|
+
|
|
1273
|
+
# Detect trivial results: single community or near-zero modularity
|
|
1274
|
+
is_trivial = len(communities) <= 1 or modularity_score < 0.05
|
|
1275
|
+
|
|
1276
|
+
if is_trivial:
|
|
1277
|
+
# Fallback: directory-based module analysis for sparse graphs
|
|
1278
|
+
return self._directory_based_module_analysis(svc, adj, method, modularity_score)
|
|
1279
|
+
|
|
1280
|
+
result_communities = []
|
|
1281
|
+
for i, community in enumerate(communities, 1):
|
|
1282
|
+
from collections import Counter as _Counter
|
|
1283
|
+
dirs = [os.path.dirname(f) for f in community if os.path.dirname(f)]
|
|
1284
|
+
theme = _Counter(dirs).most_common(1)[0][0] if dirs else "(root)"
|
|
1285
|
+
internal_edges = sum(
|
|
1286
|
+
1 for f in community for n in adj.get(f, set()) if n in community
|
|
1287
|
+
) // 2
|
|
1288
|
+
external_edges = sum(
|
|
1289
|
+
1 for f in community for n in adj.get(f, set()) if n not in community
|
|
1290
|
+
)
|
|
1291
|
+
|
|
1292
|
+
def _int_deg(f: str, _community: set[str] = community) -> int:
|
|
1293
|
+
return sum(1 for n in adj.get(f, set()) if n in _community)
|
|
1294
|
+
|
|
1295
|
+
hub = max(community, key=_int_deg)
|
|
1296
|
+
result_communities.append({
|
|
1297
|
+
"id": i,
|
|
1298
|
+
"files": sorted(community),
|
|
1299
|
+
"size": len(community),
|
|
1300
|
+
"theme": theme,
|
|
1301
|
+
"internal_edges": internal_edges,
|
|
1302
|
+
"external_edges": external_edges,
|
|
1303
|
+
"hub": hub,
|
|
1304
|
+
"hub_internal_degree": _int_deg(hub),
|
|
1305
|
+
})
|
|
1306
|
+
|
|
1307
|
+
return {
|
|
1308
|
+
"method": method,
|
|
1309
|
+
"modularity": round(modularity_score, 3),
|
|
1310
|
+
"communities": result_communities,
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
def _directory_based_module_analysis(
|
|
1314
|
+
self, svc, adj: dict[str, set[str]], method: str, modularity_score: float,
|
|
1315
|
+
) -> dict:
|
|
1316
|
+
"""Fallback module analysis using directory structure when graph is sparse."""
|
|
1317
|
+
from collections import Counter as _Counter
|
|
1318
|
+
|
|
1319
|
+
ast_cache = svc._ast_cache.copy()
|
|
1320
|
+
|
|
1321
|
+
# Group all indexed files by top-level directory
|
|
1322
|
+
dir_groups: dict[str, list[str]] = {}
|
|
1323
|
+
for rel_path in ast_cache:
|
|
1324
|
+
parts = rel_path.split("/")
|
|
1325
|
+
top_dir = parts[0] if len(parts) > 1 else "(root)"
|
|
1326
|
+
dir_groups.setdefault(top_dir, []).append(rel_path)
|
|
1327
|
+
|
|
1328
|
+
# Build directory modules sorted by file count descending
|
|
1329
|
+
modules = []
|
|
1330
|
+
for i, (dir_name, file_list) in enumerate(
|
|
1331
|
+
sorted(dir_groups.items(), key=lambda x: len(x[1]), reverse=True), 1,
|
|
1332
|
+
):
|
|
1333
|
+
total_symbols = 0
|
|
1334
|
+
file_symbol_counts: list[tuple[str, int]] = []
|
|
1335
|
+
for f in file_list:
|
|
1336
|
+
file_ast = ast_cache.get(f)
|
|
1337
|
+
sc = file_ast.symbol_count if file_ast else 0
|
|
1338
|
+
total_symbols += sc
|
|
1339
|
+
file_symbol_counts.append((f, sc))
|
|
1340
|
+
|
|
1341
|
+
# Key files: top 3 by symbol count
|
|
1342
|
+
file_symbol_counts.sort(key=lambda x: x[1], reverse=True)
|
|
1343
|
+
key_files = [
|
|
1344
|
+
{"path": path, "symbols": count}
|
|
1345
|
+
for path, count in file_symbol_counts[:3]
|
|
1346
|
+
]
|
|
1347
|
+
|
|
1348
|
+
modules.append({
|
|
1349
|
+
"id": i,
|
|
1350
|
+
"directory": dir_name,
|
|
1351
|
+
"files": sorted(file_list),
|
|
1352
|
+
"file_count": len(file_list),
|
|
1353
|
+
"total_symbols": total_symbols,
|
|
1354
|
+
"key_files": key_files,
|
|
1355
|
+
})
|
|
1356
|
+
|
|
1357
|
+
# Hub files: top 5 by total edge count (incoming + outgoing)
|
|
1358
|
+
edge_counts: _Counter = _Counter()
|
|
1359
|
+
for node, neighbors in adj.items():
|
|
1360
|
+
edge_counts[node] = len(neighbors)
|
|
1361
|
+
# Also count files from ast_cache that may not be in adj
|
|
1362
|
+
for f in ast_cache:
|
|
1363
|
+
if f not in edge_counts:
|
|
1364
|
+
edge_counts[f] = 0
|
|
1365
|
+
|
|
1366
|
+
hub_files = []
|
|
1367
|
+
for path, degree in edge_counts.most_common(5):
|
|
1368
|
+
file_ast = ast_cache.get(path)
|
|
1369
|
+
hub_files.append({
|
|
1370
|
+
"path": path,
|
|
1371
|
+
"cross_references": degree,
|
|
1372
|
+
"symbols": file_ast.symbol_count if file_ast else 0,
|
|
1373
|
+
})
|
|
1374
|
+
|
|
1375
|
+
return {
|
|
1376
|
+
"method": f"{method}+directory-fallback",
|
|
1377
|
+
"modularity": round(modularity_score, 3),
|
|
1378
|
+
"note": "Graph too sparse for meaningful community detection; using directory-based module analysis",
|
|
1379
|
+
"modules": modules,
|
|
1380
|
+
"hub_files": hub_files,
|
|
1381
|
+
"total_files": len(ast_cache),
|
|
1382
|
+
"total_directories": len(dir_groups),
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
def distill_data(
|
|
1386
|
+
self,
|
|
1387
|
+
files: list[str] | None = None,
|
|
1388
|
+
depth: int = 1,
|
|
1389
|
+
level: str = "signatures",
|
|
1390
|
+
max_tokens: int = 4000,
|
|
1391
|
+
) -> dict:
|
|
1392
|
+
"""Return structured distillation result.
|
|
1393
|
+
|
|
1394
|
+
Args:
|
|
1395
|
+
files: Specific file paths to distill. None = auto-select by importance.
|
|
1396
|
+
depth: Dependency graph expansion hops (default 1, max 3).
|
|
1397
|
+
level: Distillation level — "full", "signatures", or "structure".
|
|
1398
|
+
max_tokens: Token budget (default 4000).
|
|
1399
|
+
"""
|
|
1400
|
+
import os
|
|
1401
|
+
from collections import deque
|
|
1402
|
+
|
|
1403
|
+
# Level: full — delegate to repo_map
|
|
1404
|
+
if level == "full":
|
|
1405
|
+
ctx = self._get_context_mgr()
|
|
1406
|
+
repo = ctx.get_repo_map(include_symbols=True, max_tokens=max_tokens)
|
|
1407
|
+
return {
|
|
1408
|
+
"level": level,
|
|
1409
|
+
"text": repo.tree,
|
|
1410
|
+
"total_files": repo.total_files,
|
|
1411
|
+
"total_lines": repo.total_lines,
|
|
1412
|
+
"languages": repo.languages,
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
ctx = self._get_context_mgr()
|
|
1416
|
+
all_files = ctx._files
|
|
1417
|
+
if not all_files:
|
|
1418
|
+
return {"level": level, "text": "", "files_included": 0}
|
|
1419
|
+
|
|
1420
|
+
svc = self._get_ast_service()
|
|
1421
|
+
ast_cache = svc._ast_cache.copy()
|
|
1422
|
+
dep_graph = ctx.dependency_graph
|
|
1423
|
+
depth = min(depth, 3)
|
|
1424
|
+
|
|
1425
|
+
# File selection
|
|
1426
|
+
if files is not None:
|
|
1427
|
+
project_dir = ctx.root_dir
|
|
1428
|
+
center_rels: list[str] = []
|
|
1429
|
+
for f in files:
|
|
1430
|
+
if os.path.isabs(f):
|
|
1431
|
+
try:
|
|
1432
|
+
rel = os.path.relpath(f, project_dir)
|
|
1433
|
+
except ValueError:
|
|
1434
|
+
rel = f
|
|
1435
|
+
else:
|
|
1436
|
+
rel = f
|
|
1437
|
+
center_rels.append(rel)
|
|
1438
|
+
|
|
1439
|
+
if depth > 0 and dep_graph is not None:
|
|
1440
|
+
visited: set[str] = set(center_rels)
|
|
1441
|
+
queue: deque[tuple[str, int]] = deque()
|
|
1442
|
+
for f in center_rels:
|
|
1443
|
+
queue.append((f, 0))
|
|
1444
|
+
while queue:
|
|
1445
|
+
current, d = queue.popleft()
|
|
1446
|
+
if d >= depth:
|
|
1447
|
+
continue
|
|
1448
|
+
for dep in dep_graph.get_imports(current):
|
|
1449
|
+
if dep not in visited:
|
|
1450
|
+
visited.add(dep)
|
|
1451
|
+
queue.append((dep, d + 1))
|
|
1452
|
+
for dep in dep_graph.get_importers(current):
|
|
1453
|
+
if dep not in visited:
|
|
1454
|
+
visited.add(dep)
|
|
1455
|
+
queue.append((dep, d + 1))
|
|
1456
|
+
selected = sorted(visited)
|
|
1457
|
+
else:
|
|
1458
|
+
selected = sorted(set(center_rels))
|
|
1459
|
+
else:
|
|
1460
|
+
sorted_files = sorted(all_files, key=lambda fi: fi.importance, reverse=True)
|
|
1461
|
+
avg_chars = 80 if level == "signatures" else 40
|
|
1462
|
+
max_files_estimate = int(max_tokens * 3.5 / avg_chars)
|
|
1463
|
+
selected = [fi.relative_path for fi in sorted_files[:max_files_estimate]]
|
|
1464
|
+
|
|
1465
|
+
if level == "signatures":
|
|
1466
|
+
from attocode_intel.tools.distill_tools import _extract_signatures
|
|
1467
|
+
|
|
1468
|
+
text = _extract_signatures(ast_cache, selected, max_tokens)
|
|
1469
|
+
elif level == "structure":
|
|
1470
|
+
from attocode_intel.tools.distill_tools import _build_structure
|
|
1471
|
+
|
|
1472
|
+
text = _build_structure(all_files, dep_graph, selected, max_tokens)
|
|
1473
|
+
else:
|
|
1474
|
+
return {"level": level, "error": f"Unknown level: '{level}'", "files_included": 0}
|
|
1475
|
+
|
|
1476
|
+
return {
|
|
1477
|
+
"level": level,
|
|
1478
|
+
"text": text,
|
|
1479
|
+
"files_included": len(selected),
|
|
1480
|
+
"estimated_tokens": estimate_tokens(text) if text else 0,
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
@staticmethod
|
|
1484
|
+
def _search_after_warmup(mgr: SemanticSearchManager, run: Callable[[], list]) -> list:
|
|
1485
|
+
"""Run a search. When the lexical indexes are still warming, wait once and run it again.
|
|
1486
|
+
|
|
1487
|
+
The first tool call of a server starts the warm-up. If the first search
|
|
1488
|
+
comes before the indexes are ready, it waits up to SEARCH_WARMUP_WAIT
|
|
1489
|
+
(15 seconds by default). Most workspaces are ready in under 10 seconds.
|
|
1490
|
+
"""
|
|
1491
|
+
candidates = run()
|
|
1492
|
+
diagnostics = getattr(mgr, "candidate_diagnostics", None)
|
|
1493
|
+
if not candidates and diagnostics is not None and diagnostics().get("status") == "warming":
|
|
1494
|
+
mgr.wait_for_body_index(timeout=SEARCH_WARMUP_WAIT.get())
|
|
1495
|
+
candidates = run()
|
|
1496
|
+
return candidates
|
|
1497
|
+
|
|
1498
|
+
def semantic_search_data(self, query: str, top_k: int = 10, file_filter: str = "", branch: str = "") -> dict:
|
|
1499
|
+
"""Return structured semantic search results.
|
|
1500
|
+
|
|
1501
|
+
Args:
|
|
1502
|
+
query: Natural language search query.
|
|
1503
|
+
top_k: Number of results to return.
|
|
1504
|
+
file_filter: Optional glob pattern to filter files.
|
|
1505
|
+
branch: Optional branch name for scoping (service mode pass-through;
|
|
1506
|
+
local mode automatically scopes to working-directory files).
|
|
1507
|
+
"""
|
|
1508
|
+
mgr = self._get_semantic_search()
|
|
1509
|
+
candidates = self._search_after_warmup(
|
|
1510
|
+
mgr, lambda: mgr.search(query, top_k=self._retrieval_depth(top_k), file_filter=file_filter))
|
|
1511
|
+
results, ranking = self._rank_search_results(query, candidates, top_k, file_filter)
|
|
1512
|
+
ranking["candidate_pool_count"] = len(candidates)
|
|
1513
|
+
if hasattr(mgr, "candidate_diagnostics"):
|
|
1514
|
+
ranking["index"] = mgr.candidate_diagnostics()
|
|
1515
|
+
index_status = ranking.get("index", {}).get("status")
|
|
1516
|
+
warming = index_status == "warming"
|
|
1517
|
+
follow_up = (next_search_top_k(len(candidates), top_k, len(results))
|
|
1518
|
+
if index_status == "ready" else None)
|
|
1519
|
+
response = {
|
|
1520
|
+
"query": query,
|
|
1521
|
+
"results": [
|
|
1522
|
+
{
|
|
1523
|
+
"file_path": r.file_path,
|
|
1524
|
+
"score": r.score,
|
|
1525
|
+
"snippet": r.text,
|
|
1526
|
+
"line": r.start_line or None,
|
|
1527
|
+
"end_line": r.end_line or None,
|
|
1528
|
+
**hit_evidence(query, r),
|
|
1529
|
+
}
|
|
1530
|
+
for r in results
|
|
1531
|
+
],
|
|
1532
|
+
"total": len(results),
|
|
1533
|
+
"status": "warming" if warming else "ready",
|
|
1534
|
+
"ranking": ranking,
|
|
1535
|
+
}
|
|
1536
|
+
if follow_up is not None:
|
|
1537
|
+
arguments = {"query": query, "top_k": follow_up, "file_filter": file_filter}
|
|
1538
|
+
if branch:
|
|
1539
|
+
arguments["branch"] = branch
|
|
1540
|
+
response["follow_up"] = {
|
|
1541
|
+
"tool": "semantic_search", "arguments": arguments,
|
|
1542
|
+
"reruns_current_index": True,
|
|
1543
|
+
}
|
|
1544
|
+
return response
|
|
1545
|
+
|
|
1546
|
+
def code_evolution_data(
|
|
1547
|
+
self,
|
|
1548
|
+
path: str,
|
|
1549
|
+
symbol: str = "",
|
|
1550
|
+
since: str = "",
|
|
1551
|
+
max_results: int = 20,
|
|
1552
|
+
) -> dict:
|
|
1553
|
+
"""Return structured code evolution data for a file or symbol.
|
|
1554
|
+
|
|
1555
|
+
Uses git subprocess in local mode. In service mode, queries the
|
|
1556
|
+
Commit + CommitFileStat tables.
|
|
1557
|
+
|
|
1558
|
+
Args:
|
|
1559
|
+
path: File path (relative to project root or absolute).
|
|
1560
|
+
symbol: Optional symbol name to filter.
|
|
1561
|
+
since: Optional date filter (e.g. "2024-01-01").
|
|
1562
|
+
max_results: Maximum number of commits to return.
|
|
1563
|
+
"""
|
|
1564
|
+
from attocode_intel.tools.history_tools import (
|
|
1565
|
+
_filter_by_symbol,
|
|
1566
|
+
_parse_evolution_output,
|
|
1567
|
+
_run_git,
|
|
1568
|
+
)
|
|
1569
|
+
|
|
1570
|
+
if os.path.isabs(path):
|
|
1571
|
+
try:
|
|
1572
|
+
rel_path = os.path.relpath(path, self._project_dir)
|
|
1573
|
+
except ValueError:
|
|
1574
|
+
rel_path = path
|
|
1575
|
+
else:
|
|
1576
|
+
rel_path = path
|
|
1577
|
+
|
|
1578
|
+
git_args = [
|
|
1579
|
+
"log", "--follow", "--numstat",
|
|
1580
|
+
"--format=%H|%an|%ae|%aI|%s",
|
|
1581
|
+
f"-{max_results * 2}",
|
|
1582
|
+
]
|
|
1583
|
+
if since:
|
|
1584
|
+
git_args.append(f"--since={since}")
|
|
1585
|
+
git_args.extend(["--", rel_path])
|
|
1586
|
+
|
|
1587
|
+
raw = _run_git(git_args, self._project_dir)
|
|
1588
|
+
commits = _parse_evolution_output(raw)
|
|
1589
|
+
|
|
1590
|
+
if symbol:
|
|
1591
|
+
commits = _filter_by_symbol(commits, symbol)
|
|
1592
|
+
commits = commits[:max_results]
|
|
1593
|
+
|
|
1594
|
+
return {
|
|
1595
|
+
"path": rel_path,
|
|
1596
|
+
"symbol": symbol,
|
|
1597
|
+
"since": since,
|
|
1598
|
+
"commits": [
|
|
1599
|
+
{
|
|
1600
|
+
"sha": c["sha"],
|
|
1601
|
+
"author": c["author"],
|
|
1602
|
+
"email": c["email"],
|
|
1603
|
+
"date": c["date"],
|
|
1604
|
+
"subject": c["subject"],
|
|
1605
|
+
"files": c["files"],
|
|
1606
|
+
}
|
|
1607
|
+
for c in commits
|
|
1608
|
+
],
|
|
1609
|
+
"total": len(commits),
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
def recent_changes_data(
|
|
1613
|
+
self,
|
|
1614
|
+
days: int = 7,
|
|
1615
|
+
path: str = "",
|
|
1616
|
+
top_n: int = 20,
|
|
1617
|
+
) -> dict:
|
|
1618
|
+
"""Return structured recent changes data.
|
|
1619
|
+
|
|
1620
|
+
Uses git subprocess in local mode. In service mode, queries the
|
|
1621
|
+
Commit + CommitFileStat tables.
|
|
1622
|
+
|
|
1623
|
+
Args:
|
|
1624
|
+
days: Look back this many days.
|
|
1625
|
+
path: Optional path prefix filter.
|
|
1626
|
+
top_n: Number of top files to return.
|
|
1627
|
+
"""
|
|
1628
|
+
from collections import defaultdict
|
|
1629
|
+
|
|
1630
|
+
from attocode_intel.tools.history_tools import _run_git
|
|
1631
|
+
|
|
1632
|
+
git_args = [
|
|
1633
|
+
"log",
|
|
1634
|
+
f"--since={days} days ago",
|
|
1635
|
+
"--numstat",
|
|
1636
|
+
"--format=%H|%aI|%s",
|
|
1637
|
+
]
|
|
1638
|
+
if path:
|
|
1639
|
+
git_args.extend(["--", path])
|
|
1640
|
+
|
|
1641
|
+
raw = _run_git(git_args, self._project_dir)
|
|
1642
|
+
|
|
1643
|
+
file_stats: dict[str, dict] = defaultdict(
|
|
1644
|
+
lambda: {"commits": 0, "added": 0, "removed": 0, "last_date": ""}
|
|
1645
|
+
)
|
|
1646
|
+
commit_count = 0
|
|
1647
|
+
current_date = ""
|
|
1648
|
+
|
|
1649
|
+
for line in raw.splitlines():
|
|
1650
|
+
line = line.strip()
|
|
1651
|
+
if not line:
|
|
1652
|
+
continue
|
|
1653
|
+
if "|" in line and line.count("|") >= 2:
|
|
1654
|
+
parts = line.split("|", 2)
|
|
1655
|
+
if len(parts) == 3 and len(parts[0]) >= 7:
|
|
1656
|
+
commit_count += 1
|
|
1657
|
+
current_date = parts[1][:10] if len(parts[1]) >= 10 else parts[1]
|
|
1658
|
+
continue
|
|
1659
|
+
if "\t" in line:
|
|
1660
|
+
parts = line.split("\t")
|
|
1661
|
+
if len(parts) >= 3:
|
|
1662
|
+
try:
|
|
1663
|
+
added = int(parts[0]) if parts[0] != "-" else 0
|
|
1664
|
+
removed = int(parts[1]) if parts[1] != "-" else 0
|
|
1665
|
+
except ValueError:
|
|
1666
|
+
continue
|
|
1667
|
+
file_path = parts[2]
|
|
1668
|
+
if " => " in file_path:
|
|
1669
|
+
file_path = file_path.split(" => ")[-1].rstrip("}")
|
|
1670
|
+
if "{" in file_path:
|
|
1671
|
+
file_path = file_path.replace("{", "").replace("}", "")
|
|
1672
|
+
stats = file_stats[file_path]
|
|
1673
|
+
stats["commits"] += 1
|
|
1674
|
+
stats["added"] += added
|
|
1675
|
+
stats["removed"] += removed
|
|
1676
|
+
if current_date and (not stats["last_date"] or current_date > stats["last_date"]):
|
|
1677
|
+
stats["last_date"] = current_date
|
|
1678
|
+
|
|
1679
|
+
ranked = sorted(
|
|
1680
|
+
file_stats.items(),
|
|
1681
|
+
key=lambda kv: (kv[1]["commits"], kv[1]["added"] + kv[1]["removed"]),
|
|
1682
|
+
reverse=True,
|
|
1683
|
+
)[:top_n]
|
|
1684
|
+
|
|
1685
|
+
return {
|
|
1686
|
+
"days": days,
|
|
1687
|
+
"path": path,
|
|
1688
|
+
"commit_count": commit_count,
|
|
1689
|
+
"total_files_changed": len(file_stats),
|
|
1690
|
+
"files": [
|
|
1691
|
+
{
|
|
1692
|
+
"path": fpath,
|
|
1693
|
+
"commits": s["commits"],
|
|
1694
|
+
"added": s["added"],
|
|
1695
|
+
"removed": s["removed"],
|
|
1696
|
+
"last_date": s["last_date"],
|
|
1697
|
+
}
|
|
1698
|
+
for fpath, s in ranked
|
|
1699
|
+
],
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
def security_scan_data(self, mode: str = "full", path: str = "") -> dict:
|
|
1703
|
+
"""Return structured security scan results."""
|
|
1704
|
+
scanner = self._get_security_scanner()
|
|
1705
|
+
report = scanner.scan(mode=mode, path=path)
|
|
1706
|
+
findings = [
|
|
1707
|
+
{
|
|
1708
|
+
"severity": str(finding.severity),
|
|
1709
|
+
"category": str(finding.category),
|
|
1710
|
+
"file_path": finding.file_path,
|
|
1711
|
+
"line": finding.line,
|
|
1712
|
+
"message": finding.message,
|
|
1713
|
+
"suggestion": finding.recommendation,
|
|
1714
|
+
}
|
|
1715
|
+
for finding in report.findings
|
|
1716
|
+
]
|
|
1717
|
+
return {
|
|
1718
|
+
"mode": mode,
|
|
1719
|
+
"path": path,
|
|
1720
|
+
"findings": findings,
|
|
1721
|
+
"total_findings": len(findings),
|
|
1722
|
+
"summary": report.summary,
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
async def _ensure_lsp_started(self) -> None:
|
|
1726
|
+
"""Start LSP servers on first async call."""
|
|
1727
|
+
if not self._lsp_auto_started:
|
|
1728
|
+
self._lsp_auto_started = True
|
|
1729
|
+
lsp = self._get_lsp_manager()
|
|
1730
|
+
try:
|
|
1731
|
+
await lsp.auto_start()
|
|
1732
|
+
except Exception:
|
|
1733
|
+
import logging
|
|
1734
|
+
logging.getLogger(__name__).warning(
|
|
1735
|
+
"LSP auto_start failed for %s — language servers may not be installed",
|
|
1736
|
+
self._project_dir,
|
|
1737
|
+
)
|
|
1738
|
+
|
|
1739
|
+
async def lsp_definition_data(self, file: str, line: int, col: int = 0) -> dict:
|
|
1740
|
+
"""Return structured LSP definition result."""
|
|
1741
|
+
await self._ensure_lsp_started()
|
|
1742
|
+
lsp = self._get_lsp_manager()
|
|
1743
|
+
if not lsp.has_clients():
|
|
1744
|
+
return {"location": None, "error": "No language server running. Install pyright or typescript-language-server."}
|
|
1745
|
+
if not os.path.isabs(file):
|
|
1746
|
+
file = os.path.join(self._project_dir, file)
|
|
1747
|
+
try:
|
|
1748
|
+
loc = await lsp.get_definition(file, line, col)
|
|
1749
|
+
except Exception as e:
|
|
1750
|
+
return {"location": None, "error": f"LSP error: {e}"}
|
|
1751
|
+
if loc is None:
|
|
1752
|
+
return {"location": None, "error": "No definition found at this location"}
|
|
1753
|
+
uri = loc.uri
|
|
1754
|
+
if uri.startswith("file://"):
|
|
1755
|
+
uri = uri[7:]
|
|
1756
|
+
try:
|
|
1757
|
+
uri = os.path.relpath(uri, self._project_dir)
|
|
1758
|
+
except ValueError:
|
|
1759
|
+
pass
|
|
1760
|
+
return {
|
|
1761
|
+
"location": {
|
|
1762
|
+
"file": uri,
|
|
1763
|
+
"line": loc.range.start.line + 1,
|
|
1764
|
+
"col": loc.range.start.character + 1,
|
|
1765
|
+
},
|
|
1766
|
+
"error": None,
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
async def lsp_references_data(
|
|
1770
|
+
self, file: str, line: int, col: int = 0, include_declaration: bool = True,
|
|
1771
|
+
) -> dict:
|
|
1772
|
+
"""Return structured LSP references."""
|
|
1773
|
+
await self._ensure_lsp_started()
|
|
1774
|
+
lsp = self._get_lsp_manager()
|
|
1775
|
+
if not lsp.has_clients():
|
|
1776
|
+
return {"locations": [], "total": 0, "error": "No language server running. Install pyright or typescript-language-server."}
|
|
1777
|
+
if not os.path.isabs(file):
|
|
1778
|
+
file = os.path.join(self._project_dir, file)
|
|
1779
|
+
try:
|
|
1780
|
+
locs = await lsp.get_references(file, line, col, include_declaration=include_declaration)
|
|
1781
|
+
except Exception as e:
|
|
1782
|
+
return {"locations": [], "total": 0, "error": f"LSP error: {e}"}
|
|
1783
|
+
locations = []
|
|
1784
|
+
for loc in locs:
|
|
1785
|
+
uri = loc.uri
|
|
1786
|
+
if uri.startswith("file://"):
|
|
1787
|
+
uri = uri[7:]
|
|
1788
|
+
try:
|
|
1789
|
+
uri = os.path.relpath(uri, self._project_dir)
|
|
1790
|
+
except ValueError:
|
|
1791
|
+
pass
|
|
1792
|
+
locations.append({
|
|
1793
|
+
"file": uri,
|
|
1794
|
+
"line": loc.range.start.line + 1,
|
|
1795
|
+
"col": loc.range.start.character + 1,
|
|
1796
|
+
})
|
|
1797
|
+
return {"locations": locations, "total": len(locs), "error": None}
|
|
1798
|
+
|
|
1799
|
+
async def lsp_hover_data(self, file: str, line: int, col: int = 0) -> dict:
|
|
1800
|
+
"""Return structured LSP hover info."""
|
|
1801
|
+
await self._ensure_lsp_started()
|
|
1802
|
+
lsp = self._get_lsp_manager()
|
|
1803
|
+
if not lsp.has_clients():
|
|
1804
|
+
return {"content": None, "file": file, "line": line, "col": col,
|
|
1805
|
+
"error": "No language server running. Install pyright or typescript-language-server."}
|
|
1806
|
+
if not os.path.isabs(file):
|
|
1807
|
+
file = os.path.join(self._project_dir, file)
|
|
1808
|
+
try:
|
|
1809
|
+
info = await lsp.get_hover(file, line, col)
|
|
1810
|
+
except Exception as e:
|
|
1811
|
+
return {"content": None, "file": file, "line": line, "col": col,
|
|
1812
|
+
"error": f"LSP error: {e}"}
|
|
1813
|
+
return {"content": info, "file": file, "line": line, "col": col, "error": None}
|
|
1814
|
+
|
|
1815
|
+
def lsp_diagnostics_data(self, file: str) -> dict:
|
|
1816
|
+
"""Return structured LSP diagnostics."""
|
|
1817
|
+
lsp = self._get_lsp_manager()
|
|
1818
|
+
if not lsp.has_clients():
|
|
1819
|
+
return {"file": file, "diagnostics": [], "total": 0,
|
|
1820
|
+
"error": "No language server running. Install pyright or typescript-language-server."}
|
|
1821
|
+
if not os.path.isabs(file):
|
|
1822
|
+
file = os.path.join(self._project_dir, file)
|
|
1823
|
+
try:
|
|
1824
|
+
diags = lsp.get_diagnostics(file)
|
|
1825
|
+
except Exception as e:
|
|
1826
|
+
return {"file": file, "diagnostics": [], "total": 0,
|
|
1827
|
+
"error": f"LSP error: {e}"}
|
|
1828
|
+
return {
|
|
1829
|
+
"file": file,
|
|
1830
|
+
"diagnostics": [
|
|
1831
|
+
{
|
|
1832
|
+
"severity": d.severity,
|
|
1833
|
+
"source": d.source,
|
|
1834
|
+
"code": str(d.code) if d.code else None,
|
|
1835
|
+
"line": d.range.start.line + 1,
|
|
1836
|
+
"col": d.range.start.character + 1,
|
|
1837
|
+
"message": d.message,
|
|
1838
|
+
}
|
|
1839
|
+
for d in diags
|
|
1840
|
+
],
|
|
1841
|
+
"total": len(diags),
|
|
1842
|
+
"error": None,
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
async def lsp_completions_data(
|
|
1846
|
+
self, file: str, line: int, col: int = 0, limit: int = 20,
|
|
1847
|
+
) -> dict:
|
|
1848
|
+
"""Return structured LSP completion suggestions at position."""
|
|
1849
|
+
await self._ensure_lsp_started()
|
|
1850
|
+
lsp = self._get_lsp_manager()
|
|
1851
|
+
if not lsp.has_clients():
|
|
1852
|
+
return {
|
|
1853
|
+
"file": file, "line": line, "col": col,
|
|
1854
|
+
"completions": [], "total": 0,
|
|
1855
|
+
"error": "No language server running. Install pyright or typescript-language-server.",
|
|
1856
|
+
}
|
|
1857
|
+
if not os.path.isabs(file):
|
|
1858
|
+
file = os.path.join(self._project_dir, file)
|
|
1859
|
+
try:
|
|
1860
|
+
items = await lsp.get_completions(file, line, col)
|
|
1861
|
+
except Exception as e:
|
|
1862
|
+
return {"file": file, "line": line, "col": col, "completions": [], "total": 0,
|
|
1863
|
+
"error": f"LSP error: {e}"}
|
|
1864
|
+
return {
|
|
1865
|
+
"file": file, "line": line, "col": col,
|
|
1866
|
+
"completions": [
|
|
1867
|
+
{
|
|
1868
|
+
"label": c.label,
|
|
1869
|
+
"kind": c.kind,
|
|
1870
|
+
"detail": c.detail,
|
|
1871
|
+
"documentation": c.documentation,
|
|
1872
|
+
"insert_text": c.insert_text,
|
|
1873
|
+
}
|
|
1874
|
+
for c in items[:limit]
|
|
1875
|
+
],
|
|
1876
|
+
"total": len(items),
|
|
1877
|
+
"error": None,
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
async def lsp_workspace_symbol_data(
|
|
1881
|
+
self, query: str, limit: int = 30,
|
|
1882
|
+
) -> dict:
|
|
1883
|
+
"""Return structured LSP workspace symbol search results."""
|
|
1884
|
+
await self._ensure_lsp_started()
|
|
1885
|
+
lsp = self._get_lsp_manager()
|
|
1886
|
+
if not lsp.has_clients():
|
|
1887
|
+
return {
|
|
1888
|
+
"query": query, "symbols": [], "total": 0,
|
|
1889
|
+
"error": "No language server running. Install pyright or typescript-language-server.",
|
|
1890
|
+
}
|
|
1891
|
+
try:
|
|
1892
|
+
symbols = await lsp.search_symbols(query, max_results=limit)
|
|
1893
|
+
except Exception as e:
|
|
1894
|
+
return {"query": query, "symbols": [], "total": 0, "error": f"LSP error: {e}"}
|
|
1895
|
+
return {
|
|
1896
|
+
"query": query,
|
|
1897
|
+
"symbols": [
|
|
1898
|
+
{
|
|
1899
|
+
"name": s.name,
|
|
1900
|
+
"kind": s.kind,
|
|
1901
|
+
"file": s.uri[7:] if s.uri.startswith("file://") else s.uri,
|
|
1902
|
+
"line": s.range.start.line + 1,
|
|
1903
|
+
"container": s.container_name,
|
|
1904
|
+
"detail": s.detail,
|
|
1905
|
+
}
|
|
1906
|
+
for s in symbols
|
|
1907
|
+
],
|
|
1908
|
+
"total": len(symbols),
|
|
1909
|
+
"error": None,
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
async def lsp_incoming_calls_data(
|
|
1913
|
+
self, file: str, line: int, col: int = 0,
|
|
1914
|
+
) -> dict:
|
|
1915
|
+
"""Return structured LSP incoming call hierarchy."""
|
|
1916
|
+
await self._ensure_lsp_started()
|
|
1917
|
+
lsp = self._get_lsp_manager()
|
|
1918
|
+
if not lsp.has_clients():
|
|
1919
|
+
return {
|
|
1920
|
+
"symbol": "", "file": file, "line": line, "col": col,
|
|
1921
|
+
"callers": [], "total": 0,
|
|
1922
|
+
"error": "No language server running. Install pyright or typescript-language-server.",
|
|
1923
|
+
}
|
|
1924
|
+
if not os.path.isabs(file):
|
|
1925
|
+
file = os.path.join(self._project_dir, file)
|
|
1926
|
+
try:
|
|
1927
|
+
client = lsp._get_client_for_file(file) # type: ignore[attr-defined]
|
|
1928
|
+
if not client:
|
|
1929
|
+
return {
|
|
1930
|
+
"symbol": "", "file": file, "line": line, "col": col,
|
|
1931
|
+
"callers": [], "total": 0,
|
|
1932
|
+
"error": f"No LSP server for {file}",
|
|
1933
|
+
}
|
|
1934
|
+
uri = lsp._to_uri(file) # type: ignore[attr-defined]
|
|
1935
|
+
prepared = await client.prepare_call_hierarchy(uri, line, col)
|
|
1936
|
+
if not prepared:
|
|
1937
|
+
return {
|
|
1938
|
+
"symbol": "", "file": file, "line": line, "col": col,
|
|
1939
|
+
"callers": [], "total": 0,
|
|
1940
|
+
"error": "No callable symbol at this position",
|
|
1941
|
+
}
|
|
1942
|
+
incoming = await client.incoming_calls(prepared[0])
|
|
1943
|
+
except Exception as e:
|
|
1944
|
+
return {"symbol": "", "file": file, "line": line, "col": col,
|
|
1945
|
+
"callers": [], "total": 0, "error": f"LSP error: {e}"}
|
|
1946
|
+
symbol_name = prepared[0].get("name", "?")
|
|
1947
|
+
callers = []
|
|
1948
|
+
for call in incoming:
|
|
1949
|
+
from_loc = call.get("from", {})
|
|
1950
|
+
from_uri = from_loc.get("uri", "")
|
|
1951
|
+
if from_uri.startswith("file://"):
|
|
1952
|
+
from_uri = from_uri[7:]
|
|
1953
|
+
from_range = from_loc.get("range", {})
|
|
1954
|
+
from_start = from_range.get("start", {})
|
|
1955
|
+
callers.append({
|
|
1956
|
+
"name": from_loc.get("name", "?"),
|
|
1957
|
+
"container": from_loc.get("containerName"),
|
|
1958
|
+
"file": from_uri,
|
|
1959
|
+
"line": from_start.get("line", 0) + 1,
|
|
1960
|
+
"col": from_start.get("character", 0) + 1,
|
|
1961
|
+
})
|
|
1962
|
+
return {
|
|
1963
|
+
"symbol": symbol_name,
|
|
1964
|
+
"file": file, "line": line, "col": col,
|
|
1965
|
+
"callers": callers, "total": len(callers),
|
|
1966
|
+
"error": None,
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
async def lsp_outgoing_calls_data(
|
|
1970
|
+
self, file: str, line: int, col: int = 0,
|
|
1971
|
+
) -> dict:
|
|
1972
|
+
"""Return structured LSP outgoing call hierarchy."""
|
|
1973
|
+
await self._ensure_lsp_started()
|
|
1974
|
+
lsp = self._get_lsp_manager()
|
|
1975
|
+
if not lsp.has_clients():
|
|
1976
|
+
return {
|
|
1977
|
+
"symbol": "", "file": file, "line": line, "col": col,
|
|
1978
|
+
"callees": [], "total": 0,
|
|
1979
|
+
"error": "No language server running. Install pyright or typescript-language-server.",
|
|
1980
|
+
}
|
|
1981
|
+
if not os.path.isabs(file):
|
|
1982
|
+
file = os.path.join(self._project_dir, file)
|
|
1983
|
+
try:
|
|
1984
|
+
client = lsp._get_client_for_file(file) # type: ignore[attr-defined]
|
|
1985
|
+
if not client:
|
|
1986
|
+
return {
|
|
1987
|
+
"symbol": "", "file": file, "line": line, "col": col,
|
|
1988
|
+
"callees": [], "total": 0,
|
|
1989
|
+
"error": f"No LSP server for {file}",
|
|
1990
|
+
}
|
|
1991
|
+
uri = lsp._to_uri(file) # type: ignore[attr-defined]
|
|
1992
|
+
prepared = await client.prepare_call_hierarchy(uri, line, col)
|
|
1993
|
+
if not prepared:
|
|
1994
|
+
return {
|
|
1995
|
+
"symbol": "", "file": file, "line": line, "col": col,
|
|
1996
|
+
"callees": [], "total": 0,
|
|
1997
|
+
"error": "No callable symbol at this position",
|
|
1998
|
+
}
|
|
1999
|
+
outgoing = await client.outgoing_calls(prepared[0])
|
|
2000
|
+
except Exception as e:
|
|
2001
|
+
return {"symbol": "", "file": file, "line": line, "col": col,
|
|
2002
|
+
"callees": [], "total": 0, "error": f"LSP error: {e}"}
|
|
2003
|
+
symbol_name = prepared[0].get("name", "?")
|
|
2004
|
+
callees = []
|
|
2005
|
+
for call in outgoing:
|
|
2006
|
+
to_loc = call.get("to", {})
|
|
2007
|
+
to_uri = to_loc.get("uri", "")
|
|
2008
|
+
if to_uri.startswith("file://"):
|
|
2009
|
+
to_uri = to_uri[7:]
|
|
2010
|
+
to_range = to_loc.get("range", {})
|
|
2011
|
+
to_start = to_range.get("start", {})
|
|
2012
|
+
callees.append({
|
|
2013
|
+
"name": to_loc.get("name", "?"),
|
|
2014
|
+
"container": to_loc.get("containerName"),
|
|
2015
|
+
"file": to_uri,
|
|
2016
|
+
"line": to_start.get("line", 0) + 1,
|
|
2017
|
+
"col": to_start.get("character", 0) + 1,
|
|
2018
|
+
})
|
|
2019
|
+
return {
|
|
2020
|
+
"symbol": symbol_name,
|
|
2021
|
+
"file": file, "line": line, "col": col,
|
|
2022
|
+
"callees": callees, "total": len(callees),
|
|
2023
|
+
"error": None,
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
def repo_stats_data(self) -> dict:
|
|
2027
|
+
"""Return aggregate repository statistics."""
|
|
2028
|
+
ctx = self._get_context_mgr()
|
|
2029
|
+
files = ctx._files
|
|
2030
|
+
svc = self._get_ast_service()
|
|
2031
|
+
idx = svc._index
|
|
2032
|
+
|
|
2033
|
+
total_lines = sum(fi.line_count for fi in files)
|
|
2034
|
+
languages: Counter[str] = Counter()
|
|
2035
|
+
for fi in files:
|
|
2036
|
+
if fi.language:
|
|
2037
|
+
languages[fi.language] += 1
|
|
2038
|
+
|
|
2039
|
+
symbol_count = sum(len(syms) for syms in idx.file_symbols.values())
|
|
2040
|
+
dep_count = sum(len(deps) for deps in idx.file_dependencies.values())
|
|
2041
|
+
|
|
2042
|
+
return {
|
|
2043
|
+
"file_count": len(files),
|
|
2044
|
+
"total_lines": total_lines,
|
|
2045
|
+
"symbol_count": symbol_count,
|
|
2046
|
+
"dependency_count": dep_count,
|
|
2047
|
+
"languages": dict(languages),
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
def dead_code_data(
|
|
2051
|
+
self,
|
|
2052
|
+
scope: str = "",
|
|
2053
|
+
entry_points: list[str] | None = None,
|
|
2054
|
+
level: str = "symbol",
|
|
2055
|
+
min_confidence: float = 0.5,
|
|
2056
|
+
top_n: int = 30,
|
|
2057
|
+
) -> dict:
|
|
2058
|
+
"""Return structured dead-code detection results.
|
|
2059
|
+
|
|
2060
|
+
Args:
|
|
2061
|
+
scope: Restrict analysis to files under this directory prefix.
|
|
2062
|
+
entry_points: Additional names/paths to treat as entry points.
|
|
2063
|
+
level: ``"symbol"``, ``"file"``, or ``"module"``.
|
|
2064
|
+
min_confidence: Minimum confidence threshold (0.0-1.0).
|
|
2065
|
+
top_n: Maximum results to return.
|
|
2066
|
+
"""
|
|
2067
|
+
from attocode_intel.tools.dead_code_tools import (
|
|
2068
|
+
_find_dead_files,
|
|
2069
|
+
_find_dead_modules,
|
|
2070
|
+
_find_dead_symbols,
|
|
2071
|
+
)
|
|
2072
|
+
|
|
2073
|
+
svc = self._get_ast_service()
|
|
2074
|
+
index = svc._index
|
|
2075
|
+
ast_cache = svc._ast_cache.copy()
|
|
2076
|
+
|
|
2077
|
+
ctx = self._get_context_mgr()
|
|
2078
|
+
all_file_paths = {fi.relative_path for fi in ctx._files}
|
|
2079
|
+
|
|
2080
|
+
ep_set = set(entry_points) if entry_points else set()
|
|
2081
|
+
|
|
2082
|
+
if scope and not scope.endswith("/"):
|
|
2083
|
+
scope = scope + "/"
|
|
2084
|
+
|
|
2085
|
+
min_confidence = max(0.0, min(1.0, min_confidence))
|
|
2086
|
+
top_n = max(1, min(200, top_n))
|
|
2087
|
+
|
|
2088
|
+
if level == "symbol":
|
|
2089
|
+
items = _find_dead_symbols(
|
|
2090
|
+
index, ast_cache, self._project_dir, scope, ep_set,
|
|
2091
|
+
min_confidence, top_n,
|
|
2092
|
+
)
|
|
2093
|
+
elif level == "file":
|
|
2094
|
+
items = _find_dead_files(
|
|
2095
|
+
index, all_file_paths, scope, ep_set,
|
|
2096
|
+
min_confidence, top_n, self._project_dir,
|
|
2097
|
+
)
|
|
2098
|
+
elif level == "module":
|
|
2099
|
+
items = _find_dead_modules(
|
|
2100
|
+
index, all_file_paths, scope, ep_set,
|
|
2101
|
+
min_confidence, top_n, self._project_dir,
|
|
2102
|
+
)
|
|
2103
|
+
else:
|
|
2104
|
+
items = []
|
|
2105
|
+
|
|
2106
|
+
# Aggregate stats
|
|
2107
|
+
total_definitions = sum(len(v) for v in index.definitions.values())
|
|
2108
|
+
total_files = len(all_file_paths)
|
|
2109
|
+
|
|
2110
|
+
return {
|
|
2111
|
+
"level": level,
|
|
2112
|
+
"scope": scope,
|
|
2113
|
+
"items": items,
|
|
2114
|
+
"stats": {
|
|
2115
|
+
"total_definitions": total_definitions,
|
|
2116
|
+
"total_files": total_files,
|
|
2117
|
+
"dead_count": len(items),
|
|
2118
|
+
"min_confidence": min_confidence,
|
|
2119
|
+
},
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
def symbols(self, path: str) -> str:
|
|
2123
|
+
svc = self._get_ast_service()
|
|
2124
|
+
svc.ensure_file_parsed(path)
|
|
2125
|
+
locs = svc.get_file_symbols(path)
|
|
2126
|
+
if not locs:
|
|
2127
|
+
return f"No symbols found in {path}"
|
|
2128
|
+
lines = [f"Symbols in {path}:"]
|
|
2129
|
+
for loc in sorted(locs, key=lambda s: s.start_line):
|
|
2130
|
+
lines.append(f" {loc.kind} {loc.qualified_name} (L{loc.start_line}-{loc.end_line})")
|
|
2131
|
+
return "\n".join(lines)
|
|
2132
|
+
|
|
2133
|
+
def search_symbols(self, name: str, limit: int = 30, kind: str = "",
|
|
2134
|
+
task_hint: str = "") -> str:
|
|
2135
|
+
svc = self._get_ast_service()
|
|
2136
|
+
scored = svc.search_symbol(name, limit=max(limit * 4, 80) if task_hint else limit,
|
|
2137
|
+
kind_filter=kind)
|
|
2138
|
+
if task_hint:
|
|
2139
|
+
task_scores = self._task_file_scores(task_hint)
|
|
2140
|
+
scored = sorted(scored, key=lambda row: (
|
|
2141
|
+
-row[1], -task_scores.get(row[0].file_path, 0.0),
|
|
2142
|
+
row[0].file_path, row[0].start_line,
|
|
2143
|
+
))[:limit]
|
|
2144
|
+
if not scored:
|
|
2145
|
+
return f"No definitions found for '{name}'"
|
|
2146
|
+
lines = [f"Definitions matching '{name}' ({len(scored)} results):"]
|
|
2147
|
+
for loc, score in scored:
|
|
2148
|
+
lines.append(
|
|
2149
|
+
f" [score {score:.3f}] {loc.kind} {loc.qualified_name} "
|
|
2150
|
+
f"in {loc.file_path}:{loc.start_line}-{loc.end_line}"
|
|
2151
|
+
)
|
|
2152
|
+
return "\n".join(lines)
|
|
2153
|
+
|
|
2154
|
+
def dependencies(self, path: str) -> str:
|
|
2155
|
+
svc = self._get_ast_service()
|
|
2156
|
+
svc.ensure_file_parsed(path)
|
|
2157
|
+
deps = svc.get_dependencies(path)
|
|
2158
|
+
dependents = svc.get_dependents(path)
|
|
2159
|
+
lines = [f"Dependencies for {path}:"]
|
|
2160
|
+
lines.append(f"\n Imports from ({len(deps)} files):")
|
|
2161
|
+
if deps:
|
|
2162
|
+
for d in sorted(deps):
|
|
2163
|
+
lines.append(f" {d}")
|
|
2164
|
+
else:
|
|
2165
|
+
lines.append(" (none)")
|
|
2166
|
+
lines.append(f"\n Imported by ({len(dependents)} files):")
|
|
2167
|
+
if dependents:
|
|
2168
|
+
for d in sorted(dependents):
|
|
2169
|
+
lines.append(f" {d}")
|
|
2170
|
+
else:
|
|
2171
|
+
lines.append(" (none)")
|
|
2172
|
+
return "\n".join(lines)
|
|
2173
|
+
|
|
2174
|
+
def impact_analysis(self, changed_files: list[str]) -> str:
|
|
2175
|
+
svc = self._get_ast_service()
|
|
2176
|
+
impacted = svc.get_impact(changed_files)
|
|
2177
|
+
if not impacted:
|
|
2178
|
+
return f"No indexed relationships found for {', '.join(changed_files)}. Missing relationships do not prove absence of impact."
|
|
2179
|
+
lines = [f"Impact analysis for {', '.join(changed_files)}:"]
|
|
2180
|
+
lines.append(f"\n {len(impacted)} potentially related files (static graph reachability):")
|
|
2181
|
+
for f in sorted(impacted):
|
|
2182
|
+
lines.append(f" {f}")
|
|
2183
|
+
return "\n".join(lines)
|
|
2184
|
+
|
|
2185
|
+
def cross_references(self, symbol_name: str) -> str:
|
|
2186
|
+
svc = self._get_ast_service()
|
|
2187
|
+
# On-demand: ensure references are indexed for files containing this symbol
|
|
2188
|
+
for loc in svc.find_symbol(symbol_name):
|
|
2189
|
+
svc.ensure_references_indexed(loc.file_path)
|
|
2190
|
+
definitions = svc.find_symbol(symbol_name)
|
|
2191
|
+
references = svc.get_callers(symbol_name)
|
|
2192
|
+
lines = [f"Cross-references for '{symbol_name}':"]
|
|
2193
|
+
lines.append(f"\n Definitions ({len(definitions)}):")
|
|
2194
|
+
if definitions:
|
|
2195
|
+
for loc in definitions:
|
|
2196
|
+
lines.append(
|
|
2197
|
+
f" {loc.kind} {loc.qualified_name} "
|
|
2198
|
+
f"in {loc.file_path}:{loc.start_line}"
|
|
2199
|
+
)
|
|
2200
|
+
else:
|
|
2201
|
+
lines.append(" (none found)")
|
|
2202
|
+
lines.append(f"\n References ({len(references)}):")
|
|
2203
|
+
if references:
|
|
2204
|
+
for ref in references[:100]:
|
|
2205
|
+
lines.append(f" [{ref.ref_kind}] {ref.file_path}:{ref.line}")
|
|
2206
|
+
if len(references) > 100:
|
|
2207
|
+
lines.append(f" ... and {len(references) - 100} more")
|
|
2208
|
+
else:
|
|
2209
|
+
lines.append(" (none found)")
|
|
2210
|
+
return "\n".join(lines)
|
|
2211
|
+
|
|
2212
|
+
def file_analysis(self, path: str) -> str:
|
|
2213
|
+
analyzer = self._get_code_analyzer()
|
|
2214
|
+
if not os.path.isabs(path):
|
|
2215
|
+
path = os.path.join(self._project_dir, path)
|
|
2216
|
+
result = analyzer.analyze_file(path)
|
|
2217
|
+
lines = [f"Analysis of {result.path}:"]
|
|
2218
|
+
lines.append(f" Language: {result.language}")
|
|
2219
|
+
lines.append(f" Lines: {result.line_count}")
|
|
2220
|
+
if result.imports:
|
|
2221
|
+
lines.append(f"\n Imports ({len(result.imports)}):")
|
|
2222
|
+
for imp in result.imports:
|
|
2223
|
+
lines.append(f" {imp}")
|
|
2224
|
+
if result.exports:
|
|
2225
|
+
lines.append(f"\n Exports ({len(result.exports)}):")
|
|
2226
|
+
for exp in result.exports:
|
|
2227
|
+
lines.append(f" {exp}")
|
|
2228
|
+
if result.chunks:
|
|
2229
|
+
lines.append(f"\n Code chunks ({len(result.chunks)}):")
|
|
2230
|
+
for chunk in result.chunks:
|
|
2231
|
+
sig = f" — {chunk.signature}" if chunk.signature else ""
|
|
2232
|
+
parent = f" (in {chunk.parent})" if chunk.parent else ""
|
|
2233
|
+
lines.append(
|
|
2234
|
+
f" {chunk.kind} {chunk.name}{parent}{sig} "
|
|
2235
|
+
f"L{chunk.start_line}-{chunk.end_line}"
|
|
2236
|
+
)
|
|
2237
|
+
return "\n".join(lines)
|
|
2238
|
+
|
|
2239
|
+
def dependency_graph(self, start_file: str, depth: int = 2) -> str:
|
|
2240
|
+
data = self.dependency_graph_data(start_file, depth)
|
|
2241
|
+
rel = data["start_file"]
|
|
2242
|
+
lines = [f"Dependency graph for {rel} (depth={depth}):"]
|
|
2243
|
+
|
|
2244
|
+
lines.append("\n Imports (forward):")
|
|
2245
|
+
if data["forward"]:
|
|
2246
|
+
for node in data["forward"]:
|
|
2247
|
+
indent = " " + " " * node["depth"]
|
|
2248
|
+
lines.append(f"{indent}{node['path']}")
|
|
2249
|
+
else:
|
|
2250
|
+
lines.append(" (none)")
|
|
2251
|
+
|
|
2252
|
+
lines.append("\n Imported by (reverse):")
|
|
2253
|
+
if data["reverse"]:
|
|
2254
|
+
for node in data["reverse"]:
|
|
2255
|
+
indent = " " + " " * node["depth"]
|
|
2256
|
+
lines.append(f"{indent}{node['path']}")
|
|
2257
|
+
else:
|
|
2258
|
+
lines.append(" (none)")
|
|
2259
|
+
|
|
2260
|
+
return "\n".join(lines)
|
|
2261
|
+
|
|
2262
|
+
def graph_query(
|
|
2263
|
+
self,
|
|
2264
|
+
file: str,
|
|
2265
|
+
edge_type: str = "IMPORTS",
|
|
2266
|
+
direction: str = "outbound",
|
|
2267
|
+
depth: int = 2,
|
|
2268
|
+
) -> str:
|
|
2269
|
+
valid_edge_types = {"IMPORTS", "IMPORTED_BY"}
|
|
2270
|
+
valid_directions = {"outbound", "inbound"}
|
|
2271
|
+
if edge_type not in valid_edge_types:
|
|
2272
|
+
return f"Error: invalid edge_type '{edge_type}'. Must be one of: {', '.join(sorted(valid_edge_types))}"
|
|
2273
|
+
if direction not in valid_directions:
|
|
2274
|
+
return f"Error: invalid direction '{direction}'. Must be one of: {', '.join(sorted(valid_directions))}"
|
|
2275
|
+
|
|
2276
|
+
data = self.graph_query_data(file, edge_type, direction, depth)
|
|
2277
|
+
label = "importers" if data["direction"] == "inbound" else "imports"
|
|
2278
|
+
lines = [f"Graph query: {data['root']} ({label}, depth={data['depth']})"]
|
|
2279
|
+
if not data["hops"]:
|
|
2280
|
+
lines.append(" (no results)")
|
|
2281
|
+
else:
|
|
2282
|
+
for hop in data["hops"]:
|
|
2283
|
+
d = hop["depth"]
|
|
2284
|
+
lines.append(f"\n Hop {d}:")
|
|
2285
|
+
for f_path in hop["files"]:
|
|
2286
|
+
lines.append(f" {'>' * d} {f_path}")
|
|
2287
|
+
lines.append(f"\nTotal: {data['total_reachable']} files reachable")
|
|
2288
|
+
return "\n".join(lines)
|
|
2289
|
+
|
|
2290
|
+
def graph_dsl(self, query: str) -> str:
|
|
2291
|
+
"""Execute a graph DSL query and return formatted text."""
|
|
2292
|
+
data = self.graph_dsl_data(query)
|
|
2293
|
+
if "error" in data and data["error"]:
|
|
2294
|
+
return f"Error: {data['error']}"
|
|
2295
|
+
results = data.get("results", [])
|
|
2296
|
+
if not results:
|
|
2297
|
+
return f"No results for query: {query}"
|
|
2298
|
+
lines = [f"Graph DSL results ({len(results)} matches):"]
|
|
2299
|
+
for i, row in enumerate(results, 1):
|
|
2300
|
+
parts = [f"{k}={v}" for k, v in row.items()]
|
|
2301
|
+
lines.append(f" {i:3d}. {', '.join(parts)}")
|
|
2302
|
+
if data.get("truncated"):
|
|
2303
|
+
lines.append(f"\n (results truncated at {data['total']})")
|
|
2304
|
+
return "\n".join(lines)
|
|
2305
|
+
|
|
2306
|
+
def find_related(self, file: str, top_k: int = 10,
|
|
2307
|
+
task_hint: str = "") -> str:
|
|
2308
|
+
svc = self._get_ast_service()
|
|
2309
|
+
rel = svc._to_rel(file)
|
|
2310
|
+
idx = svc._index
|
|
2311
|
+
|
|
2312
|
+
if rel not in idx.file_symbols and rel not in idx.file_dependencies:
|
|
2313
|
+
return f"Error: file '{rel}' not found in the project index."
|
|
2314
|
+
|
|
2315
|
+
data = self.find_related_data(file, top_k, task_hint=task_hint)
|
|
2316
|
+
lines = [f"Files related to {data['file']}:"]
|
|
2317
|
+
if not data["related"]:
|
|
2318
|
+
lines.append(" (no related files found)")
|
|
2319
|
+
else:
|
|
2320
|
+
for item in data["related"]:
|
|
2321
|
+
lines.append(f" [{item['score']:>3}] {item['path']} ({item['relation_type']})")
|
|
2322
|
+
return "\n".join(lines)
|
|
2323
|
+
|
|
2324
|
+
def community_detection(self, min_community_size: int = 3, max_communities: int = 20) -> str:
|
|
2325
|
+
data = self.community_detection_data(min_community_size, max_communities)
|
|
2326
|
+
|
|
2327
|
+
# Directory-based fallback format
|
|
2328
|
+
if "modules" in data:
|
|
2329
|
+
lines = [
|
|
2330
|
+
f"Architecture module analysis ({data['method']}): "
|
|
2331
|
+
f"{data['total_directories']} directory modules, "
|
|
2332
|
+
f"{data['total_files']} total files "
|
|
2333
|
+
f"(modularity={data['modularity']:.3f})"
|
|
2334
|
+
]
|
|
2335
|
+
if data.get("note"):
|
|
2336
|
+
lines.append(f" Note: {data['note']}")
|
|
2337
|
+
|
|
2338
|
+
for mod in data["modules"]:
|
|
2339
|
+
lines.append(
|
|
2340
|
+
f"\n Module {mod['id']}: directory '{mod['directory']}' "
|
|
2341
|
+
f"— {mod['file_count']} files, {mod['total_symbols']} symbols"
|
|
2342
|
+
)
|
|
2343
|
+
for kf in mod.get("key_files", []):
|
|
2344
|
+
lines.append(f" key file: {kf['path']} ({kf['symbols']} symbols)")
|
|
2345
|
+
sample = mod["files"][:5]
|
|
2346
|
+
for f in sample:
|
|
2347
|
+
lines.append(f" - {f}")
|
|
2348
|
+
if mod["file_count"] > 5:
|
|
2349
|
+
lines.append(f" ... and {mod['file_count'] - 5} more")
|
|
2350
|
+
|
|
2351
|
+
if data.get("hub_files"):
|
|
2352
|
+
lines.append("\n Hub Files (most cross-references):")
|
|
2353
|
+
for hf in data["hub_files"]:
|
|
2354
|
+
lines.append(
|
|
2355
|
+
f" hub: {hf['path']} "
|
|
2356
|
+
f"({hf['cross_references']} cross-references, {hf['symbols']} symbols)"
|
|
2357
|
+
)
|
|
2358
|
+
|
|
2359
|
+
return "\n".join(lines)
|
|
2360
|
+
|
|
2361
|
+
# Standard community format
|
|
2362
|
+
lines = [
|
|
2363
|
+
f"Community detection ({data['method']}): {len(data['communities'])} communities "
|
|
2364
|
+
f"(min size {min_community_size}, modularity={data['modularity']:.3f})"
|
|
2365
|
+
]
|
|
2366
|
+
for comm in data["communities"]:
|
|
2367
|
+
lines.append(f"\n Community {comm['id']} ({comm['size']} files) — theme: {comm['theme']}")
|
|
2368
|
+
lines.append(f" Internal edges: {comm['internal_edges']}, External edges: {comm['external_edges']}")
|
|
2369
|
+
lines.append(f" Hub: {comm['hub']} (internal degree {comm['hub_internal_degree']})")
|
|
2370
|
+
sample = comm["files"][:5]
|
|
2371
|
+
for f in sample:
|
|
2372
|
+
lines.append(f" - {f}")
|
|
2373
|
+
if comm["size"] > 5:
|
|
2374
|
+
lines.append(f" ... and {comm['size'] - 5} more")
|
|
2375
|
+
return "\n".join(lines)
|
|
2376
|
+
|
|
2377
|
+
def relevant_context(
|
|
2378
|
+
self,
|
|
2379
|
+
files: list[str],
|
|
2380
|
+
depth: int = 1,
|
|
2381
|
+
max_tokens: int = 4000,
|
|
2382
|
+
include_symbols: bool = True,
|
|
2383
|
+
task_hint: str = "",
|
|
2384
|
+
) -> str:
|
|
2385
|
+
from attocode_intel._internal.integrations.context.semantic_search import (
|
|
2386
|
+
ContextAssemblyConfig,
|
|
2387
|
+
)
|
|
2388
|
+
|
|
2389
|
+
cc = self._context_config or ContextAssemblyConfig()
|
|
2390
|
+
|
|
2391
|
+
svc = self._get_ast_service()
|
|
2392
|
+
ctx = self._get_context_mgr()
|
|
2393
|
+
all_files = {fi.relative_path: fi for fi in ctx._files}
|
|
2394
|
+
|
|
2395
|
+
depth = min(depth, cc.max_depth)
|
|
2396
|
+
center_rels: list[str] = []
|
|
2397
|
+
for f in files:
|
|
2398
|
+
rel = os.path.normpath(svc._to_rel(f))
|
|
2399
|
+
if rel in all_files:
|
|
2400
|
+
center_rels.append(rel)
|
|
2401
|
+
if not center_rels:
|
|
2402
|
+
return "No valid files provided."
|
|
2403
|
+
|
|
2404
|
+
visited: dict[str, tuple[int, str]] = {}
|
|
2405
|
+
queue: deque[tuple[str, int, str]] = deque()
|
|
2406
|
+
for rel in center_rels:
|
|
2407
|
+
visited[rel] = (0, "center")
|
|
2408
|
+
queue.append((rel, 0, "center"))
|
|
2409
|
+
|
|
2410
|
+
while queue:
|
|
2411
|
+
current, d, _rel_type = queue.popleft()
|
|
2412
|
+
if d >= depth:
|
|
2413
|
+
continue
|
|
2414
|
+
for dep in svc.get_dependencies(current):
|
|
2415
|
+
if dep not in visited:
|
|
2416
|
+
relationship = "imported-by-center" if d == 0 else "transitive-import"
|
|
2417
|
+
visited[dep] = (d + 1, relationship)
|
|
2418
|
+
queue.append((dep, d + 1, relationship))
|
|
2419
|
+
for dep in svc.get_dependents(current):
|
|
2420
|
+
if dep not in visited:
|
|
2421
|
+
relationship = "imports-center" if d == 0 else "transitive-importer"
|
|
2422
|
+
visited[dep] = (d + 1, relationship)
|
|
2423
|
+
queue.append((dep, d + 1, relationship))
|
|
2424
|
+
|
|
2425
|
+
task_scores = self._task_file_scores(task_hint) if task_hint else {}
|
|
2426
|
+
|
|
2427
|
+
def _sort_key(item):
|
|
2428
|
+
rel, (dist, _) = item
|
|
2429
|
+
fi = all_files.get(rel)
|
|
2430
|
+
importance = fi.importance if fi else 0.0
|
|
2431
|
+
return (dist, -task_scores.get(rel, 0.0), -importance, rel)
|
|
2432
|
+
|
|
2433
|
+
sorted_files = sorted(visited.items(), key=_sort_key)
|
|
2434
|
+
sections: list[str] = []
|
|
2435
|
+
token_est = 0
|
|
2436
|
+
|
|
2437
|
+
for rel, (dist, relationship) in sorted_files:
|
|
2438
|
+
fi = all_files.get(rel)
|
|
2439
|
+
if include_symbols and fi is not None:
|
|
2440
|
+
svc.ensure_file_parsed(rel)
|
|
2441
|
+
file_ast = svc._ast_cache.get(rel)
|
|
2442
|
+
lang = fi.language if fi else ""
|
|
2443
|
+
line_count = fi.line_count if fi else 0
|
|
2444
|
+
importance = fi.importance if fi else 0.0
|
|
2445
|
+
|
|
2446
|
+
header = f"{' ' * dist}{rel}"
|
|
2447
|
+
meta = f" {lang}, {line_count}L, importance={importance:.2f}, {relationship}"
|
|
2448
|
+
file_section = [header, meta]
|
|
2449
|
+
|
|
2450
|
+
if include_symbols and file_ast:
|
|
2451
|
+
max_sym = cc.center_symbol_cap if dist == 0 else cc.neighbor_symbol_cap
|
|
2452
|
+
sym_lines: list[str] = []
|
|
2453
|
+
functions = sorted(file_ast.functions, key=lambda fn: (fn.is_nested, fn.name.startswith("_"), fn.start_line))
|
|
2454
|
+
# Keep a representative API surface when functions and types coexist.
|
|
2455
|
+
classes = sorted(file_ast.classes, key=lambda cls: (-len(cls.methods), cls.start_line))
|
|
2456
|
+
fn_limit = max_sym if not classes else max(1, max_sym // 2)
|
|
2457
|
+
for fn in functions[:fn_limit]:
|
|
2458
|
+
params = ", ".join(p.name for p in fn.parameters[:cc.param_preview_limit])
|
|
2459
|
+
ret = f" -> {fn.return_type}" if fn.return_type else ""
|
|
2460
|
+
sym_lines.append(f" fn {fn.name}({params}){ret}")
|
|
2461
|
+
for cls in classes[:max_sym - len(sym_lines)]:
|
|
2462
|
+
bases = f"({', '.join(cls.bases[:cc.base_preview_limit])})" if cls.bases else ""
|
|
2463
|
+
methods_preview = ", ".join(m.name for m in cls.methods[:cc.method_preview_limit])
|
|
2464
|
+
sym_lines.append(f" class {cls.name}{bases}: {methods_preview}")
|
|
2465
|
+
remaining = max_sym - len(sym_lines)
|
|
2466
|
+
if remaining < 0:
|
|
2467
|
+
sym_lines = sym_lines[:max_sym]
|
|
2468
|
+
sym_lines.append(" ... and more")
|
|
2469
|
+
file_section.extend(sym_lines)
|
|
2470
|
+
|
|
2471
|
+
section_text = "\n".join(file_section)
|
|
2472
|
+
section_tokens = estimate_tokens(section_text)
|
|
2473
|
+
if token_est + section_tokens > max_tokens and sections:
|
|
2474
|
+
sections.append(f" ... and {len(sorted_files) - len(sections)} more files (truncated)")
|
|
2475
|
+
break
|
|
2476
|
+
sections.append(section_text)
|
|
2477
|
+
token_est += section_tokens
|
|
2478
|
+
|
|
2479
|
+
header_text = (
|
|
2480
|
+
f"Subgraph capsule for {', '.join(center_rels)} "
|
|
2481
|
+
f"(depth={depth}, {len(visited)} files):\n"
|
|
2482
|
+
)
|
|
2483
|
+
return header_text + "\n".join(sections)
|
|
2484
|
+
|
|
2485
|
+
def project_summary(self, max_tokens: int = 4000) -> str:
|
|
2486
|
+
# Import the synthesis helpers from helpers module
|
|
2487
|
+
from attocode_intel.helpers import (
|
|
2488
|
+
_classify_layers,
|
|
2489
|
+
_detect_build_system,
|
|
2490
|
+
_detect_project_name,
|
|
2491
|
+
_detect_tech_stack,
|
|
2492
|
+
_find_entry_points,
|
|
2493
|
+
_find_hub_files,
|
|
2494
|
+
_summarize_directories,
|
|
2495
|
+
)
|
|
2496
|
+
|
|
2497
|
+
ctx = self._get_context_mgr()
|
|
2498
|
+
files = ctx._files
|
|
2499
|
+
if not files:
|
|
2500
|
+
return "No files discovered in this project."
|
|
2501
|
+
|
|
2502
|
+
repo = ctx.get_repo_map(include_symbols=False, max_tokens=500)
|
|
2503
|
+
svc = self._get_ast_service()
|
|
2504
|
+
index = svc._index
|
|
2505
|
+
ast_cache = svc._ast_cache.copy()
|
|
2506
|
+
|
|
2507
|
+
sections: list[tuple[str, str, int]] = []
|
|
2508
|
+
|
|
2509
|
+
name = _detect_project_name(self._project_dir)
|
|
2510
|
+
top_langs = sorted(repo.languages.items(), key=lambda x: -x[1])[:8]
|
|
2511
|
+
lang_str = ", ".join(f"{lang} ({count})" for lang, count in top_langs)
|
|
2512
|
+
identity = (
|
|
2513
|
+
f"Project: {name}\n"
|
|
2514
|
+
f"Files: {repo.total_files}, Lines: {repo.total_lines:,}\n"
|
|
2515
|
+
f"Languages: {lang_str or 'unknown'}"
|
|
2516
|
+
)
|
|
2517
|
+
sections.append(("Overview", identity, 10))
|
|
2518
|
+
|
|
2519
|
+
entries = _find_entry_points(files, index)
|
|
2520
|
+
if entries:
|
|
2521
|
+
entry_lines = [f" {path} — {reason}" for path, reason in entries[:20]]
|
|
2522
|
+
sections.append(("Entry Points", "\n".join(entry_lines), 9))
|
|
2523
|
+
|
|
2524
|
+
if index.file_dependents:
|
|
2525
|
+
hubs = _find_hub_files(files, index, top_n=10)
|
|
2526
|
+
if hubs:
|
|
2527
|
+
hub_lines = [f" {path} (fan-in={fi}, fan-out={fo})" for path, fi, fo in hubs]
|
|
2528
|
+
sections.append(("Core Files (by dependents)", "\n".join(hub_lines), 8))
|
|
2529
|
+
|
|
2530
|
+
dirs = _summarize_directories(files)
|
|
2531
|
+
total_files = len(files)
|
|
2532
|
+
dir_lines = []
|
|
2533
|
+
for d, count, loc in dirs[:15]:
|
|
2534
|
+
pct = count / total_files * 100 if total_files else 0
|
|
2535
|
+
if d in ("site", "docs", "doc", ".git") and pct < 10:
|
|
2536
|
+
continue
|
|
2537
|
+
dir_lines.append(f" {d}/ — {count} files, {loc:,} lines ({pct:.0f}%)")
|
|
2538
|
+
if dir_lines:
|
|
2539
|
+
sections.append(("Directory Layout", "\n".join(dir_lines), 7))
|
|
2540
|
+
|
|
2541
|
+
if index.file_dependents:
|
|
2542
|
+
layers = _classify_layers(files, index)
|
|
2543
|
+
layer_lines = []
|
|
2544
|
+
for layer_name, layer_files in layers.items():
|
|
2545
|
+
if layer_files:
|
|
2546
|
+
examples = ", ".join(layer_files[:5])
|
|
2547
|
+
more = f" (+{len(layer_files) - 5})" if len(layer_files) > 5 else ""
|
|
2548
|
+
layer_lines.append(f" {layer_name}: {len(layer_files)} files — {examples}{more}")
|
|
2549
|
+
if layer_lines:
|
|
2550
|
+
sections.append(("Dependency Layers", "\n".join(layer_lines), 5))
|
|
2551
|
+
|
|
2552
|
+
if ast_cache:
|
|
2553
|
+
stack = _detect_tech_stack(ast_cache)
|
|
2554
|
+
if stack:
|
|
2555
|
+
sections.append(("Tech Stack", " " + ", ".join(stack), 6))
|
|
2556
|
+
|
|
2557
|
+
test_files = [f for f in files if f.is_test]
|
|
2558
|
+
if test_files:
|
|
2559
|
+
has_prefix = any("test_" in os.path.basename(f.relative_path) for f in test_files)
|
|
2560
|
+
test_pat = "test_*.py" if has_prefix else "*_test.py"
|
|
2561
|
+
sections.append(("Tests", f" {len(test_files)} test files (pattern: {test_pat})", 4))
|
|
2562
|
+
|
|
2563
|
+
build = _detect_build_system(files)
|
|
2564
|
+
if build != "unknown":
|
|
2565
|
+
sections.append(("Build System", f" {build}", 3))
|
|
2566
|
+
|
|
2567
|
+
sections.sort(key=lambda x: x[2], reverse=True)
|
|
2568
|
+
output_parts: list[str] = []
|
|
2569
|
+
token_est = 0
|
|
2570
|
+
for header, text, _prio in sections:
|
|
2571
|
+
section_text = f"## {header}\n{text}"
|
|
2572
|
+
section_tokens = estimate_tokens(section_text)
|
|
2573
|
+
if token_est + section_tokens > max_tokens and output_parts:
|
|
2574
|
+
break
|
|
2575
|
+
output_parts.append(section_text)
|
|
2576
|
+
token_est += section_tokens
|
|
2577
|
+
return "\n\n".join(output_parts)
|
|
2578
|
+
|
|
2579
|
+
def bootstrap(self, task_hint: str = "", max_tokens: int = 8000, indexing_depth: str = "auto") -> str:
|
|
2580
|
+
from attocode_intel._internal.integrations.context.semantic_search import (
|
|
2581
|
+
ContextAssemblyConfig,
|
|
2582
|
+
)
|
|
2583
|
+
from attocode_intel._internal.integrations.utilities.token_estimate import count_tokens
|
|
2584
|
+
from attocode_intel.helpers import _analyze_conventions, _format_conventions
|
|
2585
|
+
from attocode_intel.output import bounded_text
|
|
2586
|
+
|
|
2587
|
+
cc = self._context_config or ContextAssemblyConfig()
|
|
2588
|
+
|
|
2589
|
+
ctx = self._get_context_mgr()
|
|
2590
|
+
ctx.refresh_excluded_checkout_roots()
|
|
2591
|
+
files = ctx._files
|
|
2592
|
+
checkout_guidance = ""
|
|
2593
|
+
if ctx.excluded_checkout_roots or ctx.excluded_checkout_roots_truncated:
|
|
2594
|
+
paths = "\n".join(
|
|
2595
|
+
f" {relative} (workspace={Path(self._project_dir, relative)})"
|
|
2596
|
+
for relative in ctx.excluded_checkout_roots
|
|
2597
|
+
)
|
|
2598
|
+
more = ("\n Scan incomplete; other ignored Git checkouts may exist."
|
|
2599
|
+
if ctx.excluded_checkout_roots_truncated else "")
|
|
2600
|
+
checkout_guidance = (
|
|
2601
|
+
"## Git checkouts excluded from this workspace\n"
|
|
2602
|
+
f"{paths}{more}\nSelect a checkout as workspace to index its files."
|
|
2603
|
+
)
|
|
2604
|
+
if not files:
|
|
2605
|
+
if checkout_guidance:
|
|
2606
|
+
return bounded_text("## Overview\nNo files discovered in this project.\n\n"
|
|
2607
|
+
+ checkout_guidance, max_tokens)[0]
|
|
2608
|
+
return "No files discovered in this project."
|
|
2609
|
+
|
|
2610
|
+
total_files = len(files)
|
|
2611
|
+
import os
|
|
2612
|
+
_file_cap = int(os.environ.get("ATTOCODE_FILE_CAP", str(cc.large_repo_threshold)))
|
|
2613
|
+
if total_files < cc.small_repo_threshold:
|
|
2614
|
+
size_tier = "small"
|
|
2615
|
+
elif total_files < _file_cap:
|
|
2616
|
+
size_tier = "medium"
|
|
2617
|
+
else:
|
|
2618
|
+
size_tier = "large"
|
|
2619
|
+
|
|
2620
|
+
if task_hint:
|
|
2621
|
+
summary_budget = int(max_tokens * cc.summary_ratio)
|
|
2622
|
+
structure_budget = int(max_tokens * cc.structure_ratio)
|
|
2623
|
+
conventions_budget = int(max_tokens * cc.conventions_ratio)
|
|
2624
|
+
search_budget = int(max_tokens * cc.search_ratio)
|
|
2625
|
+
else:
|
|
2626
|
+
summary_budget = int(max_tokens * cc.summary_ratio_no_hint)
|
|
2627
|
+
structure_budget = int(max_tokens * cc.structure_ratio_no_hint)
|
|
2628
|
+
conventions_budget = int(max_tokens * cc.conventions_ratio_no_hint)
|
|
2629
|
+
search_budget = 0
|
|
2630
|
+
|
|
2631
|
+
sections: list[str] = []
|
|
2632
|
+
if task_hint:
|
|
2633
|
+
try:
|
|
2634
|
+
mgr = self._get_semantic_search()
|
|
2635
|
+
candidates = self._search_after_warmup(mgr, lambda: mgr.search_candidates(
|
|
2636
|
+
task_hint, top_k=max(50, cc.bootstrap_search_top_k * 10, self._retrieval_depth(0)), file_filter="",
|
|
2637
|
+
))
|
|
2638
|
+
results, _ranking = self._rank_search_results(
|
|
2639
|
+
task_hint, candidates, cc.bootstrap_search_top_k,
|
|
2640
|
+
)
|
|
2641
|
+
if results:
|
|
2642
|
+
heading = f"## Relevant Code for: {task_hint}\n"
|
|
2643
|
+
advisory = (
|
|
2644
|
+
"Broad query matches multiple components; add a component or path hint.\n"
|
|
2645
|
+
if _ranking["query"]["ambiguous"] else ""
|
|
2646
|
+
)
|
|
2647
|
+
selected = ""
|
|
2648
|
+
for count in range(1, len(results) + 1):
|
|
2649
|
+
candidate = advisory + mgr.format_results(results[:count], query=task_hint)
|
|
2650
|
+
if count < len(results):
|
|
2651
|
+
candidate = candidate.replace(
|
|
2652
|
+
f"results ({count}):", f"results ({count} of {len(results)}):", 1)
|
|
2653
|
+
if count_tokens(heading + candidate) > search_budget:
|
|
2654
|
+
break
|
|
2655
|
+
selected = candidate
|
|
2656
|
+
if not selected:
|
|
2657
|
+
first = advisory + mgr.format_results(results[:1], query=task_hint)
|
|
2658
|
+
selected = (first if count_tokens(heading + first) <= max_tokens else
|
|
2659
|
+
"Top match exceeds max_tokens; narrow the task or increase the budget.")
|
|
2660
|
+
sections.append(heading + selected)
|
|
2661
|
+
else:
|
|
2662
|
+
diagnostics = mgr.candidate_diagnostics()
|
|
2663
|
+
if diagnostics.get("status") == "warming":
|
|
2664
|
+
sections.append(
|
|
2665
|
+
f"## Relevant Code for: {task_hint}\n"
|
|
2666
|
+
"Search index warming; retry shortly. Missing results do not prove absence."
|
|
2667
|
+
)
|
|
2668
|
+
else:
|
|
2669
|
+
summary_budget += search_budget // 2
|
|
2670
|
+
structure_budget += search_budget - search_budget // 2
|
|
2671
|
+
sections.append(f"## Relevant Code for: {task_hint}\nNo matches in this workspace.")
|
|
2672
|
+
except Exception as exc:
|
|
2673
|
+
logger.warning("bootstrap semantic-search step failed", exc_info=True)
|
|
2674
|
+
summary_budget += search_budget // 2
|
|
2675
|
+
structure_budget += search_budget - search_budget // 2
|
|
2676
|
+
sections.append(
|
|
2677
|
+
"## Relevant Code\n"
|
|
2678
|
+
f"Skipped due to search backend error: {type(exc).__name__}: {exc}"
|
|
2679
|
+
)
|
|
2680
|
+
|
|
2681
|
+
sections.append(self.project_summary(max_tokens=summary_budget))
|
|
2682
|
+
if checkout_guidance:
|
|
2683
|
+
sections.append(checkout_guidance)
|
|
2684
|
+
|
|
2685
|
+
if size_tier == "small":
|
|
2686
|
+
map_text = self.repo_map(include_symbols=True, max_tokens=structure_budget)
|
|
2687
|
+
sections.append(f"## Repository Map\n{map_text}")
|
|
2688
|
+
elif size_tier == "medium":
|
|
2689
|
+
map_text = self.repo_map(include_symbols=True, max_tokens=int(structure_budget * cc.medium_structure_map_ratio))
|
|
2690
|
+
hs_text = "Use hotspots for a focused complexity report after indexing completes."
|
|
2691
|
+
sections.append(f"## Repository Map\n{map_text}")
|
|
2692
|
+
sections.append(f"## Hotspots\n{hs_text}")
|
|
2693
|
+
else:
|
|
2694
|
+
explorer = self._get_explorer()
|
|
2695
|
+
root_result = explorer.explore("", max_items=cc.explore_max_items, importance_threshold=cc.explore_importance_threshold)
|
|
2696
|
+
explore_text = explorer.format_result(root_result)
|
|
2697
|
+
hs_text = "Use hotspots for a focused complexity report after indexing completes."
|
|
2698
|
+
sections.append(f"## Top-Level Structure\n{explore_text}")
|
|
2699
|
+
sections.append(f"## Hotspots\n{hs_text}")
|
|
2700
|
+
|
|
2701
|
+
svc = self._get_ast_service()
|
|
2702
|
+
ast_cache = svc._ast_cache.copy()
|
|
2703
|
+
if ast_cache:
|
|
2704
|
+
candidates = sorted(
|
|
2705
|
+
[fi for fi in files if fi.relative_path in ast_cache],
|
|
2706
|
+
key=lambda fi: fi.importance,
|
|
2707
|
+
reverse=True,
|
|
2708
|
+
)
|
|
2709
|
+
sample_rels = [fi.relative_path for fi in candidates[:cc.conventions_sample_size]]
|
|
2710
|
+
if sample_rels:
|
|
2711
|
+
stats = _analyze_conventions(ast_cache, sample_rels)
|
|
2712
|
+
conv_text = _format_conventions(stats)
|
|
2713
|
+
conv_chars = conventions_budget * 4
|
|
2714
|
+
if len(conv_text) > conv_chars:
|
|
2715
|
+
conv_text = conv_text[:conv_chars] + "\n ..."
|
|
2716
|
+
sections.append(f"## Conventions\n{conv_text}")
|
|
2717
|
+
|
|
2718
|
+
if size_tier == "small":
|
|
2719
|
+
guidance = (
|
|
2720
|
+
"## Navigation Guidance\n"
|
|
2721
|
+
"Small codebase — the repo map above shows everything.\n"
|
|
2722
|
+
"Next: `symbols(file)` or `file_analysis(file)` on files of interest."
|
|
2723
|
+
)
|
|
2724
|
+
elif size_tier == "medium":
|
|
2725
|
+
guidance = (
|
|
2726
|
+
"## Navigation Guidance\n"
|
|
2727
|
+
"Medium codebase — use `explore_codebase(dir)` to drill into directories.\n"
|
|
2728
|
+
"For specific symbols: `search_symbols(name)` or `semantic_search(query)`.\n"
|
|
2729
|
+
"Before modifying: `impact_analysis([files])` to check blast radius."
|
|
2730
|
+
)
|
|
2731
|
+
else:
|
|
2732
|
+
guidance = (
|
|
2733
|
+
"## Navigation Guidance\n"
|
|
2734
|
+
"Large codebase — do NOT request full `repo_map`, it wastes tokens.\n"
|
|
2735
|
+
"Use `explore_codebase(dir)` to drill down level by level.\n"
|
|
2736
|
+
"For specific symbols: `search_symbols(name)` or `semantic_search(query)`.\n"
|
|
2737
|
+
"Use `relevant_context([file])` to understand a file with its neighbors.\n"
|
|
2738
|
+
"Before modifying: `impact_analysis([files])` to check blast radius."
|
|
2739
|
+
)
|
|
2740
|
+
sections.append(guidance)
|
|
2741
|
+
return bounded_text("\n\n".join(sections), max_tokens)[0]
|
|
2742
|
+
|
|
2743
|
+
def hotspots(self, top_n: int = 15) -> str:
|
|
2744
|
+
from attocode_intel.helpers import (
|
|
2745
|
+
_compute_file_metrics,
|
|
2746
|
+
_compute_function_hotspots,
|
|
2747
|
+
_get_churn_scores,
|
|
2748
|
+
)
|
|
2749
|
+
|
|
2750
|
+
ctx = self._get_context_mgr()
|
|
2751
|
+
files = ctx._files
|
|
2752
|
+
if not files:
|
|
2753
|
+
return "No files discovered in this project."
|
|
2754
|
+
|
|
2755
|
+
svc = self._get_ast_service()
|
|
2756
|
+
index = svc._index
|
|
2757
|
+
ast_cache = svc._ast_cache.copy()
|
|
2758
|
+
churn_scores = _get_churn_scores(self._project_dir, files)
|
|
2759
|
+
|
|
2760
|
+
all_metrics = _compute_file_metrics(files, index, ast_cache, churn_scores)
|
|
2761
|
+
if not all_metrics:
|
|
2762
|
+
return "No analyzable files found."
|
|
2763
|
+
|
|
2764
|
+
all_metrics.sort(key=lambda m: m.composite, reverse=True)
|
|
2765
|
+
lines = [f"Top {min(top_n, len(all_metrics))} hotspots by complexity/coupling:\n"]
|
|
2766
|
+
for i, m in enumerate(all_metrics[:top_n], 1):
|
|
2767
|
+
tags = f" [{', '.join(m.categories)}]" if m.categories else ""
|
|
2768
|
+
lines.append(
|
|
2769
|
+
f" {i:2d}. {m.path}\n"
|
|
2770
|
+
f" {m.line_count} lines, {m.symbol_count} symbols, "
|
|
2771
|
+
f"pub={m.public_symbols}, "
|
|
2772
|
+
f"fan-in={m.fan_in}, fan-out={m.fan_out}, "
|
|
2773
|
+
f"density={m.density}%, score={m.composite}{tags}"
|
|
2774
|
+
)
|
|
2775
|
+
|
|
2776
|
+
fn_hotspots = _compute_function_hotspots(ast_cache, top_n=10)
|
|
2777
|
+
if fn_hotspots:
|
|
2778
|
+
lines.append("\nLongest functions:")
|
|
2779
|
+
for i, fm in enumerate(fn_hotspots, 1):
|
|
2780
|
+
pub_mark = "" if fm.is_public else " (private)"
|
|
2781
|
+
ret_mark = "" if fm.has_return_type else " [no return type]"
|
|
2782
|
+
lines.append(
|
|
2783
|
+
f" {i:2d}. {fm.name} — {fm.line_count} lines, "
|
|
2784
|
+
f"{fm.param_count} params{pub_mark}{ret_mark}\n"
|
|
2785
|
+
f" {fm.file_path}"
|
|
2786
|
+
)
|
|
2787
|
+
|
|
2788
|
+
orphans = [
|
|
2789
|
+
m for m in all_metrics
|
|
2790
|
+
if m.fan_in == 0 and m.fan_out == 0 and m.line_count >= 20
|
|
2791
|
+
and not any(fi.is_test for fi in files if fi.relative_path == m.path)
|
|
2792
|
+
]
|
|
2793
|
+
if orphans:
|
|
2794
|
+
lines.append(f"\nOrphan files (no imports/importers, {len(orphans)} found):")
|
|
2795
|
+
for m in orphans[:10]:
|
|
2796
|
+
lines.append(f" {m.path} ({m.line_count} lines, {m.symbol_count} symbols)")
|
|
2797
|
+
if len(orphans) > 10:
|
|
2798
|
+
lines.append(f" ... and {len(orphans) - 10} more")
|
|
2799
|
+
return "\n".join(lines)
|
|
2800
|
+
|
|
2801
|
+
def conventions(self, sample_size: int = 50, path: str = "") -> str:
|
|
2802
|
+
from attocode_intel.helpers import _analyze_conventions, _format_conventions
|
|
2803
|
+
|
|
2804
|
+
svc = self._get_ast_service()
|
|
2805
|
+
ast_cache = svc._ast_cache.copy()
|
|
2806
|
+
if not ast_cache:
|
|
2807
|
+
return "No files parsed — cannot detect conventions."
|
|
2808
|
+
|
|
2809
|
+
ctx = self._get_context_mgr()
|
|
2810
|
+
files = ctx._files
|
|
2811
|
+
path_prefix = path.rstrip("/") + "/" if path else ""
|
|
2812
|
+
|
|
2813
|
+
if path_prefix:
|
|
2814
|
+
scoped_candidates = sorted(
|
|
2815
|
+
[
|
|
2816
|
+
fi for fi in files
|
|
2817
|
+
if fi.relative_path in ast_cache
|
|
2818
|
+
and fi.relative_path.startswith(path_prefix)
|
|
2819
|
+
],
|
|
2820
|
+
key=lambda fi: fi.importance,
|
|
2821
|
+
reverse=True,
|
|
2822
|
+
)
|
|
2823
|
+
scoped_rels = [fi.relative_path for fi in scoped_candidates[:sample_size]]
|
|
2824
|
+
if not scoped_rels:
|
|
2825
|
+
return f"No parsed files found in '{path}'."
|
|
2826
|
+
|
|
2827
|
+
scoped_stats = _analyze_conventions(ast_cache, scoped_rels)
|
|
2828
|
+
global_candidates = sorted(
|
|
2829
|
+
[fi for fi in files if fi.relative_path in ast_cache],
|
|
2830
|
+
key=lambda fi: fi.importance,
|
|
2831
|
+
reverse=True,
|
|
2832
|
+
)
|
|
2833
|
+
global_rels = [fi.relative_path for fi in global_candidates[:sample_size]]
|
|
2834
|
+
global_stats = _analyze_conventions(ast_cache, global_rels)
|
|
2835
|
+
|
|
2836
|
+
header = f"Conventions in {path}/ ({len(scoped_rels)} files):\n"
|
|
2837
|
+
scoped_text = _format_conventions(scoped_stats)
|
|
2838
|
+
|
|
2839
|
+
comparison_parts: list[str] = []
|
|
2840
|
+
scoped_fn = scoped_stats["total_functions"]
|
|
2841
|
+
global_fn = global_stats["total_functions"]
|
|
2842
|
+
if scoped_fn > 0 and global_fn > 0:
|
|
2843
|
+
scoped_type_pct = scoped_stats["typed_return"] / scoped_fn * 100
|
|
2844
|
+
global_type_pct = global_stats["typed_return"] / global_fn * 100
|
|
2845
|
+
if abs(scoped_type_pct - global_type_pct) > 10:
|
|
2846
|
+
comparison_parts.append(
|
|
2847
|
+
f" Type hints: {scoped_type_pct:.0f}% here vs {global_type_pct:.0f}% project-wide"
|
|
2848
|
+
)
|
|
2849
|
+
scoped_doc_pct = scoped_stats["has_docstring_fn"] / scoped_fn * 100
|
|
2850
|
+
global_doc_pct = global_stats["has_docstring_fn"] / global_fn * 100
|
|
2851
|
+
if abs(scoped_doc_pct - global_doc_pct) > 10:
|
|
2852
|
+
comparison_parts.append(
|
|
2853
|
+
f" Docstrings: {scoped_doc_pct:.0f}% here vs {global_doc_pct:.0f}% project-wide"
|
|
2854
|
+
)
|
|
2855
|
+
scoped_async_pct = scoped_stats["async_count"] / scoped_fn * 100
|
|
2856
|
+
global_async_pct = global_stats["async_count"] / global_fn * 100
|
|
2857
|
+
if abs(scoped_async_pct - global_async_pct) > 10:
|
|
2858
|
+
comparison_parts.append(
|
|
2859
|
+
f" Async: {scoped_async_pct:.0f}% here vs {global_async_pct:.0f}% project-wide"
|
|
2860
|
+
)
|
|
2861
|
+
|
|
2862
|
+
if comparison_parts:
|
|
2863
|
+
header += scoped_text + "\n\nDivergence from project conventions:\n" + "\n".join(comparison_parts)
|
|
2864
|
+
else:
|
|
2865
|
+
header += scoped_text + "\n\n(Matches project-wide conventions.)"
|
|
2866
|
+
return header
|
|
2867
|
+
|
|
2868
|
+
candidates = sorted(
|
|
2869
|
+
[fi for fi in files if fi.relative_path in ast_cache],
|
|
2870
|
+
key=lambda fi: fi.importance,
|
|
2871
|
+
reverse=True,
|
|
2872
|
+
)
|
|
2873
|
+
sample_rels = [fi.relative_path for fi in candidates[:sample_size]]
|
|
2874
|
+
if not sample_rels:
|
|
2875
|
+
return "No parsed files available for convention analysis."
|
|
2876
|
+
|
|
2877
|
+
stats = _analyze_conventions(ast_cache, sample_rels)
|
|
2878
|
+
dir_groups: dict[str, list[str]] = {}
|
|
2879
|
+
for rel in sample_rels:
|
|
2880
|
+
parts = rel.split("/")
|
|
2881
|
+
dirname = parts[0] if len(parts) > 1 else "(root)"
|
|
2882
|
+
dir_groups.setdefault(dirname, []).append(rel)
|
|
2883
|
+
|
|
2884
|
+
dir_stats: dict[str, dict] = {}
|
|
2885
|
+
for dirname, dir_rels in dir_groups.items():
|
|
2886
|
+
if len(dir_rels) >= 3:
|
|
2887
|
+
dir_stats[dirname] = _analyze_conventions(ast_cache, dir_rels)
|
|
2888
|
+
|
|
2889
|
+
header = f"Conventions detected across {len(sample_rels)} files:\n"
|
|
2890
|
+
return header + _format_conventions(stats, dir_stats=dir_stats)
|
|
2891
|
+
|
|
2892
|
+
async def lsp_definition(self, file: str, line: int, col: int = 0) -> str:
|
|
2893
|
+
lsp = self._get_lsp_manager()
|
|
2894
|
+
if not os.path.isabs(file):
|
|
2895
|
+
file = os.path.join(self._project_dir, file)
|
|
2896
|
+
try:
|
|
2897
|
+
loc = await lsp.get_definition(file, line, col)
|
|
2898
|
+
except Exception as e:
|
|
2899
|
+
return f"LSP not available: {e}"
|
|
2900
|
+
if loc is None:
|
|
2901
|
+
return f"No definition found at {file}:{line}:{col}"
|
|
2902
|
+
uri = loc.uri
|
|
2903
|
+
if uri.startswith("file://"):
|
|
2904
|
+
uri = uri[7:]
|
|
2905
|
+
try:
|
|
2906
|
+
uri = os.path.relpath(uri, self._project_dir)
|
|
2907
|
+
except ValueError:
|
|
2908
|
+
pass
|
|
2909
|
+
return f"Definition: {uri}:{loc.range.start.line + 1}:{loc.range.start.character + 1}"
|
|
2910
|
+
|
|
2911
|
+
async def lsp_references(
|
|
2912
|
+
self, file: str, line: int, col: int = 0, include_declaration: bool = True,
|
|
2913
|
+
) -> str:
|
|
2914
|
+
lsp = self._get_lsp_manager()
|
|
2915
|
+
if not os.path.isabs(file):
|
|
2916
|
+
file = os.path.join(self._project_dir, file)
|
|
2917
|
+
try:
|
|
2918
|
+
locs = await lsp.get_references(file, line, col, include_declaration=include_declaration)
|
|
2919
|
+
except Exception as e:
|
|
2920
|
+
return f"LSP not available: {e}"
|
|
2921
|
+
if not locs:
|
|
2922
|
+
return f"No references found at {file}:{line}:{col}"
|
|
2923
|
+
lines = [f"References ({len(locs)}):"]
|
|
2924
|
+
for loc in locs[:100]:
|
|
2925
|
+
uri = loc.uri
|
|
2926
|
+
if uri.startswith("file://"):
|
|
2927
|
+
uri = uri[7:]
|
|
2928
|
+
try:
|
|
2929
|
+
uri = os.path.relpath(uri, self._project_dir)
|
|
2930
|
+
except ValueError:
|
|
2931
|
+
pass
|
|
2932
|
+
lines.append(f" {uri}:{loc.range.start.line + 1}:{loc.range.start.character + 1}")
|
|
2933
|
+
if len(locs) > 50:
|
|
2934
|
+
lines.append(f" ... and {len(locs) - 50} more")
|
|
2935
|
+
return "\n".join(lines)
|
|
2936
|
+
|
|
2937
|
+
async def lsp_hover(self, file: str, line: int, col: int = 0) -> str:
|
|
2938
|
+
lsp = self._get_lsp_manager()
|
|
2939
|
+
if not os.path.isabs(file):
|
|
2940
|
+
file = os.path.join(self._project_dir, file)
|
|
2941
|
+
try:
|
|
2942
|
+
info = await lsp.get_hover(file, line, col)
|
|
2943
|
+
except Exception as e:
|
|
2944
|
+
return f"LSP not available: {e}"
|
|
2945
|
+
if info is None:
|
|
2946
|
+
return f"No hover information at {file}:{line}:{col}"
|
|
2947
|
+
return f"Hover at {file}:{line}:{col}:\n{info}"
|
|
2948
|
+
|
|
2949
|
+
def lsp_diagnostics(self, file: str) -> str:
|
|
2950
|
+
lsp = self._get_lsp_manager()
|
|
2951
|
+
if not os.path.isabs(file):
|
|
2952
|
+
file = os.path.join(self._project_dir, file)
|
|
2953
|
+
try:
|
|
2954
|
+
diags = lsp.get_diagnostics(file)
|
|
2955
|
+
except Exception as e:
|
|
2956
|
+
return f"LSP not available: {e}"
|
|
2957
|
+
if not diags:
|
|
2958
|
+
return f"No diagnostics for {file}"
|
|
2959
|
+
lines = [f"Diagnostics ({len(diags)}):"]
|
|
2960
|
+
for d in diags[:30]:
|
|
2961
|
+
source = f" [{d.source}]" if d.source else ""
|
|
2962
|
+
code = f" ({d.code})" if d.code else ""
|
|
2963
|
+
lines.append(
|
|
2964
|
+
f" [{d.severity}]{source}{code} "
|
|
2965
|
+
f"L{d.range.start.line + 1}:{d.range.start.character + 1}: "
|
|
2966
|
+
f"{d.message}"
|
|
2967
|
+
)
|
|
2968
|
+
if len(diags) > 30:
|
|
2969
|
+
lines.append(f" ... and {len(diags) - 30} more")
|
|
2970
|
+
return "\n".join(lines)
|
|
2971
|
+
|
|
2972
|
+
async def lsp_completions(self, file: str, line: int, col: int = 0, limit: int = 20) -> str:
|
|
2973
|
+
"""Get completion suggestions at position."""
|
|
2974
|
+
result = await self.lsp_completions_data(file, line, col, limit)
|
|
2975
|
+
if result["error"]:
|
|
2976
|
+
return f"LSP completions error: {result['error']}"
|
|
2977
|
+
items = result["completions"]
|
|
2978
|
+
if not items:
|
|
2979
|
+
return f"No completions at {file}:{line}:{col}"
|
|
2980
|
+
lines = [f"Completions at {file}:{line}:{col} ({result['total']} available, showing top {len(items)}):"]
|
|
2981
|
+
for c in items:
|
|
2982
|
+
detail = f" — {c['detail']}" if c["detail"] else ""
|
|
2983
|
+
docs = f"\n {c['documentation'][:80]}" if c["documentation"] else ""
|
|
2984
|
+
lines.append(f" [{c['kind']}] {c['label']}{detail}{docs}")
|
|
2985
|
+
return "\n".join(lines)
|
|
2986
|
+
|
|
2987
|
+
async def lsp_workspace_symbol(self, query: str, limit: int = 30) -> str:
|
|
2988
|
+
"""Search workspace symbols by name across all language servers."""
|
|
2989
|
+
result = await self.lsp_workspace_symbol_data(query, limit)
|
|
2990
|
+
if result["error"]:
|
|
2991
|
+
return f"Workspace symbol search error: {result['error']}"
|
|
2992
|
+
symbols = result["symbols"]
|
|
2993
|
+
if not symbols:
|
|
2994
|
+
return f"No symbols matching '{query}'"
|
|
2995
|
+
lines = [f"Symbols matching '{query}' ({result['total']}):"]
|
|
2996
|
+
for s in symbols:
|
|
2997
|
+
container = f" ({s['container']})" if s["container"] else ""
|
|
2998
|
+
detail = f" — {s['detail']}" if s["detail"] else ""
|
|
2999
|
+
lines.append(f" [{s['kind']}] {s['name']}{detail}{container} at {s['file']}:{s['line']}")
|
|
3000
|
+
return "\n".join(lines)
|
|
3001
|
+
|
|
3002
|
+
async def lsp_incoming_calls(self, file: str, line: int, col: int = 0) -> str:
|
|
3003
|
+
"""Find what calls the symbol at position."""
|
|
3004
|
+
result = await self.lsp_incoming_calls_data(file, line, col)
|
|
3005
|
+
if result["error"]:
|
|
3006
|
+
return f"Incoming calls error: {result['error']}"
|
|
3007
|
+
callers = result["callers"]
|
|
3008
|
+
if not callers:
|
|
3009
|
+
return f"No callers found for {result['symbol']} at {file}:{line}:{col}"
|
|
3010
|
+
lines = [f"Called by ({result['total']} locations):"]
|
|
3011
|
+
for c in callers:
|
|
3012
|
+
container = f"{c['container']}." if c["container"] else ""
|
|
3013
|
+
lines.append(f" ← {container}{c['name']} at {c['file']}:{c['line']}:{c['col']}")
|
|
3014
|
+
return "\n".join(lines)
|
|
3015
|
+
|
|
3016
|
+
async def lsp_outgoing_calls(self, file: str, line: int, col: int = 0) -> str:
|
|
3017
|
+
"""Find what the symbol at position calls."""
|
|
3018
|
+
result = await self.lsp_outgoing_calls_data(file, line, col)
|
|
3019
|
+
if result["error"]:
|
|
3020
|
+
return f"Outgoing calls error: {result['error']}"
|
|
3021
|
+
callees = result["callees"]
|
|
3022
|
+
if not callees:
|
|
3023
|
+
return f"{result['symbol']} calls nothing at {file}:{line}:{col}"
|
|
3024
|
+
lines = [f"Calls ({result['total']} locations):"]
|
|
3025
|
+
for c in callees:
|
|
3026
|
+
container = f"{c['container']}." if c["container"] else ""
|
|
3027
|
+
lines.append(f" → {container}{c['name']} at {c['file']}:{c['line']}:{c['col']}")
|
|
3028
|
+
return "\n".join(lines)
|
|
3029
|
+
|
|
3030
|
+
def explore_codebase(
|
|
3031
|
+
self, path: str = "", max_items: int = 30,
|
|
3032
|
+
importance_threshold: float = 0.3, task_hint: str = "",
|
|
3033
|
+
) -> str:
|
|
3034
|
+
explorer = self._get_explorer()
|
|
3035
|
+
task_scores = self._task_file_scores(task_hint) if task_hint else None
|
|
3036
|
+
result = explorer.explore(path, max_items=max_items,
|
|
3037
|
+
importance_threshold=importance_threshold,
|
|
3038
|
+
task_scores=task_scores)
|
|
3039
|
+
return explorer.format_result(result)
|
|
3040
|
+
|
|
3041
|
+
def security_scan(self, mode: str = "full", path: str = "") -> str:
|
|
3042
|
+
scanner = self._get_security_scanner()
|
|
3043
|
+
report = scanner.scan(mode=mode, path=path)
|
|
3044
|
+
return scanner.format_report(report)
|
|
3045
|
+
|
|
3046
|
+
def semantic_search(self, query: str, top_k: int = 10,
|
|
3047
|
+
file_filter: str = "", branch: str = "", mode: str = "auto") -> str:
|
|
3048
|
+
mgr = self._get_semantic_search()
|
|
3049
|
+
|
|
3050
|
+
if mode == "keyword":
|
|
3051
|
+
candidates = self._search_after_warmup(
|
|
3052
|
+
mgr, lambda: mgr.search_candidates(query, top_k=self._retrieval_depth(top_k), file_filter=file_filter))
|
|
3053
|
+
results, ranking = self._rank_search_results(query, candidates, top_k, file_filter)
|
|
3054
|
+
if not results and mgr.candidate_diagnostics().get("status") == "warming":
|
|
3055
|
+
return "Search index warming; retry shortly. Missing results do not prove absence."
|
|
3056
|
+
note = "Broad query matches multiple components; narrow with file_filter or a component name.\n" if ranking["query"]["ambiguous"] else ""
|
|
3057
|
+
next_k = next_search_top_k(len(candidates), top_k, len(results))
|
|
3058
|
+
if next_k and mgr.candidate_diagnostics().get("status") == "ready":
|
|
3059
|
+
note += f"More retrieved candidates: rerun with top_k={next_k} (current index).\n"
|
|
3060
|
+
return note + mgr.format_results(results, query=query)
|
|
3061
|
+
|
|
3062
|
+
candidates = self._search_after_warmup(
|
|
3063
|
+
mgr, lambda: mgr.search(query, top_k=self._retrieval_depth(top_k), file_filter=file_filter))
|
|
3064
|
+
results, ranking = self._rank_search_results(query, candidates, top_k, file_filter)
|
|
3065
|
+
if not results and mgr.candidate_diagnostics().get("status") == "warming":
|
|
3066
|
+
return "Search index warming; retry shortly. Missing results do not prove absence."
|
|
3067
|
+
note = "Broad query matches multiple components; narrow with file_filter or a component name.\n" if ranking["query"]["ambiguous"] else ""
|
|
3068
|
+
next_k = next_search_top_k(len(candidates), top_k, len(results))
|
|
3069
|
+
if next_k and mgr.candidate_diagnostics().get("status") == "ready":
|
|
3070
|
+
note += f"More retrieved candidates: rerun with top_k={next_k} (current index).\n"
|
|
3071
|
+
return note + mgr.format_results(results, query=query)
|
|
3072
|
+
|
|
3073
|
+
def semantic_search_status(self) -> str:
|
|
3074
|
+
progress = self.indexing_status()
|
|
3075
|
+
lines = [
|
|
3076
|
+
"Semantic search status:",
|
|
3077
|
+
f" Provider: {progress.get('provider', '')}",
|
|
3078
|
+
f" Available: {progress.get('available', False)}",
|
|
3079
|
+
f" Status: {progress.get('status', 'idle')}",
|
|
3080
|
+
(
|
|
3081
|
+
" Coverage: "
|
|
3082
|
+
f"{float(progress.get('coverage', 0.0)):.0%} "
|
|
3083
|
+
f"({progress.get('indexed_files', 0)}/{progress.get('total_files', 0)} files)"
|
|
3084
|
+
),
|
|
3085
|
+
f" Failed: {progress.get('failed_files', 0)}",
|
|
3086
|
+
f" Vector search active: {progress.get('vector_search_active', False)}",
|
|
3087
|
+
f" Health: {progress.get('health_status', 'unknown')}",
|
|
3088
|
+
]
|
|
3089
|
+
if progress.get("degraded_reason"):
|
|
3090
|
+
lines.append(f" Degradation reason: {progress['degraded_reason']}")
|
|
3091
|
+
if progress.get("last_error"):
|
|
3092
|
+
lines.append(f" Last error: {progress['last_error']}")
|
|
3093
|
+
if progress.get("elapsed_seconds", 0):
|
|
3094
|
+
lines.append(f" Elapsed: {float(progress['elapsed_seconds']):.1f}s")
|
|
3095
|
+
return "\n".join(lines)
|
|
3096
|
+
|
|
3097
|
+
def start_indexing(self) -> dict:
|
|
3098
|
+
"""Start background embedding indexing."""
|
|
3099
|
+
mgr = self._get_semantic_search()
|
|
3100
|
+
progress = mgr.start_background_indexing()
|
|
3101
|
+
result = {
|
|
3102
|
+
"provider": mgr.provider_name,
|
|
3103
|
+
"available": mgr.is_available,
|
|
3104
|
+
"status": progress.status,
|
|
3105
|
+
"total_files": progress.total_files,
|
|
3106
|
+
"indexed_files": progress.indexed_files,
|
|
3107
|
+
"failed_files": progress.failed_files,
|
|
3108
|
+
"coverage": progress.coverage,
|
|
3109
|
+
"elapsed_seconds": progress.elapsed_seconds,
|
|
3110
|
+
"vector_search_active": mgr.is_index_ready(),
|
|
3111
|
+
"last_error": getattr(progress, "last_error", ""),
|
|
3112
|
+
"degraded_reason": getattr(progress, "degraded_reason", ""),
|
|
3113
|
+
}
|
|
3114
|
+
result.update(self._health_snapshot())
|
|
3115
|
+
return result
|
|
3116
|
+
|
|
3117
|
+
def indexing_status(self) -> dict:
|
|
3118
|
+
"""Get current indexing status."""
|
|
3119
|
+
mgr = self._get_semantic_search()
|
|
3120
|
+
progress = mgr.get_index_progress()
|
|
3121
|
+
result = {
|
|
3122
|
+
"provider": mgr.provider_name,
|
|
3123
|
+
"available": mgr.is_available,
|
|
3124
|
+
"status": progress.status,
|
|
3125
|
+
"total_files": progress.total_files,
|
|
3126
|
+
"indexed_files": progress.indexed_files,
|
|
3127
|
+
"failed_files": progress.failed_files,
|
|
3128
|
+
"coverage": progress.coverage,
|
|
3129
|
+
"elapsed_seconds": progress.elapsed_seconds,
|
|
3130
|
+
"vector_search_active": mgr.is_index_ready(),
|
|
3131
|
+
"last_error": getattr(progress, "last_error", ""),
|
|
3132
|
+
"degraded_reason": getattr(progress, "degraded_reason", ""),
|
|
3133
|
+
}
|
|
3134
|
+
result.update(self._health_snapshot())
|
|
3135
|
+
return result
|
|
3136
|
+
|
|
3137
|
+
def recall(self, query: str, scope: str = "", max_results: int = 10) -> str:
|
|
3138
|
+
store = self._get_memory_store()
|
|
3139
|
+
results = store.recall(query, scope=scope, max_results=max_results)
|
|
3140
|
+
if not results:
|
|
3141
|
+
return "No relevant learnings found for this project."
|
|
3142
|
+
lines = [f"## Project Learnings ({len(results)} relevant)\n"]
|
|
3143
|
+
for r in results:
|
|
3144
|
+
lines.append(f"- **[{r['type']}]** (confidence: {r['confidence']:.0%}, id: {r['id']})")
|
|
3145
|
+
lines.append(f" {r['description']}")
|
|
3146
|
+
if r["details"]:
|
|
3147
|
+
lines.append(f" _{r['details']}_")
|
|
3148
|
+
for r in results:
|
|
3149
|
+
try:
|
|
3150
|
+
store.record_applied(r["id"])
|
|
3151
|
+
except Exception:
|
|
3152
|
+
logger.debug("Failed to record_applied for learning %d", r["id"])
|
|
3153
|
+
return "\n".join(lines)
|
|
3154
|
+
|
|
3155
|
+
def record_learning(
|
|
3156
|
+
self,
|
|
3157
|
+
type: str, # noqa: A002
|
|
3158
|
+
description: str,
|
|
3159
|
+
details: str = "",
|
|
3160
|
+
scope: str = "",
|
|
3161
|
+
confidence: float = 0.7,
|
|
3162
|
+
) -> str:
|
|
3163
|
+
store = self._get_memory_store()
|
|
3164
|
+
try:
|
|
3165
|
+
learning_id = store.add(
|
|
3166
|
+
type=type, description=description,
|
|
3167
|
+
details=details, scope=scope, confidence=confidence,
|
|
3168
|
+
)
|
|
3169
|
+
except ValueError as e:
|
|
3170
|
+
return f"Error: {e}"
|
|
3171
|
+
return f"Recorded learning #{learning_id}: [{type}] {description}"
|
|
3172
|
+
|
|
3173
|
+
def learning_feedback(self, learning_id: int, helpful: bool) -> str:
|
|
3174
|
+
store = self._get_memory_store()
|
|
3175
|
+
store.record_feedback(learning_id, helpful)
|
|
3176
|
+
action = "boosted" if helpful else "reduced"
|
|
3177
|
+
return f"Feedback recorded — confidence {action} for learning #{learning_id}."
|
|
3178
|
+
|
|
3179
|
+
def list_learnings(self, status: str = "active", type: str = "", scope: str = "") -> str: # noqa: A002
|
|
3180
|
+
store = self._get_memory_store()
|
|
3181
|
+
results = store.list_all(status=status, type=type or None)
|
|
3182
|
+
if scope:
|
|
3183
|
+
results = [r for r in results if r["scope"].startswith(scope) or r["scope"] == ""]
|
|
3184
|
+
if not results:
|
|
3185
|
+
return "No learnings found matching the filters."
|
|
3186
|
+
lines = [f"## Learnings ({len(results)} total)\n"]
|
|
3187
|
+
lines.append("| ID | Type | Description | Confidence | Applied | Scope |")
|
|
3188
|
+
lines.append("|---|---|---|---|---|---|")
|
|
3189
|
+
for r in results:
|
|
3190
|
+
desc = r["description"][:60] + ("..." if len(r["description"]) > 60 else "")
|
|
3191
|
+
lines.append(
|
|
3192
|
+
f"| {r['id']} | {r['type']} | {desc} "
|
|
3193
|
+
f"| {r['confidence']:.0%} | {r['apply_count']}x "
|
|
3194
|
+
f"| {r['scope'] or '(global)'} |"
|
|
3195
|
+
)
|
|
3196
|
+
return "\n".join(lines)
|
|
3197
|
+
|
|
3198
|
+
def dead_code(
|
|
3199
|
+
self,
|
|
3200
|
+
scope: str = "",
|
|
3201
|
+
entry_points: list[str] | None = None,
|
|
3202
|
+
level: str = "symbol",
|
|
3203
|
+
min_confidence: float = 0.5,
|
|
3204
|
+
top_n: int = 30,
|
|
3205
|
+
) -> str:
|
|
3206
|
+
from attocode_intel.tools.dead_code_tools import dead_code as _dead_code
|
|
3207
|
+
|
|
3208
|
+
return _dead_code(
|
|
3209
|
+
scope=scope,
|
|
3210
|
+
entry_points=entry_points,
|
|
3211
|
+
level=level,
|
|
3212
|
+
min_confidence=min_confidence,
|
|
3213
|
+
top_n=top_n,
|
|
3214
|
+
)
|
|
3215
|
+
|
|
3216
|
+
def distill(
|
|
3217
|
+
self,
|
|
3218
|
+
files: list[str] | None = None,
|
|
3219
|
+
depth: int = 1,
|
|
3220
|
+
level: str = "signatures",
|
|
3221
|
+
max_tokens: int = 4000,
|
|
3222
|
+
) -> str:
|
|
3223
|
+
from attocode_intel.tools.distill_tools import distill as _distill
|
|
3224
|
+
|
|
3225
|
+
return _distill(files=files, depth=depth, level=level, max_tokens=max_tokens)
|
|
3226
|
+
|
|
3227
|
+
def change_coupling(self, file: str, *, days: int = 90, min_coupling: float = 0.3, top_k: int = 20) -> str:
|
|
3228
|
+
data = self.change_coupling_data(file=file, days=days, min_coupling=min_coupling, top_k=top_k)
|
|
3229
|
+
related = data.get("related", [])
|
|
3230
|
+
if not related:
|
|
3231
|
+
return f"No coupled files found for {file}."
|
|
3232
|
+
lines = [f"Files frequently changing with {file} ({len(related)} results):"]
|
|
3233
|
+
for item in related:
|
|
3234
|
+
lines.append(
|
|
3235
|
+
f" {item['path']} coupling={item['coupling']:.2f} "
|
|
3236
|
+
f"co_changes={item['co_changes']}"
|
|
3237
|
+
)
|
|
3238
|
+
return "\n".join(lines)
|
|
3239
|
+
|
|
3240
|
+
def churn_hotspots(self, *, days: int = 90, top_n: int = 20) -> str:
|
|
3241
|
+
data = self.churn_hotspots_data(days=days, top_n=top_n)
|
|
3242
|
+
files = data.get("files", [])
|
|
3243
|
+
if not files:
|
|
3244
|
+
return "No churn hotspots found."
|
|
3245
|
+
lines = [f"Top churn hotspots over the last {days} day(s):"]
|
|
3246
|
+
for item in files:
|
|
3247
|
+
lines.append(
|
|
3248
|
+
f" {item['path']} commits={item['commit_count']} "
|
|
3249
|
+
f"lines_changed={item['line_churn']}"
|
|
3250
|
+
)
|
|
3251
|
+
return "\n".join(lines)
|
|
3252
|
+
|
|
3253
|
+
def merge_risk(self, files: list[str], *, days: int = 90) -> str:
|
|
3254
|
+
data = self.merge_risk_data(files=files, days=days)
|
|
3255
|
+
preds = data.get("predictions", [])
|
|
3256
|
+
if not preds:
|
|
3257
|
+
return f"No additional merge-risk files predicted for {', '.join(files)}."
|
|
3258
|
+
lines = [f"Predicted merge-risk files for {', '.join(files)}:"]
|
|
3259
|
+
for item in preds:
|
|
3260
|
+
source = item.get("source") or item.get("reason", "unknown")
|
|
3261
|
+
lines.append(
|
|
3262
|
+
f" {item['path']} confidence={item['confidence']:.2f} "
|
|
3263
|
+
f"source={source}"
|
|
3264
|
+
)
|
|
3265
|
+
return "\n".join(lines)
|
|
3266
|
+
|
|
3267
|
+
def repo_map_ranked(self, task_context: str = "", token_budget: int = 1024, exclude_tests: bool = True) -> str:
|
|
3268
|
+
from attocode_intel.tools.analysis_tools import repo_map_ranked as _repo_map_ranked
|
|
3269
|
+
|
|
3270
|
+
return _repo_map_ranked(
|
|
3271
|
+
task_context=task_context,
|
|
3272
|
+
token_budget=token_budget,
|
|
3273
|
+
exclude_tests=exclude_tests,
|
|
3274
|
+
)
|
|
3275
|
+
|
|
3276
|
+
def bug_scan(self, base_branch: str = "main", min_confidence: float = 0.5) -> str:
|
|
3277
|
+
from attocode_intel.tools.analysis_tools import bug_scan as _bug_scan
|
|
3278
|
+
|
|
3279
|
+
return _bug_scan(base_branch=base_branch, min_confidence=min_confidence)
|
|
3280
|
+
|
|
3281
|
+
def fast_search(
|
|
3282
|
+
self,
|
|
3283
|
+
pattern: str,
|
|
3284
|
+
path: str = "",
|
|
3285
|
+
max_results: int = 50,
|
|
3286
|
+
case_insensitive: bool = False,
|
|
3287
|
+
selectivity_threshold: float = 0.10,
|
|
3288
|
+
explain: bool = False,
|
|
3289
|
+
) -> str:
|
|
3290
|
+
from attocode_intel.tools.search_tools import fast_search as _fast_search
|
|
3291
|
+
|
|
3292
|
+
return _fast_search(
|
|
3293
|
+
pattern=pattern,
|
|
3294
|
+
path=path,
|
|
3295
|
+
max_results=max_results,
|
|
3296
|
+
case_insensitive=case_insensitive,
|
|
3297
|
+
selectivity_threshold=selectivity_threshold,
|
|
3298
|
+
explain=explain,
|
|
3299
|
+
)
|
|
3300
|
+
|
|
3301
|
+
async def lsp_enrich(self, files: list[str]) -> str:
|
|
3302
|
+
from attocode_intel.tools.lsp_tools import lsp_enrich as _lsp_enrich
|
|
3303
|
+
|
|
3304
|
+
return await _lsp_enrich(files)
|
|
3305
|
+
|
|
3306
|
+
def record_adr(
|
|
3307
|
+
self,
|
|
3308
|
+
title: str,
|
|
3309
|
+
context: str,
|
|
3310
|
+
decision: str,
|
|
3311
|
+
consequences: str = "",
|
|
3312
|
+
related_files: list[str] | None = None,
|
|
3313
|
+
tags: list[str] | None = None,
|
|
3314
|
+
) -> str:
|
|
3315
|
+
from attocode_intel.tools.adr_tools import record_adr as _record_adr
|
|
3316
|
+
|
|
3317
|
+
return _record_adr(
|
|
3318
|
+
title=title,
|
|
3319
|
+
context=context,
|
|
3320
|
+
decision=decision,
|
|
3321
|
+
consequences=consequences,
|
|
3322
|
+
related_files=related_files,
|
|
3323
|
+
tags=tags,
|
|
3324
|
+
)
|
|
3325
|
+
|
|
3326
|
+
def list_adrs(self, status: str = "", tag: str = "", search: str = "") -> str:
|
|
3327
|
+
from attocode_intel.tools.adr_tools import list_adrs as _list_adrs
|
|
3328
|
+
|
|
3329
|
+
return _list_adrs(status=status, tag=tag, search=search)
|
|
3330
|
+
|
|
3331
|
+
def get_adr(self, number: int) -> str:
|
|
3332
|
+
from attocode_intel.tools.adr_tools import get_adr as _get_adr
|
|
3333
|
+
|
|
3334
|
+
return _get_adr(number)
|
|
3335
|
+
|
|
3336
|
+
def update_adr_status(self, number: int, status: str, superseded_by: int | None = None) -> str:
|
|
3337
|
+
from attocode_intel.tools.adr_tools import update_adr_status as _update_adr_status
|
|
3338
|
+
|
|
3339
|
+
return _update_adr_status(number=number, status=status, superseded_by=superseded_by)
|
|
3340
|
+
|
|
3341
|
+
def notify_file_changed(self, files: list[str]) -> str:
|
|
3342
|
+
if not files:
|
|
3343
|
+
return "No files specified."
|
|
3344
|
+
svc = self._get_ast_service()
|
|
3345
|
+
updated = 0
|
|
3346
|
+
for f in files:
|
|
3347
|
+
try:
|
|
3348
|
+
p = Path(f)
|
|
3349
|
+
rel = os.path.relpath(str(p), self._project_dir) if p.is_absolute() else str(p)
|
|
3350
|
+
rel = os.path.normpath(rel)
|
|
3351
|
+
if rel.startswith(".."):
|
|
3352
|
+
continue
|
|
3353
|
+
svc.notify_file_changed(rel)
|
|
3354
|
+
try:
|
|
3355
|
+
smgr = self._get_semantic_search()
|
|
3356
|
+
abs_path = os.path.join(self._project_dir, rel)
|
|
3357
|
+
smgr.invalidate_file(abs_path)
|
|
3358
|
+
except Exception:
|
|
3359
|
+
pass
|
|
3360
|
+
updated += 1
|
|
3361
|
+
except Exception as exc:
|
|
3362
|
+
logger.debug("notify_file_changed: error for %s: %s", f, exc)
|
|
3363
|
+
return f"Updated {updated} file(s). AST index refreshed."
|
|
3364
|
+
|
|
3365
|
+
# ------------------------------------------------------------------
|
|
3366
|
+
# Rule-based analysis (PerfInsights-inspired)
|
|
3367
|
+
# ------------------------------------------------------------------
|
|
3368
|
+
|
|
3369
|
+
def analyze(
|
|
3370
|
+
self,
|
|
3371
|
+
files: list[str] | None = None,
|
|
3372
|
+
path: str = "",
|
|
3373
|
+
language: str = "",
|
|
3374
|
+
category: str = "",
|
|
3375
|
+
severity: str = "",
|
|
3376
|
+
pack: str = "",
|
|
3377
|
+
min_confidence: float = 0.5,
|
|
3378
|
+
max_findings: int = 50,
|
|
3379
|
+
) -> str:
|
|
3380
|
+
from attocode_intel.tools.rule_tools import _analyze_impl
|
|
3381
|
+
|
|
3382
|
+
return _analyze_impl(
|
|
3383
|
+
files=files, path=path, language=language, category=category,
|
|
3384
|
+
severity=severity, pack=pack, min_confidence=min_confidence,
|
|
3385
|
+
max_findings=max_findings, project_dir=self._project_dir,
|
|
3386
|
+
)
|
|
3387
|
+
|
|
3388
|
+
def list_rules(
|
|
3389
|
+
self,
|
|
3390
|
+
language: str = "",
|
|
3391
|
+
category: str = "",
|
|
3392
|
+
severity: str = "",
|
|
3393
|
+
pack: str = "",
|
|
3394
|
+
verbose: bool = False,
|
|
3395
|
+
) -> str:
|
|
3396
|
+
from attocode_intel.tools.rule_tools import list_rules as _list_rules
|
|
3397
|
+
|
|
3398
|
+
return _list_rules(
|
|
3399
|
+
language=language, category=category, severity=severity,
|
|
3400
|
+
pack=pack, verbose=verbose,
|
|
3401
|
+
)
|
|
3402
|
+
|
|
3403
|
+
def list_packs(self) -> str:
|
|
3404
|
+
from attocode_intel.tools.rule_tools import list_packs as _list_packs
|
|
3405
|
+
|
|
3406
|
+
return _list_packs()
|
|
3407
|
+
|
|
3408
|
+
def register_rule(self, yaml_content: str) -> str:
|
|
3409
|
+
from attocode_intel.tools.rule_tools import register_rule as _register_rule
|
|
3410
|
+
|
|
3411
|
+
return _register_rule(yaml_content=yaml_content)
|
|
3412
|
+
|
|
3413
|
+
def install_pack(self, name: str) -> str:
|
|
3414
|
+
from attocode_intel.tools.rule_tools import install_pack as _install_pack
|
|
3415
|
+
|
|
3416
|
+
return _install_pack(name=name)
|