devcouncil 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (365) hide show
  1. package/README.md +46 -30
  2. package/package.json +6 -2
  3. package/packages/codeintel-grammars/hatch_build.py +43 -0
  4. package/packages/codeintel-grammars/pyproject.toml +16 -0
  5. package/packages/codeintel-grammars/src/devcouncil_codeintel_grammars/__init__.py +93 -0
  6. package/pyproject.toml +99 -4
  7. package/src/devcouncil/app/config.py +512 -20
  8. package/src/devcouncil/app/events.py +4 -23
  9. package/src/devcouncil/app/orchestrator.py +5 -0
  10. package/src/devcouncil/app/run_context.py +3 -3
  11. package/src/devcouncil/assets/__init__.py +4 -1
  12. package/src/devcouncil/assets/vendor/force-graph.min.js +5 -0
  13. package/src/devcouncil/campaign/__init__.py +71 -0
  14. package/src/devcouncil/campaign/bloom.py +137 -0
  15. package/src/devcouncil/campaign/dashboard.py +123 -0
  16. package/src/devcouncil/campaign/mailbox.py +305 -0
  17. package/src/devcouncil/campaign/notify.py +91 -0
  18. package/src/devcouncil/campaign/orchestrator.py +592 -0
  19. package/src/devcouncil/campaign/prompts/coordinator.md +29 -0
  20. package/src/devcouncil/campaign/prompts/director.md +21 -0
  21. package/src/devcouncil/campaign/prompts/protocol.md +46 -0
  22. package/src/devcouncil/campaign/prompts/reviewer.md +24 -0
  23. package/src/devcouncil/campaign/prompts/worker.md +24 -0
  24. package/src/devcouncil/campaign/roles.py +202 -0
  25. package/src/devcouncil/campaign/watcher.py +153 -0
  26. package/src/devcouncil/cli/commands/agents.py +24 -17
  27. package/src/devcouncil/cli/commands/artifacts.py +36 -27
  28. package/src/devcouncil/cli/commands/ast.py +12 -3
  29. package/src/devcouncil/cli/commands/baseline.py +21 -12
  30. package/src/devcouncil/cli/commands/boot.py +218 -0
  31. package/src/devcouncil/cli/commands/campaign.py +302 -0
  32. package/src/devcouncil/cli/commands/check.py +225 -12
  33. package/src/devcouncil/cli/commands/config.py +221 -74
  34. package/src/devcouncil/cli/commands/cost.py +137 -28
  35. package/src/devcouncil/cli/commands/dashboard.py +12 -4
  36. package/src/devcouncil/cli/commands/debug_cmd.py +249 -0
  37. package/src/devcouncil/cli/commands/design.py +27 -17
  38. package/src/devcouncil/cli/commands/doctor.py +790 -8
  39. package/src/devcouncil/cli/commands/evidence.py +41 -20
  40. package/src/devcouncil/cli/commands/export.py +73 -0
  41. package/src/devcouncil/cli/commands/gaps.py +175 -0
  42. package/src/devcouncil/cli/commands/gated_write.py +76 -0
  43. package/src/devcouncil/cli/commands/go.py +220 -68
  44. package/src/devcouncil/cli/commands/graph_cmd.py +1192 -0
  45. package/src/devcouncil/cli/commands/handoff.py +45 -34
  46. package/src/devcouncil/cli/commands/hook.py +630 -85
  47. package/src/devcouncil/cli/commands/init.py +89 -30
  48. package/src/devcouncil/cli/commands/integrate.py +296 -1385
  49. package/src/devcouncil/cli/commands/lease.py +120 -0
  50. package/src/devcouncil/cli/commands/logs.py +12 -5
  51. package/src/devcouncil/cli/commands/lsp.py +40 -5
  52. package/src/devcouncil/cli/commands/map.py +317 -74
  53. package/src/devcouncil/cli/commands/mcp_server.py +12 -2
  54. package/src/devcouncil/cli/commands/okf.py +44 -6
  55. package/src/devcouncil/cli/commands/plan.py +184 -69
  56. package/src/devcouncil/cli/commands/prompt.py +26 -17
  57. package/src/devcouncil/cli/commands/provenance.py +79 -0
  58. package/src/devcouncil/cli/commands/repair.py +60 -49
  59. package/src/devcouncil/cli/commands/report.py +148 -40
  60. package/src/devcouncil/cli/commands/requirements.py +104 -0
  61. package/src/devcouncil/cli/commands/reset_demo_state.py +13 -4
  62. package/src/devcouncil/cli/commands/rollback.py +46 -35
  63. package/src/devcouncil/cli/commands/run.py +173 -8
  64. package/src/devcouncil/cli/commands/runs.py +298 -68
  65. package/src/devcouncil/cli/commands/scaffold.py +33 -12
  66. package/src/devcouncil/cli/commands/semantic.py +29 -14
  67. package/src/devcouncil/cli/commands/setup.py +103 -93
  68. package/src/devcouncil/cli/commands/shell.py +51 -42
  69. package/src/devcouncil/cli/commands/show.py +56 -42
  70. package/src/devcouncil/cli/commands/skills.py +29 -20
  71. package/src/devcouncil/cli/commands/status.py +80 -67
  72. package/src/devcouncil/cli/commands/task_gate.py +295 -0
  73. package/src/devcouncil/cli/commands/tasks.py +248 -19
  74. package/src/devcouncil/cli/commands/trace.py +14 -8
  75. package/src/devcouncil/cli/commands/verify.py +33 -8
  76. package/src/devcouncil/cli/commands/version.py +14 -6
  77. package/src/devcouncil/cli/commands/watch.py +49 -30
  78. package/src/devcouncil/cli/commands/watch_fs.py +30 -19
  79. package/src/devcouncil/cli/commands/wiki.py +278 -0
  80. package/src/devcouncil/cli/main.py +58 -1
  81. package/src/devcouncil/codeintel/__init__.py +16 -0
  82. package/src/devcouncil/codeintel/build_control.py +429 -0
  83. package/src/devcouncil/codeintel/build_worker.py +78 -0
  84. package/src/devcouncil/codeintel/debug/__init__.py +17 -0
  85. package/src/devcouncil/codeintel/debug/broker.py +114 -0
  86. package/src/devcouncil/codeintel/debug/broker_client.py +61 -0
  87. package/src/devcouncil/codeintel/debug/consent.py +36 -0
  88. package/src/devcouncil/codeintel/debug/discovery.py +132 -0
  89. package/src/devcouncil/codeintel/debug/fingerprint.py +85 -0
  90. package/src/devcouncil/codeintel/debug/protocol.py +259 -0
  91. package/src/devcouncil/codeintel/debug/python_trace_runner.py +81 -0
  92. package/src/devcouncil/codeintel/debug/session.py +238 -0
  93. package/src/devcouncil/codeintel/debug/tracing.py +201 -0
  94. package/src/devcouncil/codeintel/languages/__init__.py +17 -0
  95. package/src/devcouncil/codeintel/languages/generic_extractor.py +236 -0
  96. package/src/devcouncil/codeintel/languages/registry.py +149 -0
  97. package/src/devcouncil/codeintel/languages/workers.py +245 -0
  98. package/src/devcouncil/codeintel/query/__init__.py +5 -0
  99. package/src/devcouncil/codeintel/query/engine.py +289 -0
  100. package/src/devcouncil/codeintel/resolution/__init__.py +6 -0
  101. package/src/devcouncil/codeintel/resolution/abstract_state.py +301 -0
  102. package/src/devcouncil/codeintel/resolution/frameworks/__init__.py +33 -0
  103. package/src/devcouncil/codeintel/resolution/frameworks/base.py +46 -0
  104. package/src/devcouncil/codeintel/resolution/frameworks/di.py +56 -0
  105. package/src/devcouncil/codeintel/resolution/frameworks/events.py +45 -0
  106. package/src/devcouncil/codeintel/resolution/frameworks/routes.py +88 -0
  107. package/src/devcouncil/codeintel/resolution/semantic.py +887 -0
  108. package/src/devcouncil/codeintel/service.py +104 -0
  109. package/src/devcouncil/codeintel/store/__init__.py +15 -0
  110. package/src/devcouncil/codeintel/store/sqlite.py +1565 -0
  111. package/src/devcouncil/codeintel/sync/__init__.py +19 -0
  112. package/src/devcouncil/codeintel/sync/coordinator.py +430 -0
  113. package/src/devcouncil/codeintel/sync/incremental.py +484 -0
  114. package/src/devcouncil/codeintel/sync/lease.py +96 -0
  115. package/src/devcouncil/codeintel/sync/scope.py +98 -0
  116. package/src/devcouncil/council/__init__.py +4 -0
  117. package/src/devcouncil/council/prompts/__init__.py +4 -0
  118. package/src/devcouncil/domain/checkpoint_refs.py +17 -0
  119. package/src/devcouncil/domain/evidence.py +1 -0
  120. package/src/devcouncil/domain/gap.py +10 -0
  121. package/src/devcouncil/domain/requirement.py +5 -1
  122. package/src/devcouncil/domain/task.py +43 -2
  123. package/src/devcouncil/execution/checkpoints.py +25 -31
  124. package/src/devcouncil/execution/context_builder.py +15 -44
  125. package/src/devcouncil/execution/fs_watcher.py +64 -0
  126. package/src/devcouncil/execution/gated_write.py +203 -0
  127. package/src/devcouncil/execution/handoff.py +2 -1
  128. package/src/devcouncil/execution/hook_policy.py +19 -5
  129. package/src/devcouncil/execution/lease_ops.py +177 -0
  130. package/src/devcouncil/execution/lease_validation.py +71 -0
  131. package/src/devcouncil/execution/patch.py +3 -0
  132. package/src/devcouncil/execution/permissions.py +1 -0
  133. package/src/devcouncil/execution/policy_engine.py +205 -10
  134. package/src/devcouncil/execution/prompt_builder.py +278 -33
  135. package/src/devcouncil/execution/run_trace.py +356 -0
  136. package/src/devcouncil/execution/shell_session.py +46 -5
  137. package/src/devcouncil/execution/stop_gate.py +746 -0
  138. package/src/devcouncil/execution/stop_gate_history.py +113 -0
  139. package/src/devcouncil/execution/stop_gate_state.py +54 -0
  140. package/src/devcouncil/execution/stop_gate_verify_cache.py +69 -0
  141. package/src/devcouncil/execution/task_gate_ops.py +590 -0
  142. package/src/devcouncil/execution/task_runner.py +19 -0
  143. package/src/devcouncil/executors/advisor_tool.py +315 -0
  144. package/src/devcouncil/executors/agent_registry.py +125 -17
  145. package/src/devcouncil/executors/claude_sdk.py +376 -0
  146. package/src/devcouncil/executors/coding_cli.py +724 -25
  147. package/src/devcouncil/executors/mini_swe.py +50 -8
  148. package/src/devcouncil/executors/native/agent.py +224 -19
  149. package/src/devcouncil/executors/openhands.py +50 -8
  150. package/src/devcouncil/executors/transient_retry.py +99 -0
  151. package/src/devcouncil/gating/checks/clean_git.py +5 -2
  152. package/src/devcouncil/gating/checks/planned_files_check.py +38 -11
  153. package/src/devcouncil/gating/checks/secret_scan_check.py +2 -2
  154. package/src/devcouncil/gating/policy.py +46 -2
  155. package/src/devcouncil/indexing/ast_matcher.py +41 -4
  156. package/src/devcouncil/indexing/graph/__init__.py +78 -0
  157. package/src/devcouncil/indexing/graph/api_routes.py +522 -0
  158. package/src/devcouncil/indexing/graph/build.py +862 -0
  159. package/src/devcouncil/indexing/graph/cache.py +329 -0
  160. package/src/devcouncil/indexing/graph/communities.py +28 -0
  161. package/src/devcouncil/indexing/graph/cypher.py +107 -0
  162. package/src/devcouncil/indexing/graph/embeddings.py +194 -0
  163. package/src/devcouncil/indexing/graph/export.py +381 -0
  164. package/src/devcouncil/indexing/graph/export_links.py +81 -0
  165. package/src/devcouncil/indexing/graph/extract_python.py +307 -0
  166. package/src/devcouncil/indexing/graph/extract_ts.py +1205 -0
  167. package/src/devcouncil/indexing/graph/intel.py +668 -0
  168. package/src/devcouncil/indexing/graph/liveness.py +992 -0
  169. package/src/devcouncil/indexing/graph/okf_export.py +65 -0
  170. package/src/devcouncil/indexing/graph/pdg/__init__.py +67 -0
  171. package/src/devcouncil/indexing/graph/pdg/build.py +11 -0
  172. package/src/devcouncil/indexing/graph/pdg/cdg.py +41 -0
  173. package/src/devcouncil/indexing/graph/pdg/cfg.py +199 -0
  174. package/src/devcouncil/indexing/graph/pdg/query.py +21 -0
  175. package/src/devcouncil/indexing/graph/pdg/reaching_def.py +126 -0
  176. package/src/devcouncil/indexing/graph/pdg/schema.py +253 -0
  177. package/src/devcouncil/indexing/graph/pdg/taint.py +154 -0
  178. package/src/devcouncil/indexing/graph/query.py +302 -0
  179. package/src/devcouncil/indexing/graph/resolve.py +1020 -0
  180. package/src/devcouncil/indexing/graph/schema.py +103 -0
  181. package/src/devcouncil/indexing/graph_index.py +20 -29
  182. package/src/devcouncil/indexing/lsp.py +57 -25
  183. package/src/devcouncil/indexing/lsp_client.py +577 -0
  184. package/src/devcouncil/indexing/map_artifacts.py +355 -0
  185. package/src/devcouncil/indexing/map_refresh.py +141 -0
  186. package/src/devcouncil/indexing/repo_mapper.py +1509 -138
  187. package/src/devcouncil/indexing/semantic_index.py +12 -6
  188. package/src/devcouncil/indexing/subsystem_map.py +163 -0
  189. package/src/devcouncil/indexing/ts_imports.py +343 -0
  190. package/src/devcouncil/indexing/viz.py +960 -0
  191. package/src/devcouncil/indexing/walk.py +52 -0
  192. package/src/devcouncil/indexing/wiring.py +1776 -0
  193. package/src/devcouncil/integrations/actions.py +27 -4
  194. package/src/devcouncil/integrations/check.py +211 -16
  195. package/src/devcouncil/integrations/claude_assets.py +209 -12
  196. package/src/devcouncil/integrations/clients/__init__.py +1 -0
  197. package/src/devcouncil/integrations/clients/aider.py +52 -0
  198. package/src/devcouncil/integrations/clients/antigravity.py +87 -0
  199. package/src/devcouncil/integrations/clients/claude.py +339 -0
  200. package/src/devcouncil/integrations/clients/codex.py +39 -0
  201. package/src/devcouncil/integrations/clients/common.py +332 -0
  202. package/src/devcouncil/integrations/clients/cursor.py +164 -0
  203. package/src/devcouncil/integrations/clients/gemini.py +49 -0
  204. package/src/devcouncil/integrations/clients/grok.py +105 -0
  205. package/src/devcouncil/integrations/clients/hooks.py +500 -0
  206. package/src/devcouncil/integrations/clients/opencode.py +96 -0
  207. package/src/devcouncil/integrations/clients/warp.py +75 -0
  208. package/src/devcouncil/integrations/code_review_graph.py +2 -2
  209. package/src/devcouncil/integrations/github.py +73 -7
  210. package/src/devcouncil/integrations/integration_cli.py +197 -0
  211. package/src/devcouncil/integrations/mcp/handlers/__init__.py +1 -0
  212. package/src/devcouncil/integrations/mcp/handlers/ast_lsp.py +77 -0
  213. package/src/devcouncil/integrations/mcp/handlers/checkout.py +50 -0
  214. package/src/devcouncil/integrations/mcp/handlers/cli_gate.py +43 -0
  215. package/src/devcouncil/integrations/mcp/handlers/codeintel.py +182 -0
  216. package/src/devcouncil/integrations/mcp/handlers/debug.py +236 -0
  217. package/src/devcouncil/integrations/mcp/handlers/evidence.py +70 -0
  218. package/src/devcouncil/integrations/mcp/handlers/git.py +99 -0
  219. package/src/devcouncil/integrations/mcp/handlers/graph.py +36 -0
  220. package/src/devcouncil/integrations/mcp/handlers/handoff.py +53 -0
  221. package/src/devcouncil/integrations/mcp/handlers/knowledge.py +30 -0
  222. package/src/devcouncil/integrations/mcp/handlers/lease.py +70 -0
  223. package/src/devcouncil/integrations/mcp/handlers/live.py +108 -0
  224. package/src/devcouncil/integrations/mcp/handlers/map.py +676 -0
  225. package/src/devcouncil/integrations/mcp/handlers/next_task.py +35 -0
  226. package/src/devcouncil/integrations/mcp/handlers/policy.py +80 -0
  227. package/src/devcouncil/integrations/mcp/handlers/prompts.py +168 -0
  228. package/src/devcouncil/integrations/mcp/handlers/provenance.py +101 -0
  229. package/src/devcouncil/integrations/mcp/handlers/read.py +74 -0
  230. package/src/devcouncil/integrations/mcp/handlers/router_cache.py +53 -0
  231. package/src/devcouncil/integrations/mcp/handlers/run.py +53 -0
  232. package/src/devcouncil/integrations/mcp/handlers/runs.py +84 -0
  233. package/src/devcouncil/integrations/mcp/handlers/scope.py +56 -0
  234. package/src/devcouncil/integrations/mcp/handlers/status.py +114 -0
  235. package/src/devcouncil/integrations/mcp/handlers/task.py +100 -0
  236. package/src/devcouncil/integrations/mcp/handlers/tool_specs.py +904 -0
  237. package/src/devcouncil/integrations/mcp/handlers/trace.py +65 -0
  238. package/src/devcouncil/integrations/mcp/handlers/verify.py +45 -0
  239. package/src/devcouncil/integrations/mcp/handlers/wiki.py +60 -0
  240. package/src/devcouncil/integrations/mcp/handlers/write.py +69 -0
  241. package/src/devcouncil/integrations/mcp/server.py +265 -2391
  242. package/src/devcouncil/integrations/mcp/util.py +303 -0
  243. package/src/devcouncil/integrations/setup.py +152 -0
  244. package/src/devcouncil/knowledge/fetch.py +4 -0
  245. package/src/devcouncil/knowledge/knowledge_select.py +38 -0
  246. package/src/devcouncil/knowledge/okf.py +2 -1
  247. package/src/devcouncil/knowledge/resource_discovery.py +40 -0
  248. package/src/devcouncil/knowledge/wiki.py +643 -0
  249. package/src/devcouncil/knowledge/wiki_read.py +87 -0
  250. package/src/devcouncil/live/cards.py +7 -7
  251. package/src/devcouncil/live/models.py +4 -1
  252. package/src/devcouncil/live/reviewer.py +90 -11
  253. package/src/devcouncil/live/signals.py +4 -2
  254. package/src/devcouncil/live/tasks.py +12 -3
  255. package/src/devcouncil/live/transcripts.py +69 -2
  256. package/src/devcouncil/llm/cache.py +5 -6
  257. package/src/devcouncil/llm/model_defaults.yaml +10 -10
  258. package/src/devcouncil/llm/provider.py +647 -73
  259. package/src/devcouncil/llm/router.py +271 -46
  260. package/src/devcouncil/llm/semantic_bridge.py +614 -0
  261. package/src/devcouncil/optimization/gepa_agent.py +6 -4
  262. package/src/devcouncil/optimization/skillopt.py +9 -5
  263. package/src/devcouncil/planning/arbiter_service.py +12 -3
  264. package/src/devcouncil/planning/correction_manifest.py +107 -10
  265. package/src/devcouncil/planning/plan_difficulty.py +69 -0
  266. package/src/devcouncil/planning/plan_service.py +5 -2
  267. package/src/devcouncil/planning/planned_files_reconcile.py +191 -0
  268. package/src/devcouncil/planning/prompt_enhancer_service.py +6 -5
  269. package/src/devcouncil/planning/question_conversion.py +56 -0
  270. package/src/devcouncil/planning/spec_service.py +9 -3
  271. package/src/devcouncil/repo/ci_scaffold.py +197 -1
  272. package/src/devcouncil/repo/gitignore.py +1 -2
  273. package/src/devcouncil/reporting/evidence_export.py +124 -0
  274. package/src/devcouncil/reporting/evidence_html.py +210 -0
  275. package/src/devcouncil/reporting/json_report.py +16 -12
  276. package/src/devcouncil/reporting/markdown_report.py +38 -9
  277. package/src/devcouncil/reporting/mcp_resources.py +142 -0
  278. package/src/devcouncil/reporting/report_builder.py +40 -4
  279. package/src/devcouncil/reporting/task_provenance.py +42 -0
  280. package/src/devcouncil/reporting/verdict.py +75 -0
  281. package/src/devcouncil/skills/library/README.md +1 -0
  282. package/src/devcouncil/skills/library/devcouncil-hero-loop.md +109 -0
  283. package/src/devcouncil/skills/library/devcouncil-verification.md +109 -0
  284. package/src/devcouncil/skills/library/devcouncil.md +93 -0
  285. package/src/devcouncil/skills/registry.py +43 -12
  286. package/src/devcouncil/storage/db.py +57 -11
  287. package/src/devcouncil/storage/models.py +6 -0
  288. package/src/devcouncil/storage/native.py +5 -3
  289. package/src/devcouncil/storage/repositories.py +50 -18
  290. package/src/devcouncil/telemetry/context.py +28 -0
  291. package/src/devcouncil/telemetry/cost.py +4 -5
  292. package/src/devcouncil/telemetry/logging_setup.py +78 -11
  293. package/src/devcouncil/telemetry/model_pricing.yaml +7 -0
  294. package/src/devcouncil/telemetry/stages.py +27 -2
  295. package/src/devcouncil/telemetry/tracker.py +50 -13
  296. package/src/devcouncil/ui/dashboard.py +120 -8
  297. package/src/devcouncil/utils/fsio.py +58 -0
  298. package/src/devcouncil/utils/git_snapshot.py +112 -0
  299. package/src/devcouncil/utils/json_persist.py +53 -0
  300. package/src/devcouncil/utils/proc.py +89 -0
  301. package/src/devcouncil/verification/acceptance_compiler.py +36 -13
  302. package/src/devcouncil/verification/ad_hoc_check.py +95 -3
  303. package/src/devcouncil/verification/checks/__init__.py +41 -0
  304. package/src/devcouncil/verification/checks/acceptance.py +39 -0
  305. package/src/devcouncil/verification/checks/acceptance_corpus.py +194 -0
  306. package/src/devcouncil/verification/checks/acceptance_evidence.py +239 -0
  307. package/src/devcouncil/verification/checks/command_evidence.py +148 -0
  308. package/src/devcouncil/verification/checks/compiled_acceptance.py +179 -0
  309. package/src/devcouncil/verification/checks/corpus_stale.py +124 -0
  310. package/src/devcouncil/verification/checks/corpus_verification.py +9 -0
  311. package/src/devcouncil/verification/checks/dead_symbols.py +360 -0
  312. package/src/devcouncil/verification/checks/diff_coverage_gate.py +101 -0
  313. package/src/devcouncil/verification/checks/doc_code_ref.py +79 -0
  314. package/src/devcouncil/verification/checks/liveness_ratchet.py +336 -0
  315. package/src/devcouncil/verification/checks/orphan_diff.py +104 -0
  316. package/src/devcouncil/verification/checks/planned_files.py +98 -0
  317. package/src/devcouncil/verification/checks/semantic_diff.py +241 -0
  318. package/src/devcouncil/verification/checks/stale_map.py +80 -0
  319. package/src/devcouncil/verification/checks/stub_scan.py +71 -0
  320. package/src/devcouncil/verification/checks/subsystem_boundary.py +103 -0
  321. package/src/devcouncil/verification/checks/wiring.py +216 -0
  322. package/src/devcouncil/verification/claims/__init__.py +23 -0
  323. package/src/devcouncil/verification/claims/checks.py +395 -0
  324. package/src/devcouncil/verification/claims/mapper.py +168 -0
  325. package/src/devcouncil/verification/claims/models.py +39 -0
  326. package/src/devcouncil/verification/claims/transcript.py +92 -0
  327. package/src/devcouncil/verification/claims/verdict.py +88 -0
  328. package/src/devcouncil/verification/command_evidence.py +170 -0
  329. package/src/devcouncil/verification/command_malformation.py +147 -0
  330. package/src/devcouncil/verification/command_runner.py +164 -0
  331. package/src/devcouncil/verification/coverage_measurement.py +292 -0
  332. package/src/devcouncil/verification/diff_coverage.py +151 -0
  333. package/src/devcouncil/verification/difficulty.py +296 -0
  334. package/src/devcouncil/verification/effort_heuristics.py +178 -0
  335. package/src/devcouncil/verification/gap_ids.py +63 -0
  336. package/src/devcouncil/verification/gate_cache.py +194 -0
  337. package/src/devcouncil/verification/gate_selector.py +344 -0
  338. package/src/devcouncil/verification/git_diff_fallback.py +272 -0
  339. package/src/devcouncil/verification/implementation_reviewer.py +13 -0
  340. package/src/devcouncil/verification/incremental_check.py +241 -0
  341. package/src/devcouncil/verification/next_actions.py +60 -1
  342. package/src/devcouncil/verification/rigor_analytics.py +130 -0
  343. package/src/devcouncil/verification/sandbox.py +38 -11
  344. package/src/devcouncil/verification/stub_detector.py +369 -0
  345. package/src/devcouncil/verification/test_resolver.py +67 -1
  346. package/src/devcouncil/verification/verifier.py +137 -1666
  347. package/src/devcouncil/verification/verify_orchestration.py +610 -0
  348. package/src/devcouncil/verification/verify_setup.py +176 -0
  349. package/src/devcouncil/verification/wiki_refresh.py +208 -0
  350. package/src/semantic_layer/__init__.py +58 -0
  351. package/src/semantic_layer/benchmark.py +75 -0
  352. package/src/semantic_layer/cache.py +290 -0
  353. package/src/semantic_layer/compressor.py +137 -0
  354. package/src/semantic_layer/config.py +75 -0
  355. package/src/semantic_layer/embeddings.py +69 -0
  356. package/src/semantic_layer/llm_backends.py +99 -0
  357. package/src/semantic_layer/pipeline.py +111 -0
  358. package/src/semantic_layer/router.py +128 -0
  359. package/src/semantic_layer/tuner.py +72 -0
  360. package/uv.lock +973 -9
  361. package/src/devcouncil/artifacts/migrations.py +0 -20
  362. package/src/devcouncil/artifacts/schemas.py +0 -23
  363. package/src/devcouncil/artifacts/serializer.py +0 -21
  364. package/src/devcouncil/integrations/gitnexus.py +0 -70
  365. package/src/devcouncil/integrations/graphify.py +0 -34
