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,820 @@
1
+ """Memory-mapped trigram inverted index for fast regex candidate pre-filtering.
2
+
3
+ Two-file mmap storage layout:
4
+
5
+ Lookup file (.attocode/index/trigrams.lookup):
6
+ Header : magic(u32) + version(u16) + entry_count(u32) + padding(u16) = 12 bytes
7
+ Entries: sorted by trigram_hash -- [trigram_hash:u32, postings_offset:u64,
8
+ postings_length:u32] = 16 bytes each
9
+
10
+ Postings file (.attocode/index/trigrams.postings):
11
+ Per posting list: [count:u32] + [file_id:u32] * count
12
+
13
+ File-ID mapping: SQLite table in .attocode/index/trigrams.db
14
+ trigram_files(file_id INTEGER PRIMARY KEY, path TEXT UNIQUE,
15
+ content_hash TEXT, mtime REAL)
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ import mmap
22
+ import os
23
+ import sqlite3
24
+ import struct
25
+ import threading
26
+ import time
27
+ import zlib
28
+ from dataclasses import dataclass, field
29
+ from pathlib import Path
30
+ from typing import Any, Literal, overload
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # QueryResult (explain mode)
36
+ # ---------------------------------------------------------------------------
37
+
38
+
39
+ @dataclass
40
+ class QueryResult:
41
+ """Diagnostics returned by ``TrigramIndex.query(explain=True)``."""
42
+
43
+ candidates: list[str] | None
44
+ trigram_literals: list[str]
45
+ posting_sizes: list[int]
46
+ total_files: int
47
+ candidate_count: int
48
+ selectivity: float
49
+ threshold: float
50
+ threshold_triggered: bool
51
+ mode: str # "trigram-filtered" | "full-scan-threshold" | "full-scan-no-trigrams" | "no-index"
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Constants
56
+ # ---------------------------------------------------------------------------
57
+
58
+ _MAGIC: int = 0x54524933 # "TRI3" as little-endian u32
59
+ _VERSION: int = 1
60
+ _HEADER_SIZE: int = 12 # magic(4) + version(2) + entry_count(4) + pad(2)
61
+ _ENTRY_SIZE: int = 16 # hash(4) + offset(8) + length(4)
62
+ _MAX_FILE_SIZE: int = 1_000_000 # Skip files larger than 1 MB
63
+ _SKIP_EXTENSIONS: frozenset[str] = frozenset({
64
+ ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".svg",
65
+ ".woff", ".woff2", ".ttf", ".eot",
66
+ ".pdf", ".zip", ".gz", ".tar", ".bz2", ".xz", ".7z",
67
+ ".pyc", ".pyo", ".so", ".dll", ".dylib", ".pyd",
68
+ ".db", ".sqlite", ".sqlite3",
69
+ ".bin", ".exe", ".o", ".a", ".obj",
70
+ ".mp3", ".mp4", ".wav", ".avi", ".mov", ".webm",
71
+ ".lock",
72
+ })
73
+ _SKIP_DIRS: frozenset[str] = frozenset({
74
+ ".git", ".hg", ".svn",
75
+ "node_modules", "__pycache__", ".venv", "venv", ".env",
76
+ ".tox", "dist", "build", ".eggs", ".mypy_cache",
77
+ ".pytest_cache", ".ruff_cache",
78
+ })
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Module-level helpers
83
+ # ---------------------------------------------------------------------------
84
+
85
+ def _trigram_hash(tri: bytes) -> int:
86
+ """CRC32 of a 3-byte sequence, masked to u32."""
87
+ return zlib.crc32(tri) & 0xFFFFFFFF
88
+
89
+
90
+ def _extract_trigrams_from_bytes(content: bytes) -> set[int]:
91
+ """Return the set of unique trigram hashes present in *content*.
92
+
93
+ Trigrams containing control characters (except \\t, \\n, \\r which appear
94
+ in normal source) are skipped -- this acts as a lightweight binary guard.
95
+ """
96
+ hashes: set[int] = set()
97
+ n = len(content)
98
+ for i in range(n - 2):
99
+ a, b, c = content[i], content[i + 1], content[i + 2]
100
+ # Allow: 0x09=\t, 0x0A=\n, 0x0D=\r; reject everything else < 0x20
101
+ if (
102
+ (a < 0x09 or (0x0E <= a <= 0x1F))
103
+ or (b < 0x09 or (0x0E <= b <= 0x1F))
104
+ or (c < 0x09 or (0x0E <= c <= 0x1F))
105
+ ):
106
+ continue
107
+ hashes.add(zlib.crc32(content[i : i + 3]) & 0xFFFFFFFF)
108
+ return hashes
109
+
110
+
111
+ def _is_likely_binary(data: bytes, sample_size: int = 8192) -> bool:
112
+ """Return True if *data* looks like binary content.
113
+
114
+ Checks the first *sample_size* bytes for control characters.
115
+ A ratio > 10% is a reliable indicator of binary data.
116
+ """
117
+ sample = data[:sample_size]
118
+ if not sample:
119
+ return False
120
+ control_count = sum(
121
+ 1 for b in sample
122
+ if b < 0x09 or (0x0E <= b <= 0x1F) or b == 0x7F
123
+ )
124
+ return control_count / len(sample) > 0.10
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # TrigramIndex
129
+ # ---------------------------------------------------------------------------
130
+
131
+ @dataclass(slots=True)
132
+ class TrigramIndex:
133
+ """Memory-mapped trigram inverted index for fast regex candidate filtering.
134
+
135
+ Usage::
136
+
137
+ idx = TrigramIndex(index_dir=".attocode/index")
138
+ if not idx.load():
139
+ stats = idx.build(project_dir=".")
140
+ candidates = idx.query("def grep_search")
141
+ # None -> caller falls back to full scan
142
+ # [] -> no files match (definitive fast exit)
143
+ # [str] -> relative paths of candidate files
144
+ """
145
+
146
+ index_dir: str
147
+
148
+ _db_path: str = field(default="", init=False, repr=False)
149
+ _lookup_path: str = field(default="", init=False, repr=False)
150
+ _postings_path: str = field(default="", init=False, repr=False)
151
+
152
+ _lookup_fd: int = field(default=-1, init=False, repr=False)
153
+ _postings_fd: int = field(default=-1, init=False, repr=False)
154
+ _lookup_mmap: mmap.mmap | None = field(default=None, init=False, repr=False)
155
+ _postings_mmap: mmap.mmap | None = field(default=None, init=False, repr=False)
156
+
157
+ _entry_count: int = field(default=0, init=False, repr=False)
158
+ _file_id_to_path: dict[int, str] = field(default_factory=dict, init=False, repr=False)
159
+ _path_to_file_id: dict[str, int] = field(default_factory=dict, init=False, repr=False)
160
+
161
+ _lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
162
+ _ready: bool = field(default=False, init=False, repr=False)
163
+
164
+ def __post_init__(self) -> None:
165
+ os.makedirs(self.index_dir, exist_ok=True)
166
+ self._db_path = os.path.join(self.index_dir, "trigrams.db")
167
+ self._lookup_path = os.path.join(self.index_dir, "trigrams.lookup")
168
+ self._postings_path = os.path.join(self.index_dir, "trigrams.postings")
169
+
170
+ # ------------------------------------------------------------------
171
+ # Public API
172
+ # ------------------------------------------------------------------
173
+
174
+ def build(self, project_dir: str) -> dict[str, Any]:
175
+ """Build the trigram index by walking *project_dir*.
176
+
177
+ Writes lookup and postings files atomically (write .tmp, os.replace).
178
+ Stores the file-ID mapping in SQLite.
179
+
180
+ Returns:
181
+ dict with keys: files_indexed, trigrams_count,
182
+ build_time_ms, index_size_bytes, loaded (bool).
183
+ """
184
+ t0 = time.monotonic()
185
+ project_path = Path(project_dir).resolve()
186
+
187
+ with self._lock:
188
+ # Phase 1: enumerate candidate files
189
+ file_paths = self._enumerate_files(project_path)
190
+
191
+ # Phase 2: build inverted index
192
+ file_map: dict[str, int] = {} # rel_path -> file_id
193
+ postings: dict[int, list[int]] = {} # trigram_hash -> [file_ids]
194
+ content_hashes: dict[str, str] = {}
195
+ mtimes: dict[str, float] = {}
196
+
197
+ for file_id, rel_path in enumerate(file_paths):
198
+ abs_path = project_path / rel_path
199
+ try:
200
+ raw = abs_path.read_bytes()
201
+ except OSError:
202
+ continue
203
+ if _is_likely_binary(raw):
204
+ continue
205
+
206
+ file_map[rel_path] = file_id
207
+ try:
208
+ mtimes[rel_path] = abs_path.stat().st_mtime
209
+ except OSError:
210
+ mtimes[rel_path] = 0.0
211
+ content_hashes[rel_path] = format(zlib.crc32(raw) & 0xFFFFFFFF, "08x")
212
+
213
+ for h in _extract_trigrams_from_bytes(raw):
214
+ if h not in postings:
215
+ postings[h] = []
216
+ postings[h].append(file_id)
217
+
218
+ # Phase 3: write index files atomically
219
+ self._write_index(postings, file_map)
220
+
221
+ # Phase 4: persist file map to SQLite
222
+ self._init_db()
223
+ self._save_file_map(file_map, content_hashes, mtimes)
224
+
225
+ # Phase 5: load the freshly built index into mmap
226
+ self._ready = False
227
+ self._close_mmap()
228
+ success = self._load_unlocked()
229
+
230
+ elapsed_ms = int((time.monotonic() - t0) * 1000)
231
+ lookup_size = (
232
+ os.path.getsize(self._lookup_path)
233
+ if os.path.exists(self._lookup_path)
234
+ else 0
235
+ )
236
+ postings_size = (
237
+ os.path.getsize(self._postings_path)
238
+ if os.path.exists(self._postings_path)
239
+ else 0
240
+ )
241
+ return {
242
+ "files_indexed": len(file_map),
243
+ "trigrams_count": len(postings),
244
+ "build_time_ms": elapsed_ms,
245
+ "index_size_bytes": lookup_size + postings_size,
246
+ "loaded": success,
247
+ }
248
+
249
+ def load(self) -> bool:
250
+ """Load an existing index from disk into memory-mapped buffers.
251
+
252
+ Returns True if the index was loaded and is ready for queries.
253
+ Returns False if files are missing, corrupt, or version-mismatched.
254
+ """
255
+ with self._lock:
256
+ self._ready = False
257
+ self._close_mmap()
258
+ return self._load_unlocked()
259
+
260
+ @overload
261
+ def query(
262
+ self,
263
+ pattern: str,
264
+ *,
265
+ case_insensitive: bool = False,
266
+ selectivity_threshold: float = 0.10,
267
+ explain: Literal[False] = False,
268
+ ) -> list[str] | None: ...
269
+
270
+ @overload
271
+ def query(
272
+ self,
273
+ pattern: str,
274
+ *,
275
+ case_insensitive: bool = False,
276
+ selectivity_threshold: float = 0.10,
277
+ explain: Literal[True],
278
+ ) -> QueryResult: ...
279
+
280
+ def query(
281
+ self,
282
+ pattern: str,
283
+ *,
284
+ case_insensitive: bool = False,
285
+ selectivity_threshold: float = 0.10,
286
+ explain: bool = False,
287
+ ) -> list[str] | None | QueryResult:
288
+ """Return candidate file paths that MAY contain a match for *pattern*.
289
+
290
+ Extracts required trigrams from the regex, intersects their posting
291
+ lists, and maps file-IDs back to relative paths.
292
+
293
+ Args:
294
+ pattern: Regex pattern to search for.
295
+ case_insensitive: Lowercase trigrams before matching.
296
+ selectivity_threshold: If the fraction of matching files exceeds
297
+ this value (0.0–1.0), the index is skipped and ``None`` is
298
+ returned so the caller falls back to a full scan. Default
299
+ is 0.10 (10%).
300
+ explain: When True, return a :class:`QueryResult` with full
301
+ diagnostics instead of a bare candidate list.
302
+
303
+ Returns (when *explain* is False):
304
+ None -- no trigrams extractable; caller must scan all files.
305
+ [] -- trigrams found but no file contains all of them.
306
+ [str] -- relative file paths that contain all required trigrams.
307
+ Returns (when *explain* is True):
308
+ :class:`QueryResult` with diagnostics.
309
+ """
310
+ if not self._ready:
311
+ if explain:
312
+ return QueryResult(
313
+ candidates=None, trigram_literals=[], posting_sizes=[],
314
+ total_files=0, candidate_count=0, selectivity=0.0,
315
+ threshold=selectivity_threshold, threshold_triggered=False,
316
+ mode="no-index",
317
+ )
318
+ return None
319
+
320
+ from attocode_intel._internal.integrations.context.trigram_regex import extract_required_trigrams
321
+
322
+ if explain:
323
+ result = extract_required_trigrams(
324
+ pattern, case_insensitive=case_insensitive, include_literals=True,
325
+ )
326
+ required, literals = result # type: ignore[misc]
327
+ else:
328
+ required = extract_required_trigrams(pattern, case_insensitive=case_insensitive)
329
+ literals = []
330
+
331
+ if not required:
332
+ if explain:
333
+ return QueryResult(
334
+ candidates=None, trigram_literals=[], posting_sizes=[],
335
+ total_files=len(self._file_id_to_path),
336
+ candidate_count=0, selectivity=0.0,
337
+ threshold=selectivity_threshold, threshold_triggered=False,
338
+ mode="full-scan-no-trigrams",
339
+ )
340
+ return None
341
+
342
+ with self._lock:
343
+ if not self._ready:
344
+ if explain:
345
+ return QueryResult(
346
+ candidates=None, trigram_literals=literals, posting_sizes=[],
347
+ total_files=0, candidate_count=0, selectivity=0.0,
348
+ threshold=selectivity_threshold, threshold_triggered=False,
349
+ mode="no-index",
350
+ )
351
+ return None
352
+
353
+ if explain:
354
+ raw, posting_sizes = self._intersect_postings(
355
+ required, collect_sizes=True,
356
+ )
357
+ else:
358
+ raw = self._intersect_postings(required)
359
+ posting_sizes = []
360
+
361
+ # Apply selectivity threshold.
362
+ # Only meaningful for large codebases — below 100 files,
363
+ # searching all candidates is trivially fast.
364
+ _MIN_FILES_FOR_THRESHOLD = 100
365
+ total_files = len(self._file_id_to_path)
366
+ candidates: list[str] | None = raw # type: ignore[assignment]
367
+ candidate_count = len(candidates) if candidates else 0
368
+ selectivity = (
369
+ candidate_count / total_files if total_files > 0 else 0.0
370
+ )
371
+ threshold_triggered = (
372
+ candidates is not None
373
+ and len(candidates) > 0
374
+ and total_files >= _MIN_FILES_FOR_THRESHOLD
375
+ and selectivity > selectivity_threshold
376
+ )
377
+
378
+ if threshold_triggered:
379
+ candidates = None # signal caller to fall back to full scan
380
+
381
+ if explain:
382
+ if threshold_triggered:
383
+ mode = "full-scan-threshold"
384
+ elif candidates is None:
385
+ mode = "full-scan-no-trigrams"
386
+ else:
387
+ mode = "trigram-filtered"
388
+ return QueryResult(
389
+ candidates=candidates,
390
+ trigram_literals=literals,
391
+ posting_sizes=posting_sizes,
392
+ total_files=total_files,
393
+ candidate_count=candidate_count,
394
+ selectivity=selectivity,
395
+ threshold=selectivity_threshold,
396
+ threshold_triggered=threshold_triggered,
397
+ mode=mode,
398
+ )
399
+
400
+ return candidates
401
+
402
+ def update_file(self, rel_path: str, content: bytes) -> None:
403
+ """Invalidate the index for *rel_path*.
404
+
405
+ Marks the index not-ready so callers fall back to brute force until
406
+ the next ``build()`` call rebuilds it.
407
+ """
408
+ with self._lock:
409
+ self._ready = False
410
+
411
+ def remove_file(self, rel_path: str) -> None:
412
+ """Remove *rel_path* from the in-memory ID mapping.
413
+
414
+ The mmap files are not modified; call ``build()`` for a full refresh.
415
+ """
416
+ with self._lock:
417
+ fid = self._path_to_file_id.pop(rel_path, None)
418
+ if fid is not None:
419
+ self._file_id_to_path.pop(fid, None)
420
+
421
+ def is_ready(self) -> bool:
422
+ """Return True if the index is loaded and ready for queries."""
423
+ return self._ready
424
+
425
+ def close(self) -> None:
426
+ """Unmap memory regions and close all file descriptors."""
427
+ with self._lock:
428
+ self._close_mmap()
429
+ self._ready = False
430
+
431
+ # ------------------------------------------------------------------
432
+ # Internal: file enumeration
433
+ # ------------------------------------------------------------------
434
+
435
+ @staticmethod
436
+ def _enumerate_files(project_path: Path) -> list[str]:
437
+ """Walk *project_path* and return relative paths of indexable files."""
438
+ result: list[str] = []
439
+ for root, dirs, files in os.walk(project_path):
440
+ dirs[:] = sorted(
441
+ d for d in dirs
442
+ if d not in _SKIP_DIRS and not d.startswith(".")
443
+ )
444
+ for fname in sorted(files):
445
+ fpath = Path(root) / fname
446
+ if fpath.suffix.lower() in _SKIP_EXTENSIONS:
447
+ continue
448
+ if fname.startswith("."):
449
+ continue
450
+ try:
451
+ if fpath.stat().st_size > _MAX_FILE_SIZE:
452
+ continue
453
+ except OSError:
454
+ continue
455
+ try:
456
+ rel = str(fpath.relative_to(project_path))
457
+ except ValueError:
458
+ rel = str(fpath)
459
+ result.append(rel)
460
+ return result
461
+
462
+ # ------------------------------------------------------------------
463
+ # Internal: index writing
464
+ # ------------------------------------------------------------------
465
+
466
+ def _write_index(
467
+ self,
468
+ postings: dict[int, list[int]],
469
+ file_map: dict[str, int],
470
+ ) -> None:
471
+ """Atomically write lookup and postings binary files."""
472
+ sorted_hashes = sorted(postings.keys())
473
+ n_entries = len(sorted_hashes)
474
+
475
+ # Build postings blob -- one posting list per unique trigram hash
476
+ postings_parts: list[bytes] = []
477
+ postings_offsets: dict[int, tuple[int, int]] = {}
478
+ current_offset = 0
479
+
480
+ for h in sorted_hashes:
481
+ file_ids = sorted(postings[h])
482
+ count = len(file_ids)
483
+ part = struct.pack(f"<I{count}I", count, *file_ids)
484
+ postings_offsets[h] = (current_offset, len(part))
485
+ postings_parts.append(part)
486
+ current_offset += len(part)
487
+
488
+ postings_blob = b"".join(postings_parts)
489
+
490
+ # Build lookup blob
491
+ # Header: magic(u32) + version(u16) + entry_count(u32) + padding(u16)
492
+ header = struct.pack("<IHIh", _MAGIC, _VERSION, n_entries, 0)
493
+ assert len(header) == _HEADER_SIZE
494
+
495
+ lookup_parts: list[bytes] = [header]
496
+ for h in sorted_hashes:
497
+ off, length = postings_offsets[h]
498
+ entry = struct.pack("<IQI", h, off, length)
499
+ assert len(entry) == _ENTRY_SIZE
500
+ lookup_parts.append(entry)
501
+
502
+ lookup_blob = b"".join(lookup_parts)
503
+
504
+ # Atomic write: write to .tmp, then os.replace
505
+ lookup_tmp = self._lookup_path + ".tmp"
506
+ postings_tmp = self._postings_path + ".tmp"
507
+ try:
508
+ with open(lookup_tmp, "wb") as f:
509
+ f.write(lookup_blob)
510
+ with open(postings_tmp, "wb") as f:
511
+ f.write(postings_blob)
512
+ os.replace(lookup_tmp, self._lookup_path)
513
+ os.replace(postings_tmp, self._postings_path)
514
+ except Exception:
515
+ for tmp in (lookup_tmp, postings_tmp):
516
+ try:
517
+ os.unlink(tmp)
518
+ except OSError:
519
+ pass
520
+ raise
521
+
522
+ # ------------------------------------------------------------------
523
+ # Internal: loading
524
+ # ------------------------------------------------------------------
525
+
526
+ def _load_unlocked(self) -> bool:
527
+ """Load index files. Caller must hold _lock."""
528
+ for path in (self._lookup_path, self._postings_path, self._db_path):
529
+ if not os.path.exists(path):
530
+ return False
531
+
532
+ # Load file-ID mapping from SQLite
533
+ try:
534
+ conn = sqlite3.connect(self._db_path, check_same_thread=False)
535
+ try:
536
+ conn.execute("PRAGMA journal_mode=WAL")
537
+ rows = conn.execute(
538
+ "SELECT file_id, path FROM trigram_files"
539
+ ).fetchall()
540
+ finally:
541
+ conn.close()
542
+ except sqlite3.Error as exc:
543
+ logger.debug("trigram_index: DB read failed: %s", exc)
544
+ return False
545
+
546
+ id_to_path: dict[int, str] = {}
547
+ path_to_id: dict[str, int] = {}
548
+ for fid, fpath in rows:
549
+ id_to_path[fid] = fpath
550
+ path_to_id[fpath] = fid
551
+
552
+ # mmap the lookup file
553
+ try:
554
+ lookup_fd = os.open(self._lookup_path, os.O_RDONLY)
555
+ lookup_size = os.path.getsize(self._lookup_path)
556
+ except OSError as exc:
557
+ logger.debug("trigram_index: cannot open lookup: %s", exc)
558
+ return False
559
+
560
+ if lookup_size < _HEADER_SIZE:
561
+ os.close(lookup_fd)
562
+ return False
563
+
564
+ try:
565
+ lookup_mm = mmap.mmap(lookup_fd, 0, access=mmap.ACCESS_READ)
566
+ except OSError as exc:
567
+ os.close(lookup_fd)
568
+ logger.debug("trigram_index: mmap lookup failed: %s", exc)
569
+ return False
570
+
571
+ # Validate header
572
+ try:
573
+ magic, version, entry_count, _pad = struct.unpack_from("<IHIh", lookup_mm, 0)
574
+ except struct.error as exc:
575
+ lookup_mm.close()
576
+ os.close(lookup_fd)
577
+ logger.debug("trigram_index: corrupt header: %s", exc)
578
+ return False
579
+
580
+ if magic != _MAGIC or version != _VERSION:
581
+ lookup_mm.close()
582
+ os.close(lookup_fd)
583
+ return False
584
+
585
+ expected_lookup_size = _HEADER_SIZE + entry_count * _ENTRY_SIZE
586
+ if lookup_size < expected_lookup_size:
587
+ lookup_mm.close()
588
+ os.close(lookup_fd)
589
+ return False
590
+
591
+ # mmap the postings file
592
+ postings_size = os.path.getsize(self._postings_path)
593
+ if postings_size == 0:
594
+ postings_fd = -1
595
+ postings_mm: mmap.mmap | None = None
596
+ else:
597
+ try:
598
+ postings_fd = os.open(self._postings_path, os.O_RDONLY)
599
+ postings_mm = mmap.mmap(postings_fd, 0, access=mmap.ACCESS_READ)
600
+ except OSError as exc:
601
+ lookup_mm.close()
602
+ os.close(lookup_fd)
603
+ logger.debug("trigram_index: mmap postings failed: %s", exc)
604
+ return False
605
+
606
+ # Commit
607
+ self._close_mmap()
608
+ self._lookup_fd = lookup_fd
609
+ self._postings_fd = postings_fd
610
+ self._lookup_mmap = lookup_mm
611
+ self._postings_mmap = postings_mm
612
+ self._entry_count = entry_count
613
+ self._file_id_to_path = id_to_path
614
+ self._path_to_file_id = path_to_id
615
+ self._ready = True
616
+
617
+ logger.debug(
618
+ "trigram_index: loaded -- %d entries, %d files",
619
+ entry_count, len(id_to_path),
620
+ )
621
+ return True
622
+
623
+ # ------------------------------------------------------------------
624
+ # Internal: query helpers
625
+ # ------------------------------------------------------------------
626
+
627
+ @overload
628
+ def _intersect_postings(
629
+ self,
630
+ required_hashes: list[int],
631
+ *,
632
+ collect_sizes: Literal[False] = False,
633
+ ) -> list[str] | None: ...
634
+
635
+ @overload
636
+ def _intersect_postings(
637
+ self,
638
+ required_hashes: list[int],
639
+ *,
640
+ collect_sizes: Literal[True],
641
+ ) -> tuple[list[str] | None, list[int]]: ...
642
+
643
+ def _intersect_postings(
644
+ self,
645
+ required_hashes: list[int],
646
+ *,
647
+ collect_sizes: bool = False,
648
+ ) -> list[str] | None | tuple[list[str] | None, list[int]]:
649
+ """Intersect posting lists for all required trigram hashes.
650
+
651
+ When *collect_sizes* is True, returns ``(candidates, posting_sizes)``
652
+ where *posting_sizes[i]* is the length of the posting list for
653
+ *required_hashes[i]*.
654
+ """
655
+ candidate_ids: set[int] | None = None
656
+ posting_sizes: list[int] = [] if collect_sizes else []
657
+
658
+ for h in required_hashes:
659
+ result = self._binary_search(h)
660
+ if result is None:
661
+ if collect_sizes:
662
+ posting_sizes.append(0)
663
+ # Fill remaining sizes for hashes not yet looked up
664
+ remaining = len(required_hashes) - len(posting_sizes)
665
+ posting_sizes.extend([0] * remaining)
666
+ return ([], posting_sizes)
667
+ return []
668
+ offset, length = result
669
+ file_ids = self._read_posting_list(offset, length)
670
+ if collect_sizes:
671
+ posting_sizes.append(len(file_ids))
672
+ if candidate_ids is None:
673
+ candidate_ids = set(file_ids)
674
+ else:
675
+ candidate_ids &= set(file_ids)
676
+ if not candidate_ids:
677
+ if collect_sizes:
678
+ # Fill remaining sizes with 0 for hashes not yet looked up
679
+ remaining = len(required_hashes) - len(posting_sizes)
680
+ posting_sizes.extend([0] * remaining)
681
+ return ([], posting_sizes)
682
+ return []
683
+
684
+ if candidate_ids is None:
685
+ return (None, posting_sizes) if collect_sizes else None
686
+
687
+ paths: list[str] = []
688
+ for fid in candidate_ids:
689
+ path = self._file_id_to_path.get(fid)
690
+ if path is not None:
691
+ paths.append(path)
692
+ return (paths, posting_sizes) if collect_sizes else paths
693
+
694
+ def _binary_search(self, trigram_hash: int) -> tuple[int, int] | None:
695
+ """Binary search the lookup mmap for *trigram_hash*.
696
+
697
+ Returns (postings_offset, postings_length) on hit, None on miss.
698
+ """
699
+ if self._lookup_mmap is None:
700
+ return None
701
+ lo, hi = 0, self._entry_count - 1
702
+ mm = self._lookup_mmap
703
+ while lo <= hi:
704
+ mid = (lo + hi) >> 1
705
+ offset = _HEADER_SIZE + mid * _ENTRY_SIZE
706
+ entry_hash = struct.unpack_from("<I", mm, offset)[0]
707
+ if entry_hash == trigram_hash:
708
+ postings_offset, postings_length = struct.unpack_from(
709
+ "<QI", mm, offset + 4
710
+ )
711
+ return (postings_offset, postings_length)
712
+ elif entry_hash < trigram_hash:
713
+ lo = mid + 1
714
+ else:
715
+ hi = mid - 1
716
+ return None
717
+
718
+ def _read_posting_list(self, offset: int, length: int) -> list[int]:
719
+ """Read and decode a posting list from the postings mmap."""
720
+ if self._postings_mmap is None:
721
+ return []
722
+ mm = self._postings_mmap
723
+ try:
724
+ raw = mm[offset : offset + length]
725
+ if len(raw) < 4:
726
+ return []
727
+ (count,) = struct.unpack_from("<I", raw, 0)
728
+ expected = 4 + count * 4
729
+ if len(raw) < expected:
730
+ return []
731
+ return list(struct.unpack_from(f"<{count}I", raw, 4))
732
+ except (struct.error, ValueError):
733
+ return []
734
+
735
+ # ------------------------------------------------------------------
736
+ # Internal: SQLite
737
+ # ------------------------------------------------------------------
738
+
739
+ def _init_db(self) -> None:
740
+ """Create the trigram_files table if it does not exist."""
741
+ conn = sqlite3.connect(self._db_path, check_same_thread=False)
742
+ try:
743
+ conn.execute("PRAGMA journal_mode=WAL")
744
+ conn.execute("PRAGMA synchronous=NORMAL")
745
+ conn.execute("""
746
+ CREATE TABLE IF NOT EXISTS trigram_files (
747
+ file_id INTEGER PRIMARY KEY,
748
+ path TEXT NOT NULL UNIQUE,
749
+ content_hash TEXT NOT NULL DEFAULT '',
750
+ mtime REAL NOT NULL DEFAULT 0
751
+ )
752
+ """)
753
+ conn.execute(
754
+ "CREATE INDEX IF NOT EXISTS ix_trigram_files_path "
755
+ "ON trigram_files(path)"
756
+ )
757
+ conn.commit()
758
+ finally:
759
+ conn.close()
760
+
761
+ def _save_file_map(
762
+ self,
763
+ file_map: dict[str, int],
764
+ content_hashes: dict[str, str],
765
+ mtimes: dict[str, float],
766
+ ) -> None:
767
+ """Persist the file-ID map to SQLite, replacing all previous rows."""
768
+ conn = sqlite3.connect(self._db_path, check_same_thread=False)
769
+ try:
770
+ conn.execute("PRAGMA journal_mode=WAL")
771
+ conn.execute("PRAGMA synchronous=NORMAL")
772
+ conn.execute("DELETE FROM trigram_files")
773
+ conn.executemany(
774
+ "INSERT INTO trigram_files (file_id, path, content_hash, mtime) "
775
+ "VALUES (?, ?, ?, ?)",
776
+ [
777
+ (fid, rp, content_hashes.get(rp, ""), mtimes.get(rp, 0.0))
778
+ for rp, fid in file_map.items()
779
+ ],
780
+ )
781
+ conn.commit()
782
+ finally:
783
+ conn.close()
784
+
785
+ # ------------------------------------------------------------------
786
+ # Internal: mmap lifecycle
787
+ # ------------------------------------------------------------------
788
+
789
+ def _close_mmap(self) -> None:
790
+ """Close mmaps and file descriptors. Caller must hold _lock."""
791
+ if self._lookup_mmap is not None:
792
+ try:
793
+ self._lookup_mmap.close()
794
+ except Exception:
795
+ pass
796
+ self._lookup_mmap = None
797
+ if self._postings_mmap is not None:
798
+ try:
799
+ self._postings_mmap.close()
800
+ except Exception:
801
+ pass
802
+ self._postings_mmap = None
803
+ if self._lookup_fd >= 0:
804
+ try:
805
+ os.close(self._lookup_fd)
806
+ except OSError:
807
+ pass
808
+ self._lookup_fd = -1
809
+ if self._postings_fd >= 0:
810
+ try:
811
+ os.close(self._postings_fd)
812
+ except OSError:
813
+ pass
814
+ self._postings_fd = -1
815
+
816
+ def __del__(self) -> None:
817
+ try:
818
+ self.close()
819
+ except Exception:
820
+ pass