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,101 @@
|
|
|
1
|
+
"""Diff↔coverage gate extracted from Verifier.verify_task."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Callable, List, Optional
|
|
7
|
+
|
|
8
|
+
from devcouncil.domain.evidence import DiffCoverageEvidence
|
|
9
|
+
from devcouncil.domain.gap import Gap
|
|
10
|
+
from devcouncil.domain.task import Task
|
|
11
|
+
from devcouncil.verification import diff_coverage as dc
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class DiffCoverageGateResult:
|
|
16
|
+
gaps: List[Gap]
|
|
17
|
+
evidence: List[DiffCoverageEvidence]
|
|
18
|
+
coverage_measured: bool
|
|
19
|
+
coverage_skipped_reason: Optional[str]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run_diff_coverage_gate(
|
|
23
|
+
*,
|
|
24
|
+
task: Task,
|
|
25
|
+
diff_content: str,
|
|
26
|
+
any_passing: bool,
|
|
27
|
+
enforce_cov: bool,
|
|
28
|
+
measure_cov: bool,
|
|
29
|
+
min_ratio: float,
|
|
30
|
+
measure_diff_coverage: Callable[[Task, str], dc.DiffCoverageResult],
|
|
31
|
+
coverage_target_commands: Callable[[Task], List[str]],
|
|
32
|
+
next_gap_id: Callable[[str, str], str],
|
|
33
|
+
) -> DiffCoverageGateResult:
|
|
34
|
+
"""Run the diff↔coverage gate and return gaps + evidence."""
|
|
35
|
+
gaps: List[Gap] = []
|
|
36
|
+
evidence: List[DiffCoverageEvidence] = []
|
|
37
|
+
coverage_measured = False
|
|
38
|
+
coverage_skipped_reason: Optional[str] = None
|
|
39
|
+
|
|
40
|
+
if not measure_cov:
|
|
41
|
+
coverage_skipped_reason = "diff coverage disabled in config"
|
|
42
|
+
elif not diff_content:
|
|
43
|
+
coverage_skipped_reason = "no diff to measure"
|
|
44
|
+
elif not task.acceptance_criterion_ids:
|
|
45
|
+
coverage_skipped_reason = "task has no acceptance criteria"
|
|
46
|
+
elif not any_passing:
|
|
47
|
+
coverage_skipped_reason = "no passing verification command to instrument"
|
|
48
|
+
|
|
49
|
+
if measure_cov and diff_content and task.acceptance_criterion_ids and any_passing:
|
|
50
|
+
cov = measure_diff_coverage(task, diff_content)
|
|
51
|
+
if not cov.measured:
|
|
52
|
+
coverage_skipped_reason = cov.reason or "diff coverage could not be measured"
|
|
53
|
+
if cov.measured:
|
|
54
|
+
coverage_measured = True
|
|
55
|
+
coverage_skipped_reason = None
|
|
56
|
+
evidence.append(DiffCoverageEvidence(
|
|
57
|
+
task_id=task.id,
|
|
58
|
+
tool=cov.tool,
|
|
59
|
+
measured=True,
|
|
60
|
+
changed_lines=cov.changed_executable_lines,
|
|
61
|
+
covered_lines=cov.covered_changed_lines,
|
|
62
|
+
coverage_ratio=cov.ratio,
|
|
63
|
+
uncovered_by_file=cov.uncovered_by_file,
|
|
64
|
+
absent_files=cov.absent_files,
|
|
65
|
+
summary=cov.summary(),
|
|
66
|
+
))
|
|
67
|
+
failing = cov.covered_changed_lines == 0 if min_ratio <= 0 else cov.ratio < min_ratio
|
|
68
|
+
if failing:
|
|
69
|
+
first_file = next(iter(cov.uncovered_by_file), None)
|
|
70
|
+
first_lines = cov.uncovered_by_file.get(first_file or "", [])
|
|
71
|
+
target_cmds = coverage_target_commands(task)
|
|
72
|
+
gaps.append(Gap(
|
|
73
|
+
id=next_gap_id(task.id, "DIFFCOV"),
|
|
74
|
+
severity="high" if enforce_cov else "medium",
|
|
75
|
+
gap_type="diff_not_exercised",
|
|
76
|
+
task_id=task.id,
|
|
77
|
+
description=(
|
|
78
|
+
f"Verification commands passed but exercised "
|
|
79
|
+
f"{cov.covered_changed_lines}/{cov.changed_executable_lines} changed line(s): "
|
|
80
|
+
f"{cov.summary()}. The acceptance criteria are not proven because the new "
|
|
81
|
+
"logic was never executed by the tests."
|
|
82
|
+
),
|
|
83
|
+
evidence=[cov.summary()] + [
|
|
84
|
+
f"{path}: lines {lines}" for path, lines in list(cov.uncovered_by_file.items())[:5]
|
|
85
|
+
],
|
|
86
|
+
recommended_fix=(
|
|
87
|
+
"Add or extend a test that executes the changed lines, then re-verify. "
|
|
88
|
+
"A passing suite that does not run the new code is not acceptance evidence."
|
|
89
|
+
),
|
|
90
|
+
blocking=enforce_cov,
|
|
91
|
+
file=first_file,
|
|
92
|
+
line=first_lines[0] if first_lines else None,
|
|
93
|
+
suggested_command=target_cmds[0] if target_cmds else None,
|
|
94
|
+
))
|
|
95
|
+
|
|
96
|
+
return DiffCoverageGateResult(
|
|
97
|
+
gaps=gaps,
|
|
98
|
+
evidence=evidence,
|
|
99
|
+
coverage_measured=coverage_measured,
|
|
100
|
+
coverage_skipped_reason=coverage_skipped_reason,
|
|
101
|
+
)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Heuristic doc→code reference gate for changed documentation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Callable, List
|
|
9
|
+
|
|
10
|
+
from devcouncil.domain.gap import Gap
|
|
11
|
+
from devcouncil.domain.task import Task
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
_CODE_REF_RE = re.compile(
|
|
16
|
+
r"(?<![`\w])(?:src|tests|docs)/[\w./-]+\.(?:py|md|ts|tsx|js|jsx|json|yaml|yml|toml)"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _norm(path: str) -> str:
|
|
21
|
+
return path.replace("\\", "/").lstrip("./")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _broken_refs_in_text(project_root: Path, text: str) -> List[str]:
|
|
25
|
+
broken: List[str] = []
|
|
26
|
+
for match in _CODE_REF_RE.finditer(text):
|
|
27
|
+
ref = match.group(0)
|
|
28
|
+
if not (project_root / ref).is_file():
|
|
29
|
+
broken.append(ref)
|
|
30
|
+
return broken
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def detect_doc_code_ref_gaps(
|
|
34
|
+
*,
|
|
35
|
+
task: Task,
|
|
36
|
+
project_root: Path,
|
|
37
|
+
changed_files: List[str],
|
|
38
|
+
diff_content: str,
|
|
39
|
+
next_gap_id: Callable[[str, str], str],
|
|
40
|
+
doc_code_ref_enabled: bool = True,
|
|
41
|
+
doc_code_ref_blocking: bool = False,
|
|
42
|
+
) -> List[Gap]:
|
|
43
|
+
if not doc_code_ref_enabled:
|
|
44
|
+
return []
|
|
45
|
+
gaps: List[Gap] = []
|
|
46
|
+
try:
|
|
47
|
+
doc_paths = sorted(
|
|
48
|
+
p for p in changed_files
|
|
49
|
+
if _norm(p).endswith(".md") or _norm(p).startswith("docs/")
|
|
50
|
+
)
|
|
51
|
+
for rel in doc_paths:
|
|
52
|
+
path = project_root / rel
|
|
53
|
+
if not path.is_file():
|
|
54
|
+
continue
|
|
55
|
+
try:
|
|
56
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
57
|
+
except OSError:
|
|
58
|
+
continue
|
|
59
|
+
broken = _broken_refs_in_text(project_root, text)
|
|
60
|
+
if not broken:
|
|
61
|
+
continue
|
|
62
|
+
gaps.append(Gap(
|
|
63
|
+
id=next_gap_id(task.id, f"DOCREF-{rel}"),
|
|
64
|
+
severity="high" if doc_code_ref_blocking else "medium",
|
|
65
|
+
gap_type="doc_code_ref",
|
|
66
|
+
task_id=task.id,
|
|
67
|
+
description=(
|
|
68
|
+
f"Changed doc `{rel}` references missing code paths: "
|
|
69
|
+
f"{', '.join(broken[:5])}"
|
|
70
|
+
+ (" …" if len(broken) > 5 else "")
|
|
71
|
+
),
|
|
72
|
+
evidence=[rel, *broken[:8]],
|
|
73
|
+
recommended_fix="Fix or remove broken code references in the changed documentation.",
|
|
74
|
+
blocking=doc_code_ref_blocking,
|
|
75
|
+
file=rel,
|
|
76
|
+
))
|
|
77
|
+
except Exception:
|
|
78
|
+
logger.debug("detect_doc_code_ref_gaps failed", exc_info=True)
|
|
79
|
+
return gaps
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""Liveness ratchet: flag existing code newly stranded by this task.
|
|
2
|
+
|
|
3
|
+
Diff-scoped gates catch *new* unwired files / dead symbols. This check catches
|
|
4
|
+
the complementary failure: a task removes the last importer/caller of *existing*
|
|
5
|
+
code, leaving it stranded relative to the checkout-time baseline.
|
|
6
|
+
|
|
7
|
+
Skip when no complete baseline exists (pre-feature tasks). Never raises.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Callable, List, Mapping, Optional, Set
|
|
15
|
+
|
|
16
|
+
from devcouncil.domain.gap import Gap
|
|
17
|
+
from devcouncil.domain.task import Task
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _norm(path: str) -> str:
|
|
23
|
+
s = str(path).replace("\\", "/")
|
|
24
|
+
while s.startswith("./"):
|
|
25
|
+
s = s[2:]
|
|
26
|
+
return s
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _as_str_set(values: Any) -> Set[str]:
|
|
30
|
+
if not isinstance(values, list):
|
|
31
|
+
return set()
|
|
32
|
+
return {_norm(str(v)) for v in values if v is not None and str(v).strip()}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _as_list(value: object) -> list[Any]:
|
|
36
|
+
return list(value) if isinstance(value, list) else []
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _symbol_key(entry: str) -> str:
|
|
40
|
+
"""Normalize ``path:line name`` / ``path:line:name`` to a stable identity key.
|
|
41
|
+
|
|
42
|
+
Prefer ``path::name`` so line-number drift does not create false regressions.
|
|
43
|
+
"""
|
|
44
|
+
text = str(entry).strip()
|
|
45
|
+
if not text:
|
|
46
|
+
return ""
|
|
47
|
+
# Common map format: "path/to.py:12 symbol_name"
|
|
48
|
+
if " " in text:
|
|
49
|
+
loc, _, name = text.partition(" ")
|
|
50
|
+
path = loc.rsplit(":", 1)[0] if ":" in loc else loc
|
|
51
|
+
return f"{_norm(path)}::{name.strip()}"
|
|
52
|
+
# Fallback: path:line:name or path:name
|
|
53
|
+
parts = text.split(":")
|
|
54
|
+
if len(parts) >= 3 and parts[-2].isdigit():
|
|
55
|
+
return f"{_norm(parts[0])}::{parts[-1].strip()}"
|
|
56
|
+
if len(parts) == 2:
|
|
57
|
+
return f"{_norm(parts[0])}::{parts[1].strip()}"
|
|
58
|
+
return _norm(text)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _symbol_display(entry: str) -> tuple[str, Optional[int], str]:
|
|
62
|
+
"""Return (path, line|None, name) for gap fields."""
|
|
63
|
+
text = str(entry).strip()
|
|
64
|
+
if " " in text:
|
|
65
|
+
loc, _, name = text.partition(" ")
|
|
66
|
+
if ":" in loc:
|
|
67
|
+
path, _, line_s = loc.rpartition(":")
|
|
68
|
+
line = int(line_s) if line_s.isdigit() else None
|
|
69
|
+
return _norm(path), line, name.strip()
|
|
70
|
+
return _norm(loc), None, name.strip()
|
|
71
|
+
parts = text.split(":")
|
|
72
|
+
if len(parts) >= 3 and parts[-2].isdigit():
|
|
73
|
+
return _norm(parts[0]), int(parts[-2]), parts[-1].strip()
|
|
74
|
+
if len(parts) == 2:
|
|
75
|
+
return _norm(parts[0]), None, parts[1].strip()
|
|
76
|
+
return _norm(text), None, ""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def baseline_is_complete(baseline: Mapping[str, Any] | None) -> bool:
|
|
80
|
+
"""True when baseline exists and carries the write-completed marker."""
|
|
81
|
+
return bool(baseline and isinstance(baseline, Mapping) and baseline.get("complete") is True)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _liveness_roots_unreliable(snap: Mapping[str, Any] | None) -> bool:
|
|
85
|
+
"""True when unreachable lists must not be trusted (empty roots / flag)."""
|
|
86
|
+
if not snap or not isinstance(snap, Mapping):
|
|
87
|
+
return True
|
|
88
|
+
if snap.get("liveness_unreachable_unreliable") is True:
|
|
89
|
+
return True
|
|
90
|
+
roots = snap.get("entry_roots")
|
|
91
|
+
if not isinstance(roots, list) or not roots:
|
|
92
|
+
return True
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def detect_liveness_regressions(
|
|
97
|
+
baseline: Mapping[str, Any] | None,
|
|
98
|
+
current: Mapping[str, Any] | None,
|
|
99
|
+
task_added_files: Set[str] | None = None,
|
|
100
|
+
*,
|
|
101
|
+
task: Task | None = None,
|
|
102
|
+
next_gap_id: Callable[[str, str], str] | None = None,
|
|
103
|
+
blocking: bool = False,
|
|
104
|
+
diff_added_lines: Mapping[str, Set[int]] | None = None,
|
|
105
|
+
) -> List[Gap]:
|
|
106
|
+
"""Diff baseline vs current liveness; flag newly stranded pre-existing code.
|
|
107
|
+
|
|
108
|
+
Files/symbols added by this task are excluded (covered by ``unwired_file`` /
|
|
109
|
+
``dead_symbol``). Dead symbols must have existed at baseline (``symbol_index``)
|
|
110
|
+
— a brand-new unused def in an existing file is not ``stranded_code``.
|
|
111
|
+
|
|
112
|
+
Returns an empty list when ``baseline`` is missing or incomplete.
|
|
113
|
+
When either side has empty entry roots / unreliable unreachable, skip
|
|
114
|
+
unreachable diffs (avoids empty-root stranded floods).
|
|
115
|
+
"""
|
|
116
|
+
gaps: List[Gap] = []
|
|
117
|
+
if not baseline_is_complete(baseline):
|
|
118
|
+
return gaps
|
|
119
|
+
if not current or not isinstance(current, Mapping):
|
|
120
|
+
return gaps
|
|
121
|
+
assert baseline is not None
|
|
122
|
+
try:
|
|
123
|
+
added = {_norm(p) for p in (task_added_files or set())}
|
|
124
|
+
task_id = task.id if task is not None else "TASK"
|
|
125
|
+
gap_id = next_gap_id or (lambda tid, kind: f"{tid}-{kind}-1")
|
|
126
|
+
added_lines = {
|
|
127
|
+
_norm(p): set(lines)
|
|
128
|
+
for p, lines in (diff_added_lines or {}).items()
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
base_unwired = _as_str_set(baseline.get("unwired_candidates"))
|
|
132
|
+
base_unreachable = _as_str_set(baseline.get("unreachable_files"))
|
|
133
|
+
cur_unwired = _as_str_set(current.get("unwired_candidates"))
|
|
134
|
+
cur_unreachable = _as_str_set(current.get("unreachable_files"))
|
|
135
|
+
|
|
136
|
+
newly_unwired = (cur_unwired - base_unwired) - added
|
|
137
|
+
skip_unreachable = (
|
|
138
|
+
_liveness_roots_unreliable(baseline) or _liveness_roots_unreliable(current)
|
|
139
|
+
)
|
|
140
|
+
newly_unreachable = (
|
|
141
|
+
set()
|
|
142
|
+
if skip_unreachable
|
|
143
|
+
else (cur_unreachable - base_unreachable) - added
|
|
144
|
+
)
|
|
145
|
+
stranded_files = sorted(newly_unwired | newly_unreachable)
|
|
146
|
+
|
|
147
|
+
for path in stranded_files:
|
|
148
|
+
reasons = []
|
|
149
|
+
if path in newly_unwired:
|
|
150
|
+
reasons.append("unwired")
|
|
151
|
+
if path in newly_unreachable:
|
|
152
|
+
reasons.append("unreachable")
|
|
153
|
+
reason = "/".join(reasons) or "stranded"
|
|
154
|
+
gaps.append(Gap(
|
|
155
|
+
id=gap_id(task_id, "STRAND"),
|
|
156
|
+
severity="high" if blocking else "medium",
|
|
157
|
+
gap_type="stranded_code",
|
|
158
|
+
task_id=task_id,
|
|
159
|
+
description=(
|
|
160
|
+
f"Pre-existing file `{path}` became {reason} during this task "
|
|
161
|
+
"(lost its last production importer/reachability path)."
|
|
162
|
+
),
|
|
163
|
+
evidence=[path, f"regression:{reason}"],
|
|
164
|
+
recommended_fix=(
|
|
165
|
+
"Restore the import/call that kept this module live, or delete "
|
|
166
|
+
"the stranded module if it is intentionally unused."
|
|
167
|
+
),
|
|
168
|
+
blocking=blocking,
|
|
169
|
+
file=path,
|
|
170
|
+
))
|
|
171
|
+
|
|
172
|
+
# Symbol-level diff only when baseline was recorded with the same scan
|
|
173
|
+
# algorithm — stale/missing scan_version would otherwise flag every newly
|
|
174
|
+
# detected dead symbol as stranded_code after a detector hardening.
|
|
175
|
+
from devcouncil.indexing.wiring import LIVENESS_SCAN_VERSION
|
|
176
|
+
|
|
177
|
+
if baseline.get("scan_version") != LIVENESS_SCAN_VERSION:
|
|
178
|
+
return gaps
|
|
179
|
+
|
|
180
|
+
base_syms = {
|
|
181
|
+
_symbol_key(s): str(s)
|
|
182
|
+
for s in (baseline.get("dead_symbol_candidates") or [])
|
|
183
|
+
if isinstance(s, str) and _symbol_key(s)
|
|
184
|
+
}
|
|
185
|
+
cur_syms = {
|
|
186
|
+
_symbol_key(s): str(s)
|
|
187
|
+
for s in (current.get("dead_symbol_candidates") or [])
|
|
188
|
+
if isinstance(s, str) and _symbol_key(s)
|
|
189
|
+
}
|
|
190
|
+
# Symbols known at checkout. Without this index (legacy baselines), refuse
|
|
191
|
+
# to flag new dead symbols — avoids double-firing on brand-new defs.
|
|
192
|
+
base_index_raw = baseline.get("symbol_index") or []
|
|
193
|
+
base_index = {
|
|
194
|
+
_norm(str(k)) if "::" in str(k) else _symbol_key(str(k))
|
|
195
|
+
for k in base_index_raw
|
|
196
|
+
if k
|
|
197
|
+
}
|
|
198
|
+
# Normalize path::name keys
|
|
199
|
+
base_index_norm: Set[str] = set()
|
|
200
|
+
for k in base_index:
|
|
201
|
+
if "::" in k:
|
|
202
|
+
path_part, _, name = k.partition("::")
|
|
203
|
+
base_index_norm.add(f"{_norm(path_part)}::{name}")
|
|
204
|
+
else:
|
|
205
|
+
base_index_norm.add(k)
|
|
206
|
+
|
|
207
|
+
for key in sorted(set(cur_syms) - set(base_syms)):
|
|
208
|
+
entry = cur_syms[key]
|
|
209
|
+
path, line, name = _symbol_display(entry)
|
|
210
|
+
if path in added:
|
|
211
|
+
continue
|
|
212
|
+
# Require the symbol existed at baseline (was live then, dead now).
|
|
213
|
+
if not base_index_norm or key not in base_index_norm:
|
|
214
|
+
continue
|
|
215
|
+
# Defining line added in this task's diff → new symbol, not stranded.
|
|
216
|
+
if line is not None and line in added_lines.get(path, ()):
|
|
217
|
+
continue
|
|
218
|
+
label = name or entry
|
|
219
|
+
gaps.append(Gap(
|
|
220
|
+
id=gap_id(task_id, "STRANDSYM"),
|
|
221
|
+
severity="high" if blocking else "medium",
|
|
222
|
+
gap_type="stranded_code",
|
|
223
|
+
task_id=task_id,
|
|
224
|
+
description=(
|
|
225
|
+
f"Pre-existing symbol `{label}` at {path}"
|
|
226
|
+
f"{':' + str(line) if line else ''} became unreferenced "
|
|
227
|
+
"during this task (lost its last caller)."
|
|
228
|
+
),
|
|
229
|
+
evidence=[entry, f"symbol:{label}", "regression:dead_symbol"],
|
|
230
|
+
recommended_fix=(
|
|
231
|
+
"Restore the import/call that referenced this symbol, or delete "
|
|
232
|
+
"the stranded module/symbol if it is intentionally unused."
|
|
233
|
+
),
|
|
234
|
+
blocking=blocking,
|
|
235
|
+
file=path,
|
|
236
|
+
line=line,
|
|
237
|
+
))
|
|
238
|
+
except Exception:
|
|
239
|
+
logger.debug("detect_liveness_regressions failed; degrading to zero gaps", exc_info=True)
|
|
240
|
+
return []
|
|
241
|
+
return gaps
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def load_liveness_baseline(project_root: Path, task_id: str) -> Optional[dict]:
|
|
245
|
+
"""Load checkout-time liveness baseline; None when absent, unreadable, or incomplete."""
|
|
246
|
+
path = project_root / ".devcouncil" / "liveness_baseline" / f"{task_id}.json"
|
|
247
|
+
if not path.is_file():
|
|
248
|
+
return None
|
|
249
|
+
try:
|
|
250
|
+
from devcouncil.utils.json_persist import read_json
|
|
251
|
+
|
|
252
|
+
data = read_json(path)
|
|
253
|
+
if not isinstance(data, dict):
|
|
254
|
+
return None
|
|
255
|
+
if data.get("complete") is not True:
|
|
256
|
+
logger.warning(
|
|
257
|
+
"liveness baseline for %s is incomplete; treating as missing",
|
|
258
|
+
task_id,
|
|
259
|
+
)
|
|
260
|
+
return None
|
|
261
|
+
return data
|
|
262
|
+
except Exception:
|
|
263
|
+
logger.debug("failed to load liveness baseline for %s", task_id, exc_info=True)
|
|
264
|
+
return None
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def delete_liveness_baseline(project_root: Path, task_id: str) -> bool:
|
|
268
|
+
"""Remove the checkout baseline for ``task_id``. Returns True when deleted."""
|
|
269
|
+
path = project_root / ".devcouncil" / "liveness_baseline" / f"{task_id}.json"
|
|
270
|
+
try:
|
|
271
|
+
if path.is_file():
|
|
272
|
+
path.unlink()
|
|
273
|
+
return True
|
|
274
|
+
except OSError:
|
|
275
|
+
logger.debug("failed to delete liveness baseline for %s", task_id, exc_info=True)
|
|
276
|
+
return False
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def snapshot_liveness_baseline(
|
|
280
|
+
project_root: Path,
|
|
281
|
+
task_id: str,
|
|
282
|
+
*,
|
|
283
|
+
reset: bool = False,
|
|
284
|
+
) -> Optional[Path]:
|
|
285
|
+
"""Write-once uncapped liveness snapshot for ``task_id``.
|
|
286
|
+
|
|
287
|
+
Skips when a complete baseline already exists unless ``reset=True``. Writes
|
|
288
|
+
only after a full successful scan and marks ``complete: true`` so partial
|
|
289
|
+
failures never become ratchet inputs. Returns the path, or None on failure.
|
|
290
|
+
Never raises.
|
|
291
|
+
"""
|
|
292
|
+
try:
|
|
293
|
+
from devcouncil.indexing.repo_mapper import RepoMapper
|
|
294
|
+
from devcouncil.utils.json_persist import write_json
|
|
295
|
+
|
|
296
|
+
out_dir = project_root / ".devcouncil" / "liveness_baseline"
|
|
297
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
298
|
+
out_path = out_dir / f"{task_id}.json"
|
|
299
|
+
|
|
300
|
+
if out_path.is_file() and not reset:
|
|
301
|
+
try:
|
|
302
|
+
from devcouncil.utils.json_persist import read_json
|
|
303
|
+
|
|
304
|
+
existing = read_json(out_path)
|
|
305
|
+
if isinstance(existing, dict) and existing.get("complete") is True:
|
|
306
|
+
return out_path
|
|
307
|
+
except Exception:
|
|
308
|
+
pass
|
|
309
|
+
# Incomplete/corrupt on disk — fall through and rewrite.
|
|
310
|
+
|
|
311
|
+
mapper = RepoMapper(project_root)
|
|
312
|
+
snap = mapper.liveness_snapshot()
|
|
313
|
+
from devcouncil.indexing.wiring import LIVENESS_SCAN_VERSION
|
|
314
|
+
|
|
315
|
+
unreliable = bool(snap.get("liveness_unreachable_unreliable")) or not _as_list(
|
|
316
|
+
snap.get("entry_roots")
|
|
317
|
+
)
|
|
318
|
+
# Empty-root scans must not become ratchet baselines (would flood or
|
|
319
|
+
# falsely look "clean" after fail-soft unreachable=[]).
|
|
320
|
+
payload: dict[str, Any] = {
|
|
321
|
+
"unwired_candidates": _as_list(snap.get("unwired_candidates")),
|
|
322
|
+
"unreachable_files": _as_list(snap.get("unreachable_files")),
|
|
323
|
+
"dead_symbol_candidates": _as_list(snap.get("dead_symbol_candidates")),
|
|
324
|
+
"entry_roots": _as_list(snap.get("entry_roots")),
|
|
325
|
+
"symbol_index": _as_list(snap.get("symbol_index")),
|
|
326
|
+
"liveness_unreachable_unreliable": unreliable,
|
|
327
|
+
"generated_head": mapper._git_head(),
|
|
328
|
+
"source": "fresh_scan",
|
|
329
|
+
"scan_version": LIVENESS_SCAN_VERSION,
|
|
330
|
+
"complete": not unreliable,
|
|
331
|
+
}
|
|
332
|
+
write_json(out_path, payload)
|
|
333
|
+
return out_path if not unreliable else None
|
|
334
|
+
except Exception:
|
|
335
|
+
logger.debug("snapshot_liveness_baseline failed for %s", task_id, exc_info=True)
|
|
336
|
+
return None
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Orphan-diff detection helpers extracted from Verifier."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Callable, List, Tuple
|
|
9
|
+
|
|
10
|
+
from devcouncil.domain.gap import Gap
|
|
11
|
+
from devcouncil.domain.task import Task
|
|
12
|
+
from devcouncil.indexing.wiring import is_test_path
|
|
13
|
+
from devcouncil.verification.stub_detector import detect_stubs
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def classify_change_paths(
|
|
19
|
+
project_root: Path,
|
|
20
|
+
changed_files: List[str],
|
|
21
|
+
get_untracked_files: Callable[[], List[str]],
|
|
22
|
+
) -> Tuple[List[str], List[str]]:
|
|
23
|
+
"""Return (added, deleted) paths intersecting changed_files."""
|
|
24
|
+
changed_set = set(changed_files)
|
|
25
|
+
added = set(get_untracked_files())
|
|
26
|
+
deleted: set[str] = set()
|
|
27
|
+
try:
|
|
28
|
+
from devcouncil.utils.proc import GIT_TIMEOUT
|
|
29
|
+
|
|
30
|
+
output = subprocess.check_output(
|
|
31
|
+
["git", "diff", "HEAD", "--name-status"],
|
|
32
|
+
cwd=project_root,
|
|
33
|
+
stderr=subprocess.DEVNULL,
|
|
34
|
+
timeout=GIT_TIMEOUT,
|
|
35
|
+
).decode("utf-8", errors="replace").splitlines()
|
|
36
|
+
for line in output:
|
|
37
|
+
parts = line.split("\t")
|
|
38
|
+
if len(parts) < 2:
|
|
39
|
+
continue
|
|
40
|
+
status = parts[0]
|
|
41
|
+
path = parts[-1].replace("\\", "/")
|
|
42
|
+
if status.startswith("A"):
|
|
43
|
+
added.add(path)
|
|
44
|
+
elif status.startswith("D"):
|
|
45
|
+
deleted.add(path)
|
|
46
|
+
except Exception as e:
|
|
47
|
+
logger.debug("Failed to classify changed files: %s", e)
|
|
48
|
+
return sorted(added & changed_set), sorted(deleted & changed_set)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def detect_orphan_diff_gaps(
|
|
52
|
+
*,
|
|
53
|
+
task: Task,
|
|
54
|
+
changed_files: List[str],
|
|
55
|
+
planned_paths: set[str],
|
|
56
|
+
diff_content: str,
|
|
57
|
+
project_root: Path,
|
|
58
|
+
get_untracked_files: Callable[[], List[str]],
|
|
59
|
+
next_gap_id: Callable[[str, str], str],
|
|
60
|
+
classify_fn: Callable[[List[str]], Tuple[List[str], List[str]]] | None = None,
|
|
61
|
+
) -> List[Gap]:
|
|
62
|
+
"""Detect orphan-diff gaps for files changed outside the task plan."""
|
|
63
|
+
gaps: List[Gap] = []
|
|
64
|
+
if classify_fn is not None:
|
|
65
|
+
orphan_added, _orphan_deleted = classify_fn(changed_files)
|
|
66
|
+
else:
|
|
67
|
+
orphan_added, _orphan_deleted = classify_change_paths(
|
|
68
|
+
project_root, changed_files, get_untracked_files,
|
|
69
|
+
)
|
|
70
|
+
assert_free_test_files: set[str] = set()
|
|
71
|
+
if diff_content:
|
|
72
|
+
for finding in detect_stubs(project_root, diff_content, honor_allow_stub=False):
|
|
73
|
+
if "no assertions" in finding.reason:
|
|
74
|
+
assert_free_test_files.add(finding.file)
|
|
75
|
+
for cf in changed_files:
|
|
76
|
+
if cf not in planned_paths:
|
|
77
|
+
new_test_file = (
|
|
78
|
+
cf in orphan_added
|
|
79
|
+
and is_test_path(cf)
|
|
80
|
+
and cf not in assert_free_test_files
|
|
81
|
+
)
|
|
82
|
+
gaps.append(Gap(
|
|
83
|
+
id=next_gap_id(task.id, f"ORPHAN-{cf}"),
|
|
84
|
+
severity="medium" if new_test_file else "high",
|
|
85
|
+
gap_type="orphan_diff",
|
|
86
|
+
task_id=task.id,
|
|
87
|
+
description=(
|
|
88
|
+
f"New test file {cf} was added but not planned for this task "
|
|
89
|
+
"(advisory: added tests cannot change shipped behavior)."
|
|
90
|
+
if new_test_file else
|
|
91
|
+
f"File {cf} was modified but not planned for this task."
|
|
92
|
+
),
|
|
93
|
+
evidence=[cf],
|
|
94
|
+
recommended_fix=(
|
|
95
|
+
f"Append {cf} with `dev scope update <task_id> --lease-token <token> "
|
|
96
|
+
f"--planned-file {cf}` (or fold the tests into a planned test file)."
|
|
97
|
+
if new_test_file else
|
|
98
|
+
f"Revert changes to {cf} or append it with "
|
|
99
|
+
f"`dev scope update <task_id> --lease-token <token> --planned-file {cf}`."
|
|
100
|
+
),
|
|
101
|
+
blocking=not new_test_file,
|
|
102
|
+
file=cf,
|
|
103
|
+
))
|
|
104
|
+
return gaps
|