devcouncil 0.3.1 → 0.4.0

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 (365) hide show
  1. package/README.md +46 -30
  2. package/package.json +6 -2
  3. package/packages/codeintel-grammars/hatch_build.py +43 -0
  4. package/packages/codeintel-grammars/pyproject.toml +16 -0
  5. package/packages/codeintel-grammars/src/devcouncil_codeintel_grammars/__init__.py +93 -0
  6. package/pyproject.toml +99 -4
  7. package/src/devcouncil/app/config.py +512 -20
  8. package/src/devcouncil/app/events.py +4 -23
  9. package/src/devcouncil/app/orchestrator.py +5 -0
  10. package/src/devcouncil/app/run_context.py +3 -3
  11. package/src/devcouncil/assets/__init__.py +4 -1
  12. package/src/devcouncil/assets/vendor/force-graph.min.js +5 -0
  13. package/src/devcouncil/campaign/__init__.py +71 -0
  14. package/src/devcouncil/campaign/bloom.py +137 -0
  15. package/src/devcouncil/campaign/dashboard.py +123 -0
  16. package/src/devcouncil/campaign/mailbox.py +305 -0
  17. package/src/devcouncil/campaign/notify.py +91 -0
  18. package/src/devcouncil/campaign/orchestrator.py +592 -0
  19. package/src/devcouncil/campaign/prompts/coordinator.md +29 -0
  20. package/src/devcouncil/campaign/prompts/director.md +21 -0
  21. package/src/devcouncil/campaign/prompts/protocol.md +46 -0
  22. package/src/devcouncil/campaign/prompts/reviewer.md +24 -0
  23. package/src/devcouncil/campaign/prompts/worker.md +24 -0
  24. package/src/devcouncil/campaign/roles.py +202 -0
  25. package/src/devcouncil/campaign/watcher.py +153 -0
  26. package/src/devcouncil/cli/commands/agents.py +24 -17
  27. package/src/devcouncil/cli/commands/artifacts.py +36 -27
  28. package/src/devcouncil/cli/commands/ast.py +12 -3
  29. package/src/devcouncil/cli/commands/baseline.py +21 -12
  30. package/src/devcouncil/cli/commands/boot.py +218 -0
  31. package/src/devcouncil/cli/commands/campaign.py +302 -0
  32. package/src/devcouncil/cli/commands/check.py +225 -12
  33. package/src/devcouncil/cli/commands/config.py +221 -74
  34. package/src/devcouncil/cli/commands/cost.py +137 -28
  35. package/src/devcouncil/cli/commands/dashboard.py +12 -4
  36. package/src/devcouncil/cli/commands/debug_cmd.py +249 -0
  37. package/src/devcouncil/cli/commands/design.py +27 -17
  38. package/src/devcouncil/cli/commands/doctor.py +790 -8
  39. package/src/devcouncil/cli/commands/evidence.py +41 -20
  40. package/src/devcouncil/cli/commands/export.py +73 -0
  41. package/src/devcouncil/cli/commands/gaps.py +175 -0
  42. package/src/devcouncil/cli/commands/gated_write.py +76 -0
  43. package/src/devcouncil/cli/commands/go.py +220 -68
  44. package/src/devcouncil/cli/commands/graph_cmd.py +1192 -0
  45. package/src/devcouncil/cli/commands/handoff.py +45 -34
  46. package/src/devcouncil/cli/commands/hook.py +630 -85
  47. package/src/devcouncil/cli/commands/init.py +89 -30
  48. package/src/devcouncil/cli/commands/integrate.py +296 -1385
  49. package/src/devcouncil/cli/commands/lease.py +120 -0
  50. package/src/devcouncil/cli/commands/logs.py +12 -5
  51. package/src/devcouncil/cli/commands/lsp.py +40 -5
  52. package/src/devcouncil/cli/commands/map.py +317 -74
  53. package/src/devcouncil/cli/commands/mcp_server.py +12 -2
  54. package/src/devcouncil/cli/commands/okf.py +44 -6
  55. package/src/devcouncil/cli/commands/plan.py +184 -69
  56. package/src/devcouncil/cli/commands/prompt.py +26 -17
  57. package/src/devcouncil/cli/commands/provenance.py +79 -0
  58. package/src/devcouncil/cli/commands/repair.py +60 -49
  59. package/src/devcouncil/cli/commands/report.py +148 -40
  60. package/src/devcouncil/cli/commands/requirements.py +104 -0
  61. package/src/devcouncil/cli/commands/reset_demo_state.py +13 -4
  62. package/src/devcouncil/cli/commands/rollback.py +46 -35
  63. package/src/devcouncil/cli/commands/run.py +173 -8
  64. package/src/devcouncil/cli/commands/runs.py +298 -68
  65. package/src/devcouncil/cli/commands/scaffold.py +33 -12
  66. package/src/devcouncil/cli/commands/semantic.py +29 -14
  67. package/src/devcouncil/cli/commands/setup.py +103 -93
  68. package/src/devcouncil/cli/commands/shell.py +51 -42
  69. package/src/devcouncil/cli/commands/show.py +56 -42
  70. package/src/devcouncil/cli/commands/skills.py +29 -20
  71. package/src/devcouncil/cli/commands/status.py +80 -67
  72. package/src/devcouncil/cli/commands/task_gate.py +295 -0
  73. package/src/devcouncil/cli/commands/tasks.py +248 -19
  74. package/src/devcouncil/cli/commands/trace.py +14 -8
  75. package/src/devcouncil/cli/commands/verify.py +33 -8
  76. package/src/devcouncil/cli/commands/version.py +14 -6
  77. package/src/devcouncil/cli/commands/watch.py +49 -30
  78. package/src/devcouncil/cli/commands/watch_fs.py +30 -19
  79. package/src/devcouncil/cli/commands/wiki.py +278 -0
  80. package/src/devcouncil/cli/main.py +58 -1
  81. package/src/devcouncil/codeintel/__init__.py +16 -0
  82. package/src/devcouncil/codeintel/build_control.py +429 -0
  83. package/src/devcouncil/codeintel/build_worker.py +78 -0
  84. package/src/devcouncil/codeintel/debug/__init__.py +17 -0
  85. package/src/devcouncil/codeintel/debug/broker.py +114 -0
  86. package/src/devcouncil/codeintel/debug/broker_client.py +61 -0
  87. package/src/devcouncil/codeintel/debug/consent.py +36 -0
  88. package/src/devcouncil/codeintel/debug/discovery.py +132 -0
  89. package/src/devcouncil/codeintel/debug/fingerprint.py +85 -0
  90. package/src/devcouncil/codeintel/debug/protocol.py +259 -0
  91. package/src/devcouncil/codeintel/debug/python_trace_runner.py +81 -0
  92. package/src/devcouncil/codeintel/debug/session.py +238 -0
  93. package/src/devcouncil/codeintel/debug/tracing.py +201 -0
  94. package/src/devcouncil/codeintel/languages/__init__.py +17 -0
  95. package/src/devcouncil/codeintel/languages/generic_extractor.py +236 -0
  96. package/src/devcouncil/codeintel/languages/registry.py +149 -0
  97. package/src/devcouncil/codeintel/languages/workers.py +245 -0
  98. package/src/devcouncil/codeintel/query/__init__.py +5 -0
  99. package/src/devcouncil/codeintel/query/engine.py +289 -0
  100. package/src/devcouncil/codeintel/resolution/__init__.py +6 -0
  101. package/src/devcouncil/codeintel/resolution/abstract_state.py +301 -0
  102. package/src/devcouncil/codeintel/resolution/frameworks/__init__.py +33 -0
  103. package/src/devcouncil/codeintel/resolution/frameworks/base.py +46 -0
  104. package/src/devcouncil/codeintel/resolution/frameworks/di.py +56 -0
  105. package/src/devcouncil/codeintel/resolution/frameworks/events.py +45 -0
  106. package/src/devcouncil/codeintel/resolution/frameworks/routes.py +88 -0
  107. package/src/devcouncil/codeintel/resolution/semantic.py +887 -0
  108. package/src/devcouncil/codeintel/service.py +104 -0
  109. package/src/devcouncil/codeintel/store/__init__.py +15 -0
  110. package/src/devcouncil/codeintel/store/sqlite.py +1565 -0
  111. package/src/devcouncil/codeintel/sync/__init__.py +19 -0
  112. package/src/devcouncil/codeintel/sync/coordinator.py +430 -0
  113. package/src/devcouncil/codeintel/sync/incremental.py +484 -0
  114. package/src/devcouncil/codeintel/sync/lease.py +96 -0
  115. package/src/devcouncil/codeintel/sync/scope.py +98 -0
  116. package/src/devcouncil/council/__init__.py +4 -0
  117. package/src/devcouncil/council/prompts/__init__.py +4 -0
  118. package/src/devcouncil/domain/checkpoint_refs.py +17 -0
  119. package/src/devcouncil/domain/evidence.py +1 -0
  120. package/src/devcouncil/domain/gap.py +10 -0
  121. package/src/devcouncil/domain/requirement.py +5 -1
  122. package/src/devcouncil/domain/task.py +43 -2
  123. package/src/devcouncil/execution/checkpoints.py +25 -31
  124. package/src/devcouncil/execution/context_builder.py +15 -44
  125. package/src/devcouncil/execution/fs_watcher.py +64 -0
  126. package/src/devcouncil/execution/gated_write.py +203 -0
  127. package/src/devcouncil/execution/handoff.py +2 -1
  128. package/src/devcouncil/execution/hook_policy.py +19 -5
  129. package/src/devcouncil/execution/lease_ops.py +177 -0
  130. package/src/devcouncil/execution/lease_validation.py +71 -0
  131. package/src/devcouncil/execution/patch.py +3 -0
  132. package/src/devcouncil/execution/permissions.py +1 -0
  133. package/src/devcouncil/execution/policy_engine.py +205 -10
  134. package/src/devcouncil/execution/prompt_builder.py +278 -33
  135. package/src/devcouncil/execution/run_trace.py +356 -0
  136. package/src/devcouncil/execution/shell_session.py +46 -5
  137. package/src/devcouncil/execution/stop_gate.py +746 -0
  138. package/src/devcouncil/execution/stop_gate_history.py +113 -0
  139. package/src/devcouncil/execution/stop_gate_state.py +54 -0
  140. package/src/devcouncil/execution/stop_gate_verify_cache.py +69 -0
  141. package/src/devcouncil/execution/task_gate_ops.py +590 -0
  142. package/src/devcouncil/execution/task_runner.py +19 -0
  143. package/src/devcouncil/executors/advisor_tool.py +315 -0
  144. package/src/devcouncil/executors/agent_registry.py +125 -17
  145. package/src/devcouncil/executors/claude_sdk.py +376 -0
  146. package/src/devcouncil/executors/coding_cli.py +724 -25
  147. package/src/devcouncil/executors/mini_swe.py +50 -8
  148. package/src/devcouncil/executors/native/agent.py +224 -19
  149. package/src/devcouncil/executors/openhands.py +50 -8
  150. package/src/devcouncil/executors/transient_retry.py +99 -0
  151. package/src/devcouncil/gating/checks/clean_git.py +5 -2
  152. package/src/devcouncil/gating/checks/planned_files_check.py +38 -11
  153. package/src/devcouncil/gating/checks/secret_scan_check.py +2 -2
  154. package/src/devcouncil/gating/policy.py +46 -2
  155. package/src/devcouncil/indexing/ast_matcher.py +41 -4
  156. package/src/devcouncil/indexing/graph/__init__.py +78 -0
  157. package/src/devcouncil/indexing/graph/api_routes.py +522 -0
  158. package/src/devcouncil/indexing/graph/build.py +862 -0
  159. package/src/devcouncil/indexing/graph/cache.py +329 -0
  160. package/src/devcouncil/indexing/graph/communities.py +28 -0
  161. package/src/devcouncil/indexing/graph/cypher.py +107 -0
  162. package/src/devcouncil/indexing/graph/embeddings.py +194 -0
  163. package/src/devcouncil/indexing/graph/export.py +381 -0
  164. package/src/devcouncil/indexing/graph/export_links.py +81 -0
  165. package/src/devcouncil/indexing/graph/extract_python.py +307 -0
  166. package/src/devcouncil/indexing/graph/extract_ts.py +1205 -0
  167. package/src/devcouncil/indexing/graph/intel.py +668 -0
  168. package/src/devcouncil/indexing/graph/liveness.py +992 -0
  169. package/src/devcouncil/indexing/graph/okf_export.py +65 -0
  170. package/src/devcouncil/indexing/graph/pdg/__init__.py +67 -0
  171. package/src/devcouncil/indexing/graph/pdg/build.py +11 -0
  172. package/src/devcouncil/indexing/graph/pdg/cdg.py +41 -0
  173. package/src/devcouncil/indexing/graph/pdg/cfg.py +199 -0
  174. package/src/devcouncil/indexing/graph/pdg/query.py +21 -0
  175. package/src/devcouncil/indexing/graph/pdg/reaching_def.py +126 -0
  176. package/src/devcouncil/indexing/graph/pdg/schema.py +253 -0
  177. package/src/devcouncil/indexing/graph/pdg/taint.py +154 -0
  178. package/src/devcouncil/indexing/graph/query.py +302 -0
  179. package/src/devcouncil/indexing/graph/resolve.py +1020 -0
  180. package/src/devcouncil/indexing/graph/schema.py +103 -0
  181. package/src/devcouncil/indexing/graph_index.py +20 -29
  182. package/src/devcouncil/indexing/lsp.py +57 -25
  183. package/src/devcouncil/indexing/lsp_client.py +577 -0
  184. package/src/devcouncil/indexing/map_artifacts.py +355 -0
  185. package/src/devcouncil/indexing/map_refresh.py +141 -0
  186. package/src/devcouncil/indexing/repo_mapper.py +1509 -138
  187. package/src/devcouncil/indexing/semantic_index.py +12 -6
  188. package/src/devcouncil/indexing/subsystem_map.py +163 -0
  189. package/src/devcouncil/indexing/ts_imports.py +343 -0
  190. package/src/devcouncil/indexing/viz.py +960 -0
  191. package/src/devcouncil/indexing/walk.py +52 -0
  192. package/src/devcouncil/indexing/wiring.py +1776 -0
  193. package/src/devcouncil/integrations/actions.py +27 -4
  194. package/src/devcouncil/integrations/check.py +211 -16
  195. package/src/devcouncil/integrations/claude_assets.py +209 -12
  196. package/src/devcouncil/integrations/clients/__init__.py +1 -0
  197. package/src/devcouncil/integrations/clients/aider.py +52 -0
  198. package/src/devcouncil/integrations/clients/antigravity.py +87 -0
  199. package/src/devcouncil/integrations/clients/claude.py +339 -0
  200. package/src/devcouncil/integrations/clients/codex.py +39 -0
  201. package/src/devcouncil/integrations/clients/common.py +332 -0
  202. package/src/devcouncil/integrations/clients/cursor.py +164 -0
  203. package/src/devcouncil/integrations/clients/gemini.py +49 -0
  204. package/src/devcouncil/integrations/clients/grok.py +105 -0
  205. package/src/devcouncil/integrations/clients/hooks.py +500 -0
  206. package/src/devcouncil/integrations/clients/opencode.py +96 -0
  207. package/src/devcouncil/integrations/clients/warp.py +75 -0
  208. package/src/devcouncil/integrations/code_review_graph.py +2 -2
  209. package/src/devcouncil/integrations/github.py +73 -7
  210. package/src/devcouncil/integrations/integration_cli.py +197 -0
  211. package/src/devcouncil/integrations/mcp/handlers/__init__.py +1 -0
  212. package/src/devcouncil/integrations/mcp/handlers/ast_lsp.py +77 -0
  213. package/src/devcouncil/integrations/mcp/handlers/checkout.py +50 -0
  214. package/src/devcouncil/integrations/mcp/handlers/cli_gate.py +43 -0
  215. package/src/devcouncil/integrations/mcp/handlers/codeintel.py +182 -0
  216. package/src/devcouncil/integrations/mcp/handlers/debug.py +236 -0
  217. package/src/devcouncil/integrations/mcp/handlers/evidence.py +70 -0
  218. package/src/devcouncil/integrations/mcp/handlers/git.py +99 -0
  219. package/src/devcouncil/integrations/mcp/handlers/graph.py +36 -0
  220. package/src/devcouncil/integrations/mcp/handlers/handoff.py +53 -0
  221. package/src/devcouncil/integrations/mcp/handlers/knowledge.py +30 -0
  222. package/src/devcouncil/integrations/mcp/handlers/lease.py +70 -0
  223. package/src/devcouncil/integrations/mcp/handlers/live.py +108 -0
  224. package/src/devcouncil/integrations/mcp/handlers/map.py +676 -0
  225. package/src/devcouncil/integrations/mcp/handlers/next_task.py +35 -0
  226. package/src/devcouncil/integrations/mcp/handlers/policy.py +80 -0
  227. package/src/devcouncil/integrations/mcp/handlers/prompts.py +168 -0
  228. package/src/devcouncil/integrations/mcp/handlers/provenance.py +101 -0
  229. package/src/devcouncil/integrations/mcp/handlers/read.py +74 -0
  230. package/src/devcouncil/integrations/mcp/handlers/router_cache.py +53 -0
  231. package/src/devcouncil/integrations/mcp/handlers/run.py +53 -0
  232. package/src/devcouncil/integrations/mcp/handlers/runs.py +84 -0
  233. package/src/devcouncil/integrations/mcp/handlers/scope.py +56 -0
  234. package/src/devcouncil/integrations/mcp/handlers/status.py +114 -0
  235. package/src/devcouncil/integrations/mcp/handlers/task.py +100 -0
  236. package/src/devcouncil/integrations/mcp/handlers/tool_specs.py +904 -0
  237. package/src/devcouncil/integrations/mcp/handlers/trace.py +65 -0
  238. package/src/devcouncil/integrations/mcp/handlers/verify.py +45 -0
  239. package/src/devcouncil/integrations/mcp/handlers/wiki.py +60 -0
  240. package/src/devcouncil/integrations/mcp/handlers/write.py +69 -0
  241. package/src/devcouncil/integrations/mcp/server.py +265 -2391
  242. package/src/devcouncil/integrations/mcp/util.py +303 -0
  243. package/src/devcouncil/integrations/setup.py +152 -0
  244. package/src/devcouncil/knowledge/fetch.py +4 -0
  245. package/src/devcouncil/knowledge/knowledge_select.py +38 -0
  246. package/src/devcouncil/knowledge/okf.py +2 -1
  247. package/src/devcouncil/knowledge/resource_discovery.py +40 -0
  248. package/src/devcouncil/knowledge/wiki.py +643 -0
  249. package/src/devcouncil/knowledge/wiki_read.py +87 -0
  250. package/src/devcouncil/live/cards.py +7 -7
  251. package/src/devcouncil/live/models.py +4 -1
  252. package/src/devcouncil/live/reviewer.py +90 -11
  253. package/src/devcouncil/live/signals.py +4 -2
  254. package/src/devcouncil/live/tasks.py +12 -3
  255. package/src/devcouncil/live/transcripts.py +69 -2
  256. package/src/devcouncil/llm/cache.py +5 -6
  257. package/src/devcouncil/llm/model_defaults.yaml +10 -10
  258. package/src/devcouncil/llm/provider.py +647 -73
  259. package/src/devcouncil/llm/router.py +271 -46
  260. package/src/devcouncil/llm/semantic_bridge.py +614 -0
  261. package/src/devcouncil/optimization/gepa_agent.py +6 -4
  262. package/src/devcouncil/optimization/skillopt.py +9 -5
  263. package/src/devcouncil/planning/arbiter_service.py +12 -3
  264. package/src/devcouncil/planning/correction_manifest.py +107 -10
  265. package/src/devcouncil/planning/plan_difficulty.py +69 -0
  266. package/src/devcouncil/planning/plan_service.py +5 -2
  267. package/src/devcouncil/planning/planned_files_reconcile.py +191 -0
  268. package/src/devcouncil/planning/prompt_enhancer_service.py +6 -5
  269. package/src/devcouncil/planning/question_conversion.py +56 -0
  270. package/src/devcouncil/planning/spec_service.py +9 -3
  271. package/src/devcouncil/repo/ci_scaffold.py +197 -1
  272. package/src/devcouncil/repo/gitignore.py +1 -2
  273. package/src/devcouncil/reporting/evidence_export.py +124 -0
  274. package/src/devcouncil/reporting/evidence_html.py +210 -0
  275. package/src/devcouncil/reporting/json_report.py +16 -12
  276. package/src/devcouncil/reporting/markdown_report.py +38 -9
  277. package/src/devcouncil/reporting/mcp_resources.py +142 -0
  278. package/src/devcouncil/reporting/report_builder.py +40 -4
  279. package/src/devcouncil/reporting/task_provenance.py +42 -0
  280. package/src/devcouncil/reporting/verdict.py +75 -0
  281. package/src/devcouncil/skills/library/README.md +1 -0
  282. package/src/devcouncil/skills/library/devcouncil-hero-loop.md +109 -0
  283. package/src/devcouncil/skills/library/devcouncil-verification.md +109 -0
  284. package/src/devcouncil/skills/library/devcouncil.md +93 -0
  285. package/src/devcouncil/skills/registry.py +43 -12
  286. package/src/devcouncil/storage/db.py +57 -11
  287. package/src/devcouncil/storage/models.py +6 -0
  288. package/src/devcouncil/storage/native.py +5 -3
  289. package/src/devcouncil/storage/repositories.py +50 -18
  290. package/src/devcouncil/telemetry/context.py +28 -0
  291. package/src/devcouncil/telemetry/cost.py +4 -5
  292. package/src/devcouncil/telemetry/logging_setup.py +78 -11
  293. package/src/devcouncil/telemetry/model_pricing.yaml +7 -0
  294. package/src/devcouncil/telemetry/stages.py +27 -2
  295. package/src/devcouncil/telemetry/tracker.py +50 -13
  296. package/src/devcouncil/ui/dashboard.py +120 -8
  297. package/src/devcouncil/utils/fsio.py +58 -0
  298. package/src/devcouncil/utils/git_snapshot.py +112 -0
  299. package/src/devcouncil/utils/json_persist.py +53 -0
  300. package/src/devcouncil/utils/proc.py +89 -0
  301. package/src/devcouncil/verification/acceptance_compiler.py +36 -13
  302. package/src/devcouncil/verification/ad_hoc_check.py +95 -3
  303. package/src/devcouncil/verification/checks/__init__.py +41 -0
  304. package/src/devcouncil/verification/checks/acceptance.py +39 -0
  305. package/src/devcouncil/verification/checks/acceptance_corpus.py +194 -0
  306. package/src/devcouncil/verification/checks/acceptance_evidence.py +239 -0
  307. package/src/devcouncil/verification/checks/command_evidence.py +148 -0
  308. package/src/devcouncil/verification/checks/compiled_acceptance.py +179 -0
  309. package/src/devcouncil/verification/checks/corpus_stale.py +124 -0
  310. package/src/devcouncil/verification/checks/corpus_verification.py +9 -0
  311. package/src/devcouncil/verification/checks/dead_symbols.py +360 -0
  312. package/src/devcouncil/verification/checks/diff_coverage_gate.py +101 -0
  313. package/src/devcouncil/verification/checks/doc_code_ref.py +79 -0
  314. package/src/devcouncil/verification/checks/liveness_ratchet.py +336 -0
  315. package/src/devcouncil/verification/checks/orphan_diff.py +104 -0
  316. package/src/devcouncil/verification/checks/planned_files.py +98 -0
  317. package/src/devcouncil/verification/checks/semantic_diff.py +241 -0
  318. package/src/devcouncil/verification/checks/stale_map.py +80 -0
  319. package/src/devcouncil/verification/checks/stub_scan.py +71 -0
  320. package/src/devcouncil/verification/checks/subsystem_boundary.py +103 -0
  321. package/src/devcouncil/verification/checks/wiring.py +216 -0
  322. package/src/devcouncil/verification/claims/__init__.py +23 -0
  323. package/src/devcouncil/verification/claims/checks.py +395 -0
  324. package/src/devcouncil/verification/claims/mapper.py +168 -0
  325. package/src/devcouncil/verification/claims/models.py +39 -0
  326. package/src/devcouncil/verification/claims/transcript.py +92 -0
  327. package/src/devcouncil/verification/claims/verdict.py +88 -0
  328. package/src/devcouncil/verification/command_evidence.py +170 -0
  329. package/src/devcouncil/verification/command_malformation.py +147 -0
  330. package/src/devcouncil/verification/command_runner.py +164 -0
  331. package/src/devcouncil/verification/coverage_measurement.py +292 -0
  332. package/src/devcouncil/verification/diff_coverage.py +151 -0
  333. package/src/devcouncil/verification/difficulty.py +296 -0
  334. package/src/devcouncil/verification/effort_heuristics.py +178 -0
  335. package/src/devcouncil/verification/gap_ids.py +63 -0
  336. package/src/devcouncil/verification/gate_cache.py +194 -0
  337. package/src/devcouncil/verification/gate_selector.py +344 -0
  338. package/src/devcouncil/verification/git_diff_fallback.py +272 -0
  339. package/src/devcouncil/verification/implementation_reviewer.py +13 -0
  340. package/src/devcouncil/verification/incremental_check.py +241 -0
  341. package/src/devcouncil/verification/next_actions.py +60 -1
  342. package/src/devcouncil/verification/rigor_analytics.py +130 -0
  343. package/src/devcouncil/verification/sandbox.py +38 -11
  344. package/src/devcouncil/verification/stub_detector.py +369 -0
  345. package/src/devcouncil/verification/test_resolver.py +67 -1
  346. package/src/devcouncil/verification/verifier.py +137 -1666
  347. package/src/devcouncil/verification/verify_orchestration.py +610 -0
  348. package/src/devcouncil/verification/verify_setup.py +176 -0
  349. package/src/devcouncil/verification/wiki_refresh.py +208 -0
  350. package/src/semantic_layer/__init__.py +58 -0
  351. package/src/semantic_layer/benchmark.py +75 -0
  352. package/src/semantic_layer/cache.py +290 -0
  353. package/src/semantic_layer/compressor.py +137 -0
  354. package/src/semantic_layer/config.py +75 -0
  355. package/src/semantic_layer/embeddings.py +69 -0
  356. package/src/semantic_layer/llm_backends.py +99 -0
  357. package/src/semantic_layer/pipeline.py +111 -0
  358. package/src/semantic_layer/router.py +128 -0
  359. package/src/semantic_layer/tuner.py +72 -0
  360. package/uv.lock +973 -9
  361. package/src/devcouncil/artifacts/migrations.py +0 -20
  362. package/src/devcouncil/artifacts/schemas.py +0 -23
  363. package/src/devcouncil/artifacts/serializer.py +0 -21
  364. package/src/devcouncil/integrations/gitnexus.py +0 -70
  365. package/src/devcouncil/integrations/graphify.py +0 -34
