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,1565 @@
|
|
|
1
|
+
"""Versioned SQLite graph store with atomic committed generations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import sqlite3
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
import uuid
|
|
12
|
+
import zlib
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Iterator, Sequence
|
|
17
|
+
|
|
18
|
+
from devcouncil.indexing.graph.schema import (
|
|
19
|
+
CodeGraph,
|
|
20
|
+
Confidence,
|
|
21
|
+
DeadCodeEntry,
|
|
22
|
+
GraphEdge,
|
|
23
|
+
GraphNode,
|
|
24
|
+
NodeKind,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
STORE_SCHEMA_VERSION = 2
|
|
28
|
+
ANALYZER_VERSION = "codeintel-1"
|
|
29
|
+
INDEX_REL = Path(".devcouncil") / "codeintel" / "index.sqlite"
|
|
30
|
+
EXTRACTION_CACHE_MAX_BYTES = 64 * 1024 * 1024
|
|
31
|
+
AMBIGUOUS_EVIDENCE_LIMIT = 8
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
# Error texts that mean the database file itself is damaged. Lock/busy
|
|
36
|
+
# conditions (OperationalError) are transient and must never quarantine.
|
|
37
|
+
_CORRUPTION_MARKERS = ("malformed", "not a database", "database disk image")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _corruption_error(exc: sqlite3.DatabaseError) -> bool:
|
|
41
|
+
text = str(exc).lower()
|
|
42
|
+
if any(marker in text for marker in _CORRUPTION_MARKERS):
|
|
43
|
+
return True
|
|
44
|
+
if isinstance(exc, sqlite3.OperationalError):
|
|
45
|
+
return False
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class StoreStatus:
|
|
51
|
+
project_root: str
|
|
52
|
+
database: str
|
|
53
|
+
schema_version: int
|
|
54
|
+
generation: int | None
|
|
55
|
+
state: str
|
|
56
|
+
node_count: int = 0
|
|
57
|
+
edge_count: int = 0
|
|
58
|
+
created_at: float | None = None
|
|
59
|
+
analyzer_version: str = ANALYZER_VERSION
|
|
60
|
+
|
|
61
|
+
def as_dict(self) -> dict[str, Any]:
|
|
62
|
+
return {
|
|
63
|
+
"project_root": self.project_root,
|
|
64
|
+
"database": self.database,
|
|
65
|
+
"schema_version": self.schema_version,
|
|
66
|
+
"generation": self.generation,
|
|
67
|
+
"state": self.state,
|
|
68
|
+
"node_count": self.node_count,
|
|
69
|
+
"edge_count": self.edge_count,
|
|
70
|
+
"created_at": self.created_at,
|
|
71
|
+
"analyzer_version": self.analyzer_version,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _json(value: Any) -> str:
|
|
76
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), default=str)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def compatibility_graph_digest(graph: CodeGraph) -> str:
|
|
80
|
+
"""Digest the public graph payload before store-only normalization."""
|
|
81
|
+
payload = graph.model_dump(mode="json")
|
|
82
|
+
return hashlib.sha256(_json(payload).encode("utf-8")).hexdigest()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _confidence_score(edge: GraphEdge) -> float:
|
|
86
|
+
raw = edge.extras.get("confidence_score")
|
|
87
|
+
if isinstance(raw, (int, float)):
|
|
88
|
+
return max(0.0, min(1.0, float(raw)))
|
|
89
|
+
confidence = edge.confidence.value if hasattr(edge.confidence, "value") else str(edge.confidence)
|
|
90
|
+
return {"extracted": 1.0, "inferred": 0.7, "ambiguous": 0.4}.get(confidence, 0.4)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _provenance(edge: GraphEdge) -> str:
|
|
94
|
+
raw = edge.extras.get("provenance")
|
|
95
|
+
if isinstance(raw, str) and raw in {"extracted", "framework", "inferred", "runtime", "user"}:
|
|
96
|
+
return raw
|
|
97
|
+
confidence = edge.confidence.value if hasattr(edge.confidence, "value") else str(edge.confidence)
|
|
98
|
+
return "extracted" if confidence == "extracted" else "inferred"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _canonicalize_duplicate_ids(graph: CodeGraph) -> CodeGraph:
|
|
102
|
+
"""Give repeated legacy symbol ids explicit structural identities.
|
|
103
|
+
|
|
104
|
+
Graph v2 historically identified a symbol by ``path::qualname``. That is
|
|
105
|
+
ambiguous for repeated declarations (including test redefinitions and
|
|
106
|
+
generated properties), while the transactional store requires one stable
|
|
107
|
+
row per identity. Preserve the first compatibility id and attach a
|
|
108
|
+
line/kind discriminator plus an alias edge to later declarations.
|
|
109
|
+
"""
|
|
110
|
+
grouped: dict[str, list[GraphNode]] = {}
|
|
111
|
+
for node in graph.nodes:
|
|
112
|
+
grouped.setdefault(node.id, []).append(node)
|
|
113
|
+
duplicates = {node_id: nodes for node_id, nodes in grouped.items() if len(nodes) > 1}
|
|
114
|
+
if not duplicates:
|
|
115
|
+
return graph
|
|
116
|
+
|
|
117
|
+
normalized = graph.model_copy(deep=True)
|
|
118
|
+
normalized_grouped: dict[str, list[GraphNode]] = {}
|
|
119
|
+
for node in normalized.nodes:
|
|
120
|
+
normalized_grouped.setdefault(node.id, []).append(node)
|
|
121
|
+
|
|
122
|
+
aliases: list[dict[str, object]] = []
|
|
123
|
+
assigned: set[str] = {node.id for node in normalized.nodes}
|
|
124
|
+
replacement_by_location: dict[tuple[str, int, str], str] = {}
|
|
125
|
+
for old_id, nodes in normalized_grouped.items():
|
|
126
|
+
if len(nodes) < 2:
|
|
127
|
+
continue
|
|
128
|
+
for ordinal, node in enumerate(nodes[1:], start=2):
|
|
129
|
+
kind = node.kind.value if hasattr(node.kind, "value") else str(node.kind)
|
|
130
|
+
discriminator = f"L{node.line}:{kind}"
|
|
131
|
+
candidate = f"{old_id}#{discriminator}"
|
|
132
|
+
suffix = ordinal
|
|
133
|
+
while candidate in assigned:
|
|
134
|
+
candidate = f"{old_id}#{discriminator}:{suffix}"
|
|
135
|
+
suffix += 1
|
|
136
|
+
assigned.add(candidate)
|
|
137
|
+
node.id = candidate
|
|
138
|
+
node.extras["identity_alias"] = old_id
|
|
139
|
+
node.extras["structural_discriminator"] = discriminator
|
|
140
|
+
replacement_by_location[(old_id, node.line, kind)] = candidate
|
|
141
|
+
aliases.append(
|
|
142
|
+
{
|
|
143
|
+
"old_id": old_id,
|
|
144
|
+
"new_id": candidate,
|
|
145
|
+
"reason": "duplicate legacy symbol identity",
|
|
146
|
+
}
|
|
147
|
+
)
|
|
148
|
+
normalized.edges.append(
|
|
149
|
+
GraphEdge(
|
|
150
|
+
source=node.path,
|
|
151
|
+
target=candidate,
|
|
152
|
+
kind="contains",
|
|
153
|
+
confidence=Confidence.EXTRACTED,
|
|
154
|
+
reason="structurally disambiguated definition",
|
|
155
|
+
extras={"provenance": "extracted", "identity_alias": old_id},
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
normalized.edges.append(
|
|
159
|
+
GraphEdge(
|
|
160
|
+
source=candidate,
|
|
161
|
+
target=old_id,
|
|
162
|
+
kind="aliases",
|
|
163
|
+
confidence=Confidence.EXTRACTED,
|
|
164
|
+
reason="duplicate legacy symbol identity",
|
|
165
|
+
extras={"provenance": "user", "structural_discriminator": discriminator},
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# Repeated definitions produced repeated identical containment rows. Keep
|
|
170
|
+
# the compatibility edge once; the disambiguated definitions have their
|
|
171
|
+
# own structural containment edge above.
|
|
172
|
+
seen_structural: set[tuple[str, str, str, str]] = set()
|
|
173
|
+
edges: list[GraphEdge] = []
|
|
174
|
+
for edge in normalized.edges:
|
|
175
|
+
key = (edge.source, edge.target, edge.kind, edge.reason)
|
|
176
|
+
if edge.kind == "contains" and edge.target in duplicates:
|
|
177
|
+
if key in seen_structural:
|
|
178
|
+
continue
|
|
179
|
+
seen_structural.add(key)
|
|
180
|
+
edges.append(edge)
|
|
181
|
+
normalized.edges = edges
|
|
182
|
+
|
|
183
|
+
for entry in normalized.dead_code:
|
|
184
|
+
replacement = replacement_by_location.get((entry.id, entry.line, entry.kind))
|
|
185
|
+
if replacement is not None:
|
|
186
|
+
entry.id = replacement
|
|
187
|
+
normalized.meta["duplicate_symbol_aliases"] = aliases
|
|
188
|
+
return normalized
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class CodeIntelStore:
|
|
192
|
+
"""Canonical graph store for one resolved project root.
|
|
193
|
+
|
|
194
|
+
A writer creates all rows for a new generation in one transaction and only
|
|
195
|
+
then advances ``current_generation``. Readers therefore see the complete old
|
|
196
|
+
or complete new graph, never a mixed refresh.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
def __init__(self, project_root: Path, *, path: Path | None = None):
|
|
200
|
+
self.project_root = project_root.expanduser().resolve()
|
|
201
|
+
self.path = (path or (self.project_root / INDEX_REL)).expanduser().resolve()
|
|
202
|
+
self._init_lock = threading.Lock()
|
|
203
|
+
self.last_write_stats: dict[str, int] = {}
|
|
204
|
+
|
|
205
|
+
def exists(self) -> bool:
|
|
206
|
+
return self.path.is_file()
|
|
207
|
+
|
|
208
|
+
def quarantine_if_corrupt(self, exc: sqlite3.DatabaseError) -> bool:
|
|
209
|
+
"""Move a damaged store aside so the next write rebuilds from scratch.
|
|
210
|
+
|
|
211
|
+
Returns True when the error signals file corruption and the store was
|
|
212
|
+
quarantined (``index.sqlite`` → ``index.sqlite.corrupt``). Lock/busy
|
|
213
|
+
errors and schema-level errors return False and must be raised by the
|
|
214
|
+
caller as before.
|
|
215
|
+
"""
|
|
216
|
+
if not _corruption_error(exc):
|
|
217
|
+
return False
|
|
218
|
+
if not self.quarantine():
|
|
219
|
+
return False
|
|
220
|
+
logger.warning(
|
|
221
|
+
"codeintel store is corrupt (%s); quarantined to %s — rebuilding from scratch",
|
|
222
|
+
exc,
|
|
223
|
+
self.path.name + ".corrupt",
|
|
224
|
+
)
|
|
225
|
+
return True
|
|
226
|
+
|
|
227
|
+
def quarantine(self) -> bool:
|
|
228
|
+
"""Move the store file (and WAL/SHM siblings) aside to ``*.corrupt``."""
|
|
229
|
+
quarantine = self.path.with_name(self.path.name + ".corrupt")
|
|
230
|
+
try:
|
|
231
|
+
for suffix in ("", "-wal", "-shm"):
|
|
232
|
+
source = Path(str(self.path) + suffix)
|
|
233
|
+
if source.exists():
|
|
234
|
+
target = Path(str(quarantine) + suffix)
|
|
235
|
+
target.unlink(missing_ok=True)
|
|
236
|
+
source.replace(target)
|
|
237
|
+
except OSError:
|
|
238
|
+
logger.warning("failed to quarantine corrupt codeintel store", exc_info=True)
|
|
239
|
+
return False
|
|
240
|
+
return True
|
|
241
|
+
|
|
242
|
+
def initialize(self) -> None:
|
|
243
|
+
with self._init_lock:
|
|
244
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
245
|
+
with self._connect() as conn:
|
|
246
|
+
self._migrate(conn)
|
|
247
|
+
|
|
248
|
+
@contextmanager
|
|
249
|
+
def _connect(self, *, readonly: bool = False) -> Iterator[sqlite3.Connection]:
|
|
250
|
+
if readonly:
|
|
251
|
+
uri = f"file:{self.path.as_posix()}?mode=ro"
|
|
252
|
+
conn = sqlite3.connect(uri, uri=True, timeout=5.0)
|
|
253
|
+
else:
|
|
254
|
+
conn = sqlite3.connect(self.path, timeout=30.0)
|
|
255
|
+
try:
|
|
256
|
+
conn.row_factory = sqlite3.Row
|
|
257
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
258
|
+
conn.execute("PRAGMA busy_timeout=5000")
|
|
259
|
+
if not readonly:
|
|
260
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
261
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
262
|
+
conn.execute("PRAGMA auto_vacuum=INCREMENTAL")
|
|
263
|
+
yield conn
|
|
264
|
+
finally:
|
|
265
|
+
# Close even when pragma setup raises (e.g. a corrupt file): a
|
|
266
|
+
# leaked handle keeps the file open, which blocks the Windows
|
|
267
|
+
# rename in quarantine().
|
|
268
|
+
conn.close()
|
|
269
|
+
|
|
270
|
+
def _migrate(self, conn: sqlite3.Connection) -> None:
|
|
271
|
+
version = int(conn.execute("PRAGMA user_version").fetchone()[0])
|
|
272
|
+
if version > STORE_SCHEMA_VERSION:
|
|
273
|
+
raise RuntimeError(
|
|
274
|
+
f"Code-intelligence database schema {version} is newer than supported "
|
|
275
|
+
f"schema {STORE_SCHEMA_VERSION}. Upgrade DevCouncil."
|
|
276
|
+
)
|
|
277
|
+
if version == 0:
|
|
278
|
+
conn.executescript(
|
|
279
|
+
"""
|
|
280
|
+
CREATE TABLE metadata (
|
|
281
|
+
key TEXT PRIMARY KEY,
|
|
282
|
+
value TEXT NOT NULL
|
|
283
|
+
);
|
|
284
|
+
CREATE TABLE generations (
|
|
285
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
286
|
+
state TEXT NOT NULL CHECK(state IN ('building', 'committed', 'failed')),
|
|
287
|
+
created_at REAL NOT NULL,
|
|
288
|
+
analyzer_version TEXT NOT NULL,
|
|
289
|
+
schema_version INTEGER NOT NULL,
|
|
290
|
+
generated_head TEXT NOT NULL DEFAULT '',
|
|
291
|
+
indexed_hash TEXT NOT NULL DEFAULT '',
|
|
292
|
+
content_fingerprint TEXT NOT NULL DEFAULT '',
|
|
293
|
+
graph_meta TEXT NOT NULL DEFAULT '{}',
|
|
294
|
+
node_count INTEGER NOT NULL DEFAULT 0,
|
|
295
|
+
edge_count INTEGER NOT NULL DEFAULT 0
|
|
296
|
+
);
|
|
297
|
+
CREATE TABLE files (
|
|
298
|
+
generation_id INTEGER NOT NULL REFERENCES generations(id) ON DELETE CASCADE,
|
|
299
|
+
path TEXT NOT NULL,
|
|
300
|
+
language TEXT NOT NULL DEFAULT '',
|
|
301
|
+
content_hash TEXT NOT NULL DEFAULT '',
|
|
302
|
+
size INTEGER NOT NULL DEFAULT 0,
|
|
303
|
+
mtime_ns INTEGER NOT NULL DEFAULT 0,
|
|
304
|
+
content BLOB,
|
|
305
|
+
PRIMARY KEY (generation_id, path)
|
|
306
|
+
);
|
|
307
|
+
CREATE TABLE nodes (
|
|
308
|
+
generation_id INTEGER NOT NULL REFERENCES generations(id) ON DELETE CASCADE,
|
|
309
|
+
id TEXT NOT NULL,
|
|
310
|
+
kind TEXT NOT NULL,
|
|
311
|
+
path TEXT NOT NULL DEFAULT '',
|
|
312
|
+
name TEXT NOT NULL DEFAULT '',
|
|
313
|
+
line INTEGER NOT NULL DEFAULT 0,
|
|
314
|
+
end_line INTEGER NOT NULL DEFAULT 0,
|
|
315
|
+
area TEXT NOT NULL DEFAULT '',
|
|
316
|
+
language TEXT NOT NULL DEFAULT '',
|
|
317
|
+
exported INTEGER NOT NULL DEFAULT 0,
|
|
318
|
+
community TEXT NOT NULL DEFAULT '',
|
|
319
|
+
extras TEXT NOT NULL DEFAULT '{}',
|
|
320
|
+
PRIMARY KEY (generation_id, id)
|
|
321
|
+
);
|
|
322
|
+
CREATE TABLE edges (
|
|
323
|
+
generation_id INTEGER NOT NULL REFERENCES generations(id) ON DELETE CASCADE,
|
|
324
|
+
ordinal INTEGER NOT NULL,
|
|
325
|
+
source TEXT NOT NULL,
|
|
326
|
+
target TEXT NOT NULL,
|
|
327
|
+
kind TEXT NOT NULL,
|
|
328
|
+
confidence TEXT NOT NULL,
|
|
329
|
+
confidence_score REAL NOT NULL,
|
|
330
|
+
provenance TEXT NOT NULL,
|
|
331
|
+
reason TEXT NOT NULL DEFAULT '',
|
|
332
|
+
evidence TEXT NOT NULL DEFAULT '[]',
|
|
333
|
+
analyzer_version TEXT NOT NULL,
|
|
334
|
+
source_fingerprint TEXT NOT NULL DEFAULT '',
|
|
335
|
+
extras TEXT NOT NULL DEFAULT '{}',
|
|
336
|
+
PRIMARY KEY (generation_id, ordinal)
|
|
337
|
+
);
|
|
338
|
+
CREATE TABLE dead_code (
|
|
339
|
+
generation_id INTEGER NOT NULL REFERENCES generations(id) ON DELETE CASCADE,
|
|
340
|
+
ordinal INTEGER NOT NULL,
|
|
341
|
+
id TEXT NOT NULL,
|
|
342
|
+
path TEXT NOT NULL,
|
|
343
|
+
line INTEGER NOT NULL DEFAULT 0,
|
|
344
|
+
kind TEXT NOT NULL DEFAULT '',
|
|
345
|
+
confidence TEXT NOT NULL,
|
|
346
|
+
reason TEXT NOT NULL DEFAULT '',
|
|
347
|
+
PRIMARY KEY (generation_id, ordinal)
|
|
348
|
+
);
|
|
349
|
+
CREATE TABLE unresolved_references (
|
|
350
|
+
generation_id INTEGER NOT NULL REFERENCES generations(id) ON DELETE CASCADE,
|
|
351
|
+
source_id TEXT NOT NULL,
|
|
352
|
+
name TEXT NOT NULL,
|
|
353
|
+
kind TEXT NOT NULL,
|
|
354
|
+
path TEXT NOT NULL DEFAULT '',
|
|
355
|
+
line INTEGER NOT NULL DEFAULT 0,
|
|
356
|
+
evidence TEXT NOT NULL DEFAULT '{}'
|
|
357
|
+
);
|
|
358
|
+
CREATE TABLE diagnostics (
|
|
359
|
+
generation_id INTEGER REFERENCES generations(id) ON DELETE CASCADE,
|
|
360
|
+
path TEXT NOT NULL DEFAULT '',
|
|
361
|
+
severity TEXT NOT NULL,
|
|
362
|
+
code TEXT NOT NULL,
|
|
363
|
+
message TEXT NOT NULL,
|
|
364
|
+
data TEXT NOT NULL DEFAULT '{}'
|
|
365
|
+
);
|
|
366
|
+
CREATE TABLE aliases (
|
|
367
|
+
old_id TEXT NOT NULL,
|
|
368
|
+
new_id TEXT NOT NULL,
|
|
369
|
+
generation_id INTEGER NOT NULL REFERENCES generations(id) ON DELETE CASCADE,
|
|
370
|
+
reason TEXT NOT NULL DEFAULT '',
|
|
371
|
+
PRIMARY KEY (old_id, generation_id)
|
|
372
|
+
);
|
|
373
|
+
CREATE TABLE extraction_cache (
|
|
374
|
+
content_hash TEXT NOT NULL,
|
|
375
|
+
language TEXT NOT NULL,
|
|
376
|
+
grammar_version TEXT NOT NULL,
|
|
377
|
+
analyzer_version TEXT NOT NULL,
|
|
378
|
+
config_hash TEXT NOT NULL,
|
|
379
|
+
payload BLOB NOT NULL,
|
|
380
|
+
created_at REAL NOT NULL,
|
|
381
|
+
PRIMARY KEY (content_hash, language, grammar_version, analyzer_version, config_hash)
|
|
382
|
+
);
|
|
383
|
+
CREATE TABLE runtime_sessions (
|
|
384
|
+
id TEXT PRIMARY KEY,
|
|
385
|
+
created_at REAL NOT NULL,
|
|
386
|
+
ended_at REAL,
|
|
387
|
+
provider TEXT NOT NULL,
|
|
388
|
+
source_fingerprint TEXT NOT NULL,
|
|
389
|
+
build_fingerprint TEXT NOT NULL,
|
|
390
|
+
executable_hash TEXT NOT NULL DEFAULT '',
|
|
391
|
+
metadata TEXT NOT NULL DEFAULT '{}'
|
|
392
|
+
);
|
|
393
|
+
CREATE TABLE runtime_observations (
|
|
394
|
+
session_id TEXT NOT NULL REFERENCES runtime_sessions(id) ON DELETE CASCADE,
|
|
395
|
+
ordinal INTEGER NOT NULL,
|
|
396
|
+
source TEXT NOT NULL,
|
|
397
|
+
target TEXT NOT NULL,
|
|
398
|
+
kind TEXT NOT NULL,
|
|
399
|
+
count INTEGER NOT NULL DEFAULT 1,
|
|
400
|
+
first_seen REAL NOT NULL,
|
|
401
|
+
last_seen REAL NOT NULL,
|
|
402
|
+
evidence TEXT NOT NULL DEFAULT '{}',
|
|
403
|
+
PRIMARY KEY (session_id, ordinal)
|
|
404
|
+
);
|
|
405
|
+
CREATE VIRTUAL TABLE nodes_fts USING fts5(
|
|
406
|
+
generation_id UNINDEXED,
|
|
407
|
+
node_id UNINDEXED,
|
|
408
|
+
name,
|
|
409
|
+
qualified_name,
|
|
410
|
+
path,
|
|
411
|
+
tokenize='unicode61'
|
|
412
|
+
);
|
|
413
|
+
CREATE INDEX idx_nodes_path ON nodes(generation_id, path);
|
|
414
|
+
CREATE INDEX idx_nodes_name ON nodes(generation_id, name);
|
|
415
|
+
CREATE INDEX idx_edges_source ON edges(generation_id, source, kind);
|
|
416
|
+
CREATE INDEX idx_edges_target ON edges(generation_id, target, kind);
|
|
417
|
+
CREATE INDEX idx_unresolved_name ON unresolved_references(generation_id, name);
|
|
418
|
+
PRAGMA user_version=1;
|
|
419
|
+
"""
|
|
420
|
+
)
|
|
421
|
+
conn.commit()
|
|
422
|
+
version = 1
|
|
423
|
+
if version == 1:
|
|
424
|
+
conn.executescript(
|
|
425
|
+
"""
|
|
426
|
+
CREATE TABLE node_payloads (
|
|
427
|
+
payload_hash TEXT PRIMARY KEY,
|
|
428
|
+
payload BLOB NOT NULL
|
|
429
|
+
);
|
|
430
|
+
CREATE TABLE generation_nodes (
|
|
431
|
+
generation_id INTEGER NOT NULL REFERENCES generations(id) ON DELETE CASCADE,
|
|
432
|
+
ordinal INTEGER NOT NULL,
|
|
433
|
+
payload_hash TEXT NOT NULL REFERENCES node_payloads(payload_hash),
|
|
434
|
+
path TEXT NOT NULL DEFAULT '',
|
|
435
|
+
node_id TEXT NOT NULL,
|
|
436
|
+
PRIMARY KEY (generation_id, node_id)
|
|
437
|
+
);
|
|
438
|
+
CREATE TABLE edge_payloads (
|
|
439
|
+
payload_hash TEXT PRIMARY KEY,
|
|
440
|
+
payload BLOB NOT NULL
|
|
441
|
+
);
|
|
442
|
+
CREATE TABLE generation_edges (
|
|
443
|
+
generation_id INTEGER NOT NULL REFERENCES generations(id) ON DELETE CASCADE,
|
|
444
|
+
ordinal INTEGER NOT NULL,
|
|
445
|
+
payload_hash TEXT NOT NULL REFERENCES edge_payloads(payload_hash),
|
|
446
|
+
source_path TEXT NOT NULL DEFAULT '',
|
|
447
|
+
target_path TEXT NOT NULL DEFAULT '',
|
|
448
|
+
PRIMARY KEY (generation_id, ordinal, payload_hash)
|
|
449
|
+
);
|
|
450
|
+
CREATE TABLE dead_payloads (
|
|
451
|
+
payload_hash TEXT PRIMARY KEY,
|
|
452
|
+
payload BLOB NOT NULL
|
|
453
|
+
);
|
|
454
|
+
CREATE TABLE generation_dead (
|
|
455
|
+
generation_id INTEGER NOT NULL REFERENCES generations(id) ON DELETE CASCADE,
|
|
456
|
+
ordinal INTEGER NOT NULL,
|
|
457
|
+
payload_hash TEXT NOT NULL REFERENCES dead_payloads(payload_hash),
|
|
458
|
+
path TEXT NOT NULL DEFAULT '',
|
|
459
|
+
PRIMARY KEY (generation_id, ordinal, payload_hash)
|
|
460
|
+
);
|
|
461
|
+
CREATE TABLE file_contents (
|
|
462
|
+
content_hash TEXT PRIMARY KEY,
|
|
463
|
+
content BLOB
|
|
464
|
+
);
|
|
465
|
+
CREATE TABLE generation_files (
|
|
466
|
+
generation_id INTEGER NOT NULL REFERENCES generations(id) ON DELETE CASCADE,
|
|
467
|
+
path TEXT NOT NULL,
|
|
468
|
+
language TEXT NOT NULL DEFAULT '',
|
|
469
|
+
content_hash TEXT NOT NULL DEFAULT '',
|
|
470
|
+
size INTEGER NOT NULL DEFAULT 0,
|
|
471
|
+
mtime_ns INTEGER NOT NULL DEFAULT 0,
|
|
472
|
+
PRIMARY KEY (generation_id, path)
|
|
473
|
+
);
|
|
474
|
+
CREATE TABLE analysis_payloads (
|
|
475
|
+
payload_hash TEXT PRIMARY KEY,
|
|
476
|
+
payload BLOB NOT NULL
|
|
477
|
+
);
|
|
478
|
+
CREATE TABLE generation_analysis (
|
|
479
|
+
generation_id INTEGER NOT NULL REFERENCES generations(id) ON DELETE CASCADE,
|
|
480
|
+
path TEXT NOT NULL,
|
|
481
|
+
payload_hash TEXT NOT NULL REFERENCES analysis_payloads(payload_hash),
|
|
482
|
+
PRIMARY KEY (generation_id, path)
|
|
483
|
+
);
|
|
484
|
+
CREATE INDEX idx_generation_nodes_path
|
|
485
|
+
ON generation_nodes(generation_id, path);
|
|
486
|
+
CREATE INDEX idx_generation_edges_source
|
|
487
|
+
ON generation_edges(generation_id, source_path);
|
|
488
|
+
CREATE INDEX idx_generation_edges_target
|
|
489
|
+
ON generation_edges(generation_id, target_path);
|
|
490
|
+
PRAGMA user_version=2;
|
|
491
|
+
"""
|
|
492
|
+
)
|
|
493
|
+
self._migrate_v1_payloads(conn)
|
|
494
|
+
conn.commit()
|
|
495
|
+
|
|
496
|
+
def _migrate_v1_payloads(self, conn: sqlite3.Connection) -> None:
|
|
497
|
+
"""Copy retained v1 generations into content-addressed payload tables."""
|
|
498
|
+
for row in conn.execute("SELECT * FROM nodes ORDER BY generation_id, rowid"):
|
|
499
|
+
payload = {
|
|
500
|
+
key: row[key]
|
|
501
|
+
for key in (
|
|
502
|
+
"id", "kind", "path", "name", "line", "end_line", "area",
|
|
503
|
+
"language", "exported", "community", "extras",
|
|
504
|
+
)
|
|
505
|
+
}
|
|
506
|
+
payload["exported"] = bool(payload["exported"])
|
|
507
|
+
payload["extras"] = json.loads(payload["extras"])
|
|
508
|
+
digest, blob = self._payload(payload)
|
|
509
|
+
conn.execute("INSERT OR IGNORE INTO node_payloads VALUES(?, ?)", (digest, blob))
|
|
510
|
+
ordinal = int(conn.execute(
|
|
511
|
+
"SELECT COUNT(*) FROM generation_nodes WHERE generation_id=?",
|
|
512
|
+
(row["generation_id"],),
|
|
513
|
+
).fetchone()[0])
|
|
514
|
+
conn.execute(
|
|
515
|
+
"INSERT OR IGNORE INTO generation_nodes VALUES(?, ?, ?, ?, ?)",
|
|
516
|
+
(row["generation_id"], ordinal, digest, row["path"], row["id"]),
|
|
517
|
+
)
|
|
518
|
+
for row in conn.execute("SELECT * FROM edges ORDER BY generation_id, ordinal"):
|
|
519
|
+
payload = {
|
|
520
|
+
key: row[key]
|
|
521
|
+
for key in (
|
|
522
|
+
"source", "target", "kind", "confidence", "confidence_score",
|
|
523
|
+
"provenance", "reason", "evidence", "analyzer_version",
|
|
524
|
+
"source_fingerprint", "extras",
|
|
525
|
+
)
|
|
526
|
+
}
|
|
527
|
+
payload["extras"] = json.loads(payload["extras"])
|
|
528
|
+
digest, blob = self._payload(payload)
|
|
529
|
+
conn.execute("INSERT OR IGNORE INTO edge_payloads VALUES(?, ?)", (digest, blob))
|
|
530
|
+
conn.execute(
|
|
531
|
+
"INSERT OR IGNORE INTO generation_edges VALUES(?, ?, ?, ?, ?)",
|
|
532
|
+
(
|
|
533
|
+
row["generation_id"], row["ordinal"], digest,
|
|
534
|
+
self._identity_path(row["source"]), self._identity_path(row["target"]),
|
|
535
|
+
),
|
|
536
|
+
)
|
|
537
|
+
for row in conn.execute("SELECT * FROM dead_code ORDER BY generation_id, ordinal"):
|
|
538
|
+
payload = {
|
|
539
|
+
key: row[key]
|
|
540
|
+
for key in ("id", "path", "line", "kind", "confidence", "reason")
|
|
541
|
+
}
|
|
542
|
+
digest, blob = self._payload(payload)
|
|
543
|
+
conn.execute("INSERT OR IGNORE INTO dead_payloads VALUES(?, ?)", (digest, blob))
|
|
544
|
+
conn.execute(
|
|
545
|
+
"INSERT OR IGNORE INTO generation_dead VALUES(?, ?, ?, ?)",
|
|
546
|
+
(row["generation_id"], row["ordinal"], digest, row["path"]),
|
|
547
|
+
)
|
|
548
|
+
for row in conn.execute("SELECT * FROM files ORDER BY generation_id, path"):
|
|
549
|
+
if row["content_hash"]:
|
|
550
|
+
conn.execute(
|
|
551
|
+
"INSERT OR IGNORE INTO file_contents VALUES(?, ?)",
|
|
552
|
+
(row["content_hash"], row["content"]),
|
|
553
|
+
)
|
|
554
|
+
conn.execute(
|
|
555
|
+
"INSERT OR IGNORE INTO generation_files VALUES(?, ?, ?, ?, ?, ?)",
|
|
556
|
+
(
|
|
557
|
+
row["generation_id"], row["path"], row["language"],
|
|
558
|
+
row["content_hash"], row["size"], row["mtime_ns"],
|
|
559
|
+
),
|
|
560
|
+
)
|
|
561
|
+
# The v2 payload/membership tables are canonical. Keep legacy tables
|
|
562
|
+
# present for rollback-compatible schema inspection, but not duplicated.
|
|
563
|
+
conn.execute("DELETE FROM diagnostics")
|
|
564
|
+
conn.execute("DELETE FROM dead_code")
|
|
565
|
+
conn.execute("DELETE FROM edges")
|
|
566
|
+
conn.execute("DELETE FROM nodes")
|
|
567
|
+
conn.execute("DELETE FROM files")
|
|
568
|
+
|
|
569
|
+
@staticmethod
|
|
570
|
+
def _payload(value: dict[str, Any]) -> tuple[str, bytes]:
|
|
571
|
+
raw = _json(value).encode("utf-8")
|
|
572
|
+
return hashlib.sha256(raw).hexdigest(), zlib.compress(raw, 6)
|
|
573
|
+
|
|
574
|
+
@staticmethod
|
|
575
|
+
def _decode_payload(blob: bytes) -> dict[str, Any]:
|
|
576
|
+
return dict(json.loads(zlib.decompress(blob)))
|
|
577
|
+
|
|
578
|
+
@staticmethod
|
|
579
|
+
def _identity_path(identity: str) -> str:
|
|
580
|
+
return identity.split("::", 1)[0].replace("\\", "/")
|
|
581
|
+
|
|
582
|
+
def current_generation(self) -> int | None:
|
|
583
|
+
if not self.exists():
|
|
584
|
+
return None
|
|
585
|
+
with self._connect(readonly=True) as conn:
|
|
586
|
+
row = conn.execute("SELECT value FROM metadata WHERE key='current_generation'").fetchone()
|
|
587
|
+
return int(row[0]) if row is not None else None
|
|
588
|
+
|
|
589
|
+
def save_graph(
|
|
590
|
+
self,
|
|
591
|
+
graph: CodeGraph,
|
|
592
|
+
*,
|
|
593
|
+
retain_generations: int = 2,
|
|
594
|
+
changed_paths: set[str] | None = None,
|
|
595
|
+
analysis_shards: dict[str, dict[str, Any]] | None = None,
|
|
596
|
+
) -> int:
|
|
597
|
+
self.initialize()
|
|
598
|
+
compatibility_digest = compatibility_graph_digest(graph)
|
|
599
|
+
graph = _canonicalize_duplicate_ids(graph)
|
|
600
|
+
graph_meta = dict(graph.meta)
|
|
601
|
+
graph_meta.pop("communities", None)
|
|
602
|
+
meta = {
|
|
603
|
+
"dead_code": len(graph.dead_code),
|
|
604
|
+
"entry_roots": graph.entry_roots,
|
|
605
|
+
"unwired_candidates": graph.unwired_candidates,
|
|
606
|
+
"unreachable_files": graph.unreachable_files,
|
|
607
|
+
"meta": graph_meta,
|
|
608
|
+
}
|
|
609
|
+
with self._connect() as conn:
|
|
610
|
+
try:
|
|
611
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
612
|
+
previous_row = conn.execute(
|
|
613
|
+
"SELECT value FROM metadata WHERE key='current_generation'"
|
|
614
|
+
).fetchone()
|
|
615
|
+
previous_generation = int(previous_row[0]) if previous_row is not None else None
|
|
616
|
+
cursor = conn.execute(
|
|
617
|
+
"""INSERT INTO generations(
|
|
618
|
+
state, created_at, analyzer_version, schema_version,
|
|
619
|
+
generated_head, indexed_hash, content_fingerprint, graph_meta
|
|
620
|
+
) VALUES('building', ?, ?, ?, ?, ?, ?, ?)""",
|
|
621
|
+
(
|
|
622
|
+
time.time(),
|
|
623
|
+
ANALYZER_VERSION,
|
|
624
|
+
graph.schema_version,
|
|
625
|
+
graph.generated_head,
|
|
626
|
+
graph.indexed_hash,
|
|
627
|
+
graph.content_fingerprint,
|
|
628
|
+
_json(meta),
|
|
629
|
+
),
|
|
630
|
+
)
|
|
631
|
+
if cursor.lastrowid is None:
|
|
632
|
+
raise RuntimeError("SQLite did not return a generation id")
|
|
633
|
+
generation = int(cursor.lastrowid)
|
|
634
|
+
normalized_changed = {
|
|
635
|
+
path.replace("\\", "/") for path in (changed_paths or set())
|
|
636
|
+
}
|
|
637
|
+
# Empty changed_paths must not enter incremental mode: _copy_unaffected
|
|
638
|
+
# returns immediately when the exclusion set is empty, which would
|
|
639
|
+
# commit a generation with zero memberships.
|
|
640
|
+
incremental = (
|
|
641
|
+
previous_generation is not None
|
|
642
|
+
and changed_paths is not None
|
|
643
|
+
and bool(normalized_changed)
|
|
644
|
+
)
|
|
645
|
+
if incremental:
|
|
646
|
+
assert previous_generation is not None
|
|
647
|
+
self._copy_unaffected_memberships(
|
|
648
|
+
conn, previous_generation, generation, normalized_changed
|
|
649
|
+
)
|
|
650
|
+
file_nodes: Sequence[GraphNode] = (
|
|
651
|
+
[node for node in graph.nodes if node.path in normalized_changed]
|
|
652
|
+
if incremental else graph.nodes
|
|
653
|
+
)
|
|
654
|
+
file_rows = self._file_rows(generation, file_nodes)
|
|
655
|
+
for row in file_rows:
|
|
656
|
+
_, path, language, content_hash, size, mtime_ns, content = row
|
|
657
|
+
if content_hash:
|
|
658
|
+
conn.execute(
|
|
659
|
+
"INSERT OR IGNORE INTO file_contents(content_hash, content) VALUES(?, ?)",
|
|
660
|
+
(content_hash, content),
|
|
661
|
+
)
|
|
662
|
+
conn.execute(
|
|
663
|
+
"""INSERT OR REPLACE INTO generation_files(
|
|
664
|
+
generation_id, path, language, content_hash, size, mtime_ns
|
|
665
|
+
) VALUES(?, ?, ?, ?, ?, ?)""",
|
|
666
|
+
(generation, path, language, content_hash, size, mtime_ns),
|
|
667
|
+
)
|
|
668
|
+
node_payload_writes = 0
|
|
669
|
+
edge_payload_writes = 0
|
|
670
|
+
dead_payload_writes = 0
|
|
671
|
+
for index, node in enumerate(graph.nodes):
|
|
672
|
+
if incremental and node.path not in normalized_changed:
|
|
673
|
+
continue
|
|
674
|
+
payload = self._node_payload(node)
|
|
675
|
+
digest, blob = self._payload(payload)
|
|
676
|
+
node_payload_writes += conn.execute(
|
|
677
|
+
"INSERT OR IGNORE INTO node_payloads VALUES(?, ?)", (digest, blob)
|
|
678
|
+
).rowcount
|
|
679
|
+
conn.execute(
|
|
680
|
+
"INSERT OR REPLACE INTO generation_nodes VALUES(?, ?, ?, ?, ?)",
|
|
681
|
+
(generation, index, digest, node.path, node.id),
|
|
682
|
+
)
|
|
683
|
+
for index, edge in enumerate(graph.edges):
|
|
684
|
+
source_path = self._identity_path(edge.source)
|
|
685
|
+
target_path = self._identity_path(edge.target)
|
|
686
|
+
if incremental and not ({source_path, target_path} & normalized_changed):
|
|
687
|
+
continue
|
|
688
|
+
payload = self._edge_payload(edge)
|
|
689
|
+
digest, blob = self._payload(payload)
|
|
690
|
+
edge_payload_writes += conn.execute(
|
|
691
|
+
"INSERT OR IGNORE INTO edge_payloads VALUES(?, ?)", (digest, blob)
|
|
692
|
+
).rowcount
|
|
693
|
+
conn.execute(
|
|
694
|
+
"INSERT OR IGNORE INTO generation_edges VALUES(?, ?, ?, ?, ?)",
|
|
695
|
+
(generation, index, digest, source_path, target_path),
|
|
696
|
+
)
|
|
697
|
+
unresolved = self._unresolved_rows(generation, graph)
|
|
698
|
+
conn.executemany(
|
|
699
|
+
"""INSERT INTO unresolved_references(
|
|
700
|
+
generation_id, source_id, name, kind, path, line, evidence
|
|
701
|
+
) VALUES(?, ?, ?, ?, ?, ?, ?)""",
|
|
702
|
+
unresolved,
|
|
703
|
+
)
|
|
704
|
+
for index, entry in enumerate(graph.dead_code):
|
|
705
|
+
if incremental and entry.path not in normalized_changed:
|
|
706
|
+
continue
|
|
707
|
+
payload = self._dead_payload(entry)
|
|
708
|
+
digest, blob = self._payload(payload)
|
|
709
|
+
dead_payload_writes += conn.execute(
|
|
710
|
+
"INSERT OR IGNORE INTO dead_payloads VALUES(?, ?)", (digest, blob)
|
|
711
|
+
).rowcount
|
|
712
|
+
conn.execute(
|
|
713
|
+
"INSERT OR IGNORE INTO generation_dead VALUES(?, ?, ?, ?)",
|
|
714
|
+
(generation, index, digest, entry.path),
|
|
715
|
+
)
|
|
716
|
+
conn.executemany(
|
|
717
|
+
"INSERT INTO nodes_fts(generation_id, node_id, name, qualified_name, path) VALUES(?, ?, ?, ?, ?)",
|
|
718
|
+
[
|
|
719
|
+
(generation, node.id, node.name, node.id.rsplit("::", 1)[-1], node.path)
|
|
720
|
+
for node in graph.nodes
|
|
721
|
+
if not incremental or node.path in normalized_changed
|
|
722
|
+
],
|
|
723
|
+
)
|
|
724
|
+
if analysis_shards is not None:
|
|
725
|
+
self._write_analysis_shards(
|
|
726
|
+
conn, generation, analysis_shards, normalized_changed if incremental else None
|
|
727
|
+
)
|
|
728
|
+
if previous_generation is not None:
|
|
729
|
+
self._record_rename_aliases(conn, previous_generation, generation)
|
|
730
|
+
conn.execute(
|
|
731
|
+
"UPDATE generations SET state='committed', node_count=?, edge_count=? WHERE id=?",
|
|
732
|
+
(len(graph.nodes), len(graph.edges), generation),
|
|
733
|
+
)
|
|
734
|
+
conn.execute(
|
|
735
|
+
"INSERT INTO metadata(key, value) VALUES('current_generation', ?) "
|
|
736
|
+
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
|
737
|
+
(str(generation),),
|
|
738
|
+
)
|
|
739
|
+
conn.execute(
|
|
740
|
+
"INSERT INTO metadata(key, value) VALUES('compatibility_export_digest', ?) "
|
|
741
|
+
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
|
742
|
+
(compatibility_digest,),
|
|
743
|
+
)
|
|
744
|
+
self._prune(conn, keep=max(1, retain_generations), current=generation)
|
|
745
|
+
self._compact_payloads(conn)
|
|
746
|
+
self.last_write_stats = {
|
|
747
|
+
"node_payloads_written": node_payload_writes,
|
|
748
|
+
"edge_payloads_written": edge_payload_writes,
|
|
749
|
+
"dead_payloads_written": dead_payload_writes,
|
|
750
|
+
"node_memberships": int(conn.execute(
|
|
751
|
+
"SELECT COUNT(*) FROM generation_nodes WHERE generation_id=?",
|
|
752
|
+
(generation,),
|
|
753
|
+
).fetchone()[0]),
|
|
754
|
+
"edge_memberships": int(conn.execute(
|
|
755
|
+
"SELECT COUNT(*) FROM generation_edges WHERE generation_id=?",
|
|
756
|
+
(generation,),
|
|
757
|
+
).fetchone()[0]),
|
|
758
|
+
}
|
|
759
|
+
conn.commit()
|
|
760
|
+
return generation
|
|
761
|
+
except Exception:
|
|
762
|
+
conn.rollback()
|
|
763
|
+
raise
|
|
764
|
+
|
|
765
|
+
def compatibility_export_state(self) -> tuple[str, int | None]:
|
|
766
|
+
"""Return the last public graph digest and observed export mtime."""
|
|
767
|
+
if not self.exists():
|
|
768
|
+
return "", None
|
|
769
|
+
with self._connect(readonly=True) as conn:
|
|
770
|
+
rows = conn.execute(
|
|
771
|
+
"SELECT key, value FROM metadata WHERE key IN "
|
|
772
|
+
"('compatibility_export_digest', 'compatibility_export_mtime_ns')"
|
|
773
|
+
).fetchall()
|
|
774
|
+
values = {str(row["key"]): str(row["value"]) for row in rows}
|
|
775
|
+
raw_mtime = values.get("compatibility_export_mtime_ns")
|
|
776
|
+
return values.get("compatibility_export_digest", ""), (
|
|
777
|
+
int(raw_mtime) if raw_mtime is not None else None
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
def record_compatibility_export(self, path: Path, graph: CodeGraph) -> None:
|
|
781
|
+
"""Mark an on-disk JSON artifact as the export for this generation."""
|
|
782
|
+
self.initialize()
|
|
783
|
+
mtime_ns = path.stat().st_mtime_ns
|
|
784
|
+
digest = compatibility_graph_digest(graph)
|
|
785
|
+
with self._connect() as conn:
|
|
786
|
+
conn.executemany(
|
|
787
|
+
"INSERT INTO metadata(key, value) VALUES(?, ?) "
|
|
788
|
+
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
|
789
|
+
[
|
|
790
|
+
("compatibility_export_digest", digest),
|
|
791
|
+
("compatibility_export_mtime_ns", str(mtime_ns)),
|
|
792
|
+
],
|
|
793
|
+
)
|
|
794
|
+
conn.commit()
|
|
795
|
+
|
|
796
|
+
def _file_rows(self, generation: int, nodes: Sequence[GraphNode]) -> list[tuple[Any, ...]]:
|
|
797
|
+
by_path: dict[str, str] = {}
|
|
798
|
+
for node in nodes:
|
|
799
|
+
if node.path:
|
|
800
|
+
by_path.setdefault(node.path, node.language)
|
|
801
|
+
rows: list[tuple[Any, ...]] = []
|
|
802
|
+
for rel, language in sorted(by_path.items()):
|
|
803
|
+
path = self.project_root / rel
|
|
804
|
+
try:
|
|
805
|
+
raw = path.read_bytes()
|
|
806
|
+
stat = path.stat()
|
|
807
|
+
except OSError:
|
|
808
|
+
rows.append((generation, rel, language, "", 0, 0, None))
|
|
809
|
+
continue
|
|
810
|
+
digest = hashlib.sha256(raw).hexdigest()
|
|
811
|
+
rows.append((generation, rel, language, digest, len(raw), stat.st_mtime_ns, zlib.compress(raw, 6)))
|
|
812
|
+
return rows
|
|
813
|
+
|
|
814
|
+
@staticmethod
|
|
815
|
+
def _node_payload(node: GraphNode) -> dict[str, Any]:
|
|
816
|
+
return node.model_dump(mode="json")
|
|
817
|
+
|
|
818
|
+
@staticmethod
|
|
819
|
+
def _edge_payload(edge: GraphEdge) -> dict[str, Any]:
|
|
820
|
+
payload = edge.model_dump(mode="json")
|
|
821
|
+
evidence = payload["extras"].get("evidence")
|
|
822
|
+
if (
|
|
823
|
+
payload["confidence"] == Confidence.AMBIGUOUS.value
|
|
824
|
+
and isinstance(evidence, list)
|
|
825
|
+
and len(evidence) > AMBIGUOUS_EVIDENCE_LIMIT
|
|
826
|
+
):
|
|
827
|
+
payload["extras"]["evidence"] = evidence[:AMBIGUOUS_EVIDENCE_LIMIT]
|
|
828
|
+
payload["extras"]["evidence_truncated"] = len(evidence) - AMBIGUOUS_EVIDENCE_LIMIT
|
|
829
|
+
return payload
|
|
830
|
+
|
|
831
|
+
@staticmethod
|
|
832
|
+
def _dead_payload(entry: DeadCodeEntry) -> dict[str, Any]:
|
|
833
|
+
return entry.model_dump(mode="json")
|
|
834
|
+
|
|
835
|
+
@staticmethod
|
|
836
|
+
def _copy_unaffected_memberships(
|
|
837
|
+
conn: sqlite3.Connection,
|
|
838
|
+
previous: int,
|
|
839
|
+
generation: int,
|
|
840
|
+
changed: set[str],
|
|
841
|
+
) -> None:
|
|
842
|
+
placeholders = ",".join("?" for _ in changed)
|
|
843
|
+
if not placeholders:
|
|
844
|
+
return
|
|
845
|
+
params: tuple[Any, ...] = (generation, previous, *sorted(changed))
|
|
846
|
+
conn.execute(
|
|
847
|
+
f"""INSERT INTO generation_files
|
|
848
|
+
SELECT ?, path, language, content_hash, size, mtime_ns
|
|
849
|
+
FROM generation_files
|
|
850
|
+
WHERE generation_id=? AND path NOT IN ({placeholders})""", # noqa: S608
|
|
851
|
+
params,
|
|
852
|
+
)
|
|
853
|
+
conn.execute(
|
|
854
|
+
f"""INSERT INTO generation_nodes
|
|
855
|
+
SELECT ?, ordinal, payload_hash, path, node_id
|
|
856
|
+
FROM generation_nodes
|
|
857
|
+
WHERE generation_id=? AND path NOT IN ({placeholders})""", # noqa: S608
|
|
858
|
+
params,
|
|
859
|
+
)
|
|
860
|
+
edge_params: tuple[Any, ...] = (
|
|
861
|
+
generation, previous, *sorted(changed), *sorted(changed)
|
|
862
|
+
)
|
|
863
|
+
conn.execute(
|
|
864
|
+
f"""INSERT INTO generation_edges
|
|
865
|
+
SELECT ?, ordinal, payload_hash, source_path, target_path
|
|
866
|
+
FROM generation_edges
|
|
867
|
+
WHERE generation_id=?
|
|
868
|
+
AND source_path NOT IN ({placeholders})
|
|
869
|
+
AND target_path NOT IN ({placeholders})""", # noqa: S608
|
|
870
|
+
edge_params,
|
|
871
|
+
)
|
|
872
|
+
conn.execute(
|
|
873
|
+
f"""INSERT INTO generation_dead
|
|
874
|
+
SELECT ?, ordinal, payload_hash, path
|
|
875
|
+
FROM generation_dead
|
|
876
|
+
WHERE generation_id=? AND path NOT IN ({placeholders})""", # noqa: S608
|
|
877
|
+
params,
|
|
878
|
+
)
|
|
879
|
+
conn.execute(
|
|
880
|
+
f"""INSERT INTO generation_analysis
|
|
881
|
+
SELECT ?, path, payload_hash
|
|
882
|
+
FROM generation_analysis
|
|
883
|
+
WHERE generation_id=? AND path NOT IN ({placeholders})""", # noqa: S608
|
|
884
|
+
params,
|
|
885
|
+
)
|
|
886
|
+
conn.execute(
|
|
887
|
+
f"""INSERT INTO nodes_fts(generation_id, node_id, name, qualified_name, path)
|
|
888
|
+
SELECT ?, node_id, name, qualified_name, path
|
|
889
|
+
FROM nodes_fts
|
|
890
|
+
WHERE generation_id=? AND path NOT IN ({placeholders})""", # noqa: S608
|
|
891
|
+
params,
|
|
892
|
+
)
|
|
893
|
+
|
|
894
|
+
def _write_analysis_shards(
|
|
895
|
+
self,
|
|
896
|
+
conn: sqlite3.Connection,
|
|
897
|
+
generation: int,
|
|
898
|
+
shards: dict[str, dict[str, Any]],
|
|
899
|
+
changed: set[str] | None,
|
|
900
|
+
) -> None:
|
|
901
|
+
for path, shard in shards.items():
|
|
902
|
+
normalized = path.replace("\\", "/")
|
|
903
|
+
if changed is not None and normalized not in changed:
|
|
904
|
+
continue
|
|
905
|
+
digest, blob = self._payload(shard)
|
|
906
|
+
conn.execute("INSERT OR IGNORE INTO analysis_payloads VALUES(?, ?)", (digest, blob))
|
|
907
|
+
conn.execute(
|
|
908
|
+
"INSERT OR REPLACE INTO generation_analysis VALUES(?, ?, ?)",
|
|
909
|
+
(generation, normalized, digest),
|
|
910
|
+
)
|
|
911
|
+
|
|
912
|
+
@staticmethod
|
|
913
|
+
def _compact_payloads(conn: sqlite3.Connection) -> None:
|
|
914
|
+
for payload_table, membership_table in (
|
|
915
|
+
("node_payloads", "generation_nodes"),
|
|
916
|
+
("edge_payloads", "generation_edges"),
|
|
917
|
+
("dead_payloads", "generation_dead"),
|
|
918
|
+
("analysis_payloads", "generation_analysis"),
|
|
919
|
+
):
|
|
920
|
+
conn.execute(
|
|
921
|
+
f"""DELETE FROM {payload_table}
|
|
922
|
+
WHERE payload_hash NOT IN (
|
|
923
|
+
SELECT DISTINCT payload_hash FROM {membership_table}
|
|
924
|
+
)""" # noqa: S608
|
|
925
|
+
)
|
|
926
|
+
conn.execute(
|
|
927
|
+
"""DELETE FROM file_contents
|
|
928
|
+
WHERE content_hash NOT IN (
|
|
929
|
+
SELECT DISTINCT content_hash FROM generation_files
|
|
930
|
+
WHERE content_hash<>''
|
|
931
|
+
)"""
|
|
932
|
+
)
|
|
933
|
+
conn.execute("PRAGMA incremental_vacuum(64)")
|
|
934
|
+
|
|
935
|
+
@staticmethod
|
|
936
|
+
def _node_row(generation: int, node: GraphNode) -> tuple[Any, ...]:
|
|
937
|
+
kind = node.kind.value if hasattr(node.kind, "value") else str(node.kind)
|
|
938
|
+
return (
|
|
939
|
+
generation,
|
|
940
|
+
node.id,
|
|
941
|
+
kind,
|
|
942
|
+
node.path,
|
|
943
|
+
node.name,
|
|
944
|
+
node.line,
|
|
945
|
+
node.end_line,
|
|
946
|
+
node.area,
|
|
947
|
+
node.language,
|
|
948
|
+
int(node.exported),
|
|
949
|
+
node.community,
|
|
950
|
+
_json(node.extras),
|
|
951
|
+
)
|
|
952
|
+
|
|
953
|
+
@staticmethod
|
|
954
|
+
def _edge_row(generation: int, ordinal: int, edge: GraphEdge) -> tuple[Any, ...]:
|
|
955
|
+
confidence = edge.confidence.value if hasattr(edge.confidence, "value") else str(edge.confidence)
|
|
956
|
+
evidence = edge.extras.get("evidence", [])
|
|
957
|
+
fingerprint = str(edge.extras.get("source_fingerprint", ""))
|
|
958
|
+
return (
|
|
959
|
+
generation,
|
|
960
|
+
ordinal,
|
|
961
|
+
edge.source,
|
|
962
|
+
edge.target,
|
|
963
|
+
edge.kind,
|
|
964
|
+
confidence,
|
|
965
|
+
_confidence_score(edge),
|
|
966
|
+
_provenance(edge),
|
|
967
|
+
edge.reason,
|
|
968
|
+
_json(evidence),
|
|
969
|
+
ANALYZER_VERSION,
|
|
970
|
+
fingerprint,
|
|
971
|
+
_json(edge.extras),
|
|
972
|
+
)
|
|
973
|
+
|
|
974
|
+
@staticmethod
|
|
975
|
+
def _unresolved_rows(generation: int, graph: CodeGraph) -> list[tuple[Any, ...]]:
|
|
976
|
+
incoming: dict[str, str] = {}
|
|
977
|
+
for edge in graph.edges:
|
|
978
|
+
if edge.kind == "dynamic_reference":
|
|
979
|
+
incoming.setdefault(edge.target, edge.source)
|
|
980
|
+
rows: list[tuple[Any, ...]] = []
|
|
981
|
+
for node in graph.nodes:
|
|
982
|
+
kind = node.kind.value if hasattr(node.kind, "value") else str(node.kind)
|
|
983
|
+
if kind != "dynamic" or node.extras.get("resolved") is not False:
|
|
984
|
+
continue
|
|
985
|
+
rows.append((
|
|
986
|
+
generation,
|
|
987
|
+
incoming.get(node.id, node.path),
|
|
988
|
+
node.name,
|
|
989
|
+
str(node.extras.get("sink") or "dynamic"),
|
|
990
|
+
node.path,
|
|
991
|
+
node.line,
|
|
992
|
+
_json({"node_id": node.id, "extras": node.extras}),
|
|
993
|
+
))
|
|
994
|
+
for raw in graph.meta.get("unresolved_references") or []:
|
|
995
|
+
if not isinstance(raw, dict) or not raw.get("name"):
|
|
996
|
+
continue
|
|
997
|
+
rows.append((
|
|
998
|
+
generation,
|
|
999
|
+
str(raw.get("source_id") or raw.get("path") or ""),
|
|
1000
|
+
str(raw["name"]),
|
|
1001
|
+
str(raw.get("kind") or "reference"),
|
|
1002
|
+
str(raw.get("path") or ""),
|
|
1003
|
+
int(raw.get("line") or 0),
|
|
1004
|
+
_json(raw.get("evidence") or {}),
|
|
1005
|
+
))
|
|
1006
|
+
return rows
|
|
1007
|
+
|
|
1008
|
+
def _record_rename_aliases(
|
|
1009
|
+
self, conn: sqlite3.Connection, previous: int, current: int
|
|
1010
|
+
) -> None:
|
|
1011
|
+
"""Preserve identities across same-content file renames as explicit aliases.
|
|
1012
|
+
|
|
1013
|
+
A rename is a path that left the index whose content reappeared at
|
|
1014
|
+
exactly one new path. Joining generations on content alone would
|
|
1015
|
+
cross-link every pair of identical files (empty ``__init__.py``,
|
|
1016
|
+
generated boilerplate) into false aliases with quadratic node lookups.
|
|
1017
|
+
"""
|
|
1018
|
+
removed = conn.execute(
|
|
1019
|
+
"""SELECT path, content_hash FROM generation_files
|
|
1020
|
+
WHERE generation_id=? AND content_hash<>''
|
|
1021
|
+
AND path NOT IN (
|
|
1022
|
+
SELECT path FROM generation_files WHERE generation_id=?
|
|
1023
|
+
)""",
|
|
1024
|
+
(previous, current),
|
|
1025
|
+
).fetchall()
|
|
1026
|
+
added = conn.execute(
|
|
1027
|
+
"""SELECT path, content_hash FROM generation_files
|
|
1028
|
+
WHERE generation_id=? AND content_hash<>''
|
|
1029
|
+
AND path NOT IN (
|
|
1030
|
+
SELECT path FROM generation_files WHERE generation_id=?
|
|
1031
|
+
)""",
|
|
1032
|
+
(current, previous),
|
|
1033
|
+
).fetchall()
|
|
1034
|
+
removed_by_hash: dict[str, list[str]] = {}
|
|
1035
|
+
for row in removed:
|
|
1036
|
+
removed_by_hash.setdefault(str(row["content_hash"]), []).append(str(row["path"]))
|
|
1037
|
+
added_by_hash: dict[str, list[str]] = {}
|
|
1038
|
+
for row in added:
|
|
1039
|
+
added_by_hash.setdefault(str(row["content_hash"]), []).append(str(row["path"]))
|
|
1040
|
+
renamed = [
|
|
1041
|
+
(old_paths[0], new_paths[0])
|
|
1042
|
+
for content_hash, old_paths in sorted(removed_by_hash.items())
|
|
1043
|
+
if len(old_paths) == 1
|
|
1044
|
+
and len(new_paths := added_by_hash.get(content_hash, [])) == 1
|
|
1045
|
+
]
|
|
1046
|
+
for old_path, new_path in renamed:
|
|
1047
|
+
old_nodes = self._nodes_for_path(conn, previous, old_path)
|
|
1048
|
+
new_nodes = self._nodes_for_path(conn, current, new_path)
|
|
1049
|
+
by_shape = {
|
|
1050
|
+
(
|
|
1051
|
+
node.kind.value, node.name if node.kind != NodeKind.FILE else "",
|
|
1052
|
+
node.line, node.end_line,
|
|
1053
|
+
): node
|
|
1054
|
+
for node in new_nodes
|
|
1055
|
+
}
|
|
1056
|
+
for old in old_nodes:
|
|
1057
|
+
key = (
|
|
1058
|
+
old.kind.value, old.name if old.kind != NodeKind.FILE else "",
|
|
1059
|
+
old.line, old.end_line,
|
|
1060
|
+
)
|
|
1061
|
+
new = by_shape.get(key)
|
|
1062
|
+
if new is not None:
|
|
1063
|
+
conn.execute(
|
|
1064
|
+
"INSERT OR IGNORE INTO aliases VALUES(?, ?, ?, ?)",
|
|
1065
|
+
(old.id, new.id, current, "same-content file rename"),
|
|
1066
|
+
)
|
|
1067
|
+
|
|
1068
|
+
def _nodes_for_path(
|
|
1069
|
+
self, conn: sqlite3.Connection, generation: int, path: str
|
|
1070
|
+
) -> list[GraphNode]:
|
|
1071
|
+
return [
|
|
1072
|
+
GraphNode.model_validate(self._decode_payload(row["payload"]))
|
|
1073
|
+
for row in conn.execute(
|
|
1074
|
+
"""SELECT p.payload FROM generation_nodes m
|
|
1075
|
+
JOIN node_payloads p ON p.payload_hash=m.payload_hash
|
|
1076
|
+
WHERE m.generation_id=? AND m.path=?""",
|
|
1077
|
+
(generation, path),
|
|
1078
|
+
)
|
|
1079
|
+
]
|
|
1080
|
+
|
|
1081
|
+
@staticmethod
|
|
1082
|
+
def _dead_row(generation: int, ordinal: int, entry: DeadCodeEntry) -> tuple[Any, ...]:
|
|
1083
|
+
confidence = entry.confidence.value if hasattr(entry.confidence, "value") else str(entry.confidence)
|
|
1084
|
+
return (generation, ordinal, entry.id, entry.path, entry.line, entry.kind, confidence, entry.reason)
|
|
1085
|
+
|
|
1086
|
+
@staticmethod
|
|
1087
|
+
def _prune(conn: sqlite3.Connection, *, keep: int, current: int) -> None:
|
|
1088
|
+
rows = conn.execute(
|
|
1089
|
+
"SELECT id FROM generations WHERE state='committed' ORDER BY id DESC"
|
|
1090
|
+
).fetchall()
|
|
1091
|
+
stale = [int(row[0]) for row in rows[keep:] if int(row[0]) != current]
|
|
1092
|
+
for generation in stale:
|
|
1093
|
+
conn.execute("DELETE FROM nodes_fts WHERE generation_id=?", (generation,))
|
|
1094
|
+
conn.execute("DELETE FROM generations WHERE id=?", (generation,))
|
|
1095
|
+
|
|
1096
|
+
def load_graph(self, generation: int | None = None) -> CodeGraph | None:
|
|
1097
|
+
if not self.exists():
|
|
1098
|
+
return None
|
|
1099
|
+
with self._connect(readonly=True) as conn:
|
|
1100
|
+
if generation is None:
|
|
1101
|
+
row = conn.execute("SELECT value FROM metadata WHERE key='current_generation'").fetchone()
|
|
1102
|
+
if row is None:
|
|
1103
|
+
return None
|
|
1104
|
+
generation = int(row[0])
|
|
1105
|
+
gen = conn.execute(
|
|
1106
|
+
"SELECT * FROM generations WHERE id=? AND state='committed'", (generation,)
|
|
1107
|
+
).fetchone()
|
|
1108
|
+
if gen is None:
|
|
1109
|
+
return None
|
|
1110
|
+
nodes = [
|
|
1111
|
+
GraphNode.model_validate(self._decode_payload(row["payload"]))
|
|
1112
|
+
for row in conn.execute(
|
|
1113
|
+
"""SELECT p.payload FROM generation_nodes m
|
|
1114
|
+
JOIN node_payloads p ON p.payload_hash=m.payload_hash
|
|
1115
|
+
WHERE m.generation_id=? ORDER BY m.ordinal, m.node_id""",
|
|
1116
|
+
(generation,),
|
|
1117
|
+
)
|
|
1118
|
+
]
|
|
1119
|
+
edges = [
|
|
1120
|
+
GraphEdge.model_validate(self._decode_payload(row["payload"]))
|
|
1121
|
+
for row in conn.execute(
|
|
1122
|
+
"""SELECT p.payload FROM generation_edges m
|
|
1123
|
+
JOIN edge_payloads p ON p.payload_hash=m.payload_hash
|
|
1124
|
+
WHERE m.generation_id=? ORDER BY m.ordinal, m.payload_hash""",
|
|
1125
|
+
(generation,),
|
|
1126
|
+
)
|
|
1127
|
+
]
|
|
1128
|
+
dead = [
|
|
1129
|
+
DeadCodeEntry.model_validate(self._decode_payload(row["payload"]))
|
|
1130
|
+
for row in conn.execute(
|
|
1131
|
+
"""SELECT p.payload FROM generation_dead m
|
|
1132
|
+
JOIN dead_payloads p ON p.payload_hash=m.payload_hash
|
|
1133
|
+
WHERE m.generation_id=? ORDER BY m.ordinal, m.payload_hash""",
|
|
1134
|
+
(generation,),
|
|
1135
|
+
)
|
|
1136
|
+
]
|
|
1137
|
+
graph_meta = json.loads(gen["graph_meta"])
|
|
1138
|
+
return CodeGraph(
|
|
1139
|
+
schema_version=int(gen["schema_version"]),
|
|
1140
|
+
nodes=nodes,
|
|
1141
|
+
edges=edges,
|
|
1142
|
+
dead_code=dead,
|
|
1143
|
+
entry_roots=list(graph_meta.get("entry_roots") or []),
|
|
1144
|
+
unwired_candidates=list(graph_meta.get("unwired_candidates") or []),
|
|
1145
|
+
unreachable_files=list(graph_meta.get("unreachable_files") or []),
|
|
1146
|
+
generated_head=str(gen["generated_head"]),
|
|
1147
|
+
indexed_hash=str(gen["indexed_hash"]),
|
|
1148
|
+
content_fingerprint=str(gen["content_fingerprint"]),
|
|
1149
|
+
meta={
|
|
1150
|
+
**dict(graph_meta.get("meta") or {}),
|
|
1151
|
+
"codeintel_generation": generation,
|
|
1152
|
+
"codeintel_analyzer_version": str(gen["analyzer_version"]),
|
|
1153
|
+
},
|
|
1154
|
+
)
|
|
1155
|
+
|
|
1156
|
+
@staticmethod
|
|
1157
|
+
def _node_from_row(row: sqlite3.Row) -> GraphNode:
|
|
1158
|
+
return GraphNode(
|
|
1159
|
+
id=row["id"],
|
|
1160
|
+
kind=row["kind"],
|
|
1161
|
+
path=row["path"],
|
|
1162
|
+
name=row["name"],
|
|
1163
|
+
line=row["line"],
|
|
1164
|
+
end_line=row["end_line"],
|
|
1165
|
+
area=row["area"],
|
|
1166
|
+
language=row["language"],
|
|
1167
|
+
exported=bool(row["exported"]),
|
|
1168
|
+
community=row["community"],
|
|
1169
|
+
extras=json.loads(row["extras"]),
|
|
1170
|
+
)
|
|
1171
|
+
|
|
1172
|
+
@staticmethod
|
|
1173
|
+
def _edge_from_row(row: sqlite3.Row) -> GraphEdge:
|
|
1174
|
+
extras = json.loads(row["extras"])
|
|
1175
|
+
return GraphEdge(
|
|
1176
|
+
source=row["source"],
|
|
1177
|
+
target=row["target"],
|
|
1178
|
+
kind=row["kind"],
|
|
1179
|
+
confidence=row["confidence"],
|
|
1180
|
+
reason=row["reason"],
|
|
1181
|
+
extras=extras,
|
|
1182
|
+
)
|
|
1183
|
+
|
|
1184
|
+
@staticmethod
|
|
1185
|
+
def _dead_from_row(row: sqlite3.Row) -> DeadCodeEntry:
|
|
1186
|
+
return DeadCodeEntry(
|
|
1187
|
+
id=row["id"],
|
|
1188
|
+
path=row["path"],
|
|
1189
|
+
line=row["line"],
|
|
1190
|
+
kind=row["kind"],
|
|
1191
|
+
confidence=Confidence(row["confidence"]),
|
|
1192
|
+
reason=row["reason"],
|
|
1193
|
+
)
|
|
1194
|
+
|
|
1195
|
+
def search(self, query: str, *, limit: int = 50) -> list[dict[str, Any]]:
|
|
1196
|
+
generation = self.current_generation()
|
|
1197
|
+
if generation is None:
|
|
1198
|
+
return []
|
|
1199
|
+
terms = " ".join(part for part in query.replace("::", " ").split() if part)
|
|
1200
|
+
if not terms:
|
|
1201
|
+
return []
|
|
1202
|
+
with self._connect(readonly=True) as conn:
|
|
1203
|
+
try:
|
|
1204
|
+
rows = conn.execute(
|
|
1205
|
+
"""SELECT node_id, bm25(nodes_fts) AS rank
|
|
1206
|
+
FROM nodes_fts
|
|
1207
|
+
WHERE nodes_fts MATCH ? AND generation_id=?
|
|
1208
|
+
ORDER BY rank LIMIT ?""",
|
|
1209
|
+
(terms, generation, max(1, min(500, limit))),
|
|
1210
|
+
).fetchall()
|
|
1211
|
+
except sqlite3.OperationalError:
|
|
1212
|
+
rows = []
|
|
1213
|
+
ranked = {str(row["node_id"]): float(row["rank"]) for row in rows}
|
|
1214
|
+
if ranked:
|
|
1215
|
+
placeholders = ",".join("?" for _ in ranked)
|
|
1216
|
+
payload_rows = conn.execute(
|
|
1217
|
+
f"""SELECT m.node_id, p.payload FROM generation_nodes m
|
|
1218
|
+
JOIN node_payloads p ON p.payload_hash=m.payload_hash
|
|
1219
|
+
WHERE m.generation_id=? AND m.node_id IN ({placeholders})""", # noqa: S608
|
|
1220
|
+
(generation, *ranked),
|
|
1221
|
+
).fetchall()
|
|
1222
|
+
found = []
|
|
1223
|
+
for row in payload_rows:
|
|
1224
|
+
node = self._decode_payload(row["payload"])
|
|
1225
|
+
found.append({
|
|
1226
|
+
key: node[key]
|
|
1227
|
+
for key in ("id", "kind", "path", "name", "line", "end_line", "area", "language")
|
|
1228
|
+
} | {"rank": ranked[str(row["node_id"])]})
|
|
1229
|
+
return sorted(found, key=lambda item: item["rank"])
|
|
1230
|
+
lowered = query.casefold()
|
|
1231
|
+
fallback = []
|
|
1232
|
+
for row in conn.execute(
|
|
1233
|
+
"""SELECT p.payload FROM generation_nodes m
|
|
1234
|
+
JOIN node_payloads p ON p.payload_hash=m.payload_hash
|
|
1235
|
+
WHERE m.generation_id=?""",
|
|
1236
|
+
(generation,),
|
|
1237
|
+
):
|
|
1238
|
+
node = self._decode_payload(row["payload"])
|
|
1239
|
+
if lowered not in f"{node['id']} {node['name']} {node['path']}".casefold():
|
|
1240
|
+
continue
|
|
1241
|
+
fallback.append({
|
|
1242
|
+
key: node[key]
|
|
1243
|
+
for key in ("id", "kind", "path", "name", "line", "end_line", "area", "language")
|
|
1244
|
+
} | {"rank": 0.0})
|
|
1245
|
+
if len(fallback) >= max(1, min(500, limit)):
|
|
1246
|
+
break
|
|
1247
|
+
return fallback
|
|
1248
|
+
|
|
1249
|
+
def status(self) -> StoreStatus:
|
|
1250
|
+
if not self.exists():
|
|
1251
|
+
return StoreStatus(
|
|
1252
|
+
project_root=str(self.project_root),
|
|
1253
|
+
database=str(self.path),
|
|
1254
|
+
schema_version=STORE_SCHEMA_VERSION,
|
|
1255
|
+
generation=None,
|
|
1256
|
+
state="uninitialized",
|
|
1257
|
+
)
|
|
1258
|
+
try:
|
|
1259
|
+
with self._connect(readonly=True) as conn:
|
|
1260
|
+
schema = int(conn.execute("PRAGMA user_version").fetchone()[0])
|
|
1261
|
+
row = conn.execute(
|
|
1262
|
+
"""SELECT g.* FROM generations g
|
|
1263
|
+
JOIN metadata m ON m.key='current_generation' AND CAST(m.value AS INTEGER)=g.id"""
|
|
1264
|
+
).fetchone()
|
|
1265
|
+
except sqlite3.DatabaseError as exc:
|
|
1266
|
+
if not _corruption_error(exc):
|
|
1267
|
+
raise
|
|
1268
|
+
return StoreStatus(
|
|
1269
|
+
str(self.project_root), str(self.path), STORE_SCHEMA_VERSION, None, "corrupt"
|
|
1270
|
+
)
|
|
1271
|
+
if row is None:
|
|
1272
|
+
return StoreStatus(str(self.project_root), str(self.path), schema, None, "empty")
|
|
1273
|
+
return StoreStatus(
|
|
1274
|
+
project_root=str(self.project_root),
|
|
1275
|
+
database=str(self.path),
|
|
1276
|
+
schema_version=schema,
|
|
1277
|
+
generation=int(row["id"]),
|
|
1278
|
+
state=str(row["state"]),
|
|
1279
|
+
node_count=int(row["node_count"]),
|
|
1280
|
+
edge_count=int(row["edge_count"]),
|
|
1281
|
+
created_at=float(row["created_at"]),
|
|
1282
|
+
analyzer_version=str(row["analyzer_version"]),
|
|
1283
|
+
)
|
|
1284
|
+
|
|
1285
|
+
def content_for_path(self, path: str, *, generation: int | None = None) -> bytes | None:
|
|
1286
|
+
generation = generation or self.current_generation()
|
|
1287
|
+
if generation is None:
|
|
1288
|
+
return None
|
|
1289
|
+
with self._connect(readonly=True) as conn:
|
|
1290
|
+
row = conn.execute(
|
|
1291
|
+
"""SELECT c.content FROM generation_files f
|
|
1292
|
+
LEFT JOIN file_contents c ON c.content_hash=f.content_hash
|
|
1293
|
+
WHERE f.generation_id=? AND f.path=?""",
|
|
1294
|
+
(generation, path.replace("\\", "/")),
|
|
1295
|
+
).fetchone()
|
|
1296
|
+
if row is None or row[0] is None:
|
|
1297
|
+
return None
|
|
1298
|
+
return zlib.decompress(row[0])
|
|
1299
|
+
|
|
1300
|
+
def file_metadata(self, *, generation: int | None = None) -> dict[str, tuple[int, int, str]]:
|
|
1301
|
+
"""Return ``path -> (size, mtime_ns, sha256)`` for reconciliation."""
|
|
1302
|
+
|
|
1303
|
+
generation = generation or self.current_generation()
|
|
1304
|
+
if generation is None:
|
|
1305
|
+
return {}
|
|
1306
|
+
with self._connect(readonly=True) as conn:
|
|
1307
|
+
rows = conn.execute(
|
|
1308
|
+
"SELECT path, size, mtime_ns, content_hash FROM generation_files WHERE generation_id=?",
|
|
1309
|
+
(generation,),
|
|
1310
|
+
).fetchall()
|
|
1311
|
+
return {
|
|
1312
|
+
str(row["path"]): (int(row["size"]), int(row["mtime_ns"]), str(row["content_hash"]))
|
|
1313
|
+
for row in rows
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
def has_indexed_path(self, path: str, *, generation: int | None = None) -> bool:
|
|
1317
|
+
"""Single-row membership check (cheap enough for per-event watcher calls)."""
|
|
1318
|
+
generation = generation or self.current_generation()
|
|
1319
|
+
if generation is None:
|
|
1320
|
+
return False
|
|
1321
|
+
with self._connect(readonly=True) as conn:
|
|
1322
|
+
row = conn.execute(
|
|
1323
|
+
"SELECT 1 FROM generation_files WHERE generation_id=? AND path=? LIMIT 1",
|
|
1324
|
+
(generation, path.replace("\\", "/")),
|
|
1325
|
+
).fetchone()
|
|
1326
|
+
return row is not None
|
|
1327
|
+
|
|
1328
|
+
def analysis_shards(
|
|
1329
|
+
self, *, generation: int | None = None
|
|
1330
|
+
) -> dict[str, dict[str, Any]]:
|
|
1331
|
+
generation = generation or self.current_generation()
|
|
1332
|
+
if generation is None:
|
|
1333
|
+
return {}
|
|
1334
|
+
with self._connect(readonly=True) as conn:
|
|
1335
|
+
rows = conn.execute(
|
|
1336
|
+
"""SELECT m.path, p.payload FROM generation_analysis m
|
|
1337
|
+
JOIN analysis_payloads p ON p.payload_hash=m.payload_hash
|
|
1338
|
+
WHERE m.generation_id=?""",
|
|
1339
|
+
(generation,),
|
|
1340
|
+
).fetchall()
|
|
1341
|
+
return {
|
|
1342
|
+
str(row["path"]): self._decode_payload(row["payload"])
|
|
1343
|
+
for row in rows
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
def unresolved_references(self, *, name: str | None = None) -> list[dict[str, Any]]:
|
|
1347
|
+
generation = self.current_generation()
|
|
1348
|
+
if generation is None:
|
|
1349
|
+
return []
|
|
1350
|
+
sql = "SELECT * FROM unresolved_references WHERE generation_id=?"
|
|
1351
|
+
params: list[Any] = [generation]
|
|
1352
|
+
if name:
|
|
1353
|
+
sql += " AND name=?"
|
|
1354
|
+
params.append(name)
|
|
1355
|
+
sql += " ORDER BY path, line, name"
|
|
1356
|
+
with self._connect(readonly=True) as conn:
|
|
1357
|
+
rows = conn.execute(sql, params).fetchall()
|
|
1358
|
+
return [{**dict(row), "evidence": json.loads(row["evidence"])} for row in rows]
|
|
1359
|
+
|
|
1360
|
+
def diagnostics(self) -> list[dict[str, Any]]:
|
|
1361
|
+
"""Derive unresolved-reference diagnostics without duplicate storage."""
|
|
1362
|
+
return [
|
|
1363
|
+
{
|
|
1364
|
+
"generation_id": row["generation_id"],
|
|
1365
|
+
"path": row["path"],
|
|
1366
|
+
"severity": "warning",
|
|
1367
|
+
"code": "dynamic_unresolved",
|
|
1368
|
+
"message": f"Unresolved dynamic reference: {row['name']}",
|
|
1369
|
+
"data": row["evidence"],
|
|
1370
|
+
}
|
|
1371
|
+
for row in self.unresolved_references()
|
|
1372
|
+
]
|
|
1373
|
+
|
|
1374
|
+
def aliases(self) -> list[dict[str, Any]]:
|
|
1375
|
+
generation = self.current_generation()
|
|
1376
|
+
if generation is None:
|
|
1377
|
+
return []
|
|
1378
|
+
with self._connect(readonly=True) as conn:
|
|
1379
|
+
rows = conn.execute(
|
|
1380
|
+
"SELECT old_id, new_id, reason FROM aliases WHERE generation_id=? ORDER BY old_id",
|
|
1381
|
+
(generation,),
|
|
1382
|
+
).fetchall()
|
|
1383
|
+
return [dict(row) for row in rows]
|
|
1384
|
+
|
|
1385
|
+
def put_extraction(
|
|
1386
|
+
self,
|
|
1387
|
+
*,
|
|
1388
|
+
content_hash: str,
|
|
1389
|
+
language: str,
|
|
1390
|
+
grammar_version: str,
|
|
1391
|
+
config_hash: str,
|
|
1392
|
+
payload: bytes,
|
|
1393
|
+
) -> None:
|
|
1394
|
+
self.initialize()
|
|
1395
|
+
with self._connect() as conn:
|
|
1396
|
+
conn.execute(
|
|
1397
|
+
"""INSERT INTO extraction_cache(
|
|
1398
|
+
content_hash, language, grammar_version, analyzer_version,
|
|
1399
|
+
config_hash, payload, created_at
|
|
1400
|
+
) VALUES(?, ?, ?, ?, ?, ?, ?)
|
|
1401
|
+
ON CONFLICT(content_hash, language, grammar_version, analyzer_version, config_hash)
|
|
1402
|
+
DO UPDATE SET payload=excluded.payload, created_at=excluded.created_at""",
|
|
1403
|
+
(content_hash, language, grammar_version, ANALYZER_VERSION, config_hash, payload, time.time()),
|
|
1404
|
+
)
|
|
1405
|
+
total = int(conn.execute(
|
|
1406
|
+
"SELECT COALESCE(SUM(length(payload)), 0) FROM extraction_cache"
|
|
1407
|
+
).fetchone()[0])
|
|
1408
|
+
if total > EXTRACTION_CACHE_MAX_BYTES:
|
|
1409
|
+
conn.execute(
|
|
1410
|
+
"""DELETE FROM extraction_cache WHERE rowid IN (
|
|
1411
|
+
SELECT rowid FROM extraction_cache ORDER BY created_at
|
|
1412
|
+
LIMIT (
|
|
1413
|
+
SELECT MAX(1, COUNT(*) / 4) FROM extraction_cache
|
|
1414
|
+
)
|
|
1415
|
+
)"""
|
|
1416
|
+
)
|
|
1417
|
+
conn.commit()
|
|
1418
|
+
|
|
1419
|
+
def get_extraction(
|
|
1420
|
+
self,
|
|
1421
|
+
*,
|
|
1422
|
+
content_hash: str,
|
|
1423
|
+
language: str,
|
|
1424
|
+
grammar_version: str,
|
|
1425
|
+
config_hash: str,
|
|
1426
|
+
) -> bytes | None:
|
|
1427
|
+
if not self.exists():
|
|
1428
|
+
return None
|
|
1429
|
+
with self._connect(readonly=True) as conn:
|
|
1430
|
+
row = conn.execute(
|
|
1431
|
+
"""SELECT payload FROM extraction_cache
|
|
1432
|
+
WHERE content_hash=? AND language=? AND grammar_version=?
|
|
1433
|
+
AND analyzer_version=? AND config_hash=?""",
|
|
1434
|
+
(content_hash, language, grammar_version, ANALYZER_VERSION, config_hash),
|
|
1435
|
+
).fetchone()
|
|
1436
|
+
return bytes(row[0]) if row is not None else None
|
|
1437
|
+
|
|
1438
|
+
def start_runtime_session(
|
|
1439
|
+
self,
|
|
1440
|
+
*,
|
|
1441
|
+
provider: str,
|
|
1442
|
+
source_fingerprint: str,
|
|
1443
|
+
build_fingerprint: str,
|
|
1444
|
+
executable_hash: str = "",
|
|
1445
|
+
metadata: dict[str, Any] | None = None,
|
|
1446
|
+
session_id: str | None = None,
|
|
1447
|
+
) -> str:
|
|
1448
|
+
self.initialize()
|
|
1449
|
+
session_id = session_id or uuid.uuid4().hex
|
|
1450
|
+
with self._connect() as conn:
|
|
1451
|
+
conn.execute(
|
|
1452
|
+
"""INSERT INTO runtime_sessions(
|
|
1453
|
+
id, created_at, provider, source_fingerprint, build_fingerprint,
|
|
1454
|
+
executable_hash, metadata
|
|
1455
|
+
) VALUES(?, ?, ?, ?, ?, ?, ?)""",
|
|
1456
|
+
(
|
|
1457
|
+
session_id,
|
|
1458
|
+
time.time(),
|
|
1459
|
+
provider,
|
|
1460
|
+
source_fingerprint,
|
|
1461
|
+
build_fingerprint,
|
|
1462
|
+
executable_hash,
|
|
1463
|
+
_json(metadata or {}),
|
|
1464
|
+
),
|
|
1465
|
+
)
|
|
1466
|
+
conn.commit()
|
|
1467
|
+
return session_id
|
|
1468
|
+
|
|
1469
|
+
def add_runtime_observations(
|
|
1470
|
+
self,
|
|
1471
|
+
session_id: str,
|
|
1472
|
+
observations: Sequence[dict[str, Any]],
|
|
1473
|
+
) -> int:
|
|
1474
|
+
if not observations:
|
|
1475
|
+
return 0
|
|
1476
|
+
now = time.time()
|
|
1477
|
+
valid = [row for row in observations if row.get("source") and row.get("target")]
|
|
1478
|
+
with self._connect() as conn:
|
|
1479
|
+
start = int(conn.execute(
|
|
1480
|
+
"SELECT COALESCE(MAX(ordinal), -1) + 1 FROM runtime_observations WHERE session_id=?",
|
|
1481
|
+
(session_id,),
|
|
1482
|
+
).fetchone()[0])
|
|
1483
|
+
conn.executemany(
|
|
1484
|
+
"""INSERT INTO runtime_observations(
|
|
1485
|
+
session_id, ordinal, source, target, kind, count,
|
|
1486
|
+
first_seen, last_seen, evidence
|
|
1487
|
+
) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
1488
|
+
[
|
|
1489
|
+
(
|
|
1490
|
+
session_id,
|
|
1491
|
+
start + index,
|
|
1492
|
+
str(row.get("source", "")),
|
|
1493
|
+
str(row.get("target", "")),
|
|
1494
|
+
str(row.get("kind", "observed_calls")),
|
|
1495
|
+
max(1, int(row.get("count", 1))),
|
|
1496
|
+
float(row.get("first_seen", now)),
|
|
1497
|
+
float(row.get("last_seen", now)),
|
|
1498
|
+
_json(row.get("evidence") or {}),
|
|
1499
|
+
)
|
|
1500
|
+
for index, row in enumerate(valid)
|
|
1501
|
+
],
|
|
1502
|
+
)
|
|
1503
|
+
conn.commit()
|
|
1504
|
+
return len(valid)
|
|
1505
|
+
|
|
1506
|
+
def end_runtime_session(self, session_id: str) -> None:
|
|
1507
|
+
with self._connect() as conn:
|
|
1508
|
+
conn.execute("UPDATE runtime_sessions SET ended_at=? WHERE id=?", (time.time(), session_id))
|
|
1509
|
+
conn.commit()
|
|
1510
|
+
|
|
1511
|
+
def has_runtime_observations(self) -> bool:
|
|
1512
|
+
if not self.exists():
|
|
1513
|
+
return False
|
|
1514
|
+
with self._connect(readonly=True) as conn:
|
|
1515
|
+
row = conn.execute(
|
|
1516
|
+
"SELECT EXISTS(SELECT 1 FROM runtime_observations)"
|
|
1517
|
+
).fetchone()
|
|
1518
|
+
return bool(row[0])
|
|
1519
|
+
|
|
1520
|
+
def runtime_observations(
|
|
1521
|
+
self,
|
|
1522
|
+
*,
|
|
1523
|
+
source_fingerprint: str | None = None,
|
|
1524
|
+
build_fingerprint: str | None = None,
|
|
1525
|
+
executable_hash: str | None = None,
|
|
1526
|
+
include_stale: bool = False,
|
|
1527
|
+
limit: int = 10_000,
|
|
1528
|
+
) -> list[dict[str, Any]]:
|
|
1529
|
+
if not self.exists():
|
|
1530
|
+
return []
|
|
1531
|
+
filters: list[str] = []
|
|
1532
|
+
params: list[Any] = []
|
|
1533
|
+
expected = (
|
|
1534
|
+
("s.source_fingerprint", source_fingerprint),
|
|
1535
|
+
("s.build_fingerprint", build_fingerprint),
|
|
1536
|
+
("s.executable_hash", executable_hash),
|
|
1537
|
+
)
|
|
1538
|
+
if not include_stale:
|
|
1539
|
+
for column, value in expected:
|
|
1540
|
+
if value is not None:
|
|
1541
|
+
filters.append(f"{column}=?")
|
|
1542
|
+
params.append(value)
|
|
1543
|
+
where = f"WHERE {' AND '.join(filters)}" if filters else ""
|
|
1544
|
+
params.append(max(1, min(100_000, limit)))
|
|
1545
|
+
with self._connect(readonly=True) as conn:
|
|
1546
|
+
rows = conn.execute(
|
|
1547
|
+
f"""SELECT o.*, s.provider, s.source_fingerprint, s.build_fingerprint,
|
|
1548
|
+
s.executable_hash, s.created_at AS session_created_at
|
|
1549
|
+
FROM runtime_observations o
|
|
1550
|
+
JOIN runtime_sessions s ON s.id=o.session_id
|
|
1551
|
+
{where}
|
|
1552
|
+
ORDER BY o.last_seen DESC LIMIT ?""",
|
|
1553
|
+
params,
|
|
1554
|
+
).fetchall()
|
|
1555
|
+
return [
|
|
1556
|
+
{
|
|
1557
|
+
**dict(row),
|
|
1558
|
+
"evidence": json.loads(row["evidence"]),
|
|
1559
|
+
"fingerprint_matches": all(
|
|
1560
|
+
value is None or row[column.removeprefix("s.")] == value
|
|
1561
|
+
for column, value in expected
|
|
1562
|
+
),
|
|
1563
|
+
}
|
|
1564
|
+
for row in rows
|
|
1565
|
+
]
|