devcouncil 0.3.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +92 -33
- 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 +56 -40
- 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 +964 -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 +281 -0
- package/src/devcouncil/integrations/mcp/handlers/graph.py +34 -0
- package/src/devcouncil/integrations/mcp/handlers/handoff.py +53 -0
- package/src/devcouncil/integrations/mcp/handlers/knowledge.py +28 -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 +87 -0
- package/src/devcouncil/integrations/mcp/handlers/read.py +103 -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 +69 -0
- package/src/devcouncil/integrations/mcp/handlers/scope.py +56 -0
- package/src/devcouncil/integrations/mcp/handlers/status.py +199 -0
- package/src/devcouncil/integrations/mcp/handlers/task.py +88 -0
- package/src/devcouncil/integrations/mcp/handlers/tool_specs.py +922 -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 +53 -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 +325 -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/summary.py +21 -3
- 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,1192 @@
|
|
|
1
|
+
"""``dev graph`` — query / trace / dead / check / process / impact / html / view / export."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import List, Optional
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(
|
|
14
|
+
name="graph",
|
|
15
|
+
help="Query and visualize the symbol-level code knowledge graph.",
|
|
16
|
+
add_completion=False,
|
|
17
|
+
)
|
|
18
|
+
hooks_app = typer.Typer(name="hooks", help="Optional Git hook integration.", add_completion=False)
|
|
19
|
+
app.add_typer(hooks_app, name="hooks")
|
|
20
|
+
console = Console()
|
|
21
|
+
status = Console(stderr=True)
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _root(project_root: Path) -> Path:
|
|
26
|
+
root = project_root.expanduser().resolve()
|
|
27
|
+
from devcouncil.telemetry.logging_setup import set_log_dir
|
|
28
|
+
|
|
29
|
+
set_log_dir(root)
|
|
30
|
+
return root
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _graph_degraded_fields(root: Path) -> dict[str, object]:
|
|
34
|
+
"""Lean-map handshake for CLI JSON that bypasses the codeintel envelope."""
|
|
35
|
+
map_path = root / ".devcouncil" / "repo_map.json"
|
|
36
|
+
if not map_path.is_file():
|
|
37
|
+
return {"graph_degraded": False}
|
|
38
|
+
try:
|
|
39
|
+
from devcouncil.utils.json_persist import read_json
|
|
40
|
+
|
|
41
|
+
data = read_json(map_path)
|
|
42
|
+
if not isinstance(data, dict):
|
|
43
|
+
return {"graph_degraded": False}
|
|
44
|
+
degraded = bool(data.get("graph_degraded"))
|
|
45
|
+
fields: dict[str, object] = {"graph_degraded": degraded}
|
|
46
|
+
if degraded:
|
|
47
|
+
fields["graph_degraded_reason"] = str(data.get("graph_degraded_reason") or "")
|
|
48
|
+
return fields
|
|
49
|
+
except Exception:
|
|
50
|
+
return {"graph_degraded": False}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _require_graph(root: Path):
|
|
54
|
+
from devcouncil.indexing.graph.build import load_code_graph
|
|
55
|
+
|
|
56
|
+
graph = load_code_graph(root)
|
|
57
|
+
if graph is None:
|
|
58
|
+
status.print("[red]No code graph; run `dev map` first.[/red]")
|
|
59
|
+
raise typer.Exit(code=1)
|
|
60
|
+
return graph
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@app.command("init")
|
|
64
|
+
def graph_init(
|
|
65
|
+
project_root: Path = typer.Option(Path("."), "--project-root"),
|
|
66
|
+
no_liveness: bool = typer.Option(False, "--no-liveness"),
|
|
67
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Build the canonical SQLite graph and deterministic compatibility exports."""
|
|
70
|
+
from devcouncil.codeintel import get_codeintel_service
|
|
71
|
+
from devcouncil.codeintel.build_control import GraphBuildBusy
|
|
72
|
+
from devcouncil.indexing.map_artifacts import refresh_map_artifacts
|
|
73
|
+
|
|
74
|
+
root = _root(project_root)
|
|
75
|
+
try:
|
|
76
|
+
refresh = refresh_map_artifacts(
|
|
77
|
+
root,
|
|
78
|
+
root / ".devcouncil" / "repo_map.json",
|
|
79
|
+
liveness=not no_liveness,
|
|
80
|
+
quiet=True,
|
|
81
|
+
)
|
|
82
|
+
except GraphBuildBusy as exc:
|
|
83
|
+
if json_output:
|
|
84
|
+
typer.echo(json.dumps({"ok": False, "code": "graph_writer_busy", "error": str(exc)}))
|
|
85
|
+
else:
|
|
86
|
+
status.print(f"[red]{exc}[/red]")
|
|
87
|
+
raise typer.Exit(code=1) from exc
|
|
88
|
+
if refresh.degraded:
|
|
89
|
+
payload = {
|
|
90
|
+
"ok": False,
|
|
91
|
+
"degraded": True,
|
|
92
|
+
"reason": refresh.reason,
|
|
93
|
+
"mode": refresh.mode,
|
|
94
|
+
}
|
|
95
|
+
if json_output:
|
|
96
|
+
typer.echo(json.dumps(payload, indent=2))
|
|
97
|
+
else:
|
|
98
|
+
status.print(f"[red]Graph init degraded: {refresh.reason}[/red]")
|
|
99
|
+
raise typer.Exit(code=1)
|
|
100
|
+
result = get_codeintel_service(root).status()
|
|
101
|
+
if refresh.compatibility_export_degraded:
|
|
102
|
+
result["compatibility_export"] = "degraded"
|
|
103
|
+
result["degraded_reason"] = refresh.reason
|
|
104
|
+
if json_output:
|
|
105
|
+
typer.echo(json.dumps(result, indent=2))
|
|
106
|
+
else:
|
|
107
|
+
color = "yellow" if refresh.compatibility_export_degraded else "green"
|
|
108
|
+
status.print(
|
|
109
|
+
f"[{color}]Indexed generation {result.get('generation')} — "
|
|
110
|
+
f"{result.get('node_count')} nodes, {result.get('edge_count')} edges"
|
|
111
|
+
f"{f'; export degraded: {refresh.reason}' if refresh.compatibility_export_degraded else ''}"
|
|
112
|
+
f"[/{color}]"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@app.command("status")
|
|
117
|
+
def graph_status(
|
|
118
|
+
project_root: Path = typer.Option(Path("."), "--project-root"),
|
|
119
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
120
|
+
) -> None:
|
|
121
|
+
"""Show canonical generation, watcher health, and pending files."""
|
|
122
|
+
from devcouncil.codeintel import get_codeintel_service
|
|
123
|
+
from devcouncil.codeintel.sync import get_sync_coordinator
|
|
124
|
+
|
|
125
|
+
root = _root(project_root)
|
|
126
|
+
result = get_codeintel_service(root).status()
|
|
127
|
+
# Cold start: existing compatibility JSON is enough to bootstrap queries/status
|
|
128
|
+
# without requiring a full ``dev map`` rebuild first.
|
|
129
|
+
if result.get("state") in {"uninitialized", "empty"}:
|
|
130
|
+
try:
|
|
131
|
+
from devcouncil.indexing.graph.build import graph_path, load_code_graph
|
|
132
|
+
|
|
133
|
+
if graph_path(root).is_file():
|
|
134
|
+
load_code_graph(root)
|
|
135
|
+
result = get_codeintel_service(root).status()
|
|
136
|
+
except Exception:
|
|
137
|
+
logger.debug("graph status cold-start bootstrap failed", exc_info=True)
|
|
138
|
+
result["sync"] = get_sync_coordinator(root).status().as_dict()
|
|
139
|
+
if json_output:
|
|
140
|
+
typer.echo(json.dumps(result, indent=2))
|
|
141
|
+
return
|
|
142
|
+
console.print(f"state: {result['state']}")
|
|
143
|
+
console.print(f"generation: {result.get('generation') or '(none)'}")
|
|
144
|
+
console.print(f"nodes/edges: {result.get('node_count', 0)}/{result.get('edge_count', 0)}")
|
|
145
|
+
sync = result["sync"]
|
|
146
|
+
console.print(f"watcher: {sync['state']} ({sync.get('backend') or 'not started'})")
|
|
147
|
+
if sync.get("state") in {"disabled", "stopped", ""} or not sync.get("backend"):
|
|
148
|
+
console.print(
|
|
149
|
+
"[dim]hint: run `dev graph watch` or `dev map --watch` to enable auto-refresh[/dim]"
|
|
150
|
+
)
|
|
151
|
+
if sync.get("build_id"):
|
|
152
|
+
progress = f"{sync.get('build_completed', 0)}/{sync.get('build_total', 0)}"
|
|
153
|
+
console.print(
|
|
154
|
+
f"build: {sync.get('build_state') or 'unknown'} / "
|
|
155
|
+
f"{sync.get('build_phase') or 'unknown'} ({progress}, "
|
|
156
|
+
f"pid={sync.get('build_pid') or 'n/a'})"
|
|
157
|
+
)
|
|
158
|
+
if sync.get("compatibility_export") == "degraded":
|
|
159
|
+
console.print("compatibility export: degraded")
|
|
160
|
+
if sync.get("pending"):
|
|
161
|
+
console.print("pending: " + ", ".join(sync["pending"]))
|
|
162
|
+
if sync.get("degraded_reason"):
|
|
163
|
+
console.print(f"degraded: {sync['degraded_reason']}")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@app.command("sync")
|
|
167
|
+
def graph_sync(
|
|
168
|
+
paths: Optional[List[str]] = typer.Argument(None, help="Optional paths; otherwise reconcile the project."),
|
|
169
|
+
project_root: Path = typer.Option(Path("."), "--project-root"),
|
|
170
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
171
|
+
) -> None:
|
|
172
|
+
"""Reconcile and commit pending filesystem changes now."""
|
|
173
|
+
from devcouncil.codeintel.sync import get_sync_coordinator
|
|
174
|
+
|
|
175
|
+
root = _root(project_root)
|
|
176
|
+
coordinator = get_sync_coordinator(root)
|
|
177
|
+
changed = list(paths or coordinator.reconcile())
|
|
178
|
+
ok = coordinator.sync_now(changed)
|
|
179
|
+
result = coordinator.status().as_dict()
|
|
180
|
+
result["ok"] = ok
|
|
181
|
+
result["reconciled"] = changed
|
|
182
|
+
if json_output:
|
|
183
|
+
typer.echo(json.dumps(result, indent=2))
|
|
184
|
+
else:
|
|
185
|
+
color = "green" if ok else "yellow"
|
|
186
|
+
status.print(f"[{color}]Synced {len(changed)} path(s); state={result['state']}[/{color}]")
|
|
187
|
+
if not ok:
|
|
188
|
+
raise typer.Exit(code=1)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@app.command("watch")
|
|
192
|
+
def graph_watch(
|
|
193
|
+
project_root: Path = typer.Option(Path("."), "--project-root"),
|
|
194
|
+
) -> None:
|
|
195
|
+
"""Run native auto-sync in the foreground until interrupted."""
|
|
196
|
+
import time
|
|
197
|
+
|
|
198
|
+
from devcouncil.codeintel.sync import get_sync_coordinator
|
|
199
|
+
|
|
200
|
+
root = _root(project_root)
|
|
201
|
+
coordinator = get_sync_coordinator(root)
|
|
202
|
+
state = coordinator.start()
|
|
203
|
+
status.print(
|
|
204
|
+
f"[cyan]Watching {root} with {state.backend or 'reconciliation'} "
|
|
205
|
+
f"(state={state.state}); Ctrl-C to stop.[/cyan]"
|
|
206
|
+
)
|
|
207
|
+
try:
|
|
208
|
+
while True:
|
|
209
|
+
time.sleep(0.5)
|
|
210
|
+
except KeyboardInterrupt:
|
|
211
|
+
status.print("Stopped watching.")
|
|
212
|
+
finally:
|
|
213
|
+
coordinator.stop()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@app.command("doctor")
|
|
217
|
+
def graph_doctor(
|
|
218
|
+
project_root: Path = typer.Option(Path("."), "--project-root"),
|
|
219
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
220
|
+
) -> None:
|
|
221
|
+
"""Verify SQLite, native watcher selection, and installed grammar assets."""
|
|
222
|
+
from watchdog.observers import Observer
|
|
223
|
+
|
|
224
|
+
from devcouncil.codeintel import get_codeintel_service
|
|
225
|
+
from devcouncil.codeintel.build_control import read_build_status
|
|
226
|
+
from devcouncil.codeintel.languages import grammar_status
|
|
227
|
+
from devcouncil.codeintel.store.sqlite import compatibility_graph_digest
|
|
228
|
+
from devcouncil.indexing.graph.build import graph_path
|
|
229
|
+
from devcouncil.utils.json_persist import read_json
|
|
230
|
+
|
|
231
|
+
root = _root(project_root)
|
|
232
|
+
service = get_codeintel_service(root)
|
|
233
|
+
store = service.status()
|
|
234
|
+
grammars = grammar_status()
|
|
235
|
+
watcher_backend = getattr(Observer, "__name__", type(Observer).__name__)
|
|
236
|
+
build = read_build_status(root)
|
|
237
|
+
export_path = graph_path(root)
|
|
238
|
+
export_health = "missing"
|
|
239
|
+
export_detail = ""
|
|
240
|
+
if store["state"] == "committed":
|
|
241
|
+
recorded_digest, recorded_mtime = service.store.compatibility_export_state()
|
|
242
|
+
if not export_path.is_file():
|
|
243
|
+
export_health = "missing"
|
|
244
|
+
export_detail = "compatibility JSON absent while store is committed"
|
|
245
|
+
else:
|
|
246
|
+
try:
|
|
247
|
+
data = read_json(export_path)
|
|
248
|
+
from devcouncil.indexing.graph.schema import CodeGraph
|
|
249
|
+
|
|
250
|
+
exported = CodeGraph.model_validate(data)
|
|
251
|
+
digest = compatibility_graph_digest(exported)
|
|
252
|
+
if recorded_digest and digest != recorded_digest:
|
|
253
|
+
export_health = "drift"
|
|
254
|
+
export_detail = "JSON digest diverges from store handshake"
|
|
255
|
+
elif build.compatibility_export == "degraded":
|
|
256
|
+
export_health = "degraded"
|
|
257
|
+
export_detail = build.degraded_reason or "last build skipped JSON export"
|
|
258
|
+
else:
|
|
259
|
+
export_health = "healthy"
|
|
260
|
+
except Exception as exc: # noqa: BLE001
|
|
261
|
+
export_health = "corrupt"
|
|
262
|
+
export_detail = f"{type(exc).__name__}: {exc}"
|
|
263
|
+
elif build.compatibility_export == "degraded":
|
|
264
|
+
export_health = "degraded"
|
|
265
|
+
export_detail = build.degraded_reason or "compatibility export degraded"
|
|
266
|
+
result = {
|
|
267
|
+
"ok": store["state"] == "committed" and grammars["ok"] and export_health == "healthy",
|
|
268
|
+
"store": store,
|
|
269
|
+
"watcher_backend": watcher_backend,
|
|
270
|
+
"grammars": grammars,
|
|
271
|
+
"compatibility_export": {
|
|
272
|
+
"health": export_health,
|
|
273
|
+
"detail": export_detail,
|
|
274
|
+
"build_state": build.state,
|
|
275
|
+
"build_compatibility_export": build.compatibility_export,
|
|
276
|
+
},
|
|
277
|
+
}
|
|
278
|
+
# Uninitialized projects are healthy when grammars are installed.
|
|
279
|
+
if store["state"] in {"uninitialized", "empty"}:
|
|
280
|
+
result["ok"] = bool(grammars["ok"])
|
|
281
|
+
if store["state"] == "corrupt":
|
|
282
|
+
result["store_action"] = (
|
|
283
|
+
"index.sqlite is damaged — run `dev map` to quarantine it and rebuild"
|
|
284
|
+
)
|
|
285
|
+
if json_output:
|
|
286
|
+
typer.echo(json.dumps(result, indent=2))
|
|
287
|
+
if not result["ok"]:
|
|
288
|
+
raise typer.Exit(code=1)
|
|
289
|
+
return
|
|
290
|
+
console.print(f"store: {store['state']} (schema {store['schema_version']})")
|
|
291
|
+
if result.get("store_action"):
|
|
292
|
+
console.print(f"store action: {result['store_action']}")
|
|
293
|
+
console.print(f"watcher backend: {watcher_backend}")
|
|
294
|
+
console.print(
|
|
295
|
+
f"compatibility export: {export_health}"
|
|
296
|
+
+ (f" — {export_detail}" if export_detail else "")
|
|
297
|
+
)
|
|
298
|
+
console.print(
|
|
299
|
+
f"grammars: {grammars['available_count']}/{grammars['required_count']} available locally"
|
|
300
|
+
)
|
|
301
|
+
for row in grammars["languages"]:
|
|
302
|
+
if not row["available"]:
|
|
303
|
+
# Python parses via stdlib ast regardless of the tree-sitter wheel —
|
|
304
|
+
# don't let a Python-heavy repo read this line as broken indexing.
|
|
305
|
+
native_note = (
|
|
306
|
+
" — extraction unaffected (native stdlib-ast parser)"
|
|
307
|
+
if row.get("grammar") == "python"
|
|
308
|
+
else ""
|
|
309
|
+
)
|
|
310
|
+
console.print(
|
|
311
|
+
f" missing: {row['language']} "
|
|
312
|
+
f"({', '.join(row['missing_grammars'])}){native_note}"
|
|
313
|
+
)
|
|
314
|
+
if grammars["action"]:
|
|
315
|
+
console.print(f"grammar action: {grammars['action']}")
|
|
316
|
+
if not result["ok"]:
|
|
317
|
+
raise typer.Exit(code=1)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@app.command("search")
|
|
321
|
+
def graph_search(
|
|
322
|
+
query: str = typer.Argument(...),
|
|
323
|
+
project_root: Path = typer.Option(Path("."), "--project-root"),
|
|
324
|
+
limit: int = typer.Option(50, "--limit"),
|
|
325
|
+
semantic: bool = typer.Option(False, "--semantic", help="Use opt-in local embeddings when enabled."),
|
|
326
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
327
|
+
) -> None:
|
|
328
|
+
"""Full-text (or semantic) symbol and path search over the committed generation."""
|
|
329
|
+
root = _root(project_root)
|
|
330
|
+
if semantic:
|
|
331
|
+
from devcouncil.indexing.graph.embeddings import semantic_search
|
|
332
|
+
|
|
333
|
+
result = semantic_search(root, query, limit=limit)
|
|
334
|
+
if not result.get("ok"):
|
|
335
|
+
from devcouncil.codeintel.query import CodeIntelQueryEngine
|
|
336
|
+
|
|
337
|
+
result = CodeIntelQueryEngine(root).search(query, limit=limit)
|
|
338
|
+
else:
|
|
339
|
+
from devcouncil.codeintel.query import CodeIntelQueryEngine
|
|
340
|
+
|
|
341
|
+
result = CodeIntelQueryEngine(root).search(query, limit=limit)
|
|
342
|
+
if json_output:
|
|
343
|
+
typer.echo(json.dumps(result, indent=2))
|
|
344
|
+
return
|
|
345
|
+
for match in result.get("matches", []):
|
|
346
|
+
if "line" in match:
|
|
347
|
+
console.print(f"{match['path']}:{match['line']} {match['id']} [{match['kind']}]")
|
|
348
|
+
else:
|
|
349
|
+
console.print(f"{match['path']} {match.get('label', match['id'])} score={match.get('score')}")
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@app.command("ingest")
|
|
353
|
+
def graph_ingest(
|
|
354
|
+
paths: Optional[List[str]] = typer.Argument(None, help="Optional paths; full rebuild when omitted."),
|
|
355
|
+
project_root: Path = typer.Option(Path("."), "--project-root"),
|
|
356
|
+
no_liveness: bool = typer.Option(False, "--no-liveness"),
|
|
357
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
358
|
+
) -> None:
|
|
359
|
+
"""Unified analyze entry: codeintel sync → graph export → repo map write."""
|
|
360
|
+
from devcouncil.indexing.map_artifacts import refresh_map_artifacts
|
|
361
|
+
from devcouncil.codeintel.sync import get_sync_coordinator
|
|
362
|
+
from devcouncil.codeintel import get_codeintel_service
|
|
363
|
+
from devcouncil.codeintel.build_control import GraphBuildBusy
|
|
364
|
+
from devcouncil.indexing.graph.embeddings import build_embeddings
|
|
365
|
+
|
|
366
|
+
root = _root(project_root)
|
|
367
|
+
coordinator = get_sync_coordinator(root)
|
|
368
|
+
changed = list(paths or [])
|
|
369
|
+
map_path = root / ".devcouncil" / "repo_map.json"
|
|
370
|
+
if paths is None:
|
|
371
|
+
try:
|
|
372
|
+
refresh = refresh_map_artifacts(
|
|
373
|
+
root,
|
|
374
|
+
map_path,
|
|
375
|
+
liveness=not no_liveness,
|
|
376
|
+
quiet=True,
|
|
377
|
+
)
|
|
378
|
+
except GraphBuildBusy as exc:
|
|
379
|
+
payload = {
|
|
380
|
+
"ok": False,
|
|
381
|
+
"code": "graph_writer_busy",
|
|
382
|
+
"error": str(exc),
|
|
383
|
+
"paths": changed,
|
|
384
|
+
}
|
|
385
|
+
if json_output:
|
|
386
|
+
typer.echo(json.dumps(payload, indent=2))
|
|
387
|
+
else:
|
|
388
|
+
status.print(f"[red]{exc}[/red]")
|
|
389
|
+
raise typer.Exit(code=1) from exc
|
|
390
|
+
else:
|
|
391
|
+
synced = coordinator.sync_now(changed)
|
|
392
|
+
if not synced:
|
|
393
|
+
payload = {"ok": False, "paths": changed, **coordinator.status().as_dict()}
|
|
394
|
+
if json_output:
|
|
395
|
+
typer.echo(json.dumps(payload, indent=2))
|
|
396
|
+
else:
|
|
397
|
+
status.print(f"[red]Graph ingest failed: {payload.get('last_error') or payload.get('degraded_reason')}[/red]")
|
|
398
|
+
raise typer.Exit(code=1)
|
|
399
|
+
refresh = refresh_map_artifacts(
|
|
400
|
+
root,
|
|
401
|
+
map_path,
|
|
402
|
+
liveness=not no_liveness,
|
|
403
|
+
quiet=True,
|
|
404
|
+
graph=get_codeintel_service(root).load(),
|
|
405
|
+
paths=changed,
|
|
406
|
+
)
|
|
407
|
+
embedded = build_embeddings(root)
|
|
408
|
+
payload = {
|
|
409
|
+
"ok": not refresh.degraded,
|
|
410
|
+
"paths": changed,
|
|
411
|
+
"map": str(map_path.relative_to(root)),
|
|
412
|
+
"embeddings_built": embedded,
|
|
413
|
+
"generation": refresh.generation,
|
|
414
|
+
"mode": refresh.mode,
|
|
415
|
+
"degraded": refresh.degraded,
|
|
416
|
+
"reason": refresh.reason,
|
|
417
|
+
}
|
|
418
|
+
if json_output:
|
|
419
|
+
typer.echo(json.dumps(payload, indent=2))
|
|
420
|
+
else:
|
|
421
|
+
color = "yellow" if refresh.degraded else "green"
|
|
422
|
+
status.print(
|
|
423
|
+
f"[{color}]Ingested {len(changed)} path(s); map at {payload['map']}"
|
|
424
|
+
f"{f'; {embedded} embeddings' if embedded else ''}"
|
|
425
|
+
f"{f'; degraded: {refresh.reason}' if refresh.degraded else ''}[/{color}]"
|
|
426
|
+
)
|
|
427
|
+
if refresh.degraded:
|
|
428
|
+
raise typer.Exit(code=1)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
@app.command("cypher")
|
|
432
|
+
def graph_cypher(
|
|
433
|
+
query: str = typer.Argument(..., help="Supported MATCH … RETURN subset."),
|
|
434
|
+
project_root: Path = typer.Option(Path("."), "--project-root"),
|
|
435
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
436
|
+
) -> None:
|
|
437
|
+
"""Run a supported Cypher subset over the native SQLite graph store."""
|
|
438
|
+
from devcouncil.indexing.graph.cypher import run_cypher
|
|
439
|
+
|
|
440
|
+
result = run_cypher(_root(project_root), query)
|
|
441
|
+
if json_output:
|
|
442
|
+
typer.echo(json.dumps(result, indent=2))
|
|
443
|
+
if not result.get("ok"):
|
|
444
|
+
raise typer.Exit(code=1)
|
|
445
|
+
return
|
|
446
|
+
if not result.get("ok"):
|
|
447
|
+
status.print(f"[red]{result.get('error', 'cypher failed')}[/red]")
|
|
448
|
+
raise typer.Exit(code=1)
|
|
449
|
+
for row in result.get("rows", []):
|
|
450
|
+
console.print(" ".join(f"{k}={v}" for k, v in row.items()))
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
@app.command("explore")
|
|
454
|
+
def graph_explore(
|
|
455
|
+
query: str = typer.Argument(...),
|
|
456
|
+
project_root: Path = typer.Option(Path("."), "--project-root"),
|
|
457
|
+
limit: int = typer.Option(20, "--limit"),
|
|
458
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
459
|
+
) -> None:
|
|
460
|
+
"""Return source, related symbols, paths, and blast radius in one query."""
|
|
461
|
+
from devcouncil.codeintel.query import CodeIntelQueryEngine
|
|
462
|
+
|
|
463
|
+
result = CodeIntelQueryEngine(_root(project_root)).explore(query, limit=limit)
|
|
464
|
+
if json_output:
|
|
465
|
+
typer.echo(json.dumps(result, indent=2))
|
|
466
|
+
return
|
|
467
|
+
for definition in result["definitions"]:
|
|
468
|
+
console.print(f"[bold]{definition['id']}[/bold] {definition['path']}:{definition['line']}")
|
|
469
|
+
if definition["source"]:
|
|
470
|
+
console.print(definition["source"])
|
|
471
|
+
console.print(
|
|
472
|
+
f" callers={len(definition['callers'])} callees={len(definition['callees'])}"
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
@app.command("affected")
|
|
477
|
+
def graph_affected(
|
|
478
|
+
targets: List[str] = typer.Argument(..., help="Symbol or path targets."),
|
|
479
|
+
project_root: Path = typer.Option(Path("."), "--project-root"),
|
|
480
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
481
|
+
) -> None:
|
|
482
|
+
"""Find tests reachable through the inbound blast radius."""
|
|
483
|
+
from devcouncil.codeintel.query import CodeIntelQueryEngine
|
|
484
|
+
|
|
485
|
+
result = CodeIntelQueryEngine(_root(project_root)).affected_tests(targets)
|
|
486
|
+
if json_output:
|
|
487
|
+
typer.echo(json.dumps(result, indent=2))
|
|
488
|
+
return
|
|
489
|
+
if not result["tests"]:
|
|
490
|
+
console.print("No affected tests found.")
|
|
491
|
+
return
|
|
492
|
+
for test in result["tests"]:
|
|
493
|
+
console.print(test)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
@hooks_app.command("install")
|
|
497
|
+
def graph_hooks_install(
|
|
498
|
+
project_root: Path = typer.Option(Path("."), "--project-root"),
|
|
499
|
+
) -> None:
|
|
500
|
+
"""Install an opt-in post-checkout/post-merge reconciliation hook."""
|
|
501
|
+
root = _root(project_root)
|
|
502
|
+
git_dir = root / ".git"
|
|
503
|
+
if not git_dir.is_dir():
|
|
504
|
+
status.print("[red]Git hook installation requires a normal .git directory.[/red]")
|
|
505
|
+
raise typer.Exit(code=1)
|
|
506
|
+
hook_body = "#!/bin/sh\nexec dev graph sync --project-root \"$(git rev-parse --show-toplevel)\" >/dev/null 2>&1\n"
|
|
507
|
+
for name in ("post-checkout", "post-merge"):
|
|
508
|
+
path = git_dir / "hooks" / name
|
|
509
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
510
|
+
if path.exists() and "dev graph sync" not in path.read_text(encoding="utf-8", errors="replace"):
|
|
511
|
+
status.print(f"[red]Refusing to overwrite existing hook: {path}[/red]")
|
|
512
|
+
raise typer.Exit(code=1)
|
|
513
|
+
path.write_text(hook_body, encoding="utf-8")
|
|
514
|
+
path.chmod(0o755)
|
|
515
|
+
status.print("[green]Installed post-checkout and post-merge code-intelligence hooks.[/green]")
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
@app.command("query")
|
|
519
|
+
def graph_query(
|
|
520
|
+
name_or_path: str = typer.Argument(..., help="Symbol name or file path."),
|
|
521
|
+
project_root: Path = typer.Option(Path("."), "--project-root"),
|
|
522
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
523
|
+
) -> None:
|
|
524
|
+
"""360° view: definition, callers, callees, importers."""
|
|
525
|
+
from devcouncil.indexing.graph import query_symbol
|
|
526
|
+
|
|
527
|
+
root = _root(project_root)
|
|
528
|
+
result = {**query_symbol(root, name_or_path), **_graph_degraded_fields(root)}
|
|
529
|
+
if json_output:
|
|
530
|
+
typer.echo(json.dumps(result, indent=2))
|
|
531
|
+
return
|
|
532
|
+
if result.get("graph_degraded"):
|
|
533
|
+
status.print(
|
|
534
|
+
f"[yellow]graph_degraded: {result.get('graph_degraded_reason') or 'lean map'}[/yellow]"
|
|
535
|
+
)
|
|
536
|
+
if result.get("error"):
|
|
537
|
+
status.print(f"[red]{result['error']}[/red]")
|
|
538
|
+
raise typer.Exit(code=1)
|
|
539
|
+
defs = result.get("definitions") or []
|
|
540
|
+
if not defs:
|
|
541
|
+
console.print(f"No matches for {name_or_path!r}")
|
|
542
|
+
return
|
|
543
|
+
for d in defs:
|
|
544
|
+
console.print(f"[bold]{d['id']}[/bold] ({d.get('kind')}) {d.get('path')}:{d.get('line')}")
|
|
545
|
+
console.print(f" callers: {', '.join(d.get('callers') or []) or '(none)'}")
|
|
546
|
+
console.print(f" callees: {', '.join(d.get('callees') or []) or '(none)'}")
|
|
547
|
+
console.print(f" importers: {', '.join(d.get('importers') or []) or '(none)'}")
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
@app.command("trace")
|
|
551
|
+
def graph_trace(
|
|
552
|
+
start: str = typer.Argument(..., help="Start node (name or path)."),
|
|
553
|
+
end: str = typer.Argument(..., help="End node (name or path)."),
|
|
554
|
+
project_root: Path = typer.Option(Path("."), "--project-root"),
|
|
555
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
556
|
+
) -> None:
|
|
557
|
+
"""Shortest path between two graph nodes."""
|
|
558
|
+
from devcouncil.indexing.graph import trace_path
|
|
559
|
+
|
|
560
|
+
root = _root(project_root)
|
|
561
|
+
result = {**trace_path(root, start, end), **_graph_degraded_fields(root)}
|
|
562
|
+
if json_output:
|
|
563
|
+
typer.echo(json.dumps(result, indent=2))
|
|
564
|
+
return
|
|
565
|
+
if result.get("graph_degraded"):
|
|
566
|
+
status.print(
|
|
567
|
+
f"[yellow]graph_degraded: {result.get('graph_degraded_reason') or 'lean map'}[/yellow]"
|
|
568
|
+
)
|
|
569
|
+
if result.get("error"):
|
|
570
|
+
status.print(f"[red]{result['error']}[/red]")
|
|
571
|
+
raise typer.Exit(code=1)
|
|
572
|
+
if not result.get("found"):
|
|
573
|
+
console.print(f"No path between {start!r} and {end!r}")
|
|
574
|
+
raise typer.Exit(code=1)
|
|
575
|
+
console.print(" → ".join(result.get("path") or []))
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
@app.command("dead")
|
|
579
|
+
def graph_dead(
|
|
580
|
+
project_root: Path = typer.Option(Path("."), "--project-root"),
|
|
581
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
582
|
+
confidence: Optional[str] = typer.Option(
|
|
583
|
+
None, "--confidence", help="Exact filter: extracted|inferred|ambiguous"
|
|
584
|
+
),
|
|
585
|
+
min_confidence: str = typer.Option(
|
|
586
|
+
"inferred",
|
|
587
|
+
"--min-confidence",
|
|
588
|
+
help="Include this tier and above: extracted > inferred > ambiguous "
|
|
589
|
+
"(default: inferred; pass ambiguous to show all)",
|
|
590
|
+
),
|
|
591
|
+
) -> None:
|
|
592
|
+
"""Full dead-code report with confidence tiers and reasons."""
|
|
593
|
+
from collections import Counter
|
|
594
|
+
|
|
595
|
+
from devcouncil.indexing.graph.liveness import confidence_at_least
|
|
596
|
+
|
|
597
|
+
root = _root(project_root)
|
|
598
|
+
graph = _require_graph(root)
|
|
599
|
+
entries = list(graph.dead_code)
|
|
600
|
+
if confidence:
|
|
601
|
+
entries = [
|
|
602
|
+
e
|
|
603
|
+
for e in entries
|
|
604
|
+
if (e.confidence.value if hasattr(e.confidence, "value") else str(e.confidence))
|
|
605
|
+
== confidence
|
|
606
|
+
]
|
|
607
|
+
before_min = len(entries)
|
|
608
|
+
if min_confidence:
|
|
609
|
+
entries = [
|
|
610
|
+
e
|
|
611
|
+
for e in entries
|
|
612
|
+
if confidence_at_least(e.confidence, min_confidence)
|
|
613
|
+
]
|
|
614
|
+
hidden = before_min - len(entries)
|
|
615
|
+
degraded = _graph_degraded_fields(root)
|
|
616
|
+
if json_output:
|
|
617
|
+
typer.echo(
|
|
618
|
+
json.dumps(
|
|
619
|
+
{
|
|
620
|
+
"dead_code": [e.model_dump() for e in entries],
|
|
621
|
+
"dead_code_hidden": hidden,
|
|
622
|
+
**degraded,
|
|
623
|
+
},
|
|
624
|
+
indent=2,
|
|
625
|
+
)
|
|
626
|
+
)
|
|
627
|
+
return
|
|
628
|
+
if degraded.get("graph_degraded"):
|
|
629
|
+
status.print(
|
|
630
|
+
f"[yellow]graph_degraded: {degraded.get('graph_degraded_reason') or 'lean map'} "
|
|
631
|
+
"— treat dead tiers as unreliable[/yellow]"
|
|
632
|
+
)
|
|
633
|
+
if not entries:
|
|
634
|
+
console.print("No dead-code entries.")
|
|
635
|
+
if hidden:
|
|
636
|
+
console.print(
|
|
637
|
+
f"{hidden} lower-confidence entries hidden "
|
|
638
|
+
"(--min-confidence ambiguous to show)."
|
|
639
|
+
)
|
|
640
|
+
return
|
|
641
|
+
for e in entries:
|
|
642
|
+
conf = e.confidence.value if hasattr(e.confidence, "value") else e.confidence
|
|
643
|
+
console.print(
|
|
644
|
+
f"{e.path}:{e.line} {e.id} [{conf}/{e.kind}] {e.reason}"
|
|
645
|
+
)
|
|
646
|
+
reason_counts = Counter(e.reason or "(none)" for e in entries)
|
|
647
|
+
console.print("")
|
|
648
|
+
console.print("Reason summary:")
|
|
649
|
+
for reason, n in reason_counts.most_common():
|
|
650
|
+
console.print(f" {n:4d} {reason}")
|
|
651
|
+
if hidden:
|
|
652
|
+
console.print("")
|
|
653
|
+
console.print(
|
|
654
|
+
f"{hidden} lower-confidence entries hidden "
|
|
655
|
+
"(--min-confidence ambiguous to show)."
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
@app.command("check")
|
|
660
|
+
def graph_check_cmd(
|
|
661
|
+
project_root: Path = typer.Option(Path("."), "--project-root"),
|
|
662
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
663
|
+
top: int = typer.Option(15, "--top", help="How many god nodes to list."),
|
|
664
|
+
) -> None:
|
|
665
|
+
"""God nodes (top-connected) and circular-import component detection."""
|
|
666
|
+
from devcouncil.indexing.graph.intel import graph_check
|
|
667
|
+
|
|
668
|
+
root = _root(project_root)
|
|
669
|
+
graph = _require_graph(root)
|
|
670
|
+
report = graph_check(graph, top_n=top)
|
|
671
|
+
if json_output:
|
|
672
|
+
typer.echo(json.dumps(report, indent=2))
|
|
673
|
+
return
|
|
674
|
+
console.print(f"[bold]God nodes[/bold] (top {top} by degree)")
|
|
675
|
+
for g in report.get("god_nodes") or []:
|
|
676
|
+
console.print(
|
|
677
|
+
f" {g.get('degree'):>4} {g.get('id')} ({g.get('kind')})"
|
|
678
|
+
)
|
|
679
|
+
cycles = report.get("circular_imports") or []
|
|
680
|
+
console.print(
|
|
681
|
+
f"\n[bold]Circular imports — strongly connected components[/bold] ({len(cycles)})"
|
|
682
|
+
)
|
|
683
|
+
if not cycles:
|
|
684
|
+
console.print(" (none)")
|
|
685
|
+
for c in cycles[:30]:
|
|
686
|
+
console.print(" " + " ↔ ".join(c.get("nodes") or []))
|
|
687
|
+
package_init_count = report.get("package_init_count", 0)
|
|
688
|
+
if package_init_count:
|
|
689
|
+
console.print(
|
|
690
|
+
f" {package_init_count} package-__init__ component(s) suppressed as barrel noise"
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
@app.command("process")
|
|
695
|
+
def graph_process(
|
|
696
|
+
entry: Optional[str] = typer.Argument(
|
|
697
|
+
None, help="Optional entry root path or name filter."
|
|
698
|
+
),
|
|
699
|
+
project_root: Path = typer.Option(Path("."), "--project-root"),
|
|
700
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
701
|
+
max_depth: int = typer.Option(6, "--max-depth"),
|
|
702
|
+
) -> None:
|
|
703
|
+
"""BFS call-flows from entry roots (named, step-ordered, depth-capped)."""
|
|
704
|
+
from devcouncil.indexing.graph.intel import extract_processes
|
|
705
|
+
|
|
706
|
+
root = _root(project_root)
|
|
707
|
+
graph = _require_graph(root)
|
|
708
|
+
processes = extract_processes(graph, entry=entry, max_depth=max_depth)
|
|
709
|
+
if json_output:
|
|
710
|
+
typer.echo(json.dumps(processes, indent=2))
|
|
711
|
+
return
|
|
712
|
+
if not processes:
|
|
713
|
+
console.print("No processes found.")
|
|
714
|
+
return
|
|
715
|
+
for p in processes:
|
|
716
|
+
console.print(f"[bold]{p.get('name')}[/bold] (depth {p.get('depth')})")
|
|
717
|
+
console.print(" " + " → ".join(p.get("steps") or []))
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
@app.command("impact")
|
|
721
|
+
def graph_impact(
|
|
722
|
+
paths: Optional[List[str]] = typer.Argument(
|
|
723
|
+
None, help="Paths to analyze (omit with --diff for working-tree changes)."
|
|
724
|
+
),
|
|
725
|
+
diff: bool = typer.Option(
|
|
726
|
+
False, "--diff", help="Use working-tree changed files as the seed set."
|
|
727
|
+
),
|
|
728
|
+
project_root: Path = typer.Option(Path("."), "--project-root"),
|
|
729
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
730
|
+
max_depth: int = typer.Option(3, "--max-depth", help="Inbound blast depth (1–3)."),
|
|
731
|
+
) -> None:
|
|
732
|
+
"""Diff / path blast radius via enclosing symbols and inbound callers."""
|
|
733
|
+
from devcouncil.indexing.graph.intel import diff_impact
|
|
734
|
+
|
|
735
|
+
root = _root(project_root)
|
|
736
|
+
graph = _require_graph(root)
|
|
737
|
+
if not diff and not paths:
|
|
738
|
+
status.print("[red]Provide paths or --diff.[/red]")
|
|
739
|
+
raise typer.Exit(code=1)
|
|
740
|
+
result = diff_impact(
|
|
741
|
+
root,
|
|
742
|
+
graph,
|
|
743
|
+
paths=paths,
|
|
744
|
+
use_diff=diff,
|
|
745
|
+
max_depth=max(1, min(3, max_depth)),
|
|
746
|
+
)
|
|
747
|
+
if json_output:
|
|
748
|
+
typer.echo(json.dumps(result, indent=2))
|
|
749
|
+
return
|
|
750
|
+
if not result.get("paths"):
|
|
751
|
+
console.print("No impacted paths.")
|
|
752
|
+
return
|
|
753
|
+
for item in result["paths"]:
|
|
754
|
+
console.print(f"[bold]{item['path']}[/bold]")
|
|
755
|
+
syms = item.get("symbols") or []
|
|
756
|
+
if syms:
|
|
757
|
+
console.print(" symbols: " + ", ".join(s["id"] for s in syms[:8]))
|
|
758
|
+
for layer in (item.get("blast") or {}).get("layers") or []:
|
|
759
|
+
nodes = layer.get("nodes") or []
|
|
760
|
+
console.print(
|
|
761
|
+
f" depth {layer['depth']} [{layer['confidence']}]: "
|
|
762
|
+
f"{len(nodes)} — " + ", ".join(nodes[:6])
|
|
763
|
+
+ (" …" if len(nodes) > 6 else "")
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
@app.command("html")
|
|
768
|
+
def graph_html(
|
|
769
|
+
project_root: Path = typer.Option(Path("."), "--project-root"),
|
|
770
|
+
open_browser: bool = typer.Option(False, "--open", help="Open in the default browser."),
|
|
771
|
+
symbols: bool = typer.Option(
|
|
772
|
+
False,
|
|
773
|
+
"--symbols",
|
|
774
|
+
help="Default the visualizer to symbol-level mode (calls/inherits) instead of file imports.",
|
|
775
|
+
),
|
|
776
|
+
) -> None:
|
|
777
|
+
"""Write a self-contained interactive ``graph.html``."""
|
|
778
|
+
from devcouncil.indexing.viz import write_graph_html
|
|
779
|
+
|
|
780
|
+
root = _root(project_root)
|
|
781
|
+
try:
|
|
782
|
+
out = write_graph_html(root, open_browser=open_browser, symbols=symbols)
|
|
783
|
+
except FileNotFoundError as exc:
|
|
784
|
+
status.print(f"[red]{exc}[/red]")
|
|
785
|
+
raise typer.Exit(code=1) from exc
|
|
786
|
+
status.print(f"[green]Wrote {out}[/green]")
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
@app.command("demo")
|
|
790
|
+
def graph_demo(
|
|
791
|
+
project_root: Path = typer.Option(Path("."), "--project-root"),
|
|
792
|
+
open_browser: bool = typer.Option(False, "--open", help="Open the interactive demo."),
|
|
793
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
794
|
+
) -> None:
|
|
795
|
+
"""Write sample graph HTML and SVG artifacts without requiring a repo map."""
|
|
796
|
+
from devcouncil.indexing.viz import write_graph_demo
|
|
797
|
+
|
|
798
|
+
paths = write_graph_demo(_root(project_root), open_browser=open_browser)
|
|
799
|
+
payload = {name: str(path) for name, path in paths.items()}
|
|
800
|
+
if json_output:
|
|
801
|
+
typer.echo(json.dumps(payload, indent=2))
|
|
802
|
+
return
|
|
803
|
+
status.print(f"[green]Wrote {payload['html']} and {payload['svg']}[/green]")
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
@app.command("view")
|
|
807
|
+
def graph_view(
|
|
808
|
+
project_root: Path = typer.Option(Path("."), "--project-root"),
|
|
809
|
+
port: int = typer.Option(8765, "--port"),
|
|
810
|
+
) -> None:
|
|
811
|
+
"""Serve/open the graph HTML via a tiny local HTTP server."""
|
|
812
|
+
import http.server
|
|
813
|
+
import socketserver
|
|
814
|
+
import threading
|
|
815
|
+
import webbrowser
|
|
816
|
+
|
|
817
|
+
from devcouncil.indexing.viz import write_graph_html
|
|
818
|
+
|
|
819
|
+
root = _root(project_root)
|
|
820
|
+
try:
|
|
821
|
+
out = write_graph_html(root, open_browser=False)
|
|
822
|
+
except FileNotFoundError as exc:
|
|
823
|
+
status.print(f"[red]{exc}[/red]")
|
|
824
|
+
raise typer.Exit(code=1) from exc
|
|
825
|
+
|
|
826
|
+
directory = str(out.parent)
|
|
827
|
+
|
|
828
|
+
class Handler(http.server.SimpleHTTPRequestHandler):
|
|
829
|
+
def __init__(self, *args, **kwargs):
|
|
830
|
+
super().__init__(*args, directory=directory, **kwargs)
|
|
831
|
+
|
|
832
|
+
def log_message(self, fmt, *args): # noqa: A003
|
|
833
|
+
return
|
|
834
|
+
|
|
835
|
+
try:
|
|
836
|
+
httpd = socketserver.TCPServer(("127.0.0.1", port), Handler)
|
|
837
|
+
except OSError as exc:
|
|
838
|
+
status.print(f"[red]Cannot serve on 127.0.0.1:{port}: {exc} (try --port)[/red]")
|
|
839
|
+
raise typer.Exit(code=1) from exc
|
|
840
|
+
with httpd:
|
|
841
|
+
url = f"http://127.0.0.1:{port}/graph.html"
|
|
842
|
+
status.print(f"[green]Serving {url} (Ctrl-C to stop)[/green]")
|
|
843
|
+
threading.Timer(0.3, lambda: webbrowser.open(url)).start()
|
|
844
|
+
try:
|
|
845
|
+
httpd.serve_forever()
|
|
846
|
+
except KeyboardInterrupt:
|
|
847
|
+
status.print("Stopped.")
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
@app.command("export")
|
|
851
|
+
def graph_export(
|
|
852
|
+
format: str = typer.Option(
|
|
853
|
+
"graphml",
|
|
854
|
+
"--format",
|
|
855
|
+
help="graphml | okf | okf-links",
|
|
856
|
+
),
|
|
857
|
+
output: Path = typer.Option(Path("-"), "--output", "-o"),
|
|
858
|
+
project_root: Path = typer.Option(Path("."), "--project-root"),
|
|
859
|
+
) -> None:
|
|
860
|
+
"""Export the code graph as attributed GraphML or an OKF v0.1 bundle."""
|
|
861
|
+
from devcouncil.indexing.graph.export import export_graphml, write_code_graph_okf
|
|
862
|
+
|
|
863
|
+
root = _root(project_root)
|
|
864
|
+
graph = _require_graph(root)
|
|
865
|
+
fmt = format.lower().strip()
|
|
866
|
+
if fmt == "graphml":
|
|
867
|
+
text = export_graphml(graph)
|
|
868
|
+
if str(output) == "-":
|
|
869
|
+
typer.echo(text)
|
|
870
|
+
else:
|
|
871
|
+
out = output if output.is_absolute() else root / output
|
|
872
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
873
|
+
out.write_text(text, encoding="utf-8")
|
|
874
|
+
status.print(f"[green]Wrote {out}[/green]")
|
|
875
|
+
return
|
|
876
|
+
if fmt == "okf":
|
|
877
|
+
if str(output) == "-":
|
|
878
|
+
status.print("[red]OKF export requires -o <directory>[/red]")
|
|
879
|
+
raise typer.Exit(code=1)
|
|
880
|
+
out_dir = output if output.is_absolute() else root / output
|
|
881
|
+
try:
|
|
882
|
+
written_dir, paths = write_code_graph_okf(root, out_dir, graph=graph)
|
|
883
|
+
except FileNotFoundError as exc:
|
|
884
|
+
status.print(f"[red]{exc}[/red]")
|
|
885
|
+
raise typer.Exit(code=1) from exc
|
|
886
|
+
status.print(f"[green]Wrote OKF bundle ({len(paths)} docs) to {written_dir}[/green]")
|
|
887
|
+
return
|
|
888
|
+
if fmt in {"okf-links"}:
|
|
889
|
+
rows = []
|
|
890
|
+
for e in graph.edges:
|
|
891
|
+
if e.kind in {"imports", "calls"}:
|
|
892
|
+
rows.append(f"{e.source} --{e.kind}--> {e.target}")
|
|
893
|
+
text = "\n".join(rows)
|
|
894
|
+
if str(output) == "-":
|
|
895
|
+
typer.echo(text)
|
|
896
|
+
else:
|
|
897
|
+
out = output if output.is_absolute() else root / output
|
|
898
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
899
|
+
out.write_text(text, encoding="utf-8")
|
|
900
|
+
status.print(f"[green]Wrote {out}[/green]")
|
|
901
|
+
return
|
|
902
|
+
status.print(f"[red]Unknown format: {format}[/red]")
|
|
903
|
+
raise typer.Exit(code=1)
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
@app.command("routes")
|
|
907
|
+
def graph_routes(
|
|
908
|
+
project_root: Path = typer.Option(Path("."), "--project-root"),
|
|
909
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
910
|
+
) -> None:
|
|
911
|
+
"""Map HTTP routes to handlers and client fetch consumers."""
|
|
912
|
+
from devcouncil.indexing.graph.api_routes import route_map
|
|
913
|
+
|
|
914
|
+
root = _root(project_root)
|
|
915
|
+
graph = _require_graph(root)
|
|
916
|
+
result = route_map(root, graph)
|
|
917
|
+
if json_output:
|
|
918
|
+
typer.echo(json.dumps(result, indent=2))
|
|
919
|
+
return
|
|
920
|
+
routes = result.get("routes") or []
|
|
921
|
+
if not routes:
|
|
922
|
+
console.print("No routes found.")
|
|
923
|
+
return
|
|
924
|
+
for route in routes:
|
|
925
|
+
console.print(
|
|
926
|
+
f"[bold]{route.get('verb')} {route.get('path')}[/bold] "
|
|
927
|
+
f"({route.get('framework') or 'unknown'})"
|
|
928
|
+
)
|
|
929
|
+
handlers = route.get("handlers") or []
|
|
930
|
+
if handlers:
|
|
931
|
+
console.print(" handlers: " + ", ".join(h.get("id", "?") for h in handlers[:4]))
|
|
932
|
+
consumers = route.get("consumers") or []
|
|
933
|
+
if consumers:
|
|
934
|
+
console.print(f" consumers: {len(consumers)}")
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
@app.command("shape-check")
|
|
938
|
+
def graph_shape_check(
|
|
939
|
+
project_root: Path = typer.Option(Path("."), "--project-root"),
|
|
940
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
941
|
+
route: Optional[str] = typer.Option(None, "--route", help="Filter to one route path or id."),
|
|
942
|
+
) -> None:
|
|
943
|
+
"""Compare handler response keys vs client accessed keys."""
|
|
944
|
+
from devcouncil.indexing.graph.api_routes import shape_check
|
|
945
|
+
|
|
946
|
+
root = _root(project_root)
|
|
947
|
+
graph = _require_graph(root)
|
|
948
|
+
result = shape_check(root, graph, route_filter=route)
|
|
949
|
+
if json_output:
|
|
950
|
+
typer.echo(json.dumps(result, indent=2))
|
|
951
|
+
return
|
|
952
|
+
mismatches = result.get("mismatches") or []
|
|
953
|
+
if not mismatches:
|
|
954
|
+
console.print("[green]No shape mismatches.[/green]")
|
|
955
|
+
return
|
|
956
|
+
for item in mismatches:
|
|
957
|
+
console.print(
|
|
958
|
+
f"[yellow]{item.get('verb')} {item.get('route')}[/yellow] — "
|
|
959
|
+
f"missing in handler: {', '.join(item.get('missing_in_handler') or [])}"
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
@app.command("api-impact")
|
|
964
|
+
def graph_api_impact(
|
|
965
|
+
route_or_path: str = typer.Argument(..., help="Route path, id, or normalized segment."),
|
|
966
|
+
project_root: Path = typer.Option(Path("."), "--project-root"),
|
|
967
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
968
|
+
) -> None:
|
|
969
|
+
"""API blast radius: consumers, middleware, shape mismatches, risk tier."""
|
|
970
|
+
from devcouncil.indexing.graph.api_routes import api_impact
|
|
971
|
+
|
|
972
|
+
root = _root(project_root)
|
|
973
|
+
graph = _require_graph(root)
|
|
974
|
+
result = api_impact(root, route_or_path, graph)
|
|
975
|
+
if json_output:
|
|
976
|
+
typer.echo(json.dumps(result, indent=2))
|
|
977
|
+
return
|
|
978
|
+
if not result.get("found"):
|
|
979
|
+
console.print(f"[red]Route not found:[/red] {route_or_path}")
|
|
980
|
+
raise typer.Exit(code=1)
|
|
981
|
+
console.print(
|
|
982
|
+
f"[bold]{result.get('verb')} {result.get('route')}[/bold] "
|
|
983
|
+
f"risk={result.get('risk')}"
|
|
984
|
+
)
|
|
985
|
+
console.print(f" consumers: {len(result.get('consumers') or [])}")
|
|
986
|
+
console.print(f" middleware: {len(result.get('middleware') or [])}")
|
|
987
|
+
mismatches = result.get("shape_mismatches") or []
|
|
988
|
+
if mismatches:
|
|
989
|
+
console.print(f" shape mismatches: {len(mismatches)}")
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
corpus_app = typer.Typer(
|
|
993
|
+
name="corpus",
|
|
994
|
+
help="Advisory mixed-corpus index for docs, PDFs, and images (never verify gates).",
|
|
995
|
+
add_completion=False,
|
|
996
|
+
)
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
@corpus_app.command("build")
|
|
1000
|
+
def corpus_build(
|
|
1001
|
+
path: Optional[str] = typer.Option(None, "--path", help="Root file or directory to index."),
|
|
1002
|
+
project_root: Path = typer.Option(Path("."), "--project-root"),
|
|
1003
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
1004
|
+
) -> None:
|
|
1005
|
+
"""Build ``.devcouncil/corpus/graph.json`` from docs, PDFs, and images."""
|
|
1006
|
+
from devcouncil.indexing.wiring import build_corpus, corpus_status
|
|
1007
|
+
|
|
1008
|
+
root = _root(project_root)
|
|
1009
|
+
build_corpus(root, path=path)
|
|
1010
|
+
result = corpus_status(root)
|
|
1011
|
+
if json_output:
|
|
1012
|
+
typer.echo(json.dumps(result, indent=2))
|
|
1013
|
+
return
|
|
1014
|
+
status.print(
|
|
1015
|
+
f"[green]Corpus indexed — {result['node_count']} nodes, "
|
|
1016
|
+
f"{result['edge_count']} edges → {result.get('graph_path')}[/green]"
|
|
1017
|
+
)
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
@corpus_app.command("query")
|
|
1021
|
+
def corpus_query(
|
|
1022
|
+
query: str = typer.Argument(..., help="Search string."),
|
|
1023
|
+
project_root: Path = typer.Option(Path("."), "--project-root"),
|
|
1024
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
1025
|
+
limit: int = typer.Option(20, "--limit"),
|
|
1026
|
+
) -> None:
|
|
1027
|
+
"""Search the advisory corpus graph."""
|
|
1028
|
+
from devcouncil.indexing.wiring import query_corpus
|
|
1029
|
+
|
|
1030
|
+
root = _root(project_root)
|
|
1031
|
+
result = query_corpus(root, query, limit=limit)
|
|
1032
|
+
if json_output:
|
|
1033
|
+
typer.echo(json.dumps(result, indent=2))
|
|
1034
|
+
if result.get("error"):
|
|
1035
|
+
raise typer.Exit(code=1)
|
|
1036
|
+
return
|
|
1037
|
+
if result.get("error"):
|
|
1038
|
+
status.print(f"[red]{result['error']}[/red]")
|
|
1039
|
+
raise typer.Exit(code=1)
|
|
1040
|
+
matches = result.get("matches") or []
|
|
1041
|
+
if not matches:
|
|
1042
|
+
console.print(f"No matches for {query!r}")
|
|
1043
|
+
return
|
|
1044
|
+
for item in matches:
|
|
1045
|
+
console.print(
|
|
1046
|
+
f"[bold]{item['label']}[/bold] ({item['kind']}) "
|
|
1047
|
+
f"{item.get('path') or ''} score={item.get('score')}"
|
|
1048
|
+
)
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
@corpus_app.command("status")
|
|
1052
|
+
def corpus_status_cmd(
|
|
1053
|
+
project_root: Path = typer.Option(Path("."), "--project-root"),
|
|
1054
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
1055
|
+
) -> None:
|
|
1056
|
+
"""Show corpus artifact freshness and counts."""
|
|
1057
|
+
from devcouncil.indexing.wiring import corpus_status
|
|
1058
|
+
|
|
1059
|
+
root = _root(project_root)
|
|
1060
|
+
result = corpus_status(root)
|
|
1061
|
+
if json_output:
|
|
1062
|
+
typer.echo(json.dumps(result, indent=2))
|
|
1063
|
+
return
|
|
1064
|
+
console.print(f"enabled: {result['enabled']}")
|
|
1065
|
+
console.print(f"graph: {result.get('graph_path') or '(not built)'}")
|
|
1066
|
+
console.print(f"built_at: {result.get('built_at') or '(none)'}")
|
|
1067
|
+
console.print(f"nodes/edges: {result.get('node_count', 0)}/{result.get('edge_count', 0)}")
|
|
1068
|
+
console.print("advisory: yes (does not feed verify gates)")
|
|
1069
|
+
|
|
1070
|
+
|
|
1071
|
+
pdg_app = typer.Typer(
|
|
1072
|
+
name="pdg",
|
|
1073
|
+
help="Opt-in CFG / reaching-def / CDG / taint analysis (Python, intra-procedural).",
|
|
1074
|
+
add_completion=False,
|
|
1075
|
+
)
|
|
1076
|
+
app.add_typer(pdg_app, name="pdg")
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
@pdg_app.command("build")
|
|
1080
|
+
def graph_pdg_build(
|
|
1081
|
+
paths: List[str] = typer.Option([], "--path", help="Limit analysis to these repo-relative files."),
|
|
1082
|
+
project_root: Path = typer.Option(Path("."), "--project-root"),
|
|
1083
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
1084
|
+
) -> None:
|
|
1085
|
+
"""Build or refresh the PDG layer for Python files."""
|
|
1086
|
+
from devcouncil.indexing.graph.build import (
|
|
1087
|
+
CompatibilityGraphTooLarge,
|
|
1088
|
+
build_pdg_for_paths,
|
|
1089
|
+
merge_pdg_into_graph,
|
|
1090
|
+
write_code_graph,
|
|
1091
|
+
)
|
|
1092
|
+
|
|
1093
|
+
root = _root(project_root)
|
|
1094
|
+
graph = _require_graph(root)
|
|
1095
|
+
layer = build_pdg_for_paths(root, graph, paths=paths or None)
|
|
1096
|
+
shards = merge_pdg_into_graph(graph, layer)
|
|
1097
|
+
merged: dict = {}
|
|
1098
|
+
try:
|
|
1099
|
+
from devcouncil.codeintel import get_codeintel_service
|
|
1100
|
+
|
|
1101
|
+
merged = dict(get_codeintel_service(root).store.analysis_shards())
|
|
1102
|
+
except Exception:
|
|
1103
|
+
pass
|
|
1104
|
+
for path, payload in shards.items():
|
|
1105
|
+
merged.setdefault(path, {}).update(payload)
|
|
1106
|
+
export_warning = ""
|
|
1107
|
+
try:
|
|
1108
|
+
write_code_graph(root, graph, analysis_shards=merged)
|
|
1109
|
+
except CompatibilityGraphTooLarge as exc:
|
|
1110
|
+
# SQLite committed the PDG shards and a stub/pointer JSON is on disk;
|
|
1111
|
+
# only the compatibility export is degraded — not the PDG build.
|
|
1112
|
+
export_warning = str(exc)
|
|
1113
|
+
stats = (graph.meta.get("pdg") or {}).get("stats") or {}
|
|
1114
|
+
payload = {"ok": True, "stats": stats, "files": sorted(layer.files.keys())}
|
|
1115
|
+
if export_warning:
|
|
1116
|
+
payload["compatibility_export"] = "degraded"
|
|
1117
|
+
payload["compatibility_export_reason"] = export_warning
|
|
1118
|
+
if json_output:
|
|
1119
|
+
typer.echo(json.dumps(payload, indent=2))
|
|
1120
|
+
return
|
|
1121
|
+
console.print(
|
|
1122
|
+
f"PDG: {stats.get('function_count', 0)} functions, "
|
|
1123
|
+
f"{stats.get('taint_count', 0)} taint findings across {stats.get('file_count', 0)} files"
|
|
1124
|
+
)
|
|
1125
|
+
if export_warning:
|
|
1126
|
+
console.print(f"[yellow]compatibility export degraded: {export_warning}[/yellow]")
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
@app.command("explain")
|
|
1130
|
+
def graph_explain(
|
|
1131
|
+
path: Optional[str] = typer.Option(None, "--path", help="Filter by file path."),
|
|
1132
|
+
category: Optional[str] = typer.Option(None, "--category", help="Filter by taint category."),
|
|
1133
|
+
project_root: Path = typer.Option(Path("."), "--project-root"),
|
|
1134
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
1135
|
+
) -> None:
|
|
1136
|
+
"""Report heuristic taint findings from the opt-in PDG layer."""
|
|
1137
|
+
from devcouncil.indexing.graph.query import explain_pdg_taint
|
|
1138
|
+
|
|
1139
|
+
root = _root(project_root)
|
|
1140
|
+
result = explain_pdg_taint(root, path=path, category=category)
|
|
1141
|
+
if json_output:
|
|
1142
|
+
typer.echo(json.dumps(result, indent=2))
|
|
1143
|
+
if not result.get("ok"):
|
|
1144
|
+
raise typer.Exit(code=1)
|
|
1145
|
+
return
|
|
1146
|
+
if not result.get("ok"):
|
|
1147
|
+
status.print(f"[red]{result.get('error')}[/red]")
|
|
1148
|
+
raise typer.Exit(code=1)
|
|
1149
|
+
findings = result.get("findings") or []
|
|
1150
|
+
if not findings:
|
|
1151
|
+
console.print("No taint findings.")
|
|
1152
|
+
return
|
|
1153
|
+
for item in findings:
|
|
1154
|
+
console.print(
|
|
1155
|
+
f"{item.get('path')}:{item.get('sink_line')} "
|
|
1156
|
+
f"[{item.get('category')}] {item.get('function')} "
|
|
1157
|
+
f"{item.get('source_expr')} -> {item.get('sink_expr')}"
|
|
1158
|
+
)
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
@app.command("pdg-query")
|
|
1162
|
+
def graph_pdg_query(
|
|
1163
|
+
mode: str = typer.Option(..., "--mode", help="controls or flows"),
|
|
1164
|
+
target: str = typer.Option(..., "--target", help="Symbol qualname or file path."),
|
|
1165
|
+
variable: Optional[str] = typer.Option(None, "--variable", help="Filter flows by variable."),
|
|
1166
|
+
project_root: Path = typer.Option(Path("."), "--project-root"),
|
|
1167
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
1168
|
+
) -> None:
|
|
1169
|
+
"""Query control or data dependence for an anchored target."""
|
|
1170
|
+
from devcouncil.indexing.graph.query import query_pdg_controls, query_pdg_flows
|
|
1171
|
+
|
|
1172
|
+
root = _root(project_root)
|
|
1173
|
+
if mode == "controls":
|
|
1174
|
+
result = query_pdg_controls(root, target)
|
|
1175
|
+
elif mode == "flows":
|
|
1176
|
+
result = query_pdg_flows(root, target, variable=variable)
|
|
1177
|
+
else:
|
|
1178
|
+
status.print("[red]--mode must be controls or flows[/red]")
|
|
1179
|
+
raise typer.Exit(code=2)
|
|
1180
|
+
if json_output:
|
|
1181
|
+
typer.echo(json.dumps(result, indent=2))
|
|
1182
|
+
if not result.get("ok"):
|
|
1183
|
+
raise typer.Exit(code=1)
|
|
1184
|
+
return
|
|
1185
|
+
if not result.get("ok"):
|
|
1186
|
+
status.print(f"[red]{result.get('error')}[/red]")
|
|
1187
|
+
raise typer.Exit(code=1)
|
|
1188
|
+
for fn in result.get("functions") or []:
|
|
1189
|
+
console.print(f"[bold]{fn.get('qualname')}[/bold] ({fn.get('path')})")
|
|
1190
|
+
key = "cdg" if mode == "controls" else "reaching_def"
|
|
1191
|
+
for edge in fn.get(key) or []:
|
|
1192
|
+
console.print(f" {edge}")
|