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,29 @@
1
+ """Indexing pipeline -- full and delta indexing with progress tracking.
2
+
3
+ Exports are lazy-loaded to avoid pulling in tree-sitter and AST parsing
4
+ modules at import time.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import importlib
10
+ from typing import Any
11
+
12
+ __all__ = ["FullIndexer", "DeltaIndexer"]
13
+
14
+ # Lazy-load map: attribute name -> (module_path, attribute_name)
15
+ _LAZY_IMPORTS: dict[str, tuple[str, str]] = {
16
+ "DeltaIndexer": ("attocode_intel.indexing.delta_indexer", "DeltaIndexer"),
17
+ "FullIndexer": ("attocode_intel.indexing.full_indexer", "FullIndexer"),
18
+ }
19
+
20
+
21
+ def __getattr__(name: str) -> Any:
22
+ if name in _LAZY_IMPORTS:
23
+ module_path, attr = _LAZY_IMPORTS[name]
24
+ mod = importlib.import_module(module_path)
25
+ value = getattr(mod, attr)
26
+ # Cache on the module so subsequent accesses are fast
27
+ globals()[name] = value
28
+ return value
29
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,136 @@
1
+ """Per-project file change debouncer.
2
+
3
+ Collects file change notifications and fires a batch update after a
4
+ configurable quiet period (default 500ms). Prevents redundant re-parsing
5
+ when a single tool call writes multiple files.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import logging
12
+ from collections import defaultdict
13
+ from typing import Awaitable, Callable
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ # Type alias for the batch handler
18
+ # handler(project_id, branch, paths, files)
19
+ BatchHandler = Callable[[str, str, list[str], dict[str, str] | None], Awaitable[None]]
20
+
21
+
22
+ _MAX_PENDING_PER_KEY = 10_000 # M9: cap pending paths to prevent unbounded memory
23
+
24
+
25
+ class FileChangeDebouncer:
26
+ """Debounces file change notifications per (project, branch).
27
+
28
+ Usage:
29
+ debouncer = FileChangeDebouncer(handler=my_batch_handler)
30
+ await debouncer.notify("proj-1", "feat/x", ["src/a.py", "src/b.py"])
31
+ # ... 200ms later ...
32
+ await debouncer.notify("proj-1", "feat/x", ["src/c.py"])
33
+ # handler fires ~500ms after last notify with all 3 paths
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ handler: BatchHandler,
39
+ delay_seconds: float = 0.5,
40
+ max_pending_per_key: int = _MAX_PENDING_PER_KEY,
41
+ ) -> None:
42
+ self._handler = handler
43
+ self._delay = delay_seconds
44
+ self._max_pending = max_pending_per_key
45
+ # Pending paths per (project_id, branch)
46
+ self._pending: dict[tuple[str, str], set[str]] = defaultdict(set)
47
+ # Pending file contents per (project_id, branch) — path → base64 content
48
+ self._pending_files: dict[tuple[str, str], dict[str, str]] = defaultdict(dict)
49
+ # Active timers per (project_id, branch)
50
+ self._timers: dict[tuple[str, str], asyncio.Task] = {}
51
+
52
+ async def notify(self, project_id: str, branch: str, paths: list[str], *, files: dict[str, str] | None = None) -> None:
53
+ """Add paths to the pending batch for (project_id, branch).
54
+
55
+ Resets the debounce timer. Handler fires after `delay_seconds` of quiet.
56
+ M9: Caps pending paths per key at max_pending_per_key.
57
+ """
58
+ key = (project_id, branch)
59
+ pending = self._pending[key]
60
+ pending.update(paths)
61
+
62
+ # Accumulate file contents (latest wins per path)
63
+ if files:
64
+ self._pending_files[key].update(files)
65
+
66
+ # M9: cap pending paths to prevent unbounded memory
67
+ if len(pending) > self._max_pending:
68
+ logger.warning(
69
+ "Debouncer: pending paths for %s/%s exceeded %d, truncating",
70
+ project_id, branch, self._max_pending,
71
+ )
72
+ # Keep first max_pending items (arbitrary but bounded)
73
+ excess = len(pending) - self._max_pending
74
+ for _ in range(excess):
75
+ pending.pop()
76
+
77
+ # Cancel existing timer if any
78
+ if key in self._timers:
79
+ self._timers[key].cancel()
80
+
81
+ # Start new timer
82
+ self._timers[key] = asyncio.create_task(self._fire_after_delay(key))
83
+
84
+ async def _fire_after_delay(self, key: tuple[str, str]) -> None:
85
+ """Wait for the quiet period, then fire the batch handler."""
86
+ try:
87
+ await asyncio.sleep(self._delay)
88
+ except asyncio.CancelledError:
89
+ return
90
+
91
+ # Copy batch, then clear pending — prevents lost updates if new
92
+ # paths arrive between pop and handler execution.
93
+ batch = self._pending.pop(key, set())
94
+ files = self._pending_files.pop(key, None) or None
95
+ paths = list(batch)
96
+ self._timers.pop(key, None)
97
+
98
+ if not paths:
99
+ return
100
+
101
+ project_id, branch = key
102
+ logger.info(
103
+ "Debouncer firing for %s/%s: %d files",
104
+ project_id, branch, len(paths),
105
+ )
106
+ try:
107
+ await self._handler(project_id, branch, paths, files)
108
+ except Exception:
109
+ logger.exception(
110
+ "Error in debounce handler for %s/%s",
111
+ project_id, branch,
112
+ )
113
+
114
+ async def flush(self, project_id: str, branch: str) -> None:
115
+ """Force-fire pending batch immediately (e.g., on shutdown)."""
116
+ key = (project_id, branch)
117
+ if key in self._timers:
118
+ self._timers[key].cancel()
119
+ self._timers.pop(key, None)
120
+ paths = list(self._pending.pop(key, set()))
121
+ files = self._pending_files.pop(key, None) or None
122
+ if paths:
123
+ await self._handler(project_id, branch, paths, files)
124
+
125
+ async def shutdown(self) -> None:
126
+ """Cancel all pending timers. Call on app shutdown."""
127
+ for task in self._timers.values():
128
+ task.cancel()
129
+ self._timers.clear()
130
+ self._pending.clear()
131
+ self._pending_files.clear()
132
+
133
+ @property
134
+ def pending_count(self) -> int:
135
+ """Number of pending batches."""
136
+ return len(self._pending)
@@ -0,0 +1,151 @@
1
+ """Delta indexer — processes only changed files between two refs.
2
+
3
+ Uses content-hash comparison to skip files whose content hasn't actually changed
4
+ (e.g., git reports a mode change but the content is identical).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import uuid
11
+ from typing import TYPE_CHECKING, Callable
12
+
13
+ from attocode_intel.indexing.parser import detect_language, extract_symbols
14
+
15
+ if TYPE_CHECKING:
16
+ from sqlalchemy.ext.asyncio import AsyncSession
17
+
18
+ from attocode_intel.git.manager import GitRepoManager
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ _MAX_FILE_SIZE = 1_000_000
23
+
24
+
25
+ class DeltaIndexer:
26
+ """Delta indexer — only processes changed files.
27
+
28
+ Computes the git diff between the previously indexed commit and the
29
+ current HEAD, then processes only additions, modifications, and deletions.
30
+
31
+ Content-hash gating: even for files git reports as changed, we compare
32
+ the SHA-256 of the new content to the existing manifest entry. If the
33
+ content hash matches, we skip re-parsing (idempotent).
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ session: AsyncSession,
39
+ git_manager: GitRepoManager,
40
+ progress_callback: Callable[[dict], None] | None = None,
41
+ ) -> None:
42
+ self._session = session
43
+ self._git = git_manager
44
+ self._progress = progress_callback or (lambda _: None)
45
+
46
+ async def index(
47
+ self,
48
+ repo_id: str,
49
+ branch_id: uuid.UUID,
50
+ from_ref: str,
51
+ to_ref: str,
52
+ ) -> dict:
53
+ """Perform a delta index between two refs.
54
+
55
+ Returns stats dict: {added, modified, deleted, skipped_unchanged,
56
+ symbols_updated, errors, duration_ms}.
57
+ """
58
+ import time
59
+
60
+ from attocode_intel.storage.branch_overlay import BranchOverlay
61
+ from attocode_intel.storage.content_store import ContentStore
62
+ from attocode_intel.storage.symbol_store import SymbolStore
63
+
64
+ start = time.monotonic()
65
+ content_store = ContentStore(self._session)
66
+ symbol_store = SymbolStore(self._session)
67
+ overlay = BranchOverlay(self._session)
68
+
69
+ stats = {
70
+ "added": 0,
71
+ "modified": 0,
72
+ "deleted": 0,
73
+ "skipped_unchanged": 0,
74
+ "symbols_updated": 0,
75
+ "errors": 0,
76
+ }
77
+
78
+ # Get current manifest for content-hash comparison
79
+ manifest = await overlay.resolve_manifest(branch_id)
80
+
81
+ diff_entries = self._git.get_diff(repo_id, from_ref, to_ref)
82
+ total = len(diff_entries)
83
+
84
+ self._progress({"phase": "delta_indexing", "total": total, "current": 0})
85
+
86
+ # Batch overlay updates for single version bump
87
+ overlay_updates: list[tuple[str, str, str]] = []
88
+
89
+ for i, entry in enumerate(diff_entries):
90
+ try:
91
+ if entry.status == "deleted":
92
+ await overlay.delete_file(branch_id, entry.path)
93
+ stats["deleted"] += 1
94
+ elif entry.status in ("added", "modified"):
95
+ content = self._git.read_file(repo_id, to_ref, entry.path)
96
+ if len(content) > _MAX_FILE_SIZE:
97
+ continue
98
+
99
+ # Content-hash gating
100
+ sha = content_store.hash_content(content)
101
+ if sha == manifest.get(entry.path):
102
+ stats["skipped_unchanged"] += 1
103
+ continue
104
+
105
+ language = detect_language(entry.path)
106
+ await content_store.store(content, language)
107
+ overlay_updates.append((entry.path, sha, entry.status))
108
+
109
+ # Extract and update symbols (skip if already indexed for this SHA)
110
+ symbols = extract_symbols(content, entry.path)
111
+ if symbols:
112
+ count = await symbol_store.upsert_symbols(
113
+ sha, symbols, skip_if_exists=True
114
+ )
115
+ stats["symbols_updated"] += count or len(symbols)
116
+
117
+ stats[entry.status] = stats.get(entry.status, 0) + 1
118
+ elif entry.status == "renamed":
119
+ if entry.old_path:
120
+ await overlay.delete_file(branch_id, entry.old_path)
121
+ content = self._git.read_file(repo_id, to_ref, entry.path)
122
+ if len(content) <= _MAX_FILE_SIZE:
123
+ sha = content_store.hash_content(content)
124
+ language = detect_language(entry.path)
125
+ await content_store.store(content, language)
126
+ overlay_updates.append((entry.path, sha, "added"))
127
+ stats["modified"] += 1
128
+
129
+ except Exception as e:
130
+ logger.warning("Error delta-indexing %s: %s", entry.path, e)
131
+ stats["errors"] += 1
132
+
133
+ if (i + 1) % 50 == 0:
134
+ self._progress({"phase": "delta_indexing", "total": total, "current": i + 1})
135
+
136
+ # Batch overlay update (single version bump)
137
+ if overlay_updates:
138
+ await overlay.set_files_batch(branch_id, overlay_updates)
139
+
140
+ await self._session.commit()
141
+
142
+ duration_ms = int((time.monotonic() - start) * 1000)
143
+ stats["duration_ms"] = duration_ms
144
+
145
+ self._progress({"phase": "completed", "stats": stats})
146
+ logger.info(
147
+ "Delta index complete: +%d ~%d -%d =%d(unchanged), %d symbols in %dms",
148
+ stats["added"], stats["modified"], stats["deleted"],
149
+ stats["skipped_unchanged"], stats["symbols_updated"], duration_ms,
150
+ )
151
+ return stats
@@ -0,0 +1,323 @@
1
+ """Full indexer — reads all files from git tree, indexes content, symbols, deps.
2
+
3
+ Uses content-addressed storage: files with the same content across branches
4
+ are stored and indexed only once.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import uuid
11
+ from typing import TYPE_CHECKING, Callable
12
+
13
+ from attocode_intel.indexing.parser import detect_language, extract_imports, extract_references, extract_symbols
14
+
15
+ if TYPE_CHECKING:
16
+ from sqlalchemy.ext.asyncio import AsyncSession
17
+
18
+ from attocode_intel.git.manager import GitRepoManager
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # Skip binary/large files
23
+ _SKIP_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg", ".woff", ".woff2",
24
+ ".ttf", ".eot", ".pdf", ".zip", ".tar", ".gz", ".bin", ".exe",
25
+ ".dll", ".so", ".dylib", ".pyc", ".pyo", ".class", ".o"}
26
+ _MAX_FILE_SIZE = 1_000_000 # 1MB
27
+
28
+
29
+ def _load_path_aliases(known_paths: set[str], config_contents: dict[str, bytes]) -> dict[str, str]:
30
+ """Parse tsconfig.json/jsconfig.json for path alias mappings.
31
+
32
+ Returns dict mapping alias prefix to resolved directory.
33
+ E.g., {"@/": "frontend/src/"} from paths: {"@/*": ["./src/*"]}
34
+ """
35
+ import json
36
+ from pathlib import PurePosixPath
37
+
38
+ aliases: dict[str, str] = {}
39
+ config_names = [p for p in known_paths if p.endswith(("tsconfig.json", "jsconfig.json"))]
40
+
41
+ for config_path in config_names:
42
+ raw = config_contents.get(config_path)
43
+ if not raw:
44
+ continue
45
+ try:
46
+ config = json.loads(raw.decode("utf-8", errors="replace"))
47
+ paths = config.get("compilerOptions", {}).get("paths", {})
48
+ base_url = config.get("compilerOptions", {}).get("baseUrl", ".")
49
+ config_dir = str(PurePosixPath(config_path).parent)
50
+
51
+ for alias_pattern, targets in paths.items():
52
+ if not targets:
53
+ continue
54
+ prefix = alias_pattern.rstrip("*")
55
+ target = targets[0].rstrip("*")
56
+ resolved = str(PurePosixPath(config_dir) / base_url / target)
57
+ resolved = resolved.lstrip("./")
58
+ if not resolved.endswith("/"):
59
+ resolved += "/"
60
+ aliases[prefix] = resolved
61
+ except (json.JSONDecodeError, KeyError, UnicodeDecodeError):
62
+ continue
63
+ return aliases
64
+
65
+
66
+ def _resolve_import_path(
67
+ import_name: str,
68
+ source_path: str,
69
+ known_paths: set[str],
70
+ language: str | None,
71
+ aliases: dict[str, str] | None = None,
72
+ ) -> str | None:
73
+ """Resolve an import string to a file path in the repo.
74
+
75
+ Returns the matching path or None if unresolvable.
76
+ """
77
+ from pathlib import PurePosixPath
78
+
79
+ if language == "python":
80
+ # foo.bar -> foo/bar.py or foo/bar/__init__.py
81
+ parts = import_name.replace(".", "/")
82
+ candidates = [f"{parts}.py", f"{parts}/__init__.py"]
83
+ elif language in ("javascript", "typescript"):
84
+ if import_name.startswith("."):
85
+ # Relative import — resolve against source dir
86
+ source_dir = str(PurePosixPath(source_path).parent)
87
+ base = str(PurePosixPath(source_dir) / import_name)
88
+ # Normalize path (remove ../)
89
+ base = str(PurePosixPath(base))
90
+ else:
91
+ # Try path aliases first
92
+ base = None
93
+ if aliases:
94
+ for prefix, target_dir in aliases.items():
95
+ if import_name.startswith(prefix):
96
+ remainder = import_name[len(prefix):]
97
+ base = f"{target_dir}{remainder}"
98
+ break
99
+ if base is None:
100
+ # Bare specifier (node_modules) — skip
101
+ return None
102
+ candidates = [
103
+ f"{base}.ts", f"{base}.tsx", f"{base}.js", f"{base}.jsx",
104
+ f"{base}/index.ts", f"{base}/index.tsx",
105
+ f"{base}/index.js", f"{base}/index.jsx",
106
+ base, # exact match (e.g., .json, .css)
107
+ ]
108
+ else:
109
+ return None
110
+
111
+ for c in candidates:
112
+ # Normalize: strip leading ./ if any
113
+ c = c.lstrip("./")
114
+ if c in known_paths:
115
+ return c
116
+ return None
117
+
118
+
119
+ class FullIndexer:
120
+ """Full repository indexer.
121
+
122
+ Reads all files from a git tree at a specific ref, computes content hashes,
123
+ extracts symbols, and stores everything in content-addressed storage.
124
+
125
+ Content dedup: if a file's content has already been stored (from another
126
+ branch or previous indexing), the content, symbols, and embeddings are
127
+ reused automatically via SHA-256 keying.
128
+ """
129
+
130
+ def __init__(
131
+ self,
132
+ session: AsyncSession,
133
+ git_manager: GitRepoManager,
134
+ progress_callback: Callable[[dict], None] | None = None,
135
+ ) -> None:
136
+ self._session = session
137
+ self._git = git_manager
138
+ self._progress = progress_callback or (lambda _: None)
139
+
140
+ async def index(
141
+ self,
142
+ repo_id: str,
143
+ branch_id: uuid.UUID,
144
+ ref: str = "main",
145
+ ) -> dict:
146
+ """Perform a full index of a branch.
147
+
148
+ Returns stats dict: {files_indexed, symbols_found, skipped_existing,
149
+ errors, duration_ms}.
150
+ """
151
+ import time
152
+
153
+ from attocode_intel.storage.branch_overlay import BranchOverlay
154
+ from attocode_intel.storage.content_store import ContentStore
155
+ from attocode_intel.storage.dependency_store import DependencyStore
156
+ from attocode_intel.storage.symbol_store import SymbolStore
157
+
158
+ start = time.monotonic()
159
+ content_store = ContentStore(self._session)
160
+ symbol_store = SymbolStore(self._session)
161
+ dep_store = DependencyStore(self._session)
162
+ overlay = BranchOverlay(self._session)
163
+
164
+ stats = {
165
+ "files_indexed": 0,
166
+ "symbols_found": 0,
167
+ "references_found": 0,
168
+ "skipped_existing": 0,
169
+ "skipped_binary": 0,
170
+ "dependencies_found": 0,
171
+ "errors": 0,
172
+ }
173
+
174
+ # Get all files from git tree recursively
175
+ all_files = self._walk_tree(repo_id, ref)
176
+ total = len(all_files)
177
+ known_paths = {path for path, _ in all_files}
178
+
179
+ self._progress({"phase": "indexing", "total": total, "current": 0})
180
+
181
+ # Capture tsconfig/jsconfig contents for path alias resolution
182
+ config_contents: dict[str, bytes] = {}
183
+
184
+ # Batch overlay updates
185
+ overlay_updates: list[tuple[str, str, str]] = []
186
+ # Track path->sha for dependency resolution (second pass)
187
+ path_to_sha: dict[str, str] = {}
188
+ # Deferred import extraction: (path, sha, imports, language)
189
+ deferred_deps: list[tuple[str, str, list[str], str | None]] = []
190
+ # Deferred reference extraction: (sha, content, path)
191
+ deferred_refs: list[tuple[str, bytes, str]] = []
192
+
193
+ for i, (path, oid) in enumerate(all_files):
194
+ try:
195
+ # Skip binary/large files
196
+ ext = "." + path.rsplit(".", 1)[-1] if "." in path else ""
197
+ if ext.lower() in _SKIP_EXTENSIONS:
198
+ stats["skipped_binary"] += 1
199
+ continue
200
+
201
+ content = self._git.read_file(repo_id, ref, path)
202
+ if path.endswith(("tsconfig.json", "jsconfig.json")):
203
+ config_contents[path] = content
204
+ if len(content) > _MAX_FILE_SIZE:
205
+ stats["skipped_binary"] += 1
206
+ continue
207
+
208
+ # Content-addressed storage — dedup across branches
209
+ language = detect_language(path)
210
+ sha = await content_store.store(content, language)
211
+ overlay_updates.append((path, sha, "added"))
212
+ path_to_sha[path] = sha
213
+
214
+ # Extract symbols (skip if SHA already has symbols — cross-branch dedup)
215
+ symbols = extract_symbols(content, path)
216
+ if symbols:
217
+ count = await symbol_store.upsert_symbols(
218
+ sha, symbols, skip_if_exists=True
219
+ )
220
+ if count == 0:
221
+ stats["skipped_existing"] += 1
222
+ else:
223
+ stats["symbols_found"] += count
224
+
225
+ # Extract imports (defer resolution until all paths are known)
226
+ imports = extract_imports(content, path, language)
227
+ if imports:
228
+ deferred_deps.append((path, sha, imports, language))
229
+
230
+ # Defer reference extraction for third pass
231
+ if language in ("python", "javascript", "typescript"):
232
+ deferred_refs.append((sha, content, path))
233
+
234
+ stats["files_indexed"] += 1
235
+
236
+ if (i + 1) % 100 == 0:
237
+ self._progress({"phase": "indexing", "total": total, "current": i + 1})
238
+ # Flush batch overlay updates periodically
239
+ if overlay_updates:
240
+ await overlay.set_files_batch(branch_id, overlay_updates)
241
+ overlay_updates = []
242
+ await self._session.flush()
243
+
244
+ except Exception as e:
245
+ logger.warning("Error indexing %s: %s", path, e)
246
+ stats["errors"] += 1
247
+
248
+ # Final batch
249
+ if overlay_updates:
250
+ await overlay.set_files_batch(branch_id, overlay_updates)
251
+
252
+ # Resolve path aliases from tsconfig/jsconfig
253
+ aliases = _load_path_aliases(known_paths, config_contents)
254
+
255
+ # Second pass: resolve imports to dependencies
256
+ self._progress({"phase": "resolving_dependencies", "total": len(deferred_deps), "current": 0})
257
+ for idx, (source_path, source_sha, imports, language) in enumerate(deferred_deps):
258
+ try:
259
+ if await dep_store.has_dependencies(source_sha):
260
+ continue
261
+ deps: list[dict] = []
262
+ for imp in imports:
263
+ target_path = _resolve_import_path(imp, source_path, known_paths, language, aliases=aliases)
264
+ if target_path and target_path in path_to_sha:
265
+ deps.append({
266
+ "target_sha": path_to_sha[target_path],
267
+ "dep_type": "import",
268
+ "weight": 1.0,
269
+ })
270
+ if deps:
271
+ await dep_store.upsert_dependencies(source_sha, deps)
272
+ stats["dependencies_found"] += len(deps)
273
+ except Exception as e:
274
+ logger.warning("Error resolving deps for %s: %s", source_path, e)
275
+
276
+ # Third pass: extract symbol references (call sites)
277
+ self._progress({"phase": "extracting_references", "total": len(deferred_refs), "current": 0})
278
+ from attocode_intel.db.models import SymbolReference
279
+
280
+ refs_seen_shas: set[str] = set()
281
+ for idx, (sha, content, file_path) in enumerate(deferred_refs):
282
+ if sha in refs_seen_shas:
283
+ continue
284
+ refs_seen_shas.add(sha)
285
+ try:
286
+ refs = extract_references(content, file_path)
287
+ for r in refs:
288
+ self._session.add(SymbolReference(
289
+ content_sha=sha,
290
+ symbol_name=r["symbol_name"],
291
+ ref_kind=r["ref_kind"],
292
+ line=r["line"],
293
+ caller_qualified_name=r.get("caller_qualified_name", ""),
294
+ ))
295
+ stats["references_found"] += len(refs)
296
+ except Exception as e:
297
+ logger.warning("Error extracting refs for %s: %s", file_path, e)
298
+
299
+ await self._session.commit()
300
+
301
+ duration_ms = int((time.monotonic() - start) * 1000)
302
+ stats["duration_ms"] = duration_ms
303
+
304
+ self._progress({"phase": "completed", "stats": stats})
305
+ logger.info(
306
+ "Full index complete: %d files, %d symbols, %d refs, %d reused, %d errors in %dms",
307
+ stats["files_indexed"], stats["symbols_found"], stats["references_found"],
308
+ stats["skipped_existing"], stats["errors"], duration_ms,
309
+ )
310
+ return stats
311
+
312
+ def _walk_tree(self, repo_id: str, ref: str, path: str = "") -> list[tuple[str, str]]:
313
+ """Recursively walk the git tree and return (path, oid) pairs."""
314
+ entries = self._git.get_tree(repo_id, ref, path)
315
+ files: list[tuple[str, str]] = []
316
+
317
+ for entry in entries:
318
+ if entry.type == "blob":
319
+ files.append((entry.path, entry.oid))
320
+ elif entry.type == "tree":
321
+ files.extend(self._walk_tree(repo_id, ref, entry.path))
322
+
323
+ return files