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,668 @@
|
|
|
1
|
+
"""Graph intelligence: communities, god nodes, cycles, processes, diff impact.
|
|
2
|
+
|
|
3
|
+
LLM-free analysis over a :class:`CodeGraph` (NetworkX Louvain + BFS).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from collections import Counter, defaultdict, deque
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
|
|
12
|
+
|
|
13
|
+
from devcouncil.indexing.graph.schema import CodeGraph, GraphNode, NodeKind
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
_STRUCTURAL_KINDS = frozenset({"imports", "calls"})
|
|
18
|
+
_FILE_EDGE_KINDS = frozenset({"imports"})
|
|
19
|
+
_CALL_KINDS = frozenset({"calls"})
|
|
20
|
+
# Ambiguous call/import edges fan out to every candidate and inflate hubs,
|
|
21
|
+
# PageRank, process walks, and blast radius. Keep them in the store for
|
|
22
|
+
# explanation UIs, but exclude them from those ranked/BFS surfaces.
|
|
23
|
+
_METRIC_CONFIDENCE = frozenset({"extracted", "inferred"})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _edge_confidence(edge: object) -> str:
|
|
27
|
+
conf = getattr(edge, "confidence", None)
|
|
28
|
+
if conf is None:
|
|
29
|
+
return "extracted"
|
|
30
|
+
return conf.value if hasattr(conf, "value") else str(conf)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _metric_edge(edge: object) -> bool:
|
|
34
|
+
"""True when an edge should influence god-node / PageRank / process / impact."""
|
|
35
|
+
kind = getattr(edge, "kind", "")
|
|
36
|
+
if kind not in _STRUCTURAL_KINDS:
|
|
37
|
+
return False
|
|
38
|
+
return _edge_confidence(edge) in _METRIC_CONFIDENCE
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _is_file_node(n: GraphNode) -> bool:
|
|
42
|
+
kind = n.kind.value if hasattr(n.kind, "value") else str(n.kind)
|
|
43
|
+
return kind == NodeKind.FILE.value or ("::" not in n.id and not n.name)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _file_id(path_or_id: str) -> str:
|
|
47
|
+
return path_or_id.replace("\\", "/").split("::", 1)[0]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _path_prefix_label(paths: Sequence[str], *, depth: int = 2) -> str:
|
|
51
|
+
"""Dominant directory prefix among ``paths`` (LLM-free community label)."""
|
|
52
|
+
counts: Counter[str] = Counter()
|
|
53
|
+
for p in paths:
|
|
54
|
+
norm = p.replace("\\", "/").strip("/")
|
|
55
|
+
if not norm:
|
|
56
|
+
continue
|
|
57
|
+
parts = norm.split("/")
|
|
58
|
+
if len(parts) >= depth:
|
|
59
|
+
counts["/".join(parts[:depth])] += 1
|
|
60
|
+
elif parts:
|
|
61
|
+
counts[parts[0]] += 1
|
|
62
|
+
if not counts:
|
|
63
|
+
return "community"
|
|
64
|
+
return counts.most_common(1)[0][0]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def compute_communities(
|
|
68
|
+
graph: CodeGraph,
|
|
69
|
+
*,
|
|
70
|
+
seed: int = 0,
|
|
71
|
+
) -> Dict[str, Any]:
|
|
72
|
+
"""Louvain communities over import+call edges; label from dominant path prefixes.
|
|
73
|
+
|
|
74
|
+
Mutates ``graph.nodes`` in place, setting ``community``. Returns a summary dict
|
|
75
|
+
suitable for ``graph.meta["communities"]``.
|
|
76
|
+
"""
|
|
77
|
+
try:
|
|
78
|
+
from networkx.algorithms.community import louvain_communities
|
|
79
|
+
except ImportError:
|
|
80
|
+
logger.warning("networkx missing; skipping communities")
|
|
81
|
+
return {"communities": [], "count": 0}
|
|
82
|
+
|
|
83
|
+
# Prefer file-level import/call collapse for stable, path-labelable clusters.
|
|
84
|
+
file_edges: List[Tuple[str, str]] = []
|
|
85
|
+
seen: Set[Tuple[str, str]] = set()
|
|
86
|
+
for e in graph.edges:
|
|
87
|
+
if e.kind not in _STRUCTURAL_KINDS:
|
|
88
|
+
continue
|
|
89
|
+
a, b = _file_id(e.source), _file_id(e.target)
|
|
90
|
+
if a == b:
|
|
91
|
+
continue
|
|
92
|
+
key = (a, b) if a < b else (b, a)
|
|
93
|
+
if key in seen:
|
|
94
|
+
continue
|
|
95
|
+
seen.add(key)
|
|
96
|
+
file_edges.append(key)
|
|
97
|
+
|
|
98
|
+
import networkx as nx
|
|
99
|
+
|
|
100
|
+
G = nx.Graph()
|
|
101
|
+
G.add_edges_from(sorted(file_edges))
|
|
102
|
+
# Include isolated file nodes so every file gets a community id.
|
|
103
|
+
isolated_file_ids = sorted(
|
|
104
|
+
{_file_id(n.id) for n in graph.nodes if _is_file_node(n)}
|
|
105
|
+
)
|
|
106
|
+
for node_id in isolated_file_ids:
|
|
107
|
+
if node_id not in G:
|
|
108
|
+
G.add_node(node_id)
|
|
109
|
+
|
|
110
|
+
if G.number_of_nodes() == 0:
|
|
111
|
+
return {"communities": [], "count": 0}
|
|
112
|
+
|
|
113
|
+
# Bound Louvain so pathological graphs cannot stall a full map/graph build.
|
|
114
|
+
timeout_seconds = 15.0
|
|
115
|
+
|
|
116
|
+
def _run_louvain() -> list:
|
|
117
|
+
return list(louvain_communities(G, seed=seed, weight=None))
|
|
118
|
+
|
|
119
|
+
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
with ThreadPoolExecutor(max_workers=1) as pool:
|
|
123
|
+
raw = pool.submit(_run_louvain).result(timeout=timeout_seconds)
|
|
124
|
+
except FuturesTimeout:
|
|
125
|
+
logger.warning(
|
|
126
|
+
"community detection timed out after %.1fs (%d nodes); skipping",
|
|
127
|
+
timeout_seconds,
|
|
128
|
+
G.number_of_nodes(),
|
|
129
|
+
)
|
|
130
|
+
return {
|
|
131
|
+
"communities": [],
|
|
132
|
+
"count": 0,
|
|
133
|
+
"skipped": True,
|
|
134
|
+
"reason": f"louvain_timeout_{timeout_seconds:.0f}s",
|
|
135
|
+
}
|
|
136
|
+
except Exception:
|
|
137
|
+
logger.warning("community detection failed; skipping", exc_info=True)
|
|
138
|
+
return {"communities": [], "count": 0, "skipped": True, "reason": "louvain_error"}
|
|
139
|
+
# Deterministic order: sort communities by sorted member list.
|
|
140
|
+
ordered = sorted(
|
|
141
|
+
(sorted(c) for c in raw),
|
|
142
|
+
key=lambda members: (members[0] if members else ""),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
id_to_path = {_file_id(n.id): (n.path or _file_id(n.id)) for n in graph.nodes}
|
|
146
|
+
communities: List[Dict[str, Any]] = []
|
|
147
|
+
file_to_label: Dict[str, str] = {}
|
|
148
|
+
used_labels: Counter[str] = Counter()
|
|
149
|
+
|
|
150
|
+
for idx, members in enumerate(ordered):
|
|
151
|
+
paths = [id_to_path.get(m, m) for m in members]
|
|
152
|
+
base = _path_prefix_label(paths)
|
|
153
|
+
used_labels[base] += 1
|
|
154
|
+
label = base if used_labels[base] == 1 else f"{base}#{used_labels[base]}"
|
|
155
|
+
for m in members:
|
|
156
|
+
file_to_label[m] = label
|
|
157
|
+
communities.append(
|
|
158
|
+
{
|
|
159
|
+
"id": idx,
|
|
160
|
+
"label": label,
|
|
161
|
+
"size": len(members),
|
|
162
|
+
"files": members[:40],
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
for n in graph.nodes:
|
|
167
|
+
fid = _file_id(n.path or n.id)
|
|
168
|
+
n.community = file_to_label.get(fid, "")
|
|
169
|
+
|
|
170
|
+
return {"communities": communities, "count": len(communities)}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _pagerank_power_iteration(
|
|
174
|
+
edges: List[tuple],
|
|
175
|
+
*,
|
|
176
|
+
alpha: float = 0.85,
|
|
177
|
+
max_iter: int = 100,
|
|
178
|
+
tol: float = 1.0e-8,
|
|
179
|
+
) -> Dict[str, float]:
|
|
180
|
+
"""Deterministic pure-Python PageRank (networkx semantics, no numpy/scipy).
|
|
181
|
+
|
|
182
|
+
networkx's pagerank imports scipy→numpy at call time; installs without them
|
|
183
|
+
raise and previously zeroed every score. Fine for code-graph sizes.
|
|
184
|
+
"""
|
|
185
|
+
nodes = sorted({n for pair in edges for n in pair})
|
|
186
|
+
if not nodes:
|
|
187
|
+
return {}
|
|
188
|
+
out_links: Dict[str, List[str]] = {n: [] for n in nodes}
|
|
189
|
+
for src, dst in edges:
|
|
190
|
+
out_links[src].append(dst)
|
|
191
|
+
count = len(nodes)
|
|
192
|
+
rank = dict.fromkeys(nodes, 1.0 / count)
|
|
193
|
+
for _ in range(max_iter):
|
|
194
|
+
prev = rank
|
|
195
|
+
dangling = sum(prev[n] for n in nodes if not out_links[n])
|
|
196
|
+
base = (1.0 - alpha) / count + alpha * dangling / count
|
|
197
|
+
rank = dict.fromkeys(nodes, base)
|
|
198
|
+
for src in nodes:
|
|
199
|
+
targets = out_links[src]
|
|
200
|
+
if not targets:
|
|
201
|
+
continue
|
|
202
|
+
share = alpha * prev[src] / len(targets)
|
|
203
|
+
for dst in targets:
|
|
204
|
+
rank[dst] += share
|
|
205
|
+
if sum(abs(rank[n] - prev[n]) for n in nodes) < count * tol:
|
|
206
|
+
break
|
|
207
|
+
return rank
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def pagerank_scores(graph: CodeGraph) -> Dict[str, float]:
|
|
211
|
+
"""PageRank over the directed import+call graph.
|
|
212
|
+
|
|
213
|
+
Prefers networkx; falls back to a pure-Python power iteration when
|
|
214
|
+
networkx (or its numpy/scipy solver deps) is unavailable. Ambiguous
|
|
215
|
+
edges are excluded so name-collision fan-out cannot dominate ranks.
|
|
216
|
+
"""
|
|
217
|
+
structural = sorted(
|
|
218
|
+
{
|
|
219
|
+
(e.source, e.target)
|
|
220
|
+
for e in graph.edges
|
|
221
|
+
if _metric_edge(e) and e.source != e.target
|
|
222
|
+
}
|
|
223
|
+
)
|
|
224
|
+
if not structural:
|
|
225
|
+
return {}
|
|
226
|
+
raw: Dict[str, float] = {}
|
|
227
|
+
try:
|
|
228
|
+
import networkx as nx
|
|
229
|
+
|
|
230
|
+
G = nx.DiGraph()
|
|
231
|
+
G.add_edges_from(structural)
|
|
232
|
+
raw = dict(nx.pagerank(G, alpha=0.85, max_iter=100, tol=1.0e-8))
|
|
233
|
+
except Exception:
|
|
234
|
+
logger.debug(
|
|
235
|
+
"networkx pagerank unavailable; using pure-Python fallback", exc_info=True
|
|
236
|
+
)
|
|
237
|
+
raw = _pagerank_power_iteration(structural)
|
|
238
|
+
# Fixed precision so tiny solver/order noise cannot flip JSON bytes.
|
|
239
|
+
return {nid: round(float(score), 4) for nid, score in raw.items()}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def god_nodes(graph: CodeGraph, *, top_n: int = 15) -> List[Dict[str, Any]]:
|
|
243
|
+
"""Top-connected production symbols/files: degree, fan-in/fan-out, PageRank.
|
|
244
|
+
|
|
245
|
+
Test-path nodes are excluded from the ranking — shared mocks and fixture
|
|
246
|
+
helpers accumulate huge fan-in and would otherwise crowd out real hubs.
|
|
247
|
+
Ambiguous edges are excluded for the same reason (collision fan-out).
|
|
248
|
+
"""
|
|
249
|
+
from devcouncil.indexing.wiring import is_test_path
|
|
250
|
+
|
|
251
|
+
degree: Counter[str] = Counter()
|
|
252
|
+
fan_in: Counter[str] = Counter()
|
|
253
|
+
fan_out: Counter[str] = Counter()
|
|
254
|
+
for e in graph.edges:
|
|
255
|
+
if not _metric_edge(e):
|
|
256
|
+
continue
|
|
257
|
+
degree[e.source] += 1
|
|
258
|
+
degree[e.target] += 1
|
|
259
|
+
fan_out[e.source] += 1
|
|
260
|
+
fan_in[e.target] += 1
|
|
261
|
+
pr = pagerank_scores(graph)
|
|
262
|
+
by_id = graph.node_by_id()
|
|
263
|
+
|
|
264
|
+
def _is_test_node(nid: str) -> bool:
|
|
265
|
+
n = by_id.get(nid)
|
|
266
|
+
return is_test_path(n.path if n and n.path else _file_id(nid))
|
|
267
|
+
|
|
268
|
+
ranked = sorted(
|
|
269
|
+
((nid, deg) for nid, deg in degree.items() if not _is_test_node(nid)),
|
|
270
|
+
key=lambda item: (-item[1], item[0]),
|
|
271
|
+
)[:top_n]
|
|
272
|
+
out: List[Dict[str, Any]] = []
|
|
273
|
+
for nid, deg in ranked:
|
|
274
|
+
n = by_id.get(nid)
|
|
275
|
+
out.append(
|
|
276
|
+
{
|
|
277
|
+
"id": nid,
|
|
278
|
+
"degree": deg,
|
|
279
|
+
"fan_in": fan_in.get(nid, 0),
|
|
280
|
+
"fan_out": fan_out.get(nid, 0),
|
|
281
|
+
"pagerank": pr.get(nid, 0.0),
|
|
282
|
+
"kind": (
|
|
283
|
+
n.kind.value if n and hasattr(n.kind, "value") else (n.kind if n else "")
|
|
284
|
+
),
|
|
285
|
+
"path": n.path if n else _file_id(nid),
|
|
286
|
+
"name": n.name if n else "",
|
|
287
|
+
"community": n.community if n else "",
|
|
288
|
+
}
|
|
289
|
+
)
|
|
290
|
+
return out
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def hotspots(
|
|
294
|
+
root: Path,
|
|
295
|
+
graph: CodeGraph,
|
|
296
|
+
*,
|
|
297
|
+
since: str = "90.days",
|
|
298
|
+
top_n: int = 20,
|
|
299
|
+
) -> List[Dict[str, Any]]:
|
|
300
|
+
"""Churn × coupling hotspots: files changed often AND heavily depended on.
|
|
301
|
+
|
|
302
|
+
Score = commits touching the file (last ``since``) × (1 + fan-in).
|
|
303
|
+
High scores are refactor-risk files where a change ripples widest.
|
|
304
|
+
"""
|
|
305
|
+
import math
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
from devcouncil.utils.proc import git_output
|
|
309
|
+
|
|
310
|
+
raw = git_output(
|
|
311
|
+
["log", f"--since={since}", "--name-only", "--pretty=format:"],
|
|
312
|
+
cwd=root,
|
|
313
|
+
default="",
|
|
314
|
+
)
|
|
315
|
+
except Exception:
|
|
316
|
+
logger.debug("hotspot churn scan failed", exc_info=True)
|
|
317
|
+
return []
|
|
318
|
+
churn: Counter[str] = Counter()
|
|
319
|
+
for line in raw.splitlines():
|
|
320
|
+
p = line.strip().replace("\\", "/")
|
|
321
|
+
if p:
|
|
322
|
+
churn[p] += 1
|
|
323
|
+
if not churn:
|
|
324
|
+
return []
|
|
325
|
+
|
|
326
|
+
fan_in: Counter[str] = Counter()
|
|
327
|
+
for e in graph.edges:
|
|
328
|
+
if e.kind in _FILE_EDGE_KINDS and "::" not in e.source and "::" not in e.target:
|
|
329
|
+
fan_in[e.target] += 1
|
|
330
|
+
|
|
331
|
+
file_paths = {n.path or n.id for n in graph.nodes if _is_file_node(n)}
|
|
332
|
+
scored: List[Dict[str, Any]] = []
|
|
333
|
+
for path, count in churn.items():
|
|
334
|
+
if path not in file_paths:
|
|
335
|
+
continue
|
|
336
|
+
fi = fan_in.get(path, 0)
|
|
337
|
+
scored.append(
|
|
338
|
+
{
|
|
339
|
+
"path": path,
|
|
340
|
+
"churn": count,
|
|
341
|
+
"fan_in": fi,
|
|
342
|
+
"score": round(count * (1 + math.log1p(fi)), 2),
|
|
343
|
+
}
|
|
344
|
+
)
|
|
345
|
+
scored.sort(key=lambda h: (-h["score"], h["path"]))
|
|
346
|
+
return scored[:top_n]
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _is_package_init(path: str) -> bool:
|
|
350
|
+
return Path(path.replace("\\", "/")).name == "__init__.py"
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _import_components(
|
|
354
|
+
graph: CodeGraph,
|
|
355
|
+
*,
|
|
356
|
+
include_package_inits: bool,
|
|
357
|
+
) -> List[Dict[str, Any]]:
|
|
358
|
+
"""Deterministic strongly connected components in the file import graph."""
|
|
359
|
+
try:
|
|
360
|
+
import networkx as nx
|
|
361
|
+
except ImportError:
|
|
362
|
+
return []
|
|
363
|
+
|
|
364
|
+
G = nx.DiGraph()
|
|
365
|
+
for e in graph.edges:
|
|
366
|
+
if e.kind not in _FILE_EDGE_KINDS:
|
|
367
|
+
continue
|
|
368
|
+
src, tgt = e.source, e.target
|
|
369
|
+
if "::" in src or "::" in tgt:
|
|
370
|
+
continue
|
|
371
|
+
if not include_package_inits and (
|
|
372
|
+
_is_package_init(src) or _is_package_init(tgt)
|
|
373
|
+
):
|
|
374
|
+
continue
|
|
375
|
+
if src != tgt:
|
|
376
|
+
G.add_edge(src, tgt)
|
|
377
|
+
|
|
378
|
+
try:
|
|
379
|
+
components = [
|
|
380
|
+
sorted(component)
|
|
381
|
+
for component in nx.strongly_connected_components(G)
|
|
382
|
+
if len(component) >= 2
|
|
383
|
+
]
|
|
384
|
+
except Exception:
|
|
385
|
+
logger.debug("cycle detection failed", exc_info=True)
|
|
386
|
+
return []
|
|
387
|
+
|
|
388
|
+
components.sort(key=lambda nodes: (len(nodes), nodes))
|
|
389
|
+
return [{"nodes": nodes, "length": len(nodes)} for nodes in components]
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def circular_imports(graph: CodeGraph, *, max_cycles: int = 50) -> List[Dict[str, Any]]:
|
|
393
|
+
"""Report actionable import SCCs, excluding Python package-init barrel noise."""
|
|
394
|
+
return _import_components(graph, include_package_inits=False)[:max_cycles]
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def graph_check(graph: CodeGraph, *, top_n: int = 15) -> Dict[str, Any]:
|
|
398
|
+
"""God nodes + circular imports report (``dev graph check``)."""
|
|
399
|
+
gods = god_nodes(graph, top_n=top_n)
|
|
400
|
+
cycles = circular_imports(graph)
|
|
401
|
+
package_init_components = [
|
|
402
|
+
component
|
|
403
|
+
for component in _import_components(graph, include_package_inits=True)
|
|
404
|
+
if any(_is_package_init(node) for node in component["nodes"])
|
|
405
|
+
]
|
|
406
|
+
return {
|
|
407
|
+
"god_nodes": gods,
|
|
408
|
+
"circular_imports": cycles,
|
|
409
|
+
"package_init_imports": package_init_components,
|
|
410
|
+
"god_count": len(gods),
|
|
411
|
+
"cycle_count": len(cycles),
|
|
412
|
+
"package_init_count": len(package_init_components),
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _call_adjacency(graph: CodeGraph) -> Dict[str, List[str]]:
|
|
417
|
+
adj: Dict[str, List[str]] = defaultdict(list)
|
|
418
|
+
for e in graph.edges:
|
|
419
|
+
if e.kind in _CALL_KINDS and _edge_confidence(e) in _METRIC_CONFIDENCE:
|
|
420
|
+
adj[e.source].append(e.target)
|
|
421
|
+
return {k: sorted(set(v)) for k, v in adj.items()}
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _entry_symbols(graph: CodeGraph, entry_root: str) -> List[str]:
|
|
425
|
+
"""Symbol ids defined in an entry-root file (prefer functions named main/run/cli)."""
|
|
426
|
+
root = entry_root.replace("\\", "/")
|
|
427
|
+
symbols: List[GraphNode] = []
|
|
428
|
+
for n in graph.nodes:
|
|
429
|
+
if _is_file_node(n):
|
|
430
|
+
continue
|
|
431
|
+
if (n.path or _file_id(n.id)) == root:
|
|
432
|
+
symbols.append(n)
|
|
433
|
+
if not symbols:
|
|
434
|
+
return [root] if any(n.id == root for n in graph.nodes) else []
|
|
435
|
+
|
|
436
|
+
preferred = {"main", "run", "cli", "app", "handler", "execute"}
|
|
437
|
+
ranked = sorted(
|
|
438
|
+
symbols,
|
|
439
|
+
key=lambda n: (
|
|
440
|
+
0 if n.name in preferred else 1,
|
|
441
|
+
0
|
|
442
|
+
if (n.kind.value if hasattr(n.kind, "value") else str(n.kind)) == "function"
|
|
443
|
+
else 1,
|
|
444
|
+
n.line,
|
|
445
|
+
n.id,
|
|
446
|
+
),
|
|
447
|
+
)
|
|
448
|
+
return [n.id for n in ranked[:5]]
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def extract_processes(
|
|
452
|
+
graph: CodeGraph,
|
|
453
|
+
*,
|
|
454
|
+
entry: Optional[str] = None,
|
|
455
|
+
max_depth: int = 6,
|
|
456
|
+
max_processes: int = 20,
|
|
457
|
+
max_steps: int = 24,
|
|
458
|
+
) -> List[Dict[str, Any]]:
|
|
459
|
+
"""BFS call-flows from entry roots (named, step-ordered, depth-capped)."""
|
|
460
|
+
adj = _call_adjacency(graph)
|
|
461
|
+
roots = list(graph.entry_roots)
|
|
462
|
+
if entry:
|
|
463
|
+
q = entry.replace("\\", "/")
|
|
464
|
+
roots = [r for r in roots if r == q or r.endswith(q) or q in r]
|
|
465
|
+
if not roots:
|
|
466
|
+
roots = [q]
|
|
467
|
+
|
|
468
|
+
processes: List[Dict[str, Any]] = []
|
|
469
|
+
for root in roots:
|
|
470
|
+
starts = _entry_symbols(graph, root)
|
|
471
|
+
if not starts and root:
|
|
472
|
+
starts = [root]
|
|
473
|
+
for start in starts:
|
|
474
|
+
steps: List[str] = []
|
|
475
|
+
seen: Set[str] = set()
|
|
476
|
+
queue: deque[Tuple[str, int]] = deque([(start, 0)])
|
|
477
|
+
while queue and len(steps) < max_steps:
|
|
478
|
+
node, depth = queue.popleft()
|
|
479
|
+
if node in seen:
|
|
480
|
+
continue
|
|
481
|
+
seen.add(node)
|
|
482
|
+
steps.append(node)
|
|
483
|
+
if depth >= max_depth:
|
|
484
|
+
continue
|
|
485
|
+
for nxt in adj.get(node, []):
|
|
486
|
+
if nxt not in seen:
|
|
487
|
+
queue.append((nxt, depth + 1))
|
|
488
|
+
name = f"{Path(root).name}"
|
|
489
|
+
if start != root and "::" in start:
|
|
490
|
+
name = f"{Path(root).name}::{start.split('::', 1)[-1]}"
|
|
491
|
+
processes.append(
|
|
492
|
+
{
|
|
493
|
+
"name": name,
|
|
494
|
+
"entry": start,
|
|
495
|
+
"entry_root": root,
|
|
496
|
+
"steps": steps,
|
|
497
|
+
"depth": max(0, len(steps) - 1),
|
|
498
|
+
}
|
|
499
|
+
)
|
|
500
|
+
if len(processes) >= max_processes:
|
|
501
|
+
return processes
|
|
502
|
+
processes.sort(key=lambda p: (-p["depth"], -len(p["steps"]), p["name"]))
|
|
503
|
+
return processes[:max_processes]
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _enclosing_symbols(graph: CodeGraph, path: str) -> List[GraphNode]:
|
|
507
|
+
norm = path.replace("\\", "/")
|
|
508
|
+
hits = [
|
|
509
|
+
n
|
|
510
|
+
for n in graph.nodes
|
|
511
|
+
if not _is_file_node(n) and (n.path or _file_id(n.id)) == norm
|
|
512
|
+
]
|
|
513
|
+
return sorted(hits, key=lambda n: (n.line, n.id))
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _inbound_adj(graph: CodeGraph) -> Dict[str, List[str]]:
|
|
517
|
+
"""Callers/importers → reverse edges for blast radius.
|
|
518
|
+
|
|
519
|
+
Ambiguous edges are omitted so collision fan-out does not inflate impact.
|
|
520
|
+
"""
|
|
521
|
+
rev: Dict[str, List[str]] = defaultdict(list)
|
|
522
|
+
for e in graph.edges:
|
|
523
|
+
if _metric_edge(e):
|
|
524
|
+
rev[e.target].append(e.source)
|
|
525
|
+
return {k: sorted(set(v)) for k, v in rev.items()}
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def blast_radius(
|
|
529
|
+
graph: CodeGraph,
|
|
530
|
+
seed_ids: Sequence[str],
|
|
531
|
+
*,
|
|
532
|
+
max_depth: int = 3,
|
|
533
|
+
) -> Dict[str, Any]:
|
|
534
|
+
"""Inbound blast radius at depths 1/2/3 with confidence tiers.
|
|
535
|
+
|
|
536
|
+
Traversal follows extracted/inferred import+call edges only. Layer labels
|
|
537
|
+
remain depth-based (depth 1 extracted … depth 3 ambiguous) as a distance
|
|
538
|
+
heuristic, not a claim about edge confidence.
|
|
539
|
+
"""
|
|
540
|
+
rev = _inbound_adj(graph)
|
|
541
|
+
by_depth: Dict[int, List[str]] = {1: [], 2: [], 3: []}
|
|
542
|
+
confidence_for_depth = {1: "extracted", 2: "inferred", 3: "ambiguous"}
|
|
543
|
+
seen: Set[str] = set(seed_ids)
|
|
544
|
+
frontier = set(seed_ids)
|
|
545
|
+
for depth in range(1, max_depth + 1):
|
|
546
|
+
nxt: Set[str] = set()
|
|
547
|
+
for nid in frontier:
|
|
548
|
+
for caller in rev.get(nid, []):
|
|
549
|
+
if caller in seen:
|
|
550
|
+
continue
|
|
551
|
+
seen.add(caller)
|
|
552
|
+
nxt.add(caller)
|
|
553
|
+
by_depth[depth].append(caller)
|
|
554
|
+
by_depth[depth] = sorted(by_depth[depth])
|
|
555
|
+
frontier = nxt
|
|
556
|
+
if not frontier:
|
|
557
|
+
break
|
|
558
|
+
|
|
559
|
+
layers = []
|
|
560
|
+
for d in range(1, max_depth + 1):
|
|
561
|
+
layers.append(
|
|
562
|
+
{
|
|
563
|
+
"depth": d,
|
|
564
|
+
"confidence": confidence_for_depth[d],
|
|
565
|
+
"nodes": by_depth[d],
|
|
566
|
+
"count": len(by_depth[d]),
|
|
567
|
+
}
|
|
568
|
+
)
|
|
569
|
+
return {
|
|
570
|
+
"seeds": list(seed_ids),
|
|
571
|
+
"layers": layers,
|
|
572
|
+
"total_impacted": sum(len(by_depth[d]) for d in by_depth),
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def working_tree_changed_paths(root: Path) -> List[str]:
|
|
577
|
+
"""Repository-relative paths changed in the working tree (unstaged+staged)."""
|
|
578
|
+
try:
|
|
579
|
+
from devcouncil.verification.git_diff_fallback import GitDiffFallback
|
|
580
|
+
|
|
581
|
+
return [p.replace("\\", "/") for p in GitDiffFallback(root).get_changed_files()]
|
|
582
|
+
except Exception:
|
|
583
|
+
logger.debug("working tree paths failed", exc_info=True)
|
|
584
|
+
return []
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def diff_impact(
|
|
588
|
+
root: Path,
|
|
589
|
+
graph: CodeGraph,
|
|
590
|
+
*,
|
|
591
|
+
paths: Optional[Sequence[str]] = None,
|
|
592
|
+
use_diff: bool = False,
|
|
593
|
+
max_depth: int = 3,
|
|
594
|
+
) -> Dict[str, Any]:
|
|
595
|
+
"""Map paths (or working-tree diff) → enclosing symbols → inbound blast radius."""
|
|
596
|
+
if use_diff or not paths:
|
|
597
|
+
changed = working_tree_changed_paths(root)
|
|
598
|
+
if paths:
|
|
599
|
+
want = {p.replace("\\", "/") for p in paths}
|
|
600
|
+
changed = [p for p in changed if p in want]
|
|
601
|
+
target_paths = changed
|
|
602
|
+
else:
|
|
603
|
+
target_paths = [p.replace("\\", "/") for p in paths]
|
|
604
|
+
|
|
605
|
+
items: List[Dict[str, Any]] = []
|
|
606
|
+
all_seeds: List[str] = []
|
|
607
|
+
for path in target_paths:
|
|
608
|
+
symbols = _enclosing_symbols(graph, path)
|
|
609
|
+
seed_ids = [s.id for s in symbols] or [path]
|
|
610
|
+
all_seeds.extend(seed_ids)
|
|
611
|
+
radius = blast_radius(graph, seed_ids, max_depth=max_depth)
|
|
612
|
+
items.append(
|
|
613
|
+
{
|
|
614
|
+
"path": path,
|
|
615
|
+
"symbols": [
|
|
616
|
+
{
|
|
617
|
+
"id": s.id,
|
|
618
|
+
"name": s.name,
|
|
619
|
+
"line": s.line,
|
|
620
|
+
"kind": s.kind.value if hasattr(s.kind, "value") else str(s.kind),
|
|
621
|
+
}
|
|
622
|
+
for s in symbols[:40]
|
|
623
|
+
],
|
|
624
|
+
"blast": radius,
|
|
625
|
+
}
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
aggregate = (
|
|
629
|
+
blast_radius(graph, all_seeds, max_depth=max_depth)
|
|
630
|
+
if all_seeds
|
|
631
|
+
else {"seeds": [], "layers": [], "total_impacted": 0}
|
|
632
|
+
)
|
|
633
|
+
return {
|
|
634
|
+
"paths": items,
|
|
635
|
+
"aggregate": aggregate,
|
|
636
|
+
"path_count": len(items),
|
|
637
|
+
"source": "diff" if (use_diff or not paths) else "paths",
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def enrich_graph_intel(
|
|
642
|
+
graph: CodeGraph, *, root: Optional[Path] = None, seed: int = 0
|
|
643
|
+
) -> CodeGraph:
|
|
644
|
+
"""Run communities + processes + centrality + hotspots; persist to ``graph.meta``."""
|
|
645
|
+
community_summary = compute_communities(graph, seed=seed)
|
|
646
|
+
processes = extract_processes(graph)
|
|
647
|
+
meta = dict(graph.meta or {})
|
|
648
|
+
meta["communities"] = community_summary
|
|
649
|
+
# Per-node community map so downstream consumers (viz) never have to
|
|
650
|
+
# re-derive it from mutated node attributes.
|
|
651
|
+
meta["node_communities"] = {
|
|
652
|
+
n.id: n.community for n in graph.nodes if n.community
|
|
653
|
+
}
|
|
654
|
+
meta["processes"] = processes[:12]
|
|
655
|
+
meta["god_nodes"] = god_nodes(graph)[:15]
|
|
656
|
+
meta["circular_imports"] = circular_imports(graph)[:30]
|
|
657
|
+
meta["package_init_import_count"] = sum(
|
|
658
|
+
any(_is_package_init(node) for node in component["nodes"])
|
|
659
|
+
for component in _import_components(graph, include_package_inits=True)
|
|
660
|
+
)
|
|
661
|
+
if root is not None:
|
|
662
|
+
meta["hotspots"] = hotspots(root, graph)
|
|
663
|
+
graph.meta = meta
|
|
664
|
+
return graph
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
# Re-export leaf helper for callers that still import from intel.
|
|
668
|
+
from devcouncil.indexing.graph.communities import community_label_for_area # noqa: E402,F401
|