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,1483 @@
|
|
|
1
|
+
"""AST Service — singleton per project for structural code intelligence.
|
|
2
|
+
|
|
3
|
+
Wraps ``codebase_ast.parse_file`` / ``diff_file_ast`` and
|
|
4
|
+
``CodebaseContextManager.discover_files`` to provide:
|
|
5
|
+
|
|
6
|
+
* Full symbol index (definitions + cross-references)
|
|
7
|
+
* Incremental updates on file changes
|
|
8
|
+
* Impact analysis (transitive dependents of changed files)
|
|
9
|
+
* Conflict detection for parallel task allocation (swarm)
|
|
10
|
+
* Related file suggestions
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import sqlite3
|
|
21
|
+
import threading
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from attocode_intel._internal.integrations.context.codebase_ast import (
|
|
26
|
+
FileAST,
|
|
27
|
+
diff_file_ast,
|
|
28
|
+
diff_imports,
|
|
29
|
+
parse_file,
|
|
30
|
+
)
|
|
31
|
+
from attocode_intel._internal.integrations.context.codebase_context import (
|
|
32
|
+
CodebaseContextManager,
|
|
33
|
+
)
|
|
34
|
+
from attocode_intel._internal.integrations.context.cross_references import (
|
|
35
|
+
CrossRefIndex,
|
|
36
|
+
SymbolLocation,
|
|
37
|
+
SymbolRef,
|
|
38
|
+
)
|
|
39
|
+
from attocode_intel._internal.integrations.context.hydration import (
|
|
40
|
+
TIER_SMALL,
|
|
41
|
+
HydrationState,
|
|
42
|
+
classify_tier,
|
|
43
|
+
skeleton_budget,
|
|
44
|
+
)
|
|
45
|
+
from attocode_intel._internal.integrations.context.index_store import IndexStore, StoredFile
|
|
46
|
+
|
|
47
|
+
logger = logging.getLogger(__name__)
|
|
48
|
+
|
|
49
|
+
# Singleton registry: root_dir -> ASTService
|
|
50
|
+
_instances: dict[str, ASTService] = {}
|
|
51
|
+
|
|
52
|
+
# Default location for the persistent index
|
|
53
|
+
_INDEX_DIR = ".attocode/index"
|
|
54
|
+
_INDEX_DB = "symbols.db"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ASTService:
|
|
58
|
+
"""Singleton per project. NOT passed to prompts — used programmatically
|
|
59
|
+
by the orchestrator and agent internals.
|
|
60
|
+
|
|
61
|
+
Usage::
|
|
62
|
+
|
|
63
|
+
svc = ASTService.get_instance("/path/to/repo")
|
|
64
|
+
svc.initialize() # full scan (once)
|
|
65
|
+
svc.notify_file_changed(path) # after edits
|
|
66
|
+
impact = svc.get_impact(["src/auth.py"])
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
# ------------------------------------------------------------------
|
|
70
|
+
# Construction
|
|
71
|
+
# ------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def __init__(self, root_dir: str, *, store: IndexStore | None = None) -> None:
|
|
74
|
+
self._root_dir = os.path.realpath(root_dir)
|
|
75
|
+
self._context_mgr = CodebaseContextManager(root_dir=self._root_dir)
|
|
76
|
+
self._index = CrossRefIndex()
|
|
77
|
+
self._ast_cache: dict[str, FileAST] = {} # rel_path -> FileAST
|
|
78
|
+
self._initialized = False
|
|
79
|
+
self._hydration_state: HydrationState | None = None
|
|
80
|
+
self._reference_indexed_files: set[str] = set()
|
|
81
|
+
self._hydration_thread: threading.Thread | None = None
|
|
82
|
+
self._hydration_stop: threading.Event = threading.Event()
|
|
83
|
+
# Persistent index store
|
|
84
|
+
if store is not None:
|
|
85
|
+
self._store = store
|
|
86
|
+
else:
|
|
87
|
+
db_path = os.path.join(self._root_dir, _INDEX_DIR, _INDEX_DB)
|
|
88
|
+
self._store = IndexStore(db_path=db_path)
|
|
89
|
+
self._index.set_store(self._store)
|
|
90
|
+
self._snapshot_tracker = None
|
|
91
|
+
self._snapshot_start = None
|
|
92
|
+
self._last_snapshot_repair = None
|
|
93
|
+
self._parse_lock = threading.RLock()
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def get_instance(cls, root_dir: str) -> ASTService:
|
|
97
|
+
"""Return (or create) the singleton for *root_dir*."""
|
|
98
|
+
key = os.path.realpath(root_dir)
|
|
99
|
+
if key not in _instances:
|
|
100
|
+
_instances[key] = cls(key)
|
|
101
|
+
return _instances[key]
|
|
102
|
+
|
|
103
|
+
@classmethod
|
|
104
|
+
def clear_instances(cls) -> None:
|
|
105
|
+
"""Reset all singletons (for testing)."""
|
|
106
|
+
_instances.clear()
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def root_dir(self) -> str:
|
|
110
|
+
return self._root_dir
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def index(self) -> CrossRefIndex:
|
|
114
|
+
return self._index
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def initialized(self) -> bool:
|
|
118
|
+
return self._initialized
|
|
119
|
+
|
|
120
|
+
def _supported_source_languages(self) -> set[str]:
|
|
121
|
+
_ts_langs: set[str] = set()
|
|
122
|
+
try:
|
|
123
|
+
from attocode_intel._internal.integrations.context.ts_parser import supported_languages
|
|
124
|
+
|
|
125
|
+
_ts_langs = set(supported_languages())
|
|
126
|
+
except ImportError:
|
|
127
|
+
pass
|
|
128
|
+
return {"python", "javascript", "typescript", "shell"} | _ts_langs
|
|
129
|
+
|
|
130
|
+
def _count_parseable_source_files(self) -> int:
|
|
131
|
+
files = self._context_mgr._files or []
|
|
132
|
+
supported = self._supported_source_languages()
|
|
133
|
+
n = 0
|
|
134
|
+
for fi in files:
|
|
135
|
+
lang = fi.language
|
|
136
|
+
if lang == "shell":
|
|
137
|
+
lang = "bash"
|
|
138
|
+
if lang in supported:
|
|
139
|
+
n += 1
|
|
140
|
+
return n
|
|
141
|
+
|
|
142
|
+
def hydration_snapshot(self) -> dict:
|
|
143
|
+
"""Return hydration fields for UIs.
|
|
144
|
+
|
|
145
|
+
When ``_hydration_state`` is set (skeleton / background hydration), returns
|
|
146
|
+
its ``to_dict()``. Otherwise, if ``initialize()`` ran without ever setting
|
|
147
|
+
state, synthesize approximate metrics from the AST cache so MCP
|
|
148
|
+
``hydration_status`` is not stuck at unknown/0.
|
|
149
|
+
"""
|
|
150
|
+
self._context_mgr.refresh_excluded_checkout_roots()
|
|
151
|
+
if self._hydration_state is not None:
|
|
152
|
+
return {**self._hydration_state.to_dict(),
|
|
153
|
+
"snapshot_repair": self._last_snapshot_repair,
|
|
154
|
+
"discovered_files": self._context_mgr.discovered_file_count,
|
|
155
|
+
"discovery_truncated": self._context_mgr.discovery_truncated,
|
|
156
|
+
"excluded_checkout_roots": self._context_mgr.excluded_checkout_roots,
|
|
157
|
+
"excluded_checkout_roots_truncated": self._context_mgr.excluded_checkout_roots_truncated}
|
|
158
|
+
|
|
159
|
+
base: dict = {
|
|
160
|
+
"tier": "unknown",
|
|
161
|
+
"phase": "unknown",
|
|
162
|
+
"total_files": 0,
|
|
163
|
+
"parsed_files": 0,
|
|
164
|
+
"parse_coverage": 0.0,
|
|
165
|
+
"reference_indexed_files": 0,
|
|
166
|
+
"reference_coverage": 0.0,
|
|
167
|
+
"dep_graph_files": 0,
|
|
168
|
+
"embedding_coverage": 0.0,
|
|
169
|
+
"elapsed_ms": 0.0,
|
|
170
|
+
}
|
|
171
|
+
if not self._initialized:
|
|
172
|
+
return base
|
|
173
|
+
|
|
174
|
+
parseable_total = self._count_parseable_source_files()
|
|
175
|
+
parsed = len(self._ast_cache)
|
|
176
|
+
ref_n = len(self._reference_indexed_files)
|
|
177
|
+
total = parseable_total if parseable_total > 0 else max(parsed, 1)
|
|
178
|
+
tier = classify_tier(parseable_total if parseable_total > 0 else parsed)
|
|
179
|
+
if parseable_total and parsed >= parseable_total:
|
|
180
|
+
phase = "ready"
|
|
181
|
+
elif parsed:
|
|
182
|
+
phase = "hydrating"
|
|
183
|
+
else:
|
|
184
|
+
phase = "skeleton"
|
|
185
|
+
|
|
186
|
+
st = HydrationState(
|
|
187
|
+
tier=tier,
|
|
188
|
+
total_files=total,
|
|
189
|
+
parsed_files=parsed,
|
|
190
|
+
reference_indexed_files=ref_n,
|
|
191
|
+
phase=phase,
|
|
192
|
+
)
|
|
193
|
+
return {**st.to_dict(),
|
|
194
|
+
"excluded_checkout_roots": self._context_mgr.excluded_checkout_roots,
|
|
195
|
+
"excluded_checkout_roots_truncated": self._context_mgr.excluded_checkout_roots_truncated}
|
|
196
|
+
|
|
197
|
+
def _ensure_initialized(self) -> None:
|
|
198
|
+
"""Auto-initialize if not yet done. Called by all query methods."""
|
|
199
|
+
if not self._initialized:
|
|
200
|
+
self.initialize()
|
|
201
|
+
|
|
202
|
+
# ------------------------------------------------------------------
|
|
203
|
+
# Lifecycle
|
|
204
|
+
# ------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
def initialize(self, *, force: bool = False) -> None:
|
|
207
|
+
"""Discover files, parse ASTs, build cross-ref index.
|
|
208
|
+
|
|
209
|
+
When a persistent ``IndexStore`` is available and *force* is False,
|
|
210
|
+
loads cached symbols/refs from SQLite and only re-parses files whose
|
|
211
|
+
mtime has changed (incremental mode). Otherwise does a full scan.
|
|
212
|
+
"""
|
|
213
|
+
self._store.set_meta("structural_snapshot_v1", "")
|
|
214
|
+
self._snapshot_start = None
|
|
215
|
+
from attocode_intel.telemetry import measure
|
|
216
|
+
with measure("discovery"):
|
|
217
|
+
files = self._context_mgr.discover_files()
|
|
218
|
+
self._index = CrossRefIndex()
|
|
219
|
+
self._index.set_store(self._store)
|
|
220
|
+
self._ast_cache.clear()
|
|
221
|
+
|
|
222
|
+
# Supported languages
|
|
223
|
+
_ts_langs: set[str] = set()
|
|
224
|
+
try:
|
|
225
|
+
from attocode_intel._internal.integrations.context.ts_parser import supported_languages
|
|
226
|
+
_ts_langs = set(supported_languages())
|
|
227
|
+
except ImportError:
|
|
228
|
+
pass
|
|
229
|
+
_supported = {"python", "javascript", "typescript", "shell"} | _ts_langs
|
|
230
|
+
|
|
231
|
+
# Build mtime map for parseable files
|
|
232
|
+
parseable: list[Any] = []
|
|
233
|
+
mtime_map: dict[str, float] = {}
|
|
234
|
+
for fi in files:
|
|
235
|
+
lang = fi.language
|
|
236
|
+
if lang == "shell":
|
|
237
|
+
lang = "bash"
|
|
238
|
+
if lang not in _supported:
|
|
239
|
+
continue
|
|
240
|
+
parseable.append(fi)
|
|
241
|
+
try:
|
|
242
|
+
mtime_map[fi.relative_path] = os.path.getmtime(fi.path)
|
|
243
|
+
except OSError:
|
|
244
|
+
mtime_map[fi.relative_path] = 0.0
|
|
245
|
+
|
|
246
|
+
# Try incremental load from store
|
|
247
|
+
last_scan = self._store.get_last_scan_time() if not force else None
|
|
248
|
+
if last_scan is not None and not force:
|
|
249
|
+
try:
|
|
250
|
+
loaded = self._index.load_from_store()
|
|
251
|
+
if loaded > 0:
|
|
252
|
+
stale = self._store.get_stale_files(mtime_map)
|
|
253
|
+
deleted = self._store.get_deleted_files(set(mtime_map.keys()))
|
|
254
|
+
|
|
255
|
+
for d in deleted:
|
|
256
|
+
self._index.remove_file(d)
|
|
257
|
+
|
|
258
|
+
stale_set = set(stale)
|
|
259
|
+
for fi in parseable:
|
|
260
|
+
rel = fi.relative_path
|
|
261
|
+
if rel in stale_set:
|
|
262
|
+
try:
|
|
263
|
+
ast = parse_file(fi.path)
|
|
264
|
+
except Exception:
|
|
265
|
+
continue
|
|
266
|
+
self._index.remove_file(rel)
|
|
267
|
+
self._ast_cache[rel] = ast
|
|
268
|
+
self._index_definitions(rel, ast)
|
|
269
|
+
|
|
270
|
+
dep_graph = self._context_mgr.dependency_graph
|
|
271
|
+
if dep_graph:
|
|
272
|
+
for src, targets in dep_graph.forward.items():
|
|
273
|
+
for tgt in targets:
|
|
274
|
+
self._index.add_file_dependency(src, tgt)
|
|
275
|
+
|
|
276
|
+
for rel in stale_set:
|
|
277
|
+
ast = self._ast_cache.get(rel)
|
|
278
|
+
if ast:
|
|
279
|
+
self._index_references(rel, ast)
|
|
280
|
+
self._persist_indexed_file(rel, ast, mtime_map.get(rel, 0.0))
|
|
281
|
+
|
|
282
|
+
self._initialized = True
|
|
283
|
+
self._store.record_scan_time()
|
|
284
|
+
logger.debug(
|
|
285
|
+
"ASTService incremental init: %d cached, %d stale, %d deleted",
|
|
286
|
+
loaded, len(stale), len(deleted),
|
|
287
|
+
)
|
|
288
|
+
return
|
|
289
|
+
except sqlite3.IntegrityError:
|
|
290
|
+
if force:
|
|
291
|
+
raise
|
|
292
|
+
logger.warning(
|
|
293
|
+
"ASTService incremental init hit sqlite integrity error; rebuilding index",
|
|
294
|
+
exc_info=True,
|
|
295
|
+
)
|
|
296
|
+
self._store.clear_all()
|
|
297
|
+
self.initialize(force=True)
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
# Full scan fallback
|
|
301
|
+
self._store.clear_all()
|
|
302
|
+
for fi in parseable:
|
|
303
|
+
try:
|
|
304
|
+
ast = parse_file(fi.path)
|
|
305
|
+
except Exception:
|
|
306
|
+
continue
|
|
307
|
+
rel = fi.relative_path
|
|
308
|
+
self._ast_cache[rel] = ast
|
|
309
|
+
self._index_definitions(rel, ast)
|
|
310
|
+
|
|
311
|
+
# Phase 2: index references
|
|
312
|
+
for rel, ast in self._ast_cache.items():
|
|
313
|
+
self._index_references(rel, ast)
|
|
314
|
+
|
|
315
|
+
# Copy dependency graph edges
|
|
316
|
+
dep_graph = self._context_mgr.dependency_graph
|
|
317
|
+
if dep_graph:
|
|
318
|
+
edges: list[tuple[str, str]] = []
|
|
319
|
+
for src, targets in dep_graph.forward.items():
|
|
320
|
+
for tgt in targets:
|
|
321
|
+
self._index.add_file_dependency(src, tgt)
|
|
322
|
+
edges.append((src, tgt))
|
|
323
|
+
if edges:
|
|
324
|
+
self._store.save_dependencies_batch(edges)
|
|
325
|
+
|
|
326
|
+
# Persist all to store — files must be saved BEFORE symbols/refs
|
|
327
|
+
# (foreign key: symbols.file_path REFERENCES files(path))
|
|
328
|
+
stored_files: list[StoredFile] = []
|
|
329
|
+
for rel, ast in self._ast_cache.items():
|
|
330
|
+
stored_files.append(self._build_stored_file(rel, ast, mtime_map.get(rel, 0.0)))
|
|
331
|
+
if stored_files:
|
|
332
|
+
self._store.save_files_batch(stored_files)
|
|
333
|
+
for rel in self._ast_cache:
|
|
334
|
+
self._index.persist_file(rel)
|
|
335
|
+
|
|
336
|
+
self._store.record_scan_time()
|
|
337
|
+
self._initialized = True
|
|
338
|
+
logger.debug(
|
|
339
|
+
"ASTService full init: %d files, %d definitions",
|
|
340
|
+
len(self._ast_cache),
|
|
341
|
+
sum(len(v) for v in self._index.definitions.values()),
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
def force_reindex(self) -> None:
|
|
345
|
+
"""Force a full re-scan, ignoring cached data."""
|
|
346
|
+
self.initialize(force=True)
|
|
347
|
+
|
|
348
|
+
def initialize_skeleton(self, *, indexing_depth: str = "auto") -> HydrationState:
|
|
349
|
+
"""Phase 1: Fast skeleton initialization.
|
|
350
|
+
|
|
351
|
+
Discovers files, classifies tier, and parses only the top-N most
|
|
352
|
+
important files. For small repos this is equivalent to full
|
|
353
|
+
``initialize()``. For larger repos, remaining files are left for
|
|
354
|
+
background hydration or on-demand parsing.
|
|
355
|
+
"""
|
|
356
|
+
from attocode_intel.telemetry import measure
|
|
357
|
+
self._snapshot_start = self._snapshot_identity()
|
|
358
|
+
with measure("discovery"):
|
|
359
|
+
files = self._context_mgr.discover_files()
|
|
360
|
+
self._index = CrossRefIndex()
|
|
361
|
+
self._index.set_store(self._store)
|
|
362
|
+
self._ast_cache.clear()
|
|
363
|
+
self._reference_indexed_files.clear()
|
|
364
|
+
|
|
365
|
+
_ts_langs: set[str] = set()
|
|
366
|
+
try:
|
|
367
|
+
from attocode_intel._internal.integrations.context.ts_parser import supported_languages
|
|
368
|
+
_ts_langs = set(supported_languages())
|
|
369
|
+
except ImportError:
|
|
370
|
+
pass
|
|
371
|
+
_supported = {"python", "javascript", "typescript", "shell"} | _ts_langs
|
|
372
|
+
|
|
373
|
+
parseable = []
|
|
374
|
+
for fi in files:
|
|
375
|
+
lang = fi.language
|
|
376
|
+
if lang == "shell":
|
|
377
|
+
lang = "bash"
|
|
378
|
+
if lang in _supported:
|
|
379
|
+
parseable.append(fi)
|
|
380
|
+
|
|
381
|
+
source_count = len(parseable)
|
|
382
|
+
tier = classify_tier(source_count)
|
|
383
|
+
|
|
384
|
+
if indexing_depth == "eager":
|
|
385
|
+
tier = TIER_SMALL
|
|
386
|
+
elif indexing_depth == "minimal":
|
|
387
|
+
self._hydration_state = HydrationState(
|
|
388
|
+
tier=tier, total_files=source_count, phase="skeleton",
|
|
389
|
+
)
|
|
390
|
+
self._initialized = True
|
|
391
|
+
return self._hydration_state
|
|
392
|
+
|
|
393
|
+
budget = skeleton_budget(tier, source_count)
|
|
394
|
+
is_full_parse = budget >= source_count
|
|
395
|
+
|
|
396
|
+
state = HydrationState(
|
|
397
|
+
tier=tier,
|
|
398
|
+
total_files=source_count,
|
|
399
|
+
phase="ready" if is_full_parse else "skeleton",
|
|
400
|
+
)
|
|
401
|
+
self._hydration_state = state
|
|
402
|
+
|
|
403
|
+
if self._restore_snapshot(files, parseable, state):
|
|
404
|
+
self._initialized = True
|
|
405
|
+
return state
|
|
406
|
+
# A partial or interrupted rebuild must never be restored as complete.
|
|
407
|
+
self._store.set_meta("structural_snapshot_v1", "")
|
|
408
|
+
self._store.clear_all()
|
|
409
|
+
|
|
410
|
+
to_parse = parseable[:budget]
|
|
411
|
+
|
|
412
|
+
for fi in to_parse:
|
|
413
|
+
try:
|
|
414
|
+
ast = parse_file(fi.path)
|
|
415
|
+
except Exception:
|
|
416
|
+
continue
|
|
417
|
+
rel = fi.relative_path
|
|
418
|
+
self._ast_cache[rel] = ast
|
|
419
|
+
self._index_definitions(rel, ast)
|
|
420
|
+
state.parsed_files += 1
|
|
421
|
+
|
|
422
|
+
if is_full_parse:
|
|
423
|
+
for rel, ast in self._ast_cache.items():
|
|
424
|
+
self._index_references(rel, ast)
|
|
425
|
+
self._reference_indexed_files.add(rel)
|
|
426
|
+
state.reference_indexed_files += 1
|
|
427
|
+
|
|
428
|
+
dep_graph = self._context_mgr.dependency_graph
|
|
429
|
+
if dep_graph:
|
|
430
|
+
for src, targets in dep_graph.forward.items():
|
|
431
|
+
for tgt in targets:
|
|
432
|
+
self._index.add_file_dependency(src, tgt)
|
|
433
|
+
|
|
434
|
+
if self._store:
|
|
435
|
+
stored_files: list[StoredFile] = []
|
|
436
|
+
for rel, ast_item in self._ast_cache.items():
|
|
437
|
+
try:
|
|
438
|
+
mtime = os.path.getmtime(os.path.join(self._root_dir, rel))
|
|
439
|
+
except OSError:
|
|
440
|
+
continue
|
|
441
|
+
stored_files.append(self._build_stored_file(rel, ast_item, mtime))
|
|
442
|
+
if stored_files:
|
|
443
|
+
self._store.save_files_batch(stored_files)
|
|
444
|
+
for rel in self._ast_cache:
|
|
445
|
+
self._index.persist_file(rel)
|
|
446
|
+
self._store.record_scan_time()
|
|
447
|
+
self._save_snapshot()
|
|
448
|
+
|
|
449
|
+
dep_graph = self._context_mgr.dependency_graph
|
|
450
|
+
if dep_graph:
|
|
451
|
+
for src, targets in dep_graph.forward.items():
|
|
452
|
+
for target in targets:
|
|
453
|
+
self._index.add_file_dependency(src, target)
|
|
454
|
+
state.dep_graph_files = len(dep_graph.forward)
|
|
455
|
+
self._initialized = True
|
|
456
|
+
return state
|
|
457
|
+
|
|
458
|
+
def _snapshot_identity(self) -> dict:
|
|
459
|
+
from attocode_intel.freshness import FreshnessTracker
|
|
460
|
+
|
|
461
|
+
if self._snapshot_tracker is None:
|
|
462
|
+
self._snapshot_tracker = FreshnessTracker(self._root_dir)
|
|
463
|
+
manifest = self._snapshot_tracker.scan_manifest()
|
|
464
|
+
return {
|
|
465
|
+
"files": {path: list(identity) for path, identity in manifest.items()},
|
|
466
|
+
"ignore": list(self._snapshot_tracker._ignore_identity or ()),
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
def _restore_snapshot(self, files, parseable, state) -> bool:
|
|
470
|
+
"""Restore a completed index and repair offline edits when necessary.
|
|
471
|
+
|
|
472
|
+
ASTs are reparsed to preserve all existing context/analysis consumers;
|
|
473
|
+
expensive reference extraction and persistence are reused. Older caches
|
|
474
|
+
without the completion record are rebuilt once.
|
|
475
|
+
"""
|
|
476
|
+
raw = self._store.get_meta("structural_snapshot_v1")
|
|
477
|
+
if not raw:
|
|
478
|
+
return False
|
|
479
|
+
try:
|
|
480
|
+
saved = json.loads(raw)
|
|
481
|
+
if saved["counts"] != self._store.stats():
|
|
482
|
+
return False
|
|
483
|
+
cached = {fi.relative_path: parse_file(fi.path) for fi in parseable}
|
|
484
|
+
if self._snapshot_identity() != self._snapshot_start:
|
|
485
|
+
return False
|
|
486
|
+
self._index.load_from_store()
|
|
487
|
+
if (saved["identity"] != self._snapshot_start
|
|
488
|
+
or saved["discovered"] != sorted(fi.relative_path for fi in files)
|
|
489
|
+
or saved["parsed"] != sorted(cached)):
|
|
490
|
+
from .index_repair import changed_paths, repair_index
|
|
491
|
+
|
|
492
|
+
changed = changed_paths(saved["identity"], self._snapshot_start)
|
|
493
|
+
changed |= set(saved["parsed"]) ^ cached.keys()
|
|
494
|
+
self._last_snapshot_repair = repair_index(self, cached, self._snapshot_start, changed)
|
|
495
|
+
return True
|
|
496
|
+
from attocode_intel._internal.integrations.context.codebase_context import (
|
|
497
|
+
DependencyGraph,
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
graph = DependencyGraph(unresolved=saved["unresolved"])
|
|
501
|
+
for source, targets in self._index.file_dependencies.items():
|
|
502
|
+
for target in targets:
|
|
503
|
+
graph.add_edge(source, target)
|
|
504
|
+
self._context_mgr._dep_graph = graph
|
|
505
|
+
self._ast_cache = cached
|
|
506
|
+
self._reference_indexed_files = set(cached)
|
|
507
|
+
state.parsed_files = len(cached)
|
|
508
|
+
state.reference_indexed_files = len(cached)
|
|
509
|
+
state.dep_graph_files = len(graph.forward)
|
|
510
|
+
state.phase = "ready"
|
|
511
|
+
return True
|
|
512
|
+
except (OSError, ValueError, KeyError, TypeError, sqlite3.Error):
|
|
513
|
+
logger.debug("Cannot restore structural snapshot", exc_info=True)
|
|
514
|
+
self._index = CrossRefIndex()
|
|
515
|
+
self._index.set_store(self._store)
|
|
516
|
+
self._ast_cache.clear()
|
|
517
|
+
self._reference_indexed_files.clear()
|
|
518
|
+
state.parsed_files = state.reference_indexed_files = 0
|
|
519
|
+
state.phase = "skeleton"
|
|
520
|
+
return False
|
|
521
|
+
|
|
522
|
+
def _save_snapshot(self) -> bool:
|
|
523
|
+
"""Publish the completion record last, after all files and graph edges."""
|
|
524
|
+
if self._snapshot_start is None:
|
|
525
|
+
return False
|
|
526
|
+
if (len(self._ast_cache) != self._count_parseable_source_files()
|
|
527
|
+
or set(self._ast_cache) != self._reference_indexed_files
|
|
528
|
+
or self._snapshot_identity() != self._snapshot_start):
|
|
529
|
+
return False
|
|
530
|
+
graph = self._context_mgr.dependency_graph
|
|
531
|
+
self._store.clear_dependencies()
|
|
532
|
+
self._store.save_dependencies_batch([
|
|
533
|
+
(source, target) for source, targets in self._index.file_dependencies.items()
|
|
534
|
+
for target in targets
|
|
535
|
+
])
|
|
536
|
+
self._store.set_meta("structural_snapshot_v1", json.dumps({
|
|
537
|
+
"identity": self._snapshot_start,
|
|
538
|
+
"discovered": sorted(fi.relative_path for fi in self._context_mgr._files),
|
|
539
|
+
"parsed": sorted(self._ast_cache),
|
|
540
|
+
"counts": self._store.stats(),
|
|
541
|
+
"unresolved": graph.unresolved if graph else {},
|
|
542
|
+
}, separators=(",", ":")))
|
|
543
|
+
return True
|
|
544
|
+
|
|
545
|
+
def repair_changed_files(self, *, expected_manifest=None) -> bool:
|
|
546
|
+
"""Checkpoint edits against a complete in-memory snapshot."""
|
|
547
|
+
state = self._hydration_state
|
|
548
|
+
if state is None or state.phase != "ready" or self._snapshot_start is None:
|
|
549
|
+
return False
|
|
550
|
+
from .index_repair import changed_paths, repair_index
|
|
551
|
+
|
|
552
|
+
self.stop_hydration()
|
|
553
|
+
identity = self._snapshot_identity()
|
|
554
|
+
if expected_manifest is not None and identity["files"] != {
|
|
555
|
+
path: list(value) for path, value in expected_manifest.items()
|
|
556
|
+
}:
|
|
557
|
+
raise ValueError("Workspace changed during freshness checks; retry")
|
|
558
|
+
changed = changed_paths(self._snapshot_start, identity)
|
|
559
|
+
if (self._snapshot_start["files"].keys() != identity["files"].keys()
|
|
560
|
+
or self._snapshot_start["ignore"] != identity["ignore"]):
|
|
561
|
+
self._context_mgr.discover_files()
|
|
562
|
+
supported = self._supported_source_languages()
|
|
563
|
+
parseable = {fi.relative_path: fi for fi in self._context_mgr._files
|
|
564
|
+
if ("bash" if fi.language == "shell" else fi.language) in supported}
|
|
565
|
+
changed |= self._ast_cache.keys() ^ parseable.keys()
|
|
566
|
+
asts = {path: ast for path, ast in self._ast_cache.items() if path in parseable}
|
|
567
|
+
for path in changed & parseable.keys():
|
|
568
|
+
asts[path] = parse_file(parseable[path].path)
|
|
569
|
+
self._last_snapshot_repair = repair_index(self, asts, identity, changed)
|
|
570
|
+
return True
|
|
571
|
+
|
|
572
|
+
def ensure_file_parsed(self, rel_path: str) -> bool:
|
|
573
|
+
"""Parse a single file on-demand if not already in cache.
|
|
574
|
+
|
|
575
|
+
Returns True if the file was newly parsed, False if already cached.
|
|
576
|
+
"""
|
|
577
|
+
with self._parse_lock:
|
|
578
|
+
if rel_path in self._ast_cache:
|
|
579
|
+
return False
|
|
580
|
+
abs_path = os.path.join(self._root_dir, rel_path)
|
|
581
|
+
if not os.path.isfile(abs_path):
|
|
582
|
+
return False
|
|
583
|
+
try:
|
|
584
|
+
ast = parse_file(abs_path)
|
|
585
|
+
except Exception:
|
|
586
|
+
return False
|
|
587
|
+
self._index_definitions(rel_path, ast)
|
|
588
|
+
# Publish only after definitions are visible to concurrent queries.
|
|
589
|
+
self._ast_cache[rel_path] = ast
|
|
590
|
+
if self._hydration_state:
|
|
591
|
+
self._hydration_state.parsed_files += 1
|
|
592
|
+
return True
|
|
593
|
+
|
|
594
|
+
def ensure_references_indexed(self, rel_path: str) -> bool:
|
|
595
|
+
"""Index references for a file on-demand if not already done."""
|
|
596
|
+
if rel_path in self._reference_indexed_files:
|
|
597
|
+
return False
|
|
598
|
+
self.ensure_file_parsed(rel_path)
|
|
599
|
+
ast = self._ast_cache.get(rel_path)
|
|
600
|
+
if ast is None:
|
|
601
|
+
return False
|
|
602
|
+
self._index_references(rel_path, ast)
|
|
603
|
+
self._reference_indexed_files.add(rel_path)
|
|
604
|
+
if self._hydration_state:
|
|
605
|
+
self._hydration_state.reference_indexed_files += 1
|
|
606
|
+
return True
|
|
607
|
+
|
|
608
|
+
def start_hydration(self) -> None:
|
|
609
|
+
"""Phase 2: Start background hydration of remaining files."""
|
|
610
|
+
state = self._hydration_state
|
|
611
|
+
if state is None or state.phase == "ready":
|
|
612
|
+
return
|
|
613
|
+
if self._hydration_thread is not None and self._hydration_thread.is_alive():
|
|
614
|
+
return
|
|
615
|
+
|
|
616
|
+
self._hydration_stop.clear()
|
|
617
|
+
state.phase = "hydrating"
|
|
618
|
+
|
|
619
|
+
def _worker():
|
|
620
|
+
try:
|
|
621
|
+
self._run_hydration()
|
|
622
|
+
except Exception:
|
|
623
|
+
logger.error("Hydration worker crashed", exc_info=True)
|
|
624
|
+
|
|
625
|
+
self._hydration_thread = threading.Thread(
|
|
626
|
+
target=_worker, daemon=True, name="ast-hydration",
|
|
627
|
+
)
|
|
628
|
+
self._hydration_thread.start()
|
|
629
|
+
|
|
630
|
+
def stop_hydration(self) -> None:
|
|
631
|
+
"""Stop background hydration if running."""
|
|
632
|
+
self._hydration_stop.set()
|
|
633
|
+
t = self._hydration_thread
|
|
634
|
+
if t is not None and t.is_alive():
|
|
635
|
+
t.join(timeout=5.0)
|
|
636
|
+
self._hydration_thread = None
|
|
637
|
+
|
|
638
|
+
def _run_hydration(self) -> None:
|
|
639
|
+
"""Background hydration loop: parse remaining files, index refs."""
|
|
640
|
+
state = self._hydration_state
|
|
641
|
+
if state is None:
|
|
642
|
+
return
|
|
643
|
+
|
|
644
|
+
ctx_files = self._context_mgr._files
|
|
645
|
+
_ts_langs: set[str] = set()
|
|
646
|
+
try:
|
|
647
|
+
from attocode_intel._internal.integrations.context.ts_parser import supported_languages
|
|
648
|
+
_ts_langs = set(supported_languages())
|
|
649
|
+
except ImportError:
|
|
650
|
+
pass
|
|
651
|
+
_supported = {"python", "javascript", "typescript", "shell"} | _ts_langs
|
|
652
|
+
|
|
653
|
+
remaining = []
|
|
654
|
+
for fi in ctx_files:
|
|
655
|
+
lang = fi.language
|
|
656
|
+
if lang == "shell":
|
|
657
|
+
lang = "bash"
|
|
658
|
+
if lang in _supported and fi.relative_path not in self._ast_cache:
|
|
659
|
+
remaining.append(fi)
|
|
660
|
+
|
|
661
|
+
# Phase 2a: Parse remaining files and index definitions
|
|
662
|
+
batch_size = 50
|
|
663
|
+
for i in range(0, len(remaining), batch_size):
|
|
664
|
+
if self._hydration_stop.is_set():
|
|
665
|
+
return
|
|
666
|
+
batch = remaining[i:i + batch_size]
|
|
667
|
+
for fi in batch:
|
|
668
|
+
if self._hydration_stop.is_set():
|
|
669
|
+
return
|
|
670
|
+
self.ensure_file_parsed(fi.relative_path)
|
|
671
|
+
|
|
672
|
+
# Phase 2b: Index references for all parsed files
|
|
673
|
+
# Snapshot keys to avoid dict-changed-size during concurrent on-demand
|
|
674
|
+
try:
|
|
675
|
+
cache_snapshot = list(self._ast_cache.items())
|
|
676
|
+
except RuntimeError:
|
|
677
|
+
cache_snapshot = list(self._ast_cache.items())
|
|
678
|
+
for rel, ast in cache_snapshot:
|
|
679
|
+
if self._hydration_stop.is_set():
|
|
680
|
+
return
|
|
681
|
+
if rel not in self._reference_indexed_files:
|
|
682
|
+
self._index_references(rel, ast)
|
|
683
|
+
self._reference_indexed_files.add(rel)
|
|
684
|
+
state.reference_indexed_files += 1
|
|
685
|
+
|
|
686
|
+
# Discovery defers expensive graphs for large repositories. Build them now.
|
|
687
|
+
from attocode_intel._internal.integrations.context.codebase_context import (
|
|
688
|
+
build_dependency_graph,
|
|
689
|
+
)
|
|
690
|
+
dep_graph = self._context_mgr.dependency_graph
|
|
691
|
+
if dep_graph is None or not dep_graph.forward:
|
|
692
|
+
self._context_mgr._dep_graph = build_dependency_graph(ctx_files, self._root_dir)
|
|
693
|
+
dep_graph = self._context_mgr.dependency_graph
|
|
694
|
+
if dep_graph:
|
|
695
|
+
for src, targets in dep_graph.forward.items():
|
|
696
|
+
for tgt in targets:
|
|
697
|
+
self._index.add_file_dependency(src, tgt)
|
|
698
|
+
|
|
699
|
+
# Persist using the same pattern as initialize_skeleton
|
|
700
|
+
if self._store:
|
|
701
|
+
try:
|
|
702
|
+
for offset in range(0, len(cache_snapshot), 100):
|
|
703
|
+
if self._hydration_stop.is_set():
|
|
704
|
+
return
|
|
705
|
+
batch = []
|
|
706
|
+
for rel, ast in cache_snapshot[offset:offset + 100]:
|
|
707
|
+
try:
|
|
708
|
+
mtime = os.path.getmtime(os.path.join(self._root_dir, rel))
|
|
709
|
+
except OSError:
|
|
710
|
+
continue
|
|
711
|
+
batch.append(self._build_stored_file(rel, ast, mtime))
|
|
712
|
+
self._store.save_files_batch(batch)
|
|
713
|
+
for stored in batch:
|
|
714
|
+
self._index.persist_file(stored.path)
|
|
715
|
+
self._store.record_scan_time()
|
|
716
|
+
self._save_snapshot()
|
|
717
|
+
except Exception:
|
|
718
|
+
logger.debug("Failed to persist after hydration", exc_info=True)
|
|
719
|
+
|
|
720
|
+
state.dep_graph_files = len(dep_graph.forward) if dep_graph else 0
|
|
721
|
+
state.phase = "ready"
|
|
722
|
+
|
|
723
|
+
async def async_initialize(self, batch_size: int = 50) -> None:
|
|
724
|
+
"""Async version of initialize that parses files in batches.
|
|
725
|
+
|
|
726
|
+
Uses ``asyncio.to_thread`` to avoid blocking the event loop
|
|
727
|
+
on large repositories. Files are parsed in batches of
|
|
728
|
+
*batch_size* concurrently.
|
|
729
|
+
"""
|
|
730
|
+
self._store.set_meta("structural_snapshot_v1", "")
|
|
731
|
+
self._snapshot_start = None
|
|
732
|
+
from attocode_intel.telemetry import measure
|
|
733
|
+
with measure("discovery"):
|
|
734
|
+
files = self._context_mgr.discover_files()
|
|
735
|
+
self._index = CrossRefIndex()
|
|
736
|
+
self._ast_cache.clear()
|
|
737
|
+
|
|
738
|
+
_ts_langs: set[str] = set()
|
|
739
|
+
try:
|
|
740
|
+
from attocode_intel._internal.integrations.context.ts_parser import supported_languages
|
|
741
|
+
_ts_langs = set(supported_languages())
|
|
742
|
+
except ImportError:
|
|
743
|
+
pass
|
|
744
|
+
_supported = {"python", "javascript", "typescript"} | _ts_langs
|
|
745
|
+
|
|
746
|
+
parseable = [fi for fi in files if fi.language in _supported]
|
|
747
|
+
|
|
748
|
+
def _parse_one(fi_path: str) -> tuple[str, FileAST | None]:
|
|
749
|
+
try:
|
|
750
|
+
return fi_path, parse_file(fi_path)
|
|
751
|
+
except Exception:
|
|
752
|
+
return fi_path, None
|
|
753
|
+
|
|
754
|
+
# Phase 1: Parse all files and index definitions
|
|
755
|
+
for i in range(0, len(parseable), batch_size):
|
|
756
|
+
batch = parseable[i:i + batch_size]
|
|
757
|
+
tasks = [
|
|
758
|
+
asyncio.to_thread(_parse_one, fi.path)
|
|
759
|
+
for fi in batch
|
|
760
|
+
]
|
|
761
|
+
results = await asyncio.gather(*tasks)
|
|
762
|
+
for fi, (_, ast) in zip(batch, results, strict=False):
|
|
763
|
+
if ast is not None:
|
|
764
|
+
rel = fi.relative_path
|
|
765
|
+
self._ast_cache[rel] = ast
|
|
766
|
+
self._index_definitions(rel, ast)
|
|
767
|
+
|
|
768
|
+
# Phase 2: Index references (now known_symbols is complete)
|
|
769
|
+
for rel, ast in self._ast_cache.items():
|
|
770
|
+
self._index_references(rel, ast)
|
|
771
|
+
|
|
772
|
+
dep_graph = self._context_mgr.dependency_graph
|
|
773
|
+
if dep_graph:
|
|
774
|
+
for src, targets in dep_graph.forward.items():
|
|
775
|
+
for tgt in targets:
|
|
776
|
+
self._index.add_file_dependency(src, tgt)
|
|
777
|
+
|
|
778
|
+
self._initialized = True
|
|
779
|
+
logger.debug(
|
|
780
|
+
"ASTService async_initialized: %d files, %d definitions",
|
|
781
|
+
len(self._ast_cache),
|
|
782
|
+
sum(len(v) for v in self._index.definitions.values()),
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
def notify_file_changed(self, path: str) -> list[Any]:
|
|
786
|
+
"""Incrementally update AST and index for a changed file.
|
|
787
|
+
|
|
788
|
+
Returns a list of ``SymbolChange`` objects (from ``diff_file_ast``).
|
|
789
|
+
"""
|
|
790
|
+
from attocode_intel._internal.integrations.context.codebase_ast import (
|
|
791
|
+
SymbolChange,
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
rel = self._to_rel(path)
|
|
795
|
+
abs_path = os.path.join(self._root_dir, rel)
|
|
796
|
+
self._store.set_meta("structural_snapshot_v1", "")
|
|
797
|
+
self._snapshot_start = None
|
|
798
|
+
|
|
799
|
+
# Handle deletion
|
|
800
|
+
if not Path(abs_path).exists():
|
|
801
|
+
self._index.remove_file(rel)
|
|
802
|
+
old_ast = self._ast_cache.pop(rel, None)
|
|
803
|
+
if old_ast is None:
|
|
804
|
+
return []
|
|
805
|
+
changes: list[SymbolChange] = []
|
|
806
|
+
for f in old_ast.functions:
|
|
807
|
+
changes.append(SymbolChange(
|
|
808
|
+
kind="removed", symbol_name=f.name,
|
|
809
|
+
symbol_kind="function", file_path=rel, previous=f,
|
|
810
|
+
))
|
|
811
|
+
for c in old_ast.classes:
|
|
812
|
+
changes.append(SymbolChange(
|
|
813
|
+
kind="removed", symbol_name=c.name,
|
|
814
|
+
symbol_kind="class", file_path=rel, previous=c,
|
|
815
|
+
))
|
|
816
|
+
return changes
|
|
817
|
+
|
|
818
|
+
# Parse new content
|
|
819
|
+
try:
|
|
820
|
+
new_ast = parse_file(abs_path)
|
|
821
|
+
except Exception:
|
|
822
|
+
return []
|
|
823
|
+
|
|
824
|
+
old_ast = self._ast_cache.get(rel)
|
|
825
|
+
|
|
826
|
+
# Compute diffs
|
|
827
|
+
if old_ast is not None:
|
|
828
|
+
symbol_changes = diff_file_ast(old_ast, new_ast)
|
|
829
|
+
_dep_changes = diff_imports(old_ast, new_ast)
|
|
830
|
+
else:
|
|
831
|
+
symbol_changes = []
|
|
832
|
+
_dep_changes = None
|
|
833
|
+
|
|
834
|
+
try:
|
|
835
|
+
# Update index: remove old entries, re-index
|
|
836
|
+
self._index.remove_file(rel)
|
|
837
|
+
self._ast_cache[rel] = new_ast
|
|
838
|
+
self._index_file(rel, new_ast)
|
|
839
|
+
|
|
840
|
+
# Update dependency edges from dependency graph
|
|
841
|
+
self._context_mgr.mark_file_dirty(path)
|
|
842
|
+
self._context_mgr.update_dirty_files()
|
|
843
|
+
dep_graph = self._context_mgr.dependency_graph
|
|
844
|
+
if dep_graph:
|
|
845
|
+
for tgt in dep_graph.forward.get(rel, set()):
|
|
846
|
+
self._index.add_file_dependency(rel, tgt)
|
|
847
|
+
|
|
848
|
+
try:
|
|
849
|
+
mtime = os.path.getmtime(abs_path)
|
|
850
|
+
except OSError:
|
|
851
|
+
mtime = 0.0
|
|
852
|
+
self._persist_indexed_file(rel, new_ast, mtime)
|
|
853
|
+
except sqlite3.IntegrityError as exc:
|
|
854
|
+
logger.warning(
|
|
855
|
+
"ASTService notify_file_changed hit sqlite integrity error for %s; rebuilding index",
|
|
856
|
+
rel,
|
|
857
|
+
exc_info=True,
|
|
858
|
+
)
|
|
859
|
+
self._recover_from_store_error(exc, rel)
|
|
860
|
+
|
|
861
|
+
return symbol_changes
|
|
862
|
+
|
|
863
|
+
def refresh(self) -> list[Any]:
|
|
864
|
+
"""Detect changed files (mtime) and update incrementally.
|
|
865
|
+
|
|
866
|
+
Returns aggregated list of ``SymbolChange`` from all changed files.
|
|
867
|
+
"""
|
|
868
|
+
all_changes: list[Any] = []
|
|
869
|
+
for rel, cached_ast in list(self._ast_cache.items()):
|
|
870
|
+
abs_path = os.path.join(self._root_dir, rel)
|
|
871
|
+
if not Path(abs_path).exists():
|
|
872
|
+
all_changes.extend(self.notify_file_changed(abs_path))
|
|
873
|
+
continue
|
|
874
|
+
try:
|
|
875
|
+
new_ast = parse_file(abs_path)
|
|
876
|
+
except Exception:
|
|
877
|
+
continue
|
|
878
|
+
changes = diff_file_ast(cached_ast, new_ast)
|
|
879
|
+
if changes:
|
|
880
|
+
all_changes.extend(self.notify_file_changed(abs_path))
|
|
881
|
+
return all_changes
|
|
882
|
+
|
|
883
|
+
# ------------------------------------------------------------------
|
|
884
|
+
# Symbol queries
|
|
885
|
+
# ------------------------------------------------------------------
|
|
886
|
+
|
|
887
|
+
def get_file_symbols(self, path: str) -> list[SymbolLocation]:
|
|
888
|
+
"""Return all symbols defined in *path*."""
|
|
889
|
+
self._ensure_initialized()
|
|
890
|
+
rel = self._to_rel(path)
|
|
891
|
+
qnames = self._index.file_symbols.get(rel, set())
|
|
892
|
+
result: list[SymbolLocation] = []
|
|
893
|
+
for qn in qnames:
|
|
894
|
+
result.extend(self._index.definitions.get(qn, []))
|
|
895
|
+
return result
|
|
896
|
+
|
|
897
|
+
def find_symbol(self, name: str, *, include_shadowed: bool = False) -> list[SymbolLocation]:
|
|
898
|
+
"""Find definitions for *name* (exact or suffix match)."""
|
|
899
|
+
self._ensure_initialized()
|
|
900
|
+
return self._index.get_definitions(name, include_shadowed=include_shadowed)
|
|
901
|
+
|
|
902
|
+
def search_symbol(
|
|
903
|
+
self,
|
|
904
|
+
name: str,
|
|
905
|
+
*,
|
|
906
|
+
limit: int = 50,
|
|
907
|
+
kind_filter: str = "",
|
|
908
|
+
) -> list[tuple[SymbolLocation, float]]:
|
|
909
|
+
"""Enhanced symbol search with multi-strategy matching and scoring.
|
|
910
|
+
|
|
911
|
+
Searches by exact match, bare name, case-insensitive, prefix,
|
|
912
|
+
substring, and camelCase/snake_case token overlap. Results are
|
|
913
|
+
ranked by match quality and symbol importance.
|
|
914
|
+
"""
|
|
915
|
+
self._ensure_initialized()
|
|
916
|
+
self._parse_symbol_candidates(name)
|
|
917
|
+
return self._index.search_definitions(
|
|
918
|
+
name, limit=limit, kind_filter=kind_filter,
|
|
919
|
+
)
|
|
920
|
+
|
|
921
|
+
def _parse_symbol_candidates(self, name: str) -> None:
|
|
922
|
+
"""Discover literal name candidates while the index is still partial.
|
|
923
|
+
|
|
924
|
+
Source text only shortlists files; the parser and normal symbol ranking
|
|
925
|
+
determine results. Comments and call sites cannot become definitions.
|
|
926
|
+
Fuzzy matches and references still have progressive coverage.
|
|
927
|
+
"""
|
|
928
|
+
state = self._hydration_state
|
|
929
|
+
if state is None or state.phase == "ready" or not name.strip():
|
|
930
|
+
return
|
|
931
|
+
# Qualified names need not appear contiguously in source (Class.method).
|
|
932
|
+
needle = name.rsplit(".", 1)[-1].lower()
|
|
933
|
+
if not needle:
|
|
934
|
+
return
|
|
935
|
+
supported = self._supported_source_languages()
|
|
936
|
+
for fi in self._context_mgr._files:
|
|
937
|
+
if state.phase == "ready":
|
|
938
|
+
break
|
|
939
|
+
language = "bash" if fi.language == "shell" else fi.language
|
|
940
|
+
if language not in supported or fi.relative_path in self._ast_cache:
|
|
941
|
+
continue
|
|
942
|
+
try:
|
|
943
|
+
content = Path(fi.path).read_text(encoding="utf-8", errors="replace")
|
|
944
|
+
except OSError:
|
|
945
|
+
continue
|
|
946
|
+
if needle in content.lower():
|
|
947
|
+
self.ensure_file_parsed(fi.relative_path)
|
|
948
|
+
|
|
949
|
+
def get_callers(self, symbol: str) -> list[SymbolRef]:
|
|
950
|
+
"""Return all call sites / references for *symbol*."""
|
|
951
|
+
self._ensure_initialized()
|
|
952
|
+
return self._index.get_references(symbol)
|
|
953
|
+
|
|
954
|
+
def get_callees_of(self, caller: str, *, depth: int = 1) -> set[str]:
|
|
955
|
+
"""Function-level call-graph traversal: callees up to ``depth`` hops.
|
|
956
|
+
|
|
957
|
+
Use ``ensure_references_indexed`` for files containing ``caller``
|
|
958
|
+
first if you need on-demand population.
|
|
959
|
+
"""
|
|
960
|
+
self._ensure_initialized()
|
|
961
|
+
return self._index.get_callees(caller, depth=depth)
|
|
962
|
+
|
|
963
|
+
def get_callers_of(self, callee: str, *, depth: int = 1) -> set[str]:
|
|
964
|
+
"""Function-level call-graph traversal: callers up to ``depth`` hops back."""
|
|
965
|
+
self._ensure_initialized()
|
|
966
|
+
return self._index.get_callers(callee, depth=depth)
|
|
967
|
+
|
|
968
|
+
def get_dependencies(self, path: str) -> set[str]:
|
|
969
|
+
"""Files that *path* imports from."""
|
|
970
|
+
self._ensure_initialized()
|
|
971
|
+
rel = self._to_rel(path)
|
|
972
|
+
return self._index.get_dependencies(rel)
|
|
973
|
+
|
|
974
|
+
def get_dependents(self, path: str) -> set[str]:
|
|
975
|
+
"""Files that import *path*."""
|
|
976
|
+
self._ensure_initialized()
|
|
977
|
+
rel = self._to_rel(path)
|
|
978
|
+
return self._index.get_dependents(rel)
|
|
979
|
+
|
|
980
|
+
def get_impact(self, changed: list[str]) -> set[str]:
|
|
981
|
+
"""Compute transitive impact set — all files affected by changes.
|
|
982
|
+
|
|
983
|
+
Uses BFS on the reverse dependency graph starting from *changed*.
|
|
984
|
+
"""
|
|
985
|
+
self._ensure_initialized()
|
|
986
|
+
visited: set[str] = set()
|
|
987
|
+
queue = [self._to_rel(p) for p in changed]
|
|
988
|
+
while queue:
|
|
989
|
+
current = queue.pop(0)
|
|
990
|
+
if current in visited:
|
|
991
|
+
continue
|
|
992
|
+
visited.add(current)
|
|
993
|
+
for dep in self._index.get_dependents(current):
|
|
994
|
+
if dep not in visited:
|
|
995
|
+
queue.append(dep)
|
|
996
|
+
# Remove the seed files themselves (they're the *cause*, not the impact)
|
|
997
|
+
for p in changed:
|
|
998
|
+
visited.discard(self._to_rel(p))
|
|
999
|
+
return visited
|
|
1000
|
+
|
|
1001
|
+
# ------------------------------------------------------------------
|
|
1002
|
+
# LSP enrichment
|
|
1003
|
+
# ------------------------------------------------------------------
|
|
1004
|
+
|
|
1005
|
+
def ingest_lsp_results(
|
|
1006
|
+
self,
|
|
1007
|
+
tool_name: str,
|
|
1008
|
+
file_path: str,
|
|
1009
|
+
results: list[Any],
|
|
1010
|
+
query: dict | None = None,
|
|
1011
|
+
) -> int:
|
|
1012
|
+
"""Ingest LSP results into the cross-reference index.
|
|
1013
|
+
|
|
1014
|
+
Called by LSPManager's on_result_callback. Converts LSP locations
|
|
1015
|
+
to SymbolLocation/SymbolRef with ``source="lsp"`` and merges them
|
|
1016
|
+
into the index.
|
|
1017
|
+
|
|
1018
|
+
``query`` carries the original request position
|
|
1019
|
+
(``{"line": int, "col": int}``, both 0-indexed) so that for
|
|
1020
|
+
``references`` results we can resolve the *queried* symbol — that
|
|
1021
|
+
symbol is the callee, while each result's enclosing function is
|
|
1022
|
+
the caller. Together they feed high-confidence call-graph edges
|
|
1023
|
+
with ``source="lsp"`` attribution.
|
|
1024
|
+
|
|
1025
|
+
Returns the number of new entries added.
|
|
1026
|
+
"""
|
|
1027
|
+
if not self._initialized:
|
|
1028
|
+
return 0
|
|
1029
|
+
|
|
1030
|
+
rel = self._to_rel(file_path)
|
|
1031
|
+
definitions: list[SymbolLocation] = []
|
|
1032
|
+
references: list[SymbolRef] = []
|
|
1033
|
+
|
|
1034
|
+
# For ``references`` results, look up the queried symbol once —
|
|
1035
|
+
# that's the callee for every edge we're about to record.
|
|
1036
|
+
callee_name = ""
|
|
1037
|
+
if tool_name == "references" and query is not None:
|
|
1038
|
+
q_line = int(query.get("line", 0)) + 1 # LSP is 0-indexed
|
|
1039
|
+
q_name, _q_qname, _q_kind = self._resolve_symbol_at_line(rel, q_line)
|
|
1040
|
+
callee_name = query.get("symbol_name", q_name)
|
|
1041
|
+
|
|
1042
|
+
for item in results:
|
|
1043
|
+
# LSPLocation has .uri, .range (.start.line, .start.character)
|
|
1044
|
+
if not hasattr(item, "range"):
|
|
1045
|
+
continue
|
|
1046
|
+
|
|
1047
|
+
item_file = file_path
|
|
1048
|
+
if hasattr(item, "uri"):
|
|
1049
|
+
uri = item.uri
|
|
1050
|
+
if uri.startswith("file://"):
|
|
1051
|
+
from urllib.parse import unquote, urlparse
|
|
1052
|
+
item_file = unquote(urlparse(uri).path)
|
|
1053
|
+
item_rel = self._to_rel(item_file)
|
|
1054
|
+
else:
|
|
1055
|
+
item_rel = rel
|
|
1056
|
+
|
|
1057
|
+
if not Path(item_file).resolve().is_relative_to(Path(self._root_dir).resolve()):
|
|
1058
|
+
continue
|
|
1059
|
+
line = item.range.start.line + 1 # LSP is 0-indexed
|
|
1060
|
+
|
|
1061
|
+
# Look up the symbol whose body contains this line — for
|
|
1062
|
+
# ``definition`` results it identifies the defined symbol;
|
|
1063
|
+
# for ``references`` results it's the *enclosing* function
|
|
1064
|
+
# (the caller of ``callee_name``).
|
|
1065
|
+
name, qname, kind = self._resolve_symbol_at_line(item_rel, line)
|
|
1066
|
+
if not name and not callee_name:
|
|
1067
|
+
continue
|
|
1068
|
+
|
|
1069
|
+
if tool_name == "definition":
|
|
1070
|
+
loc = SymbolLocation(
|
|
1071
|
+
name=name,
|
|
1072
|
+
qualified_name=qname,
|
|
1073
|
+
kind=kind,
|
|
1074
|
+
file_path=item_rel,
|
|
1075
|
+
start_line=line,
|
|
1076
|
+
end_line=item.range.end.line + 1,
|
|
1077
|
+
source="lsp",
|
|
1078
|
+
)
|
|
1079
|
+
definitions.append(loc)
|
|
1080
|
+
elif tool_name == "references":
|
|
1081
|
+
# When we know the queried (callee) symbol, store the ref
|
|
1082
|
+
# against it and attribute the *enclosing* function as the
|
|
1083
|
+
# caller — that's the call-graph edge ``qname → callee``.
|
|
1084
|
+
# When ``query`` is missing (legacy callers), fall back to
|
|
1085
|
+
# the prior behaviour (symbol_name = enclosing function,
|
|
1086
|
+
# no caller) so we don't regress.
|
|
1087
|
+
if callee_name:
|
|
1088
|
+
ref = SymbolRef(
|
|
1089
|
+
symbol_name=callee_name,
|
|
1090
|
+
ref_kind=("call" if any(
|
|
1091
|
+
r.file_path == item_rel and r.line == line and r.ref_kind == "call"
|
|
1092
|
+
for r in self._index.get_references(callee_name)
|
|
1093
|
+
) else "reference"),
|
|
1094
|
+
file_path=item_rel,
|
|
1095
|
+
line=line,
|
|
1096
|
+
source="lsp",
|
|
1097
|
+
caller_qualified_name=qname,
|
|
1098
|
+
)
|
|
1099
|
+
else:
|
|
1100
|
+
ref = SymbolRef(
|
|
1101
|
+
symbol_name=name,
|
|
1102
|
+
ref_kind="call",
|
|
1103
|
+
file_path=item_rel,
|
|
1104
|
+
line=line,
|
|
1105
|
+
source="lsp",
|
|
1106
|
+
)
|
|
1107
|
+
references.append(ref)
|
|
1108
|
+
|
|
1109
|
+
if definitions or references:
|
|
1110
|
+
return self._index.merge_lsp_results(rel, definitions, references, verified_symbol=bool(query and query.get("symbol_name")))
|
|
1111
|
+
return 0
|
|
1112
|
+
|
|
1113
|
+
# ------------------------------------------------------------------
|
|
1114
|
+
# Task allocation (swarm-specific)
|
|
1115
|
+
# ------------------------------------------------------------------
|
|
1116
|
+
|
|
1117
|
+
def detect_conflicts(
|
|
1118
|
+
self,
|
|
1119
|
+
a_files: list[str],
|
|
1120
|
+
b_files: list[str],
|
|
1121
|
+
) -> list[dict[str, Any]]:
|
|
1122
|
+
"""Detect potential conflicts between two parallel task file sets.
|
|
1123
|
+
|
|
1124
|
+
Returns a list of conflict descriptors::
|
|
1125
|
+
|
|
1126
|
+
[{"file": "src/auth.py", "kind": "direct", "symbols": [...]}]
|
|
1127
|
+
|
|
1128
|
+
Conflict kinds:
|
|
1129
|
+
- ``direct``: both tasks touch the same file
|
|
1130
|
+
- ``symbol``: tasks modify different files but share a symbol
|
|
1131
|
+
- ``dependency``: one task's output file is imported by the other's
|
|
1132
|
+
"""
|
|
1133
|
+
self._ensure_initialized()
|
|
1134
|
+
a_set = {self._to_rel(p) for p in a_files}
|
|
1135
|
+
b_set = {self._to_rel(p) for p in b_files}
|
|
1136
|
+
conflicts: list[dict[str, Any]] = []
|
|
1137
|
+
|
|
1138
|
+
# Direct overlap
|
|
1139
|
+
overlap = a_set & b_set
|
|
1140
|
+
for f in sorted(overlap):
|
|
1141
|
+
syms = sorted(self._index.file_symbols.get(f, set()))
|
|
1142
|
+
conflicts.append({
|
|
1143
|
+
"file": f,
|
|
1144
|
+
"kind": "direct",
|
|
1145
|
+
"symbols": syms,
|
|
1146
|
+
})
|
|
1147
|
+
|
|
1148
|
+
# Dependency conflicts: a_file imports from b_file or vice versa
|
|
1149
|
+
for af in a_set - overlap:
|
|
1150
|
+
a_deps = self._index.get_dependencies(af)
|
|
1151
|
+
for bf in b_set - overlap:
|
|
1152
|
+
if bf in a_deps:
|
|
1153
|
+
conflicts.append({
|
|
1154
|
+
"file": af,
|
|
1155
|
+
"depends_on": bf,
|
|
1156
|
+
"kind": "dependency",
|
|
1157
|
+
})
|
|
1158
|
+
b_deps = self._index.get_dependencies(bf)
|
|
1159
|
+
if af in b_deps:
|
|
1160
|
+
conflicts.append({
|
|
1161
|
+
"file": bf,
|
|
1162
|
+
"depends_on": af,
|
|
1163
|
+
"kind": "dependency",
|
|
1164
|
+
})
|
|
1165
|
+
|
|
1166
|
+
# Symbol-level overlap (same symbol modified in different files)
|
|
1167
|
+
a_symbols: set[str] = set()
|
|
1168
|
+
for af in a_set:
|
|
1169
|
+
a_symbols.update(self._index.file_symbols.get(af, set()))
|
|
1170
|
+
b_symbols: set[str] = set()
|
|
1171
|
+
for bf in b_set:
|
|
1172
|
+
b_symbols.update(self._index.file_symbols.get(bf, set()))
|
|
1173
|
+
shared_symbols = a_symbols & b_symbols
|
|
1174
|
+
if shared_symbols and not overlap:
|
|
1175
|
+
# Only report if it's NOT already a direct overlap
|
|
1176
|
+
conflicts.append({
|
|
1177
|
+
"kind": "symbol",
|
|
1178
|
+
"shared_symbols": sorted(shared_symbols),
|
|
1179
|
+
})
|
|
1180
|
+
|
|
1181
|
+
return conflicts
|
|
1182
|
+
|
|
1183
|
+
def suggest_related_files(self, target: list[str]) -> list[str]:
|
|
1184
|
+
"""Suggest files related to *target* that a worker may also need.
|
|
1185
|
+
|
|
1186
|
+
Uses the dependency graph (imports + imported-by) up to 1 hop.
|
|
1187
|
+
"""
|
|
1188
|
+
self._ensure_initialized()
|
|
1189
|
+
related: set[str] = set()
|
|
1190
|
+
target_set = {self._to_rel(p) for p in target}
|
|
1191
|
+
|
|
1192
|
+
for t in target_set:
|
|
1193
|
+
related.update(self._index.get_dependencies(t))
|
|
1194
|
+
related.update(self._index.get_dependents(t))
|
|
1195
|
+
|
|
1196
|
+
# Remove the target files themselves
|
|
1197
|
+
related -= target_set
|
|
1198
|
+
return sorted(related)
|
|
1199
|
+
|
|
1200
|
+
# ------------------------------------------------------------------
|
|
1201
|
+
def to_rel(self, path: str) -> str:
|
|
1202
|
+
"""Normalize *path* to a relative path from root (public API)."""
|
|
1203
|
+
return self._to_rel(path)
|
|
1204
|
+
|
|
1205
|
+
def get_symbol_names(self, file_path: str) -> list[str]:
|
|
1206
|
+
"""Return all symbol names defined in the cached AST for *file_path*."""
|
|
1207
|
+
rel = self._to_rel(file_path)
|
|
1208
|
+
ast = self._ast_cache.get(rel)
|
|
1209
|
+
if ast is None:
|
|
1210
|
+
return []
|
|
1211
|
+
return ast.get_symbols()
|
|
1212
|
+
|
|
1213
|
+
# Internals
|
|
1214
|
+
# ------------------------------------------------------------------
|
|
1215
|
+
|
|
1216
|
+
def _resolve_symbol_at_line(
|
|
1217
|
+
self, rel_path: str, line: int,
|
|
1218
|
+
) -> tuple[str, str, str]:
|
|
1219
|
+
"""Find the symbol name at a given line from the AST cache.
|
|
1220
|
+
|
|
1221
|
+
Returns (name, qualified_name, kind) or ("", "", "") if not found.
|
|
1222
|
+
"""
|
|
1223
|
+
ast = self._ast_cache.get(rel_path)
|
|
1224
|
+
if ast is None:
|
|
1225
|
+
return ("", "", "")
|
|
1226
|
+
|
|
1227
|
+
candidates = []
|
|
1228
|
+
for func in ast.functions:
|
|
1229
|
+
if func.start_line <= line <= func.end_line:
|
|
1230
|
+
candidates.append((func.end_line - func.start_line, func.name,
|
|
1231
|
+
func.qualified_name or func.name,
|
|
1232
|
+
"method" if func.is_method else "function"))
|
|
1233
|
+
|
|
1234
|
+
# Check classes and their methods
|
|
1235
|
+
for cls in ast.classes:
|
|
1236
|
+
if cls.start_line <= line <= cls.end_line:
|
|
1237
|
+
candidates.append((cls.end_line - cls.start_line, cls.name, cls.name, "class"))
|
|
1238
|
+
for method in cls.methods:
|
|
1239
|
+
if method.start_line <= line <= method.end_line:
|
|
1240
|
+
candidates.append((method.end_line - method.start_line, method.name,
|
|
1241
|
+
f"{cls.name}.{method.name}", "method"))
|
|
1242
|
+
|
|
1243
|
+
if candidates:
|
|
1244
|
+
return min(candidates, key=lambda c: (c[0], c[3] == "class"))[1:]
|
|
1245
|
+
|
|
1246
|
+
return ("", "", "")
|
|
1247
|
+
|
|
1248
|
+
def _to_rel(self, path: str) -> str:
|
|
1249
|
+
"""Normalize *path* to a relative path from root."""
|
|
1250
|
+
if os.path.isabs(path):
|
|
1251
|
+
try:
|
|
1252
|
+
return os.path.relpath(path, self._root_dir)
|
|
1253
|
+
except ValueError:
|
|
1254
|
+
return path
|
|
1255
|
+
# Already relative — treat as relative to root_dir
|
|
1256
|
+
return path
|
|
1257
|
+
|
|
1258
|
+
def _index_file(self, rel_path: str, ast: FileAST) -> None:
|
|
1259
|
+
"""Index all symbols and references from a parsed FileAST.
|
|
1260
|
+
|
|
1261
|
+
Convenience wrapper that calls both phases. Used by
|
|
1262
|
+
``notify_file_changed`` for incremental updates where most
|
|
1263
|
+
symbols are already known.
|
|
1264
|
+
"""
|
|
1265
|
+
self._index_definitions(rel_path, ast)
|
|
1266
|
+
self._index_references(rel_path, ast)
|
|
1267
|
+
|
|
1268
|
+
def _build_stored_file(self, rel_path: str, ast: FileAST, mtime: float) -> StoredFile:
|
|
1269
|
+
"""Build the persisted metadata row for one indexed file."""
|
|
1270
|
+
return StoredFile(
|
|
1271
|
+
path=rel_path,
|
|
1272
|
+
mtime=mtime,
|
|
1273
|
+
size=0,
|
|
1274
|
+
language="",
|
|
1275
|
+
line_count=ast.line_count if hasattr(ast, "line_count") else 0,
|
|
1276
|
+
content_hash="",
|
|
1277
|
+
)
|
|
1278
|
+
|
|
1279
|
+
def _persist_indexed_file(self, rel_path: str, ast: FileAST, mtime: float) -> None:
|
|
1280
|
+
"""Persist one file's metadata, symbols, refs, and dependency edges in FK-safe order."""
|
|
1281
|
+
self._store.save_file(self._build_stored_file(rel_path, ast, mtime))
|
|
1282
|
+
self._index.persist_file(rel_path)
|
|
1283
|
+
self._store.save_dependencies(rel_path, self._index.get_dependencies(rel_path))
|
|
1284
|
+
|
|
1285
|
+
def _recover_from_store_error(self, exc: Exception, rel_path: str) -> None:
|
|
1286
|
+
"""Clear the local store and rebuild once after a persistence integrity error."""
|
|
1287
|
+
self._store.clear_all()
|
|
1288
|
+
try:
|
|
1289
|
+
self.initialize(force=True)
|
|
1290
|
+
except Exception as rebuild_exc: # pragma: no cover - defensive escalation
|
|
1291
|
+
raise RuntimeError(
|
|
1292
|
+
f"AST index rebuild failed after store integrity error for {rel_path}"
|
|
1293
|
+
) from rebuild_exc
|
|
1294
|
+
|
|
1295
|
+
def _index_definitions(self, rel_path: str, ast: FileAST, *, index=None) -> None:
|
|
1296
|
+
"""Phase 1: Index all *definitions* (functions, classes, methods)."""
|
|
1297
|
+
index = self._index if index is None else index
|
|
1298
|
+
# Top-level functions
|
|
1299
|
+
for func in ast.functions:
|
|
1300
|
+
loc = SymbolLocation(
|
|
1301
|
+
name=func.name,
|
|
1302
|
+
qualified_name=func.qualified_name or func.name,
|
|
1303
|
+
kind="method" if func.is_method else "function",
|
|
1304
|
+
file_path=rel_path,
|
|
1305
|
+
start_line=func.start_line,
|
|
1306
|
+
end_line=func.end_line,
|
|
1307
|
+
)
|
|
1308
|
+
index.add_definition(loc)
|
|
1309
|
+
|
|
1310
|
+
# Classes and their methods
|
|
1311
|
+
for cls in ast.classes:
|
|
1312
|
+
cls_loc = SymbolLocation(
|
|
1313
|
+
name=cls.name,
|
|
1314
|
+
qualified_name=cls.name,
|
|
1315
|
+
kind="class",
|
|
1316
|
+
file_path=rel_path,
|
|
1317
|
+
start_line=cls.start_line,
|
|
1318
|
+
end_line=cls.end_line,
|
|
1319
|
+
)
|
|
1320
|
+
index.add_definition(cls_loc)
|
|
1321
|
+
|
|
1322
|
+
for method in cls.methods:
|
|
1323
|
+
method_loc = SymbolLocation(
|
|
1324
|
+
name=method.name,
|
|
1325
|
+
qualified_name=f"{cls.name}.{method.name}",
|
|
1326
|
+
kind="method",
|
|
1327
|
+
file_path=rel_path,
|
|
1328
|
+
start_line=method.start_line,
|
|
1329
|
+
end_line=method.end_line,
|
|
1330
|
+
)
|
|
1331
|
+
index.add_definition(method_loc)
|
|
1332
|
+
|
|
1333
|
+
# Top-level variables/constants
|
|
1334
|
+
for var_name in ast.top_level_vars:
|
|
1335
|
+
var_loc = SymbolLocation(
|
|
1336
|
+
name=var_name,
|
|
1337
|
+
qualified_name=var_name,
|
|
1338
|
+
kind="variable",
|
|
1339
|
+
file_path=rel_path,
|
|
1340
|
+
start_line=0,
|
|
1341
|
+
end_line=0,
|
|
1342
|
+
)
|
|
1343
|
+
index.add_definition(var_loc)
|
|
1344
|
+
|
|
1345
|
+
def _index_references(self, rel_path: str, ast: FileAST, *, index=None) -> None:
|
|
1346
|
+
"""Phase 2: Index import references and call-site references.
|
|
1347
|
+
|
|
1348
|
+
Should be called after all definitions are indexed so that
|
|
1349
|
+
``known_symbols`` is complete.
|
|
1350
|
+
"""
|
|
1351
|
+
index = self._index if index is None else index
|
|
1352
|
+
# Import references
|
|
1353
|
+
for imp in ast.imports:
|
|
1354
|
+
for name in imp.names:
|
|
1355
|
+
ref = SymbolRef(
|
|
1356
|
+
symbol_name=name,
|
|
1357
|
+
ref_kind="import",
|
|
1358
|
+
file_path=rel_path,
|
|
1359
|
+
line=imp.line,
|
|
1360
|
+
syntax_name=imp.alias or name,
|
|
1361
|
+
)
|
|
1362
|
+
index.add_reference(ref)
|
|
1363
|
+
|
|
1364
|
+
# Extract call-site references from source (lightweight regex scan)
|
|
1365
|
+
abs_path = os.path.join(self._root_dir, rel_path)
|
|
1366
|
+
try:
|
|
1367
|
+
content = Path(abs_path).read_text(encoding="utf-8", errors="replace")
|
|
1368
|
+
except OSError:
|
|
1369
|
+
return
|
|
1370
|
+
|
|
1371
|
+
# Collect known symbol names for targeted scanning
|
|
1372
|
+
with index._lock:
|
|
1373
|
+
known_symbols = set(index._name_to_qnames)
|
|
1374
|
+
|
|
1375
|
+
if not known_symbols:
|
|
1376
|
+
return
|
|
1377
|
+
|
|
1378
|
+
# Build a list of (qualified_name, start_line, end_line) for every
|
|
1379
|
+
# function/method in this file so we can attribute each call site to
|
|
1380
|
+
# its enclosing scope and feed the call-graph edges. Innermost wins
|
|
1381
|
+
# via smallest span on tie.
|
|
1382
|
+
scopes: list[tuple[str, int, int]] = []
|
|
1383
|
+
for fn in ast.functions:
|
|
1384
|
+
scopes.append((fn.qualified_name or fn.name, fn.start_line, fn.end_line))
|
|
1385
|
+
for cls in ast.classes:
|
|
1386
|
+
for method in cls.methods:
|
|
1387
|
+
scopes.append(
|
|
1388
|
+
(f"{cls.name}.{method.name}", method.start_line, method.end_line)
|
|
1389
|
+
)
|
|
1390
|
+
|
|
1391
|
+
def _enclosing_qname(line_no: int) -> str:
|
|
1392
|
+
best_q = ""
|
|
1393
|
+
best_span = -1
|
|
1394
|
+
for qname, s, e in scopes:
|
|
1395
|
+
if s <= line_no <= e:
|
|
1396
|
+
span = e - s
|
|
1397
|
+
if best_span < 0 or span < best_span:
|
|
1398
|
+
best_q = qname
|
|
1399
|
+
best_span = span
|
|
1400
|
+
return best_q
|
|
1401
|
+
|
|
1402
|
+
from .syntax_evidence import reference_candidates
|
|
1403
|
+
candidates = reference_candidates(abs_path, content, ast.language)
|
|
1404
|
+
if candidates is not None:
|
|
1405
|
+
aliases = {imp.alias: imp.names[-1] for imp in ast.imports if imp.alias and imp.names}
|
|
1406
|
+
seen = set()
|
|
1407
|
+
for name, kind, line in candidates:
|
|
1408
|
+
parts = name.split(".")
|
|
1409
|
+
parts[0] = aliases.get(parts[0], parts[0])
|
|
1410
|
+
qualified = ".".join(parts)
|
|
1411
|
+
bare = parts[-1]
|
|
1412
|
+
symbol = qualified if qualified in index.definitions else bare
|
|
1413
|
+
if bare not in known_symbols or (symbol, kind, line) in seen:
|
|
1414
|
+
continue
|
|
1415
|
+
seen.add((symbol, kind, line))
|
|
1416
|
+
index.add_reference(SymbolRef(
|
|
1417
|
+
symbol_name=symbol, ref_kind=kind, file_path=rel_path, line=line,
|
|
1418
|
+
source="tree-sitter", syntax_name=name,
|
|
1419
|
+
caller_qualified_name=_enclosing_qname(line),
|
|
1420
|
+
))
|
|
1421
|
+
return
|
|
1422
|
+
|
|
1423
|
+
# Build a regex pattern for call sites: symbol_name(
|
|
1424
|
+
# Only scan for symbols that are actually defined somewhere.
|
|
1425
|
+
# Skip comment lines and string literals to reduce false positives.
|
|
1426
|
+
in_multiline_string = False
|
|
1427
|
+
for i, line in enumerate(content.split("\n"), 1):
|
|
1428
|
+
stripped = line.lstrip()
|
|
1429
|
+
|
|
1430
|
+
# Track triple-quote multiline strings
|
|
1431
|
+
triple_count = line.count('"""') + line.count("'''")
|
|
1432
|
+
if in_multiline_string:
|
|
1433
|
+
if triple_count % 2 == 1:
|
|
1434
|
+
in_multiline_string = False
|
|
1435
|
+
continue
|
|
1436
|
+
if triple_count % 2 == 1:
|
|
1437
|
+
in_multiline_string = True
|
|
1438
|
+
continue
|
|
1439
|
+
|
|
1440
|
+
# Skip single-line comments
|
|
1441
|
+
if stripped.startswith("#"):
|
|
1442
|
+
continue
|
|
1443
|
+
|
|
1444
|
+
# Skip definition lines — the symbol's own name on its def line
|
|
1445
|
+
# is not a call site even though the regex matches "name(".
|
|
1446
|
+
if re.match(
|
|
1447
|
+
r"^\s*(?:async\s+)?(?:def|class|function|interface|type)\s",
|
|
1448
|
+
line,
|
|
1449
|
+
):
|
|
1450
|
+
continue
|
|
1451
|
+
|
|
1452
|
+
# Strip inline comments and string literals for matching
|
|
1453
|
+
# Remove quoted strings to avoid matching inside them
|
|
1454
|
+
clean_line = re.sub(r'(["\'])(?:(?!\1).)*\1', '""', line)
|
|
1455
|
+
caller = _enclosing_qname(i)
|
|
1456
|
+
|
|
1457
|
+
# Find function/method calls: name(
|
|
1458
|
+
for m in re.finditer(r"(?<!\.)\b(\w+)\s*\(", clean_line):
|
|
1459
|
+
name = m.group(1)
|
|
1460
|
+
if name in known_symbols and name not in ("if", "for", "while", "return", "print"):
|
|
1461
|
+
ref = SymbolRef(
|
|
1462
|
+
symbol_name=name,
|
|
1463
|
+
ref_kind="call",
|
|
1464
|
+
file_path=rel_path,
|
|
1465
|
+
line=i,
|
|
1466
|
+
syntax_name=name,
|
|
1467
|
+
caller_qualified_name=caller,
|
|
1468
|
+
)
|
|
1469
|
+
index.add_reference(ref)
|
|
1470
|
+
|
|
1471
|
+
# Find attribute access: obj.method(
|
|
1472
|
+
for m in re.finditer(r"\b(\w+)\.(\w+)\s*\(", clean_line):
|
|
1473
|
+
attr_name = m.group(2)
|
|
1474
|
+
if attr_name in known_symbols:
|
|
1475
|
+
ref = SymbolRef(
|
|
1476
|
+
symbol_name=attr_name,
|
|
1477
|
+
ref_kind="attribute",
|
|
1478
|
+
file_path=rel_path,
|
|
1479
|
+
line=i,
|
|
1480
|
+
syntax_name=m.group(0).rsplit("(", 1)[0].strip(),
|
|
1481
|
+
caller_qualified_name=caller,
|
|
1482
|
+
)
|
|
1483
|
+
index.add_reference(ref)
|