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,1020 @@
1
+ """Import + call resolution with a confidence ladder.
2
+
3
+ Reuses RepoMapper's import resolution (Python/JS/Go) so edges stay consistent
4
+ with existing verify-gate behavior. ``resolve_import_edges`` resolves from
5
+ already-extracted import specs — no source re-reads for extraction.
6
+
7
+ devcouncil: allow-unwired — package-private; reached only via graph.build.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import sys
14
+ from collections import defaultdict
15
+ from pathlib import Path
16
+ from typing import Any, Dict, List, Optional, Set, Tuple
17
+
18
+ from devcouncil.indexing.graph.extract_python import FileExtraction
19
+ from devcouncil.indexing.graph.schema import (
20
+ Confidence,
21
+ GraphEdge,
22
+ GraphNode,
23
+ NodeKind,
24
+ file_node_id,
25
+ symbol_node_id,
26
+ )
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ # Match SQLite ``AMBIGUOUS_EVIDENCE_LIMIT`` — keep JSON extras bounded.
31
+ AMBIGUOUS_CANDIDATES_CAP = 8
32
+
33
+ _JS_SUFFIXES = frozenset({".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"})
34
+ _PY_SUFFIXES = frozenset({".py"})
35
+
36
+
37
+ def _stable_candidate_ids(candidates: List[str]) -> List[str]:
38
+ """Deduplicate and sort symbol ids for stable fan-out / extras ordering."""
39
+ return sorted(dict.fromkeys(candidates))
40
+
41
+
42
+ def _capped_candidate_extras(candidates: List[str]) -> Dict[str, Any]:
43
+ """Bounded ``candidates`` extras fragment (omit when empty)."""
44
+ if not candidates:
45
+ return {}
46
+ ordered = _stable_candidate_ids(candidates)
47
+ capped = ordered[:AMBIGUOUS_CANDIDATES_CAP]
48
+ extras: Dict[str, Any] = {"candidates": capped}
49
+ truncated = len(ordered) - len(capped)
50
+ if truncated > 0:
51
+ extras["candidates_truncated"] = truncated
52
+ return extras
53
+
54
+
55
+ def _module_file_match(module: str, fpath: str) -> bool:
56
+ """True when ``fpath`` is a plausible resolution of dotted ``module``.
57
+
58
+ Accepts both ``pkg.mod`` → ``pkg/mod.py`` and ``pkg.mod.func`` aliases by
59
+ also trying the parent dotted path.
60
+ """
61
+ if not module:
62
+ return False
63
+ parts = [p for p in module.replace("\\", "/").replace("/", ".").split(".") if p]
64
+ candidates = [".".join(parts)]
65
+ if len(parts) > 1:
66
+ candidates.append(".".join(parts[:-1]))
67
+ norm = fpath[:-3] if fpath.endswith(".py") else fpath
68
+ norm = norm.replace("\\", "/")
69
+ dotted_path = norm.replace("/", ".")
70
+ stem = Path(fpath).stem
71
+ for cand in candidates:
72
+ if not cand:
73
+ continue
74
+ if dotted_path.endswith(cand) or norm.endswith(cand.replace(".", "/")):
75
+ return True
76
+ if stem == cand.split(".")[-1]:
77
+ return True
78
+ return False
79
+
80
+
81
+ def _lang_family(path: str) -> str:
82
+ """Coarse language family for cross-language call-resolution guards."""
83
+ suffix = Path(path).suffix.lower()
84
+ if suffix in _JS_SUFFIXES:
85
+ return "js"
86
+ if suffix in _PY_SUFFIXES:
87
+ return "py"
88
+ if suffix == ".go":
89
+ return "go"
90
+ if suffix == ".rs":
91
+ return "rs"
92
+ return suffix or "other"
93
+
94
+
95
+ def _same_package_dir(path: str) -> str:
96
+ """Parent directory used as same-package / sibling-module scope."""
97
+ return str(Path(path).parent).replace("\\", "/")
98
+
99
+
100
+ def _method_name_hits(
101
+ by_name: Dict[str, List[Tuple[str, str, str]]],
102
+ name: str,
103
+ *,
104
+ caller_fam: str,
105
+ path_filter: Optional[Set[str]] = None,
106
+ ) -> List[Tuple[str, str, str]]:
107
+ """Symbols whose qualname is ``*.{name}`` (methods), same language family."""
108
+ suffix = f".{name}"
109
+ out: List[Tuple[str, str, str]] = []
110
+ for p, q, i in by_name.get(name, []):
111
+ if _lang_family(p) != caller_fam:
112
+ continue
113
+ if "." not in q or not q.endswith(suffix):
114
+ continue
115
+ if path_filter is not None and p not in path_filter:
116
+ continue
117
+ out.append((p, q, i))
118
+ return out
119
+
120
+
121
+ def module_suffix_index(py_files: List[str]) -> Dict[str, str]:
122
+ """Map every dotted suffix of each module path to its file (pure).
123
+
124
+ Ambiguous suffixes shared by two files are dropped. Packages (``__init__.py``)
125
+ are indexed under their package dotted path.
126
+ """
127
+ index: Dict[str, str] = {}
128
+ ambiguous: Set[str] = set()
129
+
130
+ def _register(dotted: str, file: str) -> None:
131
+ comps = [c for c in dotted.split(".") if c]
132
+ for i in range(len(comps)):
133
+ suffix = ".".join(comps[i:])
134
+ if not suffix:
135
+ continue
136
+ if suffix in index and index[suffix] != file:
137
+ ambiguous.add(suffix)
138
+ else:
139
+ index[suffix] = file
140
+
141
+ for f in py_files:
142
+ module_path = f[:-3] if f.endswith(".py") else f
143
+ if module_path.endswith("/__init__"):
144
+ _register(module_path[: -len("/__init__")].replace("/", "."), f)
145
+ else:
146
+ _register(module_path.replace("/", "."), f)
147
+ for suffix in ambiguous:
148
+ index.pop(suffix, None)
149
+ return index
150
+
151
+
152
+ def resolve_python_module(module: str, index: Dict[str, str]) -> Optional[str]:
153
+ """Resolve a Python import module string against ``module_suffix_index`` (pure)."""
154
+ comps = [c for c in module.split(".") if c]
155
+ if comps and comps[0] in getattr(sys, "stdlib_module_names", set()):
156
+ return None
157
+ while comps:
158
+ candidate = ".".join(comps)
159
+ if candidate in index:
160
+ return index[candidate]
161
+ comps = comps[:-1]
162
+ return None
163
+
164
+
165
+ def ancestor_init_files(target: str, py_file_set: Set[str]) -> List[str]:
166
+ """Ancestor package ``__init__.py`` files between ``target`` and repo root (pure)."""
167
+ parts = target.replace("\\", "/").split("/")
168
+ if not parts:
169
+ return []
170
+ if parts[-1] == "__init__.py":
171
+ dir_parts = parts[:-2]
172
+ else:
173
+ dir_parts = parts[:-1]
174
+ out: List[str] = []
175
+ for i in range(len(dir_parts), 0, -1):
176
+ init = "/".join(dir_parts[:i]) + "/__init__.py"
177
+ if init in py_file_set and init != target:
178
+ out.append(init)
179
+ return out
180
+
181
+
182
+ def _is_js_path(path: str) -> bool:
183
+ return Path(path).suffix.lower() in _JS_SUFFIXES
184
+
185
+
186
+ def resolve_import_edges(
187
+ extractions: Dict[str, FileExtraction],
188
+ files: List[str],
189
+ *,
190
+ root: Path,
191
+ mapper: Optional[Any] = None,
192
+ ) -> List[Tuple[str, str]]:
193
+ """Resolve file→file import edges from extractions (no import re-extraction).
194
+
195
+ Uses pure Python module→path helpers plus RepoMapper JS/Go/Rust resolvers
196
+ against already-extracted ``imports`` / specs. Does not update the parse cache.
197
+ """
198
+ from devcouncil.indexing.repo_mapper import RepoMapper
199
+
200
+ root = root.expanduser().resolve()
201
+ mapper = mapper if mapper is not None else RepoMapper(root)
202
+ file_set = set(files)
203
+ edges: List[Tuple[str, str]] = []
204
+ seen: Set[Tuple[str, str]] = set()
205
+
206
+ # --- Python ---
207
+ py_files = [f for f in files if f.endswith(".py")]
208
+ if py_files:
209
+ index = module_suffix_index(py_files)
210
+ py_file_set = set(py_files)
211
+ for rel in py_files:
212
+ ext = extractions.get(rel)
213
+ modules = list(ext.imports) if ext else []
214
+ for module in modules:
215
+ target = resolve_python_module(module, index)
216
+ if not target:
217
+ continue
218
+ for dest in [target, *ancestor_init_files(target, py_file_set)]:
219
+ if dest != rel and (rel, dest) not in seen:
220
+ seen.add((rel, dest))
221
+ edges.append((rel, dest))
222
+
223
+ # --- JS/TS ---
224
+ js_files = [f for f in files if _is_js_path(f)]
225
+ if js_files:
226
+ mapper._last_file_set = file_set
227
+ mapper._js_alias_cache = None
228
+ for rel in js_files:
229
+ ext = extractions.get(rel)
230
+ specs = list(ext.imports) if ext else []
231
+ for spec in specs:
232
+ target = mapper._resolve_js_spec(rel, spec, file_set)
233
+ if not target or target == rel:
234
+ continue
235
+ to_add = [target]
236
+ to_add.extend(mapper._follow_js_reexports(rel, target, file_set))
237
+ for dest in to_add:
238
+ if dest != rel and (rel, dest) not in seen:
239
+ seen.add((rel, dest))
240
+ edges.append((rel, dest))
241
+
242
+ # --- Go (specs from extractions + same-package co-membership) ---
243
+ go_files = [f for f in files if f.endswith(".go")]
244
+ if go_files:
245
+ go_module = mapper._go_module_prefix(file_set)
246
+ if go_module:
247
+ pkg_files: Dict[str, List[str]] = defaultdict(list)
248
+ for f in go_files:
249
+ if f.endswith("_test.go"):
250
+ continue
251
+ pkg_files[Path(f).parent.as_posix()].append(f)
252
+ for members in pkg_files.values():
253
+ if len(members) < 2:
254
+ continue
255
+ for a in members:
256
+ for b in members:
257
+ if a != b and (a, b) not in seen:
258
+ seen.add((a, b))
259
+ edges.append((a, b))
260
+ for rel in go_files:
261
+ ext = extractions.get(rel)
262
+ specs = list(ext.imports) if ext else []
263
+ for spec in specs:
264
+ if spec != go_module and not spec.startswith(go_module + "/"):
265
+ continue
266
+ rel_pkg = spec[len(go_module) :].lstrip("/")
267
+ target_dir = rel_pkg if rel_pkg else "."
268
+ for target in sorted(pkg_files.get(target_dir, ())):
269
+ if target != rel and (rel, target) not in seen:
270
+ seen.add((rel, target))
271
+ edges.append((rel, target))
272
+
273
+ # --- Rust: structured refs not in extractions; fall back to mapper ---
274
+ try:
275
+ for a, b in mapper._rust_import_edges(files, file_set):
276
+ if (a, b) not in seen:
277
+ seen.add((a, b))
278
+ edges.append((a, b))
279
+ except Exception:
280
+ logger.debug("Rust import-edge resolution failed", exc_info=True)
281
+
282
+ return edges
283
+
284
+
285
+ def _area_for(path: str) -> str:
286
+ parts = path.replace("\\", "/").split("/")
287
+ if not parts:
288
+ return "root"
289
+ if parts[0] in {"src", "lib", "app", "pkg"} and len(parts) >= 2:
290
+ return "/".join(parts[:2]) if len(parts) > 2 else parts[0]
291
+ return parts[0] if parts else "root"
292
+
293
+
294
+ def build_file_and_symbol_nodes(
295
+ extractions: Dict[str, FileExtraction],
296
+ *,
297
+ area_fn=None,
298
+ ) -> Tuple[List[GraphNode], Dict[str, str]]:
299
+ """Build file + symbol nodes. Returns (nodes, qualname_index path::qual -> id)."""
300
+ area_fn = area_fn or _area_for
301
+ nodes: List[GraphNode] = []
302
+ index: Dict[str, str] = {} # path::qualname -> id
303
+ name_index: Dict[str, List[str]] = defaultdict(list) # bare name -> ids
304
+
305
+ for path, ext in sorted(extractions.items()):
306
+ fid = file_node_id(path)
307
+ area = area_fn(path)
308
+ nodes.append(
309
+ GraphNode(
310
+ id=fid,
311
+ kind=NodeKind.FILE,
312
+ path=path,
313
+ name=Path(path).name,
314
+ area=area,
315
+ language=ext.language,
316
+ )
317
+ )
318
+ for sym in ext.symbols:
319
+ sid = symbol_node_id(path, sym.qualname)
320
+ kind = {
321
+ "function": NodeKind.FUNCTION,
322
+ "class": NodeKind.CLASS,
323
+ "method": NodeKind.METHOD,
324
+ "interface": NodeKind.INTERFACE,
325
+ "type": NodeKind.TYPE,
326
+ "struct": NodeKind.STRUCT,
327
+ "enum": NodeKind.ENUM,
328
+ "trait": NodeKind.TRAIT,
329
+ "property": NodeKind.PROPERTY,
330
+ "variable": NodeKind.VARIABLE,
331
+ "rationale": NodeKind.RATIONALE,
332
+ }.get(sym.kind, NodeKind.FUNCTION)
333
+ extras = {
334
+ "qualname": sym.qualname,
335
+ "bases": list(sym.bases),
336
+ "implements": list(getattr(sym, "implements", []) or []),
337
+ "decorators": list(sym.decorators),
338
+ }
339
+ if sym.kind == "rationale" and sym.bases:
340
+ extras["enclosing"] = sym.bases[0]
341
+ nodes.append(
342
+ GraphNode(
343
+ id=sid,
344
+ kind=kind,
345
+ path=path,
346
+ name=sym.name,
347
+ line=sym.line,
348
+ end_line=sym.end_line,
349
+ area=area,
350
+ language=ext.language,
351
+ exported=bool(sym.exported),
352
+ extras=extras,
353
+ )
354
+ )
355
+ index[f"{path}::{sym.qualname}"] = sid
356
+ if sym.kind != "rationale":
357
+ name_index[sym.name].append(sid)
358
+ if "." in sym.qualname:
359
+ name_index[sym.qualname].append(sid)
360
+
361
+ return nodes, dict(index)
362
+
363
+
364
+ def contains_and_defines_edges(
365
+ extractions: Dict[str, FileExtraction],
366
+ ) -> List[GraphEdge]:
367
+ edges: List[GraphEdge] = []
368
+ for path, ext in sorted(extractions.items()):
369
+ fid = file_node_id(path)
370
+ for sym in ext.symbols:
371
+ sid = symbol_node_id(path, sym.qualname)
372
+ # Single structural edge per symbol ("contains"). The historical
373
+ # duplicate "defines" edge doubled edge counts and skewed degree /
374
+ # blast-radius metrics for zero information gain.
375
+ edges.append(
376
+ GraphEdge(
377
+ source=fid,
378
+ target=sid,
379
+ kind="contains",
380
+ confidence=Confidence.EXTRACTED,
381
+ reason="ast definition",
382
+ )
383
+ )
384
+ # Class contains method
385
+ if sym.kind == "method" and "." in sym.qualname:
386
+ cls = sym.qualname.rsplit(".", 1)[0]
387
+ cid = symbol_node_id(path, cls)
388
+ edges.append(
389
+ GraphEdge(
390
+ source=cid,
391
+ target=sid,
392
+ kind="contains",
393
+ confidence=Confidence.EXTRACTED,
394
+ reason="method of class",
395
+ )
396
+ )
397
+ if sym.kind == "rationale":
398
+ target = file_node_id(path)
399
+ if sym.bases:
400
+ target = symbol_node_id(path, sym.bases[0])
401
+ edges.append(
402
+ GraphEdge(
403
+ source=sid,
404
+ target=target,
405
+ kind="documents",
406
+ confidence=Confidence.EXTRACTED,
407
+ reason="rationale comment",
408
+ )
409
+ )
410
+ return edges
411
+
412
+
413
+ def inherit_edges(extractions: Dict[str, FileExtraction], symbol_index: Dict[str, str]) -> List[GraphEdge]:
414
+ """Resolve bases to inherits, implements lists, and matching methods to overrides."""
415
+ by_name: Dict[str, List[str]] = defaultdict(list)
416
+ for key, sid in symbol_index.items():
417
+ qual = key.split("::", 1)[-1]
418
+ if qual.startswith("$rationale:"):
419
+ continue
420
+ by_name[qual.split(".")[-1]].append(sid)
421
+ by_name[qual].append(sid)
422
+
423
+ methods_by_class: Dict[str, Dict[str, str]] = defaultdict(dict)
424
+ for key, sid in symbol_index.items():
425
+ path, _, qual = key.partition("::")
426
+ if "." in qual and not qual.startswith("$"):
427
+ cls, _, meth = qual.partition(".")
428
+ methods_by_class[f"{path}::{cls}"][meth] = sid
429
+
430
+ def _resolve_base(path: str, base: str) -> tuple[Optional[str], Confidence, str, List[str]]:
431
+ base_name = base.split(".")[-1].split("<", 1)[0]
432
+ candidates = _stable_candidate_ids(by_name.get(base_name, []))
433
+ same = [c for c in candidates if c.startswith(f"{path}::")]
434
+ if len(same) == 1:
435
+ return same[0], Confidence.EXTRACTED, "same-file base", []
436
+ if len(candidates) == 1:
437
+ return candidates[0], Confidence.INFERRED, "unique global base", []
438
+ if len(candidates) > 1:
439
+ return (
440
+ candidates[0],
441
+ Confidence.AMBIGUOUS,
442
+ f"ambiguous base ({len(candidates)} candidates)",
443
+ list(candidates),
444
+ )
445
+ return None, Confidence.AMBIGUOUS, "unresolved", []
446
+
447
+ edges: List[GraphEdge] = []
448
+ type_kinds = {"class", "interface", "struct", "enum", "trait", "type"}
449
+ for path, ext in sorted(extractions.items()):
450
+ for sym in ext.symbols:
451
+ if sym.kind not in type_kinds:
452
+ continue
453
+ src_id = symbol_node_id(path, sym.qualname)
454
+ for base in sym.bases:
455
+ target, conf, reason, cands = _resolve_base(path, base)
456
+ if not target:
457
+ continue
458
+ edges.append(
459
+ GraphEdge(
460
+ source=src_id,
461
+ target=target,
462
+ kind="inherits",
463
+ confidence=conf,
464
+ reason=reason,
465
+ extras=_capped_candidate_extras(cands),
466
+ )
467
+ )
468
+ for iface in getattr(sym, "implements", []) or []:
469
+ target, conf, reason, cands = _resolve_base(path, iface)
470
+ if not target:
471
+ continue
472
+ edges.append(
473
+ GraphEdge(
474
+ source=src_id,
475
+ target=target,
476
+ kind="implements",
477
+ confidence=conf,
478
+ reason=reason.replace("base", "interface"),
479
+ extras=_capped_candidate_extras(cands),
480
+ )
481
+ )
482
+
483
+ for sym in ext.symbols:
484
+ if sym.kind != "method" or "." not in sym.qualname:
485
+ continue
486
+ cls, _, meth = sym.qualname.partition(".")
487
+ src_id = symbol_node_id(path, sym.qualname)
488
+ owner = next((s for s in ext.symbols if s.qualname == cls), None)
489
+ parent_names: List[str] = []
490
+ if owner is not None:
491
+ parent_names.extend(owner.bases)
492
+ parent_names.extend(getattr(owner, "implements", []) or [])
493
+ parent_names.extend(sym.bases)
494
+ seen_targets: Set[str] = set()
495
+ for parent in parent_names:
496
+ parent_name = parent.split(".")[-1].split("<", 1)[0]
497
+ for pid in by_name.get(parent_name, []):
498
+ parent_path = pid.split("::", 1)[0]
499
+ parent_qual = pid.split("::", 1)[-1]
500
+ cand = methods_by_class.get(f"{parent_path}::{parent_qual}", {}).get(meth)
501
+ if cand and cand != src_id and cand not in seen_targets:
502
+ seen_targets.add(cand)
503
+ edges.append(
504
+ GraphEdge(
505
+ source=src_id,
506
+ target=cand,
507
+ kind="overrides",
508
+ confidence=Confidence.INFERRED,
509
+ reason="matching method on base",
510
+ )
511
+ )
512
+ return edges
513
+
514
+
515
+ def decorator_edges(
516
+ extractions: Dict[str, FileExtraction],
517
+ symbol_index: Dict[str, str],
518
+ ) -> List[GraphEdge]:
519
+ """``decorator -> decorated symbol`` edges (DECORATES).
520
+
521
+ Resolved same-file first, then unique-global by bare name. Purely additive:
522
+ liveness of decorator functions is handled via the bare-decorator calls
523
+ emitted at extraction time.
524
+ """
525
+ by_name: Dict[str, List[str]] = defaultdict(list)
526
+ for key, sid in symbol_index.items():
527
+ qual = key.split("::", 1)[-1]
528
+ if qual.startswith("$rationale:"):
529
+ continue
530
+ if "." not in qual:
531
+ by_name[qual].append(sid)
532
+
533
+ edges: List[GraphEdge] = []
534
+ seen: Set[Tuple[str, str]] = set()
535
+ for path, ext in sorted(extractions.items()):
536
+ for sym in ext.symbols:
537
+ if not sym.decorators:
538
+ continue
539
+ target = symbol_node_id(path, sym.qualname)
540
+ for dec in sym.decorators:
541
+ bare = dec.split("(", 1)[0].split(".")[-1].strip()
542
+ if not bare:
543
+ continue
544
+ src = symbol_index.get(f"{path}::{bare}")
545
+ conf = Confidence.EXTRACTED
546
+ reason = "same-file decorator"
547
+ if not src:
548
+ cands = _stable_candidate_ids(by_name.get(bare, []))
549
+ if len(cands) != 1:
550
+ continue
551
+ src = cands[0]
552
+ conf = Confidence.INFERRED
553
+ reason = "unique global decorator"
554
+ if src == target or (src, target) in seen:
555
+ continue
556
+ seen.add((src, target))
557
+ edges.append(
558
+ GraphEdge(
559
+ source=src,
560
+ target=target,
561
+ kind="decorates",
562
+ confidence=conf,
563
+ reason=reason,
564
+ )
565
+ )
566
+ return edges
567
+
568
+
569
+ def import_graph_edges(file_edges: List[Tuple[str, str]]) -> List[GraphEdge]:
570
+ return [
571
+ GraphEdge(
572
+ source=file_node_id(a),
573
+ target=file_node_id(b),
574
+ kind="imports",
575
+ confidence=Confidence.EXTRACTED,
576
+ reason="import resolution",
577
+ )
578
+ for a, b in file_edges
579
+ if a != b
580
+ ]
581
+
582
+
583
+ def named_import_edges(
584
+ extractions: Dict[str, FileExtraction],
585
+ symbol_index: Dict[str, str],
586
+ file_edges: List[Tuple[str, str]],
587
+ ) -> List[GraphEdge]:
588
+ """``from mod import Name`` → imports edge to the resolved symbol (keeps it live)."""
589
+ imports_of: Dict[str, Set[str]] = defaultdict(set)
590
+ for a, b in file_edges:
591
+ imports_of[a].add(b)
592
+
593
+ # path::bare_name -> symbol id (top-level only)
594
+ by_path_name: Dict[str, str] = {}
595
+ for key, sid in symbol_index.items():
596
+ path, _, qual = key.partition("::")
597
+ if "." not in qual:
598
+ by_path_name[f"{path}::{qual}"] = sid
599
+
600
+ edges: List[GraphEdge] = []
601
+ seen: Set[Tuple[str, str]] = set()
602
+ for path, ext in sorted(extractions.items()):
603
+ src = file_node_id(path)
604
+ imported_files = sorted(imports_of.get(path, set()))
605
+ for detail in ext.import_details:
606
+ module = str(detail.module or "")
607
+ for name in detail.names:
608
+ bare = name.split(".")[-1]
609
+ if not bare or bare == "*":
610
+ continue
611
+ # ``from pkg import show`` where show is a submodule: file-level
612
+ # import edges already keep the module live. Do not bind the
613
+ # bare name to a same-named function in a sibling module
614
+ # (hash-order set iteration previously flipped those targets).
615
+ alias_target = ""
616
+ if isinstance(detail.alias_map, dict):
617
+ alias_target = str(
618
+ detail.alias_map.get(bare) or detail.alias_map.get(name) or ""
619
+ )
620
+ module_hint = alias_target or (
621
+ f"{module}.{bare}" if module and bare not in module.split(".") else module
622
+ )
623
+ if any(Path(f).stem == bare for f in imported_files):
624
+ continue
625
+
626
+ # Prefer symbols in files matching the import module / alias.
627
+ scoped = [
628
+ fpath
629
+ for fpath in imported_files
630
+ if by_path_name.get(f"{fpath}::{bare}")
631
+ and (not module_hint or _module_file_match(module_hint, fpath))
632
+ ]
633
+ pool = scoped if scoped else [
634
+ fpath
635
+ for fpath in imported_files
636
+ if by_path_name.get(f"{fpath}::{bare}")
637
+ ]
638
+ matches = _stable_candidate_ids(
639
+ [by_path_name[f"{fpath}::{bare}"] for fpath in pool]
640
+ )
641
+ target: Optional[str] = None
642
+ if matches:
643
+ # Unique or stable pick among remaining collisions.
644
+ target = matches[0]
645
+ if target and (src, target) not in seen:
646
+ seen.add((src, target))
647
+ edges.append(
648
+ GraphEdge(
649
+ source=src,
650
+ target=target,
651
+ kind="imports",
652
+ confidence=Confidence.EXTRACTED,
653
+ reason="named import",
654
+ )
655
+ )
656
+ return edges
657
+
658
+
659
+ def resolve_calls(
660
+ extractions: Dict[str, FileExtraction],
661
+ symbol_index: Dict[str, str],
662
+ file_edges: List[Tuple[str, str]],
663
+ *,
664
+ class_ids: Optional[Set[str]] = None,
665
+ unresolved_out: Optional[List[Dict[str, Any]]] = None,
666
+ ) -> List[GraphEdge]:
667
+ """Confidence ladder: same-file → import-resolved → unique-global → ambiguous.
668
+
669
+ When ``class_ids`` is given, calls that target a class node are tagged
670
+ ``extras["instantiates"] = True`` (constructor calls) so consumers can
671
+ distinguish instantiation from plain function calls.
672
+ """
673
+ # name -> list of (path, qualname, id)
674
+ by_name: Dict[str, List[Tuple[str, str, str]]] = defaultdict(list)
675
+ for key, sid in sorted(symbol_index.items()):
676
+ path, _, qual = key.partition("::")
677
+ by_name[qual.split(".")[-1]].append((path, qual, sid))
678
+ if "." in qual:
679
+ by_name[qual].append((path, qual, sid))
680
+ for name in by_name:
681
+ by_name[name].sort(key=lambda item: item[2])
682
+
683
+ # importer -> imported files
684
+ imports_of: Dict[str, Set[str]] = defaultdict(set)
685
+ for a, b in file_edges:
686
+ imports_of[a].add(b)
687
+
688
+ # Local alias maps from extractions (import as)
689
+ alias_of: Dict[str, Dict[str, str]] = {}
690
+ for path, ext in sorted(extractions.items()):
691
+ amap: Dict[str, str] = {}
692
+ for detail in ext.import_details:
693
+ amap.update(detail.alias_map)
694
+ # Also map imported module last segment
695
+ for mod in ext.imports:
696
+ amap.setdefault(mod.split(".")[-1], mod)
697
+ alias_of[path] = amap
698
+
699
+ edges: List[GraphEdge] = []
700
+ seen: Set[Tuple[str, str]] = set()
701
+
702
+ for path, ext in sorted(extractions.items()):
703
+ # Caller context: prefer enclosing function/method by line
704
+ enclosing: List[Tuple[int, int, str]] = []
705
+ for sym in ext.symbols:
706
+ enclosing.append((sym.line, sym.end_line, symbol_node_id(path, sym.qualname)))
707
+ enclosing.sort()
708
+
709
+ for call in ext.calls:
710
+ caller_id = file_node_id(path)
711
+ for start, end, sid in enclosing:
712
+ if start <= call.line <= end:
713
+ caller_id = sid
714
+ # prefer innermost (last match)
715
+ target_id: Optional[str] = None
716
+ confidence = Confidence.AMBIGUOUS
717
+ reason = "unresolved"
718
+ ambig_candidates: List[str] = []
719
+
720
+ # 0) Attribute call with inferable receiver class: Foo.bar / Foo().bar
721
+ if target_id is None and call.receiver and call.name:
722
+ recv = call.receiver.split(".")[-1]
723
+ # Same-file Class.method
724
+ same_method = symbol_index.get(f"{path}::{recv}.{call.name}")
725
+ if same_method:
726
+ target_id = same_method
727
+ confidence = Confidence.EXTRACTED
728
+ reason = "attribute-call same-file class"
729
+ else:
730
+ # Unique Class.method across imported / global (same language)
731
+ method_key = f"{recv}.{call.name}"
732
+ imported_files = imports_of.get(path, set())
733
+ caller_fam = _lang_family(path)
734
+ cands = [
735
+ (p, q, i)
736
+ for p, q, i in by_name.get(call.name, [])
737
+ if _lang_family(p) == caller_fam
738
+ and (
739
+ q == method_key
740
+ or (
741
+ q.endswith(f".{call.name}")
742
+ and q.rsplit(".", 1)[0] == recv
743
+ )
744
+ )
745
+ ]
746
+ if not cands:
747
+ cands = [
748
+ (p, q, i)
749
+ for p, q, i in by_name.get(method_key, [])
750
+ if _lang_family(p) == caller_fam
751
+ ]
752
+ in_imp = [(p, q, i) for p, q, i in cands if p in imported_files or p == path]
753
+ pool = in_imp or cands
754
+ if len(pool) == 1:
755
+ target_id = pool[0][2]
756
+ confidence = Confidence.INFERRED
757
+ reason = "attribute-call inferred class"
758
+ elif len(pool) > 1:
759
+ target_id = None
760
+ confidence = Confidence.AMBIGUOUS
761
+ reason = f"attribute-call ambiguous ({len(pool)} candidates)"
762
+ ambig_candidates = _stable_candidate_ids([i for _p, _q, i in pool])
763
+
764
+ # 0b) Unique *.{name} method when receiver is a variable (not a type name).
765
+ # Prefer same-file, then same-package / imported (same language family).
766
+ if (
767
+ target_id is None
768
+ and not ambig_candidates
769
+ and call.receiver
770
+ and call.receiver not in ("self", "cls", "this")
771
+ and call.name
772
+ ):
773
+ caller_fam = _lang_family(path)
774
+ same_file = _method_name_hits(
775
+ by_name, call.name, caller_fam=caller_fam, path_filter={path}
776
+ )
777
+ if len(same_file) == 1:
778
+ target_id = same_file[0][2]
779
+ confidence = Confidence.INFERRED
780
+ reason = "unique same-file method"
781
+ elif len(same_file) > 1:
782
+ confidence = Confidence.AMBIGUOUS
783
+ reason = f"ambiguous same-file method ({len(same_file)} candidates)"
784
+ ambig_candidates = _stable_candidate_ids([i for _p, _q, i in same_file])
785
+ else:
786
+ pkg = _same_package_dir(path)
787
+ imported = imports_of.get(path, set())
788
+ scope = {
789
+ p
790
+ for p, q, _i in by_name.get(call.name, [])
791
+ if _lang_family(p) == caller_fam
792
+ and "." in q
793
+ and q.endswith(f".{call.name}")
794
+ and (_same_package_dir(p) == pkg or p in imported)
795
+ }
796
+ scoped = _method_name_hits(
797
+ by_name, call.name, caller_fam=caller_fam, path_filter=scope
798
+ )
799
+ if len(scoped) == 1:
800
+ target_id = scoped[0][2]
801
+ confidence = Confidence.INFERRED
802
+ reason = "unique package/imported method"
803
+ elif len(scoped) > 1:
804
+ confidence = Confidence.AMBIGUOUS
805
+ reason = (
806
+ f"ambiguous package/imported method ({len(scoped)} candidates)"
807
+ )
808
+ ambig_candidates = _stable_candidate_ids([i for _p, _q, i in scoped])
809
+
810
+ # 1) same-file exact qualname / name (``this`` covers TS/JS methods)
811
+ if call.receiver in ("self", "cls", "this") and call.name:
812
+ # method on same class — find enclosing class
813
+ for start, end, sid in enclosing:
814
+ if start <= call.line <= end and "." in sid:
815
+ cls = sid.split("::", 1)[1].rsplit(".", 1)[0]
816
+ cand = symbol_index.get(f"{path}::{cls}.{call.name}")
817
+ if cand:
818
+ target_id = cand
819
+ confidence = Confidence.EXTRACTED
820
+ reason = "same-file self/cls call"
821
+ break
822
+ if target_id is None and call.qualname_hint:
823
+ same = symbol_index.get(f"{path}::{call.qualname_hint}")
824
+ if same:
825
+ target_id = same
826
+ confidence = Confidence.EXTRACTED
827
+ reason = "same-file qualname"
828
+ elif not call.receiver or call.receiver in ("self", "cls", "this"):
829
+ # Bare-name fallback only when there is no module/class receiver —
830
+ # otherwise ``prompt_handlers.get_prompt`` wrongly binds to a
831
+ # same-file ``get_prompt``.
832
+ same = symbol_index.get(f"{path}::{call.name}")
833
+ if same:
834
+ target_id = same
835
+ confidence = Confidence.EXTRACTED
836
+ reason = "same-file name"
837
+
838
+ # 2) import-resolved: receiver is an imported alias pointing at a module/file
839
+ if target_id is None and call.receiver:
840
+ amap = alias_of.get(path, {})
841
+ imported_files = imports_of.get(path, set())
842
+ # Alias → defining module file (``import prompts as prompt_handlers``)
843
+ alias_target = amap.get(call.receiver, "")
844
+ alias_files: List[str] = []
845
+ if alias_target:
846
+ dotted = alias_target.replace(".", "/")
847
+ for f in sorted(imported_files):
848
+ norm = f[:-3] if f.endswith(".py") else f
849
+ if norm.replace("\\", "/").endswith(dotted) or norm.replace(
850
+ "/", "."
851
+ ).endswith(alias_target):
852
+ alias_files.append(f)
853
+ elif Path(f).stem == alias_target.split(".")[-1]:
854
+ alias_files.append(f)
855
+ candidates = [
856
+ (p, q, i)
857
+ for p, q, i in by_name.get(call.name, [])
858
+ if p in (alias_files or imported_files)
859
+ ]
860
+ if alias_files:
861
+ narrowed = [
862
+ (p, q, i) for p, q, i in by_name.get(call.name, []) if p in alias_files
863
+ ]
864
+ if len(narrowed) == 1:
865
+ target_id = narrowed[0][2]
866
+ confidence = Confidence.EXTRACTED
867
+ reason = "import-alias module attr"
868
+ elif len(narrowed) > 1:
869
+ # Prefer top-level function over methods
870
+ top = [(p, q, i) for p, q, i in narrowed if "." not in q]
871
+ pool = top or narrowed
872
+ if len(pool) == 1:
873
+ target_id = pool[0][2]
874
+ confidence = Confidence.INFERRED
875
+ reason = "import-alias module attr"
876
+ else:
877
+ target_id = None
878
+ confidence = Confidence.AMBIGUOUS
879
+ reason = f"import-alias ambiguous ({len(pool)} candidates)"
880
+ ambig_candidates = _stable_candidate_ids([i for _p, _q, i in pool])
881
+ elif len(candidates) == 1:
882
+ target_id = candidates[0][2]
883
+ confidence = Confidence.INFERRED
884
+ reason = "import-resolved"
885
+ elif call.receiver in amap or call.receiver:
886
+ # Prefer imported file whose stem matches receiver
887
+ recv_files = [
888
+ f
889
+ for f in imported_files
890
+ if Path(f).stem == call.receiver
891
+ or f.replace("/", ".").endswith(call.receiver)
892
+ ]
893
+ narrowed = [
894
+ (p, q, i)
895
+ for p, q, i in by_name.get(call.name, [])
896
+ if p in recv_files or (not recv_files and p in imported_files)
897
+ ]
898
+ if len(narrowed) == 1:
899
+ target_id = narrowed[0][2]
900
+ confidence = Confidence.INFERRED
901
+ reason = "import-resolved receiver"
902
+
903
+ # 3) unique global (same language family only — never JS→Python etc.)
904
+ if target_id is None:
905
+ # Skip stdlib-looking bare names
906
+ if call.name in getattr(sys, "stdlib_module_names", set()):
907
+ continue
908
+ caller_fam = _lang_family(path)
909
+ cands = [
910
+ (p, q, i)
911
+ for p, q, i in by_name.get(call.name, [])
912
+ if _lang_family(p) == caller_fam
913
+ ]
914
+ # Prefer non-method top-level when bare call
915
+ if not call.receiver:
916
+ top = [(p, q, i) for p, q, i in cands if "." not in q]
917
+ if len(top) == 1:
918
+ target_id = top[0][2]
919
+ confidence = Confidence.INFERRED
920
+ reason = "unique global"
921
+ elif len(cands) == 1:
922
+ target_id = cands[0][2]
923
+ confidence = Confidence.INFERRED
924
+ reason = "unique global"
925
+ elif len(cands) > 1:
926
+ target_id = None
927
+ confidence = Confidence.AMBIGUOUS
928
+ reason = f"ambiguous ({len(cands)} candidates)"
929
+ ambig_candidates = _stable_candidate_ids([i for _p, _q, i in cands])
930
+ else:
931
+ if len(cands) == 1:
932
+ target_id = cands[0][2]
933
+ confidence = Confidence.INFERRED
934
+ reason = "unique global"
935
+ elif len(cands) > 1:
936
+ target_id = None
937
+ confidence = Confidence.AMBIGUOUS
938
+ reason = f"ambiguous ({len(cands)} candidates)"
939
+ ambig_candidates = _stable_candidate_ids([i for _p, _q, i in cands])
940
+
941
+ # Ambiguous name → fan-out edges to every candidate so liveness does
942
+ # not drop real callees when the resolver cannot pick a unique target
943
+ # (e.g. ``result.to_dict()`` among many ``to_dict`` methods).
944
+ # Attach capped ``candidates`` once (not on every fan-out edge).
945
+ if target_id is None and ambig_candidates:
946
+ ambig_candidates = _stable_candidate_ids(ambig_candidates)
947
+ cand_extras = _capped_candidate_extras(ambig_candidates)
948
+ attached_candidates = False
949
+ for cand_id in ambig_candidates:
950
+ if (caller_id, cand_id) in seen:
951
+ continue
952
+ seen.add((caller_id, cand_id))
953
+ extras: Dict[str, Any] = {}
954
+ if not attached_candidates:
955
+ extras.update(cand_extras)
956
+ attached_candidates = True
957
+ if class_ids and cand_id in class_ids:
958
+ extras["instantiates"] = True
959
+ edges.append(
960
+ GraphEdge(
961
+ source=caller_id,
962
+ target=cand_id,
963
+ kind="calls",
964
+ confidence=Confidence.AMBIGUOUS,
965
+ reason=reason or f"ambiguous ({len(ambig_candidates)} candidates)",
966
+ extras=extras,
967
+ )
968
+ )
969
+ if unresolved_out is not None:
970
+ unresolved_out.append(
971
+ {
972
+ "source_id": caller_id,
973
+ "path": path,
974
+ "line": call.line,
975
+ "name": call.name,
976
+ "kind": "ambiguous_call",
977
+ "reason": reason,
978
+ "evidence": {
979
+ "candidates": _stable_candidate_ids(ambig_candidates)[
980
+ :AMBIGUOUS_CANDIDATES_CAP
981
+ ],
982
+ "receiver": call.receiver,
983
+ },
984
+ }
985
+ )
986
+ continue
987
+
988
+ if target_id and (caller_id, target_id) not in seen:
989
+ seen.add((caller_id, target_id))
990
+ extras = {}
991
+ if confidence == Confidence.AMBIGUOUS and ambig_candidates:
992
+ extras.update(_capped_candidate_extras(ambig_candidates))
993
+ if class_ids and target_id in class_ids:
994
+ extras["instantiates"] = True
995
+ edges.append(
996
+ GraphEdge(
997
+ source=caller_id,
998
+ target=target_id,
999
+ kind="calls",
1000
+ confidence=confidence,
1001
+ reason=reason,
1002
+ extras=extras,
1003
+ )
1004
+ )
1005
+ elif unresolved_out is not None and target_id is None:
1006
+ unresolved_out.append(
1007
+ {
1008
+ "source_id": caller_id,
1009
+ "path": path,
1010
+ "line": call.line,
1011
+ "name": call.name,
1012
+ "kind": "call",
1013
+ "reason": reason,
1014
+ "evidence": {
1015
+ "receiver": call.receiver,
1016
+ "qualname_hint": call.qualname_hint,
1017
+ },
1018
+ }
1019
+ )
1020
+ return edges