@@ -0,0 +1,130 @@
1
+ """Cross-run rigor analytics — tune thresholds from evidence, not guesses.
2
+
3
+ Aggregates persisted verification gaps and correction manifests to surface
4
+ recurring stub/effort failures and rough false-positive rates for advisory gaps.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections import Counter
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import Dict, List
13
+
14
+ from devcouncil.storage.db import get_db
15
+ from devcouncil.storage.repositories import GapRepository, TaskRepository
16
+ from devcouncil.utils.json_persist import read_json
17
+
18
+
19
+ @dataclass
20
+ class RigorAnalyticsReport:
21
+ """Summary of rigor-gap patterns across all persisted verification runs."""
22
+
23
+ total_gaps: int = 0
24
+ by_gap_type: Dict[str, int] = field(default_factory=dict)
25
+ stub_by_task: Dict[str, int] = field(default_factory=dict)
26
+ effort_by_task: Dict[str, int] = field(default_factory=dict)
27
+ stub_declared_count: int = 0
28
+ tasks_with_repair_attempts: int = 0
29
+ avg_repair_attempts: float = 0.0
30
+ recurring_stub_tasks: List[str] = field(default_factory=list)
31
+ notes: List[str] = field(default_factory=list)
32
+
33
+ def to_markdown(self) -> str:
34
+ lines = [
35
+ "# Rigor analytics",
36
+ "",
37
+ f"- Total gaps recorded: **{self.total_gaps}**",
38
+ f"- Stub declarations (allow-stub audit): **{self.stub_declared_count}**",
39
+ f"- Tasks with repair manifests: **{self.tasks_with_repair_attempts}** "
40
+ f"(avg attempts: {self.avg_repair_attempts:.1f})",
41
+ "",
42
+ "## Gap counts by type",
43
+ ]
44
+ for gap_type, count in sorted(self.by_gap_type.items(), key=lambda x: -x[1]):
45
+ lines.append(f"- `{gap_type}`: {count}")
46
+ if self.recurring_stub_tasks:
47
+ lines.extend(["", "## Tasks recurring on stub gaps", ""])
48
+ for tid in self.recurring_stub_tasks:
49
+ lines.append(f"- {tid} ({self.stub_by_task.get(tid, 0)} stub gap(s))")
50
+ if self.notes:
51
+ lines.extend(["", "## Notes", ""])
52
+ lines.extend(f"- {n}" for n in self.notes)
53
+ return "\n".join(lines) + "\n"
54
+
55
+
56
+ def _manifest_attempts(project_root: Path) -> List[int]:
57
+ runs_dir = project_root / ".devcouncil" / "runs"
58
+ if not runs_dir.exists():
59
+ return []
60
+ attempts: List[int] = []
61
+ for run_dir in runs_dir.iterdir():
62
+ path = run_dir / "correction-manifest.json"
63
+ if not path.exists():
64
+ continue
65
+ try:
66
+ data = read_json(path)
67
+ attempts.append(int(data.get("prior_failed_attempts", 0)) + 1)
68
+ except Exception:
69
+ continue
70
+ return attempts
71
+
72
+
73
+ def build_rigor_report(project_root: Path) -> RigorAnalyticsReport:
74
+ """Aggregate rigor-related gaps and repair history. Never raises."""
75
+ report = RigorAnalyticsReport()
76
+ db = get_db(project_root)
77
+ if db is None:
78
+ report.notes.append("No DevCouncil state database — nothing to analyze.")
79
+ return report
80
+
81
+ with db.get_session() as session:
82
+ gaps = GapRepository(session).get_all()
83
+ tasks = {t.id: t for t in TaskRepository(session).get_all()}
84
+
85
+ report.total_gaps = len(gaps)
86
+ report.by_gap_type = dict(Counter(g.gap_type for g in gaps))
87
+
88
+ stub_counts: Counter[str] = Counter()
89
+ effort_counts: Counter[str] = Counter()
90
+ for gap in gaps:
91
+ if gap.gap_type == "stub_detected" and gap.task_id:
92
+ stub_counts[gap.task_id] += 1
93
+ elif gap.gap_type == "suspicious_effort" and gap.task_id:
94
+ effort_counts[gap.task_id] += 1
95
+ elif gap.gap_type == "stub_declared":
96
+ report.stub_declared_count += 1
97
+
98
+ report.stub_by_task = dict(stub_counts)
99
+ report.effort_by_task = dict(effort_counts)
100
+ report.recurring_stub_tasks = sorted(
101
+ tid for tid, n in stub_counts.items() if n >= 2
102
+ )
103
+
104
+ manifest_attempts = _manifest_attempts(project_root)
105
+ if manifest_attempts:
106
+ report.tasks_with_repair_attempts = len(manifest_attempts)
107
+ report.avg_repair_attempts = sum(manifest_attempts) / len(manifest_attempts)
108
+
109
+ effort_total = report.by_gap_type.get("suspicious_effort", 0)
110
+ verified_tasks = sum(1 for t in tasks.values() if t.status in ("verified", "done"))
111
+ if effort_total and verified_tasks:
112
+ # Rough advisory false-positive proxy: effort gaps on tasks that still verified.
113
+ verified_with_effort = sum(
114
+ 1 for tid in effort_counts if tid in tasks and tasks[tid].status in ("verified", "done")
115
+ )
116
+ if verified_with_effort:
117
+ rate = verified_with_effort / max(1, len(effort_counts))
118
+ report.notes.append(
119
+ f"suspicious_effort advisory gaps on later-verified tasks: "
120
+ f"{verified_with_effort}/{len(effort_counts)} ({rate:.0%}) — "
121
+ "tune min_added_lines_per_planned_file if this is high."
122
+ )
123
+
124
+ if report.recurring_stub_tasks:
125
+ report.notes.append(
126
+ f"{len(report.recurring_stub_tasks)} task(s) hit stub_detected more than once — "
127
+ "review repair prompts or raise stub_detection to 'always' for those areas."
128
+ )
129
+
130
+ return report
@@ -35,6 +35,34 @@ class VerificationSandbox:
35
35
  raise NotImplementedError
36
36
 
37
37
 
38
+ def _command_timeout(config: DevCouncilConfig) -> float:
39
+ """Per-command ceiling for sandboxed verification commands.
40
+
41
+ Reuses ``execution.command_timeout`` (same knob the task runner applies to
42
+ the commands it executes) so one config value bounds both surfaces."""
43
+ try:
44
+ return float(config.execution.command_timeout)
45
+ except Exception:
46
+ return 300.0
47
+
48
+
49
+ def _run_sandboxed(argv: list[str], *, cwd: Path | None = None, timeout: float) -> subprocess.CompletedProcess:
50
+ """subprocess.run with capture + timeout; a timeout surfaces as returncode 124
51
+ (consistent with utils.proc.run_git) so callers' exit-code checks fail loudly
52
+ instead of the sandbox hanging verification forever."""
53
+ try:
54
+ return subprocess.run(
55
+ argv,
56
+ cwd=str(cwd) if cwd is not None else None,
57
+ capture_output=True,
58
+ text=True,
59
+ timeout=timeout,
60
+ )
61
+ except subprocess.TimeoutExpired:
62
+ logger.warning("Sandbox command timed out after %.0fs: %s", timeout, " ".join(argv))
63
+ return subprocess.CompletedProcess(argv, returncode=124, stdout="", stderr=f"timed out after {timeout}s")
64
+
65
+
38
66
  def _save_run(
39
67
  project_root: Path,
40
68
  task: Task,
@@ -91,11 +119,11 @@ class DockerSandbox(VerificationSandbox):
91
119
  image = self.config.verification.sandbox.docker_image
92
120
  setup = self.config.verification.sandbox.docker_setup_commands
93
121
  results: list[dict] = []
122
+ timeout = _command_timeout(self.config)
94
123
  for setup_cmd in setup:
95
- proc = subprocess.run(
124
+ proc = _run_sandboxed(
96
125
  ["docker", "run", "--rm", "-v", f"{self.project_root}:/work", "-w", "/work", image, "sh", "-c", setup_cmd],
97
- capture_output=True,
98
- text=True,
126
+ timeout=timeout,
99
127
  )
100
128
  results.append({"command": setup_cmd, "exit_code": proc.returncode})
101
129
  if proc.returncode != 0:
@@ -103,10 +131,9 @@ class DockerSandbox(VerificationSandbox):
103
131
  _save_run(self.project_root, task, "docker", result.environment, result.commands, result.status)
104
132
  return result
105
133
  for cmd in commands:
106
- proc = subprocess.run(
134
+ proc = _run_sandboxed(
107
135
  ["docker", "run", "--rm", "-v", f"{self.project_root}:/work", "-w", "/work", image, "sh", "-c", cmd],
108
- capture_output=True,
109
- text=True,
136
+ timeout=timeout,
110
137
  )
111
138
  results.append({"command": cmd, "exit_code": proc.returncode})
112
139
  if proc.returncode != 0:
@@ -135,12 +162,12 @@ class NixSandbox(VerificationSandbox):
135
162
  return result
136
163
  attr = self.config.verification.sandbox.nix_flake_attr or "devShells.default"
137
164
  results: list[dict] = []
165
+ timeout = _command_timeout(self.config)
138
166
  for cmd in commands:
139
- proc = subprocess.run(
167
+ proc = _run_sandboxed(
140
168
  ["nix", "develop", f".#{attr}", "-c", "sh", "-c", cmd],
141
169
  cwd=self.project_root,
142
- capture_output=True,
143
- text=True,
170
+ timeout=timeout,
144
171
  )
145
172
  results.append({"command": cmd, "exit_code": proc.returncode})
146
173
  if proc.returncode != 0:
@@ -162,9 +189,9 @@ def _environment_metadata(project_root: Path) -> dict[str, str]:
162
189
  "platform": platform.platform(),
163
190
  }
164
191
  try:
165
- uv = subprocess.check_output(["uv", "--version"], text=True).strip()
192
+ uv = subprocess.check_output(["uv", "--version"], text=True, timeout=10).strip()
166
193
  env["uv"] = uv
167
- except (FileNotFoundError, subprocess.CalledProcessError, OSError):
194
+ except (FileNotFoundError, subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError):
168
195
  pass
169
196
  lock = project_root / "uv.lock"
170
197
  if lock.exists():
@@ -0,0 +1,369 @@
1
+ """Stub/TODO detection over the lines a task's diff *added*.
2
+
3
+ Catches the classic lazy-agent moves before they reach "verified": placeholder
4
+ bodies (``pass`` / ``...`` / ``raise NotImplementedError``), TODO/FIXME markers,
5
+ neutered or skipped tests, assert-free test functions, and per-language "not
6
+ implemented" idioms. Scanning is restricted to lines the diff added, so
7
+ pre-existing debt in the repo never blocks a task that did not touch it.
8
+
9
+ Escape hatch: a line containing ``devcouncil: allow-stub`` suppresses stub
10
+ findings on that line **only when the task description mentions scaffolding**
11
+ (see :func:`task_allows_scaffolding`). Every newly added allow-stub marker is
12
+ still surfaced separately via :func:`detect_stub_declarations` so a human can
13
+ audit intentional placeholders — agents cannot silently blanket-suppress stubs.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import ast
19
+ import logging
20
+ import re
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import Dict, List, Tuple
24
+
25
+ from devcouncil.domain.task import Task
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ ALLOW_MARKER = "devcouncil: allow-stub"
30
+
31
+ # Documentation files: TODO lists in docs are normal writing, not lazy code.
32
+ _DOC_SUFFIXES = {".md", ".rst", ".txt"}
33
+
34
+ _SCAFFOLDING_RE = re.compile(r"\bscaffold(?:ing)?\b", re.IGNORECASE)
35
+
36
+ # Shouty markers are matched case-sensitively so prose like "hack" in a comment
37
+ # does not fire; ``stub`` and the explicit "not implemented"/"implement later"
38
+ # phrases are matched case-insensitively.
39
+ _SHOUTY_MARKER_RE = re.compile(r"\b(TODO|FIXME|XXX|HACK)\b")
40
+ _STUB_MARKER_RE = re.compile(r"\bstub\b", re.IGNORECASE)
41
+ _PHRASE_MARKER_RE = re.compile(r"\b(not implemented|implement (?:this|later|me))\b", re.IGNORECASE)
42
+
43
+ # Comment delimiters used to isolate the COMMENT portion of a line. The bare
44
+ # ``stub`` word is only a laziness signal when a human wrote it as an annotation
45
+ # ("# stub implementation for now") — in CODE it is a legitimate identifier
46
+ # (``class StubProvider``, ``def stub_response()``, test doubles) and flagging it
47
+ # produced blocking false positives on hard tasks. Shouty TODO/FIXME markers and
48
+ # the explicit "not implemented" phrases still match anywhere on the line.
49
+ _COMMENT_DELIMS = ("#", "//", "/*", "--", ";;", "<!--")
50
+
51
+
52
+ def _comment_segment(text: str) -> str:
53
+ """The portion of ``text`` from the first comment delimiter onward ('' if none)."""
54
+ first = -1
55
+ for delim in _COMMENT_DELIMS:
56
+ idx = text.find(delim)
57
+ if idx != -1 and (first == -1 or idx < first):
58
+ first = idx
59
+ return text[first:] if first != -1 else ""
60
+
61
+ # Commented-out assert lines in test files — neutered verification.
62
+ _COMMENTED_ASSERT_RE = re.compile(r"^\s*#+\s*assert\b", re.IGNORECASE)
63
+
64
+ # Empty exported function bodies added in JS/TS/Go (added lines only).
65
+ _EMPTY_EXPORTED_FN_RE = re.compile(
66
+ r"^\s*(?:export\s+)?(?:async\s+)?function\s+\w+\s*\([^)]*\)\s*\{\s*\}\s*;?\s*$"
67
+ )
68
+
69
+ _SKIPPED_TEST_RES: Tuple[Tuple[re.Pattern, str], ...] = (
70
+ (re.compile(r"@pytest\.mark\.skip\b"), "pytest test skipped"),
71
+ (re.compile(r"@pytest\.mark\.skipif\(\s*True"), "pytest test skipped unconditionally"),
72
+ (re.compile(r"@unittest\.skip\b"), "unittest test skipped"),
73
+ (re.compile(r"\b(?:it|test|describe)\.skip\s*\("), "JS test skipped"),
74
+ (re.compile(r"\bx(?:it|describe|test)\s*\("), "JS test disabled (xit/xdescribe)"),
75
+ (re.compile(r"assert\s+True\s*(#.*(placeholder|stub|todo).*)?$", re.IGNORECASE), "placeholder assertion (assert True)"),
76
+ )
77
+
78
+ _LANG_STUB_RES: Tuple[Tuple[re.Pattern, str], ...] = (
79
+ (re.compile(r"throw\s+new\s+Error\(\s*['\"](?:not implemented|todo|unimplemented)", re.IGNORECASE), "JS/TS not-implemented throw"),
80
+ (re.compile(r"\btodo!\s*\("), "Rust todo!()"),
81
+ (re.compile(r"\bunimplemented!\s*\("), "Rust unimplemented!()"),
82
+ (re.compile(r"panic\(\s*[\"'](?:TODO|not implemented|unimplemented)", re.IGNORECASE), "Go not-implemented panic"),
83
+ (re.compile(r"raise\s+NotImplementedError\b"), "NotImplementedError raised"),
84
+ )
85
+
86
+ # JS/TS assert-free test: test/it blocks with no expect/assert.
87
+ _JS_ASSERT_FREE_RE = re.compile(
88
+ r"^\s*(?:it|test)\s*\([^)]*\)\s*(?:=>)?\s*\{[^}]*\}\s*;?\s*$"
89
+ )
90
+
91
+ _HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@")
92
+
93
+
94
+ @dataclass
95
+ class StubFinding:
96
+ file: str
97
+ line: int
98
+ reason: str
99
+ snippet: str
100
+
101
+
102
+ def task_allows_scaffolding(task: Task | None) -> bool:
103
+ """True when the task explicitly declares scaffolding work."""
104
+ if task is None:
105
+ return False
106
+ text = f"{task.title} {task.description}"
107
+ return bool(_SCAFFOLDING_RE.search(text))
108
+
109
+
110
+ def added_lines_by_file(diff_content: str) -> Dict[str, List[Tuple[int, str]]]:
111
+ """Parse a unified diff into ``{new_path: [(new_lineno, added_text), ...]}``.
112
+
113
+ Only ``+`` lines are collected. Deleted files (``+++ /dev/null``) are skipped.
114
+ Tolerant of malformed hunks — it never raises.
115
+ """
116
+ out: Dict[str, List[Tuple[int, str]]] = {}
117
+ current: str | None = None
118
+ lineno = 0
119
+ in_hunk = False
120
+ for raw in diff_content.splitlines():
121
+ if raw.startswith("+++ "):
122
+ target = raw[4:].strip()
123
+ if target == "/dev/null":
124
+ current = None
125
+ else:
126
+ current = target[2:] if target.startswith(("a/", "b/")) else target
127
+ in_hunk = False
128
+ continue
129
+ if raw.startswith("@@"):
130
+ m = _HUNK_RE.match(raw)
131
+ if m and current is not None:
132
+ lineno = int(m.group(1))
133
+ in_hunk = True
134
+ else:
135
+ in_hunk = False
136
+ continue
137
+ if not in_hunk or current is None:
138
+ continue
139
+ if raw.startswith("+"):
140
+ out.setdefault(current, []).append((lineno, raw[1:]))
141
+ lineno += 1
142
+ elif raw.startswith("-"):
143
+ continue
144
+ elif raw.startswith("\\"): # ""
145
+ continue
146
+ else:
147
+ lineno += 1
148
+ return out
149
+
150
+
151
+ def detect_stub_declarations(diff_content: str) -> List[StubFinding]:
152
+ """Every newly added ``devcouncil: allow-stub`` marker (always advisory)."""
153
+ findings: List[StubFinding] = []
154
+ try:
155
+ by_file = added_lines_by_file(diff_content)
156
+ except Exception: # pragma: no cover
157
+ return []
158
+ for path, added in by_file.items():
159
+ if Path(path).suffix.lower() in _DOC_SUFFIXES:
160
+ continue
161
+ for lineno, text in added:
162
+ if ALLOW_MARKER in text:
163
+ findings.append(StubFinding(
164
+ path, lineno,
165
+ "intentional stub declared (allow-stub)",
166
+ text.strip()[:160],
167
+ ))
168
+ return findings
169
+
170
+
171
+ def _scan_added_line(
172
+ path: str,
173
+ lineno: int,
174
+ text: str,
175
+ *,
176
+ honor_allow_stub: bool,
177
+ ) -> List[StubFinding]:
178
+ findings: List[StubFinding] = []
179
+ if honor_allow_stub and ALLOW_MARKER in text:
180
+ return findings
181
+ snippet = text.strip()[:160]
182
+ is_testish = "test" in Path(path).name.lower() or "/tests/" in f"/{path}"
183
+ # ``stub`` only counts as a marker inside a comment, and never in test files —
184
+ # test doubles ("stub provider") are standard practice, not laziness. TODO/FIXME
185
+ # and "not implemented" phrases keep matching anywhere.
186
+ stub_marker = not is_testish and _STUB_MARKER_RE.search(_comment_segment(text))
187
+ if _SHOUTY_MARKER_RE.search(text) or stub_marker or _PHRASE_MARKER_RE.search(text):
188
+ findings.append(StubFinding(path, lineno, "TODO/placeholder marker in added code", snippet))
189
+ for pattern, reason in _LANG_STUB_RES:
190
+ if pattern.search(text):
191
+ findings.append(StubFinding(path, lineno, reason, snippet))
192
+ if is_testish:
193
+ for pattern, reason in _SKIPPED_TEST_RES:
194
+ if pattern.search(text):
195
+ findings.append(StubFinding(path, lineno, reason, snippet))
196
+ if _COMMENTED_ASSERT_RE.search(text):
197
+ findings.append(StubFinding(path, lineno, "commented-out assert in test", snippet))
198
+ # Single-line JS test with empty body and no expect/assert.
199
+ if path.endswith((".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs")):
200
+ lowered = text.lower()
201
+ if ("test(" in lowered or "it(" in lowered) and "expect(" not in lowered and "assert" not in lowered:
202
+ if _JS_ASSERT_FREE_RE.search(text):
203
+ findings.append(StubFinding(path, lineno, "test with no assertions", snippet))
204
+ if _EMPTY_EXPORTED_FN_RE.search(text):
205
+ findings.append(StubFinding(path, lineno, "empty exported function body", snippet))
206
+ return findings
207
+
208
+
209
+ def _function_has_assertions(node: ast.AST) -> bool:
210
+ """Return True if the function body contains any verification statement."""
211
+ for child in ast.walk(node):
212
+ if isinstance(child, ast.Assert):
213
+ return True
214
+ if isinstance(child, ast.Call):
215
+ func = child.func
216
+ if isinstance(func, ast.Attribute):
217
+ name = func.attr
218
+ if name in {"assertEqual", "assertTrue", "assertFalse", "assertRaises", "assertIn", "assertNotIn"}:
219
+ return True
220
+ if name == "raises" and isinstance(func.value, ast.Name) and func.value.id == "pytest":
221
+ return True
222
+ if isinstance(func, ast.Name) and func.id == "raises":
223
+ return True
224
+ return False
225
+
226
+
227
+ def _is_test_function(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
228
+ if node.name.startswith("test_"):
229
+ return True
230
+ for dec in node.decorator_list:
231
+ target = dec
232
+ if isinstance(dec, ast.Call):
233
+ target = dec.func
234
+ if isinstance(target, ast.Attribute) and target.attr in {"test", "parametrize"}:
235
+ return True
236
+ if isinstance(target, ast.Name) and target.id in {"pytest_mark", "unittest"}:
237
+ return True
238
+ return False
239
+
240
+
241
+ def _python_assert_free_tests(
242
+ project_root: Path,
243
+ rel_path: str,
244
+ added: List[Tuple[int, str]],
245
+ ) -> List[StubFinding]:
246
+ """Flag added/changed test functions whose body contains no assertions."""
247
+ full = project_root / rel_path
248
+ try:
249
+ source = full.read_text(encoding="utf-8", errors="replace")
250
+ tree = ast.parse(source)
251
+ except Exception:
252
+ return []
253
+ added_linenos = {ln for ln, _ in added}
254
+ source_lines = source.splitlines()
255
+ findings: List[StubFinding] = []
256
+ for node in ast.walk(tree):
257
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
258
+ continue
259
+ if not _is_test_function(node):
260
+ continue
261
+ end = getattr(node, "end_lineno", node.lineno)
262
+ span = set(range(node.lineno, end + 1))
263
+ if not (span & added_linenos):
264
+ continue
265
+ if any(
266
+ 0 <= ln - 1 < len(source_lines) and ALLOW_MARKER in source_lines[ln - 1]
267
+ for ln in span
268
+ ):
269
+ continue
270
+ if _function_has_assertions(node):
271
+ continue
272
+ # Docstring-only bodies with no asserts still count as assert-free.
273
+ findings.append(StubFinding(
274
+ rel_path, node.lineno,
275
+ f"test function '{node.name}' has no assertions",
276
+ f"def {node.name}(...)",
277
+ ))
278
+ return findings
279
+
280
+
281
+ def _python_stub_bodies(
282
+ project_root: Path,
283
+ rel_path: str,
284
+ added: List[Tuple[int, str]],
285
+ *,
286
+ honor_allow_stub: bool,
287
+ ) -> List[StubFinding]:
288
+ """AST pass: flag functions whose (added) body is only pass/.../docstring."""
289
+ full = project_root / rel_path
290
+ try:
291
+ source = full.read_text(encoding="utf-8", errors="replace")
292
+ tree = ast.parse(source)
293
+ except Exception:
294
+ return []
295
+ added_linenos = {ln for ln, _ in added}
296
+ source_lines = source.splitlines()
297
+ findings: List[StubFinding] = []
298
+ for node in ast.walk(tree):
299
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
300
+ continue
301
+ body = node.body
302
+ stmts = list(body)
303
+ first = stmts[0] if stmts else None
304
+ if isinstance(first, ast.Expr):
305
+ val = first.value
306
+ if isinstance(val, ast.Constant) and isinstance(val.value, str):
307
+ stmts = stmts[1:] # drop docstring
308
+ if not stmts:
309
+ trivial = True
310
+ else:
311
+ trivial = all(
312
+ isinstance(s, ast.Pass)
313
+ or (isinstance(s, ast.Expr) and isinstance(s.value, ast.Constant) and s.value.value is Ellipsis)
314
+ for s in stmts
315
+ )
316
+ if not trivial:
317
+ continue
318
+ end = getattr(node, "end_lineno", node.lineno)
319
+ span = set(range(node.lineno, end + 1))
320
+ if not (span & added_linenos):
321
+ continue
322
+ if honor_allow_stub and any(
323
+ 0 <= ln - 1 < len(source_lines) and ALLOW_MARKER in source_lines[ln - 1]
324
+ for ln in span
325
+ ):
326
+ continue
327
+ findings.append(StubFinding(
328
+ rel_path, node.lineno,
329
+ f"function '{node.name}' has a placeholder body (pass/...)",
330
+ f"def {node.name}(...)",
331
+ ))
332
+ return findings
333
+
334
+
335
+ def detect_stubs(
336
+ project_root: Path,
337
+ diff_content: str,
338
+ *,
339
+ honor_allow_stub: bool = False,
340
+ ) -> List[StubFinding]:
341
+ """All stub/TODO findings in the diff's added lines. Never raises."""
342
+ try:
343
+ by_file = added_lines_by_file(diff_content)
344
+ except Exception: # pragma: no cover - defensive
345
+ logger.debug("stub detection: diff parse failed", exc_info=True)
346
+ return []
347
+ findings: List[StubFinding] = []
348
+ for path, added in by_file.items():
349
+ if Path(path).suffix.lower() in _DOC_SUFFIXES:
350
+ continue
351
+ for lineno, text in added:
352
+ findings.extend(_scan_added_line(path, lineno, text, honor_allow_stub=honor_allow_stub))
353
+ if path.endswith(".py"):
354
+ try:
355
+ findings.extend(_python_stub_bodies(
356
+ project_root, path, added, honor_allow_stub=honor_allow_stub,
357
+ ))
358
+ findings.extend(_python_assert_free_tests(project_root, path, added))
359
+ except Exception: # pragma: no cover - defensive
360
+ logger.debug("stub detection: AST pass failed for %s", path, exc_info=True)
361
+ seen = set()
362
+ unique: List[StubFinding] = []
363
+ for f in findings:
364
+ key = (f.file, f.line, f.reason)
365
+ if key in seen:
366
+ continue
367
+ seen.add(key)
368
+ unique.append(f)
369
+ return unique
@@ -20,8 +20,30 @@ class EvidenceSuggestion(BaseModel):
20
20
  class TestResolver:
21
21
  __test__ = False
22
22
 
23
- def __init__(self, project_root: Path):
23
+ def __init__(self, project_root: Path, repo_map: dict | None = None):
24
24
  self.project_root = project_root.resolve()
25
+ repo_map = repo_map or {}
26
+ # file -> the files that import it (reverse import edges from `dev map`).
27
+ # A test that imports the changed file is direct evidence it exercises
28
+ # that file — a far stronger signal than guessing a test from its name.
29
+ self._dependents: dict[str, list[str]] = repo_map.get("dependents", {}) or {}
30
+ self._known_files: set[str] = {
31
+ f["path"] for f in (repo_map.get("files") or []) if f.get("path")
32
+ }
33
+ # file -> subsystem area, and area -> its declared test files
34
+ # (role_files["tests"]), so a change with no direct test importer can fall
35
+ # back to its subsystem's tests instead of the whole-package default.
36
+ self._area_by_file: dict[str, str] = {
37
+ f["path"]: f["area"]
38
+ for f in (repo_map.get("files") or [])
39
+ if f.get("path") and f.get("area")
40
+ }
41
+ self._tests_by_area: dict[str, list[str]] = {}
42
+ for sub in repo_map.get("subsystems") or []:
43
+ area = sub.get("area")
44
+ tests = (sub.get("role_files") or {}).get("tests") or []
45
+ if area and tests:
46
+ self._tests_by_area[area] = list(tests)
25
47
 
26
48
  def suggest_for_task(
27
49
  self,
@@ -34,6 +56,21 @@ class TestResolver:
34
56
 
35
57
  for path in files:
36
58
  normalized = path.replace("\\", "/")
59
+ # Repo-map-derived tests first: a test that imports the changed file
60
+ # (or, failing that, its subsystem's declared tests) actually
61
+ # exercises it, so it outranks the name-based guesses below.
62
+ for command, confidence, reason in self._map_candidates(normalized):
63
+ if command in seen:
64
+ continue
65
+ seen.add(command)
66
+ suggestions.append(
67
+ EvidenceSuggestion(
68
+ command=command,
69
+ confidence=confidence,
70
+ reason=reason,
71
+ paths=[normalized],
72
+ )
73
+ )
37
74
  for candidate in self._candidates_for_path(normalized):
38
75
  if candidate in seen:
39
76
  continue
@@ -49,6 +86,35 @@ class TestResolver:
49
86
  )
50
87
  return suggestions
51
88
 
89
+ @staticmethod
90
+ def _looks_like_test(path: str) -> bool:
91
+ base = path.rsplit("/", 1)[-1]
92
+ return base.startswith("test_") or base.endswith("_test.py")
93
+
94
+ def _file_exists(self, rel: str) -> bool:
95
+ return rel in self._known_files or (self.project_root / rel).exists()
96
+
97
+ def _map_candidates(self, path: str) -> list[tuple[str, Literal["high", "medium", "low"], str]]:
98
+ """Test commands grounded in the repo map, strongest signal first."""
99
+ out: list[tuple[str, Literal["high", "medium", "low"], str]] = []
100
+ # 1. Tests that directly import the changed file (real coverage evidence).
101
+ for importer in self._dependents.get(path, []):
102
+ imp = importer.replace("\\", "/")
103
+ if self._looks_like_test(imp) and self._file_exists(imp):
104
+ out.append(
105
+ (f"pytest {imp}", "high", "Test imports the changed file (repo map dependents)")
106
+ )
107
+ # 2. Fallback: the subsystem's declared tests for this file's area.
108
+ if not out:
109
+ area = self._area_by_file.get(path)
110
+ for test_file in self._tests_by_area.get(area or "", []):
111
+ tf = test_file.replace("\\", "/")
112
+ if self._file_exists(tf):
113
+ out.append(
114
+ (f"pytest {tf}", "medium", f"Subsystem '{area}' test file (repo map role_files)")
115
+ )
116
+ return out
117
+
52
118
  def _candidates_for_path(self, path: str) -> list[str]:
53
119
  candidates: list[str] = []
54
120
  if path.startswith("src/") and path.endswith(".py"):