@@ -0,0 +1,1565 @@
1
+ """Versioned SQLite graph store with atomic committed generations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import logging
8
+ import sqlite3
9
+ import threading
10
+ import time
11
+ import uuid
12
+ import zlib
13
+ from contextlib import contextmanager
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Any, Iterator, Sequence
17
+
18
+ from devcouncil.indexing.graph.schema import (
19
+ CodeGraph,
20
+ Confidence,
21
+ DeadCodeEntry,
22
+ GraphEdge,
23
+ GraphNode,
24
+ NodeKind,
25
+ )
26
+
27
+ STORE_SCHEMA_VERSION = 2
28
+ ANALYZER_VERSION = "codeintel-1"
29
+ INDEX_REL = Path(".devcouncil") / "codeintel" / "index.sqlite"
30
+ EXTRACTION_CACHE_MAX_BYTES = 64 * 1024 * 1024
31
+ AMBIGUOUS_EVIDENCE_LIMIT = 8
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ # Error texts that mean the database file itself is damaged. Lock/busy
36
+ # conditions (OperationalError) are transient and must never quarantine.
37
+ _CORRUPTION_MARKERS = ("malformed", "not a database", "database disk image")
38
+
39
+
40
+ def _corruption_error(exc: sqlite3.DatabaseError) -> bool:
41
+ text = str(exc).lower()
42
+ if any(marker in text for marker in _CORRUPTION_MARKERS):
43
+ return True
44
+ if isinstance(exc, sqlite3.OperationalError):
45
+ return False
46
+ return False
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class StoreStatus:
51
+ project_root: str
52
+ database: str
53
+ schema_version: int
54
+ generation: int | None
55
+ state: str
56
+ node_count: int = 0
57
+ edge_count: int = 0
58
+ created_at: float | None = None
59
+ analyzer_version: str = ANALYZER_VERSION
60
+
61
+ def as_dict(self) -> dict[str, Any]:
62
+ return {
63
+ "project_root": self.project_root,
64
+ "database": self.database,
65
+ "schema_version": self.schema_version,
66
+ "generation": self.generation,
67
+ "state": self.state,
68
+ "node_count": self.node_count,
69
+ "edge_count": self.edge_count,
70
+ "created_at": self.created_at,
71
+ "analyzer_version": self.analyzer_version,
72
+ }
73
+
74
+
75
+ def _json(value: Any) -> str:
76
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), default=str)
77
+
78
+
79
+ def compatibility_graph_digest(graph: CodeGraph) -> str:
80
+ """Digest the public graph payload before store-only normalization."""
81
+ payload = graph.model_dump(mode="json")
82
+ return hashlib.sha256(_json(payload).encode("utf-8")).hexdigest()
83
+
84
+
85
+ def _confidence_score(edge: GraphEdge) -> float:
86
+ raw = edge.extras.get("confidence_score")
87
+ if isinstance(raw, (int, float)):
88
+ return max(0.0, min(1.0, float(raw)))
89
+ confidence = edge.confidence.value if hasattr(edge.confidence, "value") else str(edge.confidence)
90
+ return {"extracted": 1.0, "inferred": 0.7, "ambiguous": 0.4}.get(confidence, 0.4)
91
+
92
+
93
+ def _provenance(edge: GraphEdge) -> str:
94
+ raw = edge.extras.get("provenance")
95
+ if isinstance(raw, str) and raw in {"extracted", "framework", "inferred", "runtime", "user"}:
96
+ return raw
97
+ confidence = edge.confidence.value if hasattr(edge.confidence, "value") else str(edge.confidence)
98
+ return "extracted" if confidence == "extracted" else "inferred"
99
+
100
+
101
+ def _canonicalize_duplicate_ids(graph: CodeGraph) -> CodeGraph:
102
+ """Give repeated legacy symbol ids explicit structural identities.
103
+
104
+ Graph v2 historically identified a symbol by ``path::qualname``. That is
105
+ ambiguous for repeated declarations (including test redefinitions and
106
+ generated properties), while the transactional store requires one stable
107
+ row per identity. Preserve the first compatibility id and attach a
108
+ line/kind discriminator plus an alias edge to later declarations.
109
+ """
110
+ grouped: dict[str, list[GraphNode]] = {}
111
+ for node in graph.nodes:
112
+ grouped.setdefault(node.id, []).append(node)
113
+ duplicates = {node_id: nodes for node_id, nodes in grouped.items() if len(nodes) > 1}
114
+ if not duplicates:
115
+ return graph
116
+
117
+ normalized = graph.model_copy(deep=True)
118
+ normalized_grouped: dict[str, list[GraphNode]] = {}
119
+ for node in normalized.nodes:
120
+ normalized_grouped.setdefault(node.id, []).append(node)
121
+
122
+ aliases: list[dict[str, object]] = []
123
+ assigned: set[str] = {node.id for node in normalized.nodes}
124
+ replacement_by_location: dict[tuple[str, int, str], str] = {}
125
+ for old_id, nodes in normalized_grouped.items():
126
+ if len(nodes) < 2:
127
+ continue
128
+ for ordinal, node in enumerate(nodes[1:], start=2):
129
+ kind = node.kind.value if hasattr(node.kind, "value") else str(node.kind)
130
+ discriminator = f"L{node.line}:{kind}"
131
+ candidate = f"{old_id}#{discriminator}"
132
+ suffix = ordinal
133
+ while candidate in assigned:
134
+ candidate = f"{old_id}#{discriminator}:{suffix}"
135
+ suffix += 1
136
+ assigned.add(candidate)
137
+ node.id = candidate
138
+ node.extras["identity_alias"] = old_id
139
+ node.extras["structural_discriminator"] = discriminator
140
+ replacement_by_location[(old_id, node.line, kind)] = candidate
141
+ aliases.append(
142
+ {
143
+ "old_id": old_id,
144
+ "new_id": candidate,
145
+ "reason": "duplicate legacy symbol identity",
146
+ }
147
+ )
148
+ normalized.edges.append(
149
+ GraphEdge(
150
+ source=node.path,
151
+ target=candidate,
152
+ kind="contains",
153
+ confidence=Confidence.EXTRACTED,
154
+ reason="structurally disambiguated definition",
155
+ extras={"provenance": "extracted", "identity_alias": old_id},
156
+ )
157
+ )
158
+ normalized.edges.append(
159
+ GraphEdge(
160
+ source=candidate,
161
+ target=old_id,
162
+ kind="aliases",
163
+ confidence=Confidence.EXTRACTED,
164
+ reason="duplicate legacy symbol identity",
165
+ extras={"provenance": "user", "structural_discriminator": discriminator},
166
+ )
167
+ )
168
+
169
+ # Repeated definitions produced repeated identical containment rows. Keep
170
+ # the compatibility edge once; the disambiguated definitions have their
171
+ # own structural containment edge above.
172
+ seen_structural: set[tuple[str, str, str, str]] = set()
173
+ edges: list[GraphEdge] = []
174
+ for edge in normalized.edges:
175
+ key = (edge.source, edge.target, edge.kind, edge.reason)
176
+ if edge.kind == "contains" and edge.target in duplicates:
177
+ if key in seen_structural:
178
+ continue
179
+ seen_structural.add(key)
180
+ edges.append(edge)
181
+ normalized.edges = edges
182
+
183
+ for entry in normalized.dead_code:
184
+ replacement = replacement_by_location.get((entry.id, entry.line, entry.kind))
185
+ if replacement is not None:
186
+ entry.id = replacement
187
+ normalized.meta["duplicate_symbol_aliases"] = aliases
188
+ return normalized
189
+
190
+
191
+ class CodeIntelStore:
192
+ """Canonical graph store for one resolved project root.
193
+
194
+ A writer creates all rows for a new generation in one transaction and only
195
+ then advances ``current_generation``. Readers therefore see the complete old
196
+ or complete new graph, never a mixed refresh.
197
+ """
198
+
199
+ def __init__(self, project_root: Path, *, path: Path | None = None):
200
+ self.project_root = project_root.expanduser().resolve()
201
+ self.path = (path or (self.project_root / INDEX_REL)).expanduser().resolve()
202
+ self._init_lock = threading.Lock()
203
+ self.last_write_stats: dict[str, int] = {}
204
+
205
+ def exists(self) -> bool:
206
+ return self.path.is_file()
207
+
208
+ def quarantine_if_corrupt(self, exc: sqlite3.DatabaseError) -> bool:
209
+ """Move a damaged store aside so the next write rebuilds from scratch.
210
+
211
+ Returns True when the error signals file corruption and the store was
212
+ quarantined (``index.sqlite`` → ``index.sqlite.corrupt``). Lock/busy
213
+ errors and schema-level errors return False and must be raised by the
214
+ caller as before.
215
+ """
216
+ if not _corruption_error(exc):
217
+ return False
218
+ if not self.quarantine():
219
+ return False
220
+ logger.warning(
221
+ "codeintel store is corrupt (%s); quarantined to %s — rebuilding from scratch",
222
+ exc,
223
+ self.path.name + ".corrupt",
224
+ )
225
+ return True
226
+
227
+ def quarantine(self) -> bool:
228
+ """Move the store file (and WAL/SHM siblings) aside to ``*.corrupt``."""
229
+ quarantine = self.path.with_name(self.path.name + ".corrupt")
230
+ try:
231
+ for suffix in ("", "-wal", "-shm"):
232
+ source = Path(str(self.path) + suffix)
233
+ if source.exists():
234
+ target = Path(str(quarantine) + suffix)
235
+ target.unlink(missing_ok=True)
236
+ source.replace(target)
237
+ except OSError:
238
+ logger.warning("failed to quarantine corrupt codeintel store", exc_info=True)
239
+ return False
240
+ return True
241
+
242
+ def initialize(self) -> None:
243
+ with self._init_lock:
244
+ self.path.parent.mkdir(parents=True, exist_ok=True)
245
+ with self._connect() as conn:
246
+ self._migrate(conn)
247
+
248
+ @contextmanager
249
+ def _connect(self, *, readonly: bool = False) -> Iterator[sqlite3.Connection]:
250
+ if readonly:
251
+ uri = f"file:{self.path.as_posix()}?mode=ro"
252
+ conn = sqlite3.connect(uri, uri=True, timeout=5.0)
253
+ else:
254
+ conn = sqlite3.connect(self.path, timeout=30.0)
255
+ try:
256
+ conn.row_factory = sqlite3.Row
257
+ conn.execute("PRAGMA foreign_keys=ON")
258
+ conn.execute("PRAGMA busy_timeout=5000")
259
+ if not readonly:
260
+ conn.execute("PRAGMA journal_mode=WAL")
261
+ conn.execute("PRAGMA synchronous=NORMAL")
262
+ conn.execute("PRAGMA auto_vacuum=INCREMENTAL")
263
+ yield conn
264
+ finally:
265
+ # Close even when pragma setup raises (e.g. a corrupt file): a
266
+ # leaked handle keeps the file open, which blocks the Windows
267
+ # rename in quarantine().
268
+ conn.close()
269
+
270
+ def _migrate(self, conn: sqlite3.Connection) -> None:
271
+ version = int(conn.execute("PRAGMA user_version").fetchone()[0])
272
+ if version > STORE_SCHEMA_VERSION:
273
+ raise RuntimeError(
274
+ f"Code-intelligence database schema {version} is newer than supported "
275
+ f"schema {STORE_SCHEMA_VERSION}. Upgrade DevCouncil."
276
+ )
277
+ if version == 0:
278
+ conn.executescript(
279
+ """
280
+ CREATE TABLE metadata (
281
+ key TEXT PRIMARY KEY,
282
+ value TEXT NOT NULL
283
+ );
284
+ CREATE TABLE generations (
285
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
286
+ state TEXT NOT NULL CHECK(state IN ('building', 'committed', 'failed')),
287
+ created_at REAL NOT NULL,
288
+ analyzer_version TEXT NOT NULL,
289
+ schema_version INTEGER NOT NULL,
290
+ generated_head TEXT NOT NULL DEFAULT '',
291
+ indexed_hash TEXT NOT NULL DEFAULT '',
292
+ content_fingerprint TEXT NOT NULL DEFAULT '',
293
+ graph_meta TEXT NOT NULL DEFAULT '{}',
294
+ node_count INTEGER NOT NULL DEFAULT 0,
295
+ edge_count INTEGER NOT NULL DEFAULT 0
296
+ );
297
+ CREATE TABLE files (
298
+ generation_id INTEGER NOT NULL REFERENCES generations(id) ON DELETE CASCADE,
299
+ path TEXT NOT NULL,
300
+ language TEXT NOT NULL DEFAULT '',
301
+ content_hash TEXT NOT NULL DEFAULT '',
302
+ size INTEGER NOT NULL DEFAULT 0,
303
+ mtime_ns INTEGER NOT NULL DEFAULT 0,
304
+ content BLOB,
305
+ PRIMARY KEY (generation_id, path)
306
+ );
307
+ CREATE TABLE nodes (
308
+ generation_id INTEGER NOT NULL REFERENCES generations(id) ON DELETE CASCADE,
309
+ id TEXT NOT NULL,
310
+ kind TEXT NOT NULL,
311
+ path TEXT NOT NULL DEFAULT '',
312
+ name TEXT NOT NULL DEFAULT '',
313
+ line INTEGER NOT NULL DEFAULT 0,
314
+ end_line INTEGER NOT NULL DEFAULT 0,
315
+ area TEXT NOT NULL DEFAULT '',
316
+ language TEXT NOT NULL DEFAULT '',
317
+ exported INTEGER NOT NULL DEFAULT 0,
318
+ community TEXT NOT NULL DEFAULT '',
319
+ extras TEXT NOT NULL DEFAULT '{}',
320
+ PRIMARY KEY (generation_id, id)
321
+ );
322
+ CREATE TABLE edges (
323
+ generation_id INTEGER NOT NULL REFERENCES generations(id) ON DELETE CASCADE,
324
+ ordinal INTEGER NOT NULL,
325
+ source TEXT NOT NULL,
326
+ target TEXT NOT NULL,
327
+ kind TEXT NOT NULL,
328
+ confidence TEXT NOT NULL,
329
+ confidence_score REAL NOT NULL,
330
+ provenance TEXT NOT NULL,
331
+ reason TEXT NOT NULL DEFAULT '',
332
+ evidence TEXT NOT NULL DEFAULT '[]',
333
+ analyzer_version TEXT NOT NULL,
334
+ source_fingerprint TEXT NOT NULL DEFAULT '',
335
+ extras TEXT NOT NULL DEFAULT '{}',
336
+ PRIMARY KEY (generation_id, ordinal)
337
+ );
338
+ CREATE TABLE dead_code (
339
+ generation_id INTEGER NOT NULL REFERENCES generations(id) ON DELETE CASCADE,
340
+ ordinal INTEGER NOT NULL,
341
+ id TEXT NOT NULL,
342
+ path TEXT NOT NULL,
343
+ line INTEGER NOT NULL DEFAULT 0,
344
+ kind TEXT NOT NULL DEFAULT '',
345
+ confidence TEXT NOT NULL,
346
+ reason TEXT NOT NULL DEFAULT '',
347
+ PRIMARY KEY (generation_id, ordinal)
348
+ );
349
+ CREATE TABLE unresolved_references (
350
+ generation_id INTEGER NOT NULL REFERENCES generations(id) ON DELETE CASCADE,
351
+ source_id TEXT NOT NULL,
352
+ name TEXT NOT NULL,
353
+ kind TEXT NOT NULL,
354
+ path TEXT NOT NULL DEFAULT '',
355
+ line INTEGER NOT NULL DEFAULT 0,
356
+ evidence TEXT NOT NULL DEFAULT '{}'
357
+ );
358
+ CREATE TABLE diagnostics (
359
+ generation_id INTEGER REFERENCES generations(id) ON DELETE CASCADE,
360
+ path TEXT NOT NULL DEFAULT '',
361
+ severity TEXT NOT NULL,
362
+ code TEXT NOT NULL,
363
+ message TEXT NOT NULL,
364
+ data TEXT NOT NULL DEFAULT '{}'
365
+ );
366
+ CREATE TABLE aliases (
367
+ old_id TEXT NOT NULL,
368
+ new_id TEXT NOT NULL,
369
+ generation_id INTEGER NOT NULL REFERENCES generations(id) ON DELETE CASCADE,
370
+ reason TEXT NOT NULL DEFAULT '',
371
+ PRIMARY KEY (old_id, generation_id)
372
+ );
373
+ CREATE TABLE extraction_cache (
374
+ content_hash TEXT NOT NULL,
375
+ language TEXT NOT NULL,
376
+ grammar_version TEXT NOT NULL,
377
+ analyzer_version TEXT NOT NULL,
378
+ config_hash TEXT NOT NULL,
379
+ payload BLOB NOT NULL,
380
+ created_at REAL NOT NULL,
381
+ PRIMARY KEY (content_hash, language, grammar_version, analyzer_version, config_hash)
382
+ );
383
+ CREATE TABLE runtime_sessions (
384
+ id TEXT PRIMARY KEY,
385
+ created_at REAL NOT NULL,
386
+ ended_at REAL,
387
+ provider TEXT NOT NULL,
388
+ source_fingerprint TEXT NOT NULL,
389
+ build_fingerprint TEXT NOT NULL,
390
+ executable_hash TEXT NOT NULL DEFAULT '',
391
+ metadata TEXT NOT NULL DEFAULT '{}'
392
+ );
393
+ CREATE TABLE runtime_observations (
394
+ session_id TEXT NOT NULL REFERENCES runtime_sessions(id) ON DELETE CASCADE,
395
+ ordinal INTEGER NOT NULL,
396
+ source TEXT NOT NULL,
397
+ target TEXT NOT NULL,
398
+ kind TEXT NOT NULL,
399
+ count INTEGER NOT NULL DEFAULT 1,
400
+ first_seen REAL NOT NULL,
401
+ last_seen REAL NOT NULL,
402
+ evidence TEXT NOT NULL DEFAULT '{}',
403
+ PRIMARY KEY (session_id, ordinal)
404
+ );
405
+ CREATE VIRTUAL TABLE nodes_fts USING fts5(
406
+ generation_id UNINDEXED,
407
+ node_id UNINDEXED,
408
+ name,
409
+ qualified_name,
410
+ path,
411
+ tokenize='unicode61'
412
+ );
413
+ CREATE INDEX idx_nodes_path ON nodes(generation_id, path);
414
+ CREATE INDEX idx_nodes_name ON nodes(generation_id, name);
415
+ CREATE INDEX idx_edges_source ON edges(generation_id, source, kind);
416
+ CREATE INDEX idx_edges_target ON edges(generation_id, target, kind);
417
+ CREATE INDEX idx_unresolved_name ON unresolved_references(generation_id, name);
418
+ PRAGMA user_version=1;
419
+ """
420
+ )
421
+ conn.commit()
422
+ version = 1
423
+ if version == 1:
424
+ conn.executescript(
425
+ """
426
+ CREATE TABLE node_payloads (
427
+ payload_hash TEXT PRIMARY KEY,
428
+ payload BLOB NOT NULL
429
+ );
430
+ CREATE TABLE generation_nodes (
431
+ generation_id INTEGER NOT NULL REFERENCES generations(id) ON DELETE CASCADE,
432
+ ordinal INTEGER NOT NULL,
433
+ payload_hash TEXT NOT NULL REFERENCES node_payloads(payload_hash),
434
+ path TEXT NOT NULL DEFAULT '',
435
+ node_id TEXT NOT NULL,
436
+ PRIMARY KEY (generation_id, node_id)
437
+ );
438
+ CREATE TABLE edge_payloads (
439
+ payload_hash TEXT PRIMARY KEY,
440
+ payload BLOB NOT NULL
441
+ );
442
+ CREATE TABLE generation_edges (
443
+ generation_id INTEGER NOT NULL REFERENCES generations(id) ON DELETE CASCADE,
444
+ ordinal INTEGER NOT NULL,
445
+ payload_hash TEXT NOT NULL REFERENCES edge_payloads(payload_hash),
446
+ source_path TEXT NOT NULL DEFAULT '',
447
+ target_path TEXT NOT NULL DEFAULT '',
448
+ PRIMARY KEY (generation_id, ordinal, payload_hash)
449
+ );
450
+ CREATE TABLE dead_payloads (
451
+ payload_hash TEXT PRIMARY KEY,
452
+ payload BLOB NOT NULL
453
+ );
454
+ CREATE TABLE generation_dead (
455
+ generation_id INTEGER NOT NULL REFERENCES generations(id) ON DELETE CASCADE,
456
+ ordinal INTEGER NOT NULL,
457
+ payload_hash TEXT NOT NULL REFERENCES dead_payloads(payload_hash),
458
+ path TEXT NOT NULL DEFAULT '',
459
+ PRIMARY KEY (generation_id, ordinal, payload_hash)
460
+ );
461
+ CREATE TABLE file_contents (
462
+ content_hash TEXT PRIMARY KEY,
463
+ content BLOB
464
+ );
465
+ CREATE TABLE generation_files (
466
+ generation_id INTEGER NOT NULL REFERENCES generations(id) ON DELETE CASCADE,
467
+ path TEXT NOT NULL,
468
+ language TEXT NOT NULL DEFAULT '',
469
+ content_hash TEXT NOT NULL DEFAULT '',
470
+ size INTEGER NOT NULL DEFAULT 0,
471
+ mtime_ns INTEGER NOT NULL DEFAULT 0,
472
+ PRIMARY KEY (generation_id, path)
473
+ );
474
+ CREATE TABLE analysis_payloads (
475
+ payload_hash TEXT PRIMARY KEY,
476
+ payload BLOB NOT NULL
477
+ );
478
+ CREATE TABLE generation_analysis (
479
+ generation_id INTEGER NOT NULL REFERENCES generations(id) ON DELETE CASCADE,
480
+ path TEXT NOT NULL,
481
+ payload_hash TEXT NOT NULL REFERENCES analysis_payloads(payload_hash),
482
+ PRIMARY KEY (generation_id, path)
483
+ );
484
+ CREATE INDEX idx_generation_nodes_path
485
+ ON generation_nodes(generation_id, path);
486
+ CREATE INDEX idx_generation_edges_source
487
+ ON generation_edges(generation_id, source_path);
488
+ CREATE INDEX idx_generation_edges_target
489
+ ON generation_edges(generation_id, target_path);
490
+ PRAGMA user_version=2;
491
+ """
492
+ )
493
+ self._migrate_v1_payloads(conn)
494
+ conn.commit()
495
+
496
+ def _migrate_v1_payloads(self, conn: sqlite3.Connection) -> None:
497
+ """Copy retained v1 generations into content-addressed payload tables."""
498
+ for row in conn.execute("SELECT * FROM nodes ORDER BY generation_id, rowid"):
499
+ payload = {
500
+ key: row[key]
501
+ for key in (
502
+ "id", "kind", "path", "name", "line", "end_line", "area",
503
+ "language", "exported", "community", "extras",
504
+ )
505
+ }
506
+ payload["exported"] = bool(payload["exported"])
507
+ payload["extras"] = json.loads(payload["extras"])
508
+ digest, blob = self._payload(payload)
509
+ conn.execute("INSERT OR IGNORE INTO node_payloads VALUES(?, ?)", (digest, blob))
510
+ ordinal = int(conn.execute(
511
+ "SELECT COUNT(*) FROM generation_nodes WHERE generation_id=?",
512
+ (row["generation_id"],),
513
+ ).fetchone()[0])
514
+ conn.execute(
515
+ "INSERT OR IGNORE INTO generation_nodes VALUES(?, ?, ?, ?, ?)",
516
+ (row["generation_id"], ordinal, digest, row["path"], row["id"]),
517
+ )
518
+ for row in conn.execute("SELECT * FROM edges ORDER BY generation_id, ordinal"):
519
+ payload = {
520
+ key: row[key]
521
+ for key in (
522
+ "source", "target", "kind", "confidence", "confidence_score",
523
+ "provenance", "reason", "evidence", "analyzer_version",
524
+ "source_fingerprint", "extras",
525
+ )
526
+ }
527
+ payload["extras"] = json.loads(payload["extras"])
528
+ digest, blob = self._payload(payload)
529
+ conn.execute("INSERT OR IGNORE INTO edge_payloads VALUES(?, ?)", (digest, blob))
530
+ conn.execute(
531
+ "INSERT OR IGNORE INTO generation_edges VALUES(?, ?, ?, ?, ?)",
532
+ (
533
+ row["generation_id"], row["ordinal"], digest,
534
+ self._identity_path(row["source"]), self._identity_path(row["target"]),
535
+ ),
536
+ )
537
+ for row in conn.execute("SELECT * FROM dead_code ORDER BY generation_id, ordinal"):
538
+ payload = {
539
+ key: row[key]
540
+ for key in ("id", "path", "line", "kind", "confidence", "reason")
541
+ }
542
+ digest, blob = self._payload(payload)
543
+ conn.execute("INSERT OR IGNORE INTO dead_payloads VALUES(?, ?)", (digest, blob))
544
+ conn.execute(
545
+ "INSERT OR IGNORE INTO generation_dead VALUES(?, ?, ?, ?)",
546
+ (row["generation_id"], row["ordinal"], digest, row["path"]),
547
+ )
548
+ for row in conn.execute("SELECT * FROM files ORDER BY generation_id, path"):
549
+ if row["content_hash"]:
550
+ conn.execute(
551
+ "INSERT OR IGNORE INTO file_contents VALUES(?, ?)",
552
+ (row["content_hash"], row["content"]),
553
+ )
554
+ conn.execute(
555
+ "INSERT OR IGNORE INTO generation_files VALUES(?, ?, ?, ?, ?, ?)",
556
+ (
557
+ row["generation_id"], row["path"], row["language"],
558
+ row["content_hash"], row["size"], row["mtime_ns"],
559
+ ),
560
+ )
561
+ # The v2 payload/membership tables are canonical. Keep legacy tables
562
+ # present for rollback-compatible schema inspection, but not duplicated.
563
+ conn.execute("DELETE FROM diagnostics")
564
+ conn.execute("DELETE FROM dead_code")
565
+ conn.execute("DELETE FROM edges")
566
+ conn.execute("DELETE FROM nodes")
567
+ conn.execute("DELETE FROM files")
568
+
569
+ @staticmethod
570
+ def _payload(value: dict[str, Any]) -> tuple[str, bytes]:
571
+ raw = _json(value).encode("utf-8")
572
+ return hashlib.sha256(raw).hexdigest(), zlib.compress(raw, 6)
573
+
574
+ @staticmethod
575
+ def _decode_payload(blob: bytes) -> dict[str, Any]:
576
+ return dict(json.loads(zlib.decompress(blob)))
577
+
578
+ @staticmethod
579
+ def _identity_path(identity: str) -> str:
580
+ return identity.split("::", 1)[0].replace("\\", "/")
581
+
582
+ def current_generation(self) -> int | None:
583
+ if not self.exists():
584
+ return None
585
+ with self._connect(readonly=True) as conn:
586
+ row = conn.execute("SELECT value FROM metadata WHERE key='current_generation'").fetchone()
587
+ return int(row[0]) if row is not None else None
588
+
589
+ def save_graph(
590
+ self,
591
+ graph: CodeGraph,
592
+ *,
593
+ retain_generations: int = 2,
594
+ changed_paths: set[str] | None = None,
595
+ analysis_shards: dict[str, dict[str, Any]] | None = None,
596
+ ) -> int:
597
+ self.initialize()
598
+ compatibility_digest = compatibility_graph_digest(graph)
599
+ graph = _canonicalize_duplicate_ids(graph)
600
+ graph_meta = dict(graph.meta)
601
+ graph_meta.pop("communities", None)
602
+ meta = {
603
+ "dead_code": len(graph.dead_code),
604
+ "entry_roots": graph.entry_roots,
605
+ "unwired_candidates": graph.unwired_candidates,
606
+ "unreachable_files": graph.unreachable_files,
607
+ "meta": graph_meta,
608
+ }
609
+ with self._connect() as conn:
610
+ try:
611
+ conn.execute("BEGIN IMMEDIATE")
612
+ previous_row = conn.execute(
613
+ "SELECT value FROM metadata WHERE key='current_generation'"
614
+ ).fetchone()
615
+ previous_generation = int(previous_row[0]) if previous_row is not None else None
616
+ cursor = conn.execute(
617
+ """INSERT INTO generations(
618
+ state, created_at, analyzer_version, schema_version,
619
+ generated_head, indexed_hash, content_fingerprint, graph_meta
620
+ ) VALUES('building', ?, ?, ?, ?, ?, ?, ?)""",
621
+ (
622
+ time.time(),
623
+ ANALYZER_VERSION,
624
+ graph.schema_version,
625
+ graph.generated_head,
626
+ graph.indexed_hash,
627
+ graph.content_fingerprint,
628
+ _json(meta),
629
+ ),
630
+ )
631
+ if cursor.lastrowid is None:
632
+ raise RuntimeError("SQLite did not return a generation id")
633
+ generation = int(cursor.lastrowid)
634
+ normalized_changed = {
635
+ path.replace("\\", "/") for path in (changed_paths or set())
636
+ }
637
+ # Empty changed_paths must not enter incremental mode: _copy_unaffected
638
+ # returns immediately when the exclusion set is empty, which would
639
+ # commit a generation with zero memberships.
640
+ incremental = (
641
+ previous_generation is not None
642
+ and changed_paths is not None
643
+ and bool(normalized_changed)
644
+ )
645
+ if incremental:
646
+ assert previous_generation is not None
647
+ self._copy_unaffected_memberships(
648
+ conn, previous_generation, generation, normalized_changed
649
+ )
650
+ file_nodes: Sequence[GraphNode] = (
651
+ [node for node in graph.nodes if node.path in normalized_changed]
652
+ if incremental else graph.nodes
653
+ )
654
+ file_rows = self._file_rows(generation, file_nodes)
655
+ for row in file_rows:
656
+ _, path, language, content_hash, size, mtime_ns, content = row
657
+ if content_hash:
658
+ conn.execute(
659
+ "INSERT OR IGNORE INTO file_contents(content_hash, content) VALUES(?, ?)",
660
+ (content_hash, content),
661
+ )
662
+ conn.execute(
663
+ """INSERT OR REPLACE INTO generation_files(
664
+ generation_id, path, language, content_hash, size, mtime_ns
665
+ ) VALUES(?, ?, ?, ?, ?, ?)""",
666
+ (generation, path, language, content_hash, size, mtime_ns),
667
+ )
668
+ node_payload_writes = 0
669
+ edge_payload_writes = 0
670
+ dead_payload_writes = 0
671
+ for index, node in enumerate(graph.nodes):
672
+ if incremental and node.path not in normalized_changed:
673
+ continue
674
+ payload = self._node_payload(node)
675
+ digest, blob = self._payload(payload)
676
+ node_payload_writes += conn.execute(
677
+ "INSERT OR IGNORE INTO node_payloads VALUES(?, ?)", (digest, blob)
678
+ ).rowcount
679
+ conn.execute(
680
+ "INSERT OR REPLACE INTO generation_nodes VALUES(?, ?, ?, ?, ?)",
681
+ (generation, index, digest, node.path, node.id),
682
+ )
683
+ for index, edge in enumerate(graph.edges):
684
+ source_path = self._identity_path(edge.source)
685
+ target_path = self._identity_path(edge.target)
686
+ if incremental and not ({source_path, target_path} & normalized_changed):
687
+ continue
688
+ payload = self._edge_payload(edge)
689
+ digest, blob = self._payload(payload)
690
+ edge_payload_writes += conn.execute(
691
+ "INSERT OR IGNORE INTO edge_payloads VALUES(?, ?)", (digest, blob)
692
+ ).rowcount
693
+ conn.execute(
694
+ "INSERT OR IGNORE INTO generation_edges VALUES(?, ?, ?, ?, ?)",
695
+ (generation, index, digest, source_path, target_path),
696
+ )
697
+ unresolved = self._unresolved_rows(generation, graph)
698
+ conn.executemany(
699
+ """INSERT INTO unresolved_references(
700
+ generation_id, source_id, name, kind, path, line, evidence
701
+ ) VALUES(?, ?, ?, ?, ?, ?, ?)""",
702
+ unresolved,
703
+ )
704
+ for index, entry in enumerate(graph.dead_code):
705
+ if incremental and entry.path not in normalized_changed:
706
+ continue
707
+ payload = self._dead_payload(entry)
708
+ digest, blob = self._payload(payload)
709
+ dead_payload_writes += conn.execute(
710
+ "INSERT OR IGNORE INTO dead_payloads VALUES(?, ?)", (digest, blob)
711
+ ).rowcount
712
+ conn.execute(
713
+ "INSERT OR IGNORE INTO generation_dead VALUES(?, ?, ?, ?)",
714
+ (generation, index, digest, entry.path),
715
+ )
716
+ conn.executemany(
717
+ "INSERT INTO nodes_fts(generation_id, node_id, name, qualified_name, path) VALUES(?, ?, ?, ?, ?)",
718
+ [
719
+ (generation, node.id, node.name, node.id.rsplit("::", 1)[-1], node.path)
720
+ for node in graph.nodes
721
+ if not incremental or node.path in normalized_changed
722
+ ],
723
+ )
724
+ if analysis_shards is not None:
725
+ self._write_analysis_shards(
726
+ conn, generation, analysis_shards, normalized_changed if incremental else None
727
+ )
728
+ if previous_generation is not None:
729
+ self._record_rename_aliases(conn, previous_generation, generation)
730
+ conn.execute(
731
+ "UPDATE generations SET state='committed', node_count=?, edge_count=? WHERE id=?",
732
+ (len(graph.nodes), len(graph.edges), generation),
733
+ )
734
+ conn.execute(
735
+ "INSERT INTO metadata(key, value) VALUES('current_generation', ?) "
736
+ "ON CONFLICT(key) DO UPDATE SET value=excluded.value",
737
+ (str(generation),),
738
+ )
739
+ conn.execute(
740
+ "INSERT INTO metadata(key, value) VALUES('compatibility_export_digest', ?) "
741
+ "ON CONFLICT(key) DO UPDATE SET value=excluded.value",
742
+ (compatibility_digest,),
743
+ )
744
+ self._prune(conn, keep=max(1, retain_generations), current=generation)
745
+ self._compact_payloads(conn)
746
+ self.last_write_stats = {
747
+ "node_payloads_written": node_payload_writes,
748
+ "edge_payloads_written": edge_payload_writes,
749
+ "dead_payloads_written": dead_payload_writes,
750
+ "node_memberships": int(conn.execute(
751
+ "SELECT COUNT(*) FROM generation_nodes WHERE generation_id=?",
752
+ (generation,),
753
+ ).fetchone()[0]),
754
+ "edge_memberships": int(conn.execute(
755
+ "SELECT COUNT(*) FROM generation_edges WHERE generation_id=?",
756
+ (generation,),
757
+ ).fetchone()[0]),
758
+ }
759
+ conn.commit()
760
+ return generation
761
+ except Exception:
762
+ conn.rollback()
763
+ raise
764
+
765
+ def compatibility_export_state(self) -> tuple[str, int | None]:
766
+ """Return the last public graph digest and observed export mtime."""
767
+ if not self.exists():
768
+ return "", None
769
+ with self._connect(readonly=True) as conn:
770
+ rows = conn.execute(
771
+ "SELECT key, value FROM metadata WHERE key IN "
772
+ "('compatibility_export_digest', 'compatibility_export_mtime_ns')"
773
+ ).fetchall()
774
+ values = {str(row["key"]): str(row["value"]) for row in rows}
775
+ raw_mtime = values.get("compatibility_export_mtime_ns")
776
+ return values.get("compatibility_export_digest", ""), (
777
+ int(raw_mtime) if raw_mtime is not None else None
778
+ )
779
+
780
+ def record_compatibility_export(self, path: Path, graph: CodeGraph) -> None:
781
+ """Mark an on-disk JSON artifact as the export for this generation."""
782
+ self.initialize()
783
+ mtime_ns = path.stat().st_mtime_ns
784
+ digest = compatibility_graph_digest(graph)
785
+ with self._connect() as conn:
786
+ conn.executemany(
787
+ "INSERT INTO metadata(key, value) VALUES(?, ?) "
788
+ "ON CONFLICT(key) DO UPDATE SET value=excluded.value",
789
+ [
790
+ ("compatibility_export_digest", digest),
791
+ ("compatibility_export_mtime_ns", str(mtime_ns)),
792
+ ],
793
+ )
794
+ conn.commit()
795
+
796
+ def _file_rows(self, generation: int, nodes: Sequence[GraphNode]) -> list[tuple[Any, ...]]:
797
+ by_path: dict[str, str] = {}
798
+ for node in nodes:
799
+ if node.path:
800
+ by_path.setdefault(node.path, node.language)
801
+ rows: list[tuple[Any, ...]] = []
802
+ for rel, language in sorted(by_path.items()):
803
+ path = self.project_root / rel
804
+ try:
805
+ raw = path.read_bytes()
806
+ stat = path.stat()
807
+ except OSError:
808
+ rows.append((generation, rel, language, "", 0, 0, None))
809
+ continue
810
+ digest = hashlib.sha256(raw).hexdigest()
811
+ rows.append((generation, rel, language, digest, len(raw), stat.st_mtime_ns, zlib.compress(raw, 6)))
812
+ return rows
813
+
814
+ @staticmethod
815
+ def _node_payload(node: GraphNode) -> dict[str, Any]:
816
+ return node.model_dump(mode="json")
817
+
818
+ @staticmethod
819
+ def _edge_payload(edge: GraphEdge) -> dict[str, Any]:
820
+ payload = edge.model_dump(mode="json")
821
+ evidence = payload["extras"].get("evidence")
822
+ if (
823
+ payload["confidence"] == Confidence.AMBIGUOUS.value
824
+ and isinstance(evidence, list)
825
+ and len(evidence) > AMBIGUOUS_EVIDENCE_LIMIT
826
+ ):
827
+ payload["extras"]["evidence"] = evidence[:AMBIGUOUS_EVIDENCE_LIMIT]
828
+ payload["extras"]["evidence_truncated"] = len(evidence) - AMBIGUOUS_EVIDENCE_LIMIT
829
+ return payload
830
+
831
+ @staticmethod
832
+ def _dead_payload(entry: DeadCodeEntry) -> dict[str, Any]:
833
+ return entry.model_dump(mode="json")
834
+
835
+ @staticmethod
836
+ def _copy_unaffected_memberships(
837
+ conn: sqlite3.Connection,
838
+ previous: int,
839
+ generation: int,
840
+ changed: set[str],
841
+ ) -> None:
842
+ placeholders = ",".join("?" for _ in changed)
843
+ if not placeholders:
844
+ return
845
+ params: tuple[Any, ...] = (generation, previous, *sorted(changed))
846
+ conn.execute(
847
+ f"""INSERT INTO generation_files
848
+ SELECT ?, path, language, content_hash, size, mtime_ns
849
+ FROM generation_files
850
+ WHERE generation_id=? AND path NOT IN ({placeholders})""", # noqa: S608
851
+ params,
852
+ )
853
+ conn.execute(
854
+ f"""INSERT INTO generation_nodes
855
+ SELECT ?, ordinal, payload_hash, path, node_id
856
+ FROM generation_nodes
857
+ WHERE generation_id=? AND path NOT IN ({placeholders})""", # noqa: S608
858
+ params,
859
+ )
860
+ edge_params: tuple[Any, ...] = (
861
+ generation, previous, *sorted(changed), *sorted(changed)
862
+ )
863
+ conn.execute(
864
+ f"""INSERT INTO generation_edges
865
+ SELECT ?, ordinal, payload_hash, source_path, target_path
866
+ FROM generation_edges
867
+ WHERE generation_id=?
868
+ AND source_path NOT IN ({placeholders})
869
+ AND target_path NOT IN ({placeholders})""", # noqa: S608
870
+ edge_params,
871
+ )
872
+ conn.execute(
873
+ f"""INSERT INTO generation_dead
874
+ SELECT ?, ordinal, payload_hash, path
875
+ FROM generation_dead
876
+ WHERE generation_id=? AND path NOT IN ({placeholders})""", # noqa: S608
877
+ params,
878
+ )
879
+ conn.execute(
880
+ f"""INSERT INTO generation_analysis
881
+ SELECT ?, path, payload_hash
882
+ FROM generation_analysis
883
+ WHERE generation_id=? AND path NOT IN ({placeholders})""", # noqa: S608
884
+ params,
885
+ )
886
+ conn.execute(
887
+ f"""INSERT INTO nodes_fts(generation_id, node_id, name, qualified_name, path)
888
+ SELECT ?, node_id, name, qualified_name, path
889
+ FROM nodes_fts
890
+ WHERE generation_id=? AND path NOT IN ({placeholders})""", # noqa: S608
891
+ params,
892
+ )
893
+
894
+ def _write_analysis_shards(
895
+ self,
896
+ conn: sqlite3.Connection,
897
+ generation: int,
898
+ shards: dict[str, dict[str, Any]],
899
+ changed: set[str] | None,
900
+ ) -> None:
901
+ for path, shard in shards.items():
902
+ normalized = path.replace("\\", "/")
903
+ if changed is not None and normalized not in changed:
904
+ continue
905
+ digest, blob = self._payload(shard)
906
+ conn.execute("INSERT OR IGNORE INTO analysis_payloads VALUES(?, ?)", (digest, blob))
907
+ conn.execute(
908
+ "INSERT OR REPLACE INTO generation_analysis VALUES(?, ?, ?)",
909
+ (generation, normalized, digest),
910
+ )
911
+
912
+ @staticmethod
913
+ def _compact_payloads(conn: sqlite3.Connection) -> None:
914
+ for payload_table, membership_table in (
915
+ ("node_payloads", "generation_nodes"),
916
+ ("edge_payloads", "generation_edges"),
917
+ ("dead_payloads", "generation_dead"),
918
+ ("analysis_payloads", "generation_analysis"),
919
+ ):
920
+ conn.execute(
921
+ f"""DELETE FROM {payload_table}
922
+ WHERE payload_hash NOT IN (
923
+ SELECT DISTINCT payload_hash FROM {membership_table}
924
+ )""" # noqa: S608
925
+ )
926
+ conn.execute(
927
+ """DELETE FROM file_contents
928
+ WHERE content_hash NOT IN (
929
+ SELECT DISTINCT content_hash FROM generation_files
930
+ WHERE content_hash<>''
931
+ )"""
932
+ )
933
+ conn.execute("PRAGMA incremental_vacuum(64)")
934
+
935
+ @staticmethod
936
+ def _node_row(generation: int, node: GraphNode) -> tuple[Any, ...]:
937
+ kind = node.kind.value if hasattr(node.kind, "value") else str(node.kind)
938
+ return (
939
+ generation,
940
+ node.id,
941
+ kind,
942
+ node.path,
943
+ node.name,
944
+ node.line,
945
+ node.end_line,
946
+ node.area,
947
+ node.language,
948
+ int(node.exported),
949
+ node.community,
950
+ _json(node.extras),
951
+ )
952
+
953
+ @staticmethod
954
+ def _edge_row(generation: int, ordinal: int, edge: GraphEdge) -> tuple[Any, ...]:
955
+ confidence = edge.confidence.value if hasattr(edge.confidence, "value") else str(edge.confidence)
956
+ evidence = edge.extras.get("evidence", [])
957
+ fingerprint = str(edge.extras.get("source_fingerprint", ""))
958
+ return (
959
+ generation,
960
+ ordinal,
961
+ edge.source,
962
+ edge.target,
963
+ edge.kind,
964
+ confidence,
965
+ _confidence_score(edge),
966
+ _provenance(edge),
967
+ edge.reason,
968
+ _json(evidence),
969
+ ANALYZER_VERSION,
970
+ fingerprint,
971
+ _json(edge.extras),
972
+ )
973
+
974
+ @staticmethod
975
+ def _unresolved_rows(generation: int, graph: CodeGraph) -> list[tuple[Any, ...]]:
976
+ incoming: dict[str, str] = {}
977
+ for edge in graph.edges:
978
+ if edge.kind == "dynamic_reference":
979
+ incoming.setdefault(edge.target, edge.source)
980
+ rows: list[tuple[Any, ...]] = []
981
+ for node in graph.nodes:
982
+ kind = node.kind.value if hasattr(node.kind, "value") else str(node.kind)
983
+ if kind != "dynamic" or node.extras.get("resolved") is not False:
984
+ continue
985
+ rows.append((
986
+ generation,
987
+ incoming.get(node.id, node.path),
988
+ node.name,
989
+ str(node.extras.get("sink") or "dynamic"),
990
+ node.path,
991
+ node.line,
992
+ _json({"node_id": node.id, "extras": node.extras}),
993
+ ))
994
+ for raw in graph.meta.get("unresolved_references") or []:
995
+ if not isinstance(raw, dict) or not raw.get("name"):
996
+ continue
997
+ rows.append((
998
+ generation,
999
+ str(raw.get("source_id") or raw.get("path") or ""),
1000
+ str(raw["name"]),
1001
+ str(raw.get("kind") or "reference"),
1002
+ str(raw.get("path") or ""),
1003
+ int(raw.get("line") or 0),
1004
+ _json(raw.get("evidence") or {}),
1005
+ ))
1006
+ return rows
1007
+
1008
+ def _record_rename_aliases(
1009
+ self, conn: sqlite3.Connection, previous: int, current: int
1010
+ ) -> None:
1011
+ """Preserve identities across same-content file renames as explicit aliases.
1012
+
1013
+ A rename is a path that left the index whose content reappeared at
1014
+ exactly one new path. Joining generations on content alone would
1015
+ cross-link every pair of identical files (empty ``__init__.py``,
1016
+ generated boilerplate) into false aliases with quadratic node lookups.
1017
+ """
1018
+ removed = conn.execute(
1019
+ """SELECT path, content_hash FROM generation_files
1020
+ WHERE generation_id=? AND content_hash<>''
1021
+ AND path NOT IN (
1022
+ SELECT path FROM generation_files WHERE generation_id=?
1023
+ )""",
1024
+ (previous, current),
1025
+ ).fetchall()
1026
+ added = conn.execute(
1027
+ """SELECT path, content_hash FROM generation_files
1028
+ WHERE generation_id=? AND content_hash<>''
1029
+ AND path NOT IN (
1030
+ SELECT path FROM generation_files WHERE generation_id=?
1031
+ )""",
1032
+ (current, previous),
1033
+ ).fetchall()
1034
+ removed_by_hash: dict[str, list[str]] = {}
1035
+ for row in removed:
1036
+ removed_by_hash.setdefault(str(row["content_hash"]), []).append(str(row["path"]))
1037
+ added_by_hash: dict[str, list[str]] = {}
1038
+ for row in added:
1039
+ added_by_hash.setdefault(str(row["content_hash"]), []).append(str(row["path"]))
1040
+ renamed = [
1041
+ (old_paths[0], new_paths[0])
1042
+ for content_hash, old_paths in sorted(removed_by_hash.items())
1043
+ if len(old_paths) == 1
1044
+ and len(new_paths := added_by_hash.get(content_hash, [])) == 1
1045
+ ]
1046
+ for old_path, new_path in renamed:
1047
+ old_nodes = self._nodes_for_path(conn, previous, old_path)
1048
+ new_nodes = self._nodes_for_path(conn, current, new_path)
1049
+ by_shape = {
1050
+ (
1051
+ node.kind.value, node.name if node.kind != NodeKind.FILE else "",
1052
+ node.line, node.end_line,
1053
+ ): node
1054
+ for node in new_nodes
1055
+ }
1056
+ for old in old_nodes:
1057
+ key = (
1058
+ old.kind.value, old.name if old.kind != NodeKind.FILE else "",
1059
+ old.line, old.end_line,
1060
+ )
1061
+ new = by_shape.get(key)
1062
+ if new is not None:
1063
+ conn.execute(
1064
+ "INSERT OR IGNORE INTO aliases VALUES(?, ?, ?, ?)",
1065
+ (old.id, new.id, current, "same-content file rename"),
1066
+ )
1067
+
1068
+ def _nodes_for_path(
1069
+ self, conn: sqlite3.Connection, generation: int, path: str
1070
+ ) -> list[GraphNode]:
1071
+ return [
1072
+ GraphNode.model_validate(self._decode_payload(row["payload"]))
1073
+ for row in conn.execute(
1074
+ """SELECT p.payload FROM generation_nodes m
1075
+ JOIN node_payloads p ON p.payload_hash=m.payload_hash
1076
+ WHERE m.generation_id=? AND m.path=?""",
1077
+ (generation, path),
1078
+ )
1079
+ ]
1080
+
1081
+ @staticmethod
1082
+ def _dead_row(generation: int, ordinal: int, entry: DeadCodeEntry) -> tuple[Any, ...]:
1083
+ confidence = entry.confidence.value if hasattr(entry.confidence, "value") else str(entry.confidence)
1084
+ return (generation, ordinal, entry.id, entry.path, entry.line, entry.kind, confidence, entry.reason)
1085
+
1086
+ @staticmethod
1087
+ def _prune(conn: sqlite3.Connection, *, keep: int, current: int) -> None:
1088
+ rows = conn.execute(
1089
+ "SELECT id FROM generations WHERE state='committed' ORDER BY id DESC"
1090
+ ).fetchall()
1091
+ stale = [int(row[0]) for row in rows[keep:] if int(row[0]) != current]
1092
+ for generation in stale:
1093
+ conn.execute("DELETE FROM nodes_fts WHERE generation_id=?", (generation,))
1094
+ conn.execute("DELETE FROM generations WHERE id=?", (generation,))
1095
+
1096
+ def load_graph(self, generation: int | None = None) -> CodeGraph | None:
1097
+ if not self.exists():
1098
+ return None
1099
+ with self._connect(readonly=True) as conn:
1100
+ if generation is None:
1101
+ row = conn.execute("SELECT value FROM metadata WHERE key='current_generation'").fetchone()
1102
+ if row is None:
1103
+ return None
1104
+ generation = int(row[0])
1105
+ gen = conn.execute(
1106
+ "SELECT * FROM generations WHERE id=? AND state='committed'", (generation,)
1107
+ ).fetchone()
1108
+ if gen is None:
1109
+ return None
1110
+ nodes = [
1111
+ GraphNode.model_validate(self._decode_payload(row["payload"]))
1112
+ for row in conn.execute(
1113
+ """SELECT p.payload FROM generation_nodes m
1114
+ JOIN node_payloads p ON p.payload_hash=m.payload_hash
1115
+ WHERE m.generation_id=? ORDER BY m.ordinal, m.node_id""",
1116
+ (generation,),
1117
+ )
1118
+ ]
1119
+ edges = [
1120
+ GraphEdge.model_validate(self._decode_payload(row["payload"]))
1121
+ for row in conn.execute(
1122
+ """SELECT p.payload FROM generation_edges m
1123
+ JOIN edge_payloads p ON p.payload_hash=m.payload_hash
1124
+ WHERE m.generation_id=? ORDER BY m.ordinal, m.payload_hash""",
1125
+ (generation,),
1126
+ )
1127
+ ]
1128
+ dead = [
1129
+ DeadCodeEntry.model_validate(self._decode_payload(row["payload"]))
1130
+ for row in conn.execute(
1131
+ """SELECT p.payload FROM generation_dead m
1132
+ JOIN dead_payloads p ON p.payload_hash=m.payload_hash
1133
+ WHERE m.generation_id=? ORDER BY m.ordinal, m.payload_hash""",
1134
+ (generation,),
1135
+ )
1136
+ ]
1137
+ graph_meta = json.loads(gen["graph_meta"])
1138
+ return CodeGraph(
1139
+ schema_version=int(gen["schema_version"]),
1140
+ nodes=nodes,
1141
+ edges=edges,
1142
+ dead_code=dead,
1143
+ entry_roots=list(graph_meta.get("entry_roots") or []),
1144
+ unwired_candidates=list(graph_meta.get("unwired_candidates") or []),
1145
+ unreachable_files=list(graph_meta.get("unreachable_files") or []),
1146
+ generated_head=str(gen["generated_head"]),
1147
+ indexed_hash=str(gen["indexed_hash"]),
1148
+ content_fingerprint=str(gen["content_fingerprint"]),
1149
+ meta={
1150
+ **dict(graph_meta.get("meta") or {}),
1151
+ "codeintel_generation": generation,
1152
+ "codeintel_analyzer_version": str(gen["analyzer_version"]),
1153
+ },
1154
+ )
1155
+
1156
+ @staticmethod
1157
+ def _node_from_row(row: sqlite3.Row) -> GraphNode:
1158
+ return GraphNode(
1159
+ id=row["id"],
1160
+ kind=row["kind"],
1161
+ path=row["path"],
1162
+ name=row["name"],
1163
+ line=row["line"],
1164
+ end_line=row["end_line"],
1165
+ area=row["area"],
1166
+ language=row["language"],
1167
+ exported=bool(row["exported"]),
1168
+ community=row["community"],
1169
+ extras=json.loads(row["extras"]),
1170
+ )
1171
+
1172
+ @staticmethod
1173
+ def _edge_from_row(row: sqlite3.Row) -> GraphEdge:
1174
+ extras = json.loads(row["extras"])
1175
+ return GraphEdge(
1176
+ source=row["source"],
1177
+ target=row["target"],
1178
+ kind=row["kind"],
1179
+ confidence=row["confidence"],
1180
+ reason=row["reason"],
1181
+ extras=extras,
1182
+ )
1183
+
1184
+ @staticmethod
1185
+ def _dead_from_row(row: sqlite3.Row) -> DeadCodeEntry:
1186
+ return DeadCodeEntry(
1187
+ id=row["id"],
1188
+ path=row["path"],
1189
+ line=row["line"],
1190
+ kind=row["kind"],
1191
+ confidence=Confidence(row["confidence"]),
1192
+ reason=row["reason"],
1193
+ )
1194
+
1195
+ def search(self, query: str, *, limit: int = 50) -> list[dict[str, Any]]:
1196
+ generation = self.current_generation()
1197
+ if generation is None:
1198
+ return []
1199
+ terms = " ".join(part for part in query.replace("::", " ").split() if part)
1200
+ if not terms:
1201
+ return []
1202
+ with self._connect(readonly=True) as conn:
1203
+ try:
1204
+ rows = conn.execute(
1205
+ """SELECT node_id, bm25(nodes_fts) AS rank
1206
+ FROM nodes_fts
1207
+ WHERE nodes_fts MATCH ? AND generation_id=?
1208
+ ORDER BY rank LIMIT ?""",
1209
+ (terms, generation, max(1, min(500, limit))),
1210
+ ).fetchall()
1211
+ except sqlite3.OperationalError:
1212
+ rows = []
1213
+ ranked = {str(row["node_id"]): float(row["rank"]) for row in rows}
1214
+ if ranked:
1215
+ placeholders = ",".join("?" for _ in ranked)
1216
+ payload_rows = conn.execute(
1217
+ f"""SELECT m.node_id, p.payload FROM generation_nodes m
1218
+ JOIN node_payloads p ON p.payload_hash=m.payload_hash
1219
+ WHERE m.generation_id=? AND m.node_id IN ({placeholders})""", # noqa: S608
1220
+ (generation, *ranked),
1221
+ ).fetchall()
1222
+ found = []
1223
+ for row in payload_rows:
1224
+ node = self._decode_payload(row["payload"])
1225
+ found.append({
1226
+ key: node[key]
1227
+ for key in ("id", "kind", "path", "name", "line", "end_line", "area", "language")
1228
+ } | {"rank": ranked[str(row["node_id"])]})
1229
+ return sorted(found, key=lambda item: item["rank"])
1230
+ lowered = query.casefold()
1231
+ fallback = []
1232
+ for row in conn.execute(
1233
+ """SELECT p.payload FROM generation_nodes m
1234
+ JOIN node_payloads p ON p.payload_hash=m.payload_hash
1235
+ WHERE m.generation_id=?""",
1236
+ (generation,),
1237
+ ):
1238
+ node = self._decode_payload(row["payload"])
1239
+ if lowered not in f"{node['id']} {node['name']} {node['path']}".casefold():
1240
+ continue
1241
+ fallback.append({
1242
+ key: node[key]
1243
+ for key in ("id", "kind", "path", "name", "line", "end_line", "area", "language")
1244
+ } | {"rank": 0.0})
1245
+ if len(fallback) >= max(1, min(500, limit)):
1246
+ break
1247
+ return fallback
1248
+
1249
+ def status(self) -> StoreStatus:
1250
+ if not self.exists():
1251
+ return StoreStatus(
1252
+ project_root=str(self.project_root),
1253
+ database=str(self.path),
1254
+ schema_version=STORE_SCHEMA_VERSION,
1255
+ generation=None,
1256
+ state="uninitialized",
1257
+ )
1258
+ try:
1259
+ with self._connect(readonly=True) as conn:
1260
+ schema = int(conn.execute("PRAGMA user_version").fetchone()[0])
1261
+ row = conn.execute(
1262
+ """SELECT g.* FROM generations g
1263
+ JOIN metadata m ON m.key='current_generation' AND CAST(m.value AS INTEGER)=g.id"""
1264
+ ).fetchone()
1265
+ except sqlite3.DatabaseError as exc:
1266
+ if not _corruption_error(exc):
1267
+ raise
1268
+ return StoreStatus(
1269
+ str(self.project_root), str(self.path), STORE_SCHEMA_VERSION, None, "corrupt"
1270
+ )
1271
+ if row is None:
1272
+ return StoreStatus(str(self.project_root), str(self.path), schema, None, "empty")
1273
+ return StoreStatus(
1274
+ project_root=str(self.project_root),
1275
+ database=str(self.path),
1276
+ schema_version=schema,
1277
+ generation=int(row["id"]),
1278
+ state=str(row["state"]),
1279
+ node_count=int(row["node_count"]),
1280
+ edge_count=int(row["edge_count"]),
1281
+ created_at=float(row["created_at"]),
1282
+ analyzer_version=str(row["analyzer_version"]),
1283
+ )
1284
+
1285
+ def content_for_path(self, path: str, *, generation: int | None = None) -> bytes | None:
1286
+ generation = generation or self.current_generation()
1287
+ if generation is None:
1288
+ return None
1289
+ with self._connect(readonly=True) as conn:
1290
+ row = conn.execute(
1291
+ """SELECT c.content FROM generation_files f
1292
+ LEFT JOIN file_contents c ON c.content_hash=f.content_hash
1293
+ WHERE f.generation_id=? AND f.path=?""",
1294
+ (generation, path.replace("\\", "/")),
1295
+ ).fetchone()
1296
+ if row is None or row[0] is None:
1297
+ return None
1298
+ return zlib.decompress(row[0])
1299
+
1300
+ def file_metadata(self, *, generation: int | None = None) -> dict[str, tuple[int, int, str]]:
1301
+ """Return ``path -> (size, mtime_ns, sha256)`` for reconciliation."""
1302
+
1303
+ generation = generation or self.current_generation()
1304
+ if generation is None:
1305
+ return {}
1306
+ with self._connect(readonly=True) as conn:
1307
+ rows = conn.execute(
1308
+ "SELECT path, size, mtime_ns, content_hash FROM generation_files WHERE generation_id=?",
1309
+ (generation,),
1310
+ ).fetchall()
1311
+ return {
1312
+ str(row["path"]): (int(row["size"]), int(row["mtime_ns"]), str(row["content_hash"]))
1313
+ for row in rows
1314
+ }
1315
+
1316
+ def has_indexed_path(self, path: str, *, generation: int | None = None) -> bool:
1317
+ """Single-row membership check (cheap enough for per-event watcher calls)."""
1318
+ generation = generation or self.current_generation()
1319
+ if generation is None:
1320
+ return False
1321
+ with self._connect(readonly=True) as conn:
1322
+ row = conn.execute(
1323
+ "SELECT 1 FROM generation_files WHERE generation_id=? AND path=? LIMIT 1",
1324
+ (generation, path.replace("\\", "/")),
1325
+ ).fetchone()
1326
+ return row is not None
1327
+
1328
+ def analysis_shards(
1329
+ self, *, generation: int | None = None
1330
+ ) -> dict[str, dict[str, Any]]:
1331
+ generation = generation or self.current_generation()
1332
+ if generation is None:
1333
+ return {}
1334
+ with self._connect(readonly=True) as conn:
1335
+ rows = conn.execute(
1336
+ """SELECT m.path, p.payload FROM generation_analysis m
1337
+ JOIN analysis_payloads p ON p.payload_hash=m.payload_hash
1338
+ WHERE m.generation_id=?""",
1339
+ (generation,),
1340
+ ).fetchall()
1341
+ return {
1342
+ str(row["path"]): self._decode_payload(row["payload"])
1343
+ for row in rows
1344
+ }
1345
+
1346
+ def unresolved_references(self, *, name: str | None = None) -> list[dict[str, Any]]:
1347
+ generation = self.current_generation()
1348
+ if generation is None:
1349
+ return []
1350
+ sql = "SELECT * FROM unresolved_references WHERE generation_id=?"
1351
+ params: list[Any] = [generation]
1352
+ if name:
1353
+ sql += " AND name=?"
1354
+ params.append(name)
1355
+ sql += " ORDER BY path, line, name"
1356
+ with self._connect(readonly=True) as conn:
1357
+ rows = conn.execute(sql, params).fetchall()
1358
+ return [{**dict(row), "evidence": json.loads(row["evidence"])} for row in rows]
1359
+
1360
+ def diagnostics(self) -> list[dict[str, Any]]:
1361
+ """Derive unresolved-reference diagnostics without duplicate storage."""
1362
+ return [
1363
+ {
1364
+ "generation_id": row["generation_id"],
1365
+ "path": row["path"],
1366
+ "severity": "warning",
1367
+ "code": "dynamic_unresolved",
1368
+ "message": f"Unresolved dynamic reference: {row['name']}",
1369
+ "data": row["evidence"],
1370
+ }
1371
+ for row in self.unresolved_references()
1372
+ ]
1373
+
1374
+ def aliases(self) -> list[dict[str, Any]]:
1375
+ generation = self.current_generation()
1376
+ if generation is None:
1377
+ return []
1378
+ with self._connect(readonly=True) as conn:
1379
+ rows = conn.execute(
1380
+ "SELECT old_id, new_id, reason FROM aliases WHERE generation_id=? ORDER BY old_id",
1381
+ (generation,),
1382
+ ).fetchall()
1383
+ return [dict(row) for row in rows]
1384
+
1385
+ def put_extraction(
1386
+ self,
1387
+ *,
1388
+ content_hash: str,
1389
+ language: str,
1390
+ grammar_version: str,
1391
+ config_hash: str,
1392
+ payload: bytes,
1393
+ ) -> None:
1394
+ self.initialize()
1395
+ with self._connect() as conn:
1396
+ conn.execute(
1397
+ """INSERT INTO extraction_cache(
1398
+ content_hash, language, grammar_version, analyzer_version,
1399
+ config_hash, payload, created_at
1400
+ ) VALUES(?, ?, ?, ?, ?, ?, ?)
1401
+ ON CONFLICT(content_hash, language, grammar_version, analyzer_version, config_hash)
1402
+ DO UPDATE SET payload=excluded.payload, created_at=excluded.created_at""",
1403
+ (content_hash, language, grammar_version, ANALYZER_VERSION, config_hash, payload, time.time()),
1404
+ )
1405
+ total = int(conn.execute(
1406
+ "SELECT COALESCE(SUM(length(payload)), 0) FROM extraction_cache"
1407
+ ).fetchone()[0])
1408
+ if total > EXTRACTION_CACHE_MAX_BYTES:
1409
+ conn.execute(
1410
+ """DELETE FROM extraction_cache WHERE rowid IN (
1411
+ SELECT rowid FROM extraction_cache ORDER BY created_at
1412
+ LIMIT (
1413
+ SELECT MAX(1, COUNT(*) / 4) FROM extraction_cache
1414
+ )
1415
+ )"""
1416
+ )
1417
+ conn.commit()
1418
+
1419
+ def get_extraction(
1420
+ self,
1421
+ *,
1422
+ content_hash: str,
1423
+ language: str,
1424
+ grammar_version: str,
1425
+ config_hash: str,
1426
+ ) -> bytes | None:
1427
+ if not self.exists():
1428
+ return None
1429
+ with self._connect(readonly=True) as conn:
1430
+ row = conn.execute(
1431
+ """SELECT payload FROM extraction_cache
1432
+ WHERE content_hash=? AND language=? AND grammar_version=?
1433
+ AND analyzer_version=? AND config_hash=?""",
1434
+ (content_hash, language, grammar_version, ANALYZER_VERSION, config_hash),
1435
+ ).fetchone()
1436
+ return bytes(row[0]) if row is not None else None
1437
+
1438
+ def start_runtime_session(
1439
+ self,
1440
+ *,
1441
+ provider: str,
1442
+ source_fingerprint: str,
1443
+ build_fingerprint: str,
1444
+ executable_hash: str = "",
1445
+ metadata: dict[str, Any] | None = None,
1446
+ session_id: str | None = None,
1447
+ ) -> str:
1448
+ self.initialize()
1449
+ session_id = session_id or uuid.uuid4().hex
1450
+ with self._connect() as conn:
1451
+ conn.execute(
1452
+ """INSERT INTO runtime_sessions(
1453
+ id, created_at, provider, source_fingerprint, build_fingerprint,
1454
+ executable_hash, metadata
1455
+ ) VALUES(?, ?, ?, ?, ?, ?, ?)""",
1456
+ (
1457
+ session_id,
1458
+ time.time(),
1459
+ provider,
1460
+ source_fingerprint,
1461
+ build_fingerprint,
1462
+ executable_hash,
1463
+ _json(metadata or {}),
1464
+ ),
1465
+ )
1466
+ conn.commit()
1467
+ return session_id
1468
+
1469
+ def add_runtime_observations(
1470
+ self,
1471
+ session_id: str,
1472
+ observations: Sequence[dict[str, Any]],
1473
+ ) -> int:
1474
+ if not observations:
1475
+ return 0
1476
+ now = time.time()
1477
+ valid = [row for row in observations if row.get("source") and row.get("target")]
1478
+ with self._connect() as conn:
1479
+ start = int(conn.execute(
1480
+ "SELECT COALESCE(MAX(ordinal), -1) + 1 FROM runtime_observations WHERE session_id=?",
1481
+ (session_id,),
1482
+ ).fetchone()[0])
1483
+ conn.executemany(
1484
+ """INSERT INTO runtime_observations(
1485
+ session_id, ordinal, source, target, kind, count,
1486
+ first_seen, last_seen, evidence
1487
+ ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)""",
1488
+ [
1489
+ (
1490
+ session_id,
1491
+ start + index,
1492
+ str(row.get("source", "")),
1493
+ str(row.get("target", "")),
1494
+ str(row.get("kind", "observed_calls")),
1495
+ max(1, int(row.get("count", 1))),
1496
+ float(row.get("first_seen", now)),
1497
+ float(row.get("last_seen", now)),
1498
+ _json(row.get("evidence") or {}),
1499
+ )
1500
+ for index, row in enumerate(valid)
1501
+ ],
1502
+ )
1503
+ conn.commit()
1504
+ return len(valid)
1505
+
1506
+ def end_runtime_session(self, session_id: str) -> None:
1507
+ with self._connect() as conn:
1508
+ conn.execute("UPDATE runtime_sessions SET ended_at=? WHERE id=?", (time.time(), session_id))
1509
+ conn.commit()
1510
+
1511
+ def has_runtime_observations(self) -> bool:
1512
+ if not self.exists():
1513
+ return False
1514
+ with self._connect(readonly=True) as conn:
1515
+ row = conn.execute(
1516
+ "SELECT EXISTS(SELECT 1 FROM runtime_observations)"
1517
+ ).fetchone()
1518
+ return bool(row[0])
1519
+
1520
+ def runtime_observations(
1521
+ self,
1522
+ *,
1523
+ source_fingerprint: str | None = None,
1524
+ build_fingerprint: str | None = None,
1525
+ executable_hash: str | None = None,
1526
+ include_stale: bool = False,
1527
+ limit: int = 10_000,
1528
+ ) -> list[dict[str, Any]]:
1529
+ if not self.exists():
1530
+ return []
1531
+ filters: list[str] = []
1532
+ params: list[Any] = []
1533
+ expected = (
1534
+ ("s.source_fingerprint", source_fingerprint),
1535
+ ("s.build_fingerprint", build_fingerprint),
1536
+ ("s.executable_hash", executable_hash),
1537
+ )
1538
+ if not include_stale:
1539
+ for column, value in expected:
1540
+ if value is not None:
1541
+ filters.append(f"{column}=?")
1542
+ params.append(value)
1543
+ where = f"WHERE {' AND '.join(filters)}" if filters else ""
1544
+ params.append(max(1, min(100_000, limit)))
1545
+ with self._connect(readonly=True) as conn:
1546
+ rows = conn.execute(
1547
+ f"""SELECT o.*, s.provider, s.source_fingerprint, s.build_fingerprint,
1548
+ s.executable_hash, s.created_at AS session_created_at
1549
+ FROM runtime_observations o
1550
+ JOIN runtime_sessions s ON s.id=o.session_id
1551
+ {where}
1552
+ ORDER BY o.last_seen DESC LIMIT ?""",
1553
+ params,
1554
+ ).fetchall()
1555
+ return [
1556
+ {
1557
+ **dict(row),
1558
+ "evidence": json.loads(row["evidence"]),
1559
+ "fingerprint_matches": all(
1560
+ value is None or row[column.removeprefix("s.")] == value
1561
+ for column, value in expected
1562
+ ),
1563
+ }
1564
+ for row in rows
1565
+ ]