devcouncil 0.3.0 → 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,194 @@
|
|
|
1
|
+
"""Content-hash keyed cache of green gate results for incremental verification.
|
|
2
|
+
|
|
3
|
+
Persisted at ``.devcouncil/cache/gate_results.json``. A gate is cached green only when
|
|
4
|
+
it *passed*; the cache key is a hash over the command string plus the byte content of
|
|
5
|
+
the gate's declared inputs (see :class:`~devcouncil.verification.gate_selector.GateSpec`).
|
|
6
|
+
On the next run, a gate whose input hash matches a stored green entry is skipped — so an
|
|
7
|
+
edit to file A never re-runs the gate that only depends on file B.
|
|
8
|
+
|
|
9
|
+
Failing results are recorded too (so the sidecar can show a cached FAIL without a
|
|
10
|
+
re-run), but ``is_green`` is the only thing that authorizes a *skip*; a cached failure
|
|
11
|
+
is always re-run so a fix is picked up immediately.
|
|
12
|
+
|
|
13
|
+
Best-effort and crash-safe: a corrupt/absent cache degrades to "nothing cached" (every
|
|
14
|
+
gate runs), and writes go through the atomic JSON helper. This cache is an optimization,
|
|
15
|
+
never a source of truth — the full verify path does not consult it.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
import logging
|
|
22
|
+
import time
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import TYPE_CHECKING
|
|
26
|
+
|
|
27
|
+
from devcouncil.utils.json_persist import read_json, write_json
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from devcouncil.verification.gate_selector import GateSpec
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
_CACHE_VERSION = 1
|
|
35
|
+
_ABSENT = "\0absent\0"
|
|
36
|
+
|
|
37
|
+
_CONFIG_FILES = (
|
|
38
|
+
"pyproject.toml",
|
|
39
|
+
"ruff.toml",
|
|
40
|
+
".ruff.toml",
|
|
41
|
+
"mypy.ini",
|
|
42
|
+
"pyrightconfig.json",
|
|
43
|
+
"tsconfig.json",
|
|
44
|
+
"package-lock.json",
|
|
45
|
+
"uv.lock",
|
|
46
|
+
"poetry.lock",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class _Entry:
|
|
52
|
+
input_hash: str
|
|
53
|
+
passed: bool
|
|
54
|
+
summary: str
|
|
55
|
+
updated_at: float
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class GateResultCache:
|
|
59
|
+
"""Load/query/update the on-disk green-gate cache.
|
|
60
|
+
|
|
61
|
+
Instantiate once per verify pass; call :meth:`is_green` to decide skips, then
|
|
62
|
+
:meth:`record` after running a gate, and :meth:`save` once at the end.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self, project_root: Path):
|
|
66
|
+
self.project_root = Path(project_root)
|
|
67
|
+
self._entries: dict[str, _Entry] = {}
|
|
68
|
+
self._loaded = False
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def path(self) -> Path:
|
|
72
|
+
return self.project_root / ".devcouncil" / "cache" / "gate_results.json"
|
|
73
|
+
|
|
74
|
+
def load(self) -> "GateResultCache":
|
|
75
|
+
self._loaded = True
|
|
76
|
+
self._entries = {}
|
|
77
|
+
if not self.path.exists():
|
|
78
|
+
return self
|
|
79
|
+
try:
|
|
80
|
+
data = read_json(self.path)
|
|
81
|
+
except Exception as exc:
|
|
82
|
+
logger.debug("gate cache unreadable (%s); treating as empty", exc)
|
|
83
|
+
return self
|
|
84
|
+
if not isinstance(data, dict) or data.get("version") != _CACHE_VERSION:
|
|
85
|
+
return self
|
|
86
|
+
gates = data.get("gates")
|
|
87
|
+
if not isinstance(gates, dict):
|
|
88
|
+
return self
|
|
89
|
+
for name, raw in gates.items():
|
|
90
|
+
if not isinstance(raw, dict) or "input_hash" not in raw:
|
|
91
|
+
continue
|
|
92
|
+
self._entries[name] = _Entry(
|
|
93
|
+
input_hash=str(raw.get("input_hash", "")),
|
|
94
|
+
passed=bool(raw.get("passed", False)),
|
|
95
|
+
summary=str(raw.get("summary", "")),
|
|
96
|
+
updated_at=float(raw.get("updated_at", 0.0) or 0.0),
|
|
97
|
+
)
|
|
98
|
+
return self
|
|
99
|
+
|
|
100
|
+
def _ensure_loaded(self) -> None:
|
|
101
|
+
if not self._loaded:
|
|
102
|
+
self.load()
|
|
103
|
+
|
|
104
|
+
def _file_hash(self, rel_path: str) -> str:
|
|
105
|
+
"""SHA-256 of a file's bytes, or a sentinel for a missing/unreadable file.
|
|
106
|
+
|
|
107
|
+
Missing is a distinct value (not skipped) so that deleting or creating an input
|
|
108
|
+
changes the hash and correctly invalidates a previously-green gate."""
|
|
109
|
+
fp = self.project_root / rel_path
|
|
110
|
+
try:
|
|
111
|
+
if not fp.is_file():
|
|
112
|
+
return _ABSENT
|
|
113
|
+
digest = hashlib.sha256()
|
|
114
|
+
with fp.open("rb") as handle:
|
|
115
|
+
for chunk in iter(lambda: handle.read(65536), b""):
|
|
116
|
+
digest.update(chunk)
|
|
117
|
+
return digest.hexdigest()
|
|
118
|
+
except OSError:
|
|
119
|
+
return _ABSENT
|
|
120
|
+
|
|
121
|
+
def _config_fingerprint(self) -> str:
|
|
122
|
+
"""Hash mtime+size of known project config files that affect gate behavior."""
|
|
123
|
+
digest = hashlib.sha256()
|
|
124
|
+
for name in _CONFIG_FILES:
|
|
125
|
+
fp = self.project_root / name
|
|
126
|
+
digest.update(name.encode("utf-8"))
|
|
127
|
+
digest.update(b"=")
|
|
128
|
+
try:
|
|
129
|
+
if not fp.is_file():
|
|
130
|
+
digest.update(_ABSENT.encode("utf-8"))
|
|
131
|
+
else:
|
|
132
|
+
stat = fp.stat()
|
|
133
|
+
digest.update(f"{stat.st_mtime_ns}:{stat.st_size}".encode("utf-8"))
|
|
134
|
+
except OSError:
|
|
135
|
+
digest.update(_ABSENT.encode("utf-8"))
|
|
136
|
+
digest.update(b"\0")
|
|
137
|
+
return digest.hexdigest()
|
|
138
|
+
|
|
139
|
+
def input_hash(self, gate: "GateSpec") -> str:
|
|
140
|
+
"""Stable hash over the gate command, config fingerprint, and input file contents."""
|
|
141
|
+
digest = hashlib.sha256()
|
|
142
|
+
digest.update(gate.command.encode("utf-8"))
|
|
143
|
+
digest.update(b"\0")
|
|
144
|
+
digest.update(self._config_fingerprint().encode("utf-8"))
|
|
145
|
+
digest.update(b"\0")
|
|
146
|
+
for rel_path in sorted(gate.inputs):
|
|
147
|
+
digest.update(rel_path.encode("utf-8"))
|
|
148
|
+
digest.update(b"=")
|
|
149
|
+
digest.update(self._file_hash(rel_path).encode("utf-8"))
|
|
150
|
+
digest.update(b"\0")
|
|
151
|
+
return digest.hexdigest()
|
|
152
|
+
|
|
153
|
+
def is_green(self, gate: "GateSpec") -> bool:
|
|
154
|
+
"""True when this gate previously passed with byte-identical inputs and command."""
|
|
155
|
+
self._ensure_loaded()
|
|
156
|
+
entry = self._entries.get(gate.name)
|
|
157
|
+
if entry is None or not entry.passed:
|
|
158
|
+
return False
|
|
159
|
+
return entry.input_hash == self.input_hash(gate)
|
|
160
|
+
|
|
161
|
+
def cached_summary(self, gate: "GateSpec") -> str | None:
|
|
162
|
+
self._ensure_loaded()
|
|
163
|
+
entry = self._entries.get(gate.name)
|
|
164
|
+
return entry.summary if entry is not None else None
|
|
165
|
+
|
|
166
|
+
def record(self, gate: "GateSpec", *, passed: bool, summary: str = "") -> None:
|
|
167
|
+
"""Update the in-memory entry for ``gate`` (call :meth:`save` to persist)."""
|
|
168
|
+
self._ensure_loaded()
|
|
169
|
+
self._entries[gate.name] = _Entry(
|
|
170
|
+
input_hash=self.input_hash(gate),
|
|
171
|
+
passed=bool(passed),
|
|
172
|
+
summary=summary,
|
|
173
|
+
updated_at=time.time(),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def save(self) -> None:
|
|
177
|
+
"""Atomically persist the cache. Best-effort: a write failure is logged, not raised."""
|
|
178
|
+
payload = {
|
|
179
|
+
"version": _CACHE_VERSION,
|
|
180
|
+
"gates": {
|
|
181
|
+
name: {
|
|
182
|
+
"input_hash": entry.input_hash,
|
|
183
|
+
"passed": entry.passed,
|
|
184
|
+
"summary": entry.summary,
|
|
185
|
+
"updated_at": entry.updated_at,
|
|
186
|
+
}
|
|
187
|
+
for name, entry in self._entries.items()
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
try:
|
|
191
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
write_json(self.path, payload, indent=2, sort_keys=True)
|
|
193
|
+
except Exception as exc:
|
|
194
|
+
logger.debug("failed to persist gate cache: %s", exc)
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""Map changed paths to the subset of verification gates/commands that need to run.
|
|
2
|
+
|
|
3
|
+
The full verify path (release / ``dev go``) always runs every configured gate. But
|
|
4
|
+
the iterative sidecar (``dev check --watch``) re-runs on every save, and re-running the
|
|
5
|
+
*whole* gate suite on a one-line edit is what makes it feel sluggish. This selector
|
|
6
|
+
narrows the work two ways:
|
|
7
|
+
|
|
8
|
+
1. **Kind relevance** — a gate keyed to a language stack (``pytest``/``mypy`` → Python,
|
|
9
|
+
``eslint``/``tsc`` → JS/TS) is skipped entirely when no changed file belongs to that
|
|
10
|
+
stack. A docs-only edit runs no code gate at all.
|
|
11
|
+
2. **Scope narrowing** — a linter/type-checker invoked over a broad target (``.``,
|
|
12
|
+
``src``, ``src tests``) is rewritten to target only the touched files of its stack
|
|
13
|
+
("lint only touched packages"), so the tool parses a handful of files instead of the
|
|
14
|
+
tree. Commands with explicit paths, shell operators, or non-narrowable tools are left
|
|
15
|
+
untouched.
|
|
16
|
+
|
|
17
|
+
Each :class:`GateSpec` also carries the ``inputs`` (the changed files that determine its
|
|
18
|
+
result) so the content-hash cache (:mod:`devcouncil.verification.gate_cache`) can skip a
|
|
19
|
+
gate whose inputs are byte-for-byte unchanged since it last passed.
|
|
20
|
+
|
|
21
|
+
Pure/deterministic and dependency-free by design: it takes changed paths + a command
|
|
22
|
+
map and returns specs. Nothing here shells out — running the gates stays the caller's job.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import shlex
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Mapping, Sequence
|
|
31
|
+
|
|
32
|
+
from devcouncil.indexing.subsystem_map import dependents_of
|
|
33
|
+
|
|
34
|
+
# Extensions we treat as "code" for the purpose of deciding whether any code gate is
|
|
35
|
+
# relevant at all. Docs/config-only diffs skip the code gates.
|
|
36
|
+
_CODE_EXTS = frozenset({
|
|
37
|
+
".py", ".pyi", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs",
|
|
38
|
+
".go", ".rs", ".java", ".kt", ".rb", ".cs", ".cpp", ".cc", ".c", ".h", ".hpp",
|
|
39
|
+
".swift", ".scala", ".php",
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
_PYTHON_EXTS = frozenset({".py", ".pyi"})
|
|
43
|
+
_JS_TS_EXTS = frozenset({".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"})
|
|
44
|
+
|
|
45
|
+
# Project config paths that affect tool behaviour. A config-only diff still selects the
|
|
46
|
+
# gates for the matching stack so ``dev check --watch`` re-runs lint/typecheck after
|
|
47
|
+
# rule changes instead of falling back to the evidence gate.
|
|
48
|
+
_CONFIG_PATHS = frozenset({
|
|
49
|
+
"pyproject.toml", "ruff.toml", ".ruff.toml", "mypy.ini", "setup.cfg",
|
|
50
|
+
"pyrightconfig.json", "tsconfig.json", "tsconfig.base.json",
|
|
51
|
+
"package.json", "package-lock.json", "uv.lock", "poetry.lock", "Pipfile",
|
|
52
|
+
".python-version",
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
# Config basename → stack it belongs to. Names in ``_CONFIG_PATHS`` but absent here apply
|
|
56
|
+
# to every stack (e.g. lockfiles that can affect multiple toolchains).
|
|
57
|
+
_CONFIG_STACK: dict[str, str] = {
|
|
58
|
+
"pyproject.toml": "python",
|
|
59
|
+
"ruff.toml": "python",
|
|
60
|
+
".ruff.toml": "python",
|
|
61
|
+
"mypy.ini": "python",
|
|
62
|
+
"setup.cfg": "python",
|
|
63
|
+
"pyrightconfig.json": "python",
|
|
64
|
+
"Pipfile": "python",
|
|
65
|
+
".python-version": "python",
|
|
66
|
+
"uv.lock": "python",
|
|
67
|
+
"poetry.lock": "python",
|
|
68
|
+
"tsconfig.json": "js_ts",
|
|
69
|
+
"tsconfig.base.json": "js_ts",
|
|
70
|
+
"package-lock.json": "js_ts",
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# Tool → the stack it belongs to. A command whose resolved tool is here is only
|
|
74
|
+
# relevant when a changed file matches that stack. Tools not listed are treated as
|
|
75
|
+
# stack-agnostic (always relevant; inputs = every changed file).
|
|
76
|
+
_TOOL_STACK: dict[str, str] = {
|
|
77
|
+
# Python
|
|
78
|
+
"pytest": "python", "mypy": "python", "pyright": "python", "ruff": "python",
|
|
79
|
+
"black": "python", "flake8": "python", "isort": "python", "pylint": "python",
|
|
80
|
+
"pyflakes": "python", "pycodestyle": "python", "autopep8": "python", "yapf": "python",
|
|
81
|
+
"bandit": "python", "unittest": "python",
|
|
82
|
+
# JS / TS
|
|
83
|
+
"eslint": "js_ts", "tsc": "js_ts", "prettier": "js_ts", "jest": "js_ts",
|
|
84
|
+
"vitest": "js_ts", "biome": "js_ts", "standard": "js_ts", "stylelint": "js_ts",
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
_STACK_EXTS: dict[str, frozenset[str]] = {
|
|
88
|
+
"python": _PYTHON_EXTS,
|
|
89
|
+
"js_ts": _JS_TS_EXTS,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
# Tools whose positional path arguments can be safely narrowed to specific files
|
|
93
|
+
# without changing their meaning. ``tsc`` is intentionally excluded: passing files to
|
|
94
|
+
# ``tsc`` disables the project's tsconfig, silently changing the check.
|
|
95
|
+
_NARROWABLE_TOOLS = frozenset({
|
|
96
|
+
"ruff", "black", "flake8", "isort", "pylint", "pyflakes", "pycodestyle",
|
|
97
|
+
"autopep8", "yapf", "bandit", "mypy", "pyright", "eslint", "prettier", "stylelint",
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
# Broad positional targets a narrowable linter is commonly pointed at. When a command's
|
|
101
|
+
# only path args are drawn from this set, they are replaced with the touched files.
|
|
102
|
+
_BROAD_TARGETS = frozenset({".", "./", "src", "src/", "tests", "tests/", "test", "test/", "lib", "lib/", "app", "app/"})
|
|
103
|
+
|
|
104
|
+
# Command-runner wrappers to peel off before resolving the underlying tool.
|
|
105
|
+
_WRAPPERS = frozenset({"npx", "poetry", "uv", "pdm", "hatch", "rye", "pipenv"})
|
|
106
|
+
|
|
107
|
+
# Subcommand tokens that are part of the invocation, not a path argument, for tools
|
|
108
|
+
# that take one (``ruff check .`` → ``check`` is the subcommand). Kept verbatim when a
|
|
109
|
+
# command is narrowed so the meaning is preserved.
|
|
110
|
+
_TOOL_SUBCOMMANDS: dict[str, frozenset[str]] = {
|
|
111
|
+
"ruff": frozenset({"check", "format"}),
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True)
|
|
116
|
+
class GateSpec:
|
|
117
|
+
"""One unit of verification work selected for the current changed-file set.
|
|
118
|
+
|
|
119
|
+
``name`` is a stable identifier used as the cache key. ``kind`` is the gate family
|
|
120
|
+
(``lint``/``typecheck``/``test``). ``command`` is the (possibly narrowed) shell
|
|
121
|
+
command to run. ``inputs`` are the changed files whose content determines the
|
|
122
|
+
result — the content-hash cache keys on them plus the command string.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
name: str
|
|
126
|
+
kind: str
|
|
127
|
+
command: str
|
|
128
|
+
inputs: tuple[str, ...] = ()
|
|
129
|
+
stack: str | None = None
|
|
130
|
+
narrowed: bool = False
|
|
131
|
+
reason: str = ""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass
|
|
135
|
+
class GateSelection:
|
|
136
|
+
"""Result of :func:`select_gates`: the gates to run and the ones skipped (with why)."""
|
|
137
|
+
|
|
138
|
+
gates: list[GateSpec] = field(default_factory=list)
|
|
139
|
+
skipped: list[tuple[str, str]] = field(default_factory=list) # (command, reason)
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def commands(self) -> list[str]:
|
|
143
|
+
return [g.command for g in self.gates]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _norm(path: str) -> str:
|
|
147
|
+
return path.replace("\\", "/")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _ext(path: str) -> str:
|
|
151
|
+
return Path(path).suffix.lower()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _resolve_tool(tokens: Sequence[str]) -> tuple[str | None, int]:
|
|
155
|
+
"""Return (tool_name, index_of_first_positional_after_tool) for a command's tokens.
|
|
156
|
+
|
|
157
|
+
Peels ``python -m``, ``npm run``, and runner wrappers (``poetry run`` etc.) so the
|
|
158
|
+
stack can be inferred from the real tool. ``tool`` is lower-cased and stripped of
|
|
159
|
+
any path prefix / version pin. Index points just past the tool token.
|
|
160
|
+
"""
|
|
161
|
+
i = 0
|
|
162
|
+
n = len(tokens)
|
|
163
|
+
while i < n:
|
|
164
|
+
tok = tokens[i]
|
|
165
|
+
if tok in {"python", "python3", "py"} and i + 1 < n and tokens[i + 1] == "-m":
|
|
166
|
+
i += 2
|
|
167
|
+
continue
|
|
168
|
+
if tok in {"npm", "pnpm", "yarn"}:
|
|
169
|
+
# `npm run lint` / `npm test` — the script name is not the tool; treat as
|
|
170
|
+
# agnostic-but-named by the manager. Return the manager so callers don't
|
|
171
|
+
# try to narrow it.
|
|
172
|
+
return tok, n
|
|
173
|
+
if tok in _WRAPPERS:
|
|
174
|
+
i += 1
|
|
175
|
+
if i < n and tokens[i] == "run":
|
|
176
|
+
i += 1
|
|
177
|
+
continue
|
|
178
|
+
break
|
|
179
|
+
if i >= n:
|
|
180
|
+
return None, n
|
|
181
|
+
raw = tokens[i]
|
|
182
|
+
tool = raw.replace("\\", "/").split("/")[-1].split("==")[0].lower()
|
|
183
|
+
return tool, i + 1
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _stack_for_tool(tool: str | None) -> str | None:
|
|
187
|
+
if tool is None:
|
|
188
|
+
return None
|
|
189
|
+
return _TOOL_STACK.get(tool)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _is_config_path(path: str) -> bool:
|
|
193
|
+
return Path(path).name in _CONFIG_PATHS
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _config_paths_for_stack(changed: Sequence[str], stack: str | None) -> list[str]:
|
|
197
|
+
"""Config files in ``changed`` that apply to ``stack`` (or any stack when unknown)."""
|
|
198
|
+
result: list[str] = []
|
|
199
|
+
for path in changed:
|
|
200
|
+
if not _is_config_path(path):
|
|
201
|
+
continue
|
|
202
|
+
name = Path(path).name
|
|
203
|
+
cfg_stack = _CONFIG_STACK.get(name)
|
|
204
|
+
if stack is None or cfg_stack is None or cfg_stack == stack:
|
|
205
|
+
result.append(path)
|
|
206
|
+
return result
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _files_for_stack(changed: Sequence[str], stack: str | None) -> list[str]:
|
|
210
|
+
if stack is None:
|
|
211
|
+
return list(changed)
|
|
212
|
+
exts = _STACK_EXTS.get(stack, frozenset())
|
|
213
|
+
return [p for p in changed if _ext(p) in exts]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _relevant_files(changed: Sequence[str], stack: str | None) -> list[str]:
|
|
217
|
+
"""Changed files that make a stack gate worth running (code and/or config)."""
|
|
218
|
+
code = _files_for_stack(changed, stack)
|
|
219
|
+
config = _config_paths_for_stack(changed, stack)
|
|
220
|
+
if code:
|
|
221
|
+
return sorted(set(code) | set(config))
|
|
222
|
+
return sorted(config)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _expand_narrow_targets(
|
|
226
|
+
code_files: Sequence[str],
|
|
227
|
+
stack: str | None,
|
|
228
|
+
repo_map: Mapping | None,
|
|
229
|
+
) -> list[str]:
|
|
230
|
+
"""Add direct import dependents so narrowed type-checkers catch cross-file errors."""
|
|
231
|
+
if not code_files or not repo_map:
|
|
232
|
+
return list(code_files)
|
|
233
|
+
exts = _STACK_EXTS.get(stack or "", frozenset())
|
|
234
|
+
expanded = set(code_files)
|
|
235
|
+
for path in code_files:
|
|
236
|
+
for dependent in dependents_of(path, repo_map):
|
|
237
|
+
if _ext(dependent) in exts:
|
|
238
|
+
expanded.add(_norm(dependent))
|
|
239
|
+
return sorted(expanded)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _narrow_command(command: str, tool: str, tool_idx: int, targets: Sequence[str]) -> str | None:
|
|
243
|
+
"""Rewrite ``command`` so its broad positional path args become ``targets``.
|
|
244
|
+
|
|
245
|
+
Returns the narrowed command string, or ``None`` when narrowing is unsafe (shell
|
|
246
|
+
operators present, no broad target found, or the command already names specific
|
|
247
|
+
paths). Flags/options are preserved; only bare broad targets are replaced.
|
|
248
|
+
"""
|
|
249
|
+
if any(op in command for op in ("&&", "||", "|", ";", ">", "<", "$(", "`")):
|
|
250
|
+
return None
|
|
251
|
+
try:
|
|
252
|
+
tokens = shlex.split(command)
|
|
253
|
+
except ValueError:
|
|
254
|
+
return None
|
|
255
|
+
head = tokens[:tool_idx]
|
|
256
|
+
rest = tokens[tool_idx:]
|
|
257
|
+
subcommands = _TOOL_SUBCOMMANDS.get(tool, frozenset())
|
|
258
|
+
# ``prefix`` keeps flags and the tool's subcommand verbatim; ``positionals`` are the
|
|
259
|
+
# path-like args we consider replacing.
|
|
260
|
+
prefix: list[str] = []
|
|
261
|
+
positionals: list[str] = []
|
|
262
|
+
for tok in rest:
|
|
263
|
+
if tok.startswith("-"):
|
|
264
|
+
prefix.append(tok)
|
|
265
|
+
elif not positionals and tok in subcommands:
|
|
266
|
+
prefix.append(tok)
|
|
267
|
+
else:
|
|
268
|
+
positionals.append(tok)
|
|
269
|
+
# Only narrow when every positional is a broad target. If the command already names
|
|
270
|
+
# a specific file/dir (or a flag value we don't understand), respect it — the author
|
|
271
|
+
# scoped it deliberately.
|
|
272
|
+
if not positionals or any(p not in _BROAD_TARGETS for p in positionals):
|
|
273
|
+
return None
|
|
274
|
+
narrowed = head + prefix + list(targets)
|
|
275
|
+
return " ".join(shlex.quote(t) if (" " in t) else t for t in narrowed)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def select_gates(
|
|
279
|
+
changed_files: Sequence[str],
|
|
280
|
+
commands: Mapping[str, Sequence[str]],
|
|
281
|
+
*,
|
|
282
|
+
narrow: bool = True,
|
|
283
|
+
repo_map: Mapping | None = None,
|
|
284
|
+
) -> GateSelection:
|
|
285
|
+
"""Select the gates to run for ``changed_files`` from a ``kind -> [command]`` map.
|
|
286
|
+
|
|
287
|
+
``commands`` keys are gate kinds (``test``/``lint``/``typecheck``); each maps to the
|
|
288
|
+
configured command strings. A command is dropped when it targets a stack none of the
|
|
289
|
+
changed files belong to, and narrowed to the touched files when it is a broad-target
|
|
290
|
+
linter/type-checker and ``narrow`` is set. Order of kinds and commands is preserved.
|
|
291
|
+
"""
|
|
292
|
+
changed = [_norm(p) for p in changed_files if p and p.strip()]
|
|
293
|
+
selection = GateSelection()
|
|
294
|
+
if not changed:
|
|
295
|
+
return selection
|
|
296
|
+
|
|
297
|
+
seen_names: set[str] = set()
|
|
298
|
+
for kind in ("lint", "typecheck", "test"):
|
|
299
|
+
for command in commands.get(kind, []) or []:
|
|
300
|
+
command = command.strip()
|
|
301
|
+
if not command:
|
|
302
|
+
continue
|
|
303
|
+
try:
|
|
304
|
+
tokens = shlex.split(command)
|
|
305
|
+
except ValueError:
|
|
306
|
+
tokens = command.split()
|
|
307
|
+
tool, tool_idx = _resolve_tool(tokens)
|
|
308
|
+
stack = _stack_for_tool(tool)
|
|
309
|
+
relevant = _relevant_files(changed, stack) if stack is not None else list(changed)
|
|
310
|
+
if stack is not None and not relevant:
|
|
311
|
+
selection.skipped.append(
|
|
312
|
+
(command, f"no changed file in the '{stack}' stack")
|
|
313
|
+
)
|
|
314
|
+
continue
|
|
315
|
+
final_command = command
|
|
316
|
+
narrowed = False
|
|
317
|
+
input_files = list(relevant if stack is not None else changed)
|
|
318
|
+
if narrow and tool in _NARROWABLE_TOOLS and stack is not None:
|
|
319
|
+
code_targets = _files_for_stack(relevant, stack)
|
|
320
|
+
narrow_targets = code_targets
|
|
321
|
+
if tool in {"mypy", "pyright"} and code_targets:
|
|
322
|
+
narrow_targets = _expand_narrow_targets(code_targets, stack, repo_map)
|
|
323
|
+
if narrow_targets:
|
|
324
|
+
candidate = _narrow_command(command, tool, tool_idx, narrow_targets)
|
|
325
|
+
if candidate is not None:
|
|
326
|
+
final_command = candidate
|
|
327
|
+
narrowed = True
|
|
328
|
+
config_in_relevant = [p for p in relevant if _is_config_path(p)]
|
|
329
|
+
input_files = sorted(set(narrow_targets) | set(config_in_relevant))
|
|
330
|
+
inputs = tuple(sorted(input_files))
|
|
331
|
+
name = f"{kind}:{final_command}"
|
|
332
|
+
if name in seen_names:
|
|
333
|
+
continue
|
|
334
|
+
seen_names.add(name)
|
|
335
|
+
selection.gates.append(GateSpec(
|
|
336
|
+
name=name,
|
|
337
|
+
kind=kind,
|
|
338
|
+
command=final_command,
|
|
339
|
+
inputs=inputs,
|
|
340
|
+
stack=stack,
|
|
341
|
+
narrowed=narrowed,
|
|
342
|
+
reason="narrowed to touched files" if narrowed else "",
|
|
343
|
+
))
|
|
344
|
+
return selection
|