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,1776 @@
1
+ """Config-declared entry roots and structural exemptions for file-level liveness.
2
+
3
+ Single source of truth shared by ``dev map`` liveness fields and the
4
+ ``unwired_file`` / ``dead_symbol`` verification gates so they never disagree on
5
+ what counts as "wired by convention/config".
6
+
7
+ Also hosts comment strippers and wiring-decorator exemptions used by both the
8
+ map's ``dead_symbol_candidates`` and the verify ``dead_symbol`` gate.
9
+
10
+ Never raises on malformed config — degrades to empty/False.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import ast
16
+ import json
17
+ import logging
18
+ import re
19
+ from datetime import UTC, datetime
20
+ from pathlib import Path
21
+ from typing import Any, Dict, Iterable, Iterator, List, Literal, Optional, Set, Tuple
22
+
23
+ from pydantic import BaseModel, Field
24
+
25
+ from devcouncil.indexing.walk import IGNORED_DIR_NAMES, should_skip_path
26
+ from devcouncil.utils.json_persist import read_model_json, write_model_json
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ # Python + JS/TS always. Go is file-level (all package members). Rust is included
31
+ # only when tree-sitter edges are available (see is_liveness_code_file).
32
+ _LIVENESS_EXTS = {".py", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".go"}
33
+ _RUST_LIVENESS_EXT = ".rs"
34
+ _TEST_DIR_NAMES = {"tests", "test", "__tests__", "spec"}
35
+ _SCRIPT_DIR_NAMES = {"scripts", "bin", "benchmarks"}
36
+ ALLOW_UNWIRED = "devcouncil: allow-unwired"
37
+ _IMPORTLIB_RE = re.compile(
38
+ r"""(?:importlib(?:\.import_module)?|__import__)\s*\(\s*['"]([^'"]+)['"]"""
39
+ )
40
+ _DYNAMIC_IMPORT_RE = re.compile(r"""import\s*\(\s*['"]([^'"]+)['"]\s*\)""")
41
+ # Vite/webpack ``new Worker(new URL("./x", import.meta.url))`` (and bare URL).
42
+ _WORKER_URL_RE = re.compile(
43
+ r"""(?:new\s+(?:Worker|SharedWorker)\s*\(\s*)?"""
44
+ r"""new\s+URL\s*\(\s*['"]([^'"]+)['"]\s*,\s*import\.meta\.url"""
45
+ )
46
+ # ``python -m pkg.mod`` and argv forms like ``"-m", "pkg.mod"``.
47
+ _PYTHON_DASH_M_RE = re.compile(
48
+ r"""(?:^|[^\w-])(?:-m|--module)(?:\s+|\s*,\s*)['"]([A-Za-z_][\w.]*)['"]"""
49
+ r"""|['"](?:-m|--module)['"]\s*,\s*['"]([A-Za-z_][\w.]*)['"]"""
50
+ )
51
+ # ``importlib.resources.files("pkg.sub")`` package-resource loads.
52
+ _PACKAGE_RESOURCES_RE = re.compile(
53
+ r"""(?:resources\.)?files\s*\(\s*['"]([A-Za-z_][\w.]*)['"]"""
54
+ )
55
+ # Bundled asset basenames referenced as string constants (plugins, images, …).
56
+ _BUNDLED_ASSET_RE = re.compile(
57
+ r"""['"]([A-Za-z_][\w.-]*\.(?:mjs|cjs|js|css|svg|png|jpe?g|webp|html))['"]"""
58
+ )
59
+ _HATCH_CUSTOM_HOOK_RE = re.compile(
60
+ r"""(?ms)^\[tool\.hatch\.build\.hooks\.custom\]\s*$.*?^path\s*=\s*['"]([^'"]+)['"]"""
61
+ )
62
+ _CODE_CONFIG_SUFFIXES = {
63
+ ".py", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
64
+ ".toml", ".json", ".yaml", ".yml", ".cfg", ".ini",
65
+ }
66
+ _ROUTE_DIR_HINTS = (
67
+ "app/",
68
+ "pages/",
69
+ "routes/",
70
+ "src/app/",
71
+ "src/pages/",
72
+ "src/routes/",
73
+ "app/routes/",
74
+ )
75
+
76
+ # Decorators that themselves constitute wiring (framework registration).
77
+ _WIRING_DECORATOR_HINTS = (
78
+ "app.", "router.", "typer.", "click.", "pytest.", "celery.",
79
+ "flask", "fastapi", "command", "route", "task", "fixture",
80
+ "register", "hookimpl", "hookable",
81
+ )
82
+
83
+ # Bumped when dead-symbol / token-scan semantics change so ratchet baselines
84
+ # skip stale symbol diffs instead of firing false stranded_code regressions.
85
+ LIVENESS_SCAN_VERSION = 3
86
+
87
+ _VENDOR_DIR_NAMES = frozenset({"vendor", "vendored", "node_modules"})
88
+
89
+
90
+ def _norm(path: str) -> str:
91
+ """Normalize to posix and strip leading ``./`` only (never ``lstrip('./')``)."""
92
+ s = str(path).replace("\\", "/")
93
+ while s.startswith("./"):
94
+ s = s[2:]
95
+ return s
96
+
97
+
98
+ def is_test_path(path: str) -> bool:
99
+ """True when path looks like a test file by common conventions."""
100
+ norm = _norm(path).lower()
101
+ name = norm.rsplit("/", 1)[-1]
102
+ parts = norm.split("/")
103
+ in_test_dir = any(p in _TEST_DIR_NAMES for p in parts[:-1])
104
+ looks_like_test = (
105
+ name.startswith("test_")
106
+ or name == "conftest.py"
107
+ or any(
108
+ name.endswith(suffix)
109
+ for suffix in (
110
+ "_test.py",
111
+ "_test.go",
112
+ ".test.js",
113
+ ".test.ts",
114
+ ".test.jsx",
115
+ ".test.tsx",
116
+ ".spec.js",
117
+ ".spec.ts",
118
+ ".spec.jsx",
119
+ ".spec.tsx",
120
+ "_spec.rb",
121
+ )
122
+ )
123
+ )
124
+ return looks_like_test or (in_test_dir and not name.startswith("."))
125
+
126
+
127
+ def is_liveness_code_file(path: str) -> bool:
128
+ """True for languages with reliable file-level import edges.
129
+
130
+ Go is included (file-level package member edges). Rust is included only when
131
+ the optional tree-sitter layer can emit ``mod``/``use`` edges; without it,
132
+ Rust files would all look unwired.
133
+ """
134
+ suffix = Path(_norm(path)).suffix.lower()
135
+ if suffix in _LIVENESS_EXTS:
136
+ return True
137
+ if suffix == _RUST_LIVENESS_EXT:
138
+ try:
139
+ from devcouncil.indexing.ts_imports import tree_sitter_available
140
+
141
+ return tree_sitter_available()
142
+ except Exception:
143
+ return False
144
+ return False
145
+
146
+
147
+ def is_private_symbol(name: str) -> bool:
148
+ """True for underscore-prefixed names skipped by dead-symbol detection."""
149
+ return bool(name) and name.startswith("_")
150
+
151
+
152
+ def is_dunder_symbol(name: str) -> bool:
153
+ """True for ``__dunder__`` names (methods exempt from dead-code reports)."""
154
+ return bool(name) and len(name) >= 4 and name.startswith("__") and name.endswith("__")
155
+
156
+
157
+ def is_vendored_path(path: str) -> bool:
158
+ """True when ``path`` is a vendored/minified bundle, not first-class source.
159
+
160
+ Matches ``vendor`` / ``vendored`` / ``node_modules`` path segments and
161
+ ``.min.js`` / ``.min.css`` basenames — same convention
162
+ :func:`structural_exemptions` already encodes for file-level liveness.
163
+ """
164
+ try:
165
+ norm = _norm(path)
166
+ name = Path(norm).name
167
+ parts = norm.lower().split("/")
168
+ if any(p in _VENDOR_DIR_NAMES for p in parts):
169
+ return True
170
+ if name.endswith(".min.js") or name.endswith(".min.css"):
171
+ return True
172
+ return False
173
+ except Exception:
174
+ logger.debug("is_vendored_path failed for %s", path, exc_info=True)
175
+ return False
176
+
177
+
178
+ # Dynamic getattr(x, "name") keys in :func:`build_dynamic_import_index`.
179
+ GETATTR_INDEX_PREFIX = "getattr:"
180
+
181
+ _GETATTR_NAME_RE = re.compile(
182
+ r"""getattr\s*\(\s*[^,]+,\s*['"]([A-Za-z_][A-Za-z0-9_]*)['"]"""
183
+ )
184
+
185
+ # JS/TS export forms shared by map token-scan and verify dead_symbol gate.
186
+ _JS_EXPORT_DECL_RE = re.compile(
187
+ r"(?m)^\s*export\s+(?:async\s+)?(?:function|class|const|let|var)\s+([A-Za-z_][A-Za-z0-9_]*)"
188
+ )
189
+ _JS_EXPORT_LIST_RE = re.compile(
190
+ r"(?m)^\s*export\s+(?:default\s+)?(?:async\s+)?(?:function|class)\s+([A-Za-z_][A-Za-z0-9_]*)"
191
+ r"|^\s*export\s+default\s+([A-Za-z_][A-Za-z0-9_]*)\s*;"
192
+ r"|^\s*export\s*\{([^}]+)\}"
193
+ r"|^\s*export\s+(?:type\s+)?\{([^}]+)\}\s*from\s*['\"][^'\"]+['\"]"
194
+ r"|^\s*export\s+\*\s+as\s+([A-Za-z_][A-Za-z0-9_]*)\s+from\s*['\"][^'\"]+['\"]"
195
+ )
196
+
197
+
198
+ def parse_python_all_exports(source: str) -> Set[str]:
199
+ """Return names listed in a module-level ``__all__`` assignment (best-effort)."""
200
+ out: Set[str] = set()
201
+ try:
202
+ tree = ast.parse(source)
203
+ except (SyntaxError, ValueError):
204
+ return out
205
+ for node in tree.body:
206
+ if not isinstance(node, ast.Assign):
207
+ continue
208
+ for t in node.targets:
209
+ if isinstance(t, ast.Name) and t.id == "__all__":
210
+ if isinstance(node.value, (ast.List, ast.Tuple)):
211
+ for elt in node.value.elts:
212
+ if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
213
+ out.add(elt.value)
214
+ return out
215
+
216
+
217
+ def parse_python_reexport_names(path: str, source: str) -> Set[str]:
218
+ """Names re-exported by a barrel ``__init__.py`` or listed in ``__all__``.
219
+
220
+ Non-init modules do not treat every ``from x import y`` as a re-export — only
221
+ names that also appear in ``__all__``.
222
+ """
223
+ all_names = parse_python_all_exports(source)
224
+ is_init = path.replace("\\", "/").endswith("__init__.py")
225
+ out: Set[str] = set()
226
+ try:
227
+ tree = ast.parse(source)
228
+ except (SyntaxError, ValueError):
229
+ return out
230
+ for stmt in tree.body:
231
+ if not isinstance(stmt, ast.ImportFrom):
232
+ continue
233
+ for alias in stmt.names:
234
+ if not alias.name or alias.name == "*":
235
+ continue
236
+ local = alias.asname or alias.name
237
+ if is_init or local in all_names:
238
+ out.add(local)
239
+ return out
240
+
241
+
242
+ def iter_js_export_symbols(source: str) -> List[tuple[int, str]]:
243
+ """Yield ``(line, name)`` for JS/TS export forms (decl, list, default, re-export)."""
244
+ found: List[tuple[int, str]] = []
245
+ seen: Set[tuple[int, str]] = set()
246
+
247
+ def _add(line: int, name: str) -> None:
248
+ if not name or is_private_symbol(name):
249
+ return
250
+ key = (line, name)
251
+ if key in seen:
252
+ return
253
+ seen.add(key)
254
+ found.append((line, name))
255
+
256
+ for m in _JS_EXPORT_DECL_RE.finditer(source):
257
+ line = source[: m.start()].count("\n") + 1
258
+ _add(line, m.group(1))
259
+
260
+ for m in _JS_EXPORT_LIST_RE.finditer(source):
261
+ line = source[: m.start()].count("\n") + 1
262
+ if m.group(1):
263
+ _add(line, m.group(1))
264
+ if m.group(2):
265
+ _add(line, m.group(2))
266
+ for group in (m.group(3), m.group(4)):
267
+ if not group:
268
+ continue
269
+ for part in group.split(","):
270
+ part = part.strip()
271
+ if not part or part == "type":
272
+ continue
273
+ # `Foo as Bar` / `type Foo` / `default as X`
274
+ part = re.sub(r"^type\s+", "", part)
275
+ if " as " in part:
276
+ part = part.split(" as ")[-1].strip()
277
+ if part == "default":
278
+ continue
279
+ name = part.split(":", 1)[0].strip()
280
+ _add(line, name)
281
+ if m.group(5):
282
+ _add(line, m.group(5))
283
+ return found
284
+
285
+
286
+ def strip_py_comments(text: str) -> str:
287
+ """Blank ``#`` comments in place — preserve newlines/line count, never renumber.
288
+
289
+ Dead-symbol detection indexes tokens from cleaned text while definition spans
290
+ come from ``ast.parse`` on the raw source; dropping lines would skew them.
291
+ """
292
+ ends_nl = text.endswith("\n")
293
+ lines = []
294
+ for line in text.splitlines():
295
+ if "#" not in line:
296
+ lines.append(line)
297
+ continue
298
+ stripped = line.lstrip()
299
+ if stripped.startswith("#"):
300
+ lines.append("")
301
+ continue
302
+ in_str = False
303
+ quote = ""
304
+ buf: List[str] = []
305
+ i = 0
306
+ while i < len(line):
307
+ ch = line[i]
308
+ if in_str:
309
+ buf.append(ch)
310
+ if ch == quote and (i == 0 or line[i - 1] != "\\"):
311
+ in_str = False
312
+ i += 1
313
+ continue
314
+ if ch in ("'", '"'):
315
+ in_str = True
316
+ quote = ch
317
+ buf.append(ch)
318
+ i += 1
319
+ continue
320
+ if ch == "#":
321
+ break
322
+ buf.append(ch)
323
+ i += 1
324
+ lines.append("".join(buf))
325
+ out = "\n".join(lines)
326
+ return out + ("\n" if ends_nl and lines else "")
327
+
328
+
329
+ def _blank_js_block_comment(match: re.Match[str]) -> str:
330
+ """Replace block-comment body with spaces, keeping every newline."""
331
+ return re.sub(r"[^\n]", " ", match.group(0))
332
+
333
+
334
+ def strip_js_comments(text: str) -> str:
335
+ """Blank ``/* */`` and ``//`` comments in place — preserve newlines/line count."""
336
+ ends_nl = text.endswith("\n")
337
+ text = re.sub(r"/\*.*?\*/", _blank_js_block_comment, text, flags=re.DOTALL)
338
+ lines = []
339
+ for line in text.splitlines():
340
+ if "//" not in line:
341
+ lines.append(line)
342
+ continue
343
+ in_str = False
344
+ quote = ""
345
+ buf: List[str] = []
346
+ i = 0
347
+ while i < len(line):
348
+ ch = line[i]
349
+ if in_str:
350
+ buf.append(ch)
351
+ if ch == quote and (i == 0 or line[i - 1] != "\\"):
352
+ in_str = False
353
+ i += 1
354
+ continue
355
+ if ch in ("'", '"', "`"):
356
+ in_str = True
357
+ quote = ch
358
+ buf.append(ch)
359
+ i += 1
360
+ continue
361
+ if ch == "/" and i + 1 < len(line) and line[i + 1] == "/":
362
+ break
363
+ buf.append(ch)
364
+ i += 1
365
+ lines.append("".join(buf))
366
+ out = "\n".join(lines)
367
+ return out + ("\n" if ends_nl and lines else "")
368
+
369
+
370
+ def strip_string_literals(text: str) -> str:
371
+ """Blank string literal bodies in place — preserve newlines/line count.
372
+
373
+ Dead-symbol token scans index identifiers from cleaned text; leaving
374
+ ``\"cost_by_task\"`` dict keys (etc.) intact falsely clears real dead
375
+ symbols. Dynamic getattr/importlib strings are indexed separately by
376
+ :func:`build_dynamic_import_index` on the raw source before stripping.
377
+ """
378
+ if not text:
379
+ return text
380
+ ends_nl = text.endswith("\n")
381
+ out: List[str] = []
382
+ i = 0
383
+ n = len(text)
384
+ while i < n:
385
+ ch = text[i]
386
+ if ch in ("'", '"', "`"):
387
+ quote = ch
388
+ triple = (
389
+ quote in ("'", '"')
390
+ and i + 2 < n
391
+ and text[i + 1] == quote
392
+ and text[i + 2] == quote
393
+ )
394
+ if triple:
395
+ out.extend((quote, quote, quote))
396
+ i += 3
397
+ while i < n:
398
+ if (
399
+ text[i] == quote
400
+ and i + 2 < n
401
+ and text[i + 1] == quote
402
+ and text[i + 2] == quote
403
+ ):
404
+ out.extend((quote, quote, quote))
405
+ i += 3
406
+ break
407
+ out.append("\n" if text[i] == "\n" else " ")
408
+ i += 1
409
+ continue
410
+ out.append(quote)
411
+ i += 1
412
+ while i < n:
413
+ c = text[i]
414
+ if c == "\\" and i + 1 < n:
415
+ out.append(" ")
416
+ i += 2
417
+ continue
418
+ if c == quote:
419
+ out.append(quote)
420
+ i += 1
421
+ break
422
+ if c == "\n":
423
+ out.append("\n")
424
+ i += 1
425
+ if quote != "`":
426
+ break
427
+ continue
428
+ out.append(" ")
429
+ i += 1
430
+ continue
431
+ out.append(ch)
432
+ i += 1
433
+ result = "".join(out)
434
+ if ends_nl and not result.endswith("\n"):
435
+ result += "\n"
436
+ return result
437
+
438
+
439
+ def decorator_names(node: ast.AST) -> List[str]:
440
+ """Unparse decorator expressions on a function/class AST node."""
441
+ out: List[str] = []
442
+ for dec in getattr(node, "decorator_list", []) or []:
443
+ try:
444
+ out.append(ast.unparse(dec))
445
+ except Exception:
446
+ if isinstance(dec, ast.Name):
447
+ out.append(dec.id)
448
+ elif isinstance(dec, ast.Attribute):
449
+ out.append(dec.attr)
450
+ return out
451
+
452
+
453
+ def is_wiring_decorated(decorators: List[str]) -> bool:
454
+ """True when any decorator looks like framework registration (route/cli/fixture).
455
+
456
+ Dotted hints (``app.``, ``router.``, …) match as prefixes; bare hints
457
+ (``route``, ``task``, ``register``, …) match whole identifier segments only.
458
+ The old substring-over-joined-string check over-matched (e.g. ``multitask``,
459
+ ``preregister``) and hid real dead code behind unrelated decorators.
460
+ """
461
+ for dec in decorators:
462
+ base = dec.split("(", 1)[0].strip().lower()
463
+ if not base:
464
+ continue
465
+ segments = [s for s in re.split(r"[.\s]+", base) if s]
466
+ for hint in _WIRING_DECORATOR_HINTS:
467
+ if hint.endswith("."):
468
+ if base.startswith(hint):
469
+ return True
470
+ elif hint in segments:
471
+ return True
472
+ return False
473
+
474
+
475
+ def structural_exemptions(path: str) -> bool:
476
+ """True when ``path`` is wired by convention and should not be flagged unwired.
477
+
478
+ Shared by map candidates and verify gates. Basename-only exemptions like
479
+ ``main.py`` are intentionally NOT included — real entry points clear via
480
+ :func:`entry_roots`.
481
+ """
482
+ try:
483
+ norm = _norm(path)
484
+ name = Path(norm).name
485
+ lower = norm.lower()
486
+ parts = lower.split("/")
487
+ suffix = Path(norm).suffix.lower()
488
+
489
+ if name in {"__main__.py", "conftest.py", "manage.py"}:
490
+ return True
491
+ if name.endswith(".d.ts"):
492
+ return True
493
+ if ".stories." in name or name.endswith((".stories.ts", ".stories.tsx", ".stories.js", ".stories.jsx")):
494
+ return True
495
+ if is_test_path(norm):
496
+ return True
497
+ # Vendored JS/CSS bundles are loaded as package resources, not imported.
498
+ if is_vendored_path(norm):
499
+ return True
500
+ if any(p in _SCRIPT_DIR_NAMES for p in parts[:-1]):
501
+ return True
502
+ # Migrations / alembic version modules.
503
+ if "migrations" in parts or "alembic" in parts:
504
+ if suffix == ".py":
505
+ return True
506
+ # Next/Remix/app-router style route files.
507
+ if any(lower.startswith(hint) or f"/{hint}" in f"/{lower}" for hint in _ROUTE_DIR_HINTS):
508
+ route_names = {
509
+ "page.tsx", "page.ts", "page.jsx", "page.js",
510
+ "layout.tsx", "layout.ts", "layout.jsx", "layout.js",
511
+ "route.ts", "route.js", "route.tsx", "route.jsx",
512
+ "loading.tsx", "error.tsx", "not-found.tsx",
513
+ "middleware.ts", "middleware.js",
514
+ "+page.svelte", "+layout.svelte", "+page.ts", "+layout.ts",
515
+ "index.tsx", "index.ts", "index.jsx", "index.js",
516
+ }
517
+ if name in route_names or name.startswith("route.") or name.startswith("+"):
518
+ return True
519
+ # Cargo build scripts are invoked by rustc, not imported by app code.
520
+ if name == "build.rs":
521
+ return True
522
+ # Bundler / test-runner configs are tooling entrypoints, not product modules.
523
+ # Exempt from unwired/unreachable — do NOT seed BFS from them.
524
+ if _TOOLING_CONFIG_RE.search(norm):
525
+ return True
526
+ if ".config." in name and suffix in {
527
+ ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts",
528
+ }:
529
+ return True
530
+ return False
531
+ except Exception:
532
+ logger.debug("structural_exemptions failed for %s", path, exc_info=True)
533
+ return False
534
+
535
+
536
+ def _read_text(root: Path, rel: str) -> str:
537
+ try:
538
+ return (root / rel).read_text(encoding="utf-8", errors="replace")
539
+ except OSError:
540
+ return ""
541
+
542
+
543
+ def _pyproject_script_targets(root: Path, file_set: Set[str]) -> Set[str]:
544
+ """Resolve pyproject [project.scripts]/entry-points/gui-scripts module paths."""
545
+ text = _read_text(root, "pyproject.toml")
546
+ if not text:
547
+ return set()
548
+ found: Set[str] = set()
549
+ try:
550
+ # Prefer tomllib when available; fall back to regex for exotic envs.
551
+ try:
552
+ import tomllib
553
+ except ImportError: # pragma: no cover
554
+ import tomli as tomllib # type: ignore
555
+
556
+ data = tomllib.loads(text)
557
+ project = data.get("project") or {}
558
+ entry_maps = []
559
+ for key in ("scripts", "gui-scripts"):
560
+ val = project.get(key)
561
+ if isinstance(val, dict):
562
+ entry_maps.append(val)
563
+ eps = project.get("entry-points") or {}
564
+ if isinstance(eps, dict):
565
+ for group in eps.values():
566
+ if isinstance(group, dict):
567
+ entry_maps.append(group)
568
+ # pytest plugins often live under tool.pytest.ini_options / pytest.ini
569
+ tool = data.get("tool") or {}
570
+ pytest_cfg = tool.get("pytest") or {}
571
+ ini = pytest_cfg.get("ini_options") or {}
572
+ plugins = ini.get("pytest_plugins") or ini.get("plugins")
573
+ if isinstance(plugins, list):
574
+ for plug in plugins:
575
+ if isinstance(plug, str):
576
+ _add_module_file(plug.split(":")[0].strip(), file_set, found)
577
+ elif isinstance(plugins, str):
578
+ for plug in re.split(r"[\s,]+", plugins):
579
+ if plug:
580
+ _add_module_file(plug.split(":")[0].strip(), file_set, found)
581
+
582
+ for mapping in entry_maps:
583
+ for target in mapping.values():
584
+ if not isinstance(target, str):
585
+ continue
586
+ mod = target.split(":")[0].strip()
587
+ _add_module_file(mod, file_set, found)
588
+ except Exception:
589
+ # Regex fallback for scripts = { name = "pkg.mod:fn" }
590
+ for m in re.finditer(
591
+ r"""['"]([A-Za-z_][\w.]*)\s*:\s*[A-Za-z_]\w*['"]""",
592
+ text,
593
+ ):
594
+ _add_module_file(m.group(1), file_set, found)
595
+ return found
596
+
597
+
598
+ def _add_module_file(module: str, file_set: Set[str], out: Set[str]) -> None:
599
+ if not module or module.startswith("."):
600
+ return
601
+ parts = module.replace(".", "/")
602
+ candidates = [
603
+ f"{parts}.py",
604
+ f"{parts}/__init__.py",
605
+ f"src/{parts}.py",
606
+ f"src/{parts}/__init__.py",
607
+ ]
608
+ for cand in candidates:
609
+ if cand in file_set:
610
+ out.add(cand)
611
+ return
612
+ # Soft match: any file whose path ends with the module path (sorted for determinism).
613
+ suffix = f"/{parts}.py"
614
+ suffix_init = f"/{parts}/__init__.py"
615
+ for f in sorted(file_set):
616
+ if f.endswith(suffix) or f.endswith(suffix_init) or f == f"{parts}.py":
617
+ out.add(f)
618
+ return
619
+
620
+
621
+ # Workspace manifests scanned for entry targets (sorted; bounded for determinism
622
+ # and to keep monorepos with generated packages from ballooning map time).
623
+ _PACKAGE_MANIFEST_CAP = 200
624
+
625
+
626
+ def _package_json_entry_targets(root: Path, file_set: Set[str]) -> Set[str]:
627
+ """Entry targets from the root ``package.json`` and workspace manifests.
628
+
629
+ Monorepos often have no root ``main``/``bin``/``exports`` at all — the real
630
+ entries live in ``frontend/package.json`` etc., so every tracked manifest is
631
+ scanned (capped) with its targets resolved relative to the manifest's dir.
632
+ """
633
+ manifests = sorted(
634
+ p for p in file_set if p.rsplit("/", 1)[-1] == "package.json"
635
+ )[:_PACKAGE_MANIFEST_CAP]
636
+ if not manifests and (root / "package.json").is_file():
637
+ manifests = ["package.json"]
638
+ found: Set[str] = set()
639
+ for manifest in manifests:
640
+ _package_json_entry_targets_for(root, manifest, file_set, found)
641
+ return found
642
+
643
+
644
+ def _package_json_entry_targets_for(
645
+ root: Path, manifest: str, file_set: Set[str], found: Set[str]
646
+ ) -> None:
647
+ text = _read_text(root, manifest)
648
+ if not text:
649
+ return
650
+ try:
651
+ data = json.loads(text)
652
+ except Exception:
653
+ return
654
+ prefix = manifest[: -len("package.json")] # "" at root, "frontend/" nested
655
+
656
+ def _add(candidate: object) -> None:
657
+ if not isinstance(candidate, str):
658
+ return
659
+ rel = _norm(f"{prefix}{_norm(candidate)}")
660
+ if rel in file_set:
661
+ found.add(rel)
662
+ return
663
+ # Strip leading ./ and try common extensions.
664
+ base = rel
665
+ for ext in ("", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"):
666
+ cand = f"{base}{ext}" if ext and not base.endswith(ext) else base
667
+ if cand in file_set:
668
+ found.add(cand)
669
+ return
670
+ idx = f"{base}/index{ext}" if ext else f"{base}/index.js"
671
+ if idx in file_set:
672
+ found.add(idx)
673
+ return
674
+
675
+ for key in ("main", "module", "browser", "types", "typings"):
676
+ _add(data.get(key))
677
+ bin_val = data.get("bin")
678
+ if isinstance(bin_val, str):
679
+ _add(bin_val)
680
+ elif isinstance(bin_val, dict):
681
+ for v in bin_val.values():
682
+ _add(v)
683
+ exports = data.get("exports")
684
+ if isinstance(exports, str):
685
+ _add(exports)
686
+ elif isinstance(exports, dict):
687
+ for v in exports.values():
688
+ if isinstance(v, str):
689
+ _add(v)
690
+ elif isinstance(v, dict):
691
+ for nested in v.values():
692
+ if isinstance(nested, str):
693
+ _add(nested)
694
+
695
+
696
+ # Convention-based mains. JS/TS and Rust paths are specific enough to seed by
697
+ # name; ``main.go`` and Python service mains collide with helpers often enough
698
+ # that a bounded content sniff gates them.
699
+ _JS_MAIN_SEED_RE = re.compile(
700
+ r"(?:^|/)"
701
+ r"(?:"
702
+ r"src/(?:index|main)"
703
+ r"|App"
704
+ r"|(?:server|api|backend|worker|functions|lambda)/index"
705
+ r")"
706
+ r"\.(?:ts|tsx|js|jsx|mjs)$"
707
+ )
708
+ _RUST_MAIN_SEED_RE = re.compile(r"(?:^|/)src/(?:main\.rs|lib\.rs|bin/[^/]+\.rs)$")
709
+ _TOOLING_CONFIG_RE = re.compile(
710
+ r"(?:^|/)"
711
+ r"(?:"
712
+ r"vite\.config\.[cm]?[jt]s"
713
+ r"|vitest\.config\.[cm]?[jt]s"
714
+ r"|webpack\.config\.[cm]?[jt]s"
715
+ r"|rollup\.config\.[cm]?[jt]s"
716
+ r"|esbuild\.config\.[cm]?[jt]s"
717
+ r"|next\.config\.[cm]?[jt]s"
718
+ r"|astro\.config\.[cm]?[jt]s"
719
+ r"|nuxt\.config\.[cm]?[jt]s"
720
+ r"|playwright\.config\.[cm]?[jt]s"
721
+ r"|tailwind\.config\.[cm]?[jt]s"
722
+ r"|postcss\.config\.[cm]?[jt]s"
723
+ r"|eslint\.config\.[cm]?[jt]s"
724
+ r")"
725
+ r"$"
726
+ )
727
+ _PY_MAIN_SEED_NAMES = {"main.py", "app.py", "wsgi.py", "asgi.py"}
728
+ _C_MAIN_SEED_SUFFIXES = (".c", ".cc", ".cpp", ".cxx")
729
+ _C_MAIN_RE = re.compile(r"\b(?:int|void)\s+main\s*\(")
730
+ # "__main__" covers both quote styles of the run guard.
731
+ _PY_MAIN_SEED_MARKERS = ("__main__", "FastAPI(", "Flask(", "uvicorn.run(")
732
+ _MAIN_SEED_SNIFF_CAP = 512
733
+ _JS_RESOLVE_EXTS = (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs")
734
+ _JS_SUFFIXES = frozenset(_JS_RESOLVE_EXTS)
735
+
736
+
737
+ def _normalize_rel_path(target: str) -> str:
738
+ parts: List[str] = []
739
+ for comp in target.replace("\\", "/").split("/"):
740
+ if comp in ("", "."):
741
+ continue
742
+ if comp == "..":
743
+ if parts:
744
+ parts.pop()
745
+ continue
746
+ parts.append(comp)
747
+ return "/".join(parts)
748
+
749
+
750
+ def _probe_js_path(norm: str, file_set: Set[str]) -> Optional[str]:
751
+ """Resolve extensionless / ``.js``-suffixed specs against ``file_set``."""
752
+ if not norm:
753
+ return None
754
+ candidates = [norm]
755
+ candidates += [f"{norm}{ext}" for ext in _JS_RESOLVE_EXTS]
756
+ candidates += [f"{norm}/index{ext}" for ext in _JS_RESOLVE_EXTS]
757
+ suffix = Path(norm).suffix.lower()
758
+ if suffix in _JS_SUFFIXES:
759
+ stem = norm[: -len(suffix)]
760
+ candidates += [f"{stem}{ext}" for ext in _JS_RESOLVE_EXTS]
761
+ candidates += [f"{stem}/index{ext}" for ext in _JS_RESOLVE_EXTS]
762
+ for cand in candidates:
763
+ if cand in file_set:
764
+ return cand
765
+ return None
766
+
767
+
768
+ def _expand_roots_via_dynamic_imports(
769
+ root: Path, file_set: Set[str], seeds: Set[str]
770
+ ) -> Set[str]:
771
+ """One-BFS expansion: dynamic ``import(...)`` / worker URL targets become roots.
772
+
773
+ Covers Vite/CRA ``main.tsx → import('./App')``, alias ``import('@/x')``, and
774
+ ``new Worker(new URL('./worker.ts', import.meta.url))`` without treating every
775
+ dynamic import in the repo as a seed.
776
+ """
777
+ from devcouncil.indexing.repo_mapper import RepoMapper
778
+
779
+ mapper = RepoMapper(root)
780
+ mapper._last_file_set = file_set
781
+ expanded = set(seeds)
782
+ queue = list(seeds)
783
+ seen = set(seeds)
784
+ while queue:
785
+ cur = queue.pop()
786
+ text = _read_text(root, cur)
787
+ if not text:
788
+ continue
789
+ specs: List[str] = [match.group(1) for match in _DYNAMIC_IMPORT_RE.finditer(text)]
790
+ specs.extend(match.group(1) for match in _WORKER_URL_RE.finditer(text))
791
+ for spec in specs:
792
+ if not spec:
793
+ continue
794
+ hit: Optional[str] = None
795
+ if spec.startswith("."):
796
+ try:
797
+ joined = (Path(cur).parent / spec).as_posix()
798
+ except Exception:
799
+ continue
800
+ hit = _probe_js_path(_normalize_rel_path(joined), file_set)
801
+ else:
802
+ try:
803
+ hit = mapper._resolve_js_spec(cur, spec, file_set)
804
+ except Exception:
805
+ hit = None
806
+ if hit and hit not in seen:
807
+ seen.add(hit)
808
+ expanded.add(hit)
809
+ queue.append(hit)
810
+ return expanded
811
+
812
+
813
+ def _conventional_main_seeds(root: Path, file_set: Set[str]) -> Set[str]:
814
+ """Language-convention entry mains: Go/Rust/C/C++ binaries, Python service
815
+ mains, JS/TS ``src/index``/``src/main``/``App``/service ``index`` modules,
816
+ and Rust ``main.rs`` / ``lib.rs``.
817
+
818
+ Tooling configs (Vite/PostCSS/…) are structural exemptions only — never BFS
819
+ seeds (they do not import product modules).
820
+ """
821
+ found: Set[str] = set()
822
+ sniffed = 0
823
+ for f in sorted(file_set):
824
+ if _JS_MAIN_SEED_RE.search(f) or _RUST_MAIN_SEED_RE.search(f):
825
+ found.add(f)
826
+ continue
827
+ name = f.rsplit("/", 1)[-1]
828
+ if name == "main.go":
829
+ if sniffed >= _MAIN_SEED_SNIFF_CAP:
830
+ continue
831
+ sniffed += 1
832
+ text = _read_text(root, f)
833
+ if "package main" in text and "func main(" in text:
834
+ found.add(f)
835
+ elif name in _PY_MAIN_SEED_NAMES:
836
+ if sniffed >= _MAIN_SEED_SNIFF_CAP:
837
+ continue
838
+ sniffed += 1
839
+ text = _read_text(root, f)
840
+ if any(marker in text for marker in _PY_MAIN_SEED_MARKERS):
841
+ found.add(f)
842
+ elif name.endswith(_C_MAIN_SEED_SUFFIXES):
843
+ # C/C++ binaries: a file defining main() is a conventional entry,
844
+ # same as ``package main`` in Go.
845
+ if sniffed >= _MAIN_SEED_SNIFF_CAP:
846
+ continue
847
+ sniffed += 1
848
+ if _C_MAIN_RE.search(_read_text(root, f)):
849
+ found.add(f)
850
+ return found
851
+
852
+
853
+ def _config_declared_entry_roots(root: Path, file_set: Set[str]) -> Set[str]:
854
+ """Paths from ``indexing.entry_roots`` that exist in the tracked file set."""
855
+ declared: list = []
856
+ try:
857
+ from devcouncil.app.config import load_config
858
+
859
+ cfg = load_config(root)
860
+ raw_declared = getattr(cfg.indexing, "entry_roots", None)
861
+ if isinstance(raw_declared, list):
862
+ declared = raw_declared
863
+ except Exception:
864
+ logger.debug("config entry_roots load failed", exc_info=True)
865
+ if not declared:
866
+ try:
867
+ import yaml
868
+
869
+ cfg_path = root / ".devcouncil" / "config.yaml"
870
+ if cfg_path.is_file():
871
+ payload = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {}
872
+ indexing = payload.get("indexing")
873
+ if isinstance(indexing, dict):
874
+ yaml_roots = indexing.get("entry_roots")
875
+ if isinstance(yaml_roots, list):
876
+ declared = yaml_roots
877
+ except Exception:
878
+ logger.debug("yaml entry_roots load failed", exc_info=True)
879
+ return {_norm(str(p)) for p in declared if p and _norm(str(p)) in file_set}
880
+
881
+
882
+ def entry_roots(
883
+ root: Path,
884
+ files: Iterable[str],
885
+ *,
886
+ production_only: bool = False,
887
+ ) -> list[str]:
888
+ """Config-declared + small convention set used as BFS reachability seeds.
889
+
890
+ Seeds are pyproject targets, package.json targets (root + workspace
891
+ manifests), language-convention mains (``main.go`` binaries, Python service
892
+ mains, JS/TS ``src/index``/``src/main``/``App``/service indexes, Rust
893
+ ``src/main.rs``/``lib.rs``), relative dynamic-import expansions from those
894
+ seeds, plus ``__main__.py`` / ``manage.py``. Structural exemptions (routes,
895
+ migrations, scripts, stories, tests, tooling configs, ``build.rs``) remain a
896
+ skip-list for unwired/unreachable — they are NOT BFS seeds (that diluted
897
+ reachability and, with caps, could truncate real config entries).
898
+
899
+ When ``production_only`` is True, test-file seeds are excluded so reachability
900
+ means "reachable from production code".
901
+
902
+ Never raises. Returns a sorted list of repo-relative posix paths.
903
+ """
904
+ try:
905
+ file_set = {_norm(f) for f in files}
906
+ roots: Set[str] = set()
907
+ roots |= _config_declared_entry_roots(root, file_set)
908
+ roots |= _pyproject_script_targets(root, file_set)
909
+ roots |= _package_json_entry_targets(root, file_set)
910
+
911
+ for f in _conventional_main_seeds(root, file_set):
912
+ if production_only and is_test_path(f):
913
+ continue
914
+ roots.add(f)
915
+
916
+ for f in file_set:
917
+ if production_only and is_test_path(f):
918
+ continue
919
+ name = Path(f).name
920
+ if name in {"__main__.py", "manage.py"}:
921
+ roots.add(f)
922
+
923
+ roots = _expand_roots_via_dynamic_imports(root, file_set, roots)
924
+ if production_only:
925
+ roots = {r for r in roots if not is_test_path(r)}
926
+
927
+ return sorted(roots)
928
+ except Exception:
929
+ logger.debug("entry_roots failed", exc_info=True)
930
+ return []
931
+
932
+
933
+ def entry_point_symbols(root: Path, files: Iterable[str]) -> Set[str]:
934
+ """Return ``path::attr`` keys for pyproject ``module:attr`` script targets.
935
+
936
+ Used by graph dead-code so CLI entry functions (e.g. ``pkg.b:main``) are not
937
+ flagged merely because nothing in-repo calls them.
938
+ """
939
+ out: Set[str] = set()
940
+ try:
941
+ file_set = {_norm(f) for f in files}
942
+ text = _read_text(root, "pyproject.toml")
943
+ if not text:
944
+ return out
945
+ try:
946
+ import tomllib
947
+ except ImportError: # pragma: no cover
948
+ import tomli as tomllib # type: ignore
949
+
950
+ data = tomllib.loads(text)
951
+ project = data.get("project") or {}
952
+ entry_maps: list = []
953
+ for key in ("scripts", "gui-scripts"):
954
+ val = project.get(key)
955
+ if isinstance(val, dict):
956
+ entry_maps.append(val)
957
+ eps = project.get("entry-points") or {}
958
+ if isinstance(eps, dict):
959
+ for group in eps.values():
960
+ if isinstance(group, dict):
961
+ entry_maps.append(group)
962
+ for mapping in entry_maps:
963
+ for target in mapping.values():
964
+ if not isinstance(target, str) or ":" not in target:
965
+ continue
966
+ mod, _, attr = target.partition(":")
967
+ mod, attr = mod.strip(), attr.strip()
968
+ if not mod or not attr:
969
+ continue
970
+ found: Set[str] = set()
971
+ _add_module_file(mod, file_set, found)
972
+ for path in found:
973
+ out.add(f"{path}::{attr}")
974
+ except Exception:
975
+ logger.debug("entry_point_symbols failed", exc_info=True)
976
+ return out
977
+
978
+
979
+ _SHORT_STEM_MAX = 12
980
+
981
+
982
+ def module_tokens_for(path: str) -> Set[str]:
983
+ """Tokens that could appear in an importlib/dynamic string for ``path``.
984
+
985
+ Omits bare short stems (``config``, ``utils``) that over-match via suffix
986
+ checks against unrelated modules like ``other.config``.
987
+ """
988
+ norm = _norm(path)
989
+ stem = Path(norm).stem
990
+ no_ext = norm.rsplit(".", 1)[0] if "." in Path(norm).name else norm
991
+ dotted = no_ext.replace("/", ".")
992
+ if dotted.startswith("src."):
993
+ dotted = dotted[4:]
994
+ tokens = {no_ext, dotted, norm}
995
+ if Path(norm).name == "__init__.py":
996
+ pkg = Path(norm).parent.as_posix().replace("/", ".")
997
+ if pkg.startswith("src."):
998
+ pkg = pkg[4:]
999
+ tokens.add(pkg)
1000
+ tokens.add(Path(norm).parent.as_posix())
1001
+ # Bare stem only when long enough to be specific, or path is top-level.
1002
+ if "/" not in no_ext and len(stem) >= _SHORT_STEM_MAX:
1003
+ tokens.add(stem)
1004
+ elif "/" in no_ext and len(stem) >= _SHORT_STEM_MAX:
1005
+ # Still skip short stems; path/dotted forms above are enough.
1006
+ pass
1007
+ # Bundled non-Python assets are often referenced by basename only.
1008
+ name = Path(norm).name
1009
+ if Path(norm).suffix.lower() in {
1010
+ ".mjs",
1011
+ ".cjs",
1012
+ ".js",
1013
+ ".css",
1014
+ ".svg",
1015
+ ".png",
1016
+ ".jpg",
1017
+ ".jpeg",
1018
+ ".webp",
1019
+ ".html",
1020
+ }:
1021
+ tokens.add(name)
1022
+ return {t for t in tokens if t}
1023
+
1024
+
1025
+ def _module_forms(value: str) -> Set[str]:
1026
+ """Comparable dotted + slash forms (extensions stripped) for boundary matching."""
1027
+ v = _norm(value)
1028
+ forms = {v, v.replace("/", "."), v.replace(".", "/")}
1029
+ for ext in (".py", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"):
1030
+ if v.endswith(ext):
1031
+ base = v[: -len(ext)]
1032
+ forms.add(base)
1033
+ forms.add(base.replace("/", "."))
1034
+ forms.add(base.replace(".", "/"))
1035
+ break
1036
+ return {f for f in forms if f}
1037
+
1038
+
1039
+ def import_spec_matches(spec: str, tokens: Set[str]) -> bool:
1040
+ """True when an import string matches ``tokens`` on a module/path boundary."""
1041
+ if not spec or not tokens:
1042
+ return False
1043
+ spec_forms = _module_forms(spec)
1044
+ for t in tokens:
1045
+ if spec_forms & _module_forms(t):
1046
+ return True
1047
+ return False
1048
+
1049
+
1050
+ def has_allow_unwired(project_root: Path, path: str) -> bool:
1051
+ """True when ``path`` contains the ``devcouncil: allow-unwired`` marker."""
1052
+ try:
1053
+ text = (project_root / path).read_text(encoding="utf-8", errors="replace")
1054
+ except OSError:
1055
+ return False
1056
+ return ALLOW_UNWIRED in text
1057
+
1058
+
1059
+ def dynamic_import_keys(path: str, source: str) -> Set[str]:
1060
+ """Return normalized dynamic-import and ``getattr`` keys for one file."""
1061
+ norm = _norm(path)
1062
+ suffix = Path(norm).suffix.lower()
1063
+ if suffix not in _CODE_CONFIG_SUFFIXES:
1064
+ return set()
1065
+ specs: List[str] = [match.group(1) for match in _IMPORTLIB_RE.finditer(source)]
1066
+ for match in _DYNAMIC_IMPORT_RE.finditer(source):
1067
+ spec = match.group(1)
1068
+ if not spec:
1069
+ continue
1070
+ if spec.startswith("."):
1071
+ # Relative dynamic import — resolve against this file so
1072
+ # ``import('./App')`` clears ``App.tsx`` via reference_cleared.
1073
+ try:
1074
+ joined = (Path(norm).parent / spec).as_posix()
1075
+ except Exception:
1076
+ continue
1077
+ resolved = _normalize_rel_path(joined)
1078
+ hit_stem = resolved
1079
+ for ext in _JS_RESOLVE_EXTS:
1080
+ if resolved.endswith(ext):
1081
+ hit_stem = resolved[: -len(ext)]
1082
+ break
1083
+ specs.append(resolved)
1084
+ specs.append(hit_stem)
1085
+ else:
1086
+ specs.append(spec)
1087
+ for match in _WORKER_URL_RE.finditer(source):
1088
+ spec = match.group(1)
1089
+ if not spec:
1090
+ continue
1091
+ if spec.startswith("."):
1092
+ try:
1093
+ joined = (Path(norm).parent / spec).as_posix()
1094
+ except Exception:
1095
+ continue
1096
+ resolved = _normalize_rel_path(joined)
1097
+ hit_stem = resolved
1098
+ for ext in _JS_RESOLVE_EXTS:
1099
+ if resolved.endswith(ext):
1100
+ hit_stem = resolved[: -len(ext)]
1101
+ break
1102
+ specs.append(resolved)
1103
+ specs.append(hit_stem)
1104
+ else:
1105
+ specs.append(spec)
1106
+ for match in _PYTHON_DASH_M_RE.finditer(source):
1107
+ spec = match.group(1) or match.group(2)
1108
+ if spec:
1109
+ specs.append(spec)
1110
+ specs.extend(_PACKAGE_RESOURCES_RE.findall(source))
1111
+ specs.extend(_BUNDLED_ASSET_RE.findall(source))
1112
+ if suffix == ".toml":
1113
+ specs.extend(
1114
+ (Path(norm).parent / match.group(1)).with_suffix("").as_posix()
1115
+ for match in _HATCH_CUSTOM_HOOK_RE.finditer(source)
1116
+ )
1117
+ keys = {form for spec in specs for form in _module_forms(spec)}
1118
+ keys.update(
1119
+ f"{GETATTR_INDEX_PREFIX}{match.group(1)}"
1120
+ for match in _GETATTR_NAME_RE.finditer(source)
1121
+ if match.group(1)
1122
+ )
1123
+ return keys
1124
+
1125
+
1126
+ def build_dynamic_import_index(
1127
+ project_root: Path,
1128
+ git_files: Optional[List[str]] = None,
1129
+ ) -> dict[str, Set[str]]:
1130
+ """One shared scan: normalized module form → non-test files that reference it.
1131
+
1132
+ Call once per liveness/verify pass; O(repo files) instead of O(candidates × files).
1133
+ """
1134
+ index: dict[str, Set[str]] = {}
1135
+ try:
1136
+ if git_files is None:
1137
+ from devcouncil.indexing.repo_mapper import RepoMapper
1138
+
1139
+ try:
1140
+ candidates = RepoMapper(project_root).get_git_files()
1141
+ except Exception:
1142
+ candidates = []
1143
+ else:
1144
+ candidates = list(git_files)
1145
+
1146
+ for rel in candidates:
1147
+ norm = _norm(rel)
1148
+ if is_test_path(norm):
1149
+ continue
1150
+ path = project_root / norm
1151
+ if not path.is_file():
1152
+ continue
1153
+ if path.suffix.lower() not in _CODE_CONFIG_SUFFIXES:
1154
+ continue
1155
+ try:
1156
+ text = path.read_text(encoding="utf-8", errors="replace")
1157
+ except OSError:
1158
+ continue
1159
+ for key in dynamic_import_keys(norm, text):
1160
+ index.setdefault(key, set()).add(norm)
1161
+ except Exception:
1162
+ logger.debug("build_dynamic_import_index failed", exc_info=True)
1163
+ return index
1164
+
1165
+
1166
+ def reference_cleared(
1167
+ project_root: Path,
1168
+ target: str,
1169
+ *,
1170
+ skip_files: Optional[Set[str]] = None,
1171
+ git_files: Optional[List[str]] = None,
1172
+ dynamic_index: Optional[dict[str, Set[str]]] = None,
1173
+ ) -> bool:
1174
+ """True when a non-test file holds an import-shaped string reference to ``target``.
1175
+
1176
+ Prefer a prebuilt ``dynamic_index`` (from :func:`build_dynamic_import_index`) so
1177
+ a liveness pass pays one repo scan. Falls back to a targeted scan when omitted.
1178
+
1179
+ Scans only non-test code/config files so a dynamic import in a test does not
1180
+ clear unwired (parity with the static-import rule).
1181
+ """
1182
+ tokens = module_tokens_for(target)
1183
+ if not tokens:
1184
+ return False
1185
+ skip = {_norm(p) for p in (skip_files or set())}
1186
+ target_n = _norm(target)
1187
+ token_forms: Set[str] = set()
1188
+ for t in tokens:
1189
+ token_forms |= _module_forms(t)
1190
+
1191
+ try:
1192
+ if dynamic_index is not None:
1193
+ for form in token_forms:
1194
+ for ref in dynamic_index.get(form, ()):
1195
+ if ref in skip or ref == target_n or is_test_path(ref):
1196
+ continue
1197
+ return True
1198
+ return False
1199
+
1200
+ if git_files is None:
1201
+ from devcouncil.indexing.repo_mapper import RepoMapper
1202
+
1203
+ try:
1204
+ candidates = RepoMapper(project_root).get_git_files()
1205
+ except Exception:
1206
+ candidates = []
1207
+ else:
1208
+ candidates = list(git_files)
1209
+ for rel in candidates:
1210
+ norm = _norm(rel)
1211
+ if norm in skip or norm == target_n:
1212
+ continue
1213
+ if is_test_path(norm):
1214
+ continue
1215
+ path = project_root / norm
1216
+ if not path.is_file():
1217
+ continue
1218
+ if path.suffix.lower() not in _CODE_CONFIG_SUFFIXES:
1219
+ continue
1220
+ try:
1221
+ text = path.read_text(encoding="utf-8", errors="replace")
1222
+ except OSError:
1223
+ continue
1224
+ for m in _IMPORTLIB_RE.finditer(text):
1225
+ if import_spec_matches(m.group(1), tokens):
1226
+ return True
1227
+ for m in _DYNAMIC_IMPORT_RE.finditer(text):
1228
+ spec = m.group(1)
1229
+ if not spec:
1230
+ continue
1231
+ if spec.startswith("."):
1232
+ try:
1233
+ joined = (Path(norm).parent / spec).as_posix()
1234
+ except Exception:
1235
+ continue
1236
+ resolved = _normalize_rel_path(joined)
1237
+ if import_spec_matches(resolved, tokens):
1238
+ return True
1239
+ # Extensionless stem match (./App → App.tsx tokens)
1240
+ stem = resolved
1241
+ for ext in _JS_RESOLVE_EXTS:
1242
+ if resolved.endswith(ext):
1243
+ stem = resolved[: -len(ext)]
1244
+ break
1245
+ if import_spec_matches(stem, tokens):
1246
+ return True
1247
+ continue
1248
+ if import_spec_matches(spec, tokens):
1249
+ return True
1250
+ for m in _PYTHON_DASH_M_RE.finditer(text):
1251
+ spec = m.group(1) or m.group(2)
1252
+ if spec and import_spec_matches(spec, tokens):
1253
+ return True
1254
+ for spec in _PACKAGE_RESOURCES_RE.findall(text):
1255
+ if import_spec_matches(spec, tokens):
1256
+ return True
1257
+ for spec in _BUNDLED_ASSET_RE.findall(text):
1258
+ if import_spec_matches(spec, tokens):
1259
+ return True
1260
+ except Exception:
1261
+ logger.debug("reference scan failed for %s", target, exc_info=True)
1262
+ return False
1263
+
1264
+
1265
+ # ---------------------------------------------------------------------------
1266
+ # Advisory corpus index (docs / PDF / image side graph)
1267
+ # ---------------------------------------------------------------------------
1268
+ # Separate from the deterministic code graph — never wired into verify gates.
1269
+ # Artifacts: ``.devcouncil/corpus/graph.json`` (+ optional ``graph.html``).
1270
+
1271
+ CorpusNodeKind = Literal[
1272
+ "document",
1273
+ "section",
1274
+ "concept",
1275
+ "link",
1276
+ "code_ref",
1277
+ "pdf",
1278
+ "pdf_page",
1279
+ "image",
1280
+ ]
1281
+ CorpusEdgeKind = Literal[
1282
+ "contains",
1283
+ "links_to",
1284
+ "references",
1285
+ "cites",
1286
+ "parent_of",
1287
+ "mentions",
1288
+ ]
1289
+
1290
+ _CORPUS_TEXT_EXTS = frozenset({".md", ".markdown", ".txt", ".rst"})
1291
+ _CORPUS_PDF_EXTS = frozenset({".pdf"})
1292
+ _CORPUS_IMAGE_EXTS = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"})
1293
+ _MD_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$", re.MULTILINE)
1294
+ _MD_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
1295
+ _WIKILINK_RE = re.compile(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]")
1296
+ _CODE_PATH_RE = re.compile(
1297
+ r"`([^`]+)`|(?:^|\s)((?:src|docs|tests)/[\w./-]+\.(?:py|ts|tsx|js|md|rst|yaml|yml))(?:\s|$)"
1298
+ )
1299
+ _RST_HEADING_RE = re.compile(
1300
+ r"^(?P<title>.+)\n(?P<uline>[=\-`:~^_*+#]+)\s*$",
1301
+ re.MULTILINE,
1302
+ )
1303
+
1304
+
1305
+ class CorpusNode(BaseModel):
1306
+ id: str
1307
+ kind: CorpusNodeKind
1308
+ label: str
1309
+ path: Optional[str] = None
1310
+ content: Optional[str] = None
1311
+ metadata: Dict[str, Any] = Field(default_factory=dict)
1312
+
1313
+
1314
+ class CorpusEdge(BaseModel):
1315
+ id: str
1316
+ source: str
1317
+ target: str
1318
+ kind: CorpusEdgeKind
1319
+ metadata: Dict[str, Any] = Field(default_factory=dict)
1320
+
1321
+
1322
+ class CorpusGraph(BaseModel):
1323
+ version: int = 1
1324
+ advisory: bool = True
1325
+ built_at: str = Field(default_factory=lambda: datetime.now(UTC).isoformat())
1326
+ source_roots: List[str] = Field(default_factory=list)
1327
+ nodes: List[CorpusNode] = Field(default_factory=list)
1328
+ edges: List[CorpusEdge] = Field(default_factory=list)
1329
+
1330
+
1331
+ class CorpusSettings(BaseModel):
1332
+ enabled: bool = True
1333
+ paths: List[str] = Field(default_factory=lambda: ["docs", "README.md"])
1334
+ llm_enrichment: bool = False
1335
+ vision_captions: bool = False
1336
+ write_html: bool = False
1337
+ auto_refresh_on_verify: bool = True
1338
+ extensions: List[str] = Field(
1339
+ default_factory=lambda: sorted(
1340
+ _CORPUS_TEXT_EXTS | _CORPUS_PDF_EXTS | _CORPUS_IMAGE_EXTS
1341
+ )
1342
+ )
1343
+
1344
+
1345
+ def corpus_dir(project_root: Path) -> Path:
1346
+ return project_root / ".devcouncil" / "corpus"
1347
+
1348
+
1349
+ def corpus_graph_path(project_root: Path) -> Path:
1350
+ return corpus_dir(project_root) / "graph.json"
1351
+
1352
+
1353
+ def corpus_html_path(project_root: Path) -> Path:
1354
+ return corpus_dir(project_root) / "graph.html"
1355
+
1356
+
1357
+ def load_corpus_settings(project_root: Path) -> CorpusSettings:
1358
+ merged: dict = {}
1359
+ try:
1360
+ from devcouncil.app.config import load_config
1361
+
1362
+ merged.update(load_config(project_root).indexing.corpus.model_dump())
1363
+ except FileNotFoundError:
1364
+ pass
1365
+ return CorpusSettings.model_validate(merged)
1366
+
1367
+
1368
+ def load_corpus_graph(project_root: Path) -> Optional[CorpusGraph]:
1369
+ path = corpus_graph_path(project_root)
1370
+ if not path.is_file():
1371
+ return None
1372
+ return read_model_json(path, CorpusGraph)
1373
+
1374
+
1375
+ def write_corpus_graph(project_root: Path, graph: CorpusGraph) -> Path:
1376
+ out = corpus_graph_path(project_root)
1377
+ out.parent.mkdir(parents=True, exist_ok=True)
1378
+ write_model_json(out, graph)
1379
+ return out
1380
+
1381
+
1382
+ def _slug_id(prefix: str, label: str) -> str:
1383
+ slug = re.sub(r"[^a-z0-9]+", "-", label.lower()).strip("-") or "node"
1384
+ return f"{prefix}:{slug}"
1385
+
1386
+
1387
+ def _edge_id(source: str, target: str, kind: str) -> str:
1388
+ return f"{source}->{kind}->{target}"
1389
+
1390
+
1391
+ def _iter_corpus_files(
1392
+ project_root: Path,
1393
+ roots: List[str],
1394
+ extensions: List[str],
1395
+ ) -> Iterator[Path]:
1396
+ ext_set = {e.lower() if e.startswith(".") else f".{e.lower()}" for e in extensions}
1397
+ root = project_root.resolve()
1398
+ for rel in roots:
1399
+ target = (root / rel).resolve()
1400
+ if not str(target).startswith(str(root)):
1401
+ continue
1402
+ if target.is_file():
1403
+ if target.suffix.lower() in ext_set:
1404
+ yield target
1405
+ continue
1406
+ if not target.is_dir():
1407
+ continue
1408
+ for dirpath, dirnames, filenames in target.walk(on_error=lambda _: None):
1409
+ dirnames[:] = [n for n in dirnames if n not in IGNORED_DIR_NAMES]
1410
+ for name in filenames:
1411
+ file_path = dirpath / name
1412
+ if file_path.suffix.lower() not in ext_set:
1413
+ continue
1414
+ rel_path = file_path.relative_to(root)
1415
+ if should_skip_path(rel_path):
1416
+ continue
1417
+ yield file_path
1418
+
1419
+
1420
+ def _extract_text_doc(
1421
+ rel: str,
1422
+ text: str,
1423
+ *,
1424
+ suffix: str,
1425
+ ) -> Tuple[List[CorpusNode], List[CorpusEdge]]:
1426
+ doc_id = f"doc:{rel}"
1427
+ nodes: List[CorpusNode] = [
1428
+ CorpusNode(id=doc_id, kind="document", label=rel, path=rel, content=text[:8000])
1429
+ ]
1430
+ edges: List[CorpusEdge] = []
1431
+ parent_stack: List[Tuple[int, str]] = [(0, doc_id)]
1432
+
1433
+ if suffix in _CORPUS_TEXT_EXTS and suffix != ".rst":
1434
+ for match in _MD_HEADING_RE.finditer(text):
1435
+ level = len(match.group(1))
1436
+ title = match.group(2).strip()
1437
+ sec_id = f"section:{rel}:{level}:{title[:48]}"
1438
+ nodes.append(
1439
+ CorpusNode(
1440
+ id=sec_id,
1441
+ kind="section",
1442
+ label=title,
1443
+ path=rel,
1444
+ metadata={"level": level},
1445
+ )
1446
+ )
1447
+ while parent_stack and parent_stack[-1][0] >= level:
1448
+ parent_stack.pop()
1449
+ parent_id = parent_stack[-1][1] if parent_stack else doc_id
1450
+ edges.append(
1451
+ CorpusEdge(
1452
+ id=_edge_id(parent_id, sec_id, "contains"),
1453
+ source=parent_id,
1454
+ target=sec_id,
1455
+ kind="contains",
1456
+ )
1457
+ )
1458
+ parent_stack.append((level, sec_id))
1459
+
1460
+ for match in _MD_LINK_RE.finditer(text):
1461
+ label, href = match.group(1).strip(), match.group(2).strip()
1462
+ link_id = _slug_id(f"link:{rel}", label)
1463
+ nodes.append(
1464
+ CorpusNode(
1465
+ id=link_id,
1466
+ kind="link",
1467
+ label=label,
1468
+ path=rel,
1469
+ metadata={"href": href},
1470
+ )
1471
+ )
1472
+ edges.append(
1473
+ CorpusEdge(
1474
+ id=_edge_id(doc_id, link_id, "mentions"),
1475
+ source=doc_id,
1476
+ target=link_id,
1477
+ kind="mentions",
1478
+ )
1479
+ )
1480
+ if href and not href.startswith(("http://", "https://", "#", "mailto:")):
1481
+ target = href.split("#", 1)[0].lstrip("./")
1482
+ edges.append(
1483
+ CorpusEdge(
1484
+ id=_edge_id(link_id, f"doc:{target}", "links_to"),
1485
+ source=link_id,
1486
+ target=f"doc:{target}",
1487
+ kind="links_to",
1488
+ )
1489
+ )
1490
+
1491
+ for match in _WIKILINK_RE.finditer(text):
1492
+ target = match.group(1).strip()
1493
+ label = (match.group(2) or target).strip()
1494
+ link_id = _slug_id(f"wiki:{rel}", target)
1495
+ nodes.append(
1496
+ CorpusNode(id=link_id, kind="link", label=label, path=rel, metadata={"wiki": target})
1497
+ )
1498
+ edges.append(
1499
+ CorpusEdge(
1500
+ id=_edge_id(doc_id, link_id, "mentions"),
1501
+ source=doc_id,
1502
+ target=link_id,
1503
+ kind="mentions",
1504
+ )
1505
+ )
1506
+
1507
+ if suffix == ".rst":
1508
+ for match in _RST_HEADING_RE.finditer(text):
1509
+ title = match.group("title").strip()
1510
+ sec_id = f"section:{rel}:rst:{title[:48]}"
1511
+ nodes.append(CorpusNode(id=sec_id, kind="section", label=title, path=rel))
1512
+ edges.append(
1513
+ CorpusEdge(
1514
+ id=_edge_id(doc_id, sec_id, "contains"),
1515
+ source=doc_id,
1516
+ target=sec_id,
1517
+ kind="contains",
1518
+ )
1519
+ )
1520
+
1521
+ for match in _CODE_PATH_RE.finditer(text):
1522
+ code_path = (match.group(1) or match.group(2) or "").strip()
1523
+ if not code_path or "/" not in code_path:
1524
+ continue
1525
+ ref_id = f"code:{code_path}"
1526
+ nodes.append(
1527
+ CorpusNode(id=ref_id, kind="code_ref", label=code_path, path=rel, metadata={"ref": code_path})
1528
+ )
1529
+ edges.append(
1530
+ CorpusEdge(
1531
+ id=_edge_id(doc_id, ref_id, "references"),
1532
+ source=doc_id,
1533
+ target=ref_id,
1534
+ kind="references",
1535
+ )
1536
+ )
1537
+
1538
+ return nodes, edges
1539
+
1540
+
1541
+ def _extract_pdf(rel: str, file_path: Path) -> Tuple[List[CorpusNode], List[CorpusEdge]]:
1542
+ doc_id = f"pdf:{rel}"
1543
+ nodes: List[CorpusNode] = [
1544
+ CorpusNode(id=doc_id, kind="pdf", label=rel, path=rel, metadata={"pages": 0})
1545
+ ]
1546
+ edges: List[CorpusEdge] = []
1547
+ try:
1548
+ from pypdf import PdfReader
1549
+ except ImportError:
1550
+ logger.debug("pypdf not installed; PDF %s indexed as metadata-only", rel)
1551
+ return nodes, edges
1552
+
1553
+ try:
1554
+ reader = PdfReader(str(file_path))
1555
+ nodes[0].metadata["pages"] = len(reader.pages)
1556
+ for idx, page in enumerate(reader.pages[:200]):
1557
+ try:
1558
+ page_text = page.extract_text() or ""
1559
+ except Exception:
1560
+ page_text = ""
1561
+ page_id = f"pdf-page:{rel}:{idx + 1}"
1562
+ nodes.append(
1563
+ CorpusNode(
1564
+ id=page_id,
1565
+ kind="pdf_page",
1566
+ label=f"{rel} p.{idx + 1}",
1567
+ path=rel,
1568
+ content=page_text[:4000],
1569
+ metadata={"page": idx + 1},
1570
+ )
1571
+ )
1572
+ edges.append(
1573
+ CorpusEdge(
1574
+ id=_edge_id(doc_id, page_id, "contains"),
1575
+ source=doc_id,
1576
+ target=page_id,
1577
+ kind="contains",
1578
+ )
1579
+ )
1580
+ for match in _MD_LINK_RE.finditer(page_text):
1581
+ href = match.group(2).strip()
1582
+ if href.startswith(("http://", "https://")):
1583
+ cite_id = _slug_id(f"cite:{rel}:{idx}", href)
1584
+ nodes.append(
1585
+ CorpusNode(
1586
+ id=cite_id,
1587
+ kind="link",
1588
+ label=match.group(1).strip(),
1589
+ path=rel,
1590
+ metadata={"href": href},
1591
+ )
1592
+ )
1593
+ edges.append(
1594
+ CorpusEdge(
1595
+ id=_edge_id(page_id, cite_id, "cites"),
1596
+ source=page_id,
1597
+ target=cite_id,
1598
+ kind="cites",
1599
+ )
1600
+ )
1601
+ except Exception:
1602
+ logger.debug("PDF extract failed for %s", rel, exc_info=True)
1603
+ return nodes, edges
1604
+
1605
+
1606
+ def _extract_image(
1607
+ rel: str,
1608
+ file_path: Path,
1609
+ *,
1610
+ vision_captions: bool,
1611
+ project_root: Path,
1612
+ ) -> Tuple[List[CorpusNode], List[CorpusEdge]]:
1613
+ img_id = f"image:{rel}"
1614
+ stat = file_path.stat()
1615
+ meta: Dict[str, Any] = {
1616
+ "size_bytes": stat.st_size,
1617
+ "suffix": file_path.suffix.lower(),
1618
+ }
1619
+ caption: Optional[str] = None
1620
+ if vision_captions:
1621
+ try:
1622
+ from devcouncil.app.config import load_config
1623
+
1624
+ cfg = load_config(project_root)
1625
+ if cfg.models.roles:
1626
+ caption = _optional_vision_caption(project_root, file_path)
1627
+ except Exception:
1628
+ logger.debug("vision caption skipped for %s", rel, exc_info=True)
1629
+ if caption:
1630
+ meta["caption"] = caption
1631
+ return (
1632
+ [CorpusNode(id=img_id, kind="image", label=rel, path=rel, content=caption, metadata=meta)],
1633
+ [],
1634
+ )
1635
+
1636
+
1637
+ def _optional_vision_caption(project_root: Path, file_path: Path) -> Optional[str]:
1638
+ """Best-effort caption when a vision-capable model is configured (opt-in)."""
1639
+ # ModelRouter does not yet expose a standardized multimodal request API.
1640
+ # Keep the opt-in deterministic and advisory until that contract exists.
1641
+ return None
1642
+
1643
+
1644
+ def _optional_llm_enrich(project_root: Path, graph: CorpusGraph) -> CorpusGraph:
1645
+ settings = load_corpus_settings(project_root)
1646
+ if not settings.llm_enrichment:
1647
+ return graph
1648
+ try:
1649
+ from devcouncil.app.config import load_config
1650
+
1651
+ cfg = load_config(project_root)
1652
+ if not cfg.models.roles:
1653
+ return graph
1654
+ except Exception:
1655
+ return graph
1656
+ # Placeholder: deterministic graph is authoritative; LLM enrichment is optional.
1657
+ return graph
1658
+
1659
+
1660
+ def build_corpus(
1661
+ project_root: Path,
1662
+ *,
1663
+ path: Optional[str] = None,
1664
+ ) -> CorpusGraph:
1665
+ """Build the advisory corpus graph under ``.devcouncil/corpus/``."""
1666
+ settings = load_corpus_settings(project_root)
1667
+ roots = [path] if path else list(settings.paths)
1668
+ nodes: List[CorpusNode] = []
1669
+ edges: List[CorpusEdge] = []
1670
+ seen_nodes: set[str] = set()
1671
+ seen_edges: set[str] = set()
1672
+
1673
+ for file_path in _iter_corpus_files(project_root, roots, settings.extensions):
1674
+ rel = file_path.relative_to(project_root.resolve()).as_posix()
1675
+ suffix = file_path.suffix.lower()
1676
+ if suffix in _CORPUS_TEXT_EXTS:
1677
+ try:
1678
+ text = file_path.read_text(encoding="utf-8", errors="replace")
1679
+ except OSError:
1680
+ continue
1681
+ n, e = _extract_text_doc(rel, text, suffix=suffix)
1682
+ elif suffix in _CORPUS_PDF_EXTS:
1683
+ n, e = _extract_pdf(rel, file_path)
1684
+ elif suffix in _CORPUS_IMAGE_EXTS:
1685
+ n, e = _extract_image(
1686
+ rel,
1687
+ file_path,
1688
+ vision_captions=settings.vision_captions,
1689
+ project_root=project_root,
1690
+ )
1691
+ else:
1692
+ continue
1693
+ for node in n:
1694
+ if node.id not in seen_nodes:
1695
+ seen_nodes.add(node.id)
1696
+ nodes.append(node)
1697
+ for edge in e:
1698
+ if edge.id not in seen_edges:
1699
+ seen_edges.add(edge.id)
1700
+ edges.append(edge)
1701
+
1702
+ graph = CorpusGraph(source_roots=roots, nodes=nodes, edges=edges)
1703
+ graph = _optional_llm_enrich(project_root, graph)
1704
+ write_corpus_graph(project_root, graph)
1705
+ if settings.write_html:
1706
+ write_corpus_html(project_root, graph)
1707
+ return graph
1708
+
1709
+
1710
+ def write_corpus_html(project_root: Path, graph: CorpusGraph) -> Path:
1711
+ """Self-contained advisory corpus listing (not the code-graph visualizer)."""
1712
+ rows = []
1713
+ for node in sorted(graph.nodes, key=lambda n: (n.kind, n.label)):
1714
+ rows.append(
1715
+ f"<tr><td>{node.kind}</td><td>{node.label}</td><td>{node.path or ''}</td></tr>"
1716
+ )
1717
+ html = (
1718
+ "<!DOCTYPE html><html><head><meta charset='utf-8'>"
1719
+ "<title>DevCouncil Corpus Index</title>"
1720
+ "<style>body{font-family:system-ui;margin:1.5rem}"
1721
+ "table{border-collapse:collapse;width:100%}td,th{border:1px solid #ccc;padding:.4rem}"
1722
+ "</style></head><body>"
1723
+ "<h1>DevCouncil Corpus Index (advisory)</h1>"
1724
+ f"<p>Built {graph.built_at} — {len(graph.nodes)} nodes, {len(graph.edges)} edges</p>"
1725
+ "<table><thead><tr><th>Kind</th><th>Label</th><th>Path</th></tr></thead><tbody>"
1726
+ + "".join(rows)
1727
+ + "</tbody></table></body></html>"
1728
+ )
1729
+ out = corpus_html_path(project_root)
1730
+ out.parent.mkdir(parents=True, exist_ok=True)
1731
+ out.write_text(html, encoding="utf-8")
1732
+ return out
1733
+
1734
+
1735
+ def query_corpus(project_root: Path, query: str, *, limit: int = 20) -> Dict[str, Any]:
1736
+ graph = load_corpus_graph(project_root)
1737
+ if graph is None:
1738
+ return {"error": "No corpus graph; run `dev corpus build` first.", "matches": []}
1739
+ needle = query.strip().lower()
1740
+ if not needle:
1741
+ return {"error": "Empty query.", "matches": []}
1742
+ scored: List[Tuple[int, CorpusNode]] = []
1743
+ for node in graph.nodes:
1744
+ hay = " ".join(
1745
+ filter(None, [node.label, node.content or "", str(node.metadata)])
1746
+ ).lower()
1747
+ if needle in hay:
1748
+ scored.append((hay.count(needle), node))
1749
+ scored.sort(key=lambda item: (-item[0], item[1].label))
1750
+ matches = [
1751
+ {
1752
+ "id": node.id,
1753
+ "kind": node.kind,
1754
+ "label": node.label,
1755
+ "path": node.path,
1756
+ "score": score,
1757
+ }
1758
+ for score, node in scored[:limit]
1759
+ ]
1760
+ return {"query": query, "matches": matches, "count": len(matches)}
1761
+
1762
+
1763
+ def corpus_status(project_root: Path) -> Dict[str, Any]:
1764
+ settings = load_corpus_settings(project_root)
1765
+ path = corpus_graph_path(project_root)
1766
+ graph = load_corpus_graph(project_root)
1767
+ return {
1768
+ "enabled": settings.enabled,
1769
+ "graph_path": str(path.relative_to(project_root.resolve())) if path.is_file() else None,
1770
+ "built_at": graph.built_at if graph else None,
1771
+ "node_count": len(graph.nodes) if graph else 0,
1772
+ "edge_count": len(graph.edges) if graph else 0,
1773
+ "source_roots": graph.source_roots if graph else settings.paths,
1774
+ "advisory": True,
1775
+ "verify_gates": False,
1776
+ }