devcouncil 0.3.1 → 0.4.1

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 (366) hide show
  1. package/README.md +92 -33
  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 +56 -40
  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 +964 -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 +281 -0
  219. package/src/devcouncil/integrations/mcp/handlers/graph.py +34 -0
  220. package/src/devcouncil/integrations/mcp/handlers/handoff.py +53 -0
  221. package/src/devcouncil/integrations/mcp/handlers/knowledge.py +28 -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 +87 -0
  229. package/src/devcouncil/integrations/mcp/handlers/read.py +103 -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 +69 -0
  233. package/src/devcouncil/integrations/mcp/handlers/scope.py +56 -0
  234. package/src/devcouncil/integrations/mcp/handlers/status.py +199 -0
  235. package/src/devcouncil/integrations/mcp/handlers/task.py +88 -0
  236. package/src/devcouncil/integrations/mcp/handlers/tool_specs.py +922 -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 +53 -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 +325 -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/summary.py +21 -3
  255. package/src/devcouncil/live/tasks.py +12 -3
  256. package/src/devcouncil/live/transcripts.py +69 -2
  257. package/src/devcouncil/llm/cache.py +5 -6
  258. package/src/devcouncil/llm/model_defaults.yaml +10 -10
  259. package/src/devcouncil/llm/provider.py +647 -73
  260. package/src/devcouncil/llm/router.py +271 -46
  261. package/src/devcouncil/llm/semantic_bridge.py +614 -0
  262. package/src/devcouncil/optimization/gepa_agent.py +6 -4
  263. package/src/devcouncil/optimization/skillopt.py +9 -5
  264. package/src/devcouncil/planning/arbiter_service.py +12 -3
  265. package/src/devcouncil/planning/correction_manifest.py +107 -10
  266. package/src/devcouncil/planning/plan_difficulty.py +69 -0
  267. package/src/devcouncil/planning/plan_service.py +5 -2
  268. package/src/devcouncil/planning/planned_files_reconcile.py +191 -0
  269. package/src/devcouncil/planning/prompt_enhancer_service.py +6 -5
  270. package/src/devcouncil/planning/question_conversion.py +56 -0
  271. package/src/devcouncil/planning/spec_service.py +9 -3
  272. package/src/devcouncil/repo/ci_scaffold.py +197 -1
  273. package/src/devcouncil/repo/gitignore.py +1 -2
  274. package/src/devcouncil/reporting/evidence_export.py +124 -0
  275. package/src/devcouncil/reporting/evidence_html.py +210 -0
  276. package/src/devcouncil/reporting/json_report.py +16 -12
  277. package/src/devcouncil/reporting/markdown_report.py +38 -9
  278. package/src/devcouncil/reporting/mcp_resources.py +142 -0
  279. package/src/devcouncil/reporting/report_builder.py +40 -4
  280. package/src/devcouncil/reporting/task_provenance.py +42 -0
  281. package/src/devcouncil/reporting/verdict.py +75 -0
  282. package/src/devcouncil/skills/library/README.md +1 -0
  283. package/src/devcouncil/skills/library/devcouncil-hero-loop.md +109 -0
  284. package/src/devcouncil/skills/library/devcouncil-verification.md +109 -0
  285. package/src/devcouncil/skills/library/devcouncil.md +93 -0
  286. package/src/devcouncil/skills/registry.py +43 -12
  287. package/src/devcouncil/storage/db.py +57 -11
  288. package/src/devcouncil/storage/models.py +6 -0
  289. package/src/devcouncil/storage/native.py +5 -3
  290. package/src/devcouncil/storage/repositories.py +50 -18
  291. package/src/devcouncil/telemetry/context.py +28 -0
  292. package/src/devcouncil/telemetry/cost.py +4 -5
  293. package/src/devcouncil/telemetry/logging_setup.py +78 -11
  294. package/src/devcouncil/telemetry/model_pricing.yaml +7 -0
  295. package/src/devcouncil/telemetry/stages.py +27 -2
  296. package/src/devcouncil/telemetry/tracker.py +50 -13
  297. package/src/devcouncil/ui/dashboard.py +120 -8
  298. package/src/devcouncil/utils/fsio.py +58 -0
  299. package/src/devcouncil/utils/git_snapshot.py +112 -0
  300. package/src/devcouncil/utils/json_persist.py +53 -0
  301. package/src/devcouncil/utils/proc.py +89 -0
  302. package/src/devcouncil/verification/acceptance_compiler.py +36 -13
  303. package/src/devcouncil/verification/ad_hoc_check.py +95 -3
  304. package/src/devcouncil/verification/checks/__init__.py +41 -0
  305. package/src/devcouncil/verification/checks/acceptance.py +39 -0
  306. package/src/devcouncil/verification/checks/acceptance_corpus.py +194 -0
  307. package/src/devcouncil/verification/checks/acceptance_evidence.py +239 -0
  308. package/src/devcouncil/verification/checks/command_evidence.py +148 -0
  309. package/src/devcouncil/verification/checks/compiled_acceptance.py +179 -0
  310. package/src/devcouncil/verification/checks/corpus_stale.py +124 -0
  311. package/src/devcouncil/verification/checks/corpus_verification.py +9 -0
  312. package/src/devcouncil/verification/checks/dead_symbols.py +360 -0
  313. package/src/devcouncil/verification/checks/diff_coverage_gate.py +101 -0
  314. package/src/devcouncil/verification/checks/doc_code_ref.py +79 -0
  315. package/src/devcouncil/verification/checks/liveness_ratchet.py +336 -0
  316. package/src/devcouncil/verification/checks/orphan_diff.py +104 -0
  317. package/src/devcouncil/verification/checks/planned_files.py +98 -0
  318. package/src/devcouncil/verification/checks/semantic_diff.py +241 -0
  319. package/src/devcouncil/verification/checks/stale_map.py +80 -0
  320. package/src/devcouncil/verification/checks/stub_scan.py +71 -0
  321. package/src/devcouncil/verification/checks/subsystem_boundary.py +103 -0
  322. package/src/devcouncil/verification/checks/wiring.py +216 -0
  323. package/src/devcouncil/verification/claims/__init__.py +23 -0
  324. package/src/devcouncil/verification/claims/checks.py +395 -0
  325. package/src/devcouncil/verification/claims/mapper.py +168 -0
  326. package/src/devcouncil/verification/claims/models.py +39 -0
  327. package/src/devcouncil/verification/claims/transcript.py +92 -0
  328. package/src/devcouncil/verification/claims/verdict.py +88 -0
  329. package/src/devcouncil/verification/command_evidence.py +170 -0
  330. package/src/devcouncil/verification/command_malformation.py +147 -0
  331. package/src/devcouncil/verification/command_runner.py +164 -0
  332. package/src/devcouncil/verification/coverage_measurement.py +292 -0
  333. package/src/devcouncil/verification/diff_coverage.py +151 -0
  334. package/src/devcouncil/verification/difficulty.py +296 -0
  335. package/src/devcouncil/verification/effort_heuristics.py +178 -0
  336. package/src/devcouncil/verification/gap_ids.py +63 -0
  337. package/src/devcouncil/verification/gate_cache.py +194 -0
  338. package/src/devcouncil/verification/gate_selector.py +344 -0
  339. package/src/devcouncil/verification/git_diff_fallback.py +272 -0
  340. package/src/devcouncil/verification/implementation_reviewer.py +13 -0
  341. package/src/devcouncil/verification/incremental_check.py +241 -0
  342. package/src/devcouncil/verification/next_actions.py +60 -1
  343. package/src/devcouncil/verification/rigor_analytics.py +130 -0
  344. package/src/devcouncil/verification/sandbox.py +38 -11
  345. package/src/devcouncil/verification/stub_detector.py +369 -0
  346. package/src/devcouncil/verification/test_resolver.py +67 -1
  347. package/src/devcouncil/verification/verifier.py +137 -1666
  348. package/src/devcouncil/verification/verify_orchestration.py +610 -0
  349. package/src/devcouncil/verification/verify_setup.py +176 -0
  350. package/src/devcouncil/verification/wiki_refresh.py +208 -0
  351. package/src/semantic_layer/__init__.py +58 -0
  352. package/src/semantic_layer/benchmark.py +75 -0
  353. package/src/semantic_layer/cache.py +290 -0
  354. package/src/semantic_layer/compressor.py +137 -0
  355. package/src/semantic_layer/config.py +75 -0
  356. package/src/semantic_layer/embeddings.py +69 -0
  357. package/src/semantic_layer/llm_backends.py +99 -0
  358. package/src/semantic_layer/pipeline.py +111 -0
  359. package/src/semantic_layer/router.py +128 -0
  360. package/src/semantic_layer/tuner.py +72 -0
  361. package/uv.lock +973 -9
  362. package/src/devcouncil/artifacts/migrations.py +0 -20
  363. package/src/devcouncil/artifacts/schemas.py +0 -23
  364. package/src/devcouncil/artifacts/serializer.py +0 -21
  365. package/src/devcouncil/integrations/gitnexus.py +0 -70
  366. package/src/devcouncil/integrations/graphify.py +0 -34
