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.
Files changed (345) hide show
  1. attocode_code_intel-0.1.0.dist-info/METADATA +221 -0
  2. attocode_code_intel-0.1.0.dist-info/RECORD +345 -0
  3. attocode_code_intel-0.1.0.dist-info/WHEEL +4 -0
  4. attocode_code_intel-0.1.0.dist-info/entry_points.txt +2 -0
  5. attocode_code_intel-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. attocode_intel/GUIDELINES.md +432 -0
  7. attocode_intel/__init__.py +52 -0
  8. attocode_intel/_internal/__init__.py +1 -0
  9. attocode_intel/_internal/errors/__init__.py +126 -0
  10. attocode_intel/_internal/integrations/__init__.py +1 -0
  11. attocode_intel/_internal/integrations/context/__init__.py +1 -0
  12. attocode_intel/_internal/integrations/context/architecture_drift.py +354 -0
  13. attocode_intel/_internal/integrations/context/ast_chunker.py +202 -0
  14. attocode_intel/_internal/integrations/context/ast_service.py +1483 -0
  15. attocode_intel/_internal/integrations/context/blob_oid.py +423 -0
  16. attocode_intel/_internal/integrations/context/cache_manifest.py +221 -0
  17. attocode_intel/_internal/integrations/context/cas.py +466 -0
  18. attocode_intel/_internal/integrations/context/code_analyzer.py +528 -0
  19. attocode_intel/_internal/integrations/context/codebase_ast.py +1461 -0
  20. attocode_intel/_internal/integrations/context/codebase_context.py +1736 -0
  21. attocode_intel/_internal/integrations/context/cross_mode.py +263 -0
  22. attocode_intel/_internal/integrations/context/cross_references.py +616 -0
  23. attocode_intel/_internal/integrations/context/embedding_rotation.py +597 -0
  24. attocode_intel/_internal/integrations/context/embeddings.py +282 -0
  25. attocode_intel/_internal/integrations/context/frecency.py +427 -0
  26. attocode_intel/_internal/integrations/context/fuzzy.py +320 -0
  27. attocode_intel/_internal/integrations/context/hierarchical_explorer.py +331 -0
  28. attocode_intel/_internal/integrations/context/hydration.py +90 -0
  29. attocode_intel/_internal/integrations/context/index_repair.py +87 -0
  30. attocode_intel/_internal/integrations/context/index_store.py +586 -0
  31. attocode_intel/_internal/integrations/context/memory_store.py +602 -0
  32. attocode_intel/_internal/integrations/context/module_resolution.py +52 -0
  33. attocode_intel/_internal/integrations/context/query_constraints.py +361 -0
  34. attocode_intel/_internal/integrations/context/query_history.py +417 -0
  35. attocode_intel/_internal/integrations/context/reranker.py +351 -0
  36. attocode_intel/_internal/integrations/context/semantic_search.py +2931 -0
  37. attocode_intel/_internal/integrations/context/syntax_evidence.py +94 -0
  38. attocode_intel/_internal/integrations/context/systemone_ranker.py +245 -0
  39. attocode_intel/_internal/integrations/context/temporal_coupling.py +411 -0
  40. attocode_intel/_internal/integrations/context/trigram_index.py +820 -0
  41. attocode_intel/_internal/integrations/context/trigram_regex.py +171 -0
  42. attocode_intel/_internal/integrations/context/ts_parser.py +2199 -0
  43. attocode_intel/_internal/integrations/context/vector_store.py +795 -0
  44. attocode_intel/_internal/integrations/feature_flags/__init__.py +357 -0
  45. attocode_intel/_internal/integrations/lsp/__init__.py +1 -0
  46. attocode_intel/_internal/integrations/lsp/client.py +1140 -0
  47. attocode_intel/_internal/integrations/mcp/__init__.py +1 -0
  48. attocode_intel/_internal/integrations/mcp/client.py +253 -0
  49. attocode_intel/_internal/integrations/security/__init__.py +1 -0
  50. attocode_intel/_internal/integrations/security/custom_rules.py +176 -0
  51. attocode_intel/_internal/integrations/security/dataflow.py +522 -0
  52. attocode_intel/_internal/integrations/security/dependency_audit.py +303 -0
  53. attocode_intel/_internal/integrations/security/matcher.py +44 -0
  54. attocode_intel/_internal/integrations/security/patterns.py +724 -0
  55. attocode_intel/_internal/integrations/security/scanner.py +351 -0
  56. attocode_intel/_internal/integrations/utilities/__init__.py +1 -0
  57. attocode_intel/_internal/integrations/utilities/ignore.py +64 -0
  58. attocode_intel/_internal/integrations/utilities/token_estimate.py +51 -0
  59. attocode_intel/_internal/tricks/__init__.py +1 -0
  60. attocode_intel/_internal/tricks/recursive_context.py +357 -0
  61. attocode_intel/_internal/types/__init__.py +1 -0
  62. attocode_intel/_internal/types/events.py +289 -0
  63. attocode_intel/_internal/types/severity.py +15 -0
  64. attocode_intel/_shared.py +341 -0
  65. attocode_intel/analysis_coverage.py +56 -0
  66. attocode_intel/api/__init__.py +1 -0
  67. attocode_intel/api/access.py +32 -0
  68. attocode_intel/api/app.py +247 -0
  69. attocode_intel/api/auth/__init__.py +128 -0
  70. attocode_intel/api/auth/api_keys.py +61 -0
  71. attocode_intel/api/auth/context.py +34 -0
  72. attocode_intel/api/auth/jwt.py +66 -0
  73. attocode_intel/api/auth/oauth.py +146 -0
  74. attocode_intel/api/auth/passwords.py +15 -0
  75. attocode_intel/api/deps.py +351 -0
  76. attocode_intel/api/knowledge_bridge.py +13 -0
  77. attocode_intel/api/mcp_transport.py +51 -0
  78. attocode_intel/api/middleware.py +411 -0
  79. attocode_intel/api/models.py +698 -0
  80. attocode_intel/api/providers/__init__.py +1 -0
  81. attocode_intel/api/providers/db_provider.py +1106 -0
  82. attocode_intel/api/providers/local_provider.py +476 -0
  83. attocode_intel/api/providers/remote_provider.py +740 -0
  84. attocode_intel/api/repository_source.py +21 -0
  85. attocode_intel/api/routes/__init__.py +1 -0
  86. attocode_intel/api/routes/_pin_helper.py +158 -0
  87. attocode_intel/api/routes/activity.py +148 -0
  88. attocode_intel/api/routes/adr.py +102 -0
  89. attocode_intel/api/routes/analysis.py +515 -0
  90. attocode_intel/api/routes/api_keys.py +170 -0
  91. attocode_intel/api/routes/auth.py +428 -0
  92. attocode_intel/api/routes/branches.py +262 -0
  93. attocode_intel/api/routes/cross_repo_search.py +198 -0
  94. attocode_intel/api/routes/embeddings.py +386 -0
  95. attocode_intel/api/routes/files.py +132 -0
  96. attocode_intel/api/routes/files_v2.py +388 -0
  97. attocode_intel/api/routes/gc.py +322 -0
  98. attocode_intel/api/routes/git_v2.py +433 -0
  99. attocode_intel/api/routes/graph.py +174 -0
  100. attocode_intel/api/routes/graph_viz.py +160 -0
  101. attocode_intel/api/routes/health.py +62 -0
  102. attocode_intel/api/routes/history.py +175 -0
  103. attocode_intel/api/routes/jobs.py +84 -0
  104. attocode_intel/api/routes/learning.py +205 -0
  105. attocode_intel/api/routes/lsp.py +272 -0
  106. attocode_intel/api/routes/notify.py +623 -0
  107. attocode_intel/api/routes/operations.py +27 -0
  108. attocode_intel/api/routes/orgs.py +924 -0
  109. attocode_intel/api/routes/preferences.py +100 -0
  110. attocode_intel/api/routes/presence.py +42 -0
  111. attocode_intel/api/routes/projects.py +116 -0
  112. attocode_intel/api/routes/repos.py +47 -0
  113. attocode_intel/api/routes/search.py +180 -0
  114. attocode_intel/api/routes/snapshots.py +614 -0
  115. attocode_intel/api/routes/webhooks.py +313 -0
  116. attocode_intel/api/routes/websocket.py +116 -0
  117. attocode_intel/api/static/graph.html +239 -0
  118. attocode_intel/api/utils.py +96 -0
  119. attocode_intel/artifacts/__init__.py +45 -0
  120. attocode_intel/artifacts/hashing.py +230 -0
  121. attocode_intel/artifacts/provenance.py +190 -0
  122. attocode_intel/artifacts/retrieval_pin.py +159 -0
  123. attocode_intel/audit.py +59 -0
  124. attocode_intel/bug_finder.py +206 -0
  125. attocode_intel/bundle.py +80 -0
  126. attocode_intel/catalog.py +242 -0
  127. attocode_intel/change_review.py +75 -0
  128. attocode_intel/cli.py +3062 -0
  129. attocode_intel/community.py +63 -0
  130. attocode_intel/confidence/__init__.py +144 -0
  131. attocode_intel/confidence/jev.py +234 -0
  132. attocode_intel/confidence/llm.py +330 -0
  133. attocode_intel/confidence/redact.py +44 -0
  134. attocode_intel/confidence/settings.py +74 -0
  135. attocode_intel/config.py +256 -0
  136. attocode_intel/crypto.py +31 -0
  137. attocode_intel/db/__init__.py +7 -0
  138. attocode_intel/db/base.py +33 -0
  139. attocode_intel/db/engine.py +61 -0
  140. attocode_intel/db/models.py +529 -0
  141. attocode_intel/entrypoint.py +185 -0
  142. attocode_intel/focused_evidence.py +130 -0
  143. attocode_intel/freshness.py +154 -0
  144. attocode_intel/gateway.py +734 -0
  145. attocode_intel/git/__init__.py +7 -0
  146. attocode_intel/git/credentials.py +75 -0
  147. attocode_intel/git/manager.py +493 -0
  148. attocode_intel/git/models.py +96 -0
  149. attocode_intel/git/storage.py +98 -0
  150. attocode_intel/graph_query_parser.py +761 -0
  151. attocode_intel/helpers.py +712 -0
  152. attocode_intel/indexing/__init__.py +29 -0
  153. attocode_intel/indexing/debouncer.py +136 -0
  154. attocode_intel/indexing/delta_indexer.py +151 -0
  155. attocode_intel/indexing/full_indexer.py +323 -0
  156. attocode_intel/indexing/incremental.py +240 -0
  157. attocode_intel/indexing/parser.py +403 -0
  158. attocode_intel/indexing/summarizer.py +264 -0
  159. attocode_intel/installer.py +1896 -0
  160. attocode_intel/knowledge.py +269 -0
  161. attocode_intel/local_knowledge.py +77 -0
  162. attocode_intel/migrations/alembic.ini +36 -0
  163. attocode_intel/migrations/env.py +71 -0
  164. attocode_intel/migrations/script.py.mako +27 -0
  165. attocode_intel/migrations/versions/001_initial_schema.py +219 -0
  166. attocode_intel/migrations/versions/002_branch_version_and_embeddings.py +61 -0
  167. attocode_intel/migrations/versions/003_embedding_uniqueness_and_cascade.py +51 -0
  168. attocode_intel/migrations/versions/004_audit_events.py +38 -0
  169. attocode_intel/migrations/versions/005_user_preferences.py +30 -0
  170. attocode_intel/migrations/versions/006_google_oauth.py +27 -0
  171. attocode_intel/migrations/versions/007_pgvector_embeddings.py +28 -0
  172. attocode_intel/migrations/versions/008_symbol_references.py +37 -0
  173. attocode_intel/migrations/versions/009_webhook_secret_encryption.py +25 -0
  174. attocode_intel/migrations/versions/010_add_commits_table.py +40 -0
  175. attocode_intel/migrations/versions/011_commit_changed_files.py +25 -0
  176. attocode_intel/migrations/versions/012_branch_merged_at.py +25 -0
  177. attocode_intel/migrations/versions/013_revoked_tokens.py +32 -0
  178. attocode_intel/migrations/versions/014_blame_hunks.py +40 -0
  179. attocode_intel/migrations/versions/015_commit_file_stats.py +40 -0
  180. attocode_intel/migrations/versions/016_embedding_provenance_columns.py +179 -0
  181. attocode_intel/migrations/versions/017_provenance_table.py +102 -0
  182. attocode_intel/migrations/versions/018_repo_snapshots.py +178 -0
  183. attocode_intel/migrations/versions/019_symref_caller.py +42 -0
  184. attocode_intel/migrations/versions/020_shared_knowledge.py +29 -0
  185. attocode_intel/onboarding.py +258 -0
  186. attocode_intel/output.py +178 -0
  187. attocode_intel/precision.py +133 -0
  188. attocode_intel/presence.py +160 -0
  189. attocode_intel/probe.py +110 -0
  190. attocode_intel/project_dir.py +97 -0
  191. attocode_intel/pubsub.py +113 -0
  192. attocode_intel/query_ranking.py +242 -0
  193. attocode_intel/readiness.py +972 -0
  194. attocode_intel/redis.py +51 -0
  195. attocode_intel/reference_pages.py +60 -0
  196. attocode_intel/remote.py +131 -0
  197. attocode_intel/repo_ranker.py +246 -0
  198. attocode_intel/request_context.py +82 -0
  199. attocode_intel/rules/__init__.py +28 -0
  200. attocode_intel/rules/ci.py +320 -0
  201. attocode_intel/rules/combinators.py +293 -0
  202. attocode_intel/rules/enricher.py +127 -0
  203. attocode_intel/rules/evolution.py +452 -0
  204. attocode_intel/rules/executor.py +531 -0
  205. attocode_intel/rules/filters/__init__.py +5 -0
  206. attocode_intel/rules/filters/pipeline.py +151 -0
  207. attocode_intel/rules/formatter.py +198 -0
  208. attocode_intel/rules/hygiene.py +242 -0
  209. attocode_intel/rules/importers/__init__.py +1 -0
  210. attocode_intel/rules/importers/semgrep.py +382 -0
  211. attocode_intel/rules/loader.py +292 -0
  212. attocode_intel/rules/marketplace.py +375 -0
  213. attocode_intel/rules/metavar.py +181 -0
  214. attocode_intel/rules/model.py +317 -0
  215. attocode_intel/rules/packs/__init__.py +1 -0
  216. attocode_intel/rules/packs/community/_license_templates/Apache-2.0.txt +176 -0
  217. attocode_intel/rules/packs/community/_license_templates/MIT.txt +21 -0
  218. attocode_intel/rules/packs/community/bandit-python/LICENSE +176 -0
  219. attocode_intel/rules/packs/community/bandit-python/NOTICE +13 -0
  220. attocode_intel/rules/packs/community/bandit-python/PORTING.md +23 -0
  221. attocode_intel/rules/packs/community/bandit-python/manifest.yaml +14 -0
  222. attocode_intel/rules/packs/community/bandit-python/rules/B105-hardcoded-password.yaml +23 -0
  223. attocode_intel/rules/packs/community/bandit-python/rules/B303-weak-md5.yaml +21 -0
  224. attocode_intel/rules/packs/community/bandit-python/rules/B304-weak-des.yaml +20 -0
  225. attocode_intel/rules/packs/community/bandit-python/rules/B307-use-of-eval.yaml +22 -0
  226. attocode_intel/rules/packs/community/bandit-python/rules/B324-weak-sha1.yaml +21 -0
  227. attocode_intel/rules/packs/community/bandit-python/rules/B501-no-cert-validation.yaml +23 -0
  228. attocode_intel/rules/packs/community/bandit-python/rules/B602-shell-true.yaml +22 -0
  229. attocode_intel/rules/packs/community/bandit-python/rules/B608-sqli.yaml +22 -0
  230. attocode_intel/rules/packs/community/eslint-typescript/LICENSE +21 -0
  231. attocode_intel/rules/packs/community/eslint-typescript/NOTICE +13 -0
  232. attocode_intel/rules/packs/community/eslint-typescript/PORTING.md +23 -0
  233. attocode_intel/rules/packs/community/eslint-typescript/manifest.yaml +14 -0
  234. attocode_intel/rules/packs/community/eslint-typescript/rules/eqeqeq.yaml +20 -0
  235. attocode_intel/rules/packs/community/eslint-typescript/rules/no-eval.yaml +22 -0
  236. attocode_intel/rules/packs/community/eslint-typescript/rules/no-implied-eval.yaml +20 -0
  237. attocode_intel/rules/packs/community/eslint-typescript/rules/no-new-func.yaml +21 -0
  238. attocode_intel/rules/packs/community/eslint-typescript/rules/no-throw-literal.yaml +19 -0
  239. attocode_intel/rules/packs/community/eslint-typescript/rules/no-var.yaml +20 -0
  240. attocode_intel/rules/packs/community/eslint-typescript/rules/no-with.yaml +18 -0
  241. attocode_intel/rules/packs/community/gosec-go/LICENSE +176 -0
  242. attocode_intel/rules/packs/community/gosec-go/NOTICE +13 -0
  243. attocode_intel/rules/packs/community/gosec-go/PORTING.md +23 -0
  244. attocode_intel/rules/packs/community/gosec-go/manifest.yaml +14 -0
  245. attocode_intel/rules/packs/community/gosec-go/rules/G101-hardcoded-credentials.yaml +22 -0
  246. attocode_intel/rules/packs/community/gosec-go/rules/G102-bind-all-interfaces.yaml +21 -0
  247. attocode_intel/rules/packs/community/gosec-go/rules/G103-unsafe-block.yaml +21 -0
  248. attocode_intel/rules/packs/community/gosec-go/rules/G201-sql-format.yaml +21 -0
  249. attocode_intel/rules/packs/community/gosec-go/rules/G304-file-inclusion.yaml +23 -0
  250. attocode_intel/rules/packs/community/gosec-go/rules/G401-weak-cipher.yaml +20 -0
  251. attocode_intel/rules/packs/community/gosec-go/rules/G402-tls-min-version.yaml +20 -0
  252. attocode_intel/rules/packs/community/gosec-go/rules/G501-md5-import.yaml +21 -0
  253. attocode_intel/rules/packs/examples/cpp/manifest.yaml +4 -0
  254. attocode_intel/rules/packs/examples/cpp/rules/security.yaml +90 -0
  255. attocode_intel/rules/packs/examples/cpp/rules/structural.yaml +42 -0
  256. attocode_intel/rules/packs/examples/csharp/manifest.yaml +4 -0
  257. attocode_intel/rules/packs/examples/csharp/rules/security.yaml +69 -0
  258. attocode_intel/rules/packs/examples/csharp/rules/structural.yaml +42 -0
  259. attocode_intel/rules/packs/examples/go/manifest.yaml +4 -0
  260. attocode_intel/rules/packs/examples/go/rules/performance.yaml +136 -0
  261. attocode_intel/rules/packs/examples/go/rules/security.yaml +48 -0
  262. attocode_intel/rules/packs/examples/go/rules/structural.yaml +58 -0
  263. attocode_intel/rules/packs/examples/go/taint/sanitizers.yaml +24 -0
  264. attocode_intel/rules/packs/examples/go/taint/sinks.yaml +51 -0
  265. attocode_intel/rules/packs/examples/go/taint/sources.yaml +26 -0
  266. attocode_intel/rules/packs/examples/java/manifest.yaml +4 -0
  267. attocode_intel/rules/packs/examples/java/rules/correctness.yaml +55 -0
  268. attocode_intel/rules/packs/examples/java/rules/structural.yaml +48 -0
  269. attocode_intel/rules/packs/examples/java/taint/sinks.yaml +37 -0
  270. attocode_intel/rules/packs/examples/java/taint/sources.yaml +21 -0
  271. attocode_intel/rules/packs/examples/kotlin/manifest.yaml +4 -0
  272. attocode_intel/rules/packs/examples/kotlin/rules/correctness.yaml +50 -0
  273. attocode_intel/rules/packs/examples/kotlin/rules/structural.yaml +38 -0
  274. attocode_intel/rules/packs/examples/php/manifest.yaml +4 -0
  275. attocode_intel/rules/packs/examples/php/rules/security.yaml +66 -0
  276. attocode_intel/rules/packs/examples/php/rules/structural.yaml +41 -0
  277. attocode_intel/rules/packs/examples/python/manifest.yaml +4 -0
  278. attocode_intel/rules/packs/examples/python/rules/performance.yaml +90 -0
  279. attocode_intel/rules/packs/examples/python/rules/structural.yaml +55 -0
  280. attocode_intel/rules/packs/examples/ruby/manifest.yaml +4 -0
  281. attocode_intel/rules/packs/examples/ruby/rules/security.yaml +73 -0
  282. attocode_intel/rules/packs/examples/ruby/rules/structural.yaml +41 -0
  283. attocode_intel/rules/packs/examples/rust/manifest.yaml +4 -0
  284. attocode_intel/rules/packs/examples/rust/rules/correctness.yaml +51 -0
  285. attocode_intel/rules/packs/examples/rust/rules/structural.yaml +49 -0
  286. attocode_intel/rules/packs/examples/typescript/manifest.yaml +4 -0
  287. attocode_intel/rules/packs/examples/typescript/rules/performance.yaml +70 -0
  288. attocode_intel/rules/packs/examples/typescript/rules/structural.yaml +48 -0
  289. attocode_intel/rules/packs/pack_loader.py +244 -0
  290. attocode_intel/rules/plugins/__init__.py +1 -0
  291. attocode_intel/rules/plugins/plugin_loader.py +132 -0
  292. attocode_intel/rules/profiling.py +332 -0
  293. attocode_intel/rules/registry.py +213 -0
  294. attocode_intel/rules/sarif.py +187 -0
  295. attocode_intel/rules/synthesis.py +420 -0
  296. attocode_intel/rules/testing.py +326 -0
  297. attocode_intel/server.py +734 -0
  298. attocode_intel/service.py +3416 -0
  299. attocode_intel/sharing.py +57 -0
  300. attocode_intel/storage/__init__.py +6 -0
  301. attocode_intel/storage/branch_overlay.py +352 -0
  302. attocode_intel/storage/content_store.py +220 -0
  303. attocode_intel/storage/dependency_store.py +143 -0
  304. attocode_intel/storage/diff_engine.py +187 -0
  305. attocode_intel/storage/embedding_store.py +594 -0
  306. attocode_intel/storage/provenance_store.py +137 -0
  307. attocode_intel/storage/security_scanner_db.py +164 -0
  308. attocode_intel/storage/symbol_store.py +160 -0
  309. attocode_intel/structural_evidence.py +144 -0
  310. attocode_intel/symbol_inspection.py +141 -0
  311. attocode_intel/symbol_links.py +167 -0
  312. attocode_intel/telemetry.py +42 -0
  313. attocode_intel/test_ranking.py +73 -0
  314. attocode_intel/testing/__init__.py +43 -0
  315. attocode_intel/testing/fixtures.py +122 -0
  316. attocode_intel/testing/helpers.py +75 -0
  317. attocode_intel/testing/mocks.py +99 -0
  318. attocode_intel/tools/__init__.py +2 -0
  319. attocode_intel/tools/adr_tools.py +628 -0
  320. attocode_intel/tools/analysis_tools.py +382 -0
  321. attocode_intel/tools/composite_tools.py +695 -0
  322. attocode_intel/tools/cross_mode_tools.py +173 -0
  323. attocode_intel/tools/dead_code_tools.py +582 -0
  324. attocode_intel/tools/distill_tools.py +353 -0
  325. attocode_intel/tools/frecency_tools.py +220 -0
  326. attocode_intel/tools/fuzzy_tools.py +255 -0
  327. attocode_intel/tools/history_tools.py +486 -0
  328. attocode_intel/tools/learning_tools.py +222 -0
  329. attocode_intel/tools/lsp_tools.py +218 -0
  330. attocode_intel/tools/maintenance_tools.py +1662 -0
  331. attocode_intel/tools/navigation_tools.py +220 -0
  332. attocode_intel/tools/overlay_tools.py +540 -0
  333. attocode_intel/tools/pin_store.py +432 -0
  334. attocode_intel/tools/pin_tools.py +266 -0
  335. attocode_intel/tools/query_constraints_tools.py +262 -0
  336. attocode_intel/tools/query_history_tools.py +158 -0
  337. attocode_intel/tools/readiness_tools.py +44 -0
  338. attocode_intel/tools/rule_tools.py +992 -0
  339. attocode_intel/tools/search_tools.py +770 -0
  340. attocode_intel/tools/snapshot_tools.py +624 -0
  341. attocode_intel/workers/__init__.py +1 -0
  342. attocode_intel/workers/job_utils.py +111 -0
  343. attocode_intel/workers/jobs.py +573 -0
  344. attocode_intel/workers/run.py +30 -0
  345. 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)