devcouncil 0.3.0 → 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,992 @@
1
+ """Graph-based file + symbol liveness / dead-code detection.
2
+
3
+ devcouncil: allow-unwired — package-private; reached only via graph.build / CLI.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import re
10
+ from collections import defaultdict
11
+ from dataclasses import asdict
12
+ from pathlib import Path
13
+ from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, cast
14
+
15
+ from devcouncil.indexing.graph.extract_python import (
16
+ ExtractedCall,
17
+ ExtractedImport,
18
+ ExtractedSymbol,
19
+ FileExtraction,
20
+ )
21
+ from devcouncil.indexing.graph.schema import (
22
+ Confidence,
23
+ DeadCodeEntry,
24
+ GraphEdge,
25
+ GraphNode,
26
+ NodeKind,
27
+ )
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ # React class lifecycle methods — framework-invoked; no inbound call edges.
32
+ _REACT_LIFECYCLE_METHODS = frozenset(
33
+ {
34
+ "render",
35
+ "componentDidMount",
36
+ "componentDidUpdate",
37
+ "componentWillUnmount",
38
+ "shouldComponentUpdate",
39
+ "getSnapshotBeforeUpdate",
40
+ "componentDidCatch",
41
+ "getDerivedStateFromProps",
42
+ "getDerivedStateFromError",
43
+ "UNSAFE_componentWillMount",
44
+ "UNSAFE_componentWillReceiveProps",
45
+ "UNSAFE_componentWillUpdate",
46
+ "componentWillMount",
47
+ "componentWillReceiveProps",
48
+ "componentWillUpdate",
49
+ }
50
+ )
51
+
52
+ _CONFIDENCE_RANK = {
53
+ Confidence.AMBIGUOUS: 0,
54
+ Confidence.INFERRED: 1,
55
+ Confidence.EXTRACTED: 2,
56
+ "ambiguous": 0,
57
+ "inferred": 1,
58
+ "extracted": 2,
59
+ }
60
+
61
+ # Default: if unreachable covers ≥25% of liveness code files, static BFS is
62
+ # too blind (dynamic imports / routers / JSX) — omit the flood.
63
+ _DEFAULT_UNREACHABLE_UNRELIABLE_RATIO = 0.25
64
+ # Ratio alone misreads small repos: 1 dead file out of 3 is real signal, not
65
+ # analysis blindness. Only gate when the absolute flood is at least this big.
66
+ _MIN_UNREACHABLE_FOR_RATIO_GATE = 5
67
+
68
+
69
+ def _unreachable_unreliable_ratio(root: Path) -> float:
70
+ try:
71
+ from devcouncil.app.config import load_config
72
+
73
+ return float(load_config(root).indexing.unreachable_unreliable_ratio)
74
+ except Exception:
75
+ return _DEFAULT_UNREACHABLE_UNRELIABLE_RATIO
76
+
77
+
78
+ def _apply_unreachable_ratio_gate(
79
+ root: Path,
80
+ files: List[str],
81
+ unreachable: List[str],
82
+ unreliable: bool,
83
+ ) -> Tuple[List[str], bool, dict]:
84
+ """Fail-soft when unreachable density indicates analysis blind spots."""
85
+ from devcouncil.indexing.wiring import is_liveness_code_file
86
+
87
+ code_files = sum(1 for f in files if is_liveness_code_file(f))
88
+ ratio = (len(unreachable) / code_files) if code_files else 0.0
89
+ threshold = _unreachable_unreliable_ratio(root)
90
+ meta: Dict[str, Any] = {
91
+ "unreachable_total": len(unreachable),
92
+ "liveness_code_files": code_files,
93
+ "unreachable_ratio": round(ratio, 4),
94
+ "unreachable_ratio_threshold": threshold,
95
+ }
96
+ if unreliable:
97
+ meta["unreachable_unreliable_reason"] = "empty_production_entry_roots"
98
+ return [], True, meta
99
+ if (
100
+ threshold > 0
101
+ and code_files > 0
102
+ and len(unreachable) >= _MIN_UNREACHABLE_FOR_RATIO_GATE
103
+ and ratio >= threshold
104
+ ):
105
+ meta["unreachable_unreliable_reason"] = "high_unreachable_ratio"
106
+ return [], True, meta
107
+ return unreachable, False, meta
108
+
109
+
110
+ def file_liveness(
111
+ root: Path,
112
+ files: List[str],
113
+ file_edges: List[Tuple[str, str]],
114
+ *,
115
+ cap: Optional[int] = None,
116
+ ) -> Tuple[List[str], List[str], List[str], bool]:
117
+ """Return (entry_roots, unwired, unreachable, unreachable_unreliable).
118
+
119
+ ``cap`` defaults to uncapped (``None`` / ``<= 0``). Caps belong on
120
+ ``repo_map.json`` serialization, not the in-memory / ``code_graph.json`` lists.
121
+
122
+ When production entry roots are empty, unreachable BFS would flood every
123
+ non-root file. Fail soft: ``unreachable=[]`` and
124
+ ``unreachable_unreliable=True`` (still compute unwired).
125
+
126
+ When unreachable density exceeds ``indexing.unreachable_unreliable_ratio``
127
+ (default 0.25), also fail soft — treat the list as low-confidence noise.
128
+ """
129
+ from devcouncil.indexing.wiring import (
130
+ build_dynamic_import_index,
131
+ entry_roots,
132
+ has_allow_unwired,
133
+ is_liveness_code_file,
134
+ is_test_path,
135
+ reference_cleared,
136
+ structural_exemptions,
137
+ )
138
+
139
+ limit = None if (cap is None or cap <= 0) else cap
140
+ roots = entry_roots(root, files)
141
+ prod_roots = entry_roots(root, files, production_only=True)
142
+ root_set = set(roots)
143
+ prod_root_set = set(prod_roots)
144
+ dyn_index = build_dynamic_import_index(root, files)
145
+ unreachable_unreliable = not bool(prod_roots)
146
+
147
+ inbound: Dict[str, Set[str]] = defaultdict(set)
148
+ outbound: Dict[str, Set[str]] = defaultdict(set)
149
+ for importer, imported in file_edges:
150
+ inbound[imported].add(importer)
151
+ outbound[importer].add(imported)
152
+
153
+ unwired: List[str] = []
154
+ for f in sorted(files):
155
+ if not is_liveness_code_file(f):
156
+ continue
157
+ if f in root_set or structural_exemptions(f):
158
+ continue
159
+ if has_allow_unwired(root, f):
160
+ continue
161
+ non_test_importers = {i for i in inbound.get(f, ()) if not is_test_path(i)}
162
+ if non_test_importers:
163
+ continue
164
+ if reference_cleared(
165
+ root, f, skip_files=set(), git_files=files, dynamic_index=dyn_index
166
+ ):
167
+ continue
168
+ unwired.append(f)
169
+ if limit is not None and len(unwired) >= limit:
170
+ break
171
+
172
+ unreachable: List[str] = []
173
+ if not unreachable_unreliable:
174
+ reachable: Set[str] = set()
175
+ queue = list(prod_roots)
176
+ seen_q: Set[str] = set(queue)
177
+ while queue:
178
+ cur = queue.pop()
179
+ reachable.add(cur)
180
+ for nxt in outbound.get(cur, ()):
181
+ if nxt not in seen_q:
182
+ seen_q.add(nxt)
183
+ queue.append(nxt)
184
+
185
+ for f in sorted(files):
186
+ if not is_liveness_code_file(f):
187
+ continue
188
+ if f in prod_root_set or f in root_set or structural_exemptions(f):
189
+ continue
190
+ if f in reachable:
191
+ continue
192
+ # Dynamic entrypoints / markers clear unwired; keep unreachable in parity.
193
+ if has_allow_unwired(root, f):
194
+ continue
195
+ if reference_cleared(
196
+ root, f, skip_files=set(), git_files=files, dynamic_index=dyn_index
197
+ ):
198
+ continue
199
+ unreachable.append(f)
200
+ if limit is not None and len(unreachable) >= limit:
201
+ break
202
+
203
+ unreachable, unreachable_unreliable, _ratio_meta = _apply_unreachable_ratio_gate(
204
+ root, files, unreachable, unreachable_unreliable
205
+ )
206
+ return prod_roots, unwired, unreachable, unreachable_unreliable
207
+
208
+
209
+ def _token_scan_dead(
210
+ root: Path,
211
+ files: List[str],
212
+ *,
213
+ cap: int = 0,
214
+ lsp_refs: bool = False,
215
+ mapper: Optional[object] = None,
216
+ ) -> Tuple[List[str], List[str], Set[str]]:
217
+ """Legacy token-scan dead symbols via RepoMapper (format path:line name).
218
+
219
+ Returns (dead_list, symbol_index, dead_keys as path::name).
220
+ """
221
+ from devcouncil.indexing.repo_mapper import RepoMapper
222
+
223
+ m = cast(RepoMapper, mapper) if mapper is not None else RepoMapper(root)
224
+ dead, index = m._dead_symbol_candidates(
225
+ files, cap=cap if cap > 0 else 0, with_index=True, lsp_refs=lsp_refs
226
+ )
227
+ keys: Set[str] = set()
228
+ for entry in dead:
229
+ # "path:line name"
230
+ loc, _, name = entry.partition(" ")
231
+ path, _, _line = loc.rpartition(":")
232
+ if path and name:
233
+ keys.add(f"{path}::{name}")
234
+ return dead, index, keys
235
+
236
+
237
+ def _reference_index(
238
+ extractions: Dict[str, FileExtraction],
239
+ ) -> Dict[str, Set[str]]:
240
+ """name -> set of files that reference it outside a call site.
241
+
242
+ Covers callbacks passed by name (``register(handler)``), registry/dict
243
+ dispatch, and attribute access on ``@property``-style members — including
244
+ references from tests (so property-only APIs are not inferred-dead).
245
+ """
246
+ index: Dict[str, Set[str]] = defaultdict(set)
247
+ for path, ext in extractions.items():
248
+ for name in getattr(ext, "references", None) or ():
249
+ index[name].add(path)
250
+ return index
251
+
252
+
253
+ def build_liveness_shard(root: Path, extraction: FileExtraction) -> dict[str, object]:
254
+ """Compact persisted reference shard used by one-file liveness updates."""
255
+ from devcouncil.indexing.wiring import (
256
+ dynamic_import_keys,
257
+ strip_js_comments,
258
+ strip_py_comments,
259
+ strip_string_literals,
260
+ )
261
+
262
+ try:
263
+ source = (root / extraction.path).read_text(encoding="utf-8", errors="replace")
264
+ except OSError:
265
+ source = ""
266
+ cleaned = (
267
+ strip_py_comments(source)
268
+ if Path(extraction.path).suffix.lower() == ".py"
269
+ else strip_js_comments(source)
270
+ )
271
+ cleaned = strip_string_literals(cleaned)
272
+ token_lines: dict[str, list[int]] = defaultdict(list)
273
+ for lineno, line in enumerate(cleaned.splitlines(), 1):
274
+ for token in re.findall(r"\b[A-Za-z_][A-Za-z0-9_]*\b", line):
275
+ if len(token) >= 2:
276
+ token_lines[token].append(lineno)
277
+ return {
278
+ "extraction": asdict(extraction),
279
+ "token_lines": dict(token_lines),
280
+ "dynamic_import_keys": sorted(dynamic_import_keys(extraction.path, source)),
281
+ "allow_unwired": "devcouncil: allow-unwired" in source,
282
+ }
283
+
284
+
285
+ def dynamic_import_index_from_shards(
286
+ shards: dict[str, dict[str, object]],
287
+ ) -> dict[str, Set[str]]:
288
+ """Rebuild the dynamic-reference index without rescanning repository files."""
289
+ from devcouncil.indexing.wiring import is_test_path
290
+
291
+ index: dict[str, Set[str]] = defaultdict(set)
292
+ for path, shard in shards.items():
293
+ if is_test_path(path):
294
+ continue
295
+ raw_keys = shard.get("dynamic_import_keys")
296
+ keys = raw_keys if isinstance(raw_keys, list) else []
297
+ for key in keys:
298
+ if isinstance(key, str) and key:
299
+ index[key].add(path)
300
+ return dict(index)
301
+
302
+
303
+ def extraction_from_liveness_shard(shard: dict[str, object]) -> FileExtraction:
304
+ extraction = shard.get("extraction")
305
+ raw = dict(extraction) if isinstance(extraction, dict) else {}
306
+ return FileExtraction(
307
+ path=str(raw.get("path") or ""),
308
+ language=str(raw.get("language") or ""),
309
+ imports=list(raw.get("imports") or []),
310
+ import_details=[ExtractedImport(**row) for row in raw.get("import_details") or []],
311
+ symbols=[ExtractedSymbol(**row) for row in raw.get("symbols") or []],
312
+ calls=[ExtractedCall(**row) for row in raw.get("calls") or []],
313
+ all_exports=list(raw.get("all_exports") or []),
314
+ reexports=list(raw.get("reexports") or []),
315
+ references=list(raw.get("references") or []),
316
+ )
317
+
318
+
319
+ def token_dead_from_shards(
320
+ nodes: List[GraphNode],
321
+ shards: dict[str, dict[str, object]],
322
+ ) -> Tuple[List[str], List[str], Set[str]]:
323
+ """Compute token agreement from persisted per-file token occurrence shards."""
324
+ from devcouncil.indexing.wiring import (
325
+ is_private_symbol,
326
+ is_test_path,
327
+ is_vendored_path,
328
+ is_wiring_decorated,
329
+ )
330
+
331
+ token_files: Dict[str, Set[str]] = defaultdict(set)
332
+ token_lines: Dict[str, Dict[str, Set[int]]] = defaultdict(lambda: defaultdict(set))
333
+ for path, shard in shards.items():
334
+ raw_token_lines = shard.get("token_lines")
335
+ rows = raw_token_lines if isinstance(raw_token_lines, dict) else {}
336
+ for name, lines in rows.items():
337
+ token_files[str(name)].add(path)
338
+ if isinstance(lines, list):
339
+ token_lines[str(name)][path].update(int(line) for line in lines)
340
+ # Read all_exports from the raw shard payload: rebuilding a FileExtraction
341
+ # here would instantiate every symbol/call dataclass in the repo per sync.
342
+ protected_by_path: Dict[str, Set[str]] = {}
343
+ for path, shard in shards.items():
344
+ raw_extraction = shard.get("extraction")
345
+ exports = (
346
+ raw_extraction.get("all_exports") if isinstance(raw_extraction, dict) else None
347
+ )
348
+ protected_by_path[path] = set(exports or [])
349
+ definitions: List[GraphNode] = []
350
+ for node in nodes:
351
+ suffix = Path(node.path).suffix.lower()
352
+ if node.kind not in {NodeKind.FUNCTION, NodeKind.CLASS}:
353
+ continue
354
+ if suffix not in {".py", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"}:
355
+ continue
356
+ if is_test_path(node.path) or is_vendored_path(node.path):
357
+ continue
358
+ if "." in str(node.extras.get("qualname") or node.name):
359
+ continue
360
+ if is_private_symbol(node.name):
361
+ continue
362
+ if node.name in protected_by_path.get(node.path, set()):
363
+ continue
364
+ if is_wiring_decorated(list(node.extras.get("decorators") or [])):
365
+ continue
366
+ if suffix != ".py" and not node.exported:
367
+ continue
368
+ definitions.append(node)
369
+ dead: List[str] = []
370
+ keys: Set[str] = set()
371
+ for node in definitions:
372
+ refs = token_files.get(node.name, set())
373
+ same_lines = token_lines.get(node.name, {}).get(node.path, set())
374
+ if refs - {node.path} or any(
375
+ line < node.line or line > node.end_line for line in same_lines
376
+ ):
377
+ continue
378
+ dead.append(f"{node.path}:{node.line} {node.name}")
379
+ keys.add(f"{node.path}::{node.name}")
380
+ return dead, sorted(f"{node.path}::{node.name}" for node in definitions), keys
381
+
382
+
383
+ def project_call_edges_to_files(edges: Iterable[Any]) -> Set[Tuple[str, str]]:
384
+ """Confidently-resolved cross-file call edges collapsed to file pairs.
385
+
386
+ Import edges alone miss languages where files call across the same package
387
+ without importing (Go); union these pairs into file-level liveness edges so
388
+ a called file counts as wired. Ambiguous resolutions are excluded — an
389
+ uncertain call must not mark a file live. Shared by the full build and the
390
+ incremental sync so the two paths cannot diverge.
391
+ """
392
+ out: Set[Tuple[str, str]] = set()
393
+ for e in edges:
394
+ if getattr(e, "kind", None) != "calls":
395
+ continue
396
+ if getattr(e.confidence, "value", e.confidence) == "ambiguous":
397
+ continue
398
+ src_file = e.source.split("::", 1)[0]
399
+ dst_file = e.target.split("::", 1)[0]
400
+ if src_file != dst_file:
401
+ out.add((src_file, dst_file))
402
+ return out
403
+
404
+
405
+ def file_liveness_from_shards(
406
+ files: List[str],
407
+ file_edges: List[Tuple[str, str]],
408
+ shards: dict[str, dict[str, object]],
409
+ *,
410
+ root: Path,
411
+ entry_roots: List[str],
412
+ production_entry_roots: List[str],
413
+ dynamic_index: Optional[dict[str, Set[str]]] = None,
414
+ ) -> Tuple[List[str], List[str], List[str], bool]:
415
+ """Recompute file reachability from persisted adjacency without source scans."""
416
+ from devcouncil.indexing.wiring import (
417
+ is_liveness_code_file,
418
+ is_test_path,
419
+ reference_cleared,
420
+ structural_exemptions,
421
+ )
422
+
423
+ file_set = set(files)
424
+ roots = [path for path in entry_roots if path in file_set]
425
+ production_roots = [path for path in production_entry_roots if path in file_set]
426
+ unreliable = not bool(production_roots)
427
+ root_set = set(roots)
428
+ production_root_set = set(production_roots)
429
+ effective_dynamic_index = (
430
+ dynamic_import_index_from_shards(shards)
431
+ if dynamic_index is None
432
+ else dynamic_index
433
+ )
434
+ inbound: Dict[str, Set[str]] = defaultdict(set)
435
+ outbound: Dict[str, Set[str]] = defaultdict(set)
436
+ for importer, imported in file_edges:
437
+ inbound[imported].add(importer)
438
+ outbound[importer].add(imported)
439
+ unwired = [
440
+ path for path in sorted(files)
441
+ if is_liveness_code_file(path)
442
+ and path not in root_set
443
+ and not structural_exemptions(path)
444
+ and not bool(shards.get(path, {}).get("allow_unwired"))
445
+ and not {item for item in inbound.get(path, ()) if not is_test_path(item)}
446
+ and not reference_cleared(root, path, dynamic_index=effective_dynamic_index)
447
+ ]
448
+ reachable: Set[str] = set()
449
+ queue = list(production_roots)
450
+ while queue:
451
+ path = queue.pop()
452
+ if path in reachable:
453
+ continue
454
+ reachable.add(path)
455
+ queue.extend(outbound.get(path, ()))
456
+ unreachable = [] if unreliable else [
457
+ path for path in sorted(files)
458
+ if is_liveness_code_file(path)
459
+ and path not in root_set
460
+ and path not in production_root_set
461
+ and not structural_exemptions(path)
462
+ and path not in reachable
463
+ and not bool(shards.get(path, {}).get("allow_unwired"))
464
+ and not reference_cleared(root, path, dynamic_index=effective_dynamic_index)
465
+ ]
466
+ unreachable, unreliable, _ratio_meta = _apply_unreachable_ratio_gate(
467
+ root, files, unreachable, unreliable
468
+ )
469
+ return production_roots, unwired, unreachable, unreliable
470
+
471
+
472
+ def _resolve_reexport_targets(
473
+ extractions: Dict[str, FileExtraction],
474
+ file_edges: List[Tuple[str, str]],
475
+ ) -> Set[Tuple[str, str]]:
476
+ """Return ``(defining_path, name)`` pairs protected by barrel / ``__all__`` re-exports."""
477
+ imports_of: Dict[str, Set[str]] = defaultdict(set)
478
+ for a, b in file_edges:
479
+ imports_of[a].add(b)
480
+
481
+ # Top-level symbol names per file
482
+ names_in: Dict[str, Set[str]] = defaultdict(set)
483
+ for path, ext in extractions.items():
484
+ for sym in ext.symbols:
485
+ if "." not in sym.qualname and sym.kind != "rationale":
486
+ names_in[path].add(sym.name)
487
+
488
+ protected: Set[Tuple[str, str]] = set()
489
+ for path, ext in extractions.items():
490
+ for name in ext.all_exports:
491
+ protected.add((path, name))
492
+
493
+ reexport_names = set(ext.reexports)
494
+ if not reexport_names:
495
+ continue
496
+ imported_files = imports_of.get(path, set())
497
+ for detail in ext.import_details:
498
+ for name in detail.names:
499
+ bare = name.split(".")[-1]
500
+ if not bare or bare == "*":
501
+ continue
502
+ # Match either the imported name or local alias if present in reexports
503
+ locals_for = [
504
+ loc for loc, remote in detail.alias_map.items() if remote.endswith(bare) or remote == name
505
+ ]
506
+ if bare not in reexport_names and not (set(locals_for) & reexport_names):
507
+ # Also accept when reexport list uses the imported bare name
508
+ if name not in reexport_names:
509
+ continue
510
+ for fpath in imported_files:
511
+ if bare in names_in.get(fpath, ()):
512
+ protected.add((fpath, bare))
513
+ break
514
+ else:
515
+ # Fallback: unique defining file among imports
516
+ hits = [f for f in imported_files if bare in names_in.get(f, ())]
517
+ if len(hits) == 1:
518
+ protected.add((hits[0], bare))
519
+ return protected
520
+
521
+
522
+ # Framework targets are live only through a reachable registration owner.
523
+ _FRAMEWORK_LIVENESS_EDGE_KINDS = frozenset({
524
+ "registers",
525
+ "routes_to",
526
+ "listens",
527
+ "provides",
528
+ "reflects_to",
529
+ })
530
+
531
+
532
+ def _live_seeds(
533
+ root: Path,
534
+ files: List[str],
535
+ nodes: List[GraphNode],
536
+ edges: List[GraphEdge],
537
+ extractions: Dict[str, FileExtraction],
538
+ protected: Set[Tuple[str, str]],
539
+ file_edges: Optional[List[Tuple[str, str]]] = None,
540
+ ) -> Set[str]:
541
+ """Entry / export / wiring / test-referenced symbols seed the live fixed-point."""
542
+ from devcouncil.indexing.wiring import (
543
+ entry_point_symbols,
544
+ is_test_path,
545
+ is_wiring_decorated,
546
+ )
547
+
548
+ live: Set[str] = set()
549
+ entry_syms = entry_point_symbols(root, files)
550
+ node_by_id = {n.id: n for n in nodes}
551
+
552
+ imports_of: Dict[str, Set[str]] = defaultdict(set)
553
+ if file_edges:
554
+ for a, b in file_edges:
555
+ imports_of[a].add(b)
556
+
557
+ for node in nodes:
558
+ if node.kind not in {
559
+ NodeKind.FUNCTION,
560
+ NodeKind.CLASS,
561
+ NodeKind.METHOD,
562
+ NodeKind.INTERFACE,
563
+ NodeKind.TYPE,
564
+ NodeKind.STRUCT,
565
+ NodeKind.ENUM,
566
+ NodeKind.TRAIT,
567
+ }:
568
+ continue
569
+ key = f"{node.path}::{node.name}"
570
+ qual = (node.extras or {}).get("qualname") or node.name
571
+ if key in entry_syms or f"{node.path}::{qual}" in entry_syms:
572
+ live.add(node.id)
573
+ continue
574
+ if (node.path, node.name) in protected:
575
+ live.add(node.id)
576
+ continue
577
+ # JS/TS explicit export surface (not Python's default-public exported flag)
578
+ lang = (node.language or "").lower()
579
+ if node.exported and lang in {"javascript", "typescript", "js", "ts", "tsx", "jsx"}:
580
+ live.add(node.id)
581
+ continue
582
+ # Python: only __all__/reexport protection above — do not seed all public names
583
+ if path_is_init_all_member(extractions, node):
584
+ live.add(node.id)
585
+ continue
586
+ decs = list((node.extras or {}).get("decorators") or [])
587
+ if is_wiring_decorated(decs):
588
+ live.add(node.id)
589
+ continue
590
+ # Test-referenced: inbound call/named-import from a test file seeds live
591
+ for e in edges:
592
+ if e.kind not in {"calls", "imports"}:
593
+ continue
594
+ src_path = e.source.split("::", 1)[0]
595
+ if not is_test_path(src_path):
596
+ continue
597
+ if e.target in node_by_id:
598
+ live.add(e.target)
599
+
600
+ # Same-file + imported-module name references (``pool.submit(self._run_one)``,
601
+ # ``card.blocks_completion`` property access, registry dispatch). Includes
602
+ # private methods so callback callees stay in the live fixed-point.
603
+ from devcouncil.indexing.graph.schema import symbol_node_id
604
+
605
+ by_name: Dict[str, List[GraphNode]] = defaultdict(list)
606
+ for node in nodes:
607
+ if node.kind in {
608
+ NodeKind.FUNCTION,
609
+ NodeKind.CLASS,
610
+ NodeKind.METHOD,
611
+ NodeKind.INTERFACE,
612
+ NodeKind.TYPE,
613
+ NodeKind.STRUCT,
614
+ NodeKind.ENUM,
615
+ NodeKind.TRAIT,
616
+ }:
617
+ by_name[node.name].append(node)
618
+
619
+ for path, ext in extractions.items():
620
+ ref_names = set(getattr(ext, "references", None) or ())
621
+ if not ref_names:
622
+ continue
623
+ imported = set(imports_of.get(path, ())) | {path}
624
+ for name in ref_names:
625
+ hits = [n for n in by_name.get(name, ()) if n.path in imported]
626
+ if len(hits) == 1:
627
+ live.add(hits[0].id)
628
+ continue
629
+ if len(hits) > 1:
630
+ same = [n for n in hits if n.path == path]
631
+ if len(same) == 1:
632
+ live.add(same[0].id)
633
+ continue
634
+ # Same-file symbol by qualname even when name is globally ambiguous
635
+ for sym in ext.symbols:
636
+ if sym.name == name:
637
+ live.add(symbol_node_id(path, sym.qualname))
638
+
639
+ return live
640
+
641
+
642
+ def path_is_init_all_member(
643
+ extractions: Dict[str, FileExtraction],
644
+ node: GraphNode,
645
+ ) -> bool:
646
+ """True when ``node.name`` is listed in the defining file's ``__all__``."""
647
+ ext = extractions.get(node.path)
648
+ if ext is None:
649
+ return False
650
+ return node.name in set(ext.all_exports)
651
+
652
+
653
+ def symbol_reachability_dead(
654
+ root: Path,
655
+ files: List[str],
656
+ nodes: List[GraphNode],
657
+ edges: List[GraphEdge],
658
+ extractions: Dict[str, FileExtraction],
659
+ entry_roots: List[str],
660
+ *,
661
+ token_dead_keys: Optional[Set[str]] = None,
662
+ file_edges: Optional[List[Tuple[str, str]]] = None,
663
+ unreachable: Optional[List[str]] = None,
664
+ dynamic_index: Optional[dict[str, Set[str]]] = None,
665
+ ) -> List[DeadCodeEntry]:
666
+ """Symbol-level dead code with fixed-point live propagation and confidence tiers.
667
+
668
+ A symbol is live only if it is a seed (entry / exported surface / wiring /
669
+ test-referenced / getattr) or has an inbound call/named-import from a live
670
+ source. Cascade members whose only callers are dead get
671
+ ``reason="only callers are dead"`` at ``inferred`` confidence.
672
+ """
673
+ from devcouncil.indexing.wiring import (
674
+ GETATTR_INDEX_PREFIX,
675
+ build_dynamic_import_index,
676
+ has_allow_unwired,
677
+ is_dunder_symbol,
678
+ is_private_symbol,
679
+ is_test_path,
680
+ is_wiring_decorated,
681
+ structural_exemptions,
682
+ )
683
+
684
+ # Reconstruct file edges from graph imports when not provided
685
+ if file_edges is None:
686
+ file_edges = [
687
+ (e.source, e.target)
688
+ for e in edges
689
+ if e.kind == "imports" and "::" not in e.source and "::" not in e.target
690
+ ]
691
+
692
+ protected = _resolve_reexport_targets(extractions, file_edges)
693
+ ref_index = _reference_index(extractions)
694
+ dyn_index = (
695
+ build_dynamic_import_index(root, files)
696
+ if dynamic_index is None
697
+ else dynamic_index
698
+ )
699
+ getattr_names = {
700
+ k[len(GETATTR_INDEX_PREFIX) :]
701
+ for k in dyn_index
702
+ if k.startswith(GETATTR_INDEX_PREFIX)
703
+ }
704
+
705
+ imported_by: Dict[str, Set[str]] = defaultdict(set)
706
+ for a, b in file_edges:
707
+ imported_by[b].add(a)
708
+
709
+ # Inbound call / named-import sources per symbol (liveness-relevant edges)
710
+ inbound_live_edges: Dict[str, List[GraphEdge]] = defaultdict(list)
711
+ overrides_of: Dict[str, List[str]] = defaultdict(list) # child -> parent method ids
712
+ for e in edges:
713
+ if e.kind in {"calls", "imports"} | _FRAMEWORK_LIVENESS_EDGE_KINDS:
714
+ if e.kind in _FRAMEWORK_LIVENESS_EDGE_KINDS:
715
+ confidence = (
716
+ e.confidence.value
717
+ if hasattr(e.confidence, "value")
718
+ else str(e.confidence)
719
+ )
720
+ if confidence == Confidence.AMBIGUOUS.value:
721
+ continue
722
+ # Ignore file→file imports for symbol liveness; named imports target symbols
723
+ if e.kind == "imports" and "::" not in e.target:
724
+ continue
725
+ # Ignore contains/defines-style noise: already filtered by kind
726
+ inbound_live_edges[e.target].append(e)
727
+ elif e.kind == "overrides":
728
+ overrides_of[e.source].append(e.target)
729
+
730
+ live = _live_seeds(
731
+ root, files, nodes, edges, extractions, protected, file_edges=file_edges
732
+ )
733
+
734
+ # getattr(x, "name") in non-test files seeds the named symbol live
735
+ _seed_kinds = {
736
+ NodeKind.FUNCTION,
737
+ NodeKind.CLASS,
738
+ NodeKind.METHOD,
739
+ NodeKind.INTERFACE,
740
+ NodeKind.TYPE,
741
+ NodeKind.STRUCT,
742
+ NodeKind.ENUM,
743
+ NodeKind.TRAIT,
744
+ }
745
+ for node in nodes:
746
+ if node.kind in _seed_kinds and node.name in getattr_names:
747
+ live.add(node.id)
748
+
749
+ # Fixed-point: live if inbound from live source, or overrides a live base method.
750
+ unreachable_set = {p.replace("\\", "/") for p in (unreachable or [])}
751
+ entry_root_set = {p.replace("\\", "/") for p in entry_roots}
752
+
753
+ def _file_source_is_live(path: str) -> bool:
754
+ """Module-level calls execute on import/run when the file is reachable."""
755
+ norm = path.replace("\\", "/")
756
+ if norm in entry_root_set or structural_exemptions(norm):
757
+ return True
758
+ return norm not in unreachable_set
759
+
760
+ def _source_is_live(src: str) -> bool:
761
+ if src in live:
762
+ return True
763
+ if "::" not in src:
764
+ if _file_source_is_live(src):
765
+ return True
766
+ return any(n.startswith(f"{src}::") for n in live)
767
+ return False
768
+
769
+ changed = True
770
+ while changed:
771
+ changed = False
772
+ for node in nodes:
773
+ alias = str(node.extras.get("identity_alias") or "")
774
+ if not alias:
775
+ continue
776
+ if node.id in live and alias not in live:
777
+ live.add(alias)
778
+ changed = True
779
+ elif alias in live and node.id not in live:
780
+ live.add(node.id)
781
+ changed = True
782
+ for target, srcs in inbound_live_edges.items():
783
+ if target in live:
784
+ continue
785
+ if any(_source_is_live(e.source) for e in srcs):
786
+ live.add(target)
787
+ changed = True
788
+ for child, parents in overrides_of.items():
789
+ if child in live:
790
+ continue
791
+ if any(p in live for p in parents):
792
+ live.add(child)
793
+ changed = True
794
+
795
+ token_dead_keys = token_dead_keys or set()
796
+ dead: List[DeadCodeEntry] = []
797
+
798
+ for node in nodes:
799
+ if node.kind not in {NodeKind.FUNCTION, NodeKind.CLASS, NodeKind.METHOD}:
800
+ continue
801
+ if is_test_path(node.path):
802
+ continue
803
+ if structural_exemptions(node.path):
804
+ continue
805
+ if is_private_symbol(node.name) or is_dunder_symbol(node.name):
806
+ continue
807
+ if has_allow_unwired(root, node.path):
808
+ continue
809
+ if node.id in live:
810
+ continue
811
+ decs = list((node.extras or {}).get("decorators") or [])
812
+ if is_wiring_decorated(decs):
813
+ continue
814
+ if (node.path, node.name) in protected:
815
+ continue
816
+
817
+ key = f"{node.path}::{node.name}"
818
+ qual_key = f"{node.path}::{(node.extras or {}).get('qualname') or node.name}"
819
+ in_token = key in token_dead_keys or qual_key in token_dead_keys
820
+ # Non-call references scoped to defining file + its importers (not global
821
+ # bare-name collisions in unrelated modules).
822
+ ref_files = ref_index.get(node.name, set())
823
+ scoped_refs = {node.path} | imported_by.get(node.path, set())
824
+ referenced_by_name = bool(ref_files & scoped_refs)
825
+
826
+ # Relevant inbound call/named-import edges (exclude self-recursive).
827
+ # Module-level file→symbol calls count when the source file is live.
828
+ relevant = [
829
+ e
830
+ for e in inbound_live_edges.get(node.id, [])
831
+ if e.source != node.id
832
+ ]
833
+ has_any_caller = bool(relevant)
834
+ only_dead_callers = has_any_caller and all(
835
+ not _source_is_live(e.source) for e in relevant
836
+ )
837
+
838
+ if node.kind == NodeKind.METHOD:
839
+ if is_dunder_symbol(node.name):
840
+ continue
841
+ if node.name in _REACT_LIFECYCLE_METHODS:
842
+ continue
843
+ if referenced_by_name:
844
+ dead.append(
845
+ DeadCodeEntry(
846
+ id=node.id,
847
+ path=node.path,
848
+ line=node.line,
849
+ kind="method",
850
+ confidence=Confidence.AMBIGUOUS,
851
+ reason="referenced by name only (possible callback/property)",
852
+ )
853
+ )
854
+ continue
855
+ if only_dead_callers:
856
+ # Reachable file + "dead" callers usually means incomplete call
857
+ # edges (JSX/React), not a real cascade from a dead root.
858
+ file_reachable = node.path.replace("\\", "/") not in unreachable_set
859
+ dead.append(
860
+ DeadCodeEntry(
861
+ id=node.id,
862
+ path=node.path,
863
+ line=node.line,
864
+ kind="method",
865
+ confidence=(
866
+ Confidence.AMBIGUOUS if file_reachable else Confidence.INFERRED
867
+ ),
868
+ reason=(
869
+ "only callers look dead (reachable file; possible incomplete call graph)"
870
+ if file_reachable
871
+ else "only callers are dead"
872
+ ),
873
+ )
874
+ )
875
+ continue
876
+ dead.append(
877
+ DeadCodeEntry(
878
+ id=node.id,
879
+ path=node.path,
880
+ line=node.line,
881
+ kind="method",
882
+ confidence=Confidence.INFERRED,
883
+ reason="no inbound call edges (method)",
884
+ )
885
+ )
886
+ continue
887
+
888
+ # Top-level function/class
889
+ if only_dead_callers:
890
+ file_reachable = node.path.replace("\\", "/") not in unreachable_set
891
+ dead.append(
892
+ DeadCodeEntry(
893
+ id=node.id,
894
+ path=node.path,
895
+ line=node.line,
896
+ kind=node.kind.value,
897
+ confidence=(
898
+ Confidence.AMBIGUOUS if file_reachable else Confidence.INFERRED
899
+ ),
900
+ reason=(
901
+ "only callers look dead (reachable file; possible incomplete call graph)"
902
+ if file_reachable
903
+ else "only callers are dead"
904
+ ),
905
+ )
906
+ )
907
+ continue
908
+
909
+ graph_dead = not has_any_caller
910
+ if not graph_dead:
911
+ # Has live-or-entry callers — should have been marked live; skip
912
+ continue
913
+
914
+ if graph_dead and in_token:
915
+ dead.append(
916
+ DeadCodeEntry(
917
+ id=node.id,
918
+ path=node.path,
919
+ line=node.line,
920
+ kind=node.kind.value,
921
+ confidence=(
922
+ Confidence.AMBIGUOUS if referenced_by_name else Confidence.EXTRACTED
923
+ ),
924
+ reason=(
925
+ "referenced by name only (possible callback)"
926
+ if referenced_by_name
927
+ else "no inbound calls and token-scan agrees"
928
+ ),
929
+ )
930
+ )
931
+ elif graph_dead and not in_token:
932
+ dead.append(
933
+ DeadCodeEntry(
934
+ id=node.id,
935
+ path=node.path,
936
+ line=node.line,
937
+ kind=node.kind.value,
938
+ confidence=Confidence.AMBIGUOUS,
939
+ reason="graph-dead but token-scan cleared (possible name collision)",
940
+ )
941
+ )
942
+
943
+ return dead
944
+
945
+
946
+ def legacy_dead_strings(
947
+ dead_code: List[DeadCodeEntry],
948
+ token_dead: List[str],
949
+ *,
950
+ cap: Optional[int] = None,
951
+ ) -> List[str]:
952
+ """repo_map.json format: intersection of extracted-confidence dead + token list.
953
+
954
+ Preserves ``path:line name`` strings for verify-gate / ratchet compatibility.
955
+ Methods (inferred) are excluded from the legacy list.
956
+ ``cap`` defaults to uncapped; apply caps only when serializing ``repo_map.json``.
957
+
958
+ When the graph has no extracted-tier dead symbols, return an empty list rather
959
+ than flooding agents with token-only false positives.
960
+ """
961
+ extracted_paths_names = {
962
+ (d.path, d.id.split("::")[-1].split(".")[-1])
963
+ for d in dead_code
964
+ if d.confidence == Confidence.EXTRACTED
965
+ }
966
+ if not extracted_paths_names:
967
+ return []
968
+ out: List[str] = []
969
+ for entry in token_dead:
970
+ loc, _, name = entry.partition(" ")
971
+ path, _, _line = loc.rpartition(":")
972
+ if (path, name) in extracted_paths_names:
973
+ out.append(entry)
974
+ if cap is not None and cap > 0:
975
+ return out[:cap]
976
+ return out
977
+
978
+
979
+ def confidence_at_least(conf: object, minimum: str) -> bool:
980
+ """True when ``conf`` ranks at or above ``minimum`` (extracted > inferred > ambiguous)."""
981
+ want = _CONFIDENCE_RANK.get(minimum, 0)
982
+ if hasattr(conf, "value"):
983
+ conf = conf.value
984
+ have = _CONFIDENCE_RANK.get(str(conf), 0)
985
+ return have >= want
986
+
987
+ def apply_liveness_cap(items: list, cap: int | None) -> tuple[list, dict]:
988
+ total = len(items)
989
+ if cap is None or cap <= 0 or total <= cap:
990
+ return list(items), {"total": total, "shown": total, "truncated": 0}
991
+ shown = list(items)[:cap]
992
+ return shown, {"total": total, "shown": len(shown), "truncated": total - len(shown)}