devcouncil 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -30
- package/package.json +6 -2
- package/packages/codeintel-grammars/hatch_build.py +43 -0
- package/packages/codeintel-grammars/pyproject.toml +16 -0
- package/packages/codeintel-grammars/src/devcouncil_codeintel_grammars/__init__.py +93 -0
- package/pyproject.toml +99 -4
- package/src/devcouncil/app/config.py +512 -20
- package/src/devcouncil/app/events.py +4 -23
- package/src/devcouncil/app/orchestrator.py +5 -0
- package/src/devcouncil/app/run_context.py +3 -3
- package/src/devcouncil/assets/__init__.py +4 -1
- package/src/devcouncil/assets/vendor/force-graph.min.js +5 -0
- package/src/devcouncil/campaign/__init__.py +71 -0
- package/src/devcouncil/campaign/bloom.py +137 -0
- package/src/devcouncil/campaign/dashboard.py +123 -0
- package/src/devcouncil/campaign/mailbox.py +305 -0
- package/src/devcouncil/campaign/notify.py +91 -0
- package/src/devcouncil/campaign/orchestrator.py +592 -0
- package/src/devcouncil/campaign/prompts/coordinator.md +29 -0
- package/src/devcouncil/campaign/prompts/director.md +21 -0
- package/src/devcouncil/campaign/prompts/protocol.md +46 -0
- package/src/devcouncil/campaign/prompts/reviewer.md +24 -0
- package/src/devcouncil/campaign/prompts/worker.md +24 -0
- package/src/devcouncil/campaign/roles.py +202 -0
- package/src/devcouncil/campaign/watcher.py +153 -0
- package/src/devcouncil/cli/commands/agents.py +24 -17
- package/src/devcouncil/cli/commands/artifacts.py +36 -27
- package/src/devcouncil/cli/commands/ast.py +12 -3
- package/src/devcouncil/cli/commands/baseline.py +21 -12
- package/src/devcouncil/cli/commands/boot.py +218 -0
- package/src/devcouncil/cli/commands/campaign.py +302 -0
- package/src/devcouncil/cli/commands/check.py +225 -12
- package/src/devcouncil/cli/commands/config.py +221 -74
- package/src/devcouncil/cli/commands/cost.py +137 -28
- package/src/devcouncil/cli/commands/dashboard.py +12 -4
- package/src/devcouncil/cli/commands/debug_cmd.py +249 -0
- package/src/devcouncil/cli/commands/design.py +27 -17
- package/src/devcouncil/cli/commands/doctor.py +790 -8
- package/src/devcouncil/cli/commands/evidence.py +41 -20
- package/src/devcouncil/cli/commands/export.py +73 -0
- package/src/devcouncil/cli/commands/gaps.py +175 -0
- package/src/devcouncil/cli/commands/gated_write.py +76 -0
- package/src/devcouncil/cli/commands/go.py +220 -68
- package/src/devcouncil/cli/commands/graph_cmd.py +1192 -0
- package/src/devcouncil/cli/commands/handoff.py +45 -34
- package/src/devcouncil/cli/commands/hook.py +630 -85
- package/src/devcouncil/cli/commands/init.py +89 -30
- package/src/devcouncil/cli/commands/integrate.py +296 -1385
- package/src/devcouncil/cli/commands/lease.py +120 -0
- package/src/devcouncil/cli/commands/logs.py +12 -5
- package/src/devcouncil/cli/commands/lsp.py +40 -5
- package/src/devcouncil/cli/commands/map.py +317 -74
- package/src/devcouncil/cli/commands/mcp_server.py +12 -2
- package/src/devcouncil/cli/commands/okf.py +44 -6
- package/src/devcouncil/cli/commands/plan.py +184 -69
- package/src/devcouncil/cli/commands/prompt.py +26 -17
- package/src/devcouncil/cli/commands/provenance.py +79 -0
- package/src/devcouncil/cli/commands/repair.py +60 -49
- package/src/devcouncil/cli/commands/report.py +148 -40
- package/src/devcouncil/cli/commands/requirements.py +104 -0
- package/src/devcouncil/cli/commands/reset_demo_state.py +13 -4
- package/src/devcouncil/cli/commands/rollback.py +46 -35
- package/src/devcouncil/cli/commands/run.py +173 -8
- package/src/devcouncil/cli/commands/runs.py +298 -68
- package/src/devcouncil/cli/commands/scaffold.py +33 -12
- package/src/devcouncil/cli/commands/semantic.py +29 -14
- package/src/devcouncil/cli/commands/setup.py +103 -93
- package/src/devcouncil/cli/commands/shell.py +51 -42
- package/src/devcouncil/cli/commands/show.py +56 -42
- package/src/devcouncil/cli/commands/skills.py +29 -20
- package/src/devcouncil/cli/commands/status.py +80 -67
- package/src/devcouncil/cli/commands/task_gate.py +295 -0
- package/src/devcouncil/cli/commands/tasks.py +248 -19
- package/src/devcouncil/cli/commands/trace.py +14 -8
- package/src/devcouncil/cli/commands/verify.py +33 -8
- package/src/devcouncil/cli/commands/version.py +14 -6
- package/src/devcouncil/cli/commands/watch.py +49 -30
- package/src/devcouncil/cli/commands/watch_fs.py +30 -19
- package/src/devcouncil/cli/commands/wiki.py +278 -0
- package/src/devcouncil/cli/main.py +58 -1
- package/src/devcouncil/codeintel/__init__.py +16 -0
- package/src/devcouncil/codeintel/build_control.py +429 -0
- package/src/devcouncil/codeintel/build_worker.py +78 -0
- package/src/devcouncil/codeintel/debug/__init__.py +17 -0
- package/src/devcouncil/codeintel/debug/broker.py +114 -0
- package/src/devcouncil/codeintel/debug/broker_client.py +61 -0
- package/src/devcouncil/codeintel/debug/consent.py +36 -0
- package/src/devcouncil/codeintel/debug/discovery.py +132 -0
- package/src/devcouncil/codeintel/debug/fingerprint.py +85 -0
- package/src/devcouncil/codeintel/debug/protocol.py +259 -0
- package/src/devcouncil/codeintel/debug/python_trace_runner.py +81 -0
- package/src/devcouncil/codeintel/debug/session.py +238 -0
- package/src/devcouncil/codeintel/debug/tracing.py +201 -0
- package/src/devcouncil/codeintel/languages/__init__.py +17 -0
- package/src/devcouncil/codeintel/languages/generic_extractor.py +236 -0
- package/src/devcouncil/codeintel/languages/registry.py +149 -0
- package/src/devcouncil/codeintel/languages/workers.py +245 -0
- package/src/devcouncil/codeintel/query/__init__.py +5 -0
- package/src/devcouncil/codeintel/query/engine.py +289 -0
- package/src/devcouncil/codeintel/resolution/__init__.py +6 -0
- package/src/devcouncil/codeintel/resolution/abstract_state.py +301 -0
- package/src/devcouncil/codeintel/resolution/frameworks/__init__.py +33 -0
- package/src/devcouncil/codeintel/resolution/frameworks/base.py +46 -0
- package/src/devcouncil/codeintel/resolution/frameworks/di.py +56 -0
- package/src/devcouncil/codeintel/resolution/frameworks/events.py +45 -0
- package/src/devcouncil/codeintel/resolution/frameworks/routes.py +88 -0
- package/src/devcouncil/codeintel/resolution/semantic.py +887 -0
- package/src/devcouncil/codeintel/service.py +104 -0
- package/src/devcouncil/codeintel/store/__init__.py +15 -0
- package/src/devcouncil/codeintel/store/sqlite.py +1565 -0
- package/src/devcouncil/codeintel/sync/__init__.py +19 -0
- package/src/devcouncil/codeintel/sync/coordinator.py +430 -0
- package/src/devcouncil/codeintel/sync/incremental.py +484 -0
- package/src/devcouncil/codeintel/sync/lease.py +96 -0
- package/src/devcouncil/codeintel/sync/scope.py +98 -0
- package/src/devcouncil/council/__init__.py +4 -0
- package/src/devcouncil/council/prompts/__init__.py +4 -0
- package/src/devcouncil/domain/checkpoint_refs.py +17 -0
- package/src/devcouncil/domain/evidence.py +1 -0
- package/src/devcouncil/domain/gap.py +10 -0
- package/src/devcouncil/domain/requirement.py +5 -1
- package/src/devcouncil/domain/task.py +43 -2
- package/src/devcouncil/execution/checkpoints.py +25 -31
- package/src/devcouncil/execution/context_builder.py +15 -44
- package/src/devcouncil/execution/fs_watcher.py +64 -0
- package/src/devcouncil/execution/gated_write.py +203 -0
- package/src/devcouncil/execution/handoff.py +2 -1
- package/src/devcouncil/execution/hook_policy.py +19 -5
- package/src/devcouncil/execution/lease_ops.py +177 -0
- package/src/devcouncil/execution/lease_validation.py +71 -0
- package/src/devcouncil/execution/patch.py +3 -0
- package/src/devcouncil/execution/permissions.py +1 -0
- package/src/devcouncil/execution/policy_engine.py +205 -10
- package/src/devcouncil/execution/prompt_builder.py +278 -33
- package/src/devcouncil/execution/run_trace.py +356 -0
- package/src/devcouncil/execution/shell_session.py +46 -5
- package/src/devcouncil/execution/stop_gate.py +746 -0
- package/src/devcouncil/execution/stop_gate_history.py +113 -0
- package/src/devcouncil/execution/stop_gate_state.py +54 -0
- package/src/devcouncil/execution/stop_gate_verify_cache.py +69 -0
- package/src/devcouncil/execution/task_gate_ops.py +590 -0
- package/src/devcouncil/execution/task_runner.py +19 -0
- package/src/devcouncil/executors/advisor_tool.py +315 -0
- package/src/devcouncil/executors/agent_registry.py +125 -17
- package/src/devcouncil/executors/claude_sdk.py +376 -0
- package/src/devcouncil/executors/coding_cli.py +724 -25
- package/src/devcouncil/executors/mini_swe.py +50 -8
- package/src/devcouncil/executors/native/agent.py +224 -19
- package/src/devcouncil/executors/openhands.py +50 -8
- package/src/devcouncil/executors/transient_retry.py +99 -0
- package/src/devcouncil/gating/checks/clean_git.py +5 -2
- package/src/devcouncil/gating/checks/planned_files_check.py +38 -11
- package/src/devcouncil/gating/checks/secret_scan_check.py +2 -2
- package/src/devcouncil/gating/policy.py +46 -2
- package/src/devcouncil/indexing/ast_matcher.py +41 -4
- package/src/devcouncil/indexing/graph/__init__.py +78 -0
- package/src/devcouncil/indexing/graph/api_routes.py +522 -0
- package/src/devcouncil/indexing/graph/build.py +862 -0
- package/src/devcouncil/indexing/graph/cache.py +329 -0
- package/src/devcouncil/indexing/graph/communities.py +28 -0
- package/src/devcouncil/indexing/graph/cypher.py +107 -0
- package/src/devcouncil/indexing/graph/embeddings.py +194 -0
- package/src/devcouncil/indexing/graph/export.py +381 -0
- package/src/devcouncil/indexing/graph/export_links.py +81 -0
- package/src/devcouncil/indexing/graph/extract_python.py +307 -0
- package/src/devcouncil/indexing/graph/extract_ts.py +1205 -0
- package/src/devcouncil/indexing/graph/intel.py +668 -0
- package/src/devcouncil/indexing/graph/liveness.py +992 -0
- package/src/devcouncil/indexing/graph/okf_export.py +65 -0
- package/src/devcouncil/indexing/graph/pdg/__init__.py +67 -0
- package/src/devcouncil/indexing/graph/pdg/build.py +11 -0
- package/src/devcouncil/indexing/graph/pdg/cdg.py +41 -0
- package/src/devcouncil/indexing/graph/pdg/cfg.py +199 -0
- package/src/devcouncil/indexing/graph/pdg/query.py +21 -0
- package/src/devcouncil/indexing/graph/pdg/reaching_def.py +126 -0
- package/src/devcouncil/indexing/graph/pdg/schema.py +253 -0
- package/src/devcouncil/indexing/graph/pdg/taint.py +154 -0
- package/src/devcouncil/indexing/graph/query.py +302 -0
- package/src/devcouncil/indexing/graph/resolve.py +1020 -0
- package/src/devcouncil/indexing/graph/schema.py +103 -0
- package/src/devcouncil/indexing/graph_index.py +20 -29
- package/src/devcouncil/indexing/lsp.py +57 -25
- package/src/devcouncil/indexing/lsp_client.py +577 -0
- package/src/devcouncil/indexing/map_artifacts.py +355 -0
- package/src/devcouncil/indexing/map_refresh.py +141 -0
- package/src/devcouncil/indexing/repo_mapper.py +1509 -138
- package/src/devcouncil/indexing/semantic_index.py +12 -6
- package/src/devcouncil/indexing/subsystem_map.py +163 -0
- package/src/devcouncil/indexing/ts_imports.py +343 -0
- package/src/devcouncil/indexing/viz.py +960 -0
- package/src/devcouncil/indexing/walk.py +52 -0
- package/src/devcouncil/indexing/wiring.py +1776 -0
- package/src/devcouncil/integrations/actions.py +27 -4
- package/src/devcouncil/integrations/check.py +211 -16
- package/src/devcouncil/integrations/claude_assets.py +209 -12
- package/src/devcouncil/integrations/clients/__init__.py +1 -0
- package/src/devcouncil/integrations/clients/aider.py +52 -0
- package/src/devcouncil/integrations/clients/antigravity.py +87 -0
- package/src/devcouncil/integrations/clients/claude.py +339 -0
- package/src/devcouncil/integrations/clients/codex.py +39 -0
- package/src/devcouncil/integrations/clients/common.py +332 -0
- package/src/devcouncil/integrations/clients/cursor.py +164 -0
- package/src/devcouncil/integrations/clients/gemini.py +49 -0
- package/src/devcouncil/integrations/clients/grok.py +105 -0
- package/src/devcouncil/integrations/clients/hooks.py +500 -0
- package/src/devcouncil/integrations/clients/opencode.py +96 -0
- package/src/devcouncil/integrations/clients/warp.py +75 -0
- package/src/devcouncil/integrations/code_review_graph.py +2 -2
- package/src/devcouncil/integrations/github.py +73 -7
- package/src/devcouncil/integrations/integration_cli.py +197 -0
- package/src/devcouncil/integrations/mcp/handlers/__init__.py +1 -0
- package/src/devcouncil/integrations/mcp/handlers/ast_lsp.py +77 -0
- package/src/devcouncil/integrations/mcp/handlers/checkout.py +50 -0
- package/src/devcouncil/integrations/mcp/handlers/cli_gate.py +43 -0
- package/src/devcouncil/integrations/mcp/handlers/codeintel.py +182 -0
- package/src/devcouncil/integrations/mcp/handlers/debug.py +236 -0
- package/src/devcouncil/integrations/mcp/handlers/evidence.py +70 -0
- package/src/devcouncil/integrations/mcp/handlers/git.py +99 -0
- package/src/devcouncil/integrations/mcp/handlers/graph.py +36 -0
- package/src/devcouncil/integrations/mcp/handlers/handoff.py +53 -0
- package/src/devcouncil/integrations/mcp/handlers/knowledge.py +30 -0
- package/src/devcouncil/integrations/mcp/handlers/lease.py +70 -0
- package/src/devcouncil/integrations/mcp/handlers/live.py +108 -0
- package/src/devcouncil/integrations/mcp/handlers/map.py +676 -0
- package/src/devcouncil/integrations/mcp/handlers/next_task.py +35 -0
- package/src/devcouncil/integrations/mcp/handlers/policy.py +80 -0
- package/src/devcouncil/integrations/mcp/handlers/prompts.py +168 -0
- package/src/devcouncil/integrations/mcp/handlers/provenance.py +101 -0
- package/src/devcouncil/integrations/mcp/handlers/read.py +74 -0
- package/src/devcouncil/integrations/mcp/handlers/router_cache.py +53 -0
- package/src/devcouncil/integrations/mcp/handlers/run.py +53 -0
- package/src/devcouncil/integrations/mcp/handlers/runs.py +84 -0
- package/src/devcouncil/integrations/mcp/handlers/scope.py +56 -0
- package/src/devcouncil/integrations/mcp/handlers/status.py +114 -0
- package/src/devcouncil/integrations/mcp/handlers/task.py +100 -0
- package/src/devcouncil/integrations/mcp/handlers/tool_specs.py +904 -0
- package/src/devcouncil/integrations/mcp/handlers/trace.py +65 -0
- package/src/devcouncil/integrations/mcp/handlers/verify.py +45 -0
- package/src/devcouncil/integrations/mcp/handlers/wiki.py +60 -0
- package/src/devcouncil/integrations/mcp/handlers/write.py +69 -0
- package/src/devcouncil/integrations/mcp/server.py +265 -2391
- package/src/devcouncil/integrations/mcp/util.py +303 -0
- package/src/devcouncil/integrations/setup.py +152 -0
- package/src/devcouncil/knowledge/fetch.py +4 -0
- package/src/devcouncil/knowledge/knowledge_select.py +38 -0
- package/src/devcouncil/knowledge/okf.py +2 -1
- package/src/devcouncil/knowledge/resource_discovery.py +40 -0
- package/src/devcouncil/knowledge/wiki.py +643 -0
- package/src/devcouncil/knowledge/wiki_read.py +87 -0
- package/src/devcouncil/live/cards.py +7 -7
- package/src/devcouncil/live/models.py +4 -1
- package/src/devcouncil/live/reviewer.py +90 -11
- package/src/devcouncil/live/signals.py +4 -2
- package/src/devcouncil/live/tasks.py +12 -3
- package/src/devcouncil/live/transcripts.py +69 -2
- package/src/devcouncil/llm/cache.py +5 -6
- package/src/devcouncil/llm/model_defaults.yaml +10 -10
- package/src/devcouncil/llm/provider.py +647 -73
- package/src/devcouncil/llm/router.py +271 -46
- package/src/devcouncil/llm/semantic_bridge.py +614 -0
- package/src/devcouncil/optimization/gepa_agent.py +6 -4
- package/src/devcouncil/optimization/skillopt.py +9 -5
- package/src/devcouncil/planning/arbiter_service.py +12 -3
- package/src/devcouncil/planning/correction_manifest.py +107 -10
- package/src/devcouncil/planning/plan_difficulty.py +69 -0
- package/src/devcouncil/planning/plan_service.py +5 -2
- package/src/devcouncil/planning/planned_files_reconcile.py +191 -0
- package/src/devcouncil/planning/prompt_enhancer_service.py +6 -5
- package/src/devcouncil/planning/question_conversion.py +56 -0
- package/src/devcouncil/planning/spec_service.py +9 -3
- package/src/devcouncil/repo/ci_scaffold.py +197 -1
- package/src/devcouncil/repo/gitignore.py +1 -2
- package/src/devcouncil/reporting/evidence_export.py +124 -0
- package/src/devcouncil/reporting/evidence_html.py +210 -0
- package/src/devcouncil/reporting/json_report.py +16 -12
- package/src/devcouncil/reporting/markdown_report.py +38 -9
- package/src/devcouncil/reporting/mcp_resources.py +142 -0
- package/src/devcouncil/reporting/report_builder.py +40 -4
- package/src/devcouncil/reporting/task_provenance.py +42 -0
- package/src/devcouncil/reporting/verdict.py +75 -0
- package/src/devcouncil/skills/library/README.md +1 -0
- package/src/devcouncil/skills/library/devcouncil-hero-loop.md +109 -0
- package/src/devcouncil/skills/library/devcouncil-verification.md +109 -0
- package/src/devcouncil/skills/library/devcouncil.md +93 -0
- package/src/devcouncil/skills/registry.py +43 -12
- package/src/devcouncil/storage/db.py +57 -11
- package/src/devcouncil/storage/models.py +6 -0
- package/src/devcouncil/storage/native.py +5 -3
- package/src/devcouncil/storage/repositories.py +50 -18
- package/src/devcouncil/telemetry/context.py +28 -0
- package/src/devcouncil/telemetry/cost.py +4 -5
- package/src/devcouncil/telemetry/logging_setup.py +78 -11
- package/src/devcouncil/telemetry/model_pricing.yaml +7 -0
- package/src/devcouncil/telemetry/stages.py +27 -2
- package/src/devcouncil/telemetry/tracker.py +50 -13
- package/src/devcouncil/ui/dashboard.py +120 -8
- package/src/devcouncil/utils/fsio.py +58 -0
- package/src/devcouncil/utils/git_snapshot.py +112 -0
- package/src/devcouncil/utils/json_persist.py +53 -0
- package/src/devcouncil/utils/proc.py +89 -0
- package/src/devcouncil/verification/acceptance_compiler.py +36 -13
- package/src/devcouncil/verification/ad_hoc_check.py +95 -3
- package/src/devcouncil/verification/checks/__init__.py +41 -0
- package/src/devcouncil/verification/checks/acceptance.py +39 -0
- package/src/devcouncil/verification/checks/acceptance_corpus.py +194 -0
- package/src/devcouncil/verification/checks/acceptance_evidence.py +239 -0
- package/src/devcouncil/verification/checks/command_evidence.py +148 -0
- package/src/devcouncil/verification/checks/compiled_acceptance.py +179 -0
- package/src/devcouncil/verification/checks/corpus_stale.py +124 -0
- package/src/devcouncil/verification/checks/corpus_verification.py +9 -0
- package/src/devcouncil/verification/checks/dead_symbols.py +360 -0
- package/src/devcouncil/verification/checks/diff_coverage_gate.py +101 -0
- package/src/devcouncil/verification/checks/doc_code_ref.py +79 -0
- package/src/devcouncil/verification/checks/liveness_ratchet.py +336 -0
- package/src/devcouncil/verification/checks/orphan_diff.py +104 -0
- package/src/devcouncil/verification/checks/planned_files.py +98 -0
- package/src/devcouncil/verification/checks/semantic_diff.py +241 -0
- package/src/devcouncil/verification/checks/stale_map.py +80 -0
- package/src/devcouncil/verification/checks/stub_scan.py +71 -0
- package/src/devcouncil/verification/checks/subsystem_boundary.py +103 -0
- package/src/devcouncil/verification/checks/wiring.py +216 -0
- package/src/devcouncil/verification/claims/__init__.py +23 -0
- package/src/devcouncil/verification/claims/checks.py +395 -0
- package/src/devcouncil/verification/claims/mapper.py +168 -0
- package/src/devcouncil/verification/claims/models.py +39 -0
- package/src/devcouncil/verification/claims/transcript.py +92 -0
- package/src/devcouncil/verification/claims/verdict.py +88 -0
- package/src/devcouncil/verification/command_evidence.py +170 -0
- package/src/devcouncil/verification/command_malformation.py +147 -0
- package/src/devcouncil/verification/command_runner.py +164 -0
- package/src/devcouncil/verification/coverage_measurement.py +292 -0
- package/src/devcouncil/verification/diff_coverage.py +151 -0
- package/src/devcouncil/verification/difficulty.py +296 -0
- package/src/devcouncil/verification/effort_heuristics.py +178 -0
- package/src/devcouncil/verification/gap_ids.py +63 -0
- package/src/devcouncil/verification/gate_cache.py +194 -0
- package/src/devcouncil/verification/gate_selector.py +344 -0
- package/src/devcouncil/verification/git_diff_fallback.py +272 -0
- package/src/devcouncil/verification/implementation_reviewer.py +13 -0
- package/src/devcouncil/verification/incremental_check.py +241 -0
- package/src/devcouncil/verification/next_actions.py +60 -1
- package/src/devcouncil/verification/rigor_analytics.py +130 -0
- package/src/devcouncil/verification/sandbox.py +38 -11
- package/src/devcouncil/verification/stub_detector.py +369 -0
- package/src/devcouncil/verification/test_resolver.py +67 -1
- package/src/devcouncil/verification/verifier.py +137 -1666
- package/src/devcouncil/verification/verify_orchestration.py +610 -0
- package/src/devcouncil/verification/verify_setup.py +176 -0
- package/src/devcouncil/verification/wiki_refresh.py +208 -0
- package/src/semantic_layer/__init__.py +58 -0
- package/src/semantic_layer/benchmark.py +75 -0
- package/src/semantic_layer/cache.py +290 -0
- package/src/semantic_layer/compressor.py +137 -0
- package/src/semantic_layer/config.py +75 -0
- package/src/semantic_layer/embeddings.py +69 -0
- package/src/semantic_layer/llm_backends.py +99 -0
- package/src/semantic_layer/pipeline.py +111 -0
- package/src/semantic_layer/router.py +128 -0
- package/src/semantic_layer/tuner.py +72 -0
- package/uv.lock +973 -9
- package/src/devcouncil/artifacts/migrations.py +0 -20
- package/src/devcouncil/artifacts/schemas.py +0 -23
- package/src/devcouncil/artifacts/serializer.py +0 -21
- package/src/devcouncil/integrations/gitnexus.py +0 -70
- package/src/devcouncil/integrations/graphify.py +0 -34
|
@@ -0,0 +1,862 @@
|
|
|
1
|
+
"""Build / load / incrementally refresh the code knowledge graph (schema v2)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import tempfile
|
|
11
|
+
import weakref
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Callable, Dict, Iterable, List, Optional, Set
|
|
14
|
+
|
|
15
|
+
from devcouncil.codeintel.languages.registry import LANGUAGE_SPECS
|
|
16
|
+
from devcouncil.indexing.graph.cache import (
|
|
17
|
+
PARSE_CACHE_VERSION,
|
|
18
|
+
extract_cached,
|
|
19
|
+
load_parse_cache,
|
|
20
|
+
merge_parse_cache,
|
|
21
|
+
)
|
|
22
|
+
from devcouncil.indexing.graph.extract_python import FileExtraction
|
|
23
|
+
from devcouncil.indexing.graph.liveness import (
|
|
24
|
+
build_liveness_shard,
|
|
25
|
+
file_liveness,
|
|
26
|
+
legacy_dead_strings,
|
|
27
|
+
symbol_reachability_dead,
|
|
28
|
+
_token_scan_dead,
|
|
29
|
+
)
|
|
30
|
+
from devcouncil.indexing.graph.resolve import (
|
|
31
|
+
build_file_and_symbol_nodes,
|
|
32
|
+
contains_and_defines_edges,
|
|
33
|
+
decorator_edges,
|
|
34
|
+
inherit_edges,
|
|
35
|
+
named_import_edges,
|
|
36
|
+
resolve_calls,
|
|
37
|
+
resolve_import_edges,
|
|
38
|
+
import_graph_edges,
|
|
39
|
+
)
|
|
40
|
+
from devcouncil.indexing.graph.schema import NodeKind
|
|
41
|
+
from devcouncil.indexing.graph.schema import CodeGraph, SCHEMA_VERSION
|
|
42
|
+
from devcouncil.utils.json_persist import read_json
|
|
43
|
+
|
|
44
|
+
logger = logging.getLogger(__name__)
|
|
45
|
+
_PENDING_ANALYSIS_SHARDS: dict[int, dict[str, dict[str, object]]] = {}
|
|
46
|
+
|
|
47
|
+
GRAPH_REL = Path(".devcouncil") / "graph" / "code_graph.json"
|
|
48
|
+
|
|
49
|
+
_CODE_SUFFIXES = {
|
|
50
|
+
extension.lower()
|
|
51
|
+
for spec in LANGUAGE_SPECS
|
|
52
|
+
for extension in spec.extensions
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class CompatibilityGraphTooLarge(ValueError):
|
|
57
|
+
"""Compatibility JSON exceeded the configured import/export boundary."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def graph_path(root: Path) -> Path:
|
|
61
|
+
return root / GRAPH_REL
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def content_fingerprint(root: Path, files: List[str]) -> str:
|
|
65
|
+
"""sha1 over sorted ``(path, size, mtime_ns)`` so content edits mark staleness."""
|
|
66
|
+
lines: List[str] = []
|
|
67
|
+
for rel in sorted(files):
|
|
68
|
+
try:
|
|
69
|
+
st = (root / rel).stat()
|
|
70
|
+
lines.append(f"{rel}\0{st.st_size}\0{st.st_mtime_ns}")
|
|
71
|
+
except OSError:
|
|
72
|
+
lines.append(f"{rel}\0-1\0-1")
|
|
73
|
+
return hashlib.sha1("\n".join(lines).encode("utf-8")).hexdigest()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _git_head(root: Path) -> str:
|
|
77
|
+
from devcouncil.utils.proc import git_output
|
|
78
|
+
|
|
79
|
+
return git_output(["rev-parse", "HEAD"], cwd=root, default="").strip()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _files_fingerprint(files: List[str]) -> str:
|
|
83
|
+
return hashlib.sha1("\n".join(sorted(files)).encode("utf-8")).hexdigest()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _code_files(files: Iterable[str]) -> List[str]:
|
|
87
|
+
from devcouncil.indexing.wiring import is_vendored_path
|
|
88
|
+
|
|
89
|
+
return sorted(
|
|
90
|
+
f
|
|
91
|
+
for f in files
|
|
92
|
+
if Path(f).suffix.lower() in _CODE_SUFFIXES and not is_vendored_path(f)
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _area_fn_for(root: Path, mapper: Optional[Any] = None):
|
|
97
|
+
"""Prefer RepoMapper area bucketing; reuse one shared mapper instance."""
|
|
98
|
+
|
|
99
|
+
def _area(path: str) -> str:
|
|
100
|
+
try:
|
|
101
|
+
if mapper is not None:
|
|
102
|
+
return str(mapper._area_for_file(path))
|
|
103
|
+
from devcouncil.indexing.repo_mapper import RepoMapper
|
|
104
|
+
|
|
105
|
+
return RepoMapper(root)._area_for_file(path)
|
|
106
|
+
except Exception:
|
|
107
|
+
parts = path.replace("\\", "/").split("/")
|
|
108
|
+
return parts[0] if parts else "root"
|
|
109
|
+
|
|
110
|
+
return _area
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _cache_entry_matches_disk(root: Path, rel: str, entry: dict) -> bool:
|
|
114
|
+
"""True when cached sha256 (and size/mtime when present) still match disk."""
|
|
115
|
+
path = root / rel
|
|
116
|
+
try:
|
|
117
|
+
st = path.stat()
|
|
118
|
+
except OSError:
|
|
119
|
+
return False
|
|
120
|
+
if "size" in entry and entry["size"] is not None:
|
|
121
|
+
try:
|
|
122
|
+
if int(entry["size"]) != st.st_size:
|
|
123
|
+
return False
|
|
124
|
+
except (TypeError, ValueError):
|
|
125
|
+
return False
|
|
126
|
+
if "mtime_ns" in entry and entry["mtime_ns"] is not None:
|
|
127
|
+
try:
|
|
128
|
+
if int(entry["mtime_ns"]) != st.st_mtime_ns:
|
|
129
|
+
return False
|
|
130
|
+
except (TypeError, ValueError):
|
|
131
|
+
return False
|
|
132
|
+
stored = entry.get("sha256")
|
|
133
|
+
if not isinstance(stored, str) or not stored:
|
|
134
|
+
return False
|
|
135
|
+
try:
|
|
136
|
+
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
137
|
+
except OSError:
|
|
138
|
+
return False
|
|
139
|
+
return stored == digest
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def extract_all(
|
|
143
|
+
root: Path,
|
|
144
|
+
files: List[str],
|
|
145
|
+
*,
|
|
146
|
+
changed_paths: Optional[Set[str]] = None,
|
|
147
|
+
progress: Callable[[str, int, int], None] | None = None,
|
|
148
|
+
) -> Dict[str, FileExtraction]:
|
|
149
|
+
"""Extract (cache v5) all code files; only force-reparse ``changed_paths`` when set.
|
|
150
|
+
|
|
151
|
+
Incremental cache hits still verify sha256 (and size/mtime if stored) so a
|
|
152
|
+
concurrent edit to a non-listed path cannot stamp a fresh fingerprint over
|
|
153
|
+
stale symbols. Cache-hit entries stay in ``updates`` so merge does not evict them.
|
|
154
|
+
"""
|
|
155
|
+
code = _code_files(files)
|
|
156
|
+
cache = load_parse_cache(root)
|
|
157
|
+
extractions: Dict[str, FileExtraction] = {}
|
|
158
|
+
updates: Dict[str, dict] = {}
|
|
159
|
+
force_set = changed_paths if changed_paths is not None else None
|
|
160
|
+
|
|
161
|
+
for index, rel in enumerate(code, start=1):
|
|
162
|
+
if progress is not None:
|
|
163
|
+
progress("extract", index - 1, len(code))
|
|
164
|
+
force = force_set is not None and rel in force_set
|
|
165
|
+
# When incremental: prefer warm cache for non-listed paths, but never
|
|
166
|
+
# trust an entry whose digests no longer match disk.
|
|
167
|
+
if force_set is not None and rel not in force_set:
|
|
168
|
+
entry = cache.get(rel)
|
|
169
|
+
if (
|
|
170
|
+
isinstance(entry, dict)
|
|
171
|
+
and isinstance(entry.get("symbols"), list)
|
|
172
|
+
and isinstance(entry.get("import_details"), list)
|
|
173
|
+
and _cache_entry_matches_disk(root, rel, entry)
|
|
174
|
+
):
|
|
175
|
+
from devcouncil.indexing.graph.cache import extraction_from_cache_entry
|
|
176
|
+
|
|
177
|
+
extractions[rel] = extraction_from_cache_entry(rel, entry)
|
|
178
|
+
# Keep cache-hit entries in ``updates`` — ``merge_parse_cache``
|
|
179
|
+
# prunes managed paths absent from updates, so omitting them
|
|
180
|
+
# would evict every unchanged file from the warm cache.
|
|
181
|
+
updates[rel] = entry
|
|
182
|
+
if progress is not None:
|
|
183
|
+
progress("extract", index, len(code))
|
|
184
|
+
continue
|
|
185
|
+
ext, entry = extract_cached(root, rel, cache=cache, force=bool(force))
|
|
186
|
+
extractions[rel] = ext
|
|
187
|
+
updates[rel] = entry
|
|
188
|
+
|
|
189
|
+
if progress is not None:
|
|
190
|
+
progress("extract", index, len(code))
|
|
191
|
+
|
|
192
|
+
managed = {k for k in cache if Path(k).suffix.lower() in _CODE_SUFFIXES} | set(code)
|
|
193
|
+
if updates:
|
|
194
|
+
merge_parse_cache(root, updates, managed)
|
|
195
|
+
return extractions
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def extract_paths(
|
|
199
|
+
root: Path,
|
|
200
|
+
paths: Iterable[str],
|
|
201
|
+
) -> Dict[str, FileExtraction]:
|
|
202
|
+
"""Extract only existing changed paths and evict deleted cache entries."""
|
|
203
|
+
selected = {
|
|
204
|
+
path.replace("\\", "/")
|
|
205
|
+
for path in paths
|
|
206
|
+
if Path(path).suffix.lower() in _CODE_SUFFIXES
|
|
207
|
+
}
|
|
208
|
+
cache = load_parse_cache(root)
|
|
209
|
+
updates: Dict[str, dict] = {}
|
|
210
|
+
extractions: Dict[str, FileExtraction] = {}
|
|
211
|
+
for rel in sorted(selected):
|
|
212
|
+
if not (root / rel).is_file():
|
|
213
|
+
continue
|
|
214
|
+
extraction, entry = extract_cached(root, rel, cache=cache, force=True)
|
|
215
|
+
extractions[rel] = extraction
|
|
216
|
+
updates[rel] = entry
|
|
217
|
+
merge_parse_cache(root, updates, selected)
|
|
218
|
+
return extractions
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def assemble_graph(
|
|
222
|
+
root: Path,
|
|
223
|
+
files: List[str],
|
|
224
|
+
extractions: Dict[str, FileExtraction],
|
|
225
|
+
*,
|
|
226
|
+
liveness: bool = True,
|
|
227
|
+
lsp_refs: bool = False,
|
|
228
|
+
mapper: Optional[Any] = None,
|
|
229
|
+
progress: Callable[[str, int, int], None] | None = None,
|
|
230
|
+
) -> CodeGraph:
|
|
231
|
+
"""Resolve + liveness over extractions; always full resolve."""
|
|
232
|
+
from devcouncil.indexing.repo_mapper import RepoMapper
|
|
233
|
+
|
|
234
|
+
if mapper is None:
|
|
235
|
+
mapper = RepoMapper(root)
|
|
236
|
+
if progress is not None:
|
|
237
|
+
progress("assemble_nodes", 0, 1)
|
|
238
|
+
area_fn = _area_fn_for(root, mapper=mapper)
|
|
239
|
+
nodes, symbol_index = build_file_and_symbol_nodes(extractions, area_fn=area_fn)
|
|
240
|
+
class_ids = {n.id for n in nodes if n.kind == NodeKind.CLASS}
|
|
241
|
+
edges = []
|
|
242
|
+
edges.extend(contains_and_defines_edges(extractions))
|
|
243
|
+
edges.extend(inherit_edges(extractions, symbol_index))
|
|
244
|
+
edges.extend(decorator_edges(extractions, symbol_index))
|
|
245
|
+
|
|
246
|
+
file_edges = resolve_import_edges(extractions, files, root=root, mapper=mapper)
|
|
247
|
+
edges.extend(import_graph_edges(file_edges))
|
|
248
|
+
edges.extend(named_import_edges(extractions, symbol_index, file_edges))
|
|
249
|
+
edges.extend(
|
|
250
|
+
resolve_calls(extractions, symbol_index, file_edges, class_ids=class_ids)
|
|
251
|
+
)
|
|
252
|
+
if progress is not None:
|
|
253
|
+
progress("assemble_nodes", 1, 1)
|
|
254
|
+
|
|
255
|
+
semantic_meta: Dict[str, Any] = {}
|
|
256
|
+
try:
|
|
257
|
+
from devcouncil.codeintel.resolution import enrich_semantic_edges
|
|
258
|
+
|
|
259
|
+
enrich_budget = 120.0
|
|
260
|
+
try:
|
|
261
|
+
from devcouncil.app.config import load_config
|
|
262
|
+
|
|
263
|
+
enrich_budget = float(
|
|
264
|
+
load_config(root).indexing.semantic_enrich_timeout_seconds
|
|
265
|
+
)
|
|
266
|
+
except Exception:
|
|
267
|
+
logger.debug("semantic enrich budget config load failed", exc_info=True)
|
|
268
|
+
|
|
269
|
+
semantic_graph = CodeGraph(nodes=nodes, edges=edges)
|
|
270
|
+
enrich_semantic_edges(
|
|
271
|
+
semantic_graph,
|
|
272
|
+
root=root,
|
|
273
|
+
extractions=extractions,
|
|
274
|
+
progress=progress,
|
|
275
|
+
budget_seconds=enrich_budget,
|
|
276
|
+
)
|
|
277
|
+
nodes = semantic_graph.nodes
|
|
278
|
+
edges = semantic_graph.edges
|
|
279
|
+
semantic_meta = semantic_graph.meta
|
|
280
|
+
except Exception:
|
|
281
|
+
logger.debug("semantic graph enrichment failed", exc_info=True)
|
|
282
|
+
|
|
283
|
+
entry_roots: List[str] = []
|
|
284
|
+
unwired: List[str] = []
|
|
285
|
+
unreachable: List[str] = []
|
|
286
|
+
unreachable_unreliable = False
|
|
287
|
+
dead_code = []
|
|
288
|
+
if liveness:
|
|
289
|
+
if progress is not None:
|
|
290
|
+
progress("liveness", 0, 1)
|
|
291
|
+
from devcouncil.indexing.graph.liveness import project_call_edges_to_files
|
|
292
|
+
|
|
293
|
+
liveness_edges = sorted(set(file_edges) | project_call_edges_to_files(edges))
|
|
294
|
+
entry_roots, unwired, unreachable, unreachable_unreliable = file_liveness(
|
|
295
|
+
root, files, liveness_edges, cap=0
|
|
296
|
+
)
|
|
297
|
+
# Empty prod roots → fail-soft empty unreachable (already); keep symbol
|
|
298
|
+
# reachability from treating every file as unreachable.
|
|
299
|
+
reach_unreachable = [] if unreachable_unreliable else unreachable
|
|
300
|
+
token_dead, _idx, token_keys = _token_scan_dead(
|
|
301
|
+
root, files, cap=0, lsp_refs=lsp_refs, mapper=mapper
|
|
302
|
+
)
|
|
303
|
+
dead_code = symbol_reachability_dead(
|
|
304
|
+
root,
|
|
305
|
+
files,
|
|
306
|
+
nodes,
|
|
307
|
+
edges,
|
|
308
|
+
extractions,
|
|
309
|
+
entry_roots,
|
|
310
|
+
token_dead_keys=token_keys,
|
|
311
|
+
file_edges=file_edges,
|
|
312
|
+
unreachable=reach_unreachable,
|
|
313
|
+
)
|
|
314
|
+
# Uncapped in code_graph meta; repo_map.json applies _LIVENESS_CAP on write.
|
|
315
|
+
legacy = legacy_dead_strings(dead_code, token_dead, cap=None)
|
|
316
|
+
if progress is not None:
|
|
317
|
+
progress("liveness", 1, 1)
|
|
318
|
+
else:
|
|
319
|
+
legacy = []
|
|
320
|
+
token_dead = []
|
|
321
|
+
|
|
322
|
+
graph = CodeGraph(
|
|
323
|
+
schema_version=SCHEMA_VERSION,
|
|
324
|
+
nodes=nodes,
|
|
325
|
+
edges=edges,
|
|
326
|
+
dead_code=dead_code,
|
|
327
|
+
entry_roots=entry_roots,
|
|
328
|
+
unwired_candidates=unwired,
|
|
329
|
+
unreachable_files=unreachable,
|
|
330
|
+
generated_head=_git_head(root),
|
|
331
|
+
indexed_hash=_files_fingerprint(files),
|
|
332
|
+
content_fingerprint=content_fingerprint(root, files),
|
|
333
|
+
meta={
|
|
334
|
+
"parse_cache_version": PARSE_CACHE_VERSION,
|
|
335
|
+
"legacy_dead_symbol_candidates": legacy,
|
|
336
|
+
"file_edge_count": len(file_edges),
|
|
337
|
+
"token_dead_count": len(token_dead) if liveness else 0,
|
|
338
|
+
"liveness_unreachable_unreliable": unreachable_unreliable,
|
|
339
|
+
**semantic_meta,
|
|
340
|
+
},
|
|
341
|
+
)
|
|
342
|
+
try:
|
|
343
|
+
from devcouncil.indexing.graph.intel import enrich_graph_intel
|
|
344
|
+
|
|
345
|
+
enrich_graph_intel(graph, root=root)
|
|
346
|
+
except Exception:
|
|
347
|
+
logger.debug("graph intel enrichment failed", exc_info=True)
|
|
348
|
+
return graph
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def build_code_graph(
|
|
352
|
+
root: Path,
|
|
353
|
+
files: Optional[List[str]] = None,
|
|
354
|
+
*,
|
|
355
|
+
changed_paths: Optional[Iterable[str]] = None,
|
|
356
|
+
liveness: bool = True,
|
|
357
|
+
lsp_refs: bool = False,
|
|
358
|
+
mapper: Optional[Any] = None,
|
|
359
|
+
progress: Callable[[str, int, int], None] | None = None,
|
|
360
|
+
) -> CodeGraph:
|
|
361
|
+
"""Full (or incremental-extract) graph build."""
|
|
362
|
+
from devcouncil.indexing.repo_mapper import RepoMapper
|
|
363
|
+
|
|
364
|
+
root = root.expanduser().resolve()
|
|
365
|
+
if mapper is None:
|
|
366
|
+
mapper = RepoMapper(root)
|
|
367
|
+
if files is None:
|
|
368
|
+
files = mapper.get_git_files()
|
|
369
|
+
changed = {p.replace("\\", "/") for p in changed_paths} if changed_paths else None
|
|
370
|
+
extractions = extract_all(root, files, changed_paths=changed, progress=progress)
|
|
371
|
+
graph = assemble_graph(
|
|
372
|
+
root,
|
|
373
|
+
files,
|
|
374
|
+
extractions,
|
|
375
|
+
liveness=liveness,
|
|
376
|
+
lsp_refs=lsp_refs,
|
|
377
|
+
mapper=mapper,
|
|
378
|
+
progress=progress,
|
|
379
|
+
)
|
|
380
|
+
_PENDING_ANALYSIS_SHARDS[id(graph)] = {
|
|
381
|
+
path: build_liveness_shard(root, extraction)
|
|
382
|
+
for path, extraction in extractions.items()
|
|
383
|
+
}
|
|
384
|
+
weakref.finalize(graph, _PENDING_ANALYSIS_SHARDS.pop, id(graph), None)
|
|
385
|
+
return graph
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _slim_graph_export(graph: CodeGraph) -> CodeGraph:
|
|
389
|
+
"""Copy for JSON export: drop bulky meta already recoverable elsewhere.
|
|
390
|
+
|
|
391
|
+
``node_communities`` duplicates per-node ``community``; legacy dead strings
|
|
392
|
+
remain uncapped on the in-memory/SQLite graph for repo_map consumers.
|
|
393
|
+
Volatile PageRank floats are stripped from ``god_nodes`` (degree/fan metrics
|
|
394
|
+
remain); incremental bookkeeping keys stay but no longer re-inflate via
|
|
395
|
+
pretty-indent or duplicated community maps.
|
|
396
|
+
"""
|
|
397
|
+
meta = dict(graph.meta or {})
|
|
398
|
+
meta.pop("node_communities", None)
|
|
399
|
+
meta.pop("legacy_dead_symbol_candidates", None)
|
|
400
|
+
gods = meta.get("god_nodes")
|
|
401
|
+
if isinstance(gods, list):
|
|
402
|
+
cleaned: list[dict[str, object]] = []
|
|
403
|
+
for row in gods:
|
|
404
|
+
if not isinstance(row, dict):
|
|
405
|
+
continue
|
|
406
|
+
item = dict(row)
|
|
407
|
+
item.pop("pagerank", None)
|
|
408
|
+
cleaned.append(item)
|
|
409
|
+
meta["god_nodes"] = cleaned
|
|
410
|
+
meta["compatibility_export_tier"] = "slim"
|
|
411
|
+
return graph.model_copy(update={"meta": meta})
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _compact_graph_export(graph: CodeGraph) -> CodeGraph:
|
|
415
|
+
"""Aggressive slim: strip node/edge extras and cap noisy liveness lists."""
|
|
416
|
+
slim = _slim_graph_export(graph)
|
|
417
|
+
nodes = [
|
|
418
|
+
n.model_copy(update={"extras": {}})
|
|
419
|
+
for n in slim.nodes
|
|
420
|
+
]
|
|
421
|
+
edges = [
|
|
422
|
+
e.model_copy(update={"extras": {}, "reason": ""})
|
|
423
|
+
for e in slim.edges
|
|
424
|
+
]
|
|
425
|
+
meta = dict(slim.meta or {})
|
|
426
|
+
meta["compatibility_export_tier"] = "compact"
|
|
427
|
+
meta["unreachable_omitted"] = len(slim.unreachable_files or [])
|
|
428
|
+
meta["unwired_omitted"] = max(0, len(slim.unwired_candidates or []) - 200)
|
|
429
|
+
return slim.model_copy(
|
|
430
|
+
update={
|
|
431
|
+
"nodes": nodes,
|
|
432
|
+
"edges": edges,
|
|
433
|
+
"unreachable_files": [],
|
|
434
|
+
"unwired_candidates": list(slim.unwired_candidates or [])[:200],
|
|
435
|
+
"dead_code": list(slim.dead_code or [])[:500],
|
|
436
|
+
"meta": meta,
|
|
437
|
+
}
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _stub_graph_export(graph: CodeGraph) -> CodeGraph:
|
|
442
|
+
"""Pointer-only compatibility JSON when even compact export exceeds the limit."""
|
|
443
|
+
return CodeGraph(
|
|
444
|
+
schema_version=graph.schema_version,
|
|
445
|
+
nodes=[],
|
|
446
|
+
edges=[],
|
|
447
|
+
dead_code=[],
|
|
448
|
+
entry_roots=list(graph.entry_roots or [])[:200],
|
|
449
|
+
unwired_candidates=list(graph.unwired_candidates or [])[:100],
|
|
450
|
+
unreachable_files=[],
|
|
451
|
+
generated_head=graph.generated_head,
|
|
452
|
+
indexed_hash=graph.indexed_hash,
|
|
453
|
+
content_fingerprint=graph.content_fingerprint,
|
|
454
|
+
meta={
|
|
455
|
+
"compatibility_export_tier": "stub",
|
|
456
|
+
"sqlite_canonical": True,
|
|
457
|
+
"node_count": len(graph.nodes),
|
|
458
|
+
"edge_count": len(graph.edges),
|
|
459
|
+
"dead_code_count": len(graph.dead_code or []),
|
|
460
|
+
"unwired_count": len(graph.unwired_candidates or []),
|
|
461
|
+
"unreachable_count": len(graph.unreachable_files or []),
|
|
462
|
+
"liveness_unreachable_unreliable": bool(
|
|
463
|
+
(graph.meta or {}).get("liveness_unreachable_unreliable")
|
|
464
|
+
),
|
|
465
|
+
"compatibility_export_reason": (
|
|
466
|
+
"exceeded indexing.graph_json_max_bytes; prefer SQLite-backed "
|
|
467
|
+
"`dev graph` commands"
|
|
468
|
+
),
|
|
469
|
+
},
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _graph_json_indent(root: Path) -> int | None:
|
|
474
|
+
"""Compact JSON by default; honor ``indexing.compact_graph_json``."""
|
|
475
|
+
try:
|
|
476
|
+
from devcouncil.app.config import load_config
|
|
477
|
+
|
|
478
|
+
if bool(load_config(root).indexing.compact_graph_json):
|
|
479
|
+
return None
|
|
480
|
+
return 2
|
|
481
|
+
except Exception:
|
|
482
|
+
return None
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _graph_json_max_bytes(root: Path) -> int:
|
|
486
|
+
try:
|
|
487
|
+
from devcouncil.app.config import load_config
|
|
488
|
+
|
|
489
|
+
return int(load_config(root).indexing.graph_json_max_bytes)
|
|
490
|
+
except Exception:
|
|
491
|
+
return 128 * 1024 * 1024
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _write_graph_json_bounded(
|
|
495
|
+
path: Path,
|
|
496
|
+
graph: CodeGraph,
|
|
497
|
+
*,
|
|
498
|
+
indent: int | None,
|
|
499
|
+
max_bytes: int,
|
|
500
|
+
) -> None:
|
|
501
|
+
"""Stream JSON to a sibling temporary file and atomically publish it."""
|
|
502
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
503
|
+
encoder = json.JSONEncoder(
|
|
504
|
+
indent=indent,
|
|
505
|
+
ensure_ascii=False,
|
|
506
|
+
separators=(",", ":") if indent is None else None,
|
|
507
|
+
)
|
|
508
|
+
payload = graph.model_dump(mode="json")
|
|
509
|
+
fd, raw_temp = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
|
|
510
|
+
temp = Path(raw_temp)
|
|
511
|
+
written = 0
|
|
512
|
+
try:
|
|
513
|
+
os.chmod(temp, (path.stat().st_mode & 0o777) if path.exists() else 0o644)
|
|
514
|
+
with os.fdopen(fd, "wb") as handle:
|
|
515
|
+
for chunk in encoder.iterencode(payload):
|
|
516
|
+
encoded = chunk.encode("utf-8")
|
|
517
|
+
written += len(encoded)
|
|
518
|
+
if written + 1 > max_bytes:
|
|
519
|
+
raise CompatibilityGraphTooLarge(
|
|
520
|
+
f"compatibility graph export exceeds {max_bytes} bytes"
|
|
521
|
+
)
|
|
522
|
+
handle.write(encoded)
|
|
523
|
+
handle.write(b"\n")
|
|
524
|
+
handle.flush()
|
|
525
|
+
os.fsync(handle.fileno())
|
|
526
|
+
os.replace(temp, path)
|
|
527
|
+
finally:
|
|
528
|
+
temp.unlink(missing_ok=True)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _write_compatibility_export_tiers(root: Path, path: Path, graph: CodeGraph) -> str:
|
|
532
|
+
"""Try slim → compact → stub until the size cap fits. Returns the tier used.
|
|
533
|
+
|
|
534
|
+
Raises ``CompatibilityGraphTooLarge`` only when even the stub cannot be written
|
|
535
|
+
(misconfigured tiny limit). When the stub tier is used, still raises after a
|
|
536
|
+
successful write so callers mark ``compatibility_export=degraded`` while
|
|
537
|
+
leaving a usable pointer JSON on disk.
|
|
538
|
+
"""
|
|
539
|
+
from devcouncil.codeintel import get_codeintel_service
|
|
540
|
+
|
|
541
|
+
indent = _graph_json_indent(root)
|
|
542
|
+
max_bytes = _graph_json_max_bytes(root)
|
|
543
|
+
# The stub is a bounded pointer JSON (~1 KB floor plus capped entry lists);
|
|
544
|
+
# always leave it on disk even under a tiny configured cap, backstopped at
|
|
545
|
+
# 1 MiB against pathological entry-root lists.
|
|
546
|
+
stub_cap = max(max_bytes, 1024 * 1024)
|
|
547
|
+
tiers: list[tuple[str, CodeGraph, int]] = [
|
|
548
|
+
("slim", _slim_graph_export(graph), max_bytes),
|
|
549
|
+
("compact", _compact_graph_export(graph), max_bytes),
|
|
550
|
+
("stub", _stub_graph_export(graph), stub_cap),
|
|
551
|
+
]
|
|
552
|
+
last_err: CompatibilityGraphTooLarge | None = None
|
|
553
|
+
for tier_name, export_graph, tier_cap in tiers:
|
|
554
|
+
try:
|
|
555
|
+
_write_graph_json_bounded(
|
|
556
|
+
path, export_graph, indent=indent, max_bytes=tier_cap
|
|
557
|
+
)
|
|
558
|
+
except CompatibilityGraphTooLarge as exc:
|
|
559
|
+
last_err = exc
|
|
560
|
+
logger.warning(
|
|
561
|
+
"compatibility export tier %s exceeded %s bytes; trying next",
|
|
562
|
+
tier_name,
|
|
563
|
+
max_bytes,
|
|
564
|
+
)
|
|
565
|
+
continue
|
|
566
|
+
get_codeintel_service(root).store.record_compatibility_export(path, export_graph)
|
|
567
|
+
if tier_name == "stub":
|
|
568
|
+
raise CompatibilityGraphTooLarge(
|
|
569
|
+
f"compatibility graph export exceeded {max_bytes} bytes; "
|
|
570
|
+
"wrote stub JSON (SQLite remains canonical)"
|
|
571
|
+
)
|
|
572
|
+
return tier_name
|
|
573
|
+
raise last_err or CompatibilityGraphTooLarge(
|
|
574
|
+
f"compatibility graph export exceeds {max_bytes} bytes"
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def write_code_graph(
|
|
579
|
+
root: Path,
|
|
580
|
+
graph: CodeGraph,
|
|
581
|
+
*,
|
|
582
|
+
changed_paths: Set[str] | None = None,
|
|
583
|
+
analysis_shards: dict[str, dict[str, object]] | None = None,
|
|
584
|
+
_lease_held: bool = False,
|
|
585
|
+
) -> Path:
|
|
586
|
+
if not _lease_held:
|
|
587
|
+
from devcouncil.codeintel.build_control import graph_build_session
|
|
588
|
+
|
|
589
|
+
with graph_build_session(root):
|
|
590
|
+
return write_code_graph(
|
|
591
|
+
root,
|
|
592
|
+
graph,
|
|
593
|
+
changed_paths=changed_paths,
|
|
594
|
+
analysis_shards=analysis_shards,
|
|
595
|
+
_lease_held=True,
|
|
596
|
+
)
|
|
597
|
+
# SQLite is the canonical store. Keep the deterministic JSON artifact as a
|
|
598
|
+
# compatibility/export boundary for existing consumers and older clients.
|
|
599
|
+
from devcouncil.codeintel import get_codeintel_service
|
|
600
|
+
|
|
601
|
+
root = root.expanduser().resolve()
|
|
602
|
+
shards = analysis_shards or _PENDING_ANALYSIS_SHARDS.pop(id(graph), None)
|
|
603
|
+
get_codeintel_service(root).persist(
|
|
604
|
+
graph,
|
|
605
|
+
changed_paths=changed_paths,
|
|
606
|
+
analysis_shards=shards,
|
|
607
|
+
)
|
|
608
|
+
path = graph_path(root)
|
|
609
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
610
|
+
_write_compatibility_export_tiers(root, path, graph)
|
|
611
|
+
return path
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def export_code_graph_json(root: Path) -> Optional[Path]:
|
|
615
|
+
"""Rewrite the compatibility ``code_graph.json`` from the canonical store.
|
|
616
|
+
|
|
617
|
+
Self-heal for a deleted or failed JSON export: loads from SQLite only (no
|
|
618
|
+
re-persist) and rewrites the artifact + export handshake. Returns the path,
|
|
619
|
+
or None when the store has no graph or the write fails.
|
|
620
|
+
"""
|
|
621
|
+
from devcouncil.codeintel import get_codeintel_service
|
|
622
|
+
|
|
623
|
+
root = root.expanduser().resolve()
|
|
624
|
+
try:
|
|
625
|
+
service = get_codeintel_service(root)
|
|
626
|
+
graph = service.load()
|
|
627
|
+
if graph is None:
|
|
628
|
+
return None
|
|
629
|
+
path = graph_path(root)
|
|
630
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
631
|
+
try:
|
|
632
|
+
_write_compatibility_export_tiers(root, path, graph)
|
|
633
|
+
except CompatibilityGraphTooLarge:
|
|
634
|
+
# Stub may still be on disk — treat as healed when the file exists.
|
|
635
|
+
if not path.is_file():
|
|
636
|
+
raise
|
|
637
|
+
return path
|
|
638
|
+
except Exception:
|
|
639
|
+
logger.warning("code graph JSON re-export failed", exc_info=True)
|
|
640
|
+
return None
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def load_code_graph(root: Path) -> Optional[CodeGraph]:
|
|
644
|
+
from devcouncil.codeintel import get_codeintel_service
|
|
645
|
+
|
|
646
|
+
root = root.expanduser().resolve()
|
|
647
|
+
path = graph_path(root)
|
|
648
|
+
service = get_codeintel_service(root)
|
|
649
|
+
# SQLite is canonical. Import the JSON compatibility artifact only when the
|
|
650
|
+
# store is empty/missing — never let an external JSON rewrite clobber a
|
|
651
|
+
# committed generation (accidental or malicious mtime/digest churn).
|
|
652
|
+
try:
|
|
653
|
+
if not service.store.exists() and path.is_file():
|
|
654
|
+
if path.stat().st_size > _graph_json_max_bytes(root):
|
|
655
|
+
raise CompatibilityGraphTooLarge(
|
|
656
|
+
f"compatibility graph import exceeds {_graph_json_max_bytes(root)} bytes"
|
|
657
|
+
)
|
|
658
|
+
data = read_json(path)
|
|
659
|
+
exported = CodeGraph.model_validate(data)
|
|
660
|
+
# Import is a store write: hold the writer lease like every other
|
|
661
|
+
# persist path. GraphBuildBusy lands in the except below and the
|
|
662
|
+
# read falls through to whatever generation the builder commits.
|
|
663
|
+
from devcouncil.codeintel.build_control import graph_build_session
|
|
664
|
+
|
|
665
|
+
with graph_build_session(root):
|
|
666
|
+
service.persist(exported)
|
|
667
|
+
service.store.record_compatibility_export(path, exported)
|
|
668
|
+
return _annotate_graph_degraded(root, exported)
|
|
669
|
+
recorded_digest, recorded_mtime = service.store.compatibility_export_state()
|
|
670
|
+
if (
|
|
671
|
+
path.is_file()
|
|
672
|
+
and recorded_mtime is not None
|
|
673
|
+
and path.stat().st_mtime_ns != recorded_mtime
|
|
674
|
+
):
|
|
675
|
+
# Refresh the handshake when the on-disk export matches the store;
|
|
676
|
+
# otherwise leave SQLite authoritative and let ``dev graph doctor``
|
|
677
|
+
# / export self-heal report drift.
|
|
678
|
+
from devcouncil.codeintel.store.sqlite import compatibility_graph_digest
|
|
679
|
+
|
|
680
|
+
if path.stat().st_size <= _graph_json_max_bytes(root):
|
|
681
|
+
data = read_json(path)
|
|
682
|
+
exported = CodeGraph.model_validate(data)
|
|
683
|
+
if compatibility_graph_digest(exported) == recorded_digest:
|
|
684
|
+
service.store.record_compatibility_export(path, exported)
|
|
685
|
+
else:
|
|
686
|
+
logger.info(
|
|
687
|
+
"ignoring external compatibility graph that diverges from "
|
|
688
|
+
"canonical store (sqlite wins); re-export with "
|
|
689
|
+
"`dev graph export` / map refresh if JSON must catch up"
|
|
690
|
+
)
|
|
691
|
+
except Exception:
|
|
692
|
+
logger.debug("Failed to reconcile compatibility graph export", exc_info=True)
|
|
693
|
+
try:
|
|
694
|
+
graph = service.load()
|
|
695
|
+
if graph is not None:
|
|
696
|
+
return _annotate_graph_degraded(root, graph)
|
|
697
|
+
except Exception:
|
|
698
|
+
# A corrupt/newer database must not strand users who still have the
|
|
699
|
+
# versioned JSON export. ``dev graph doctor`` reports the store failure;
|
|
700
|
+
# compatibility reads remain available until it is repaired/rebuilt.
|
|
701
|
+
logger.debug("Failed to load canonical code-intelligence store", exc_info=True)
|
|
702
|
+
if not path.is_file():
|
|
703
|
+
return None
|
|
704
|
+
try:
|
|
705
|
+
if path.stat().st_size > _graph_json_max_bytes(root):
|
|
706
|
+
raise CompatibilityGraphTooLarge(
|
|
707
|
+
f"compatibility graph import exceeds {_graph_json_max_bytes(root)} bytes"
|
|
708
|
+
)
|
|
709
|
+
data = read_json(path)
|
|
710
|
+
return _annotate_graph_degraded(root, CodeGraph.model_validate(data))
|
|
711
|
+
except Exception:
|
|
712
|
+
logger.debug("Failed to load code graph", exc_info=True)
|
|
713
|
+
return None
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _annotate_graph_degraded(root: Path, graph: CodeGraph) -> CodeGraph:
|
|
717
|
+
"""Surface repo_map lean/degraded handshake on graph payloads for consumers."""
|
|
718
|
+
map_path = root / ".devcouncil" / "repo_map.json"
|
|
719
|
+
if not map_path.is_file():
|
|
720
|
+
return graph
|
|
721
|
+
try:
|
|
722
|
+
data = read_json(map_path)
|
|
723
|
+
if not isinstance(data, dict) or not data.get("graph_degraded"):
|
|
724
|
+
return graph
|
|
725
|
+
graph.meta["graph_degraded"] = True
|
|
726
|
+
graph.meta["graph_degraded_reason"] = str(data.get("graph_degraded_reason") or "")
|
|
727
|
+
except Exception:
|
|
728
|
+
logger.debug("graph_degraded annotation failed", exc_info=True)
|
|
729
|
+
return graph
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def refresh_map_for_paths(
|
|
733
|
+
root: Path,
|
|
734
|
+
paths: List[str],
|
|
735
|
+
*,
|
|
736
|
+
liveness: bool = True,
|
|
737
|
+
fail_on_degraded: bool = True,
|
|
738
|
+
) -> CodeGraph | None:
|
|
739
|
+
"""Refresh graph and repo map once; never retry a failed graph assembly.
|
|
740
|
+
|
|
741
|
+
When ``fail_on_degraded`` is True (default for watch/sync), a lean/degraded
|
|
742
|
+
refresh raises so ``sync_now`` returns False and pending work is not cleared
|
|
743
|
+
as healthy. One-shot CLI callers that intentionally accept lean maps should
|
|
744
|
+
pass ``fail_on_degraded=False`` or call ``refresh_map_artifacts`` directly.
|
|
745
|
+
"""
|
|
746
|
+
from devcouncil.indexing.map_artifacts import refresh_map_artifacts
|
|
747
|
+
|
|
748
|
+
root = root.expanduser().resolve()
|
|
749
|
+
result = refresh_map_artifacts(
|
|
750
|
+
root,
|
|
751
|
+
root / ".devcouncil" / "repo_map.json",
|
|
752
|
+
paths=paths,
|
|
753
|
+
liveness=liveness,
|
|
754
|
+
quiet=True,
|
|
755
|
+
)
|
|
756
|
+
if fail_on_degraded and result.degraded:
|
|
757
|
+
raise RuntimeError(result.reason or "graph refresh degraded")
|
|
758
|
+
return result.graph
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
# --- Opt-in PDG layer (CFG / reaching-def / CDG / taint) ---
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def build_pdg_for_paths(
|
|
765
|
+
root: Path,
|
|
766
|
+
graph: CodeGraph,
|
|
767
|
+
*,
|
|
768
|
+
paths: Optional[Iterable[str]] = None,
|
|
769
|
+
):
|
|
770
|
+
"""Analyze Python files and return a PDG layer."""
|
|
771
|
+
from devcouncil.indexing.graph.pdg.cdg import build_cdg
|
|
772
|
+
from devcouncil.indexing.graph.pdg.cfg import build_cfg_for_function
|
|
773
|
+
from devcouncil.indexing.graph.pdg.reaching_def import compute_reaching_defs
|
|
774
|
+
from devcouncil.indexing.graph.pdg.schema import FilePDG, FunctionPDG, PDGLayer, PDG_VERSION
|
|
775
|
+
from devcouncil.indexing.graph.pdg.taint import analyze_taint
|
|
776
|
+
|
|
777
|
+
root = root.expanduser().resolve()
|
|
778
|
+
if paths is None:
|
|
779
|
+
paths = sorted({n.path for n in graph.nodes if n.path.endswith(".py")})
|
|
780
|
+
|
|
781
|
+
def _python_functions(tree: ast.AST) -> List[tuple[str, ast.AST]]:
|
|
782
|
+
out: List[tuple[str, ast.AST]] = []
|
|
783
|
+
if isinstance(tree, ast.Module):
|
|
784
|
+
for node in tree.body:
|
|
785
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
786
|
+
out.append((node.name, node))
|
|
787
|
+
elif isinstance(node, ast.ClassDef):
|
|
788
|
+
for item in node.body:
|
|
789
|
+
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
790
|
+
out.append((f"{node.name}.{item.name}", item))
|
|
791
|
+
seen: Set[str] = set()
|
|
792
|
+
unique: List[tuple[str, ast.AST]] = []
|
|
793
|
+
for qual, fn in out:
|
|
794
|
+
if qual in seen:
|
|
795
|
+
continue
|
|
796
|
+
seen.add(qual)
|
|
797
|
+
unique.append((qual, fn))
|
|
798
|
+
return unique
|
|
799
|
+
|
|
800
|
+
layer = PDGLayer(version=PDG_VERSION)
|
|
801
|
+
for raw in paths:
|
|
802
|
+
rel = raw.replace("\\", "/")
|
|
803
|
+
path = root / rel
|
|
804
|
+
if path.suffix.lower() != ".py":
|
|
805
|
+
continue
|
|
806
|
+
try:
|
|
807
|
+
source = path.read_text(encoding="utf-8")
|
|
808
|
+
tree = ast.parse(source, filename=rel)
|
|
809
|
+
except (OSError, SyntaxError, UnicodeDecodeError):
|
|
810
|
+
logger.debug("PDG skip %s", rel, exc_info=True)
|
|
811
|
+
continue
|
|
812
|
+
lines = source.splitlines()
|
|
813
|
+
functions: List[FunctionPDG] = []
|
|
814
|
+
for qualname, fn_node in _python_functions(tree):
|
|
815
|
+
start_line = int(getattr(fn_node, "lineno", 0) or 0)
|
|
816
|
+
end_line = int(getattr(fn_node, "end_lineno", start_line) or start_line)
|
|
817
|
+
cfg = build_cfg_for_function(rel, qualname, fn_node, lines)
|
|
818
|
+
reaching = compute_reaching_defs(cfg, fn_node)
|
|
819
|
+
cdg = build_cdg(cfg, fn_node)
|
|
820
|
+
taint = analyze_taint(rel, qualname, fn_node, reaching)
|
|
821
|
+
functions.append(
|
|
822
|
+
FunctionPDG(
|
|
823
|
+
path=rel,
|
|
824
|
+
qualname=qualname,
|
|
825
|
+
start_line=start_line,
|
|
826
|
+
end_line=end_line,
|
|
827
|
+
blocks=cfg.blocks,
|
|
828
|
+
cfg_edges=cfg.edges,
|
|
829
|
+
reaching_def=reaching,
|
|
830
|
+
cdg=cdg,
|
|
831
|
+
taint=taint,
|
|
832
|
+
)
|
|
833
|
+
)
|
|
834
|
+
if functions:
|
|
835
|
+
file_pdg = FilePDG(path=rel, language="python", functions=functions)
|
|
836
|
+
layer.files[rel] = file_pdg
|
|
837
|
+
for fn in functions:
|
|
838
|
+
layer.taint_findings.extend(fn.taint)
|
|
839
|
+
return layer
|
|
840
|
+
|
|
841
|
+
def merge_pdg_into_graph(graph: CodeGraph, layer) -> Dict[str, dict[str, object]]:
|
|
842
|
+
"""Persist PDG summary in graph.meta and return analysis shards."""
|
|
843
|
+
graph.meta["pdg"] = layer.to_meta()
|
|
844
|
+
shards: Dict[str, dict[str, object]] = {}
|
|
845
|
+
for path in layer.files:
|
|
846
|
+
payload = layer.shard_payload(path)
|
|
847
|
+
if payload is not None:
|
|
848
|
+
shards[path] = payload
|
|
849
|
+
return shards
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def load_pdg_layer(graph: CodeGraph):
|
|
853
|
+
from devcouncil.indexing.graph.pdg.schema import PDGLayer, PDG_VERSION, TaintFinding
|
|
854
|
+
|
|
855
|
+
raw = graph.meta.get("pdg")
|
|
856
|
+
if not isinstance(raw, dict):
|
|
857
|
+
return None
|
|
858
|
+
layer = PDGLayer(version=int(raw.get("version") or PDG_VERSION))
|
|
859
|
+
for item in raw.get("taint_findings") or []:
|
|
860
|
+
if isinstance(item, dict):
|
|
861
|
+
layer.taint_findings.append(TaintFinding.from_dict(item))
|
|
862
|
+
return layer
|