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.
- package/README.md +46 -30
- package/package.json +6 -2
- package/packages/codeintel-grammars/hatch_build.py +43 -0
- package/packages/codeintel-grammars/pyproject.toml +16 -0
- package/packages/codeintel-grammars/src/devcouncil_codeintel_grammars/__init__.py +93 -0
- package/pyproject.toml +99 -4
- package/src/devcouncil/app/config.py +512 -20
- package/src/devcouncil/app/events.py +4 -23
- package/src/devcouncil/app/orchestrator.py +5 -0
- package/src/devcouncil/app/run_context.py +3 -3
- package/src/devcouncil/assets/__init__.py +4 -1
- package/src/devcouncil/assets/vendor/force-graph.min.js +5 -0
- package/src/devcouncil/campaign/__init__.py +71 -0
- package/src/devcouncil/campaign/bloom.py +137 -0
- package/src/devcouncil/campaign/dashboard.py +123 -0
- package/src/devcouncil/campaign/mailbox.py +305 -0
- package/src/devcouncil/campaign/notify.py +91 -0
- package/src/devcouncil/campaign/orchestrator.py +592 -0
- package/src/devcouncil/campaign/prompts/coordinator.md +29 -0
- package/src/devcouncil/campaign/prompts/director.md +21 -0
- package/src/devcouncil/campaign/prompts/protocol.md +46 -0
- package/src/devcouncil/campaign/prompts/reviewer.md +24 -0
- package/src/devcouncil/campaign/prompts/worker.md +24 -0
- package/src/devcouncil/campaign/roles.py +202 -0
- package/src/devcouncil/campaign/watcher.py +153 -0
- package/src/devcouncil/cli/commands/agents.py +24 -17
- package/src/devcouncil/cli/commands/artifacts.py +36 -27
- package/src/devcouncil/cli/commands/ast.py +12 -3
- package/src/devcouncil/cli/commands/baseline.py +21 -12
- package/src/devcouncil/cli/commands/boot.py +218 -0
- package/src/devcouncil/cli/commands/campaign.py +302 -0
- package/src/devcouncil/cli/commands/check.py +225 -12
- package/src/devcouncil/cli/commands/config.py +221 -74
- package/src/devcouncil/cli/commands/cost.py +137 -28
- package/src/devcouncil/cli/commands/dashboard.py +12 -4
- package/src/devcouncil/cli/commands/debug_cmd.py +249 -0
- package/src/devcouncil/cli/commands/design.py +27 -17
- package/src/devcouncil/cli/commands/doctor.py +790 -8
- package/src/devcouncil/cli/commands/evidence.py +41 -20
- package/src/devcouncil/cli/commands/export.py +73 -0
- package/src/devcouncil/cli/commands/gaps.py +175 -0
- package/src/devcouncil/cli/commands/gated_write.py +76 -0
- package/src/devcouncil/cli/commands/go.py +220 -68
- package/src/devcouncil/cli/commands/graph_cmd.py +1192 -0
- package/src/devcouncil/cli/commands/handoff.py +45 -34
- package/src/devcouncil/cli/commands/hook.py +630 -85
- package/src/devcouncil/cli/commands/init.py +89 -30
- package/src/devcouncil/cli/commands/integrate.py +296 -1385
- package/src/devcouncil/cli/commands/lease.py +120 -0
- package/src/devcouncil/cli/commands/logs.py +12 -5
- package/src/devcouncil/cli/commands/lsp.py +40 -5
- package/src/devcouncil/cli/commands/map.py +317 -74
- package/src/devcouncil/cli/commands/mcp_server.py +12 -2
- package/src/devcouncil/cli/commands/okf.py +44 -6
- package/src/devcouncil/cli/commands/plan.py +184 -69
- package/src/devcouncil/cli/commands/prompt.py +26 -17
- package/src/devcouncil/cli/commands/provenance.py +79 -0
- package/src/devcouncil/cli/commands/repair.py +60 -49
- package/src/devcouncil/cli/commands/report.py +148 -40
- package/src/devcouncil/cli/commands/requirements.py +104 -0
- package/src/devcouncil/cli/commands/reset_demo_state.py +13 -4
- package/src/devcouncil/cli/commands/rollback.py +46 -35
- package/src/devcouncil/cli/commands/run.py +173 -8
- package/src/devcouncil/cli/commands/runs.py +298 -68
- package/src/devcouncil/cli/commands/scaffold.py +33 -12
- package/src/devcouncil/cli/commands/semantic.py +29 -14
- package/src/devcouncil/cli/commands/setup.py +103 -93
- package/src/devcouncil/cli/commands/shell.py +51 -42
- package/src/devcouncil/cli/commands/show.py +56 -42
- package/src/devcouncil/cli/commands/skills.py +29 -20
- package/src/devcouncil/cli/commands/status.py +80 -67
- package/src/devcouncil/cli/commands/task_gate.py +295 -0
- package/src/devcouncil/cli/commands/tasks.py +248 -19
- package/src/devcouncil/cli/commands/trace.py +14 -8
- package/src/devcouncil/cli/commands/verify.py +33 -8
- package/src/devcouncil/cli/commands/version.py +14 -6
- package/src/devcouncil/cli/commands/watch.py +49 -30
- package/src/devcouncil/cli/commands/watch_fs.py +30 -19
- package/src/devcouncil/cli/commands/wiki.py +278 -0
- package/src/devcouncil/cli/main.py +58 -1
- package/src/devcouncil/codeintel/__init__.py +16 -0
- package/src/devcouncil/codeintel/build_control.py +429 -0
- package/src/devcouncil/codeintel/build_worker.py +78 -0
- package/src/devcouncil/codeintel/debug/__init__.py +17 -0
- package/src/devcouncil/codeintel/debug/broker.py +114 -0
- package/src/devcouncil/codeintel/debug/broker_client.py +61 -0
- package/src/devcouncil/codeintel/debug/consent.py +36 -0
- package/src/devcouncil/codeintel/debug/discovery.py +132 -0
- package/src/devcouncil/codeintel/debug/fingerprint.py +85 -0
- package/src/devcouncil/codeintel/debug/protocol.py +259 -0
- package/src/devcouncil/codeintel/debug/python_trace_runner.py +81 -0
- package/src/devcouncil/codeintel/debug/session.py +238 -0
- package/src/devcouncil/codeintel/debug/tracing.py +201 -0
- package/src/devcouncil/codeintel/languages/__init__.py +17 -0
- package/src/devcouncil/codeintel/languages/generic_extractor.py +236 -0
- package/src/devcouncil/codeintel/languages/registry.py +149 -0
- package/src/devcouncil/codeintel/languages/workers.py +245 -0
- package/src/devcouncil/codeintel/query/__init__.py +5 -0
- package/src/devcouncil/codeintel/query/engine.py +289 -0
- package/src/devcouncil/codeintel/resolution/__init__.py +6 -0
- package/src/devcouncil/codeintel/resolution/abstract_state.py +301 -0
- package/src/devcouncil/codeintel/resolution/frameworks/__init__.py +33 -0
- package/src/devcouncil/codeintel/resolution/frameworks/base.py +46 -0
- package/src/devcouncil/codeintel/resolution/frameworks/di.py +56 -0
- package/src/devcouncil/codeintel/resolution/frameworks/events.py +45 -0
- package/src/devcouncil/codeintel/resolution/frameworks/routes.py +88 -0
- package/src/devcouncil/codeintel/resolution/semantic.py +887 -0
- package/src/devcouncil/codeintel/service.py +104 -0
- package/src/devcouncil/codeintel/store/__init__.py +15 -0
- package/src/devcouncil/codeintel/store/sqlite.py +1565 -0
- package/src/devcouncil/codeintel/sync/__init__.py +19 -0
- package/src/devcouncil/codeintel/sync/coordinator.py +430 -0
- package/src/devcouncil/codeintel/sync/incremental.py +484 -0
- package/src/devcouncil/codeintel/sync/lease.py +96 -0
- package/src/devcouncil/codeintel/sync/scope.py +98 -0
- package/src/devcouncil/council/__init__.py +4 -0
- package/src/devcouncil/council/prompts/__init__.py +4 -0
- package/src/devcouncil/domain/checkpoint_refs.py +17 -0
- package/src/devcouncil/domain/evidence.py +1 -0
- package/src/devcouncil/domain/gap.py +10 -0
- package/src/devcouncil/domain/requirement.py +5 -1
- package/src/devcouncil/domain/task.py +43 -2
- package/src/devcouncil/execution/checkpoints.py +25 -31
- package/src/devcouncil/execution/context_builder.py +15 -44
- package/src/devcouncil/execution/fs_watcher.py +64 -0
- package/src/devcouncil/execution/gated_write.py +203 -0
- package/src/devcouncil/execution/handoff.py +2 -1
- package/src/devcouncil/execution/hook_policy.py +19 -5
- package/src/devcouncil/execution/lease_ops.py +177 -0
- package/src/devcouncil/execution/lease_validation.py +71 -0
- package/src/devcouncil/execution/patch.py +3 -0
- package/src/devcouncil/execution/permissions.py +1 -0
- package/src/devcouncil/execution/policy_engine.py +205 -10
- package/src/devcouncil/execution/prompt_builder.py +278 -33
- package/src/devcouncil/execution/run_trace.py +356 -0
- package/src/devcouncil/execution/shell_session.py +46 -5
- package/src/devcouncil/execution/stop_gate.py +746 -0
- package/src/devcouncil/execution/stop_gate_history.py +113 -0
- package/src/devcouncil/execution/stop_gate_state.py +54 -0
- package/src/devcouncil/execution/stop_gate_verify_cache.py +69 -0
- package/src/devcouncil/execution/task_gate_ops.py +590 -0
- package/src/devcouncil/execution/task_runner.py +19 -0
- package/src/devcouncil/executors/advisor_tool.py +315 -0
- package/src/devcouncil/executors/agent_registry.py +125 -17
- package/src/devcouncil/executors/claude_sdk.py +376 -0
- package/src/devcouncil/executors/coding_cli.py +724 -25
- package/src/devcouncil/executors/mini_swe.py +50 -8
- package/src/devcouncil/executors/native/agent.py +224 -19
- package/src/devcouncil/executors/openhands.py +50 -8
- package/src/devcouncil/executors/transient_retry.py +99 -0
- package/src/devcouncil/gating/checks/clean_git.py +5 -2
- package/src/devcouncil/gating/checks/planned_files_check.py +38 -11
- package/src/devcouncil/gating/checks/secret_scan_check.py +2 -2
- package/src/devcouncil/gating/policy.py +46 -2
- package/src/devcouncil/indexing/ast_matcher.py +41 -4
- package/src/devcouncil/indexing/graph/__init__.py +78 -0
- package/src/devcouncil/indexing/graph/api_routes.py +522 -0
- package/src/devcouncil/indexing/graph/build.py +862 -0
- package/src/devcouncil/indexing/graph/cache.py +329 -0
- package/src/devcouncil/indexing/graph/communities.py +28 -0
- package/src/devcouncil/indexing/graph/cypher.py +107 -0
- package/src/devcouncil/indexing/graph/embeddings.py +194 -0
- package/src/devcouncil/indexing/graph/export.py +381 -0
- package/src/devcouncil/indexing/graph/export_links.py +81 -0
- package/src/devcouncil/indexing/graph/extract_python.py +307 -0
- package/src/devcouncil/indexing/graph/extract_ts.py +1205 -0
- package/src/devcouncil/indexing/graph/intel.py +668 -0
- package/src/devcouncil/indexing/graph/liveness.py +992 -0
- package/src/devcouncil/indexing/graph/okf_export.py +65 -0
- package/src/devcouncil/indexing/graph/pdg/__init__.py +67 -0
- package/src/devcouncil/indexing/graph/pdg/build.py +11 -0
- package/src/devcouncil/indexing/graph/pdg/cdg.py +41 -0
- package/src/devcouncil/indexing/graph/pdg/cfg.py +199 -0
- package/src/devcouncil/indexing/graph/pdg/query.py +21 -0
- package/src/devcouncil/indexing/graph/pdg/reaching_def.py +126 -0
- package/src/devcouncil/indexing/graph/pdg/schema.py +253 -0
- package/src/devcouncil/indexing/graph/pdg/taint.py +154 -0
- package/src/devcouncil/indexing/graph/query.py +302 -0
- package/src/devcouncil/indexing/graph/resolve.py +1020 -0
- package/src/devcouncil/indexing/graph/schema.py +103 -0
- package/src/devcouncil/indexing/graph_index.py +20 -29
- package/src/devcouncil/indexing/lsp.py +57 -25
- package/src/devcouncil/indexing/lsp_client.py +577 -0
- package/src/devcouncil/indexing/map_artifacts.py +355 -0
- package/src/devcouncil/indexing/map_refresh.py +141 -0
- package/src/devcouncil/indexing/repo_mapper.py +1509 -138
- package/src/devcouncil/indexing/semantic_index.py +12 -6
- package/src/devcouncil/indexing/subsystem_map.py +163 -0
- package/src/devcouncil/indexing/ts_imports.py +343 -0
- package/src/devcouncil/indexing/viz.py +960 -0
- package/src/devcouncil/indexing/walk.py +52 -0
- package/src/devcouncil/indexing/wiring.py +1776 -0
- package/src/devcouncil/integrations/actions.py +27 -4
- package/src/devcouncil/integrations/check.py +211 -16
- package/src/devcouncil/integrations/claude_assets.py +209 -12
- package/src/devcouncil/integrations/clients/__init__.py +1 -0
- package/src/devcouncil/integrations/clients/aider.py +52 -0
- package/src/devcouncil/integrations/clients/antigravity.py +87 -0
- package/src/devcouncil/integrations/clients/claude.py +339 -0
- package/src/devcouncil/integrations/clients/codex.py +39 -0
- package/src/devcouncil/integrations/clients/common.py +332 -0
- package/src/devcouncil/integrations/clients/cursor.py +164 -0
- package/src/devcouncil/integrations/clients/gemini.py +49 -0
- package/src/devcouncil/integrations/clients/grok.py +105 -0
- package/src/devcouncil/integrations/clients/hooks.py +500 -0
- package/src/devcouncil/integrations/clients/opencode.py +96 -0
- package/src/devcouncil/integrations/clients/warp.py +75 -0
- package/src/devcouncil/integrations/code_review_graph.py +2 -2
- package/src/devcouncil/integrations/github.py +73 -7
- package/src/devcouncil/integrations/integration_cli.py +197 -0
- package/src/devcouncil/integrations/mcp/handlers/__init__.py +1 -0
- package/src/devcouncil/integrations/mcp/handlers/ast_lsp.py +77 -0
- package/src/devcouncil/integrations/mcp/handlers/checkout.py +50 -0
- package/src/devcouncil/integrations/mcp/handlers/cli_gate.py +43 -0
- package/src/devcouncil/integrations/mcp/handlers/codeintel.py +182 -0
- package/src/devcouncil/integrations/mcp/handlers/debug.py +236 -0
- package/src/devcouncil/integrations/mcp/handlers/evidence.py +70 -0
- package/src/devcouncil/integrations/mcp/handlers/git.py +99 -0
- package/src/devcouncil/integrations/mcp/handlers/graph.py +36 -0
- package/src/devcouncil/integrations/mcp/handlers/handoff.py +53 -0
- package/src/devcouncil/integrations/mcp/handlers/knowledge.py +30 -0
- package/src/devcouncil/integrations/mcp/handlers/lease.py +70 -0
- package/src/devcouncil/integrations/mcp/handlers/live.py +108 -0
- package/src/devcouncil/integrations/mcp/handlers/map.py +676 -0
- package/src/devcouncil/integrations/mcp/handlers/next_task.py +35 -0
- package/src/devcouncil/integrations/mcp/handlers/policy.py +80 -0
- package/src/devcouncil/integrations/mcp/handlers/prompts.py +168 -0
- package/src/devcouncil/integrations/mcp/handlers/provenance.py +101 -0
- package/src/devcouncil/integrations/mcp/handlers/read.py +74 -0
- package/src/devcouncil/integrations/mcp/handlers/router_cache.py +53 -0
- package/src/devcouncil/integrations/mcp/handlers/run.py +53 -0
- package/src/devcouncil/integrations/mcp/handlers/runs.py +84 -0
- package/src/devcouncil/integrations/mcp/handlers/scope.py +56 -0
- package/src/devcouncil/integrations/mcp/handlers/status.py +114 -0
- package/src/devcouncil/integrations/mcp/handlers/task.py +100 -0
- package/src/devcouncil/integrations/mcp/handlers/tool_specs.py +904 -0
- package/src/devcouncil/integrations/mcp/handlers/trace.py +65 -0
- package/src/devcouncil/integrations/mcp/handlers/verify.py +45 -0
- package/src/devcouncil/integrations/mcp/handlers/wiki.py +60 -0
- package/src/devcouncil/integrations/mcp/handlers/write.py +69 -0
- package/src/devcouncil/integrations/mcp/server.py +265 -2391
- package/src/devcouncil/integrations/mcp/util.py +303 -0
- package/src/devcouncil/integrations/setup.py +152 -0
- package/src/devcouncil/knowledge/fetch.py +4 -0
- package/src/devcouncil/knowledge/knowledge_select.py +38 -0
- package/src/devcouncil/knowledge/okf.py +2 -1
- package/src/devcouncil/knowledge/resource_discovery.py +40 -0
- package/src/devcouncil/knowledge/wiki.py +643 -0
- package/src/devcouncil/knowledge/wiki_read.py +87 -0
- package/src/devcouncil/live/cards.py +7 -7
- package/src/devcouncil/live/models.py +4 -1
- package/src/devcouncil/live/reviewer.py +90 -11
- package/src/devcouncil/live/signals.py +4 -2
- package/src/devcouncil/live/tasks.py +12 -3
- package/src/devcouncil/live/transcripts.py +69 -2
- package/src/devcouncil/llm/cache.py +5 -6
- package/src/devcouncil/llm/model_defaults.yaml +10 -10
- package/src/devcouncil/llm/provider.py +647 -73
- package/src/devcouncil/llm/router.py +271 -46
- package/src/devcouncil/llm/semantic_bridge.py +614 -0
- package/src/devcouncil/optimization/gepa_agent.py +6 -4
- package/src/devcouncil/optimization/skillopt.py +9 -5
- package/src/devcouncil/planning/arbiter_service.py +12 -3
- package/src/devcouncil/planning/correction_manifest.py +107 -10
- package/src/devcouncil/planning/plan_difficulty.py +69 -0
- package/src/devcouncil/planning/plan_service.py +5 -2
- package/src/devcouncil/planning/planned_files_reconcile.py +191 -0
- package/src/devcouncil/planning/prompt_enhancer_service.py +6 -5
- package/src/devcouncil/planning/question_conversion.py +56 -0
- package/src/devcouncil/planning/spec_service.py +9 -3
- package/src/devcouncil/repo/ci_scaffold.py +197 -1
- package/src/devcouncil/repo/gitignore.py +1 -2
- package/src/devcouncil/reporting/evidence_export.py +124 -0
- package/src/devcouncil/reporting/evidence_html.py +210 -0
- package/src/devcouncil/reporting/json_report.py +16 -12
- package/src/devcouncil/reporting/markdown_report.py +38 -9
- package/src/devcouncil/reporting/mcp_resources.py +142 -0
- package/src/devcouncil/reporting/report_builder.py +40 -4
- package/src/devcouncil/reporting/task_provenance.py +42 -0
- package/src/devcouncil/reporting/verdict.py +75 -0
- package/src/devcouncil/skills/library/README.md +1 -0
- package/src/devcouncil/skills/library/devcouncil-hero-loop.md +109 -0
- package/src/devcouncil/skills/library/devcouncil-verification.md +109 -0
- package/src/devcouncil/skills/library/devcouncil.md +93 -0
- package/src/devcouncil/skills/registry.py +43 -12
- package/src/devcouncil/storage/db.py +57 -11
- package/src/devcouncil/storage/models.py +6 -0
- package/src/devcouncil/storage/native.py +5 -3
- package/src/devcouncil/storage/repositories.py +50 -18
- package/src/devcouncil/telemetry/context.py +28 -0
- package/src/devcouncil/telemetry/cost.py +4 -5
- package/src/devcouncil/telemetry/logging_setup.py +78 -11
- package/src/devcouncil/telemetry/model_pricing.yaml +7 -0
- package/src/devcouncil/telemetry/stages.py +27 -2
- package/src/devcouncil/telemetry/tracker.py +50 -13
- package/src/devcouncil/ui/dashboard.py +120 -8
- package/src/devcouncil/utils/fsio.py +58 -0
- package/src/devcouncil/utils/git_snapshot.py +112 -0
- package/src/devcouncil/utils/json_persist.py +53 -0
- package/src/devcouncil/utils/proc.py +89 -0
- package/src/devcouncil/verification/acceptance_compiler.py +36 -13
- package/src/devcouncil/verification/ad_hoc_check.py +95 -3
- package/src/devcouncil/verification/checks/__init__.py +41 -0
- package/src/devcouncil/verification/checks/acceptance.py +39 -0
- package/src/devcouncil/verification/checks/acceptance_corpus.py +194 -0
- package/src/devcouncil/verification/checks/acceptance_evidence.py +239 -0
- package/src/devcouncil/verification/checks/command_evidence.py +148 -0
- package/src/devcouncil/verification/checks/compiled_acceptance.py +179 -0
- package/src/devcouncil/verification/checks/corpus_stale.py +124 -0
- package/src/devcouncil/verification/checks/corpus_verification.py +9 -0
- package/src/devcouncil/verification/checks/dead_symbols.py +360 -0
- package/src/devcouncil/verification/checks/diff_coverage_gate.py +101 -0
- package/src/devcouncil/verification/checks/doc_code_ref.py +79 -0
- package/src/devcouncil/verification/checks/liveness_ratchet.py +336 -0
- package/src/devcouncil/verification/checks/orphan_diff.py +104 -0
- package/src/devcouncil/verification/checks/planned_files.py +98 -0
- package/src/devcouncil/verification/checks/semantic_diff.py +241 -0
- package/src/devcouncil/verification/checks/stale_map.py +80 -0
- package/src/devcouncil/verification/checks/stub_scan.py +71 -0
- package/src/devcouncil/verification/checks/subsystem_boundary.py +103 -0
- package/src/devcouncil/verification/checks/wiring.py +216 -0
- package/src/devcouncil/verification/claims/__init__.py +23 -0
- package/src/devcouncil/verification/claims/checks.py +395 -0
- package/src/devcouncil/verification/claims/mapper.py +168 -0
- package/src/devcouncil/verification/claims/models.py +39 -0
- package/src/devcouncil/verification/claims/transcript.py +92 -0
- package/src/devcouncil/verification/claims/verdict.py +88 -0
- package/src/devcouncil/verification/command_evidence.py +170 -0
- package/src/devcouncil/verification/command_malformation.py +147 -0
- package/src/devcouncil/verification/command_runner.py +164 -0
- package/src/devcouncil/verification/coverage_measurement.py +292 -0
- package/src/devcouncil/verification/diff_coverage.py +151 -0
- package/src/devcouncil/verification/difficulty.py +296 -0
- package/src/devcouncil/verification/effort_heuristics.py +178 -0
- package/src/devcouncil/verification/gap_ids.py +63 -0
- package/src/devcouncil/verification/gate_cache.py +194 -0
- package/src/devcouncil/verification/gate_selector.py +344 -0
- package/src/devcouncil/verification/git_diff_fallback.py +272 -0
- package/src/devcouncil/verification/implementation_reviewer.py +13 -0
- package/src/devcouncil/verification/incremental_check.py +241 -0
- package/src/devcouncil/verification/next_actions.py +60 -1
- package/src/devcouncil/verification/rigor_analytics.py +130 -0
- package/src/devcouncil/verification/sandbox.py +38 -11
- package/src/devcouncil/verification/stub_detector.py +369 -0
- package/src/devcouncil/verification/test_resolver.py +67 -1
- package/src/devcouncil/verification/verifier.py +137 -1666
- package/src/devcouncil/verification/verify_orchestration.py +610 -0
- package/src/devcouncil/verification/verify_setup.py +176 -0
- package/src/devcouncil/verification/wiki_refresh.py +208 -0
- package/src/semantic_layer/__init__.py +58 -0
- package/src/semantic_layer/benchmark.py +75 -0
- package/src/semantic_layer/cache.py +290 -0
- package/src/semantic_layer/compressor.py +137 -0
- package/src/semantic_layer/config.py +75 -0
- package/src/semantic_layer/embeddings.py +69 -0
- package/src/semantic_layer/llm_backends.py +99 -0
- package/src/semantic_layer/pipeline.py +111 -0
- package/src/semantic_layer/router.py +128 -0
- package/src/semantic_layer/tuner.py +72 -0
- package/uv.lock +973 -9
- package/src/devcouncil/artifacts/migrations.py +0 -20
- package/src/devcouncil/artifacts/schemas.py +0 -23
- package/src/devcouncil/artifacts/serializer.py +0 -21
- package/src/devcouncil/integrations/gitnexus.py +0 -70
- 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 =
|
|
124
|
+
proc = _run_sandboxed(
|
|
96
125
|
["docker", "run", "--rm", "-v", f"{self.project_root}:/work", "-w", "/work", image, "sh", "-c", setup_cmd],
|
|
97
|
-
|
|
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 =
|
|
134
|
+
proc = _run_sandboxed(
|
|
107
135
|
["docker", "run", "--rm", "-v", f"{self.project_root}:/work", "-w", "/work", image, "sh", "-c", cmd],
|
|
108
|
-
|
|
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 =
|
|
167
|
+
proc = _run_sandboxed(
|
|
140
168
|
["nix", "develop", f".#{attr}", "-c", "sh", "-c", cmd],
|
|
141
169
|
cwd=self.project_root,
|
|
142
|
-
|
|
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"):
|