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,1736 @@
|
|
|
1
|
+
"""Codebase context system.
|
|
2
|
+
|
|
3
|
+
Provides intelligent code understanding through:
|
|
4
|
+
- File discovery with ignore patterns
|
|
5
|
+
- Repository map generation
|
|
6
|
+
- Lightweight tree view
|
|
7
|
+
- File importance scoring
|
|
8
|
+
- Context selection for prompts
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import time
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from attocode_intel._internal.integrations.utilities.token_estimate import CHARS_PER_TOKEN
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(slots=True)
|
|
23
|
+
class FileInfo:
|
|
24
|
+
"""Information about a discovered file."""
|
|
25
|
+
|
|
26
|
+
path: str
|
|
27
|
+
relative_path: str
|
|
28
|
+
size: int = 0
|
|
29
|
+
language: str = ""
|
|
30
|
+
importance: float = 0.0
|
|
31
|
+
is_test: bool = False
|
|
32
|
+
is_config: bool = False
|
|
33
|
+
line_count: int = 0
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def extension(self) -> str:
|
|
37
|
+
return Path(self.path).suffix
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(slots=True)
|
|
41
|
+
class RepoMap:
|
|
42
|
+
"""Repository map showing file structure and key definitions."""
|
|
43
|
+
|
|
44
|
+
tree: str # Text tree view
|
|
45
|
+
files: list[FileInfo]
|
|
46
|
+
total_files: int = 0
|
|
47
|
+
total_lines: int = 0
|
|
48
|
+
languages: dict[str, int] = field(default_factory=dict) # lang -> file count
|
|
49
|
+
symbols: dict[str, list[str]] = field(default_factory=dict) # rel_path -> symbol names
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(slots=True)
|
|
53
|
+
class DependencyGraph:
|
|
54
|
+
"""Forward and reverse dependency graph between files."""
|
|
55
|
+
|
|
56
|
+
forward: dict[str, set[str]] = field(default_factory=dict) # file -> files it imports
|
|
57
|
+
reverse: dict[str, set[str]] = field(default_factory=dict) # file -> files that import it
|
|
58
|
+
|
|
59
|
+
unresolved: dict[str, list[str]] = field(default_factory=dict)
|
|
60
|
+
|
|
61
|
+
def add_edge(self, source: str, target: str) -> None:
|
|
62
|
+
"""Add a dependency edge: source imports target."""
|
|
63
|
+
self.forward.setdefault(source, set()).add(target)
|
|
64
|
+
self.reverse.setdefault(target, set()).add(source)
|
|
65
|
+
|
|
66
|
+
def get_importers(self, file_path: str) -> set[str]:
|
|
67
|
+
"""Get files that import the given file."""
|
|
68
|
+
return self.reverse.get(file_path, set())
|
|
69
|
+
|
|
70
|
+
def get_imports(self, file_path: str) -> set[str]:
|
|
71
|
+
"""Get files that the given file imports."""
|
|
72
|
+
return self.forward.get(file_path, set())
|
|
73
|
+
|
|
74
|
+
def hub_score(self, file_path: str) -> float:
|
|
75
|
+
"""Score based on how many files depend on this file (0.0-0.2)."""
|
|
76
|
+
count = len(self.reverse.get(file_path, set()))
|
|
77
|
+
return min(0.2, count * 0.04)
|
|
78
|
+
|
|
79
|
+
def pagerank(
|
|
80
|
+
self,
|
|
81
|
+
damping: float = 0.85,
|
|
82
|
+
iterations: int = 20,
|
|
83
|
+
tolerance: float = 1e-6,
|
|
84
|
+
) -> dict[str, float]:
|
|
85
|
+
"""Compute PageRank scores for all files in the dependency graph.
|
|
86
|
+
|
|
87
|
+
Uses the reverse (importer) graph: a file is important if important
|
|
88
|
+
files import it. Returns scores normalized to 0.0-1.0 range.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
damping: Damping factor (probability of following a link). Default 0.85.
|
|
92
|
+
iterations: Maximum power-iteration rounds. Default 20.
|
|
93
|
+
tolerance: Early-stop when max score change < tolerance.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Dict mapping file paths to PageRank scores (0.0-1.0 normalized).
|
|
97
|
+
"""
|
|
98
|
+
# Collect all nodes from both forward and reverse graphs
|
|
99
|
+
all_nodes: set[str] = set()
|
|
100
|
+
all_nodes.update(self.forward.keys())
|
|
101
|
+
all_nodes.update(self.reverse.keys())
|
|
102
|
+
for targets in self.forward.values():
|
|
103
|
+
all_nodes.update(targets)
|
|
104
|
+
for sources in self.reverse.values():
|
|
105
|
+
all_nodes.update(sources)
|
|
106
|
+
|
|
107
|
+
if not all_nodes:
|
|
108
|
+
return {}
|
|
109
|
+
|
|
110
|
+
n = len(all_nodes)
|
|
111
|
+
base = 1.0 / n
|
|
112
|
+
scores: dict[str, float] = {node: base for node in all_nodes}
|
|
113
|
+
|
|
114
|
+
# Pre-compute out-degree (forward edges = this file imports N others)
|
|
115
|
+
out_degree: dict[str, int] = {}
|
|
116
|
+
for node in all_nodes:
|
|
117
|
+
out_degree[node] = len(self.forward.get(node, set()))
|
|
118
|
+
|
|
119
|
+
for _ in range(iterations):
|
|
120
|
+
new_scores: dict[str, float] = {}
|
|
121
|
+
for node in all_nodes:
|
|
122
|
+
# Sum contributions from files that import this node
|
|
123
|
+
rank_sum = 0.0
|
|
124
|
+
for importer in self.reverse.get(node, set()):
|
|
125
|
+
od = out_degree.get(importer, 0)
|
|
126
|
+
if od > 0:
|
|
127
|
+
rank_sum += scores[importer] / od
|
|
128
|
+
new_scores[node] = (1 - damping) / n + damping * rank_sum
|
|
129
|
+
|
|
130
|
+
# Check convergence
|
|
131
|
+
max_diff = max(
|
|
132
|
+
abs(new_scores[node] - scores[node]) for node in all_nodes
|
|
133
|
+
)
|
|
134
|
+
scores = new_scores
|
|
135
|
+
if max_diff < tolerance:
|
|
136
|
+
break
|
|
137
|
+
|
|
138
|
+
# Normalize to 0.0-1.0
|
|
139
|
+
max_score = max(scores.values()) if scores else 1.0
|
|
140
|
+
if max_score > 0:
|
|
141
|
+
scores = {k: v / max_score for k, v in scores.items()}
|
|
142
|
+
|
|
143
|
+
return scores
|
|
144
|
+
|
|
145
|
+
def to_import_graph(self) -> dict[str, list[str]]:
|
|
146
|
+
"""Convert to the dict format expected by CodeSelector.ranked_search."""
|
|
147
|
+
return {k: list(v) for k, v in self.forward.items()}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# Language detection by extension
|
|
151
|
+
EXTENSION_LANGUAGES: dict[str, str] = {
|
|
152
|
+
".py": "python", ".pyi": "python",
|
|
153
|
+
".ts": "typescript", ".tsx": "typescript",
|
|
154
|
+
".js": "javascript", ".jsx": "javascript",
|
|
155
|
+
".rs": "rust",
|
|
156
|
+
".go": "go",
|
|
157
|
+
".java": "java",
|
|
158
|
+
".kt": "kotlin",
|
|
159
|
+
".c": "c", ".h": "c",
|
|
160
|
+
".cpp": "cpp", ".hpp": "cpp", ".cc": "cpp",
|
|
161
|
+
".rb": "ruby",
|
|
162
|
+
".php": "php",
|
|
163
|
+
".swift": "swift",
|
|
164
|
+
".cs": "csharp",
|
|
165
|
+
".lua": "lua",
|
|
166
|
+
".scala": "scala", ".sc": "scala",
|
|
167
|
+
".ex": "elixir", ".exs": "elixir",
|
|
168
|
+
".zig": "zig",
|
|
169
|
+
".hs": "haskell", ".lhs": "haskell",
|
|
170
|
+
".tf": "hcl", ".tfvars": "hcl", ".hcl": "hcl",
|
|
171
|
+
".sh": "shell", ".bash": "shell", ".zsh": "shell",
|
|
172
|
+
".yaml": "yaml", ".yml": "yaml",
|
|
173
|
+
".json": "json",
|
|
174
|
+
".toml": "toml",
|
|
175
|
+
".md": "markdown",
|
|
176
|
+
".html": "html", ".htm": "html",
|
|
177
|
+
".css": "css", ".scss": "scss",
|
|
178
|
+
".sql": "sql",
|
|
179
|
+
".dockerfile": "docker",
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
# Patterns indicating test files
|
|
183
|
+
TEST_PATTERNS = (
|
|
184
|
+
"test_", "_test.", ".test.", "tests/", "test/",
|
|
185
|
+
"spec_", "_spec.", ".spec.", "specs/", "spec/",
|
|
186
|
+
"__tests__/",
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# Patterns indicating config files
|
|
190
|
+
CONFIG_PATTERNS = (
|
|
191
|
+
"config.", ".config.", "settings.",
|
|
192
|
+
"pyproject.toml", "package.json", "tsconfig",
|
|
193
|
+
".eslintrc", ".prettierrc", "Makefile", "Dockerfile",
|
|
194
|
+
".github/", ".gitlab-ci",
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# Default ignore directories
|
|
198
|
+
DEFAULT_IGNORES = {
|
|
199
|
+
# VCS
|
|
200
|
+
".git", ".svn", ".hg",
|
|
201
|
+
# Python
|
|
202
|
+
"__pycache__", ".venv", "venv", "env", ".env",
|
|
203
|
+
".mypy_cache", ".pytest_cache", ".ruff_cache",
|
|
204
|
+
".tox", ".nox", ".eggs", "*.egg-info",
|
|
205
|
+
"site-packages",
|
|
206
|
+
# JS/TS
|
|
207
|
+
"node_modules", "bower_components",
|
|
208
|
+
".next", ".nuxt", ".svelte-kit", ".parcel-cache",
|
|
209
|
+
# Build output
|
|
210
|
+
"dist", "build", "out", "output", "target",
|
|
211
|
+
"_build", "cmake-build-debug", "cmake-build-release",
|
|
212
|
+
# Coverage / test artifacts
|
|
213
|
+
"coverage", ".coverage", "htmlcov", ".nyc_output",
|
|
214
|
+
# IDE / editor
|
|
215
|
+
".idea", ".vscode", ".vs",
|
|
216
|
+
# OS
|
|
217
|
+
".DS_Store", "Thumbs.db",
|
|
218
|
+
# Containers / infra
|
|
219
|
+
".terraform", ".vagrant",
|
|
220
|
+
# Misc generated
|
|
221
|
+
".cache", ".tmp", "tmp",
|
|
222
|
+
"vendor", # Go/PHP/Ruby vendor dirs
|
|
223
|
+
".gradle", ".maven",
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
# File extensions to skip (non-source, binary, generated)
|
|
227
|
+
SKIP_EXTENSIONS = {
|
|
228
|
+
# Compiled / bytecode
|
|
229
|
+
".pyc", ".pyo", ".class", ".o", ".obj", ".so", ".dylib", ".dll",
|
|
230
|
+
".a", ".lib", ".exe", ".wasm",
|
|
231
|
+
# Archives
|
|
232
|
+
".zip", ".tar", ".gz", ".bz2", ".xz", ".rar", ".7z", ".jar", ".war",
|
|
233
|
+
# Media
|
|
234
|
+
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".svg", ".webp",
|
|
235
|
+
".mp3", ".mp4", ".wav", ".avi", ".mov", ".webm",
|
|
236
|
+
".ttf", ".otf", ".woff", ".woff2", ".eot",
|
|
237
|
+
# Data / DB
|
|
238
|
+
".db", ".sqlite", ".sqlite3",
|
|
239
|
+
".parquet", ".feather", ".hdf5", ".h5",
|
|
240
|
+
# Lock files (large, generated)
|
|
241
|
+
".lock",
|
|
242
|
+
# Maps
|
|
243
|
+
".map",
|
|
244
|
+
# Certificates
|
|
245
|
+
".pem", ".crt", ".key", ".p12",
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
# Filenames to skip (exact match)
|
|
249
|
+
SKIP_FILENAMES = {
|
|
250
|
+
".DS_Store", "Thumbs.db", ".gitkeep", ".npmrc",
|
|
251
|
+
"package-lock.json", "yarn.lock", "pnpm-lock.yaml",
|
|
252
|
+
"Pipfile.lock", "poetry.lock", "uv.lock",
|
|
253
|
+
"composer.lock", "Gemfile.lock", "Cargo.lock",
|
|
254
|
+
"go.sum",
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _detect_source_prefixes(root_dir: str) -> list[str]:
|
|
259
|
+
"""Detect source directory prefixes for import resolution.
|
|
260
|
+
|
|
261
|
+
Many Python projects use a ``src/`` layout where packages live under
|
|
262
|
+
``src/`` (e.g., ``src/mypackage/``). When files are discovered with
|
|
263
|
+
relative paths like ``src/mypackage/foo.py``, absolute imports such as
|
|
264
|
+
``from mypackage.foo import ...`` fail because the resolver generates
|
|
265
|
+
candidate ``mypackage/foo.py`` which doesn't match the ``src/``-prefixed
|
|
266
|
+
key in the file index.
|
|
267
|
+
|
|
268
|
+
This function detects such prefixes by examining:
|
|
269
|
+
1. ``pyproject.toml`` — hatchling ``packages`` or setuptools ``package-dir``
|
|
270
|
+
2. ``setup.cfg`` — ``[options] package_dir``
|
|
271
|
+
3. Filesystem fallback — ``src/`` containing Python packages
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
Sorted list of prefix strings ending with ``/`` (e.g., ``["src/"]``).
|
|
275
|
+
"""
|
|
276
|
+
prefixes: set[str] = set()
|
|
277
|
+
root = Path(root_dir)
|
|
278
|
+
|
|
279
|
+
# --- pyproject.toml ---
|
|
280
|
+
pyproject = root / "pyproject.toml"
|
|
281
|
+
if pyproject.exists():
|
|
282
|
+
import tomllib # stdlib, Python 3.11+ (project requires 3.12+)
|
|
283
|
+
|
|
284
|
+
try:
|
|
285
|
+
with open(pyproject, "rb") as fh:
|
|
286
|
+
data = tomllib.load(fh)
|
|
287
|
+
|
|
288
|
+
# Hatchling: [tool.hatch.build.targets.wheel] packages = ["src/pkg"]
|
|
289
|
+
hatch_packages = (
|
|
290
|
+
data.get("tool", {})
|
|
291
|
+
.get("hatch", {})
|
|
292
|
+
.get("build", {})
|
|
293
|
+
.get("targets", {})
|
|
294
|
+
.get("wheel", {})
|
|
295
|
+
.get("packages", [])
|
|
296
|
+
)
|
|
297
|
+
for pkg in hatch_packages:
|
|
298
|
+
parts = Path(pkg).parts
|
|
299
|
+
if len(parts) >= 2:
|
|
300
|
+
prefixes.add(parts[0] + "/")
|
|
301
|
+
|
|
302
|
+
# Setuptools: [tool.setuptools] package-dir = {"" = "src"}
|
|
303
|
+
setup_pkg_dir = (
|
|
304
|
+
data.get("tool", {})
|
|
305
|
+
.get("setuptools", {})
|
|
306
|
+
.get("package-dir", {})
|
|
307
|
+
)
|
|
308
|
+
for _key, src_dir in setup_pkg_dir.items():
|
|
309
|
+
if src_dir:
|
|
310
|
+
prefixes.add(src_dir.rstrip("/") + "/")
|
|
311
|
+
except Exception:
|
|
312
|
+
pass
|
|
313
|
+
|
|
314
|
+
# --- setup.cfg ---
|
|
315
|
+
if not prefixes:
|
|
316
|
+
setup_cfg = root / "setup.cfg"
|
|
317
|
+
if setup_cfg.exists():
|
|
318
|
+
try:
|
|
319
|
+
import configparser
|
|
320
|
+
|
|
321
|
+
cfg = configparser.ConfigParser()
|
|
322
|
+
cfg.read(str(setup_cfg))
|
|
323
|
+
pkg_dir = cfg.get("options", "package_dir", fallback="")
|
|
324
|
+
if "=" in pkg_dir:
|
|
325
|
+
src_dir = pkg_dir.split("=", 1)[1].strip()
|
|
326
|
+
if src_dir:
|
|
327
|
+
prefixes.add(src_dir.rstrip("/") + "/")
|
|
328
|
+
except Exception:
|
|
329
|
+
pass
|
|
330
|
+
|
|
331
|
+
# --- Filesystem fallback ---
|
|
332
|
+
if not prefixes:
|
|
333
|
+
src_dir_path = root / "src"
|
|
334
|
+
if src_dir_path.is_dir():
|
|
335
|
+
for child in src_dir_path.iterdir():
|
|
336
|
+
if child.is_dir() and (child / "__init__.py").exists():
|
|
337
|
+
prefixes.add("src/")
|
|
338
|
+
break
|
|
339
|
+
|
|
340
|
+
return sorted(prefixes)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _build_file_index(
|
|
344
|
+
files: list[FileInfo],
|
|
345
|
+
root_dir: str,
|
|
346
|
+
) -> dict[str, str]:
|
|
347
|
+
"""Build a file index mapping normalized paths to relative paths.
|
|
348
|
+
|
|
349
|
+
In addition to the standard ``relative_path`` keys, this function
|
|
350
|
+
adds prefix-stripped alternate keys for projects using a ``src/``
|
|
351
|
+
layout (or similar). For example, if the index contains
|
|
352
|
+
``src/attocode/core/loop.py``, an alternate key
|
|
353
|
+
``attocode/core/loop.py`` is also added so that
|
|
354
|
+
``_resolve_python_import`` can find it when resolving
|
|
355
|
+
``from attocode.core.loop import ...``.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
files: Discovered files with relative paths.
|
|
359
|
+
root_dir: Repository root directory.
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
Mapping of normalized paths (with ``/``) to relative paths.
|
|
363
|
+
"""
|
|
364
|
+
from .module_resolution import FileIndex
|
|
365
|
+
file_index = FileIndex()
|
|
366
|
+
for f in files:
|
|
367
|
+
normalized = f.relative_path.replace(os.sep, "/")
|
|
368
|
+
file_index[normalized] = f.relative_path
|
|
369
|
+
|
|
370
|
+
# Detect source prefixes and add stripped alternate keys
|
|
371
|
+
source_prefixes = _detect_source_prefixes(root_dir)
|
|
372
|
+
if source_prefixes:
|
|
373
|
+
extra: dict[str, str] = {}
|
|
374
|
+
for normalized, rel_path in file_index.items():
|
|
375
|
+
for prefix in source_prefixes:
|
|
376
|
+
if normalized.startswith(prefix):
|
|
377
|
+
stripped = normalized[len(prefix):]
|
|
378
|
+
# Don't override direct matches
|
|
379
|
+
if stripped not in file_index:
|
|
380
|
+
extra[stripped] = rel_path
|
|
381
|
+
file_index.update(extra)
|
|
382
|
+
|
|
383
|
+
file_index.configure_javascript(root_dir)
|
|
384
|
+
return file_index
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _resolve_python_import(module: str, source_file: str, file_index: dict[str, str]) -> str | None:
|
|
388
|
+
"""Resolve a Python import module name to a relative file path.
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
module: Module name (e.g. 'os', 'mypackage.utils', '.sibling').
|
|
392
|
+
source_file: Relative path of the importing file.
|
|
393
|
+
file_index: Mapping of relative paths (normalized with /) to relative paths.
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
Resolved relative path or None if not found.
|
|
397
|
+
"""
|
|
398
|
+
# Skip stdlib / third-party (no dots in path and not found locally)
|
|
399
|
+
parts = module.split(".")
|
|
400
|
+
|
|
401
|
+
# Handle relative imports (leading dots)
|
|
402
|
+
if module.startswith("."):
|
|
403
|
+
# Strip leading dots and compute base directory
|
|
404
|
+
dots = len(module) - len(module.lstrip("."))
|
|
405
|
+
base = Path(source_file).parent
|
|
406
|
+
for _ in range(dots - 1):
|
|
407
|
+
base = base.parent
|
|
408
|
+
parts = [p for p in module.lstrip(".").split(".") if p]
|
|
409
|
+
candidate = str(base / "/".join(parts)) if parts else str(base / "__init__")
|
|
410
|
+
else:
|
|
411
|
+
candidate = "/".join(parts)
|
|
412
|
+
|
|
413
|
+
# Try candidate.py, candidate/__init__.py
|
|
414
|
+
for suffix in (".py", "/__init__.py"):
|
|
415
|
+
key = candidate + suffix
|
|
416
|
+
normalized = key.replace(os.sep, "/")
|
|
417
|
+
if normalized in file_index:
|
|
418
|
+
return file_index[normalized]
|
|
419
|
+
|
|
420
|
+
return None
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _resolve_js_import(module: str, source_file: str, file_index: dict[str, str]) -> str | None:
|
|
424
|
+
"""Resolve a JS/TS import path to a relative file path.
|
|
425
|
+
|
|
426
|
+
Args:
|
|
427
|
+
module: Import path (e.g. './utils', '../lib/helpers').
|
|
428
|
+
source_file: Relative path of the importing file.
|
|
429
|
+
file_index: Mapping of relative paths (normalized with /) to relative paths.
|
|
430
|
+
|
|
431
|
+
Returns:
|
|
432
|
+
Resolved relative path or None if not found.
|
|
433
|
+
"""
|
|
434
|
+
if module.startswith("."):
|
|
435
|
+
candidates = [os.path.normpath(str(Path(source_file).parent / module))]
|
|
436
|
+
elif hasattr(file_index, "javascript_candidates"):
|
|
437
|
+
candidates = list(file_index.javascript_candidates(module, source_file))
|
|
438
|
+
else:
|
|
439
|
+
return None
|
|
440
|
+
for resolved in candidates:
|
|
441
|
+
# TS resolves source .ts files behind emitted .js imports.
|
|
442
|
+
variants = [resolved]
|
|
443
|
+
if resolved.endswith((".js", ".jsx", ".mjs", ".cjs")):
|
|
444
|
+
variants += [str(Path(resolved).with_suffix(ext)) for ext in (".ts", ".tsx", ".mts", ".cts")]
|
|
445
|
+
for candidate in variants:
|
|
446
|
+
for suffix in ("", ".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs", ".json", "/index.ts", "/index.tsx", "/index.js", "/index.cjs", "/index.mjs"):
|
|
447
|
+
key = os.path.normpath(candidate + suffix).replace(os.sep, "/")
|
|
448
|
+
if key in file_index:
|
|
449
|
+
return file_index[key]
|
|
450
|
+
return None
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _resolve_rust_import(module: str, source_file: str, file_index: dict[str, str]) -> str | None:
|
|
454
|
+
"""Resolve local Rust modules, including nested workspace crate roots.
|
|
455
|
+
|
|
456
|
+
External dependencies and ambiguous matches remain unresolved.
|
|
457
|
+
"""
|
|
458
|
+
if not module or module.startswith(("std::", "core::", "alloc::", "extern ")):
|
|
459
|
+
return None
|
|
460
|
+
source = Path(source_file)
|
|
461
|
+
roots = [parent for parent in (source.parent, *source.parents)
|
|
462
|
+
if any(str(parent / entry) in file_index for entry in ("lib.rs", "main.rs"))]
|
|
463
|
+
crate_root = roots[0] if roots else Path("src")
|
|
464
|
+
current = source.parent if source.stem in {"lib", "main", "mod"} else source.with_suffix("")
|
|
465
|
+
if module.startswith("mod:"):
|
|
466
|
+
parts = [module[4:]]
|
|
467
|
+
base = current
|
|
468
|
+
else:
|
|
469
|
+
parts = module.split("::")
|
|
470
|
+
if parts[0] == "crate":
|
|
471
|
+
base, parts = crate_root, parts[1:]
|
|
472
|
+
elif parts[0] == "self":
|
|
473
|
+
base, parts = current, parts[1:]
|
|
474
|
+
elif parts[0] == "super":
|
|
475
|
+
base = current
|
|
476
|
+
while parts and parts[0] == "super":
|
|
477
|
+
base, parts = base.parent, parts[1:]
|
|
478
|
+
else:
|
|
479
|
+
# Rust 2015 crate-relative paths, then workspace crate source roots.
|
|
480
|
+
base = crate_root
|
|
481
|
+
if len(parts) > 1:
|
|
482
|
+
name = parts[0].replace("_", "-")
|
|
483
|
+
candidates = {str(Path(f).parent) for f in file_index.values()
|
|
484
|
+
if Path(f).name == "lib.rs" and name in Path(f).parts}
|
|
485
|
+
if len(candidates) == 1:
|
|
486
|
+
base, parts = Path(candidates.pop()), parts[1:]
|
|
487
|
+
for length in range(len(parts), 0, -1):
|
|
488
|
+
candidate = str(base.joinpath(*parts[:length]))
|
|
489
|
+
for suffix in (".rs", "/mod.rs"):
|
|
490
|
+
key = os.path.normpath(candidate + suffix).replace(os.sep, "/")
|
|
491
|
+
if key in file_index:
|
|
492
|
+
return file_index[key]
|
|
493
|
+
# Root-level reexports live in the crate entrypoint, not a same-named file.
|
|
494
|
+
if len(parts) == 1 and not module.startswith("mod:"):
|
|
495
|
+
for entry in ("lib.rs", "main.rs"):
|
|
496
|
+
key = str(base / entry)
|
|
497
|
+
if key in file_index:
|
|
498
|
+
return file_index[key]
|
|
499
|
+
return None
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _resolve_go_import(module: str, source_file: str, file_index: dict[str, str]) -> str | None:
|
|
503
|
+
"""Resolve a Go import path to a relative file path.
|
|
504
|
+
|
|
505
|
+
Handles:
|
|
506
|
+
- ``import "./internal/parser"`` → relative local import
|
|
507
|
+
- Internal packages sharing module path prefix → local resolution
|
|
508
|
+
- Skips stdlib (no dots in path) and external packages
|
|
509
|
+
"""
|
|
510
|
+
if not module:
|
|
511
|
+
return None
|
|
512
|
+
|
|
513
|
+
# Relative imports (rare but valid in Go)
|
|
514
|
+
if module.startswith("./") or module.startswith("../"):
|
|
515
|
+
base = Path(source_file).parent
|
|
516
|
+
resolved = os.path.normpath(str(base / module)).replace(os.sep, "/")
|
|
517
|
+
# Go packages are directories — look for any .go file in that dir
|
|
518
|
+
for suffix in (".go", "/main.go", "/"+Path(resolved).name+".go"):
|
|
519
|
+
key = resolved + suffix
|
|
520
|
+
if key in file_index:
|
|
521
|
+
return file_index[key]
|
|
522
|
+
return None
|
|
523
|
+
|
|
524
|
+
# Skip stdlib (no dots in import path = stdlib in Go)
|
|
525
|
+
if "." not in module:
|
|
526
|
+
return None
|
|
527
|
+
|
|
528
|
+
# Skip external packages — only resolve if the module path maps to local files
|
|
529
|
+
# Try the last N segments as a directory path
|
|
530
|
+
parts = module.split("/")
|
|
531
|
+
for start in range(len(parts)):
|
|
532
|
+
candidate_dir = "/".join(parts[start:])
|
|
533
|
+
# Look for any file under this directory in the index
|
|
534
|
+
for key in file_index:
|
|
535
|
+
if key.startswith(candidate_dir + "/") and key.endswith(".go"):
|
|
536
|
+
return file_index[key]
|
|
537
|
+
|
|
538
|
+
return None
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _resolve_java_import(module: str, source_file: str, file_index: dict[str, str]) -> str | None:
|
|
542
|
+
"""Resolve a Java import to a relative file path.
|
|
543
|
+
|
|
544
|
+
Handles:
|
|
545
|
+
- ``import com.myproject.utils.Helper`` → ``com/myproject/utils/Helper.java``
|
|
546
|
+
- Skips stdlib (``java.``, ``javax.``) and common third-party packages
|
|
547
|
+
"""
|
|
548
|
+
if not module:
|
|
549
|
+
return None
|
|
550
|
+
|
|
551
|
+
# Skip stdlib and common external packages
|
|
552
|
+
if module.startswith(("java.", "javax.", "sun.", "com.sun.",
|
|
553
|
+
"org.junit.", "org.apache.", "org.springframework.",
|
|
554
|
+
"com.google.", "org.slf4j.", "org.mockito.")):
|
|
555
|
+
return None
|
|
556
|
+
|
|
557
|
+
# Skip wildcard imports for resolution (still tracked for other purposes)
|
|
558
|
+
if module.endswith(".*"):
|
|
559
|
+
module = module[:-2]
|
|
560
|
+
|
|
561
|
+
# Convert package.Class to path
|
|
562
|
+
candidate = module.replace(".", "/") + ".java"
|
|
563
|
+
normalized = candidate.replace(os.sep, "/")
|
|
564
|
+
if normalized in file_index:
|
|
565
|
+
return file_index[normalized]
|
|
566
|
+
|
|
567
|
+
# Try under common source roots
|
|
568
|
+
for prefix in ("src/main/java/", "src/", "app/src/main/java/"):
|
|
569
|
+
key = prefix + normalized
|
|
570
|
+
if key in file_index:
|
|
571
|
+
return file_index[key]
|
|
572
|
+
|
|
573
|
+
return None
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _resolve_ruby_import(module: str, source_file: str, file_index: dict[str, str]) -> str | None:
|
|
577
|
+
"""Resolve a Ruby require/require_relative to a relative file path.
|
|
578
|
+
|
|
579
|
+
Handles:
|
|
580
|
+
- ``require_relative './helper'`` → relative path resolution
|
|
581
|
+
- ``require 'mylib/utils'`` → try local lib/ resolution
|
|
582
|
+
- Skips stdlib/gems (no reliable local resolution)
|
|
583
|
+
"""
|
|
584
|
+
if not module:
|
|
585
|
+
return None
|
|
586
|
+
|
|
587
|
+
# require_relative: module starts with ./ or ../
|
|
588
|
+
if module.startswith("./") or module.startswith("../"):
|
|
589
|
+
base = Path(source_file).parent
|
|
590
|
+
resolved = os.path.normpath(str(base / module)).replace(os.sep, "/")
|
|
591
|
+
for suffix in ("", ".rb"):
|
|
592
|
+
key = resolved + suffix
|
|
593
|
+
if key in file_index:
|
|
594
|
+
return file_index[key]
|
|
595
|
+
return None
|
|
596
|
+
|
|
597
|
+
# Plain require: try as a local path with .rb extension
|
|
598
|
+
candidate = module
|
|
599
|
+
for suffix in (".rb",):
|
|
600
|
+
key = candidate + suffix
|
|
601
|
+
normalized = key.replace(os.sep, "/")
|
|
602
|
+
if normalized in file_index:
|
|
603
|
+
return file_index[normalized]
|
|
604
|
+
# Try under lib/
|
|
605
|
+
lib_key = "lib/" + normalized
|
|
606
|
+
if lib_key in file_index:
|
|
607
|
+
return file_index[lib_key]
|
|
608
|
+
|
|
609
|
+
return None
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _resolve_c_import(module: str, source_file: str, file_index: dict[str, str]) -> str | None:
|
|
613
|
+
"""Resolve a C/C++ #include to a relative file path.
|
|
614
|
+
|
|
615
|
+
Handles:
|
|
616
|
+
- ``#include "myheader.h"`` → search in same dir and include paths
|
|
617
|
+
- Skips system headers (angle-bracket includes like ``<stdio.h>``)
|
|
618
|
+
|
|
619
|
+
Note: The module string from the parser is the include path without
|
|
620
|
+
quotes/brackets. System includes are identified by convention (no path
|
|
621
|
+
separators, common system headers).
|
|
622
|
+
"""
|
|
623
|
+
if not module:
|
|
624
|
+
return None
|
|
625
|
+
|
|
626
|
+
# System headers (angle-bracket includes are typically marked differently,
|
|
627
|
+
# but as a heuristic: skip common system/stdlib headers)
|
|
628
|
+
_SYSTEM_PREFIXES = ( # noqa: N806
|
|
629
|
+
"stdio", "stdlib", "string", "math", "time", "errno", "signal",
|
|
630
|
+
"assert", "ctype", "locale", "setjmp", "stdarg", "stddef",
|
|
631
|
+
"sys/", "linux/", "windows.h", "unistd.h", "fcntl.h",
|
|
632
|
+
"iostream", "vector", "map", "string", "algorithm", "memory",
|
|
633
|
+
"functional", "utility", "numeric", "iterator", "cassert",
|
|
634
|
+
"cstdio", "cstdlib", "cstring", "cmath",
|
|
635
|
+
)
|
|
636
|
+
module_lower = module.lower()
|
|
637
|
+
if any(module_lower.startswith(p) or module_lower == p for p in _SYSTEM_PREFIXES):
|
|
638
|
+
return None
|
|
639
|
+
|
|
640
|
+
# Try exact path in file index
|
|
641
|
+
normalized = module.replace(os.sep, "/")
|
|
642
|
+
if normalized in file_index:
|
|
643
|
+
return file_index[normalized]
|
|
644
|
+
|
|
645
|
+
# Try relative to source file directory
|
|
646
|
+
source_dir = str(Path(source_file).parent)
|
|
647
|
+
candidate = source_dir + "/" + normalized
|
|
648
|
+
if candidate in file_index:
|
|
649
|
+
return file_index[candidate]
|
|
650
|
+
|
|
651
|
+
# Try common include directories
|
|
652
|
+
for prefix in ("include/", "src/", "inc/"):
|
|
653
|
+
key = prefix + normalized
|
|
654
|
+
if key in file_index:
|
|
655
|
+
return file_index[key]
|
|
656
|
+
|
|
657
|
+
return None
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
IMPORT_RESOLVERS = {
|
|
661
|
+
"python": _resolve_python_import, "javascript": _resolve_js_import,
|
|
662
|
+
"typescript": _resolve_js_import, "rust": _resolve_rust_import,
|
|
663
|
+
"go": _resolve_go_import, "java": _resolve_java_import,
|
|
664
|
+
"ruby": _resolve_ruby_import, "c": _resolve_c_import, "cpp": _resolve_c_import,
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _compute_dynamic_cap(files: list[FileInfo], configured_max: int) -> int:
|
|
669
|
+
"""Compute a dynamic file cap based on repo composition.
|
|
670
|
+
|
|
671
|
+
Rules:
|
|
672
|
+
- <1,000 source files → no cap (keep all)
|
|
673
|
+
- 1,000–5,000 source files → cap at source count (keep all source, trim non-source)
|
|
674
|
+
- 5,000–20,000 → cap at 10,000
|
|
675
|
+
- 20,000+ → cap at 20,000
|
|
676
|
+
|
|
677
|
+
Args:
|
|
678
|
+
files: All discovered files (before truncation).
|
|
679
|
+
configured_max: The user-configured max_files (used as a minimum).
|
|
680
|
+
|
|
681
|
+
Returns:
|
|
682
|
+
The effective cap to apply.
|
|
683
|
+
"""
|
|
684
|
+
source_count = sum(1 for f in files if f.language)
|
|
685
|
+
total = len(files)
|
|
686
|
+
|
|
687
|
+
if source_count < 1_000:
|
|
688
|
+
cap = total # Keep everything
|
|
689
|
+
elif source_count <= 5_000:
|
|
690
|
+
cap = max(source_count, configured_max)
|
|
691
|
+
elif source_count <= 20_000:
|
|
692
|
+
cap = max(10_000, configured_max)
|
|
693
|
+
else:
|
|
694
|
+
cap = max(20_000, configured_max)
|
|
695
|
+
|
|
696
|
+
return cap
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def build_dependency_graph(
|
|
700
|
+
files: list[FileInfo],
|
|
701
|
+
root_dir: str,
|
|
702
|
+
*,
|
|
703
|
+
ast_cache: dict | None = None,
|
|
704
|
+
) -> DependencyGraph:
|
|
705
|
+
"""Build a dependency graph from file imports.
|
|
706
|
+
|
|
707
|
+
Parses each file's imports and resolves them to file paths
|
|
708
|
+
to build forward (imports) and reverse (imported-by) graphs.
|
|
709
|
+
|
|
710
|
+
Args:
|
|
711
|
+
files: Discovered files with language info.
|
|
712
|
+
root_dir: Repository root directory.
|
|
713
|
+
|
|
714
|
+
Returns:
|
|
715
|
+
DependencyGraph with forward and reverse edges.
|
|
716
|
+
"""
|
|
717
|
+
from attocode_intel._internal.integrations.context.codebase_ast import parse_file
|
|
718
|
+
|
|
719
|
+
graph = DependencyGraph()
|
|
720
|
+
|
|
721
|
+
# Build index with prefix-stripped alternate keys for src/ layout
|
|
722
|
+
file_index = _build_file_index(files, root_dir)
|
|
723
|
+
|
|
724
|
+
_resolvers = IMPORT_RESOLVERS
|
|
725
|
+
|
|
726
|
+
# Languages that have parseable imports
|
|
727
|
+
supported_langs = set(_resolvers.keys())
|
|
728
|
+
|
|
729
|
+
for f in files:
|
|
730
|
+
if f.language not in supported_langs:
|
|
731
|
+
continue
|
|
732
|
+
|
|
733
|
+
try:
|
|
734
|
+
ast = ast_cache.get(f.relative_path) if ast_cache is not None else None
|
|
735
|
+
if ast is None:
|
|
736
|
+
ast = parse_file(f.path)
|
|
737
|
+
except Exception:
|
|
738
|
+
continue
|
|
739
|
+
|
|
740
|
+
resolver = _resolvers[f.language]
|
|
741
|
+
for imp in ast.imports:
|
|
742
|
+
target = resolver(imp.module, f.relative_path, file_index)
|
|
743
|
+
if target is not None and target != f.relative_path:
|
|
744
|
+
graph.add_edge(f.relative_path, target)
|
|
745
|
+
elif target is None:
|
|
746
|
+
graph.unresolved.setdefault(f.relative_path, []).append(imp.module)
|
|
747
|
+
|
|
748
|
+
return graph
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
@dataclass
|
|
752
|
+
class CodebaseContextManager:
|
|
753
|
+
"""Manages codebase context for intelligent code understanding.
|
|
754
|
+
|
|
755
|
+
Discovers files, builds repository maps, scores importance,
|
|
756
|
+
and selects relevant context for LLM prompts.
|
|
757
|
+
|
|
758
|
+
Supports incremental updates: after file edits, call
|
|
759
|
+
``mark_file_dirty()`` then ``update_dirty_files()`` to
|
|
760
|
+
re-parse only changed files instead of a full ``discover_files()``.
|
|
761
|
+
"""
|
|
762
|
+
|
|
763
|
+
root_dir: str
|
|
764
|
+
max_files: int = 2000
|
|
765
|
+
max_context_tokens: int = 8000
|
|
766
|
+
discovered_file_count: int = 0
|
|
767
|
+
discovery_truncated: bool = False
|
|
768
|
+
excluded_checkout_roots: list[str] = field(default_factory=list)
|
|
769
|
+
excluded_checkout_roots_truncated: bool = False
|
|
770
|
+
ignore_patterns: set[str] = field(default_factory=lambda: set(DEFAULT_IGNORES))
|
|
771
|
+
_files: list[FileInfo] = field(default_factory=list, repr=False)
|
|
772
|
+
_repo_map: RepoMap | None = field(default=None, repr=False)
|
|
773
|
+
_dep_graph: DependencyGraph | None = field(default=None, repr=False)
|
|
774
|
+
_file_mtimes: dict[str, float] = field(default_factory=dict, repr=False)
|
|
775
|
+
_dirty_files: set[str] = field(default_factory=set, repr=False)
|
|
776
|
+
_ast_cache: dict[str, Any] = field(default_factory=dict, repr=False)
|
|
777
|
+
_last_refresh_time: float = field(default=0.0, repr=False)
|
|
778
|
+
_staleness_threshold: float = field(default=300.0, repr=False) # 5 minutes
|
|
779
|
+
_ignored_checkout_dirs: list[str] = field(default_factory=list, repr=False)
|
|
780
|
+
_ignored_checkout_dirs_capped: bool = field(default=False, repr=False)
|
|
781
|
+
|
|
782
|
+
@property
|
|
783
|
+
def is_stale(self) -> bool:
|
|
784
|
+
"""Check if the file discovery cache is older than the staleness threshold."""
|
|
785
|
+
if not self._files or self._last_refresh_time == 0.0:
|
|
786
|
+
return True
|
|
787
|
+
return (time.monotonic() - self._last_refresh_time) > self._staleness_threshold
|
|
788
|
+
|
|
789
|
+
def _ensure_fresh(self) -> None:
|
|
790
|
+
"""Auto-refresh if the cache is stale."""
|
|
791
|
+
if self.is_stale:
|
|
792
|
+
self.discover_files()
|
|
793
|
+
|
|
794
|
+
def refresh_excluded_checkout_roots(self) -> None:
|
|
795
|
+
"""Refresh bounded checkout guidance without rediscovering source files."""
|
|
796
|
+
root = Path(self.root_dir)
|
|
797
|
+
self.excluded_checkout_roots = []
|
|
798
|
+
self.excluded_checkout_roots_truncated = self._ignored_checkout_dirs_capped
|
|
799
|
+
for rel_path in self._ignored_checkout_dirs:
|
|
800
|
+
path = root / rel_path
|
|
801
|
+
if path.is_symlink() or not path.is_dir():
|
|
802
|
+
continue
|
|
803
|
+
candidates = [(path, rel_path)]
|
|
804
|
+
try:
|
|
805
|
+
with os.scandir(path) as entries:
|
|
806
|
+
for offset, entry in enumerate(entries):
|
|
807
|
+
if offset >= 128:
|
|
808
|
+
self.excluded_checkout_roots_truncated = True
|
|
809
|
+
break
|
|
810
|
+
if entry.is_dir(follow_symlinks=False):
|
|
811
|
+
candidates.append((Path(entry.path), f"{rel_path}/{entry.name}"))
|
|
812
|
+
except OSError:
|
|
813
|
+
self.excluded_checkout_roots_truncated = True
|
|
814
|
+
continue
|
|
815
|
+
for candidate, relative in candidates:
|
|
816
|
+
if (candidate / ".git").exists():
|
|
817
|
+
if len(self.excluded_checkout_roots) < 5:
|
|
818
|
+
self.excluded_checkout_roots.append(relative.replace(os.sep, "/"))
|
|
819
|
+
else:
|
|
820
|
+
self.excluded_checkout_roots_truncated = True
|
|
821
|
+
self.excluded_checkout_roots.sort()
|
|
822
|
+
|
|
823
|
+
def discover_files(self) -> list[FileInfo]:
|
|
824
|
+
"""Discover all relevant files in the repository.
|
|
825
|
+
|
|
826
|
+
Two-pass approach: collect all files (with safety ceiling), then
|
|
827
|
+
score importance and truncate to a dynamic cap. This ensures
|
|
828
|
+
important files deep in the alphabet aren't excluded by
|
|
829
|
+
filesystem traversal order.
|
|
830
|
+
|
|
831
|
+
Returns:
|
|
832
|
+
List of FileInfo objects for discovered files.
|
|
833
|
+
"""
|
|
834
|
+
root = Path(self.root_dir)
|
|
835
|
+
from attocode_intel._internal.integrations.utilities.ignore import IgnoreManager
|
|
836
|
+
|
|
837
|
+
ignore = IgnoreManager(root)
|
|
838
|
+
files: list[FileInfo] = []
|
|
839
|
+
_SAFETY_CEILING = 50_000 # noqa: N806 # OOM guard for massive repos
|
|
840
|
+
self._ignored_checkout_dirs = []
|
|
841
|
+
self._ignored_checkout_dirs_capped = False
|
|
842
|
+
|
|
843
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
844
|
+
# Filter ignored directories (in-place to prevent os.walk descent)
|
|
845
|
+
kept_dirs = []
|
|
846
|
+
for directory in dirnames:
|
|
847
|
+
if directory in self.ignore_patterns or directory.startswith("."):
|
|
848
|
+
continue
|
|
849
|
+
path = Path(dirpath) / directory
|
|
850
|
+
rel_path = path.relative_to(root).as_posix()
|
|
851
|
+
if ignore.is_ignored(rel_path + "/"):
|
|
852
|
+
if not path.is_symlink():
|
|
853
|
+
if len(self._ignored_checkout_dirs) < 32:
|
|
854
|
+
self._ignored_checkout_dirs.append(rel_path)
|
|
855
|
+
else:
|
|
856
|
+
self._ignored_checkout_dirs_capped = True
|
|
857
|
+
else:
|
|
858
|
+
kept_dirs.append(directory)
|
|
859
|
+
# Sorted names: the file system order differs between macOS and Linux, and files of
|
|
860
|
+
# equal importance keep the walk order through the sort and the cap below.
|
|
861
|
+
dirnames[:] = sorted(kept_dirs)
|
|
862
|
+
|
|
863
|
+
for filename in sorted(filenames):
|
|
864
|
+
if filename.startswith("."):
|
|
865
|
+
continue
|
|
866
|
+
if filename in SKIP_FILENAMES:
|
|
867
|
+
continue
|
|
868
|
+
ext = Path(filename).suffix.lower()
|
|
869
|
+
if ext in SKIP_EXTENSIONS:
|
|
870
|
+
continue
|
|
871
|
+
|
|
872
|
+
full_path = os.path.join(dirpath, filename)
|
|
873
|
+
try:
|
|
874
|
+
rel_path = os.path.relpath(full_path, root)
|
|
875
|
+
except ValueError:
|
|
876
|
+
continue
|
|
877
|
+
if ignore.is_ignored(rel_path):
|
|
878
|
+
continue
|
|
879
|
+
|
|
880
|
+
lang = EXTENSION_LANGUAGES.get(ext, "")
|
|
881
|
+
|
|
882
|
+
try:
|
|
883
|
+
size = os.path.getsize(full_path)
|
|
884
|
+
except OSError:
|
|
885
|
+
size = 0
|
|
886
|
+
|
|
887
|
+
# Skip very large files
|
|
888
|
+
if size > 1_000_000: # 1MB
|
|
889
|
+
continue
|
|
890
|
+
|
|
891
|
+
is_test = any(p in rel_path.lower() for p in TEST_PATTERNS)
|
|
892
|
+
is_config = any(p in rel_path.lower() for p in CONFIG_PATTERNS)
|
|
893
|
+
|
|
894
|
+
# Estimate line count (only for source files with a recognized lang)
|
|
895
|
+
line_count = 0
|
|
896
|
+
if lang and size < 500_000:
|
|
897
|
+
try:
|
|
898
|
+
with open(full_path, encoding="utf-8", errors="ignore") as f:
|
|
899
|
+
line_count = sum(1 for _ in f)
|
|
900
|
+
except OSError:
|
|
901
|
+
pass
|
|
902
|
+
|
|
903
|
+
files.append(FileInfo(
|
|
904
|
+
path=full_path,
|
|
905
|
+
relative_path=rel_path,
|
|
906
|
+
size=size,
|
|
907
|
+
language=lang,
|
|
908
|
+
is_test=is_test,
|
|
909
|
+
is_config=is_config,
|
|
910
|
+
line_count=line_count,
|
|
911
|
+
))
|
|
912
|
+
|
|
913
|
+
if len(files) >= _SAFETY_CEILING:
|
|
914
|
+
break
|
|
915
|
+
|
|
916
|
+
if len(files) >= _SAFETY_CEILING:
|
|
917
|
+
break
|
|
918
|
+
|
|
919
|
+
# Score importance
|
|
920
|
+
for f in files:
|
|
921
|
+
f.importance = self._score_importance(f)
|
|
922
|
+
|
|
923
|
+
# Build dependency graph and boost hub files using PageRank
|
|
924
|
+
# Skip for large repos (>5K source files) — dep graph parsing is the
|
|
925
|
+
# main bootstrap bottleneck. Heuristic importance is good enough for
|
|
926
|
+
# skeleton init; full dep graph is built during background hydration.
|
|
927
|
+
source_count = sum(1 for f in files if f.language)
|
|
928
|
+
if source_count < 5_000:
|
|
929
|
+
self._dep_graph = build_dependency_graph(files, self.root_dir)
|
|
930
|
+
pr_scores = self._dep_graph.pagerank()
|
|
931
|
+
for f in files:
|
|
932
|
+
pr = pr_scores.get(f.relative_path, 0.0)
|
|
933
|
+
# Normalize PageRank to 0.0-0.3 boost range
|
|
934
|
+
hub_boost = pr * 0.3
|
|
935
|
+
if hub_boost > 0.01:
|
|
936
|
+
f.importance = min(1.0, f.importance + hub_boost)
|
|
937
|
+
else:
|
|
938
|
+
self._dep_graph = DependencyGraph() # empty placeholder
|
|
939
|
+
|
|
940
|
+
# Sort by importance (highest first)
|
|
941
|
+
files.sort(key=lambda f: f.importance, reverse=True)
|
|
942
|
+
|
|
943
|
+
# Truncate to dynamic cap AFTER scoring (smart truncation)
|
|
944
|
+
cap = _compute_dynamic_cap(files, self.max_files)
|
|
945
|
+
self.discovered_file_count = len(files)
|
|
946
|
+
self.discovery_truncated = len(files) > cap
|
|
947
|
+
if len(files) > cap:
|
|
948
|
+
files = files[:cap]
|
|
949
|
+
|
|
950
|
+
self._files = files
|
|
951
|
+
self.refresh_excluded_checkout_roots()
|
|
952
|
+
self._last_refresh_time = time.monotonic()
|
|
953
|
+
return files
|
|
954
|
+
|
|
955
|
+
@property
|
|
956
|
+
def dependency_graph(self) -> DependencyGraph | None:
|
|
957
|
+
"""Get the dependency graph (available after discover_files)."""
|
|
958
|
+
return self._dep_graph
|
|
959
|
+
|
|
960
|
+
def get_repo_map(
|
|
961
|
+
self,
|
|
962
|
+
*,
|
|
963
|
+
include_symbols: bool = False,
|
|
964
|
+
max_tokens: int | None = None,
|
|
965
|
+
) -> RepoMap:
|
|
966
|
+
"""Generate a repository map.
|
|
967
|
+
|
|
968
|
+
Args:
|
|
969
|
+
include_symbols: If True, annotate files with top-level symbols.
|
|
970
|
+
max_tokens: When set, produce a token-budgeted tree using
|
|
971
|
+
importance tiers. Files are partitioned into:
|
|
972
|
+
- **Tier 1** (importance >= 0.7): shown WITH symbols (up to 5)
|
|
973
|
+
- **Tier 2** (importance >= 0.4): shown WITHOUT symbols
|
|
974
|
+
- **Tier 3** (below 0.4): collapsed as ``... +N files`` per dir
|
|
975
|
+
When ``None`` (default), the full unbounded tree is returned
|
|
976
|
+
(backward-compatible).
|
|
977
|
+
|
|
978
|
+
Returns:
|
|
979
|
+
RepoMap with tree view and file information.
|
|
980
|
+
"""
|
|
981
|
+
if self._repo_map is not None and not include_symbols and max_tokens is None:
|
|
982
|
+
return self._repo_map
|
|
983
|
+
|
|
984
|
+
if not self._files:
|
|
985
|
+
self.discover_files()
|
|
986
|
+
|
|
987
|
+
# Collect symbols per file if requested
|
|
988
|
+
file_symbols: dict[str, list[str]] = {}
|
|
989
|
+
if include_symbols:
|
|
990
|
+
file_symbols = self._collect_symbols()
|
|
991
|
+
|
|
992
|
+
# Build tree — budgeted or full
|
|
993
|
+
if max_tokens is not None:
|
|
994
|
+
tree, included_symbols = self._build_budgeted_tree(
|
|
995
|
+
max_tokens, file_symbols,
|
|
996
|
+
)
|
|
997
|
+
# Narrow symbols dict to only included files
|
|
998
|
+
file_symbols = included_symbols
|
|
999
|
+
else:
|
|
1000
|
+
tree = self._build_tree(file_symbols=file_symbols)
|
|
1001
|
+
|
|
1002
|
+
# Compute language stats
|
|
1003
|
+
languages: dict[str, int] = {}
|
|
1004
|
+
total_lines = 0
|
|
1005
|
+
for f in self._files:
|
|
1006
|
+
if f.language:
|
|
1007
|
+
languages[f.language] = languages.get(f.language, 0) + 1
|
|
1008
|
+
total_lines += f.line_count
|
|
1009
|
+
|
|
1010
|
+
repo_map = RepoMap(
|
|
1011
|
+
tree=tree,
|
|
1012
|
+
files=self._files,
|
|
1013
|
+
total_files=len(self._files),
|
|
1014
|
+
total_lines=total_lines,
|
|
1015
|
+
languages=languages,
|
|
1016
|
+
symbols=file_symbols,
|
|
1017
|
+
)
|
|
1018
|
+
|
|
1019
|
+
if not include_symbols and max_tokens is None:
|
|
1020
|
+
self._repo_map = repo_map
|
|
1021
|
+
|
|
1022
|
+
return repo_map
|
|
1023
|
+
|
|
1024
|
+
def get_preseed_map(self, max_tokens: int = 6000) -> RepoMap:
|
|
1025
|
+
"""Convenience wrapper for preseed injection.
|
|
1026
|
+
|
|
1027
|
+
Returns a token-budgeted repo map suitable for injecting into
|
|
1028
|
+
the initial system context without blowing up the context window.
|
|
1029
|
+
|
|
1030
|
+
Args:
|
|
1031
|
+
max_tokens: Token budget for the tree + symbols output.
|
|
1032
|
+
"""
|
|
1033
|
+
return self.get_repo_map(include_symbols=True, max_tokens=max_tokens)
|
|
1034
|
+
|
|
1035
|
+
def _collect_symbols(self) -> dict[str, list[str]]:
|
|
1036
|
+
"""Collect top-level symbols for all parseable files.
|
|
1037
|
+
|
|
1038
|
+
Prefers the ASTService cache (if available and initialized)
|
|
1039
|
+
over re-parsing each file.
|
|
1040
|
+
"""
|
|
1041
|
+
file_symbols: dict[str, list[str]] = {}
|
|
1042
|
+
|
|
1043
|
+
# Try ASTService cache first
|
|
1044
|
+
ast_cache: dict[str, Any] | None = None
|
|
1045
|
+
try:
|
|
1046
|
+
from attocode_intel._internal.integrations.context.ast_service import ASTService
|
|
1047
|
+
svc = ASTService.get_instance(self.root_dir)
|
|
1048
|
+
if svc.initialized:
|
|
1049
|
+
ast_cache = svc._ast_cache
|
|
1050
|
+
except Exception:
|
|
1051
|
+
pass
|
|
1052
|
+
|
|
1053
|
+
from attocode_intel._internal.integrations.context.codebase_ast import (
|
|
1054
|
+
parse_file as _parse_file,
|
|
1055
|
+
)
|
|
1056
|
+
|
|
1057
|
+
_SYMBOL_LANGS = {"python", "javascript", "typescript", "rust", "go", "java", "ruby", "c", "cpp"} # noqa: N806
|
|
1058
|
+
for f in self._files:
|
|
1059
|
+
if f.language not in _SYMBOL_LANGS:
|
|
1060
|
+
continue
|
|
1061
|
+
try:
|
|
1062
|
+
# Use cached AST when available
|
|
1063
|
+
if ast_cache and f.relative_path in ast_cache:
|
|
1064
|
+
ast = ast_cache[f.relative_path]
|
|
1065
|
+
else:
|
|
1066
|
+
ast = _parse_file(f.path)
|
|
1067
|
+
syms = ast.get_symbols()
|
|
1068
|
+
if syms:
|
|
1069
|
+
file_symbols[f.relative_path] = syms[:15] # Cap at 15 symbols
|
|
1070
|
+
except Exception:
|
|
1071
|
+
pass
|
|
1072
|
+
return file_symbols
|
|
1073
|
+
|
|
1074
|
+
def get_tree_view(self, max_depth: int = 3) -> str:
|
|
1075
|
+
"""Get a lightweight tree view of the repository.
|
|
1076
|
+
|
|
1077
|
+
Args:
|
|
1078
|
+
max_depth: Maximum directory depth to show.
|
|
1079
|
+
|
|
1080
|
+
Returns:
|
|
1081
|
+
Text tree view string.
|
|
1082
|
+
"""
|
|
1083
|
+
return self._build_tree(max_depth=max_depth)
|
|
1084
|
+
|
|
1085
|
+
def select_context(
|
|
1086
|
+
self,
|
|
1087
|
+
query: str = "",
|
|
1088
|
+
strategy: str = "importance",
|
|
1089
|
+
max_files: int = 20,
|
|
1090
|
+
) -> list[FileInfo]:
|
|
1091
|
+
"""Select the most relevant files for context.
|
|
1092
|
+
|
|
1093
|
+
Args:
|
|
1094
|
+
query: Optional query to influence selection.
|
|
1095
|
+
strategy: Selection strategy ('importance', 'relevance', 'breadth').
|
|
1096
|
+
max_files: Maximum files to include.
|
|
1097
|
+
|
|
1098
|
+
Returns:
|
|
1099
|
+
Selected files.
|
|
1100
|
+
"""
|
|
1101
|
+
self._ensure_fresh()
|
|
1102
|
+
|
|
1103
|
+
if strategy == "relevance" and query:
|
|
1104
|
+
return self._select_by_relevance(query, max_files)
|
|
1105
|
+
elif strategy == "breadth":
|
|
1106
|
+
return self._select_by_breadth(max_files)
|
|
1107
|
+
else:
|
|
1108
|
+
# Default: importance-based
|
|
1109
|
+
return self._files[:max_files]
|
|
1110
|
+
|
|
1111
|
+
def format_context(
|
|
1112
|
+
self,
|
|
1113
|
+
files: list[FileInfo] | None = None,
|
|
1114
|
+
include_content: bool = False,
|
|
1115
|
+
max_tokens: int | None = None,
|
|
1116
|
+
) -> str:
|
|
1117
|
+
"""Format selected files as context for LLM prompt.
|
|
1118
|
+
|
|
1119
|
+
Args:
|
|
1120
|
+
files: Files to include (defaults to top by importance).
|
|
1121
|
+
include_content: Whether to include file contents.
|
|
1122
|
+
max_tokens: Token budget for context.
|
|
1123
|
+
|
|
1124
|
+
Returns:
|
|
1125
|
+
Formatted context string.
|
|
1126
|
+
"""
|
|
1127
|
+
if files is None:
|
|
1128
|
+
files = self.select_context(max_files=15)
|
|
1129
|
+
|
|
1130
|
+
max_tok = max_tokens or self.max_context_tokens
|
|
1131
|
+
from attocode_intel._internal.integrations.utilities.token_estimate import estimate_tokens
|
|
1132
|
+
|
|
1133
|
+
parts = ["## Repository Context\n"]
|
|
1134
|
+
parts.append(f"Files: {len(self._files)} | ")
|
|
1135
|
+
parts.append(f"Languages: {', '.join(sorted(self.get_repo_map().languages.keys()))}\n")
|
|
1136
|
+
parts.append("\n### Key Files\n")
|
|
1137
|
+
|
|
1138
|
+
token_count = estimate_tokens("\n".join(parts))
|
|
1139
|
+
|
|
1140
|
+
for f in files:
|
|
1141
|
+
entry = f"- `{f.relative_path}` ({f.line_count}L, {f.language})"
|
|
1142
|
+
entry_tokens = estimate_tokens(entry)
|
|
1143
|
+
|
|
1144
|
+
if include_content and f.size < 50_000:
|
|
1145
|
+
try:
|
|
1146
|
+
content = Path(f.path).read_text(encoding="utf-8", errors="ignore")
|
|
1147
|
+
content_entry = f"\n```{f.language}\n# {f.relative_path}\n{content}\n```\n"
|
|
1148
|
+
content_tokens = estimate_tokens(content_entry)
|
|
1149
|
+
if token_count + content_tokens <= max_tok:
|
|
1150
|
+
entry = entry + content_entry
|
|
1151
|
+
entry_tokens += content_tokens
|
|
1152
|
+
except OSError:
|
|
1153
|
+
pass
|
|
1154
|
+
|
|
1155
|
+
if token_count + entry_tokens > max_tok:
|
|
1156
|
+
break
|
|
1157
|
+
|
|
1158
|
+
parts.append(entry)
|
|
1159
|
+
token_count += entry_tokens
|
|
1160
|
+
|
|
1161
|
+
return "\n".join(parts)
|
|
1162
|
+
|
|
1163
|
+
# --- Post-compaction file restoration ---
|
|
1164
|
+
|
|
1165
|
+
def get_top_files_by_importance(
|
|
1166
|
+
self,
|
|
1167
|
+
*,
|
|
1168
|
+
max_files: int = 5,
|
|
1169
|
+
max_tokens: int = 50_000,
|
|
1170
|
+
exclude_patterns: list[str] | None = None,
|
|
1171
|
+
) -> list[tuple[str, float]]:
|
|
1172
|
+
"""Return top-N files by importance score that fit within token budget.
|
|
1173
|
+
|
|
1174
|
+
Uses existing PageRank-boosted importance scores to identify the most
|
|
1175
|
+
important files in the current project context. Used by post-compaction
|
|
1176
|
+
restoration to re-inject critical context.
|
|
1177
|
+
|
|
1178
|
+
Files are drawn from the discovered file list (``self._files``), which
|
|
1179
|
+
is sorted by importance descending after ``discover_files()`` applies
|
|
1180
|
+
heuristic scoring plus PageRank hub boosts.
|
|
1181
|
+
|
|
1182
|
+
Args:
|
|
1183
|
+
max_files: Maximum number of files to return.
|
|
1184
|
+
max_tokens: Maximum total estimated tokens across all returned files.
|
|
1185
|
+
exclude_patterns: Glob patterns to exclude (e.g.,
|
|
1186
|
+
``["*.test.*", "node_modules/*"]``).
|
|
1187
|
+
|
|
1188
|
+
Returns:
|
|
1189
|
+
List of ``(relative_path, importance_score)`` tuples, sorted by
|
|
1190
|
+
score descending.
|
|
1191
|
+
"""
|
|
1192
|
+
self._ensure_fresh()
|
|
1193
|
+
|
|
1194
|
+
if not self._files:
|
|
1195
|
+
return []
|
|
1196
|
+
|
|
1197
|
+
# Apply exclusion patterns
|
|
1198
|
+
candidates = self._files
|
|
1199
|
+
if exclude_patterns:
|
|
1200
|
+
import fnmatch
|
|
1201
|
+
|
|
1202
|
+
def _excluded(rel_path: str) -> bool:
|
|
1203
|
+
return any(
|
|
1204
|
+
fnmatch.fnmatch(rel_path, pat) for pat in exclude_patterns # type: ignore[arg-type]
|
|
1205
|
+
)
|
|
1206
|
+
|
|
1207
|
+
candidates = [f for f in candidates if not _excluded(f.relative_path)]
|
|
1208
|
+
|
|
1209
|
+
# Select files within token budget
|
|
1210
|
+
# Files are already sorted by importance (descending) from discover_files()
|
|
1211
|
+
result: list[tuple[str, float]] = []
|
|
1212
|
+
remaining_tokens = max_tokens
|
|
1213
|
+
|
|
1214
|
+
for file_info in candidates:
|
|
1215
|
+
if len(result) >= max_files:
|
|
1216
|
+
break
|
|
1217
|
+
|
|
1218
|
+
# Estimate tokens from file size (size in bytes / CHARS_PER_TOKEN)
|
|
1219
|
+
est_tokens = max(1, int(file_info.size / CHARS_PER_TOKEN))
|
|
1220
|
+
|
|
1221
|
+
if est_tokens > remaining_tokens:
|
|
1222
|
+
continue # Skip files that don't fit, try smaller ones
|
|
1223
|
+
|
|
1224
|
+
result.append((file_info.relative_path, file_info.importance))
|
|
1225
|
+
remaining_tokens -= est_tokens
|
|
1226
|
+
|
|
1227
|
+
return result
|
|
1228
|
+
|
|
1229
|
+
# --- Recursive context retrieval ---
|
|
1230
|
+
|
|
1231
|
+
def get_recursive_context(
|
|
1232
|
+
self,
|
|
1233
|
+
seed_files: list[str],
|
|
1234
|
+
token_budget: int = 20_000,
|
|
1235
|
+
max_depth: int = 3,
|
|
1236
|
+
max_files: int = 15,
|
|
1237
|
+
) -> str:
|
|
1238
|
+
"""Gather context by recursively following imports from seed files.
|
|
1239
|
+
|
|
1240
|
+
Uses the RecursiveContextRetriever to mechanically follow Python/JS
|
|
1241
|
+
imports starting from seed files, gathering content up to a depth
|
|
1242
|
+
and token budget. Zero LLM cost.
|
|
1243
|
+
|
|
1244
|
+
Args:
|
|
1245
|
+
seed_files: Files to start traversal from.
|
|
1246
|
+
token_budget: Maximum tokens to gather.
|
|
1247
|
+
max_depth: Maximum import-following depth.
|
|
1248
|
+
max_files: Maximum files to visit.
|
|
1249
|
+
|
|
1250
|
+
Returns:
|
|
1251
|
+
Combined content from all gathered files.
|
|
1252
|
+
"""
|
|
1253
|
+
from attocode_intel._internal.tricks.recursive_context import RecursiveContextRetriever
|
|
1254
|
+
|
|
1255
|
+
class _FileProvider:
|
|
1256
|
+
def __init__(self, root: str) -> None:
|
|
1257
|
+
self._root = os.path.realpath(root)
|
|
1258
|
+
|
|
1259
|
+
def get_content(self, path: str) -> str | None:
|
|
1260
|
+
candidate = os.path.normpath(os.path.join(self._root, path))
|
|
1261
|
+
if not candidate.startswith(self._root):
|
|
1262
|
+
return None # Path traversal rejected
|
|
1263
|
+
try:
|
|
1264
|
+
with open(candidate, encoding="utf-8", errors="replace") as f:
|
|
1265
|
+
return f.read()
|
|
1266
|
+
except (OSError, UnicodeDecodeError):
|
|
1267
|
+
return None
|
|
1268
|
+
|
|
1269
|
+
retriever = RecursiveContextRetriever(
|
|
1270
|
+
_FileProvider(self.root_dir),
|
|
1271
|
+
max_depth=max_depth,
|
|
1272
|
+
token_budget=token_budget,
|
|
1273
|
+
max_files=max_files,
|
|
1274
|
+
)
|
|
1275
|
+
result = retriever.retrieve(seed_files)
|
|
1276
|
+
return result.content
|
|
1277
|
+
|
|
1278
|
+
# --- Incremental update API ---
|
|
1279
|
+
|
|
1280
|
+
def mark_file_dirty(self, file_path: str) -> None:
|
|
1281
|
+
"""Mark a file as needing re-analysis after edit.
|
|
1282
|
+
|
|
1283
|
+
Args:
|
|
1284
|
+
file_path: Absolute or relative path of the edited file.
|
|
1285
|
+
"""
|
|
1286
|
+
# Normalize to relative path
|
|
1287
|
+
try:
|
|
1288
|
+
rel = os.path.relpath(file_path, self.root_dir) if os.path.isabs(file_path) else os.path.normpath(file_path)
|
|
1289
|
+
except ValueError:
|
|
1290
|
+
rel = file_path
|
|
1291
|
+
self._dirty_files.add(rel)
|
|
1292
|
+
|
|
1293
|
+
def invalidate_file(self, file_path: str) -> None:
|
|
1294
|
+
"""Remove cached AST and analysis for a specific file.
|
|
1295
|
+
|
|
1296
|
+
Args:
|
|
1297
|
+
file_path: Path of the file to invalidate.
|
|
1298
|
+
"""
|
|
1299
|
+
try:
|
|
1300
|
+
rel = os.path.relpath(file_path, self.root_dir) if os.path.isabs(file_path) else os.path.normpath(file_path)
|
|
1301
|
+
except ValueError:
|
|
1302
|
+
rel = file_path
|
|
1303
|
+
self._ast_cache.pop(rel, None)
|
|
1304
|
+
self._ast_cache.pop(file_path, None)
|
|
1305
|
+
# Also invalidate repo map cache since it may reference stale symbols
|
|
1306
|
+
self._repo_map = None
|
|
1307
|
+
|
|
1308
|
+
def update_dirty_files(self) -> list[Any]:
|
|
1309
|
+
"""Re-parse only dirty files and update the dependency graph incrementally.
|
|
1310
|
+
|
|
1311
|
+
Returns:
|
|
1312
|
+
List of FileChangeResult objects describing what changed.
|
|
1313
|
+
"""
|
|
1314
|
+
if not self._dirty_files:
|
|
1315
|
+
return []
|
|
1316
|
+
|
|
1317
|
+
from attocode_intel._internal.integrations.context.codebase_ast import (
|
|
1318
|
+
DependencyChanges,
|
|
1319
|
+
FileChangeResult,
|
|
1320
|
+
SymbolChange,
|
|
1321
|
+
diff_file_ast,
|
|
1322
|
+
diff_imports,
|
|
1323
|
+
parse_file,
|
|
1324
|
+
)
|
|
1325
|
+
|
|
1326
|
+
results: list[Any] = []
|
|
1327
|
+
file_index = _build_file_index(self._files, self.root_dir)
|
|
1328
|
+
|
|
1329
|
+
for rel_path in list(self._dirty_files):
|
|
1330
|
+
# Find absolute path
|
|
1331
|
+
abs_path = os.path.join(self.root_dir, rel_path)
|
|
1332
|
+
|
|
1333
|
+
# Handle deleted files
|
|
1334
|
+
if not Path(abs_path).exists():
|
|
1335
|
+
old_ast = self._ast_cache.pop(rel_path, None)
|
|
1336
|
+
# Emit removal changes for all symbols in old AST
|
|
1337
|
+
if old_ast is not None:
|
|
1338
|
+
for func in old_ast.functions:
|
|
1339
|
+
results.append(FileChangeResult(
|
|
1340
|
+
file_path=rel_path,
|
|
1341
|
+
symbol_changes=[SymbolChange(
|
|
1342
|
+
kind="removed", symbol_name=func.name,
|
|
1343
|
+
symbol_kind="function", file_path=rel_path,
|
|
1344
|
+
previous=func,
|
|
1345
|
+
)],
|
|
1346
|
+
dependency_changes=DependencyChanges(),
|
|
1347
|
+
was_incremental=True,
|
|
1348
|
+
))
|
|
1349
|
+
for cls in old_ast.classes:
|
|
1350
|
+
results.append(FileChangeResult(
|
|
1351
|
+
file_path=rel_path,
|
|
1352
|
+
symbol_changes=[SymbolChange(
|
|
1353
|
+
kind="removed", symbol_name=cls.name,
|
|
1354
|
+
symbol_kind="class", file_path=rel_path,
|
|
1355
|
+
previous=cls,
|
|
1356
|
+
)],
|
|
1357
|
+
dependency_changes=DependencyChanges(),
|
|
1358
|
+
was_incremental=True,
|
|
1359
|
+
))
|
|
1360
|
+
# Remove dependency edges
|
|
1361
|
+
if self._dep_graph is not None:
|
|
1362
|
+
old_imports = self._dep_graph.forward.pop(rel_path, set())
|
|
1363
|
+
for target in old_imports:
|
|
1364
|
+
rev = self._dep_graph.reverse.get(target)
|
|
1365
|
+
if rev:
|
|
1366
|
+
rev.discard(rel_path)
|
|
1367
|
+
if not rev:
|
|
1368
|
+
del self._dep_graph.reverse[target]
|
|
1369
|
+
# Also remove as a reverse dep target
|
|
1370
|
+
importers = self._dep_graph.reverse.pop(rel_path, set())
|
|
1371
|
+
for importer in importers:
|
|
1372
|
+
self._dep_graph.forward.get(importer, set()).discard(rel_path)
|
|
1373
|
+
self._dep_graph.unresolved.pop(rel_path, None)
|
|
1374
|
+
self._file_mtimes.pop(rel_path, None)
|
|
1375
|
+
continue
|
|
1376
|
+
|
|
1377
|
+
# Get old AST from cache
|
|
1378
|
+
old_ast = self._ast_cache.get(rel_path)
|
|
1379
|
+
|
|
1380
|
+
# Parse new content
|
|
1381
|
+
try:
|
|
1382
|
+
new_ast = parse_file(abs_path)
|
|
1383
|
+
except Exception:
|
|
1384
|
+
continue
|
|
1385
|
+
|
|
1386
|
+
# Compute diffs
|
|
1387
|
+
if old_ast is not None:
|
|
1388
|
+
symbol_changes = diff_file_ast(old_ast, new_ast)
|
|
1389
|
+
dep_changes = diff_imports(old_ast, new_ast)
|
|
1390
|
+
else:
|
|
1391
|
+
symbol_changes = []
|
|
1392
|
+
dep_changes = DependencyChanges()
|
|
1393
|
+
|
|
1394
|
+
# Update AST cache
|
|
1395
|
+
self._ast_cache[rel_path] = new_ast
|
|
1396
|
+
|
|
1397
|
+
# Update dependency graph incrementally
|
|
1398
|
+
if self._dep_graph is not None and dep_changes:
|
|
1399
|
+
# Remove old edges for this file
|
|
1400
|
+
old_imports = self._dep_graph.forward.pop(rel_path, set())
|
|
1401
|
+
for target in old_imports:
|
|
1402
|
+
rev = self._dep_graph.reverse.get(target)
|
|
1403
|
+
if rev:
|
|
1404
|
+
rev.discard(rel_path)
|
|
1405
|
+
if not rev:
|
|
1406
|
+
del self._dep_graph.reverse[target]
|
|
1407
|
+
|
|
1408
|
+
# Add new edges
|
|
1409
|
+
self._dep_graph.unresolved.pop(rel_path, None)
|
|
1410
|
+
for imp in new_ast.imports:
|
|
1411
|
+
resolver = IMPORT_RESOLVERS.get(new_ast.language)
|
|
1412
|
+
target = resolver(imp.module, rel_path, file_index) if resolver else None
|
|
1413
|
+
if target is not None and target != rel_path:
|
|
1414
|
+
self._dep_graph.add_edge(rel_path, target)
|
|
1415
|
+
elif target is None:
|
|
1416
|
+
self._dep_graph.unresolved.setdefault(rel_path, []).append(imp.module)
|
|
1417
|
+
|
|
1418
|
+
# Update file mtime
|
|
1419
|
+
try:
|
|
1420
|
+
self._file_mtimes[rel_path] = os.path.getmtime(abs_path)
|
|
1421
|
+
except OSError:
|
|
1422
|
+
pass
|
|
1423
|
+
|
|
1424
|
+
results.append(FileChangeResult(
|
|
1425
|
+
file_path=rel_path,
|
|
1426
|
+
symbol_changes=symbol_changes,
|
|
1427
|
+
dependency_changes=dep_changes,
|
|
1428
|
+
was_incremental=True,
|
|
1429
|
+
))
|
|
1430
|
+
|
|
1431
|
+
# Invalidate repo map (symbols may have changed)
|
|
1432
|
+
self._repo_map = None
|
|
1433
|
+
self._dirty_files.clear()
|
|
1434
|
+
|
|
1435
|
+
return results
|
|
1436
|
+
|
|
1437
|
+
def _score_importance(self, file: FileInfo) -> float:
|
|
1438
|
+
"""Score file importance (0.0 - 1.0).
|
|
1439
|
+
|
|
1440
|
+
Heuristic scoring based on:
|
|
1441
|
+
- Entry points (main, cli) score highest
|
|
1442
|
+
- Config files score high
|
|
1443
|
+
- Source files score by size (moderate = best)
|
|
1444
|
+
- Test files score lower
|
|
1445
|
+
"""
|
|
1446
|
+
score = 0.5 # Base
|
|
1447
|
+
|
|
1448
|
+
name = Path(file.relative_path).name.lower()
|
|
1449
|
+
_rel = file.relative_path.lower()
|
|
1450
|
+
|
|
1451
|
+
# Entry points
|
|
1452
|
+
if name in ("main.py", "main.ts", "app.py", "app.ts", "cli.py", "cli.ts"):
|
|
1453
|
+
score += 0.3
|
|
1454
|
+
elif name in ("__init__.py", "index.ts", "index.js"):
|
|
1455
|
+
score += 0.1
|
|
1456
|
+
|
|
1457
|
+
# Config files
|
|
1458
|
+
if file.is_config:
|
|
1459
|
+
score += 0.2
|
|
1460
|
+
if name in ("pyproject.toml", "package.json", "cargo.toml"):
|
|
1461
|
+
score += 0.15
|
|
1462
|
+
|
|
1463
|
+
# Source vs test
|
|
1464
|
+
if file.is_test:
|
|
1465
|
+
score -= 0.2
|
|
1466
|
+
|
|
1467
|
+
# Moderate-size files are often more important
|
|
1468
|
+
if 50 < file.line_count < 500:
|
|
1469
|
+
score += 0.1
|
|
1470
|
+
elif file.line_count > 1000:
|
|
1471
|
+
score += 0.05
|
|
1472
|
+
elif file.line_count < 10:
|
|
1473
|
+
score -= 0.1
|
|
1474
|
+
|
|
1475
|
+
# Depth penalty (deeply nested = less important)
|
|
1476
|
+
depth = file.relative_path.count(os.sep)
|
|
1477
|
+
score -= depth * 0.03
|
|
1478
|
+
|
|
1479
|
+
# README and docs
|
|
1480
|
+
if name in ("readme.md", "readme.rst", "readme.txt"):
|
|
1481
|
+
score += 0.25
|
|
1482
|
+
|
|
1483
|
+
return max(0.0, min(1.0, score))
|
|
1484
|
+
|
|
1485
|
+
def _select_by_relevance(self, query: str, max_files: int) -> list[FileInfo]:
|
|
1486
|
+
"""Select files by relevance to a query (keyword + symbol matching)."""
|
|
1487
|
+
query_lower = query.lower()
|
|
1488
|
+
keywords = query_lower.split()
|
|
1489
|
+
|
|
1490
|
+
# Try to get symbol names from AST service for content matching
|
|
1491
|
+
symbol_names: dict[str, list[str]] = {}
|
|
1492
|
+
try:
|
|
1493
|
+
from attocode_intel._internal.integrations.context.ast_service import ASTService
|
|
1494
|
+
svc = ASTService.get_instance(self._root_dir)
|
|
1495
|
+
if svc.initialized:
|
|
1496
|
+
for f in self._files:
|
|
1497
|
+
syms = svc.get_symbol_names(f.relative_path)
|
|
1498
|
+
if syms:
|
|
1499
|
+
symbol_names[f.relative_path] = [s.lower() for s in syms]
|
|
1500
|
+
except Exception:
|
|
1501
|
+
pass
|
|
1502
|
+
|
|
1503
|
+
_CONFIG_NAMES = frozenset({ # noqa: N806
|
|
1504
|
+
"pyproject.toml", "package.json", "tsconfig.json", "setup.cfg",
|
|
1505
|
+
"setup.py", "readme.md", "changelog.md",
|
|
1506
|
+
})
|
|
1507
|
+
|
|
1508
|
+
scored: list[tuple[float, FileInfo]] = []
|
|
1509
|
+
for f in self._files:
|
|
1510
|
+
rel_lower = f.relative_path.lower()
|
|
1511
|
+
basename_lower = os.path.basename(rel_lower)
|
|
1512
|
+
|
|
1513
|
+
# Path keyword matching
|
|
1514
|
+
path_score = sum(1 for kw in keywords if kw in rel_lower)
|
|
1515
|
+
|
|
1516
|
+
# Symbol name matching (content-aware)
|
|
1517
|
+
sym_score = 0
|
|
1518
|
+
file_syms = symbol_names.get(f.relative_path, [])
|
|
1519
|
+
for kw in keywords:
|
|
1520
|
+
for sym in file_syms:
|
|
1521
|
+
if kw in sym:
|
|
1522
|
+
sym_score += 1
|
|
1523
|
+
break
|
|
1524
|
+
|
|
1525
|
+
# Config file penalty
|
|
1526
|
+
config_penalty = 1.0
|
|
1527
|
+
if basename_lower in _CONFIG_NAMES or f.is_config:
|
|
1528
|
+
config_penalty = 0.3
|
|
1529
|
+
|
|
1530
|
+
# Compute final score: reduced importance weight, boosted content match
|
|
1531
|
+
base = f.importance * 0.3 # Reduced from 1.0
|
|
1532
|
+
match_bonus = path_score * 0.3 + sym_score * 0.5
|
|
1533
|
+
score = (base + match_bonus) * config_penalty
|
|
1534
|
+
|
|
1535
|
+
scored.append((score, f))
|
|
1536
|
+
|
|
1537
|
+
scored.sort(key=lambda x: x[0], reverse=True)
|
|
1538
|
+
return [f for _, f in scored[:max_files]]
|
|
1539
|
+
|
|
1540
|
+
def _select_by_breadth(self, max_files: int) -> list[FileInfo]:
|
|
1541
|
+
"""Select files for broad coverage across directories."""
|
|
1542
|
+
seen_dirs: set[str] = set()
|
|
1543
|
+
selected: list[FileInfo] = []
|
|
1544
|
+
|
|
1545
|
+
for f in self._files:
|
|
1546
|
+
parent = str(Path(f.relative_path).parent)
|
|
1547
|
+
if parent not in seen_dirs or len(selected) < max_files // 2:
|
|
1548
|
+
selected.append(f)
|
|
1549
|
+
seen_dirs.add(parent)
|
|
1550
|
+
if len(selected) >= max_files:
|
|
1551
|
+
break
|
|
1552
|
+
|
|
1553
|
+
return selected
|
|
1554
|
+
|
|
1555
|
+
# ------ Budgeted tree helpers ------
|
|
1556
|
+
|
|
1557
|
+
def _build_budgeted_tree(
|
|
1558
|
+
self,
|
|
1559
|
+
max_tokens: int,
|
|
1560
|
+
file_symbols: dict[str, list[str]],
|
|
1561
|
+
) -> tuple[str, dict[str, list[str]]]:
|
|
1562
|
+
"""Build a token-budgeted tree using importance tiers.
|
|
1563
|
+
|
|
1564
|
+
Files are partitioned into three tiers based on importance:
|
|
1565
|
+
- **Tier 1** (>= 0.7): appear in tree WITH up to 5 symbols each
|
|
1566
|
+
- **Tier 2** (>= 0.4): appear in tree WITHOUT symbols
|
|
1567
|
+
- **Tier 3** (< 0.4): collapsed as ``... +N files`` per directory
|
|
1568
|
+
|
|
1569
|
+
The builder tracks estimated token count and stops adding entries
|
|
1570
|
+
once *max_tokens* is reached.
|
|
1571
|
+
|
|
1572
|
+
Returns:
|
|
1573
|
+
``(tree_text, included_symbols)`` — the rendered tree string
|
|
1574
|
+
and the symbols dict narrowed to only Tier 1 files.
|
|
1575
|
+
"""
|
|
1576
|
+
from attocode_intel._internal.integrations.utilities.token_estimate import estimate_tokens
|
|
1577
|
+
|
|
1578
|
+
tier1: list[FileInfo] = []
|
|
1579
|
+
tier2: list[FileInfo] = []
|
|
1580
|
+
tier3: list[FileInfo] = []
|
|
1581
|
+
|
|
1582
|
+
for f in self._files:
|
|
1583
|
+
if f.importance >= 0.7:
|
|
1584
|
+
tier1.append(f)
|
|
1585
|
+
elif f.importance >= 0.4:
|
|
1586
|
+
tier2.append(f)
|
|
1587
|
+
else:
|
|
1588
|
+
tier3.append(f)
|
|
1589
|
+
|
|
1590
|
+
# Build included set (Tier 1 + Tier 2 files shown individually)
|
|
1591
|
+
included_set = {f.relative_path for f in tier1} | {f.relative_path for f in tier2}
|
|
1592
|
+
|
|
1593
|
+
# Narrow symbols to Tier 1 only, capped at 5 per file
|
|
1594
|
+
included_symbols: dict[str, list[str]] = {}
|
|
1595
|
+
for f in tier1:
|
|
1596
|
+
syms = file_symbols.get(f.relative_path, [])
|
|
1597
|
+
if syms:
|
|
1598
|
+
included_symbols[f.relative_path] = syms[:10]
|
|
1599
|
+
|
|
1600
|
+
# Count omitted files per directory for collapse annotations
|
|
1601
|
+
omitted_per_dir = self._count_omitted_per_dir(
|
|
1602
|
+
[f.relative_path for f in self._files], included_set,
|
|
1603
|
+
)
|
|
1604
|
+
|
|
1605
|
+
# Build tree dict from included files
|
|
1606
|
+
root = Path(self.root_dir)
|
|
1607
|
+
tree_dict: dict[str, Any] = {}
|
|
1608
|
+
leaf_rel_paths: dict[str, str] = {}
|
|
1609
|
+
max_depth = 6
|
|
1610
|
+
|
|
1611
|
+
for f in tier1 + tier2:
|
|
1612
|
+
parts = Path(f.relative_path).parts
|
|
1613
|
+
if len(parts) > max_depth + 1:
|
|
1614
|
+
parts = parts[:max_depth] + ("...",)
|
|
1615
|
+
current = tree_dict
|
|
1616
|
+
for part in parts[:-1]:
|
|
1617
|
+
if part not in current:
|
|
1618
|
+
current[part] = {}
|
|
1619
|
+
current = current[part]
|
|
1620
|
+
current[parts[-1]] = None
|
|
1621
|
+
leaf_rel_paths[parts[-1]] = f.relative_path
|
|
1622
|
+
|
|
1623
|
+
# Inject collapse markers (``... +N files``) into directory nodes
|
|
1624
|
+
for dir_path, count in sorted(omitted_per_dir.items()):
|
|
1625
|
+
parts = Path(dir_path).parts if dir_path != "." else ()
|
|
1626
|
+
current = tree_dict
|
|
1627
|
+
for part in parts:
|
|
1628
|
+
if part not in current:
|
|
1629
|
+
current[part] = {}
|
|
1630
|
+
current = current[part]
|
|
1631
|
+
if current is None:
|
|
1632
|
+
break
|
|
1633
|
+
if isinstance(current, dict):
|
|
1634
|
+
current[f"... +{count} files"] = None
|
|
1635
|
+
|
|
1636
|
+
lines = [str(root.name) + "/"]
|
|
1637
|
+
self._format_tree_dict(
|
|
1638
|
+
tree_dict, lines, prefix="",
|
|
1639
|
+
file_symbols=included_symbols, leaf_rel_paths=leaf_rel_paths,
|
|
1640
|
+
)
|
|
1641
|
+
|
|
1642
|
+
tree_text = "\n".join(lines)
|
|
1643
|
+
|
|
1644
|
+
# Truncate to budget if still over
|
|
1645
|
+
tokens = estimate_tokens(tree_text)
|
|
1646
|
+
if tokens > max_tokens:
|
|
1647
|
+
# Progressively trim lines from the end (least important)
|
|
1648
|
+
while lines and estimate_tokens("\n".join(lines)) > max_tokens:
|
|
1649
|
+
lines.pop()
|
|
1650
|
+
if lines:
|
|
1651
|
+
lines.append(f"... (truncated, {len(self._files)} total files)")
|
|
1652
|
+
tree_text = "\n".join(lines)
|
|
1653
|
+
|
|
1654
|
+
return tree_text, included_symbols
|
|
1655
|
+
|
|
1656
|
+
@staticmethod
|
|
1657
|
+
def _count_omitted_per_dir(
|
|
1658
|
+
all_paths: list[str],
|
|
1659
|
+
included_set: set[str],
|
|
1660
|
+
) -> dict[str, int]:
|
|
1661
|
+
"""Count how many files per directory were omitted (Tier 3).
|
|
1662
|
+
|
|
1663
|
+
Returns:
|
|
1664
|
+
``{dir_relative_path: count}`` for directories with omitted files.
|
|
1665
|
+
"""
|
|
1666
|
+
counts: dict[str, int] = {}
|
|
1667
|
+
for path in all_paths:
|
|
1668
|
+
if path not in included_set:
|
|
1669
|
+
parent = str(Path(path).parent)
|
|
1670
|
+
counts[parent] = counts.get(parent, 0) + 1
|
|
1671
|
+
return counts
|
|
1672
|
+
|
|
1673
|
+
def _build_tree(
|
|
1674
|
+
self,
|
|
1675
|
+
max_depth: int = 4,
|
|
1676
|
+
file_symbols: dict[str, list[str]] | None = None,
|
|
1677
|
+
) -> str:
|
|
1678
|
+
"""Build a text tree view of the repository.
|
|
1679
|
+
|
|
1680
|
+
Args:
|
|
1681
|
+
max_depth: Maximum directory nesting to display.
|
|
1682
|
+
file_symbols: Optional map of relative_path -> symbol names to show.
|
|
1683
|
+
"""
|
|
1684
|
+
if not self._files:
|
|
1685
|
+
return "(no files discovered)"
|
|
1686
|
+
|
|
1687
|
+
root = Path(self.root_dir)
|
|
1688
|
+
tree_dict: dict[str, Any] = {}
|
|
1689
|
+
# Track relative path per leaf for symbol lookup
|
|
1690
|
+
leaf_rel_paths: dict[str, str] = {}
|
|
1691
|
+
|
|
1692
|
+
for f in self._files:
|
|
1693
|
+
parts = Path(f.relative_path).parts
|
|
1694
|
+
if len(parts) > max_depth + 1:
|
|
1695
|
+
parts = parts[:max_depth] + ("...",)
|
|
1696
|
+
|
|
1697
|
+
current = tree_dict
|
|
1698
|
+
for part in parts[:-1]:
|
|
1699
|
+
if part not in current:
|
|
1700
|
+
current[part] = {}
|
|
1701
|
+
current = current[part]
|
|
1702
|
+
current[parts[-1]] = None # Leaf file
|
|
1703
|
+
leaf_rel_paths[parts[-1]] = f.relative_path
|
|
1704
|
+
|
|
1705
|
+
lines = [str(root.name) + "/"]
|
|
1706
|
+
self._format_tree_dict(tree_dict, lines, prefix="", file_symbols=file_symbols, leaf_rel_paths=leaf_rel_paths)
|
|
1707
|
+
return "\n".join(lines)
|
|
1708
|
+
|
|
1709
|
+
def _format_tree_dict(
|
|
1710
|
+
self,
|
|
1711
|
+
d: dict[str, Any],
|
|
1712
|
+
lines: list[str],
|
|
1713
|
+
prefix: str,
|
|
1714
|
+
file_symbols: dict[str, list[str]] | None = None,
|
|
1715
|
+
leaf_rel_paths: dict[str, str] | None = None,
|
|
1716
|
+
) -> None:
|
|
1717
|
+
"""Recursively format tree dict into lines."""
|
|
1718
|
+
items = sorted(d.items(), key=lambda x: (x[1] is not None, x[0]))
|
|
1719
|
+
for i, (name, subtree) in enumerate(items):
|
|
1720
|
+
is_last = i == len(items) - 1
|
|
1721
|
+
connector = "└── " if is_last else "├── "
|
|
1722
|
+
suffix = "/" if subtree is not None else ""
|
|
1723
|
+
|
|
1724
|
+
# Annotate leaf files with symbols
|
|
1725
|
+
sym_annotation = ""
|
|
1726
|
+
if file_symbols and subtree is None and leaf_rel_paths:
|
|
1727
|
+
rel_path = leaf_rel_paths.get(name, "")
|
|
1728
|
+
syms = file_symbols.get(rel_path, [])
|
|
1729
|
+
if syms:
|
|
1730
|
+
sym_annotation = " [" + ", ".join(syms) + "]"
|
|
1731
|
+
|
|
1732
|
+
lines.append(f"{prefix}{connector}{name}{suffix}{sym_annotation}")
|
|
1733
|
+
|
|
1734
|
+
if subtree is not None:
|
|
1735
|
+
next_prefix = prefix + (" " if is_last else "│ ")
|
|
1736
|
+
self._format_tree_dict(subtree, lines, next_prefix, file_symbols, leaf_rel_paths)
|