@@ -0,0 +1,1192 @@
1
+ """``dev graph`` — query / trace / dead / check / process / impact / html / view / export."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from pathlib import Path
8
+ from typing import List, Optional
9
+
10
+ import typer
11
+ from rich.console import Console
12
+
13
+ app = typer.Typer(
14
+ name="graph",
15
+ help="Query and visualize the symbol-level code knowledge graph.",
16
+ add_completion=False,
17
+ )
18
+ hooks_app = typer.Typer(name="hooks", help="Optional Git hook integration.", add_completion=False)
19
+ app.add_typer(hooks_app, name="hooks")
20
+ console = Console()
21
+ status = Console(stderr=True)
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ def _root(project_root: Path) -> Path:
26
+ root = project_root.expanduser().resolve()
27
+ from devcouncil.telemetry.logging_setup import set_log_dir
28
+
29
+ set_log_dir(root)
30
+ return root
31
+
32
+
33
+ def _graph_degraded_fields(root: Path) -> dict[str, object]:
34
+ """Lean-map handshake for CLI JSON that bypasses the codeintel envelope."""
35
+ map_path = root / ".devcouncil" / "repo_map.json"
36
+ if not map_path.is_file():
37
+ return {"graph_degraded": False}
38
+ try:
39
+ from devcouncil.utils.json_persist import read_json
40
+
41
+ data = read_json(map_path)
42
+ if not isinstance(data, dict):
43
+ return {"graph_degraded": False}
44
+ degraded = bool(data.get("graph_degraded"))
45
+ fields: dict[str, object] = {"graph_degraded": degraded}
46
+ if degraded:
47
+ fields["graph_degraded_reason"] = str(data.get("graph_degraded_reason") or "")
48
+ return fields
49
+ except Exception:
50
+ return {"graph_degraded": False}
51
+
52
+
53
+ def _require_graph(root: Path):
54
+ from devcouncil.indexing.graph.build import load_code_graph
55
+
56
+ graph = load_code_graph(root)
57
+ if graph is None:
58
+ status.print("[red]No code graph; run `dev map` first.[/red]")
59
+ raise typer.Exit(code=1)
60
+ return graph
61
+
62
+
63
+ @app.command("init")
64
+ def graph_init(
65
+ project_root: Path = typer.Option(Path("."), "--project-root"),
66
+ no_liveness: bool = typer.Option(False, "--no-liveness"),
67
+ json_output: bool = typer.Option(False, "--json"),
68
+ ) -> None:
69
+ """Build the canonical SQLite graph and deterministic compatibility exports."""
70
+ from devcouncil.codeintel import get_codeintel_service
71
+ from devcouncil.codeintel.build_control import GraphBuildBusy
72
+ from devcouncil.indexing.map_artifacts import refresh_map_artifacts
73
+
74
+ root = _root(project_root)
75
+ try:
76
+ refresh = refresh_map_artifacts(
77
+ root,
78
+ root / ".devcouncil" / "repo_map.json",
79
+ liveness=not no_liveness,
80
+ quiet=True,
81
+ )
82
+ except GraphBuildBusy as exc:
83
+ if json_output:
84
+ typer.echo(json.dumps({"ok": False, "code": "graph_writer_busy", "error": str(exc)}))
85
+ else:
86
+ status.print(f"[red]{exc}[/red]")
87
+ raise typer.Exit(code=1) from exc
88
+ if refresh.degraded:
89
+ payload = {
90
+ "ok": False,
91
+ "degraded": True,
92
+ "reason": refresh.reason,
93
+ "mode": refresh.mode,
94
+ }
95
+ if json_output:
96
+ typer.echo(json.dumps(payload, indent=2))
97
+ else:
98
+ status.print(f"[red]Graph init degraded: {refresh.reason}[/red]")
99
+ raise typer.Exit(code=1)
100
+ result = get_codeintel_service(root).status()
101
+ if refresh.compatibility_export_degraded:
102
+ result["compatibility_export"] = "degraded"
103
+ result["degraded_reason"] = refresh.reason
104
+ if json_output:
105
+ typer.echo(json.dumps(result, indent=2))
106
+ else:
107
+ color = "yellow" if refresh.compatibility_export_degraded else "green"
108
+ status.print(
109
+ f"[{color}]Indexed generation {result.get('generation')} — "
110
+ f"{result.get('node_count')} nodes, {result.get('edge_count')} edges"
111
+ f"{f'; export degraded: {refresh.reason}' if refresh.compatibility_export_degraded else ''}"
112
+ f"[/{color}]"
113
+ )
114
+
115
+
116
+ @app.command("status")
117
+ def graph_status(
118
+ project_root: Path = typer.Option(Path("."), "--project-root"),
119
+ json_output: bool = typer.Option(False, "--json"),
120
+ ) -> None:
121
+ """Show canonical generation, watcher health, and pending files."""
122
+ from devcouncil.codeintel import get_codeintel_service
123
+ from devcouncil.codeintel.sync import get_sync_coordinator
124
+
125
+ root = _root(project_root)
126
+ result = get_codeintel_service(root).status()
127
+ # Cold start: existing compatibility JSON is enough to bootstrap queries/status
128
+ # without requiring a full ``dev map`` rebuild first.
129
+ if result.get("state") in {"uninitialized", "empty"}:
130
+ try:
131
+ from devcouncil.indexing.graph.build import graph_path, load_code_graph
132
+
133
+ if graph_path(root).is_file():
134
+ load_code_graph(root)
135
+ result = get_codeintel_service(root).status()
136
+ except Exception:
137
+ logger.debug("graph status cold-start bootstrap failed", exc_info=True)
138
+ result["sync"] = get_sync_coordinator(root).status().as_dict()
139
+ if json_output:
140
+ typer.echo(json.dumps(result, indent=2))
141
+ return
142
+ console.print(f"state: {result['state']}")
143
+ console.print(f"generation: {result.get('generation') or '(none)'}")
144
+ console.print(f"nodes/edges: {result.get('node_count', 0)}/{result.get('edge_count', 0)}")
145
+ sync = result["sync"]
146
+ console.print(f"watcher: {sync['state']} ({sync.get('backend') or 'not started'})")
147
+ if sync.get("state") in {"disabled", "stopped", ""} or not sync.get("backend"):
148
+ console.print(
149
+ "[dim]hint: run `dev graph watch` or `dev map --watch` to enable auto-refresh[/dim]"
150
+ )
151
+ if sync.get("build_id"):
152
+ progress = f"{sync.get('build_completed', 0)}/{sync.get('build_total', 0)}"
153
+ console.print(
154
+ f"build: {sync.get('build_state') or 'unknown'} / "
155
+ f"{sync.get('build_phase') or 'unknown'} ({progress}, "
156
+ f"pid={sync.get('build_pid') or 'n/a'})"
157
+ )
158
+ if sync.get("compatibility_export") == "degraded":
159
+ console.print("compatibility export: degraded")
160
+ if sync.get("pending"):
161
+ console.print("pending: " + ", ".join(sync["pending"]))
162
+ if sync.get("degraded_reason"):
163
+ console.print(f"degraded: {sync['degraded_reason']}")
164
+
165
+
166
+ @app.command("sync")
167
+ def graph_sync(
168
+ paths: Optional[List[str]] = typer.Argument(None, help="Optional paths; otherwise reconcile the project."),
169
+ project_root: Path = typer.Option(Path("."), "--project-root"),
170
+ json_output: bool = typer.Option(False, "--json"),
171
+ ) -> None:
172
+ """Reconcile and commit pending filesystem changes now."""
173
+ from devcouncil.codeintel.sync import get_sync_coordinator
174
+
175
+ root = _root(project_root)
176
+ coordinator = get_sync_coordinator(root)
177
+ changed = list(paths or coordinator.reconcile())
178
+ ok = coordinator.sync_now(changed)
179
+ result = coordinator.status().as_dict()
180
+ result["ok"] = ok
181
+ result["reconciled"] = changed
182
+ if json_output:
183
+ typer.echo(json.dumps(result, indent=2))
184
+ else:
185
+ color = "green" if ok else "yellow"
186
+ status.print(f"[{color}]Synced {len(changed)} path(s); state={result['state']}[/{color}]")
187
+ if not ok:
188
+ raise typer.Exit(code=1)
189
+
190
+
191
+ @app.command("watch")
192
+ def graph_watch(
193
+ project_root: Path = typer.Option(Path("."), "--project-root"),
194
+ ) -> None:
195
+ """Run native auto-sync in the foreground until interrupted."""
196
+ import time
197
+
198
+ from devcouncil.codeintel.sync import get_sync_coordinator
199
+
200
+ root = _root(project_root)
201
+ coordinator = get_sync_coordinator(root)
202
+ state = coordinator.start()
203
+ status.print(
204
+ f"[cyan]Watching {root} with {state.backend or 'reconciliation'} "
205
+ f"(state={state.state}); Ctrl-C to stop.[/cyan]"
206
+ )
207
+ try:
208
+ while True:
209
+ time.sleep(0.5)
210
+ except KeyboardInterrupt:
211
+ status.print("Stopped watching.")
212
+ finally:
213
+ coordinator.stop()
214
+
215
+
216
+ @app.command("doctor")
217
+ def graph_doctor(
218
+ project_root: Path = typer.Option(Path("."), "--project-root"),
219
+ json_output: bool = typer.Option(False, "--json"),
220
+ ) -> None:
221
+ """Verify SQLite, native watcher selection, and installed grammar assets."""
222
+ from watchdog.observers import Observer
223
+
224
+ from devcouncil.codeintel import get_codeintel_service
225
+ from devcouncil.codeintel.build_control import read_build_status
226
+ from devcouncil.codeintel.languages import grammar_status
227
+ from devcouncil.codeintel.store.sqlite import compatibility_graph_digest
228
+ from devcouncil.indexing.graph.build import graph_path
229
+ from devcouncil.utils.json_persist import read_json
230
+
231
+ root = _root(project_root)
232
+ service = get_codeintel_service(root)
233
+ store = service.status()
234
+ grammars = grammar_status()
235
+ watcher_backend = getattr(Observer, "__name__", type(Observer).__name__)
236
+ build = read_build_status(root)
237
+ export_path = graph_path(root)
238
+ export_health = "missing"
239
+ export_detail = ""
240
+ if store["state"] == "committed":
241
+ recorded_digest, recorded_mtime = service.store.compatibility_export_state()
242
+ if not export_path.is_file():
243
+ export_health = "missing"
244
+ export_detail = "compatibility JSON absent while store is committed"
245
+ else:
246
+ try:
247
+ data = read_json(export_path)
248
+ from devcouncil.indexing.graph.schema import CodeGraph
249
+
250
+ exported = CodeGraph.model_validate(data)
251
+ digest = compatibility_graph_digest(exported)
252
+ if recorded_digest and digest != recorded_digest:
253
+ export_health = "drift"
254
+ export_detail = "JSON digest diverges from store handshake"
255
+ elif build.compatibility_export == "degraded":
256
+ export_health = "degraded"
257
+ export_detail = build.degraded_reason or "last build skipped JSON export"
258
+ else:
259
+ export_health = "healthy"
260
+ except Exception as exc: # noqa: BLE001
261
+ export_health = "corrupt"
262
+ export_detail = f"{type(exc).__name__}: {exc}"
263
+ elif build.compatibility_export == "degraded":
264
+ export_health = "degraded"
265
+ export_detail = build.degraded_reason or "compatibility export degraded"
266
+ result = {
267
+ "ok": store["state"] == "committed" and grammars["ok"] and export_health == "healthy",
268
+ "store": store,
269
+ "watcher_backend": watcher_backend,
270
+ "grammars": grammars,
271
+ "compatibility_export": {
272
+ "health": export_health,
273
+ "detail": export_detail,
274
+ "build_state": build.state,
275
+ "build_compatibility_export": build.compatibility_export,
276
+ },
277
+ }
278
+ # Uninitialized projects are healthy when grammars are installed.
279
+ if store["state"] in {"uninitialized", "empty"}:
280
+ result["ok"] = bool(grammars["ok"])
281
+ if store["state"] == "corrupt":
282
+ result["store_action"] = (
283
+ "index.sqlite is damaged — run `dev map` to quarantine it and rebuild"
284
+ )
285
+ if json_output:
286
+ typer.echo(json.dumps(result, indent=2))
287
+ if not result["ok"]:
288
+ raise typer.Exit(code=1)
289
+ return
290
+ console.print(f"store: {store['state']} (schema {store['schema_version']})")
291
+ if result.get("store_action"):
292
+ console.print(f"store action: {result['store_action']}")
293
+ console.print(f"watcher backend: {watcher_backend}")
294
+ console.print(
295
+ f"compatibility export: {export_health}"
296
+ + (f" — {export_detail}" if export_detail else "")
297
+ )
298
+ console.print(
299
+ f"grammars: {grammars['available_count']}/{grammars['required_count']} available locally"
300
+ )
301
+ for row in grammars["languages"]:
302
+ if not row["available"]:
303
+ # Python parses via stdlib ast regardless of the tree-sitter wheel —
304
+ # don't let a Python-heavy repo read this line as broken indexing.
305
+ native_note = (
306
+ " — extraction unaffected (native stdlib-ast parser)"
307
+ if row.get("grammar") == "python"
308
+ else ""
309
+ )
310
+ console.print(
311
+ f" missing: {row['language']} "
312
+ f"({', '.join(row['missing_grammars'])}){native_note}"
313
+ )
314
+ if grammars["action"]:
315
+ console.print(f"grammar action: {grammars['action']}")
316
+ if not result["ok"]:
317
+ raise typer.Exit(code=1)
318
+
319
+
320
+ @app.command("search")
321
+ def graph_search(
322
+ query: str = typer.Argument(...),
323
+ project_root: Path = typer.Option(Path("."), "--project-root"),
324
+ limit: int = typer.Option(50, "--limit"),
325
+ semantic: bool = typer.Option(False, "--semantic", help="Use opt-in local embeddings when enabled."),
326
+ json_output: bool = typer.Option(False, "--json"),
327
+ ) -> None:
328
+ """Full-text (or semantic) symbol and path search over the committed generation."""
329
+ root = _root(project_root)
330
+ if semantic:
331
+ from devcouncil.indexing.graph.embeddings import semantic_search
332
+
333
+ result = semantic_search(root, query, limit=limit)
334
+ if not result.get("ok"):
335
+ from devcouncil.codeintel.query import CodeIntelQueryEngine
336
+
337
+ result = CodeIntelQueryEngine(root).search(query, limit=limit)
338
+ else:
339
+ from devcouncil.codeintel.query import CodeIntelQueryEngine
340
+
341
+ result = CodeIntelQueryEngine(root).search(query, limit=limit)
342
+ if json_output:
343
+ typer.echo(json.dumps(result, indent=2))
344
+ return
345
+ for match in result.get("matches", []):
346
+ if "line" in match:
347
+ console.print(f"{match['path']}:{match['line']} {match['id']} [{match['kind']}]")
348
+ else:
349
+ console.print(f"{match['path']} {match.get('label', match['id'])} score={match.get('score')}")
350
+
351
+
352
+ @app.command("ingest")
353
+ def graph_ingest(
354
+ paths: Optional[List[str]] = typer.Argument(None, help="Optional paths; full rebuild when omitted."),
355
+ project_root: Path = typer.Option(Path("."), "--project-root"),
356
+ no_liveness: bool = typer.Option(False, "--no-liveness"),
357
+ json_output: bool = typer.Option(False, "--json"),
358
+ ) -> None:
359
+ """Unified analyze entry: codeintel sync → graph export → repo map write."""
360
+ from devcouncil.indexing.map_artifacts import refresh_map_artifacts
361
+ from devcouncil.codeintel.sync import get_sync_coordinator
362
+ from devcouncil.codeintel import get_codeintel_service
363
+ from devcouncil.codeintel.build_control import GraphBuildBusy
364
+ from devcouncil.indexing.graph.embeddings import build_embeddings
365
+
366
+ root = _root(project_root)
367
+ coordinator = get_sync_coordinator(root)
368
+ changed = list(paths or [])
369
+ map_path = root / ".devcouncil" / "repo_map.json"
370
+ if paths is None:
371
+ try:
372
+ refresh = refresh_map_artifacts(
373
+ root,
374
+ map_path,
375
+ liveness=not no_liveness,
376
+ quiet=True,
377
+ )
378
+ except GraphBuildBusy as exc:
379
+ payload = {
380
+ "ok": False,
381
+ "code": "graph_writer_busy",
382
+ "error": str(exc),
383
+ "paths": changed,
384
+ }
385
+ if json_output:
386
+ typer.echo(json.dumps(payload, indent=2))
387
+ else:
388
+ status.print(f"[red]{exc}[/red]")
389
+ raise typer.Exit(code=1) from exc
390
+ else:
391
+ synced = coordinator.sync_now(changed)
392
+ if not synced:
393
+ payload = {"ok": False, "paths": changed, **coordinator.status().as_dict()}
394
+ if json_output:
395
+ typer.echo(json.dumps(payload, indent=2))
396
+ else:
397
+ status.print(f"[red]Graph ingest failed: {payload.get('last_error') or payload.get('degraded_reason')}[/red]")
398
+ raise typer.Exit(code=1)
399
+ refresh = refresh_map_artifacts(
400
+ root,
401
+ map_path,
402
+ liveness=not no_liveness,
403
+ quiet=True,
404
+ graph=get_codeintel_service(root).load(),
405
+ paths=changed,
406
+ )
407
+ embedded = build_embeddings(root)
408
+ payload = {
409
+ "ok": not refresh.degraded,
410
+ "paths": changed,
411
+ "map": str(map_path.relative_to(root)),
412
+ "embeddings_built": embedded,
413
+ "generation": refresh.generation,
414
+ "mode": refresh.mode,
415
+ "degraded": refresh.degraded,
416
+ "reason": refresh.reason,
417
+ }
418
+ if json_output:
419
+ typer.echo(json.dumps(payload, indent=2))
420
+ else:
421
+ color = "yellow" if refresh.degraded else "green"
422
+ status.print(
423
+ f"[{color}]Ingested {len(changed)} path(s); map at {payload['map']}"
424
+ f"{f'; {embedded} embeddings' if embedded else ''}"
425
+ f"{f'; degraded: {refresh.reason}' if refresh.degraded else ''}[/{color}]"
426
+ )
427
+ if refresh.degraded:
428
+ raise typer.Exit(code=1)
429
+
430
+
431
+ @app.command("cypher")
432
+ def graph_cypher(
433
+ query: str = typer.Argument(..., help="Supported MATCH … RETURN subset."),
434
+ project_root: Path = typer.Option(Path("."), "--project-root"),
435
+ json_output: bool = typer.Option(False, "--json"),
436
+ ) -> None:
437
+ """Run a supported Cypher subset over the native SQLite graph store."""
438
+ from devcouncil.indexing.graph.cypher import run_cypher
439
+
440
+ result = run_cypher(_root(project_root), query)
441
+ if json_output:
442
+ typer.echo(json.dumps(result, indent=2))
443
+ if not result.get("ok"):
444
+ raise typer.Exit(code=1)
445
+ return
446
+ if not result.get("ok"):
447
+ status.print(f"[red]{result.get('error', 'cypher failed')}[/red]")
448
+ raise typer.Exit(code=1)
449
+ for row in result.get("rows", []):
450
+ console.print(" ".join(f"{k}={v}" for k, v in row.items()))
451
+
452
+
453
+ @app.command("explore")
454
+ def graph_explore(
455
+ query: str = typer.Argument(...),
456
+ project_root: Path = typer.Option(Path("."), "--project-root"),
457
+ limit: int = typer.Option(20, "--limit"),
458
+ json_output: bool = typer.Option(False, "--json"),
459
+ ) -> None:
460
+ """Return source, related symbols, paths, and blast radius in one query."""
461
+ from devcouncil.codeintel.query import CodeIntelQueryEngine
462
+
463
+ result = CodeIntelQueryEngine(_root(project_root)).explore(query, limit=limit)
464
+ if json_output:
465
+ typer.echo(json.dumps(result, indent=2))
466
+ return
467
+ for definition in result["definitions"]:
468
+ console.print(f"[bold]{definition['id']}[/bold] {definition['path']}:{definition['line']}")
469
+ if definition["source"]:
470
+ console.print(definition["source"])
471
+ console.print(
472
+ f" callers={len(definition['callers'])} callees={len(definition['callees'])}"
473
+ )
474
+
475
+
476
+ @app.command("affected")
477
+ def graph_affected(
478
+ targets: List[str] = typer.Argument(..., help="Symbol or path targets."),
479
+ project_root: Path = typer.Option(Path("."), "--project-root"),
480
+ json_output: bool = typer.Option(False, "--json"),
481
+ ) -> None:
482
+ """Find tests reachable through the inbound blast radius."""
483
+ from devcouncil.codeintel.query import CodeIntelQueryEngine
484
+
485
+ result = CodeIntelQueryEngine(_root(project_root)).affected_tests(targets)
486
+ if json_output:
487
+ typer.echo(json.dumps(result, indent=2))
488
+ return
489
+ if not result["tests"]:
490
+ console.print("No affected tests found.")
491
+ return
492
+ for test in result["tests"]:
493
+ console.print(test)
494
+
495
+
496
+ @hooks_app.command("install")
497
+ def graph_hooks_install(
498
+ project_root: Path = typer.Option(Path("."), "--project-root"),
499
+ ) -> None:
500
+ """Install an opt-in post-checkout/post-merge reconciliation hook."""
501
+ root = _root(project_root)
502
+ git_dir = root / ".git"
503
+ if not git_dir.is_dir():
504
+ status.print("[red]Git hook installation requires a normal .git directory.[/red]")
505
+ raise typer.Exit(code=1)
506
+ hook_body = "#!/bin/sh\nexec dev graph sync --project-root \"$(git rev-parse --show-toplevel)\" >/dev/null 2>&1\n"
507
+ for name in ("post-checkout", "post-merge"):
508
+ path = git_dir / "hooks" / name
509
+ path.parent.mkdir(parents=True, exist_ok=True)
510
+ if path.exists() and "dev graph sync" not in path.read_text(encoding="utf-8", errors="replace"):
511
+ status.print(f"[red]Refusing to overwrite existing hook: {path}[/red]")
512
+ raise typer.Exit(code=1)
513
+ path.write_text(hook_body, encoding="utf-8")
514
+ path.chmod(0o755)
515
+ status.print("[green]Installed post-checkout and post-merge code-intelligence hooks.[/green]")
516
+
517
+
518
+ @app.command("query")
519
+ def graph_query(
520
+ name_or_path: str = typer.Argument(..., help="Symbol name or file path."),
521
+ project_root: Path = typer.Option(Path("."), "--project-root"),
522
+ json_output: bool = typer.Option(False, "--json"),
523
+ ) -> None:
524
+ """360° view: definition, callers, callees, importers."""
525
+ from devcouncil.indexing.graph import query_symbol
526
+
527
+ root = _root(project_root)
528
+ result = {**query_symbol(root, name_or_path), **_graph_degraded_fields(root)}
529
+ if json_output:
530
+ typer.echo(json.dumps(result, indent=2))
531
+ return
532
+ if result.get("graph_degraded"):
533
+ status.print(
534
+ f"[yellow]graph_degraded: {result.get('graph_degraded_reason') or 'lean map'}[/yellow]"
535
+ )
536
+ if result.get("error"):
537
+ status.print(f"[red]{result['error']}[/red]")
538
+ raise typer.Exit(code=1)
539
+ defs = result.get("definitions") or []
540
+ if not defs:
541
+ console.print(f"No matches for {name_or_path!r}")
542
+ return
543
+ for d in defs:
544
+ console.print(f"[bold]{d['id']}[/bold] ({d.get('kind')}) {d.get('path')}:{d.get('line')}")
545
+ console.print(f" callers: {', '.join(d.get('callers') or []) or '(none)'}")
546
+ console.print(f" callees: {', '.join(d.get('callees') or []) or '(none)'}")
547
+ console.print(f" importers: {', '.join(d.get('importers') or []) or '(none)'}")
548
+
549
+
550
+ @app.command("trace")
551
+ def graph_trace(
552
+ start: str = typer.Argument(..., help="Start node (name or path)."),
553
+ end: str = typer.Argument(..., help="End node (name or path)."),
554
+ project_root: Path = typer.Option(Path("."), "--project-root"),
555
+ json_output: bool = typer.Option(False, "--json"),
556
+ ) -> None:
557
+ """Shortest path between two graph nodes."""
558
+ from devcouncil.indexing.graph import trace_path
559
+
560
+ root = _root(project_root)
561
+ result = {**trace_path(root, start, end), **_graph_degraded_fields(root)}
562
+ if json_output:
563
+ typer.echo(json.dumps(result, indent=2))
564
+ return
565
+ if result.get("graph_degraded"):
566
+ status.print(
567
+ f"[yellow]graph_degraded: {result.get('graph_degraded_reason') or 'lean map'}[/yellow]"
568
+ )
569
+ if result.get("error"):
570
+ status.print(f"[red]{result['error']}[/red]")
571
+ raise typer.Exit(code=1)
572
+ if not result.get("found"):
573
+ console.print(f"No path between {start!r} and {end!r}")
574
+ raise typer.Exit(code=1)
575
+ console.print(" → ".join(result.get("path") or []))
576
+
577
+
578
+ @app.command("dead")
579
+ def graph_dead(
580
+ project_root: Path = typer.Option(Path("."), "--project-root"),
581
+ json_output: bool = typer.Option(False, "--json"),
582
+ confidence: Optional[str] = typer.Option(
583
+ None, "--confidence", help="Exact filter: extracted|inferred|ambiguous"
584
+ ),
585
+ min_confidence: str = typer.Option(
586
+ "inferred",
587
+ "--min-confidence",
588
+ help="Include this tier and above: extracted > inferred > ambiguous "
589
+ "(default: inferred; pass ambiguous to show all)",
590
+ ),
591
+ ) -> None:
592
+ """Full dead-code report with confidence tiers and reasons."""
593
+ from collections import Counter
594
+
595
+ from devcouncil.indexing.graph.liveness import confidence_at_least
596
+
597
+ root = _root(project_root)
598
+ graph = _require_graph(root)
599
+ entries = list(graph.dead_code)
600
+ if confidence:
601
+ entries = [
602
+ e
603
+ for e in entries
604
+ if (e.confidence.value if hasattr(e.confidence, "value") else str(e.confidence))
605
+ == confidence
606
+ ]
607
+ before_min = len(entries)
608
+ if min_confidence:
609
+ entries = [
610
+ e
611
+ for e in entries
612
+ if confidence_at_least(e.confidence, min_confidence)
613
+ ]
614
+ hidden = before_min - len(entries)
615
+ degraded = _graph_degraded_fields(root)
616
+ if json_output:
617
+ typer.echo(
618
+ json.dumps(
619
+ {
620
+ "dead_code": [e.model_dump() for e in entries],
621
+ "dead_code_hidden": hidden,
622
+ **degraded,
623
+ },
624
+ indent=2,
625
+ )
626
+ )
627
+ return
628
+ if degraded.get("graph_degraded"):
629
+ status.print(
630
+ f"[yellow]graph_degraded: {degraded.get('graph_degraded_reason') or 'lean map'} "
631
+ "— treat dead tiers as unreliable[/yellow]"
632
+ )
633
+ if not entries:
634
+ console.print("No dead-code entries.")
635
+ if hidden:
636
+ console.print(
637
+ f"{hidden} lower-confidence entries hidden "
638
+ "(--min-confidence ambiguous to show)."
639
+ )
640
+ return
641
+ for e in entries:
642
+ conf = e.confidence.value if hasattr(e.confidence, "value") else e.confidence
643
+ console.print(
644
+ f"{e.path}:{e.line} {e.id} [{conf}/{e.kind}] {e.reason}"
645
+ )
646
+ reason_counts = Counter(e.reason or "(none)" for e in entries)
647
+ console.print("")
648
+ console.print("Reason summary:")
649
+ for reason, n in reason_counts.most_common():
650
+ console.print(f" {n:4d} {reason}")
651
+ if hidden:
652
+ console.print("")
653
+ console.print(
654
+ f"{hidden} lower-confidence entries hidden "
655
+ "(--min-confidence ambiguous to show)."
656
+ )
657
+
658
+
659
+ @app.command("check")
660
+ def graph_check_cmd(
661
+ project_root: Path = typer.Option(Path("."), "--project-root"),
662
+ json_output: bool = typer.Option(False, "--json"),
663
+ top: int = typer.Option(15, "--top", help="How many god nodes to list."),
664
+ ) -> None:
665
+ """God nodes (top-connected) and circular-import component detection."""
666
+ from devcouncil.indexing.graph.intel import graph_check
667
+
668
+ root = _root(project_root)
669
+ graph = _require_graph(root)
670
+ report = graph_check(graph, top_n=top)
671
+ if json_output:
672
+ typer.echo(json.dumps(report, indent=2))
673
+ return
674
+ console.print(f"[bold]God nodes[/bold] (top {top} by degree)")
675
+ for g in report.get("god_nodes") or []:
676
+ console.print(
677
+ f" {g.get('degree'):>4} {g.get('id')} ({g.get('kind')})"
678
+ )
679
+ cycles = report.get("circular_imports") or []
680
+ console.print(
681
+ f"\n[bold]Circular imports — strongly connected components[/bold] ({len(cycles)})"
682
+ )
683
+ if not cycles:
684
+ console.print(" (none)")
685
+ for c in cycles[:30]:
686
+ console.print(" " + " ↔ ".join(c.get("nodes") or []))
687
+ package_init_count = report.get("package_init_count", 0)
688
+ if package_init_count:
689
+ console.print(
690
+ f" {package_init_count} package-__init__ component(s) suppressed as barrel noise"
691
+ )
692
+
693
+
694
+ @app.command("process")
695
+ def graph_process(
696
+ entry: Optional[str] = typer.Argument(
697
+ None, help="Optional entry root path or name filter."
698
+ ),
699
+ project_root: Path = typer.Option(Path("."), "--project-root"),
700
+ json_output: bool = typer.Option(False, "--json"),
701
+ max_depth: int = typer.Option(6, "--max-depth"),
702
+ ) -> None:
703
+ """BFS call-flows from entry roots (named, step-ordered, depth-capped)."""
704
+ from devcouncil.indexing.graph.intel import extract_processes
705
+
706
+ root = _root(project_root)
707
+ graph = _require_graph(root)
708
+ processes = extract_processes(graph, entry=entry, max_depth=max_depth)
709
+ if json_output:
710
+ typer.echo(json.dumps(processes, indent=2))
711
+ return
712
+ if not processes:
713
+ console.print("No processes found.")
714
+ return
715
+ for p in processes:
716
+ console.print(f"[bold]{p.get('name')}[/bold] (depth {p.get('depth')})")
717
+ console.print(" " + " → ".join(p.get("steps") or []))
718
+
719
+
720
+ @app.command("impact")
721
+ def graph_impact(
722
+ paths: Optional[List[str]] = typer.Argument(
723
+ None, help="Paths to analyze (omit with --diff for working-tree changes)."
724
+ ),
725
+ diff: bool = typer.Option(
726
+ False, "--diff", help="Use working-tree changed files as the seed set."
727
+ ),
728
+ project_root: Path = typer.Option(Path("."), "--project-root"),
729
+ json_output: bool = typer.Option(False, "--json"),
730
+ max_depth: int = typer.Option(3, "--max-depth", help="Inbound blast depth (1–3)."),
731
+ ) -> None:
732
+ """Diff / path blast radius via enclosing symbols and inbound callers."""
733
+ from devcouncil.indexing.graph.intel import diff_impact
734
+
735
+ root = _root(project_root)
736
+ graph = _require_graph(root)
737
+ if not diff and not paths:
738
+ status.print("[red]Provide paths or --diff.[/red]")
739
+ raise typer.Exit(code=1)
740
+ result = diff_impact(
741
+ root,
742
+ graph,
743
+ paths=paths,
744
+ use_diff=diff,
745
+ max_depth=max(1, min(3, max_depth)),
746
+ )
747
+ if json_output:
748
+ typer.echo(json.dumps(result, indent=2))
749
+ return
750
+ if not result.get("paths"):
751
+ console.print("No impacted paths.")
752
+ return
753
+ for item in result["paths"]:
754
+ console.print(f"[bold]{item['path']}[/bold]")
755
+ syms = item.get("symbols") or []
756
+ if syms:
757
+ console.print(" symbols: " + ", ".join(s["id"] for s in syms[:8]))
758
+ for layer in (item.get("blast") or {}).get("layers") or []:
759
+ nodes = layer.get("nodes") or []
760
+ console.print(
761
+ f" depth {layer['depth']} [{layer['confidence']}]: "
762
+ f"{len(nodes)} — " + ", ".join(nodes[:6])
763
+ + (" …" if len(nodes) > 6 else "")
764
+ )
765
+
766
+
767
+ @app.command("html")
768
+ def graph_html(
769
+ project_root: Path = typer.Option(Path("."), "--project-root"),
770
+ open_browser: bool = typer.Option(False, "--open", help="Open in the default browser."),
771
+ symbols: bool = typer.Option(
772
+ False,
773
+ "--symbols",
774
+ help="Default the visualizer to symbol-level mode (calls/inherits) instead of file imports.",
775
+ ),
776
+ ) -> None:
777
+ """Write a self-contained interactive ``graph.html``."""
778
+ from devcouncil.indexing.viz import write_graph_html
779
+
780
+ root = _root(project_root)
781
+ try:
782
+ out = write_graph_html(root, open_browser=open_browser, symbols=symbols)
783
+ except FileNotFoundError as exc:
784
+ status.print(f"[red]{exc}[/red]")
785
+ raise typer.Exit(code=1) from exc
786
+ status.print(f"[green]Wrote {out}[/green]")
787
+
788
+
789
+ @app.command("demo")
790
+ def graph_demo(
791
+ project_root: Path = typer.Option(Path("."), "--project-root"),
792
+ open_browser: bool = typer.Option(False, "--open", help="Open the interactive demo."),
793
+ json_output: bool = typer.Option(False, "--json"),
794
+ ) -> None:
795
+ """Write sample graph HTML and SVG artifacts without requiring a repo map."""
796
+ from devcouncil.indexing.viz import write_graph_demo
797
+
798
+ paths = write_graph_demo(_root(project_root), open_browser=open_browser)
799
+ payload = {name: str(path) for name, path in paths.items()}
800
+ if json_output:
801
+ typer.echo(json.dumps(payload, indent=2))
802
+ return
803
+ status.print(f"[green]Wrote {payload['html']} and {payload['svg']}[/green]")
804
+
805
+
806
+ @app.command("view")
807
+ def graph_view(
808
+ project_root: Path = typer.Option(Path("."), "--project-root"),
809
+ port: int = typer.Option(8765, "--port"),
810
+ ) -> None:
811
+ """Serve/open the graph HTML via a tiny local HTTP server."""
812
+ import http.server
813
+ import socketserver
814
+ import threading
815
+ import webbrowser
816
+
817
+ from devcouncil.indexing.viz import write_graph_html
818
+
819
+ root = _root(project_root)
820
+ try:
821
+ out = write_graph_html(root, open_browser=False)
822
+ except FileNotFoundError as exc:
823
+ status.print(f"[red]{exc}[/red]")
824
+ raise typer.Exit(code=1) from exc
825
+
826
+ directory = str(out.parent)
827
+
828
+ class Handler(http.server.SimpleHTTPRequestHandler):
829
+ def __init__(self, *args, **kwargs):
830
+ super().__init__(*args, directory=directory, **kwargs)
831
+
832
+ def log_message(self, fmt, *args): # noqa: A003
833
+ return
834
+
835
+ try:
836
+ httpd = socketserver.TCPServer(("127.0.0.1", port), Handler)
837
+ except OSError as exc:
838
+ status.print(f"[red]Cannot serve on 127.0.0.1:{port}: {exc} (try --port)[/red]")
839
+ raise typer.Exit(code=1) from exc
840
+ with httpd:
841
+ url = f"http://127.0.0.1:{port}/graph.html"
842
+ status.print(f"[green]Serving {url} (Ctrl-C to stop)[/green]")
843
+ threading.Timer(0.3, lambda: webbrowser.open(url)).start()
844
+ try:
845
+ httpd.serve_forever()
846
+ except KeyboardInterrupt:
847
+ status.print("Stopped.")
848
+
849
+
850
+ @app.command("export")
851
+ def graph_export(
852
+ format: str = typer.Option(
853
+ "graphml",
854
+ "--format",
855
+ help="graphml | okf | okf-links",
856
+ ),
857
+ output: Path = typer.Option(Path("-"), "--output", "-o"),
858
+ project_root: Path = typer.Option(Path("."), "--project-root"),
859
+ ) -> None:
860
+ """Export the code graph as attributed GraphML or an OKF v0.1 bundle."""
861
+ from devcouncil.indexing.graph.export import export_graphml, write_code_graph_okf
862
+
863
+ root = _root(project_root)
864
+ graph = _require_graph(root)
865
+ fmt = format.lower().strip()
866
+ if fmt == "graphml":
867
+ text = export_graphml(graph)
868
+ if str(output) == "-":
869
+ typer.echo(text)
870
+ else:
871
+ out = output if output.is_absolute() else root / output
872
+ out.parent.mkdir(parents=True, exist_ok=True)
873
+ out.write_text(text, encoding="utf-8")
874
+ status.print(f"[green]Wrote {out}[/green]")
875
+ return
876
+ if fmt == "okf":
877
+ if str(output) == "-":
878
+ status.print("[red]OKF export requires -o <directory>[/red]")
879
+ raise typer.Exit(code=1)
880
+ out_dir = output if output.is_absolute() else root / output
881
+ try:
882
+ written_dir, paths = write_code_graph_okf(root, out_dir, graph=graph)
883
+ except FileNotFoundError as exc:
884
+ status.print(f"[red]{exc}[/red]")
885
+ raise typer.Exit(code=1) from exc
886
+ status.print(f"[green]Wrote OKF bundle ({len(paths)} docs) to {written_dir}[/green]")
887
+ return
888
+ if fmt in {"okf-links"}:
889
+ rows = []
890
+ for e in graph.edges:
891
+ if e.kind in {"imports", "calls"}:
892
+ rows.append(f"{e.source} --{e.kind}--> {e.target}")
893
+ text = "\n".join(rows)
894
+ if str(output) == "-":
895
+ typer.echo(text)
896
+ else:
897
+ out = output if output.is_absolute() else root / output
898
+ out.parent.mkdir(parents=True, exist_ok=True)
899
+ out.write_text(text, encoding="utf-8")
900
+ status.print(f"[green]Wrote {out}[/green]")
901
+ return
902
+ status.print(f"[red]Unknown format: {format}[/red]")
903
+ raise typer.Exit(code=1)
904
+
905
+
906
+ @app.command("routes")
907
+ def graph_routes(
908
+ project_root: Path = typer.Option(Path("."), "--project-root"),
909
+ json_output: bool = typer.Option(False, "--json"),
910
+ ) -> None:
911
+ """Map HTTP routes to handlers and client fetch consumers."""
912
+ from devcouncil.indexing.graph.api_routes import route_map
913
+
914
+ root = _root(project_root)
915
+ graph = _require_graph(root)
916
+ result = route_map(root, graph)
917
+ if json_output:
918
+ typer.echo(json.dumps(result, indent=2))
919
+ return
920
+ routes = result.get("routes") or []
921
+ if not routes:
922
+ console.print("No routes found.")
923
+ return
924
+ for route in routes:
925
+ console.print(
926
+ f"[bold]{route.get('verb')} {route.get('path')}[/bold] "
927
+ f"({route.get('framework') or 'unknown'})"
928
+ )
929
+ handlers = route.get("handlers") or []
930
+ if handlers:
931
+ console.print(" handlers: " + ", ".join(h.get("id", "?") for h in handlers[:4]))
932
+ consumers = route.get("consumers") or []
933
+ if consumers:
934
+ console.print(f" consumers: {len(consumers)}")
935
+
936
+
937
+ @app.command("shape-check")
938
+ def graph_shape_check(
939
+ project_root: Path = typer.Option(Path("."), "--project-root"),
940
+ json_output: bool = typer.Option(False, "--json"),
941
+ route: Optional[str] = typer.Option(None, "--route", help="Filter to one route path or id."),
942
+ ) -> None:
943
+ """Compare handler response keys vs client accessed keys."""
944
+ from devcouncil.indexing.graph.api_routes import shape_check
945
+
946
+ root = _root(project_root)
947
+ graph = _require_graph(root)
948
+ result = shape_check(root, graph, route_filter=route)
949
+ if json_output:
950
+ typer.echo(json.dumps(result, indent=2))
951
+ return
952
+ mismatches = result.get("mismatches") or []
953
+ if not mismatches:
954
+ console.print("[green]No shape mismatches.[/green]")
955
+ return
956
+ for item in mismatches:
957
+ console.print(
958
+ f"[yellow]{item.get('verb')} {item.get('route')}[/yellow] — "
959
+ f"missing in handler: {', '.join(item.get('missing_in_handler') or [])}"
960
+ )
961
+
962
+
963
+ @app.command("api-impact")
964
+ def graph_api_impact(
965
+ route_or_path: str = typer.Argument(..., help="Route path, id, or normalized segment."),
966
+ project_root: Path = typer.Option(Path("."), "--project-root"),
967
+ json_output: bool = typer.Option(False, "--json"),
968
+ ) -> None:
969
+ """API blast radius: consumers, middleware, shape mismatches, risk tier."""
970
+ from devcouncil.indexing.graph.api_routes import api_impact
971
+
972
+ root = _root(project_root)
973
+ graph = _require_graph(root)
974
+ result = api_impact(root, route_or_path, graph)
975
+ if json_output:
976
+ typer.echo(json.dumps(result, indent=2))
977
+ return
978
+ if not result.get("found"):
979
+ console.print(f"[red]Route not found:[/red] {route_or_path}")
980
+ raise typer.Exit(code=1)
981
+ console.print(
982
+ f"[bold]{result.get('verb')} {result.get('route')}[/bold] "
983
+ f"risk={result.get('risk')}"
984
+ )
985
+ console.print(f" consumers: {len(result.get('consumers') or [])}")
986
+ console.print(f" middleware: {len(result.get('middleware') or [])}")
987
+ mismatches = result.get("shape_mismatches") or []
988
+ if mismatches:
989
+ console.print(f" shape mismatches: {len(mismatches)}")
990
+
991
+
992
+ corpus_app = typer.Typer(
993
+ name="corpus",
994
+ help="Advisory mixed-corpus index for docs, PDFs, and images (never verify gates).",
995
+ add_completion=False,
996
+ )
997
+
998
+
999
+ @corpus_app.command("build")
1000
+ def corpus_build(
1001
+ path: Optional[str] = typer.Option(None, "--path", help="Root file or directory to index."),
1002
+ project_root: Path = typer.Option(Path("."), "--project-root"),
1003
+ json_output: bool = typer.Option(False, "--json"),
1004
+ ) -> None:
1005
+ """Build ``.devcouncil/corpus/graph.json`` from docs, PDFs, and images."""
1006
+ from devcouncil.indexing.wiring import build_corpus, corpus_status
1007
+
1008
+ root = _root(project_root)
1009
+ build_corpus(root, path=path)
1010
+ result = corpus_status(root)
1011
+ if json_output:
1012
+ typer.echo(json.dumps(result, indent=2))
1013
+ return
1014
+ status.print(
1015
+ f"[green]Corpus indexed — {result['node_count']} nodes, "
1016
+ f"{result['edge_count']} edges → {result.get('graph_path')}[/green]"
1017
+ )
1018
+
1019
+
1020
+ @corpus_app.command("query")
1021
+ def corpus_query(
1022
+ query: str = typer.Argument(..., help="Search string."),
1023
+ project_root: Path = typer.Option(Path("."), "--project-root"),
1024
+ json_output: bool = typer.Option(False, "--json"),
1025
+ limit: int = typer.Option(20, "--limit"),
1026
+ ) -> None:
1027
+ """Search the advisory corpus graph."""
1028
+ from devcouncil.indexing.wiring import query_corpus
1029
+
1030
+ root = _root(project_root)
1031
+ result = query_corpus(root, query, limit=limit)
1032
+ if json_output:
1033
+ typer.echo(json.dumps(result, indent=2))
1034
+ if result.get("error"):
1035
+ raise typer.Exit(code=1)
1036
+ return
1037
+ if result.get("error"):
1038
+ status.print(f"[red]{result['error']}[/red]")
1039
+ raise typer.Exit(code=1)
1040
+ matches = result.get("matches") or []
1041
+ if not matches:
1042
+ console.print(f"No matches for {query!r}")
1043
+ return
1044
+ for item in matches:
1045
+ console.print(
1046
+ f"[bold]{item['label']}[/bold] ({item['kind']}) "
1047
+ f"{item.get('path') or ''} score={item.get('score')}"
1048
+ )
1049
+
1050
+
1051
+ @corpus_app.command("status")
1052
+ def corpus_status_cmd(
1053
+ project_root: Path = typer.Option(Path("."), "--project-root"),
1054
+ json_output: bool = typer.Option(False, "--json"),
1055
+ ) -> None:
1056
+ """Show corpus artifact freshness and counts."""
1057
+ from devcouncil.indexing.wiring import corpus_status
1058
+
1059
+ root = _root(project_root)
1060
+ result = corpus_status(root)
1061
+ if json_output:
1062
+ typer.echo(json.dumps(result, indent=2))
1063
+ return
1064
+ console.print(f"enabled: {result['enabled']}")
1065
+ console.print(f"graph: {result.get('graph_path') or '(not built)'}")
1066
+ console.print(f"built_at: {result.get('built_at') or '(none)'}")
1067
+ console.print(f"nodes/edges: {result.get('node_count', 0)}/{result.get('edge_count', 0)}")
1068
+ console.print("advisory: yes (does not feed verify gates)")
1069
+
1070
+
1071
+ pdg_app = typer.Typer(
1072
+ name="pdg",
1073
+ help="Opt-in CFG / reaching-def / CDG / taint analysis (Python, intra-procedural).",
1074
+ add_completion=False,
1075
+ )
1076
+ app.add_typer(pdg_app, name="pdg")
1077
+
1078
+
1079
+ @pdg_app.command("build")
1080
+ def graph_pdg_build(
1081
+ paths: List[str] = typer.Option([], "--path", help="Limit analysis to these repo-relative files."),
1082
+ project_root: Path = typer.Option(Path("."), "--project-root"),
1083
+ json_output: bool = typer.Option(False, "--json"),
1084
+ ) -> None:
1085
+ """Build or refresh the PDG layer for Python files."""
1086
+ from devcouncil.indexing.graph.build import (
1087
+ CompatibilityGraphTooLarge,
1088
+ build_pdg_for_paths,
1089
+ merge_pdg_into_graph,
1090
+ write_code_graph,
1091
+ )
1092
+
1093
+ root = _root(project_root)
1094
+ graph = _require_graph(root)
1095
+ layer = build_pdg_for_paths(root, graph, paths=paths or None)
1096
+ shards = merge_pdg_into_graph(graph, layer)
1097
+ merged: dict = {}
1098
+ try:
1099
+ from devcouncil.codeintel import get_codeintel_service
1100
+
1101
+ merged = dict(get_codeintel_service(root).store.analysis_shards())
1102
+ except Exception:
1103
+ pass
1104
+ for path, payload in shards.items():
1105
+ merged.setdefault(path, {}).update(payload)
1106
+ export_warning = ""
1107
+ try:
1108
+ write_code_graph(root, graph, analysis_shards=merged)
1109
+ except CompatibilityGraphTooLarge as exc:
1110
+ # SQLite committed the PDG shards and a stub/pointer JSON is on disk;
1111
+ # only the compatibility export is degraded — not the PDG build.
1112
+ export_warning = str(exc)
1113
+ stats = (graph.meta.get("pdg") or {}).get("stats") or {}
1114
+ payload = {"ok": True, "stats": stats, "files": sorted(layer.files.keys())}
1115
+ if export_warning:
1116
+ payload["compatibility_export"] = "degraded"
1117
+ payload["compatibility_export_reason"] = export_warning
1118
+ if json_output:
1119
+ typer.echo(json.dumps(payload, indent=2))
1120
+ return
1121
+ console.print(
1122
+ f"PDG: {stats.get('function_count', 0)} functions, "
1123
+ f"{stats.get('taint_count', 0)} taint findings across {stats.get('file_count', 0)} files"
1124
+ )
1125
+ if export_warning:
1126
+ console.print(f"[yellow]compatibility export degraded: {export_warning}[/yellow]")
1127
+
1128
+
1129
+ @app.command("explain")
1130
+ def graph_explain(
1131
+ path: Optional[str] = typer.Option(None, "--path", help="Filter by file path."),
1132
+ category: Optional[str] = typer.Option(None, "--category", help="Filter by taint category."),
1133
+ project_root: Path = typer.Option(Path("."), "--project-root"),
1134
+ json_output: bool = typer.Option(False, "--json"),
1135
+ ) -> None:
1136
+ """Report heuristic taint findings from the opt-in PDG layer."""
1137
+ from devcouncil.indexing.graph.query import explain_pdg_taint
1138
+
1139
+ root = _root(project_root)
1140
+ result = explain_pdg_taint(root, path=path, category=category)
1141
+ if json_output:
1142
+ typer.echo(json.dumps(result, indent=2))
1143
+ if not result.get("ok"):
1144
+ raise typer.Exit(code=1)
1145
+ return
1146
+ if not result.get("ok"):
1147
+ status.print(f"[red]{result.get('error')}[/red]")
1148
+ raise typer.Exit(code=1)
1149
+ findings = result.get("findings") or []
1150
+ if not findings:
1151
+ console.print("No taint findings.")
1152
+ return
1153
+ for item in findings:
1154
+ console.print(
1155
+ f"{item.get('path')}:{item.get('sink_line')} "
1156
+ f"[{item.get('category')}] {item.get('function')} "
1157
+ f"{item.get('source_expr')} -> {item.get('sink_expr')}"
1158
+ )
1159
+
1160
+
1161
+ @app.command("pdg-query")
1162
+ def graph_pdg_query(
1163
+ mode: str = typer.Option(..., "--mode", help="controls or flows"),
1164
+ target: str = typer.Option(..., "--target", help="Symbol qualname or file path."),
1165
+ variable: Optional[str] = typer.Option(None, "--variable", help="Filter flows by variable."),
1166
+ project_root: Path = typer.Option(Path("."), "--project-root"),
1167
+ json_output: bool = typer.Option(False, "--json"),
1168
+ ) -> None:
1169
+ """Query control or data dependence for an anchored target."""
1170
+ from devcouncil.indexing.graph.query import query_pdg_controls, query_pdg_flows
1171
+
1172
+ root = _root(project_root)
1173
+ if mode == "controls":
1174
+ result = query_pdg_controls(root, target)
1175
+ elif mode == "flows":
1176
+ result = query_pdg_flows(root, target, variable=variable)
1177
+ else:
1178
+ status.print("[red]--mode must be controls or flows[/red]")
1179
+ raise typer.Exit(code=2)
1180
+ if json_output:
1181
+ typer.echo(json.dumps(result, indent=2))
1182
+ if not result.get("ok"):
1183
+ raise typer.Exit(code=1)
1184
+ return
1185
+ if not result.get("ok"):
1186
+ status.print(f"[red]{result.get('error')}[/red]")
1187
+ raise typer.Exit(code=1)
1188
+ for fn in result.get("functions") or []:
1189
+ console.print(f"[bold]{fn.get('qualname')}[/bold] ({fn.get('path')})")
1190
+ key = "cdg" if mode == "controls" else "reaching_def"
1191
+ for edge in fn.get(key) or []:
1192
+ console.print(f" {edge}")