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