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,1020 @@
|
|
|
1
|
+
"""Import + call resolution with a confidence ladder.
|
|
2
|
+
|
|
3
|
+
Reuses RepoMapper's import resolution (Python/JS/Go) so edges stay consistent
|
|
4
|
+
with existing verify-gate behavior. ``resolve_import_edges`` resolves from
|
|
5
|
+
already-extracted import specs — no source re-reads for extraction.
|
|
6
|
+
|
|
7
|
+
devcouncil: allow-unwired — package-private; reached only via graph.build.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import sys
|
|
14
|
+
from collections import defaultdict
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Dict, List, Optional, Set, Tuple
|
|
17
|
+
|
|
18
|
+
from devcouncil.indexing.graph.extract_python import FileExtraction
|
|
19
|
+
from devcouncil.indexing.graph.schema import (
|
|
20
|
+
Confidence,
|
|
21
|
+
GraphEdge,
|
|
22
|
+
GraphNode,
|
|
23
|
+
NodeKind,
|
|
24
|
+
file_node_id,
|
|
25
|
+
symbol_node_id,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
# Match SQLite ``AMBIGUOUS_EVIDENCE_LIMIT`` — keep JSON extras bounded.
|
|
31
|
+
AMBIGUOUS_CANDIDATES_CAP = 8
|
|
32
|
+
|
|
33
|
+
_JS_SUFFIXES = frozenset({".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"})
|
|
34
|
+
_PY_SUFFIXES = frozenset({".py"})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _stable_candidate_ids(candidates: List[str]) -> List[str]:
|
|
38
|
+
"""Deduplicate and sort symbol ids for stable fan-out / extras ordering."""
|
|
39
|
+
return sorted(dict.fromkeys(candidates))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _capped_candidate_extras(candidates: List[str]) -> Dict[str, Any]:
|
|
43
|
+
"""Bounded ``candidates`` extras fragment (omit when empty)."""
|
|
44
|
+
if not candidates:
|
|
45
|
+
return {}
|
|
46
|
+
ordered = _stable_candidate_ids(candidates)
|
|
47
|
+
capped = ordered[:AMBIGUOUS_CANDIDATES_CAP]
|
|
48
|
+
extras: Dict[str, Any] = {"candidates": capped}
|
|
49
|
+
truncated = len(ordered) - len(capped)
|
|
50
|
+
if truncated > 0:
|
|
51
|
+
extras["candidates_truncated"] = truncated
|
|
52
|
+
return extras
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _module_file_match(module: str, fpath: str) -> bool:
|
|
56
|
+
"""True when ``fpath`` is a plausible resolution of dotted ``module``.
|
|
57
|
+
|
|
58
|
+
Accepts both ``pkg.mod`` → ``pkg/mod.py`` and ``pkg.mod.func`` aliases by
|
|
59
|
+
also trying the parent dotted path.
|
|
60
|
+
"""
|
|
61
|
+
if not module:
|
|
62
|
+
return False
|
|
63
|
+
parts = [p for p in module.replace("\\", "/").replace("/", ".").split(".") if p]
|
|
64
|
+
candidates = [".".join(parts)]
|
|
65
|
+
if len(parts) > 1:
|
|
66
|
+
candidates.append(".".join(parts[:-1]))
|
|
67
|
+
norm = fpath[:-3] if fpath.endswith(".py") else fpath
|
|
68
|
+
norm = norm.replace("\\", "/")
|
|
69
|
+
dotted_path = norm.replace("/", ".")
|
|
70
|
+
stem = Path(fpath).stem
|
|
71
|
+
for cand in candidates:
|
|
72
|
+
if not cand:
|
|
73
|
+
continue
|
|
74
|
+
if dotted_path.endswith(cand) or norm.endswith(cand.replace(".", "/")):
|
|
75
|
+
return True
|
|
76
|
+
if stem == cand.split(".")[-1]:
|
|
77
|
+
return True
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _lang_family(path: str) -> str:
|
|
82
|
+
"""Coarse language family for cross-language call-resolution guards."""
|
|
83
|
+
suffix = Path(path).suffix.lower()
|
|
84
|
+
if suffix in _JS_SUFFIXES:
|
|
85
|
+
return "js"
|
|
86
|
+
if suffix in _PY_SUFFIXES:
|
|
87
|
+
return "py"
|
|
88
|
+
if suffix == ".go":
|
|
89
|
+
return "go"
|
|
90
|
+
if suffix == ".rs":
|
|
91
|
+
return "rs"
|
|
92
|
+
return suffix or "other"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _same_package_dir(path: str) -> str:
|
|
96
|
+
"""Parent directory used as same-package / sibling-module scope."""
|
|
97
|
+
return str(Path(path).parent).replace("\\", "/")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _method_name_hits(
|
|
101
|
+
by_name: Dict[str, List[Tuple[str, str, str]]],
|
|
102
|
+
name: str,
|
|
103
|
+
*,
|
|
104
|
+
caller_fam: str,
|
|
105
|
+
path_filter: Optional[Set[str]] = None,
|
|
106
|
+
) -> List[Tuple[str, str, str]]:
|
|
107
|
+
"""Symbols whose qualname is ``*.{name}`` (methods), same language family."""
|
|
108
|
+
suffix = f".{name}"
|
|
109
|
+
out: List[Tuple[str, str, str]] = []
|
|
110
|
+
for p, q, i in by_name.get(name, []):
|
|
111
|
+
if _lang_family(p) != caller_fam:
|
|
112
|
+
continue
|
|
113
|
+
if "." not in q or not q.endswith(suffix):
|
|
114
|
+
continue
|
|
115
|
+
if path_filter is not None and p not in path_filter:
|
|
116
|
+
continue
|
|
117
|
+
out.append((p, q, i))
|
|
118
|
+
return out
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def module_suffix_index(py_files: List[str]) -> Dict[str, str]:
|
|
122
|
+
"""Map every dotted suffix of each module path to its file (pure).
|
|
123
|
+
|
|
124
|
+
Ambiguous suffixes shared by two files are dropped. Packages (``__init__.py``)
|
|
125
|
+
are indexed under their package dotted path.
|
|
126
|
+
"""
|
|
127
|
+
index: Dict[str, str] = {}
|
|
128
|
+
ambiguous: Set[str] = set()
|
|
129
|
+
|
|
130
|
+
def _register(dotted: str, file: str) -> None:
|
|
131
|
+
comps = [c for c in dotted.split(".") if c]
|
|
132
|
+
for i in range(len(comps)):
|
|
133
|
+
suffix = ".".join(comps[i:])
|
|
134
|
+
if not suffix:
|
|
135
|
+
continue
|
|
136
|
+
if suffix in index and index[suffix] != file:
|
|
137
|
+
ambiguous.add(suffix)
|
|
138
|
+
else:
|
|
139
|
+
index[suffix] = file
|
|
140
|
+
|
|
141
|
+
for f in py_files:
|
|
142
|
+
module_path = f[:-3] if f.endswith(".py") else f
|
|
143
|
+
if module_path.endswith("/__init__"):
|
|
144
|
+
_register(module_path[: -len("/__init__")].replace("/", "."), f)
|
|
145
|
+
else:
|
|
146
|
+
_register(module_path.replace("/", "."), f)
|
|
147
|
+
for suffix in ambiguous:
|
|
148
|
+
index.pop(suffix, None)
|
|
149
|
+
return index
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def resolve_python_module(module: str, index: Dict[str, str]) -> Optional[str]:
|
|
153
|
+
"""Resolve a Python import module string against ``module_suffix_index`` (pure)."""
|
|
154
|
+
comps = [c for c in module.split(".") if c]
|
|
155
|
+
if comps and comps[0] in getattr(sys, "stdlib_module_names", set()):
|
|
156
|
+
return None
|
|
157
|
+
while comps:
|
|
158
|
+
candidate = ".".join(comps)
|
|
159
|
+
if candidate in index:
|
|
160
|
+
return index[candidate]
|
|
161
|
+
comps = comps[:-1]
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def ancestor_init_files(target: str, py_file_set: Set[str]) -> List[str]:
|
|
166
|
+
"""Ancestor package ``__init__.py`` files between ``target`` and repo root (pure)."""
|
|
167
|
+
parts = target.replace("\\", "/").split("/")
|
|
168
|
+
if not parts:
|
|
169
|
+
return []
|
|
170
|
+
if parts[-1] == "__init__.py":
|
|
171
|
+
dir_parts = parts[:-2]
|
|
172
|
+
else:
|
|
173
|
+
dir_parts = parts[:-1]
|
|
174
|
+
out: List[str] = []
|
|
175
|
+
for i in range(len(dir_parts), 0, -1):
|
|
176
|
+
init = "/".join(dir_parts[:i]) + "/__init__.py"
|
|
177
|
+
if init in py_file_set and init != target:
|
|
178
|
+
out.append(init)
|
|
179
|
+
return out
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _is_js_path(path: str) -> bool:
|
|
183
|
+
return Path(path).suffix.lower() in _JS_SUFFIXES
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def resolve_import_edges(
|
|
187
|
+
extractions: Dict[str, FileExtraction],
|
|
188
|
+
files: List[str],
|
|
189
|
+
*,
|
|
190
|
+
root: Path,
|
|
191
|
+
mapper: Optional[Any] = None,
|
|
192
|
+
) -> List[Tuple[str, str]]:
|
|
193
|
+
"""Resolve file→file import edges from extractions (no import re-extraction).
|
|
194
|
+
|
|
195
|
+
Uses pure Python module→path helpers plus RepoMapper JS/Go/Rust resolvers
|
|
196
|
+
against already-extracted ``imports`` / specs. Does not update the parse cache.
|
|
197
|
+
"""
|
|
198
|
+
from devcouncil.indexing.repo_mapper import RepoMapper
|
|
199
|
+
|
|
200
|
+
root = root.expanduser().resolve()
|
|
201
|
+
mapper = mapper if mapper is not None else RepoMapper(root)
|
|
202
|
+
file_set = set(files)
|
|
203
|
+
edges: List[Tuple[str, str]] = []
|
|
204
|
+
seen: Set[Tuple[str, str]] = set()
|
|
205
|
+
|
|
206
|
+
# --- Python ---
|
|
207
|
+
py_files = [f for f in files if f.endswith(".py")]
|
|
208
|
+
if py_files:
|
|
209
|
+
index = module_suffix_index(py_files)
|
|
210
|
+
py_file_set = set(py_files)
|
|
211
|
+
for rel in py_files:
|
|
212
|
+
ext = extractions.get(rel)
|
|
213
|
+
modules = list(ext.imports) if ext else []
|
|
214
|
+
for module in modules:
|
|
215
|
+
target = resolve_python_module(module, index)
|
|
216
|
+
if not target:
|
|
217
|
+
continue
|
|
218
|
+
for dest in [target, *ancestor_init_files(target, py_file_set)]:
|
|
219
|
+
if dest != rel and (rel, dest) not in seen:
|
|
220
|
+
seen.add((rel, dest))
|
|
221
|
+
edges.append((rel, dest))
|
|
222
|
+
|
|
223
|
+
# --- JS/TS ---
|
|
224
|
+
js_files = [f for f in files if _is_js_path(f)]
|
|
225
|
+
if js_files:
|
|
226
|
+
mapper._last_file_set = file_set
|
|
227
|
+
mapper._js_alias_cache = None
|
|
228
|
+
for rel in js_files:
|
|
229
|
+
ext = extractions.get(rel)
|
|
230
|
+
specs = list(ext.imports) if ext else []
|
|
231
|
+
for spec in specs:
|
|
232
|
+
target = mapper._resolve_js_spec(rel, spec, file_set)
|
|
233
|
+
if not target or target == rel:
|
|
234
|
+
continue
|
|
235
|
+
to_add = [target]
|
|
236
|
+
to_add.extend(mapper._follow_js_reexports(rel, target, file_set))
|
|
237
|
+
for dest in to_add:
|
|
238
|
+
if dest != rel and (rel, dest) not in seen:
|
|
239
|
+
seen.add((rel, dest))
|
|
240
|
+
edges.append((rel, dest))
|
|
241
|
+
|
|
242
|
+
# --- Go (specs from extractions + same-package co-membership) ---
|
|
243
|
+
go_files = [f for f in files if f.endswith(".go")]
|
|
244
|
+
if go_files:
|
|
245
|
+
go_module = mapper._go_module_prefix(file_set)
|
|
246
|
+
if go_module:
|
|
247
|
+
pkg_files: Dict[str, List[str]] = defaultdict(list)
|
|
248
|
+
for f in go_files:
|
|
249
|
+
if f.endswith("_test.go"):
|
|
250
|
+
continue
|
|
251
|
+
pkg_files[Path(f).parent.as_posix()].append(f)
|
|
252
|
+
for members in pkg_files.values():
|
|
253
|
+
if len(members) < 2:
|
|
254
|
+
continue
|
|
255
|
+
for a in members:
|
|
256
|
+
for b in members:
|
|
257
|
+
if a != b and (a, b) not in seen:
|
|
258
|
+
seen.add((a, b))
|
|
259
|
+
edges.append((a, b))
|
|
260
|
+
for rel in go_files:
|
|
261
|
+
ext = extractions.get(rel)
|
|
262
|
+
specs = list(ext.imports) if ext else []
|
|
263
|
+
for spec in specs:
|
|
264
|
+
if spec != go_module and not spec.startswith(go_module + "/"):
|
|
265
|
+
continue
|
|
266
|
+
rel_pkg = spec[len(go_module) :].lstrip("/")
|
|
267
|
+
target_dir = rel_pkg if rel_pkg else "."
|
|
268
|
+
for target in sorted(pkg_files.get(target_dir, ())):
|
|
269
|
+
if target != rel and (rel, target) not in seen:
|
|
270
|
+
seen.add((rel, target))
|
|
271
|
+
edges.append((rel, target))
|
|
272
|
+
|
|
273
|
+
# --- Rust: structured refs not in extractions; fall back to mapper ---
|
|
274
|
+
try:
|
|
275
|
+
for a, b in mapper._rust_import_edges(files, file_set):
|
|
276
|
+
if (a, b) not in seen:
|
|
277
|
+
seen.add((a, b))
|
|
278
|
+
edges.append((a, b))
|
|
279
|
+
except Exception:
|
|
280
|
+
logger.debug("Rust import-edge resolution failed", exc_info=True)
|
|
281
|
+
|
|
282
|
+
return edges
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _area_for(path: str) -> str:
|
|
286
|
+
parts = path.replace("\\", "/").split("/")
|
|
287
|
+
if not parts:
|
|
288
|
+
return "root"
|
|
289
|
+
if parts[0] in {"src", "lib", "app", "pkg"} and len(parts) >= 2:
|
|
290
|
+
return "/".join(parts[:2]) if len(parts) > 2 else parts[0]
|
|
291
|
+
return parts[0] if parts else "root"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def build_file_and_symbol_nodes(
|
|
295
|
+
extractions: Dict[str, FileExtraction],
|
|
296
|
+
*,
|
|
297
|
+
area_fn=None,
|
|
298
|
+
) -> Tuple[List[GraphNode], Dict[str, str]]:
|
|
299
|
+
"""Build file + symbol nodes. Returns (nodes, qualname_index path::qual -> id)."""
|
|
300
|
+
area_fn = area_fn or _area_for
|
|
301
|
+
nodes: List[GraphNode] = []
|
|
302
|
+
index: Dict[str, str] = {} # path::qualname -> id
|
|
303
|
+
name_index: Dict[str, List[str]] = defaultdict(list) # bare name -> ids
|
|
304
|
+
|
|
305
|
+
for path, ext in sorted(extractions.items()):
|
|
306
|
+
fid = file_node_id(path)
|
|
307
|
+
area = area_fn(path)
|
|
308
|
+
nodes.append(
|
|
309
|
+
GraphNode(
|
|
310
|
+
id=fid,
|
|
311
|
+
kind=NodeKind.FILE,
|
|
312
|
+
path=path,
|
|
313
|
+
name=Path(path).name,
|
|
314
|
+
area=area,
|
|
315
|
+
language=ext.language,
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
for sym in ext.symbols:
|
|
319
|
+
sid = symbol_node_id(path, sym.qualname)
|
|
320
|
+
kind = {
|
|
321
|
+
"function": NodeKind.FUNCTION,
|
|
322
|
+
"class": NodeKind.CLASS,
|
|
323
|
+
"method": NodeKind.METHOD,
|
|
324
|
+
"interface": NodeKind.INTERFACE,
|
|
325
|
+
"type": NodeKind.TYPE,
|
|
326
|
+
"struct": NodeKind.STRUCT,
|
|
327
|
+
"enum": NodeKind.ENUM,
|
|
328
|
+
"trait": NodeKind.TRAIT,
|
|
329
|
+
"property": NodeKind.PROPERTY,
|
|
330
|
+
"variable": NodeKind.VARIABLE,
|
|
331
|
+
"rationale": NodeKind.RATIONALE,
|
|
332
|
+
}.get(sym.kind, NodeKind.FUNCTION)
|
|
333
|
+
extras = {
|
|
334
|
+
"qualname": sym.qualname,
|
|
335
|
+
"bases": list(sym.bases),
|
|
336
|
+
"implements": list(getattr(sym, "implements", []) or []),
|
|
337
|
+
"decorators": list(sym.decorators),
|
|
338
|
+
}
|
|
339
|
+
if sym.kind == "rationale" and sym.bases:
|
|
340
|
+
extras["enclosing"] = sym.bases[0]
|
|
341
|
+
nodes.append(
|
|
342
|
+
GraphNode(
|
|
343
|
+
id=sid,
|
|
344
|
+
kind=kind,
|
|
345
|
+
path=path,
|
|
346
|
+
name=sym.name,
|
|
347
|
+
line=sym.line,
|
|
348
|
+
end_line=sym.end_line,
|
|
349
|
+
area=area,
|
|
350
|
+
language=ext.language,
|
|
351
|
+
exported=bool(sym.exported),
|
|
352
|
+
extras=extras,
|
|
353
|
+
)
|
|
354
|
+
)
|
|
355
|
+
index[f"{path}::{sym.qualname}"] = sid
|
|
356
|
+
if sym.kind != "rationale":
|
|
357
|
+
name_index[sym.name].append(sid)
|
|
358
|
+
if "." in sym.qualname:
|
|
359
|
+
name_index[sym.qualname].append(sid)
|
|
360
|
+
|
|
361
|
+
return nodes, dict(index)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def contains_and_defines_edges(
|
|
365
|
+
extractions: Dict[str, FileExtraction],
|
|
366
|
+
) -> List[GraphEdge]:
|
|
367
|
+
edges: List[GraphEdge] = []
|
|
368
|
+
for path, ext in sorted(extractions.items()):
|
|
369
|
+
fid = file_node_id(path)
|
|
370
|
+
for sym in ext.symbols:
|
|
371
|
+
sid = symbol_node_id(path, sym.qualname)
|
|
372
|
+
# Single structural edge per symbol ("contains"). The historical
|
|
373
|
+
# duplicate "defines" edge doubled edge counts and skewed degree /
|
|
374
|
+
# blast-radius metrics for zero information gain.
|
|
375
|
+
edges.append(
|
|
376
|
+
GraphEdge(
|
|
377
|
+
source=fid,
|
|
378
|
+
target=sid,
|
|
379
|
+
kind="contains",
|
|
380
|
+
confidence=Confidence.EXTRACTED,
|
|
381
|
+
reason="ast definition",
|
|
382
|
+
)
|
|
383
|
+
)
|
|
384
|
+
# Class contains method
|
|
385
|
+
if sym.kind == "method" and "." in sym.qualname:
|
|
386
|
+
cls = sym.qualname.rsplit(".", 1)[0]
|
|
387
|
+
cid = symbol_node_id(path, cls)
|
|
388
|
+
edges.append(
|
|
389
|
+
GraphEdge(
|
|
390
|
+
source=cid,
|
|
391
|
+
target=sid,
|
|
392
|
+
kind="contains",
|
|
393
|
+
confidence=Confidence.EXTRACTED,
|
|
394
|
+
reason="method of class",
|
|
395
|
+
)
|
|
396
|
+
)
|
|
397
|
+
if sym.kind == "rationale":
|
|
398
|
+
target = file_node_id(path)
|
|
399
|
+
if sym.bases:
|
|
400
|
+
target = symbol_node_id(path, sym.bases[0])
|
|
401
|
+
edges.append(
|
|
402
|
+
GraphEdge(
|
|
403
|
+
source=sid,
|
|
404
|
+
target=target,
|
|
405
|
+
kind="documents",
|
|
406
|
+
confidence=Confidence.EXTRACTED,
|
|
407
|
+
reason="rationale comment",
|
|
408
|
+
)
|
|
409
|
+
)
|
|
410
|
+
return edges
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def inherit_edges(extractions: Dict[str, FileExtraction], symbol_index: Dict[str, str]) -> List[GraphEdge]:
|
|
414
|
+
"""Resolve bases to inherits, implements lists, and matching methods to overrides."""
|
|
415
|
+
by_name: Dict[str, List[str]] = defaultdict(list)
|
|
416
|
+
for key, sid in symbol_index.items():
|
|
417
|
+
qual = key.split("::", 1)[-1]
|
|
418
|
+
if qual.startswith("$rationale:"):
|
|
419
|
+
continue
|
|
420
|
+
by_name[qual.split(".")[-1]].append(sid)
|
|
421
|
+
by_name[qual].append(sid)
|
|
422
|
+
|
|
423
|
+
methods_by_class: Dict[str, Dict[str, str]] = defaultdict(dict)
|
|
424
|
+
for key, sid in symbol_index.items():
|
|
425
|
+
path, _, qual = key.partition("::")
|
|
426
|
+
if "." in qual and not qual.startswith("$"):
|
|
427
|
+
cls, _, meth = qual.partition(".")
|
|
428
|
+
methods_by_class[f"{path}::{cls}"][meth] = sid
|
|
429
|
+
|
|
430
|
+
def _resolve_base(path: str, base: str) -> tuple[Optional[str], Confidence, str, List[str]]:
|
|
431
|
+
base_name = base.split(".")[-1].split("<", 1)[0]
|
|
432
|
+
candidates = _stable_candidate_ids(by_name.get(base_name, []))
|
|
433
|
+
same = [c for c in candidates if c.startswith(f"{path}::")]
|
|
434
|
+
if len(same) == 1:
|
|
435
|
+
return same[0], Confidence.EXTRACTED, "same-file base", []
|
|
436
|
+
if len(candidates) == 1:
|
|
437
|
+
return candidates[0], Confidence.INFERRED, "unique global base", []
|
|
438
|
+
if len(candidates) > 1:
|
|
439
|
+
return (
|
|
440
|
+
candidates[0],
|
|
441
|
+
Confidence.AMBIGUOUS,
|
|
442
|
+
f"ambiguous base ({len(candidates)} candidates)",
|
|
443
|
+
list(candidates),
|
|
444
|
+
)
|
|
445
|
+
return None, Confidence.AMBIGUOUS, "unresolved", []
|
|
446
|
+
|
|
447
|
+
edges: List[GraphEdge] = []
|
|
448
|
+
type_kinds = {"class", "interface", "struct", "enum", "trait", "type"}
|
|
449
|
+
for path, ext in sorted(extractions.items()):
|
|
450
|
+
for sym in ext.symbols:
|
|
451
|
+
if sym.kind not in type_kinds:
|
|
452
|
+
continue
|
|
453
|
+
src_id = symbol_node_id(path, sym.qualname)
|
|
454
|
+
for base in sym.bases:
|
|
455
|
+
target, conf, reason, cands = _resolve_base(path, base)
|
|
456
|
+
if not target:
|
|
457
|
+
continue
|
|
458
|
+
edges.append(
|
|
459
|
+
GraphEdge(
|
|
460
|
+
source=src_id,
|
|
461
|
+
target=target,
|
|
462
|
+
kind="inherits",
|
|
463
|
+
confidence=conf,
|
|
464
|
+
reason=reason,
|
|
465
|
+
extras=_capped_candidate_extras(cands),
|
|
466
|
+
)
|
|
467
|
+
)
|
|
468
|
+
for iface in getattr(sym, "implements", []) or []:
|
|
469
|
+
target, conf, reason, cands = _resolve_base(path, iface)
|
|
470
|
+
if not target:
|
|
471
|
+
continue
|
|
472
|
+
edges.append(
|
|
473
|
+
GraphEdge(
|
|
474
|
+
source=src_id,
|
|
475
|
+
target=target,
|
|
476
|
+
kind="implements",
|
|
477
|
+
confidence=conf,
|
|
478
|
+
reason=reason.replace("base", "interface"),
|
|
479
|
+
extras=_capped_candidate_extras(cands),
|
|
480
|
+
)
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
for sym in ext.symbols:
|
|
484
|
+
if sym.kind != "method" or "." not in sym.qualname:
|
|
485
|
+
continue
|
|
486
|
+
cls, _, meth = sym.qualname.partition(".")
|
|
487
|
+
src_id = symbol_node_id(path, sym.qualname)
|
|
488
|
+
owner = next((s for s in ext.symbols if s.qualname == cls), None)
|
|
489
|
+
parent_names: List[str] = []
|
|
490
|
+
if owner is not None:
|
|
491
|
+
parent_names.extend(owner.bases)
|
|
492
|
+
parent_names.extend(getattr(owner, "implements", []) or [])
|
|
493
|
+
parent_names.extend(sym.bases)
|
|
494
|
+
seen_targets: Set[str] = set()
|
|
495
|
+
for parent in parent_names:
|
|
496
|
+
parent_name = parent.split(".")[-1].split("<", 1)[0]
|
|
497
|
+
for pid in by_name.get(parent_name, []):
|
|
498
|
+
parent_path = pid.split("::", 1)[0]
|
|
499
|
+
parent_qual = pid.split("::", 1)[-1]
|
|
500
|
+
cand = methods_by_class.get(f"{parent_path}::{parent_qual}", {}).get(meth)
|
|
501
|
+
if cand and cand != src_id and cand not in seen_targets:
|
|
502
|
+
seen_targets.add(cand)
|
|
503
|
+
edges.append(
|
|
504
|
+
GraphEdge(
|
|
505
|
+
source=src_id,
|
|
506
|
+
target=cand,
|
|
507
|
+
kind="overrides",
|
|
508
|
+
confidence=Confidence.INFERRED,
|
|
509
|
+
reason="matching method on base",
|
|
510
|
+
)
|
|
511
|
+
)
|
|
512
|
+
return edges
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def decorator_edges(
|
|
516
|
+
extractions: Dict[str, FileExtraction],
|
|
517
|
+
symbol_index: Dict[str, str],
|
|
518
|
+
) -> List[GraphEdge]:
|
|
519
|
+
"""``decorator -> decorated symbol`` edges (DECORATES).
|
|
520
|
+
|
|
521
|
+
Resolved same-file first, then unique-global by bare name. Purely additive:
|
|
522
|
+
liveness of decorator functions is handled via the bare-decorator calls
|
|
523
|
+
emitted at extraction time.
|
|
524
|
+
"""
|
|
525
|
+
by_name: Dict[str, List[str]] = defaultdict(list)
|
|
526
|
+
for key, sid in symbol_index.items():
|
|
527
|
+
qual = key.split("::", 1)[-1]
|
|
528
|
+
if qual.startswith("$rationale:"):
|
|
529
|
+
continue
|
|
530
|
+
if "." not in qual:
|
|
531
|
+
by_name[qual].append(sid)
|
|
532
|
+
|
|
533
|
+
edges: List[GraphEdge] = []
|
|
534
|
+
seen: Set[Tuple[str, str]] = set()
|
|
535
|
+
for path, ext in sorted(extractions.items()):
|
|
536
|
+
for sym in ext.symbols:
|
|
537
|
+
if not sym.decorators:
|
|
538
|
+
continue
|
|
539
|
+
target = symbol_node_id(path, sym.qualname)
|
|
540
|
+
for dec in sym.decorators:
|
|
541
|
+
bare = dec.split("(", 1)[0].split(".")[-1].strip()
|
|
542
|
+
if not bare:
|
|
543
|
+
continue
|
|
544
|
+
src = symbol_index.get(f"{path}::{bare}")
|
|
545
|
+
conf = Confidence.EXTRACTED
|
|
546
|
+
reason = "same-file decorator"
|
|
547
|
+
if not src:
|
|
548
|
+
cands = _stable_candidate_ids(by_name.get(bare, []))
|
|
549
|
+
if len(cands) != 1:
|
|
550
|
+
continue
|
|
551
|
+
src = cands[0]
|
|
552
|
+
conf = Confidence.INFERRED
|
|
553
|
+
reason = "unique global decorator"
|
|
554
|
+
if src == target or (src, target) in seen:
|
|
555
|
+
continue
|
|
556
|
+
seen.add((src, target))
|
|
557
|
+
edges.append(
|
|
558
|
+
GraphEdge(
|
|
559
|
+
source=src,
|
|
560
|
+
target=target,
|
|
561
|
+
kind="decorates",
|
|
562
|
+
confidence=conf,
|
|
563
|
+
reason=reason,
|
|
564
|
+
)
|
|
565
|
+
)
|
|
566
|
+
return edges
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def import_graph_edges(file_edges: List[Tuple[str, str]]) -> List[GraphEdge]:
|
|
570
|
+
return [
|
|
571
|
+
GraphEdge(
|
|
572
|
+
source=file_node_id(a),
|
|
573
|
+
target=file_node_id(b),
|
|
574
|
+
kind="imports",
|
|
575
|
+
confidence=Confidence.EXTRACTED,
|
|
576
|
+
reason="import resolution",
|
|
577
|
+
)
|
|
578
|
+
for a, b in file_edges
|
|
579
|
+
if a != b
|
|
580
|
+
]
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def named_import_edges(
|
|
584
|
+
extractions: Dict[str, FileExtraction],
|
|
585
|
+
symbol_index: Dict[str, str],
|
|
586
|
+
file_edges: List[Tuple[str, str]],
|
|
587
|
+
) -> List[GraphEdge]:
|
|
588
|
+
"""``from mod import Name`` → imports edge to the resolved symbol (keeps it live)."""
|
|
589
|
+
imports_of: Dict[str, Set[str]] = defaultdict(set)
|
|
590
|
+
for a, b in file_edges:
|
|
591
|
+
imports_of[a].add(b)
|
|
592
|
+
|
|
593
|
+
# path::bare_name -> symbol id (top-level only)
|
|
594
|
+
by_path_name: Dict[str, str] = {}
|
|
595
|
+
for key, sid in symbol_index.items():
|
|
596
|
+
path, _, qual = key.partition("::")
|
|
597
|
+
if "." not in qual:
|
|
598
|
+
by_path_name[f"{path}::{qual}"] = sid
|
|
599
|
+
|
|
600
|
+
edges: List[GraphEdge] = []
|
|
601
|
+
seen: Set[Tuple[str, str]] = set()
|
|
602
|
+
for path, ext in sorted(extractions.items()):
|
|
603
|
+
src = file_node_id(path)
|
|
604
|
+
imported_files = sorted(imports_of.get(path, set()))
|
|
605
|
+
for detail in ext.import_details:
|
|
606
|
+
module = str(detail.module or "")
|
|
607
|
+
for name in detail.names:
|
|
608
|
+
bare = name.split(".")[-1]
|
|
609
|
+
if not bare or bare == "*":
|
|
610
|
+
continue
|
|
611
|
+
# ``from pkg import show`` where show is a submodule: file-level
|
|
612
|
+
# import edges already keep the module live. Do not bind the
|
|
613
|
+
# bare name to a same-named function in a sibling module
|
|
614
|
+
# (hash-order set iteration previously flipped those targets).
|
|
615
|
+
alias_target = ""
|
|
616
|
+
if isinstance(detail.alias_map, dict):
|
|
617
|
+
alias_target = str(
|
|
618
|
+
detail.alias_map.get(bare) or detail.alias_map.get(name) or ""
|
|
619
|
+
)
|
|
620
|
+
module_hint = alias_target or (
|
|
621
|
+
f"{module}.{bare}" if module and bare not in module.split(".") else module
|
|
622
|
+
)
|
|
623
|
+
if any(Path(f).stem == bare for f in imported_files):
|
|
624
|
+
continue
|
|
625
|
+
|
|
626
|
+
# Prefer symbols in files matching the import module / alias.
|
|
627
|
+
scoped = [
|
|
628
|
+
fpath
|
|
629
|
+
for fpath in imported_files
|
|
630
|
+
if by_path_name.get(f"{fpath}::{bare}")
|
|
631
|
+
and (not module_hint or _module_file_match(module_hint, fpath))
|
|
632
|
+
]
|
|
633
|
+
pool = scoped if scoped else [
|
|
634
|
+
fpath
|
|
635
|
+
for fpath in imported_files
|
|
636
|
+
if by_path_name.get(f"{fpath}::{bare}")
|
|
637
|
+
]
|
|
638
|
+
matches = _stable_candidate_ids(
|
|
639
|
+
[by_path_name[f"{fpath}::{bare}"] for fpath in pool]
|
|
640
|
+
)
|
|
641
|
+
target: Optional[str] = None
|
|
642
|
+
if matches:
|
|
643
|
+
# Unique or stable pick among remaining collisions.
|
|
644
|
+
target = matches[0]
|
|
645
|
+
if target and (src, target) not in seen:
|
|
646
|
+
seen.add((src, target))
|
|
647
|
+
edges.append(
|
|
648
|
+
GraphEdge(
|
|
649
|
+
source=src,
|
|
650
|
+
target=target,
|
|
651
|
+
kind="imports",
|
|
652
|
+
confidence=Confidence.EXTRACTED,
|
|
653
|
+
reason="named import",
|
|
654
|
+
)
|
|
655
|
+
)
|
|
656
|
+
return edges
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def resolve_calls(
|
|
660
|
+
extractions: Dict[str, FileExtraction],
|
|
661
|
+
symbol_index: Dict[str, str],
|
|
662
|
+
file_edges: List[Tuple[str, str]],
|
|
663
|
+
*,
|
|
664
|
+
class_ids: Optional[Set[str]] = None,
|
|
665
|
+
unresolved_out: Optional[List[Dict[str, Any]]] = None,
|
|
666
|
+
) -> List[GraphEdge]:
|
|
667
|
+
"""Confidence ladder: same-file → import-resolved → unique-global → ambiguous.
|
|
668
|
+
|
|
669
|
+
When ``class_ids`` is given, calls that target a class node are tagged
|
|
670
|
+
``extras["instantiates"] = True`` (constructor calls) so consumers can
|
|
671
|
+
distinguish instantiation from plain function calls.
|
|
672
|
+
"""
|
|
673
|
+
# name -> list of (path, qualname, id)
|
|
674
|
+
by_name: Dict[str, List[Tuple[str, str, str]]] = defaultdict(list)
|
|
675
|
+
for key, sid in sorted(symbol_index.items()):
|
|
676
|
+
path, _, qual = key.partition("::")
|
|
677
|
+
by_name[qual.split(".")[-1]].append((path, qual, sid))
|
|
678
|
+
if "." in qual:
|
|
679
|
+
by_name[qual].append((path, qual, sid))
|
|
680
|
+
for name in by_name:
|
|
681
|
+
by_name[name].sort(key=lambda item: item[2])
|
|
682
|
+
|
|
683
|
+
# importer -> imported files
|
|
684
|
+
imports_of: Dict[str, Set[str]] = defaultdict(set)
|
|
685
|
+
for a, b in file_edges:
|
|
686
|
+
imports_of[a].add(b)
|
|
687
|
+
|
|
688
|
+
# Local alias maps from extractions (import as)
|
|
689
|
+
alias_of: Dict[str, Dict[str, str]] = {}
|
|
690
|
+
for path, ext in sorted(extractions.items()):
|
|
691
|
+
amap: Dict[str, str] = {}
|
|
692
|
+
for detail in ext.import_details:
|
|
693
|
+
amap.update(detail.alias_map)
|
|
694
|
+
# Also map imported module last segment
|
|
695
|
+
for mod in ext.imports:
|
|
696
|
+
amap.setdefault(mod.split(".")[-1], mod)
|
|
697
|
+
alias_of[path] = amap
|
|
698
|
+
|
|
699
|
+
edges: List[GraphEdge] = []
|
|
700
|
+
seen: Set[Tuple[str, str]] = set()
|
|
701
|
+
|
|
702
|
+
for path, ext in sorted(extractions.items()):
|
|
703
|
+
# Caller context: prefer enclosing function/method by line
|
|
704
|
+
enclosing: List[Tuple[int, int, str]] = []
|
|
705
|
+
for sym in ext.symbols:
|
|
706
|
+
enclosing.append((sym.line, sym.end_line, symbol_node_id(path, sym.qualname)))
|
|
707
|
+
enclosing.sort()
|
|
708
|
+
|
|
709
|
+
for call in ext.calls:
|
|
710
|
+
caller_id = file_node_id(path)
|
|
711
|
+
for start, end, sid in enclosing:
|
|
712
|
+
if start <= call.line <= end:
|
|
713
|
+
caller_id = sid
|
|
714
|
+
# prefer innermost (last match)
|
|
715
|
+
target_id: Optional[str] = None
|
|
716
|
+
confidence = Confidence.AMBIGUOUS
|
|
717
|
+
reason = "unresolved"
|
|
718
|
+
ambig_candidates: List[str] = []
|
|
719
|
+
|
|
720
|
+
# 0) Attribute call with inferable receiver class: Foo.bar / Foo().bar
|
|
721
|
+
if target_id is None and call.receiver and call.name:
|
|
722
|
+
recv = call.receiver.split(".")[-1]
|
|
723
|
+
# Same-file Class.method
|
|
724
|
+
same_method = symbol_index.get(f"{path}::{recv}.{call.name}")
|
|
725
|
+
if same_method:
|
|
726
|
+
target_id = same_method
|
|
727
|
+
confidence = Confidence.EXTRACTED
|
|
728
|
+
reason = "attribute-call same-file class"
|
|
729
|
+
else:
|
|
730
|
+
# Unique Class.method across imported / global (same language)
|
|
731
|
+
method_key = f"{recv}.{call.name}"
|
|
732
|
+
imported_files = imports_of.get(path, set())
|
|
733
|
+
caller_fam = _lang_family(path)
|
|
734
|
+
cands = [
|
|
735
|
+
(p, q, i)
|
|
736
|
+
for p, q, i in by_name.get(call.name, [])
|
|
737
|
+
if _lang_family(p) == caller_fam
|
|
738
|
+
and (
|
|
739
|
+
q == method_key
|
|
740
|
+
or (
|
|
741
|
+
q.endswith(f".{call.name}")
|
|
742
|
+
and q.rsplit(".", 1)[0] == recv
|
|
743
|
+
)
|
|
744
|
+
)
|
|
745
|
+
]
|
|
746
|
+
if not cands:
|
|
747
|
+
cands = [
|
|
748
|
+
(p, q, i)
|
|
749
|
+
for p, q, i in by_name.get(method_key, [])
|
|
750
|
+
if _lang_family(p) == caller_fam
|
|
751
|
+
]
|
|
752
|
+
in_imp = [(p, q, i) for p, q, i in cands if p in imported_files or p == path]
|
|
753
|
+
pool = in_imp or cands
|
|
754
|
+
if len(pool) == 1:
|
|
755
|
+
target_id = pool[0][2]
|
|
756
|
+
confidence = Confidence.INFERRED
|
|
757
|
+
reason = "attribute-call inferred class"
|
|
758
|
+
elif len(pool) > 1:
|
|
759
|
+
target_id = None
|
|
760
|
+
confidence = Confidence.AMBIGUOUS
|
|
761
|
+
reason = f"attribute-call ambiguous ({len(pool)} candidates)"
|
|
762
|
+
ambig_candidates = _stable_candidate_ids([i for _p, _q, i in pool])
|
|
763
|
+
|
|
764
|
+
# 0b) Unique *.{name} method when receiver is a variable (not a type name).
|
|
765
|
+
# Prefer same-file, then same-package / imported (same language family).
|
|
766
|
+
if (
|
|
767
|
+
target_id is None
|
|
768
|
+
and not ambig_candidates
|
|
769
|
+
and call.receiver
|
|
770
|
+
and call.receiver not in ("self", "cls", "this")
|
|
771
|
+
and call.name
|
|
772
|
+
):
|
|
773
|
+
caller_fam = _lang_family(path)
|
|
774
|
+
same_file = _method_name_hits(
|
|
775
|
+
by_name, call.name, caller_fam=caller_fam, path_filter={path}
|
|
776
|
+
)
|
|
777
|
+
if len(same_file) == 1:
|
|
778
|
+
target_id = same_file[0][2]
|
|
779
|
+
confidence = Confidence.INFERRED
|
|
780
|
+
reason = "unique same-file method"
|
|
781
|
+
elif len(same_file) > 1:
|
|
782
|
+
confidence = Confidence.AMBIGUOUS
|
|
783
|
+
reason = f"ambiguous same-file method ({len(same_file)} candidates)"
|
|
784
|
+
ambig_candidates = _stable_candidate_ids([i for _p, _q, i in same_file])
|
|
785
|
+
else:
|
|
786
|
+
pkg = _same_package_dir(path)
|
|
787
|
+
imported = imports_of.get(path, set())
|
|
788
|
+
scope = {
|
|
789
|
+
p
|
|
790
|
+
for p, q, _i in by_name.get(call.name, [])
|
|
791
|
+
if _lang_family(p) == caller_fam
|
|
792
|
+
and "." in q
|
|
793
|
+
and q.endswith(f".{call.name}")
|
|
794
|
+
and (_same_package_dir(p) == pkg or p in imported)
|
|
795
|
+
}
|
|
796
|
+
scoped = _method_name_hits(
|
|
797
|
+
by_name, call.name, caller_fam=caller_fam, path_filter=scope
|
|
798
|
+
)
|
|
799
|
+
if len(scoped) == 1:
|
|
800
|
+
target_id = scoped[0][2]
|
|
801
|
+
confidence = Confidence.INFERRED
|
|
802
|
+
reason = "unique package/imported method"
|
|
803
|
+
elif len(scoped) > 1:
|
|
804
|
+
confidence = Confidence.AMBIGUOUS
|
|
805
|
+
reason = (
|
|
806
|
+
f"ambiguous package/imported method ({len(scoped)} candidates)"
|
|
807
|
+
)
|
|
808
|
+
ambig_candidates = _stable_candidate_ids([i for _p, _q, i in scoped])
|
|
809
|
+
|
|
810
|
+
# 1) same-file exact qualname / name (``this`` covers TS/JS methods)
|
|
811
|
+
if call.receiver in ("self", "cls", "this") and call.name:
|
|
812
|
+
# method on same class — find enclosing class
|
|
813
|
+
for start, end, sid in enclosing:
|
|
814
|
+
if start <= call.line <= end and "." in sid:
|
|
815
|
+
cls = sid.split("::", 1)[1].rsplit(".", 1)[0]
|
|
816
|
+
cand = symbol_index.get(f"{path}::{cls}.{call.name}")
|
|
817
|
+
if cand:
|
|
818
|
+
target_id = cand
|
|
819
|
+
confidence = Confidence.EXTRACTED
|
|
820
|
+
reason = "same-file self/cls call"
|
|
821
|
+
break
|
|
822
|
+
if target_id is None and call.qualname_hint:
|
|
823
|
+
same = symbol_index.get(f"{path}::{call.qualname_hint}")
|
|
824
|
+
if same:
|
|
825
|
+
target_id = same
|
|
826
|
+
confidence = Confidence.EXTRACTED
|
|
827
|
+
reason = "same-file qualname"
|
|
828
|
+
elif not call.receiver or call.receiver in ("self", "cls", "this"):
|
|
829
|
+
# Bare-name fallback only when there is no module/class receiver —
|
|
830
|
+
# otherwise ``prompt_handlers.get_prompt`` wrongly binds to a
|
|
831
|
+
# same-file ``get_prompt``.
|
|
832
|
+
same = symbol_index.get(f"{path}::{call.name}")
|
|
833
|
+
if same:
|
|
834
|
+
target_id = same
|
|
835
|
+
confidence = Confidence.EXTRACTED
|
|
836
|
+
reason = "same-file name"
|
|
837
|
+
|
|
838
|
+
# 2) import-resolved: receiver is an imported alias pointing at a module/file
|
|
839
|
+
if target_id is None and call.receiver:
|
|
840
|
+
amap = alias_of.get(path, {})
|
|
841
|
+
imported_files = imports_of.get(path, set())
|
|
842
|
+
# Alias → defining module file (``import prompts as prompt_handlers``)
|
|
843
|
+
alias_target = amap.get(call.receiver, "")
|
|
844
|
+
alias_files: List[str] = []
|
|
845
|
+
if alias_target:
|
|
846
|
+
dotted = alias_target.replace(".", "/")
|
|
847
|
+
for f in sorted(imported_files):
|
|
848
|
+
norm = f[:-3] if f.endswith(".py") else f
|
|
849
|
+
if norm.replace("\\", "/").endswith(dotted) or norm.replace(
|
|
850
|
+
"/", "."
|
|
851
|
+
).endswith(alias_target):
|
|
852
|
+
alias_files.append(f)
|
|
853
|
+
elif Path(f).stem == alias_target.split(".")[-1]:
|
|
854
|
+
alias_files.append(f)
|
|
855
|
+
candidates = [
|
|
856
|
+
(p, q, i)
|
|
857
|
+
for p, q, i in by_name.get(call.name, [])
|
|
858
|
+
if p in (alias_files or imported_files)
|
|
859
|
+
]
|
|
860
|
+
if alias_files:
|
|
861
|
+
narrowed = [
|
|
862
|
+
(p, q, i) for p, q, i in by_name.get(call.name, []) if p in alias_files
|
|
863
|
+
]
|
|
864
|
+
if len(narrowed) == 1:
|
|
865
|
+
target_id = narrowed[0][2]
|
|
866
|
+
confidence = Confidence.EXTRACTED
|
|
867
|
+
reason = "import-alias module attr"
|
|
868
|
+
elif len(narrowed) > 1:
|
|
869
|
+
# Prefer top-level function over methods
|
|
870
|
+
top = [(p, q, i) for p, q, i in narrowed if "." not in q]
|
|
871
|
+
pool = top or narrowed
|
|
872
|
+
if len(pool) == 1:
|
|
873
|
+
target_id = pool[0][2]
|
|
874
|
+
confidence = Confidence.INFERRED
|
|
875
|
+
reason = "import-alias module attr"
|
|
876
|
+
else:
|
|
877
|
+
target_id = None
|
|
878
|
+
confidence = Confidence.AMBIGUOUS
|
|
879
|
+
reason = f"import-alias ambiguous ({len(pool)} candidates)"
|
|
880
|
+
ambig_candidates = _stable_candidate_ids([i for _p, _q, i in pool])
|
|
881
|
+
elif len(candidates) == 1:
|
|
882
|
+
target_id = candidates[0][2]
|
|
883
|
+
confidence = Confidence.INFERRED
|
|
884
|
+
reason = "import-resolved"
|
|
885
|
+
elif call.receiver in amap or call.receiver:
|
|
886
|
+
# Prefer imported file whose stem matches receiver
|
|
887
|
+
recv_files = [
|
|
888
|
+
f
|
|
889
|
+
for f in imported_files
|
|
890
|
+
if Path(f).stem == call.receiver
|
|
891
|
+
or f.replace("/", ".").endswith(call.receiver)
|
|
892
|
+
]
|
|
893
|
+
narrowed = [
|
|
894
|
+
(p, q, i)
|
|
895
|
+
for p, q, i in by_name.get(call.name, [])
|
|
896
|
+
if p in recv_files or (not recv_files and p in imported_files)
|
|
897
|
+
]
|
|
898
|
+
if len(narrowed) == 1:
|
|
899
|
+
target_id = narrowed[0][2]
|
|
900
|
+
confidence = Confidence.INFERRED
|
|
901
|
+
reason = "import-resolved receiver"
|
|
902
|
+
|
|
903
|
+
# 3) unique global (same language family only — never JS→Python etc.)
|
|
904
|
+
if target_id is None:
|
|
905
|
+
# Skip stdlib-looking bare names
|
|
906
|
+
if call.name in getattr(sys, "stdlib_module_names", set()):
|
|
907
|
+
continue
|
|
908
|
+
caller_fam = _lang_family(path)
|
|
909
|
+
cands = [
|
|
910
|
+
(p, q, i)
|
|
911
|
+
for p, q, i in by_name.get(call.name, [])
|
|
912
|
+
if _lang_family(p) == caller_fam
|
|
913
|
+
]
|
|
914
|
+
# Prefer non-method top-level when bare call
|
|
915
|
+
if not call.receiver:
|
|
916
|
+
top = [(p, q, i) for p, q, i in cands if "." not in q]
|
|
917
|
+
if len(top) == 1:
|
|
918
|
+
target_id = top[0][2]
|
|
919
|
+
confidence = Confidence.INFERRED
|
|
920
|
+
reason = "unique global"
|
|
921
|
+
elif len(cands) == 1:
|
|
922
|
+
target_id = cands[0][2]
|
|
923
|
+
confidence = Confidence.INFERRED
|
|
924
|
+
reason = "unique global"
|
|
925
|
+
elif len(cands) > 1:
|
|
926
|
+
target_id = None
|
|
927
|
+
confidence = Confidence.AMBIGUOUS
|
|
928
|
+
reason = f"ambiguous ({len(cands)} candidates)"
|
|
929
|
+
ambig_candidates = _stable_candidate_ids([i for _p, _q, i in cands])
|
|
930
|
+
else:
|
|
931
|
+
if len(cands) == 1:
|
|
932
|
+
target_id = cands[0][2]
|
|
933
|
+
confidence = Confidence.INFERRED
|
|
934
|
+
reason = "unique global"
|
|
935
|
+
elif len(cands) > 1:
|
|
936
|
+
target_id = None
|
|
937
|
+
confidence = Confidence.AMBIGUOUS
|
|
938
|
+
reason = f"ambiguous ({len(cands)} candidates)"
|
|
939
|
+
ambig_candidates = _stable_candidate_ids([i for _p, _q, i in cands])
|
|
940
|
+
|
|
941
|
+
# Ambiguous name → fan-out edges to every candidate so liveness does
|
|
942
|
+
# not drop real callees when the resolver cannot pick a unique target
|
|
943
|
+
# (e.g. ``result.to_dict()`` among many ``to_dict`` methods).
|
|
944
|
+
# Attach capped ``candidates`` once (not on every fan-out edge).
|
|
945
|
+
if target_id is None and ambig_candidates:
|
|
946
|
+
ambig_candidates = _stable_candidate_ids(ambig_candidates)
|
|
947
|
+
cand_extras = _capped_candidate_extras(ambig_candidates)
|
|
948
|
+
attached_candidates = False
|
|
949
|
+
for cand_id in ambig_candidates:
|
|
950
|
+
if (caller_id, cand_id) in seen:
|
|
951
|
+
continue
|
|
952
|
+
seen.add((caller_id, cand_id))
|
|
953
|
+
extras: Dict[str, Any] = {}
|
|
954
|
+
if not attached_candidates:
|
|
955
|
+
extras.update(cand_extras)
|
|
956
|
+
attached_candidates = True
|
|
957
|
+
if class_ids and cand_id in class_ids:
|
|
958
|
+
extras["instantiates"] = True
|
|
959
|
+
edges.append(
|
|
960
|
+
GraphEdge(
|
|
961
|
+
source=caller_id,
|
|
962
|
+
target=cand_id,
|
|
963
|
+
kind="calls",
|
|
964
|
+
confidence=Confidence.AMBIGUOUS,
|
|
965
|
+
reason=reason or f"ambiguous ({len(ambig_candidates)} candidates)",
|
|
966
|
+
extras=extras,
|
|
967
|
+
)
|
|
968
|
+
)
|
|
969
|
+
if unresolved_out is not None:
|
|
970
|
+
unresolved_out.append(
|
|
971
|
+
{
|
|
972
|
+
"source_id": caller_id,
|
|
973
|
+
"path": path,
|
|
974
|
+
"line": call.line,
|
|
975
|
+
"name": call.name,
|
|
976
|
+
"kind": "ambiguous_call",
|
|
977
|
+
"reason": reason,
|
|
978
|
+
"evidence": {
|
|
979
|
+
"candidates": _stable_candidate_ids(ambig_candidates)[
|
|
980
|
+
:AMBIGUOUS_CANDIDATES_CAP
|
|
981
|
+
],
|
|
982
|
+
"receiver": call.receiver,
|
|
983
|
+
},
|
|
984
|
+
}
|
|
985
|
+
)
|
|
986
|
+
continue
|
|
987
|
+
|
|
988
|
+
if target_id and (caller_id, target_id) not in seen:
|
|
989
|
+
seen.add((caller_id, target_id))
|
|
990
|
+
extras = {}
|
|
991
|
+
if confidence == Confidence.AMBIGUOUS and ambig_candidates:
|
|
992
|
+
extras.update(_capped_candidate_extras(ambig_candidates))
|
|
993
|
+
if class_ids and target_id in class_ids:
|
|
994
|
+
extras["instantiates"] = True
|
|
995
|
+
edges.append(
|
|
996
|
+
GraphEdge(
|
|
997
|
+
source=caller_id,
|
|
998
|
+
target=target_id,
|
|
999
|
+
kind="calls",
|
|
1000
|
+
confidence=confidence,
|
|
1001
|
+
reason=reason,
|
|
1002
|
+
extras=extras,
|
|
1003
|
+
)
|
|
1004
|
+
)
|
|
1005
|
+
elif unresolved_out is not None and target_id is None:
|
|
1006
|
+
unresolved_out.append(
|
|
1007
|
+
{
|
|
1008
|
+
"source_id": caller_id,
|
|
1009
|
+
"path": path,
|
|
1010
|
+
"line": call.line,
|
|
1011
|
+
"name": call.name,
|
|
1012
|
+
"kind": "call",
|
|
1013
|
+
"reason": reason,
|
|
1014
|
+
"evidence": {
|
|
1015
|
+
"receiver": call.receiver,
|
|
1016
|
+
"qualname_hint": call.qualname_hint,
|
|
1017
|
+
},
|
|
1018
|
+
}
|
|
1019
|
+
)
|
|
1020
|
+
return edges
|