devcouncil 0.3.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +92 -33
- package/package.json +6 -2
- package/packages/codeintel-grammars/hatch_build.py +43 -0
- package/packages/codeintel-grammars/pyproject.toml +16 -0
- package/packages/codeintel-grammars/src/devcouncil_codeintel_grammars/__init__.py +93 -0
- package/pyproject.toml +99 -4
- package/src/devcouncil/app/config.py +512 -20
- package/src/devcouncil/app/events.py +4 -23
- package/src/devcouncil/app/orchestrator.py +5 -0
- package/src/devcouncil/app/run_context.py +3 -3
- package/src/devcouncil/assets/__init__.py +4 -1
- package/src/devcouncil/assets/vendor/force-graph.min.js +5 -0
- package/src/devcouncil/campaign/__init__.py +71 -0
- package/src/devcouncil/campaign/bloom.py +137 -0
- package/src/devcouncil/campaign/dashboard.py +123 -0
- package/src/devcouncil/campaign/mailbox.py +305 -0
- package/src/devcouncil/campaign/notify.py +91 -0
- package/src/devcouncil/campaign/orchestrator.py +592 -0
- package/src/devcouncil/campaign/prompts/coordinator.md +29 -0
- package/src/devcouncil/campaign/prompts/director.md +21 -0
- package/src/devcouncil/campaign/prompts/protocol.md +46 -0
- package/src/devcouncil/campaign/prompts/reviewer.md +24 -0
- package/src/devcouncil/campaign/prompts/worker.md +24 -0
- package/src/devcouncil/campaign/roles.py +202 -0
- package/src/devcouncil/campaign/watcher.py +153 -0
- package/src/devcouncil/cli/commands/agents.py +24 -17
- package/src/devcouncil/cli/commands/artifacts.py +36 -27
- package/src/devcouncil/cli/commands/ast.py +12 -3
- package/src/devcouncil/cli/commands/baseline.py +21 -12
- package/src/devcouncil/cli/commands/boot.py +218 -0
- package/src/devcouncil/cli/commands/campaign.py +302 -0
- package/src/devcouncil/cli/commands/check.py +225 -12
- package/src/devcouncil/cli/commands/config.py +221 -74
- package/src/devcouncil/cli/commands/cost.py +137 -28
- package/src/devcouncil/cli/commands/dashboard.py +12 -4
- package/src/devcouncil/cli/commands/debug_cmd.py +249 -0
- package/src/devcouncil/cli/commands/design.py +27 -17
- package/src/devcouncil/cli/commands/doctor.py +790 -8
- package/src/devcouncil/cli/commands/evidence.py +41 -20
- package/src/devcouncil/cli/commands/export.py +73 -0
- package/src/devcouncil/cli/commands/gaps.py +175 -0
- package/src/devcouncil/cli/commands/gated_write.py +76 -0
- package/src/devcouncil/cli/commands/go.py +220 -68
- package/src/devcouncil/cli/commands/graph_cmd.py +1192 -0
- package/src/devcouncil/cli/commands/handoff.py +45 -34
- package/src/devcouncil/cli/commands/hook.py +630 -85
- package/src/devcouncil/cli/commands/init.py +89 -30
- package/src/devcouncil/cli/commands/integrate.py +296 -1385
- package/src/devcouncil/cli/commands/lease.py +120 -0
- package/src/devcouncil/cli/commands/logs.py +12 -5
- package/src/devcouncil/cli/commands/lsp.py +40 -5
- package/src/devcouncil/cli/commands/map.py +317 -74
- package/src/devcouncil/cli/commands/mcp_server.py +12 -2
- package/src/devcouncil/cli/commands/okf.py +44 -6
- package/src/devcouncil/cli/commands/plan.py +184 -69
- package/src/devcouncil/cli/commands/prompt.py +26 -17
- package/src/devcouncil/cli/commands/provenance.py +79 -0
- package/src/devcouncil/cli/commands/repair.py +60 -49
- package/src/devcouncil/cli/commands/report.py +148 -40
- package/src/devcouncil/cli/commands/requirements.py +104 -0
- package/src/devcouncil/cli/commands/reset_demo_state.py +13 -4
- package/src/devcouncil/cli/commands/rollback.py +46 -35
- package/src/devcouncil/cli/commands/run.py +173 -8
- package/src/devcouncil/cli/commands/runs.py +298 -68
- package/src/devcouncil/cli/commands/scaffold.py +33 -12
- package/src/devcouncil/cli/commands/semantic.py +29 -14
- package/src/devcouncil/cli/commands/setup.py +103 -93
- package/src/devcouncil/cli/commands/shell.py +51 -42
- package/src/devcouncil/cli/commands/show.py +56 -42
- package/src/devcouncil/cli/commands/skills.py +29 -20
- package/src/devcouncil/cli/commands/status.py +80 -67
- package/src/devcouncil/cli/commands/task_gate.py +295 -0
- package/src/devcouncil/cli/commands/tasks.py +248 -19
- package/src/devcouncil/cli/commands/trace.py +14 -8
- package/src/devcouncil/cli/commands/verify.py +33 -8
- package/src/devcouncil/cli/commands/version.py +14 -6
- package/src/devcouncil/cli/commands/watch.py +56 -40
- package/src/devcouncil/cli/commands/watch_fs.py +30 -19
- package/src/devcouncil/cli/commands/wiki.py +278 -0
- package/src/devcouncil/cli/main.py +58 -1
- package/src/devcouncil/codeintel/__init__.py +16 -0
- package/src/devcouncil/codeintel/build_control.py +429 -0
- package/src/devcouncil/codeintel/build_worker.py +78 -0
- package/src/devcouncil/codeintel/debug/__init__.py +17 -0
- package/src/devcouncil/codeintel/debug/broker.py +114 -0
- package/src/devcouncil/codeintel/debug/broker_client.py +61 -0
- package/src/devcouncil/codeintel/debug/consent.py +36 -0
- package/src/devcouncil/codeintel/debug/discovery.py +132 -0
- package/src/devcouncil/codeintel/debug/fingerprint.py +85 -0
- package/src/devcouncil/codeintel/debug/protocol.py +259 -0
- package/src/devcouncil/codeintel/debug/python_trace_runner.py +81 -0
- package/src/devcouncil/codeintel/debug/session.py +238 -0
- package/src/devcouncil/codeintel/debug/tracing.py +201 -0
- package/src/devcouncil/codeintel/languages/__init__.py +17 -0
- package/src/devcouncil/codeintel/languages/generic_extractor.py +236 -0
- package/src/devcouncil/codeintel/languages/registry.py +149 -0
- package/src/devcouncil/codeintel/languages/workers.py +245 -0
- package/src/devcouncil/codeintel/query/__init__.py +5 -0
- package/src/devcouncil/codeintel/query/engine.py +289 -0
- package/src/devcouncil/codeintel/resolution/__init__.py +6 -0
- package/src/devcouncil/codeintel/resolution/abstract_state.py +301 -0
- package/src/devcouncil/codeintel/resolution/frameworks/__init__.py +33 -0
- package/src/devcouncil/codeintel/resolution/frameworks/base.py +46 -0
- package/src/devcouncil/codeintel/resolution/frameworks/di.py +56 -0
- package/src/devcouncil/codeintel/resolution/frameworks/events.py +45 -0
- package/src/devcouncil/codeintel/resolution/frameworks/routes.py +88 -0
- package/src/devcouncil/codeintel/resolution/semantic.py +887 -0
- package/src/devcouncil/codeintel/service.py +104 -0
- package/src/devcouncil/codeintel/store/__init__.py +15 -0
- package/src/devcouncil/codeintel/store/sqlite.py +1565 -0
- package/src/devcouncil/codeintel/sync/__init__.py +19 -0
- package/src/devcouncil/codeintel/sync/coordinator.py +430 -0
- package/src/devcouncil/codeintel/sync/incremental.py +484 -0
- package/src/devcouncil/codeintel/sync/lease.py +96 -0
- package/src/devcouncil/codeintel/sync/scope.py +98 -0
- package/src/devcouncil/council/__init__.py +4 -0
- package/src/devcouncil/council/prompts/__init__.py +4 -0
- package/src/devcouncil/domain/checkpoint_refs.py +17 -0
- package/src/devcouncil/domain/evidence.py +1 -0
- package/src/devcouncil/domain/gap.py +10 -0
- package/src/devcouncil/domain/requirement.py +5 -1
- package/src/devcouncil/domain/task.py +43 -2
- package/src/devcouncil/execution/checkpoints.py +25 -31
- package/src/devcouncil/execution/context_builder.py +15 -44
- package/src/devcouncil/execution/fs_watcher.py +64 -0
- package/src/devcouncil/execution/gated_write.py +203 -0
- package/src/devcouncil/execution/handoff.py +2 -1
- package/src/devcouncil/execution/hook_policy.py +19 -5
- package/src/devcouncil/execution/lease_ops.py +177 -0
- package/src/devcouncil/execution/lease_validation.py +71 -0
- package/src/devcouncil/execution/patch.py +3 -0
- package/src/devcouncil/execution/permissions.py +1 -0
- package/src/devcouncil/execution/policy_engine.py +205 -10
- package/src/devcouncil/execution/prompt_builder.py +278 -33
- package/src/devcouncil/execution/run_trace.py +356 -0
- package/src/devcouncil/execution/shell_session.py +46 -5
- package/src/devcouncil/execution/stop_gate.py +746 -0
- package/src/devcouncil/execution/stop_gate_history.py +113 -0
- package/src/devcouncil/execution/stop_gate_state.py +54 -0
- package/src/devcouncil/execution/stop_gate_verify_cache.py +69 -0
- package/src/devcouncil/execution/task_gate_ops.py +590 -0
- package/src/devcouncil/execution/task_runner.py +19 -0
- package/src/devcouncil/executors/advisor_tool.py +315 -0
- package/src/devcouncil/executors/agent_registry.py +125 -17
- package/src/devcouncil/executors/claude_sdk.py +376 -0
- package/src/devcouncil/executors/coding_cli.py +724 -25
- package/src/devcouncil/executors/mini_swe.py +50 -8
- package/src/devcouncil/executors/native/agent.py +224 -19
- package/src/devcouncil/executors/openhands.py +50 -8
- package/src/devcouncil/executors/transient_retry.py +99 -0
- package/src/devcouncil/gating/checks/clean_git.py +5 -2
- package/src/devcouncil/gating/checks/planned_files_check.py +38 -11
- package/src/devcouncil/gating/checks/secret_scan_check.py +2 -2
- package/src/devcouncil/gating/policy.py +46 -2
- package/src/devcouncil/indexing/ast_matcher.py +41 -4
- package/src/devcouncil/indexing/graph/__init__.py +78 -0
- package/src/devcouncil/indexing/graph/api_routes.py +522 -0
- package/src/devcouncil/indexing/graph/build.py +862 -0
- package/src/devcouncil/indexing/graph/cache.py +329 -0
- package/src/devcouncil/indexing/graph/communities.py +28 -0
- package/src/devcouncil/indexing/graph/cypher.py +107 -0
- package/src/devcouncil/indexing/graph/embeddings.py +194 -0
- package/src/devcouncil/indexing/graph/export.py +381 -0
- package/src/devcouncil/indexing/graph/export_links.py +81 -0
- package/src/devcouncil/indexing/graph/extract_python.py +307 -0
- package/src/devcouncil/indexing/graph/extract_ts.py +1205 -0
- package/src/devcouncil/indexing/graph/intel.py +668 -0
- package/src/devcouncil/indexing/graph/liveness.py +992 -0
- package/src/devcouncil/indexing/graph/okf_export.py +65 -0
- package/src/devcouncil/indexing/graph/pdg/__init__.py +67 -0
- package/src/devcouncil/indexing/graph/pdg/build.py +11 -0
- package/src/devcouncil/indexing/graph/pdg/cdg.py +41 -0
- package/src/devcouncil/indexing/graph/pdg/cfg.py +199 -0
- package/src/devcouncil/indexing/graph/pdg/query.py +21 -0
- package/src/devcouncil/indexing/graph/pdg/reaching_def.py +126 -0
- package/src/devcouncil/indexing/graph/pdg/schema.py +253 -0
- package/src/devcouncil/indexing/graph/pdg/taint.py +154 -0
- package/src/devcouncil/indexing/graph/query.py +302 -0
- package/src/devcouncil/indexing/graph/resolve.py +1020 -0
- package/src/devcouncil/indexing/graph/schema.py +103 -0
- package/src/devcouncil/indexing/graph_index.py +20 -29
- package/src/devcouncil/indexing/lsp.py +57 -25
- package/src/devcouncil/indexing/lsp_client.py +577 -0
- package/src/devcouncil/indexing/map_artifacts.py +355 -0
- package/src/devcouncil/indexing/map_refresh.py +141 -0
- package/src/devcouncil/indexing/repo_mapper.py +1509 -138
- package/src/devcouncil/indexing/semantic_index.py +12 -6
- package/src/devcouncil/indexing/subsystem_map.py +163 -0
- package/src/devcouncil/indexing/ts_imports.py +343 -0
- package/src/devcouncil/indexing/viz.py +964 -0
- package/src/devcouncil/indexing/walk.py +52 -0
- package/src/devcouncil/indexing/wiring.py +1776 -0
- package/src/devcouncil/integrations/actions.py +27 -4
- package/src/devcouncil/integrations/check.py +211 -16
- package/src/devcouncil/integrations/claude_assets.py +209 -12
- package/src/devcouncil/integrations/clients/__init__.py +1 -0
- package/src/devcouncil/integrations/clients/aider.py +52 -0
- package/src/devcouncil/integrations/clients/antigravity.py +87 -0
- package/src/devcouncil/integrations/clients/claude.py +339 -0
- package/src/devcouncil/integrations/clients/codex.py +39 -0
- package/src/devcouncil/integrations/clients/common.py +332 -0
- package/src/devcouncil/integrations/clients/cursor.py +164 -0
- package/src/devcouncil/integrations/clients/gemini.py +49 -0
- package/src/devcouncil/integrations/clients/grok.py +105 -0
- package/src/devcouncil/integrations/clients/hooks.py +500 -0
- package/src/devcouncil/integrations/clients/opencode.py +96 -0
- package/src/devcouncil/integrations/clients/warp.py +75 -0
- package/src/devcouncil/integrations/code_review_graph.py +2 -2
- package/src/devcouncil/integrations/github.py +73 -7
- package/src/devcouncil/integrations/integration_cli.py +197 -0
- package/src/devcouncil/integrations/mcp/handlers/__init__.py +1 -0
- package/src/devcouncil/integrations/mcp/handlers/ast_lsp.py +77 -0
- package/src/devcouncil/integrations/mcp/handlers/checkout.py +50 -0
- package/src/devcouncil/integrations/mcp/handlers/cli_gate.py +43 -0
- package/src/devcouncil/integrations/mcp/handlers/codeintel.py +182 -0
- package/src/devcouncil/integrations/mcp/handlers/debug.py +236 -0
- package/src/devcouncil/integrations/mcp/handlers/evidence.py +70 -0
- package/src/devcouncil/integrations/mcp/handlers/git.py +281 -0
- package/src/devcouncil/integrations/mcp/handlers/graph.py +34 -0
- package/src/devcouncil/integrations/mcp/handlers/handoff.py +53 -0
- package/src/devcouncil/integrations/mcp/handlers/knowledge.py +28 -0
- package/src/devcouncil/integrations/mcp/handlers/lease.py +70 -0
- package/src/devcouncil/integrations/mcp/handlers/live.py +108 -0
- package/src/devcouncil/integrations/mcp/handlers/map.py +676 -0
- package/src/devcouncil/integrations/mcp/handlers/next_task.py +35 -0
- package/src/devcouncil/integrations/mcp/handlers/policy.py +80 -0
- package/src/devcouncil/integrations/mcp/handlers/prompts.py +168 -0
- package/src/devcouncil/integrations/mcp/handlers/provenance.py +87 -0
- package/src/devcouncil/integrations/mcp/handlers/read.py +103 -0
- package/src/devcouncil/integrations/mcp/handlers/router_cache.py +53 -0
- package/src/devcouncil/integrations/mcp/handlers/run.py +53 -0
- package/src/devcouncil/integrations/mcp/handlers/runs.py +69 -0
- package/src/devcouncil/integrations/mcp/handlers/scope.py +56 -0
- package/src/devcouncil/integrations/mcp/handlers/status.py +199 -0
- package/src/devcouncil/integrations/mcp/handlers/task.py +88 -0
- package/src/devcouncil/integrations/mcp/handlers/tool_specs.py +922 -0
- package/src/devcouncil/integrations/mcp/handlers/trace.py +65 -0
- package/src/devcouncil/integrations/mcp/handlers/verify.py +45 -0
- package/src/devcouncil/integrations/mcp/handlers/wiki.py +53 -0
- package/src/devcouncil/integrations/mcp/handlers/write.py +69 -0
- package/src/devcouncil/integrations/mcp/server.py +265 -2391
- package/src/devcouncil/integrations/mcp/util.py +325 -0
- package/src/devcouncil/integrations/setup.py +152 -0
- package/src/devcouncil/knowledge/fetch.py +4 -0
- package/src/devcouncil/knowledge/knowledge_select.py +38 -0
- package/src/devcouncil/knowledge/okf.py +2 -1
- package/src/devcouncil/knowledge/resource_discovery.py +40 -0
- package/src/devcouncil/knowledge/wiki.py +643 -0
- package/src/devcouncil/knowledge/wiki_read.py +87 -0
- package/src/devcouncil/live/cards.py +7 -7
- package/src/devcouncil/live/models.py +4 -1
- package/src/devcouncil/live/reviewer.py +90 -11
- package/src/devcouncil/live/signals.py +4 -2
- package/src/devcouncil/live/summary.py +21 -3
- package/src/devcouncil/live/tasks.py +12 -3
- package/src/devcouncil/live/transcripts.py +69 -2
- package/src/devcouncil/llm/cache.py +5 -6
- package/src/devcouncil/llm/model_defaults.yaml +10 -10
- package/src/devcouncil/llm/provider.py +647 -73
- package/src/devcouncil/llm/router.py +271 -46
- package/src/devcouncil/llm/semantic_bridge.py +614 -0
- package/src/devcouncil/optimization/gepa_agent.py +6 -4
- package/src/devcouncil/optimization/skillopt.py +9 -5
- package/src/devcouncil/planning/arbiter_service.py +12 -3
- package/src/devcouncil/planning/correction_manifest.py +107 -10
- package/src/devcouncil/planning/plan_difficulty.py +69 -0
- package/src/devcouncil/planning/plan_service.py +5 -2
- package/src/devcouncil/planning/planned_files_reconcile.py +191 -0
- package/src/devcouncil/planning/prompt_enhancer_service.py +6 -5
- package/src/devcouncil/planning/question_conversion.py +56 -0
- package/src/devcouncil/planning/spec_service.py +9 -3
- package/src/devcouncil/repo/ci_scaffold.py +197 -1
- package/src/devcouncil/repo/gitignore.py +1 -2
- package/src/devcouncil/reporting/evidence_export.py +124 -0
- package/src/devcouncil/reporting/evidence_html.py +210 -0
- package/src/devcouncil/reporting/json_report.py +16 -12
- package/src/devcouncil/reporting/markdown_report.py +38 -9
- package/src/devcouncil/reporting/mcp_resources.py +142 -0
- package/src/devcouncil/reporting/report_builder.py +40 -4
- package/src/devcouncil/reporting/task_provenance.py +42 -0
- package/src/devcouncil/reporting/verdict.py +75 -0
- package/src/devcouncil/skills/library/README.md +1 -0
- package/src/devcouncil/skills/library/devcouncil-hero-loop.md +109 -0
- package/src/devcouncil/skills/library/devcouncil-verification.md +109 -0
- package/src/devcouncil/skills/library/devcouncil.md +93 -0
- package/src/devcouncil/skills/registry.py +43 -12
- package/src/devcouncil/storage/db.py +57 -11
- package/src/devcouncil/storage/models.py +6 -0
- package/src/devcouncil/storage/native.py +5 -3
- package/src/devcouncil/storage/repositories.py +50 -18
- package/src/devcouncil/telemetry/context.py +28 -0
- package/src/devcouncil/telemetry/cost.py +4 -5
- package/src/devcouncil/telemetry/logging_setup.py +78 -11
- package/src/devcouncil/telemetry/model_pricing.yaml +7 -0
- package/src/devcouncil/telemetry/stages.py +27 -2
- package/src/devcouncil/telemetry/tracker.py +50 -13
- package/src/devcouncil/ui/dashboard.py +120 -8
- package/src/devcouncil/utils/fsio.py +58 -0
- package/src/devcouncil/utils/git_snapshot.py +112 -0
- package/src/devcouncil/utils/json_persist.py +53 -0
- package/src/devcouncil/utils/proc.py +89 -0
- package/src/devcouncil/verification/acceptance_compiler.py +36 -13
- package/src/devcouncil/verification/ad_hoc_check.py +95 -3
- package/src/devcouncil/verification/checks/__init__.py +41 -0
- package/src/devcouncil/verification/checks/acceptance.py +39 -0
- package/src/devcouncil/verification/checks/acceptance_corpus.py +194 -0
- package/src/devcouncil/verification/checks/acceptance_evidence.py +239 -0
- package/src/devcouncil/verification/checks/command_evidence.py +148 -0
- package/src/devcouncil/verification/checks/compiled_acceptance.py +179 -0
- package/src/devcouncil/verification/checks/corpus_stale.py +124 -0
- package/src/devcouncil/verification/checks/corpus_verification.py +9 -0
- package/src/devcouncil/verification/checks/dead_symbols.py +360 -0
- package/src/devcouncil/verification/checks/diff_coverage_gate.py +101 -0
- package/src/devcouncil/verification/checks/doc_code_ref.py +79 -0
- package/src/devcouncil/verification/checks/liveness_ratchet.py +336 -0
- package/src/devcouncil/verification/checks/orphan_diff.py +104 -0
- package/src/devcouncil/verification/checks/planned_files.py +98 -0
- package/src/devcouncil/verification/checks/semantic_diff.py +241 -0
- package/src/devcouncil/verification/checks/stale_map.py +80 -0
- package/src/devcouncil/verification/checks/stub_scan.py +71 -0
- package/src/devcouncil/verification/checks/subsystem_boundary.py +103 -0
- package/src/devcouncil/verification/checks/wiring.py +216 -0
- package/src/devcouncil/verification/claims/__init__.py +23 -0
- package/src/devcouncil/verification/claims/checks.py +395 -0
- package/src/devcouncil/verification/claims/mapper.py +168 -0
- package/src/devcouncil/verification/claims/models.py +39 -0
- package/src/devcouncil/verification/claims/transcript.py +92 -0
- package/src/devcouncil/verification/claims/verdict.py +88 -0
- package/src/devcouncil/verification/command_evidence.py +170 -0
- package/src/devcouncil/verification/command_malformation.py +147 -0
- package/src/devcouncil/verification/command_runner.py +164 -0
- package/src/devcouncil/verification/coverage_measurement.py +292 -0
- package/src/devcouncil/verification/diff_coverage.py +151 -0
- package/src/devcouncil/verification/difficulty.py +296 -0
- package/src/devcouncil/verification/effort_heuristics.py +178 -0
- package/src/devcouncil/verification/gap_ids.py +63 -0
- package/src/devcouncil/verification/gate_cache.py +194 -0
- package/src/devcouncil/verification/gate_selector.py +344 -0
- package/src/devcouncil/verification/git_diff_fallback.py +272 -0
- package/src/devcouncil/verification/implementation_reviewer.py +13 -0
- package/src/devcouncil/verification/incremental_check.py +241 -0
- package/src/devcouncil/verification/next_actions.py +60 -1
- package/src/devcouncil/verification/rigor_analytics.py +130 -0
- package/src/devcouncil/verification/sandbox.py +38 -11
- package/src/devcouncil/verification/stub_detector.py +369 -0
- package/src/devcouncil/verification/test_resolver.py +67 -1
- package/src/devcouncil/verification/verifier.py +137 -1666
- package/src/devcouncil/verification/verify_orchestration.py +610 -0
- package/src/devcouncil/verification/verify_setup.py +176 -0
- package/src/devcouncil/verification/wiki_refresh.py +208 -0
- package/src/semantic_layer/__init__.py +58 -0
- package/src/semantic_layer/benchmark.py +75 -0
- package/src/semantic_layer/cache.py +290 -0
- package/src/semantic_layer/compressor.py +137 -0
- package/src/semantic_layer/config.py +75 -0
- package/src/semantic_layer/embeddings.py +69 -0
- package/src/semantic_layer/llm_backends.py +99 -0
- package/src/semantic_layer/pipeline.py +111 -0
- package/src/semantic_layer/router.py +128 -0
- package/src/semantic_layer/tuner.py +72 -0
- package/uv.lock +973 -9
- package/src/devcouncil/artifacts/migrations.py +0 -20
- package/src/devcouncil/artifacts/schemas.py +0 -23
- package/src/devcouncil/artifacts/serializer.py +0 -21
- package/src/devcouncil/integrations/gitnexus.py +0 -70
- package/src/devcouncil/integrations/graphify.py +0 -34
|
@@ -0,0 +1,992 @@
|
|
|
1
|
+
"""Graph-based file + symbol liveness / dead-code detection.
|
|
2
|
+
|
|
3
|
+
devcouncil: allow-unwired — package-private; reached only via graph.build / CLI.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import re
|
|
10
|
+
from collections import defaultdict
|
|
11
|
+
from dataclasses import asdict
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, cast
|
|
14
|
+
|
|
15
|
+
from devcouncil.indexing.graph.extract_python import (
|
|
16
|
+
ExtractedCall,
|
|
17
|
+
ExtractedImport,
|
|
18
|
+
ExtractedSymbol,
|
|
19
|
+
FileExtraction,
|
|
20
|
+
)
|
|
21
|
+
from devcouncil.indexing.graph.schema import (
|
|
22
|
+
Confidence,
|
|
23
|
+
DeadCodeEntry,
|
|
24
|
+
GraphEdge,
|
|
25
|
+
GraphNode,
|
|
26
|
+
NodeKind,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
# React class lifecycle methods — framework-invoked; no inbound call edges.
|
|
32
|
+
_REACT_LIFECYCLE_METHODS = frozenset(
|
|
33
|
+
{
|
|
34
|
+
"render",
|
|
35
|
+
"componentDidMount",
|
|
36
|
+
"componentDidUpdate",
|
|
37
|
+
"componentWillUnmount",
|
|
38
|
+
"shouldComponentUpdate",
|
|
39
|
+
"getSnapshotBeforeUpdate",
|
|
40
|
+
"componentDidCatch",
|
|
41
|
+
"getDerivedStateFromProps",
|
|
42
|
+
"getDerivedStateFromError",
|
|
43
|
+
"UNSAFE_componentWillMount",
|
|
44
|
+
"UNSAFE_componentWillReceiveProps",
|
|
45
|
+
"UNSAFE_componentWillUpdate",
|
|
46
|
+
"componentWillMount",
|
|
47
|
+
"componentWillReceiveProps",
|
|
48
|
+
"componentWillUpdate",
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
_CONFIDENCE_RANK = {
|
|
53
|
+
Confidence.AMBIGUOUS: 0,
|
|
54
|
+
Confidence.INFERRED: 1,
|
|
55
|
+
Confidence.EXTRACTED: 2,
|
|
56
|
+
"ambiguous": 0,
|
|
57
|
+
"inferred": 1,
|
|
58
|
+
"extracted": 2,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
# Default: if unreachable covers ≥25% of liveness code files, static BFS is
|
|
62
|
+
# too blind (dynamic imports / routers / JSX) — omit the flood.
|
|
63
|
+
_DEFAULT_UNREACHABLE_UNRELIABLE_RATIO = 0.25
|
|
64
|
+
# Ratio alone misreads small repos: 1 dead file out of 3 is real signal, not
|
|
65
|
+
# analysis blindness. Only gate when the absolute flood is at least this big.
|
|
66
|
+
_MIN_UNREACHABLE_FOR_RATIO_GATE = 5
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _unreachable_unreliable_ratio(root: Path) -> float:
|
|
70
|
+
try:
|
|
71
|
+
from devcouncil.app.config import load_config
|
|
72
|
+
|
|
73
|
+
return float(load_config(root).indexing.unreachable_unreliable_ratio)
|
|
74
|
+
except Exception:
|
|
75
|
+
return _DEFAULT_UNREACHABLE_UNRELIABLE_RATIO
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _apply_unreachable_ratio_gate(
|
|
79
|
+
root: Path,
|
|
80
|
+
files: List[str],
|
|
81
|
+
unreachable: List[str],
|
|
82
|
+
unreliable: bool,
|
|
83
|
+
) -> Tuple[List[str], bool, dict]:
|
|
84
|
+
"""Fail-soft when unreachable density indicates analysis blind spots."""
|
|
85
|
+
from devcouncil.indexing.wiring import is_liveness_code_file
|
|
86
|
+
|
|
87
|
+
code_files = sum(1 for f in files if is_liveness_code_file(f))
|
|
88
|
+
ratio = (len(unreachable) / code_files) if code_files else 0.0
|
|
89
|
+
threshold = _unreachable_unreliable_ratio(root)
|
|
90
|
+
meta: Dict[str, Any] = {
|
|
91
|
+
"unreachable_total": len(unreachable),
|
|
92
|
+
"liveness_code_files": code_files,
|
|
93
|
+
"unreachable_ratio": round(ratio, 4),
|
|
94
|
+
"unreachable_ratio_threshold": threshold,
|
|
95
|
+
}
|
|
96
|
+
if unreliable:
|
|
97
|
+
meta["unreachable_unreliable_reason"] = "empty_production_entry_roots"
|
|
98
|
+
return [], True, meta
|
|
99
|
+
if (
|
|
100
|
+
threshold > 0
|
|
101
|
+
and code_files > 0
|
|
102
|
+
and len(unreachable) >= _MIN_UNREACHABLE_FOR_RATIO_GATE
|
|
103
|
+
and ratio >= threshold
|
|
104
|
+
):
|
|
105
|
+
meta["unreachable_unreliable_reason"] = "high_unreachable_ratio"
|
|
106
|
+
return [], True, meta
|
|
107
|
+
return unreachable, False, meta
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def file_liveness(
|
|
111
|
+
root: Path,
|
|
112
|
+
files: List[str],
|
|
113
|
+
file_edges: List[Tuple[str, str]],
|
|
114
|
+
*,
|
|
115
|
+
cap: Optional[int] = None,
|
|
116
|
+
) -> Tuple[List[str], List[str], List[str], bool]:
|
|
117
|
+
"""Return (entry_roots, unwired, unreachable, unreachable_unreliable).
|
|
118
|
+
|
|
119
|
+
``cap`` defaults to uncapped (``None`` / ``<= 0``). Caps belong on
|
|
120
|
+
``repo_map.json`` serialization, not the in-memory / ``code_graph.json`` lists.
|
|
121
|
+
|
|
122
|
+
When production entry roots are empty, unreachable BFS would flood every
|
|
123
|
+
non-root file. Fail soft: ``unreachable=[]`` and
|
|
124
|
+
``unreachable_unreliable=True`` (still compute unwired).
|
|
125
|
+
|
|
126
|
+
When unreachable density exceeds ``indexing.unreachable_unreliable_ratio``
|
|
127
|
+
(default 0.25), also fail soft — treat the list as low-confidence noise.
|
|
128
|
+
"""
|
|
129
|
+
from devcouncil.indexing.wiring import (
|
|
130
|
+
build_dynamic_import_index,
|
|
131
|
+
entry_roots,
|
|
132
|
+
has_allow_unwired,
|
|
133
|
+
is_liveness_code_file,
|
|
134
|
+
is_test_path,
|
|
135
|
+
reference_cleared,
|
|
136
|
+
structural_exemptions,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
limit = None if (cap is None or cap <= 0) else cap
|
|
140
|
+
roots = entry_roots(root, files)
|
|
141
|
+
prod_roots = entry_roots(root, files, production_only=True)
|
|
142
|
+
root_set = set(roots)
|
|
143
|
+
prod_root_set = set(prod_roots)
|
|
144
|
+
dyn_index = build_dynamic_import_index(root, files)
|
|
145
|
+
unreachable_unreliable = not bool(prod_roots)
|
|
146
|
+
|
|
147
|
+
inbound: Dict[str, Set[str]] = defaultdict(set)
|
|
148
|
+
outbound: Dict[str, Set[str]] = defaultdict(set)
|
|
149
|
+
for importer, imported in file_edges:
|
|
150
|
+
inbound[imported].add(importer)
|
|
151
|
+
outbound[importer].add(imported)
|
|
152
|
+
|
|
153
|
+
unwired: List[str] = []
|
|
154
|
+
for f in sorted(files):
|
|
155
|
+
if not is_liveness_code_file(f):
|
|
156
|
+
continue
|
|
157
|
+
if f in root_set or structural_exemptions(f):
|
|
158
|
+
continue
|
|
159
|
+
if has_allow_unwired(root, f):
|
|
160
|
+
continue
|
|
161
|
+
non_test_importers = {i for i in inbound.get(f, ()) if not is_test_path(i)}
|
|
162
|
+
if non_test_importers:
|
|
163
|
+
continue
|
|
164
|
+
if reference_cleared(
|
|
165
|
+
root, f, skip_files=set(), git_files=files, dynamic_index=dyn_index
|
|
166
|
+
):
|
|
167
|
+
continue
|
|
168
|
+
unwired.append(f)
|
|
169
|
+
if limit is not None and len(unwired) >= limit:
|
|
170
|
+
break
|
|
171
|
+
|
|
172
|
+
unreachable: List[str] = []
|
|
173
|
+
if not unreachable_unreliable:
|
|
174
|
+
reachable: Set[str] = set()
|
|
175
|
+
queue = list(prod_roots)
|
|
176
|
+
seen_q: Set[str] = set(queue)
|
|
177
|
+
while queue:
|
|
178
|
+
cur = queue.pop()
|
|
179
|
+
reachable.add(cur)
|
|
180
|
+
for nxt in outbound.get(cur, ()):
|
|
181
|
+
if nxt not in seen_q:
|
|
182
|
+
seen_q.add(nxt)
|
|
183
|
+
queue.append(nxt)
|
|
184
|
+
|
|
185
|
+
for f in sorted(files):
|
|
186
|
+
if not is_liveness_code_file(f):
|
|
187
|
+
continue
|
|
188
|
+
if f in prod_root_set or f in root_set or structural_exemptions(f):
|
|
189
|
+
continue
|
|
190
|
+
if f in reachable:
|
|
191
|
+
continue
|
|
192
|
+
# Dynamic entrypoints / markers clear unwired; keep unreachable in parity.
|
|
193
|
+
if has_allow_unwired(root, f):
|
|
194
|
+
continue
|
|
195
|
+
if reference_cleared(
|
|
196
|
+
root, f, skip_files=set(), git_files=files, dynamic_index=dyn_index
|
|
197
|
+
):
|
|
198
|
+
continue
|
|
199
|
+
unreachable.append(f)
|
|
200
|
+
if limit is not None and len(unreachable) >= limit:
|
|
201
|
+
break
|
|
202
|
+
|
|
203
|
+
unreachable, unreachable_unreliable, _ratio_meta = _apply_unreachable_ratio_gate(
|
|
204
|
+
root, files, unreachable, unreachable_unreliable
|
|
205
|
+
)
|
|
206
|
+
return prod_roots, unwired, unreachable, unreachable_unreliable
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _token_scan_dead(
|
|
210
|
+
root: Path,
|
|
211
|
+
files: List[str],
|
|
212
|
+
*,
|
|
213
|
+
cap: int = 0,
|
|
214
|
+
lsp_refs: bool = False,
|
|
215
|
+
mapper: Optional[object] = None,
|
|
216
|
+
) -> Tuple[List[str], List[str], Set[str]]:
|
|
217
|
+
"""Legacy token-scan dead symbols via RepoMapper (format path:line name).
|
|
218
|
+
|
|
219
|
+
Returns (dead_list, symbol_index, dead_keys as path::name).
|
|
220
|
+
"""
|
|
221
|
+
from devcouncil.indexing.repo_mapper import RepoMapper
|
|
222
|
+
|
|
223
|
+
m = cast(RepoMapper, mapper) if mapper is not None else RepoMapper(root)
|
|
224
|
+
dead, index = m._dead_symbol_candidates(
|
|
225
|
+
files, cap=cap if cap > 0 else 0, with_index=True, lsp_refs=lsp_refs
|
|
226
|
+
)
|
|
227
|
+
keys: Set[str] = set()
|
|
228
|
+
for entry in dead:
|
|
229
|
+
# "path:line name"
|
|
230
|
+
loc, _, name = entry.partition(" ")
|
|
231
|
+
path, _, _line = loc.rpartition(":")
|
|
232
|
+
if path and name:
|
|
233
|
+
keys.add(f"{path}::{name}")
|
|
234
|
+
return dead, index, keys
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _reference_index(
|
|
238
|
+
extractions: Dict[str, FileExtraction],
|
|
239
|
+
) -> Dict[str, Set[str]]:
|
|
240
|
+
"""name -> set of files that reference it outside a call site.
|
|
241
|
+
|
|
242
|
+
Covers callbacks passed by name (``register(handler)``), registry/dict
|
|
243
|
+
dispatch, and attribute access on ``@property``-style members — including
|
|
244
|
+
references from tests (so property-only APIs are not inferred-dead).
|
|
245
|
+
"""
|
|
246
|
+
index: Dict[str, Set[str]] = defaultdict(set)
|
|
247
|
+
for path, ext in extractions.items():
|
|
248
|
+
for name in getattr(ext, "references", None) or ():
|
|
249
|
+
index[name].add(path)
|
|
250
|
+
return index
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def build_liveness_shard(root: Path, extraction: FileExtraction) -> dict[str, object]:
|
|
254
|
+
"""Compact persisted reference shard used by one-file liveness updates."""
|
|
255
|
+
from devcouncil.indexing.wiring import (
|
|
256
|
+
dynamic_import_keys,
|
|
257
|
+
strip_js_comments,
|
|
258
|
+
strip_py_comments,
|
|
259
|
+
strip_string_literals,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
source = (root / extraction.path).read_text(encoding="utf-8", errors="replace")
|
|
264
|
+
except OSError:
|
|
265
|
+
source = ""
|
|
266
|
+
cleaned = (
|
|
267
|
+
strip_py_comments(source)
|
|
268
|
+
if Path(extraction.path).suffix.lower() == ".py"
|
|
269
|
+
else strip_js_comments(source)
|
|
270
|
+
)
|
|
271
|
+
cleaned = strip_string_literals(cleaned)
|
|
272
|
+
token_lines: dict[str, list[int]] = defaultdict(list)
|
|
273
|
+
for lineno, line in enumerate(cleaned.splitlines(), 1):
|
|
274
|
+
for token in re.findall(r"\b[A-Za-z_][A-Za-z0-9_]*\b", line):
|
|
275
|
+
if len(token) >= 2:
|
|
276
|
+
token_lines[token].append(lineno)
|
|
277
|
+
return {
|
|
278
|
+
"extraction": asdict(extraction),
|
|
279
|
+
"token_lines": dict(token_lines),
|
|
280
|
+
"dynamic_import_keys": sorted(dynamic_import_keys(extraction.path, source)),
|
|
281
|
+
"allow_unwired": "devcouncil: allow-unwired" in source,
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def dynamic_import_index_from_shards(
|
|
286
|
+
shards: dict[str, dict[str, object]],
|
|
287
|
+
) -> dict[str, Set[str]]:
|
|
288
|
+
"""Rebuild the dynamic-reference index without rescanning repository files."""
|
|
289
|
+
from devcouncil.indexing.wiring import is_test_path
|
|
290
|
+
|
|
291
|
+
index: dict[str, Set[str]] = defaultdict(set)
|
|
292
|
+
for path, shard in shards.items():
|
|
293
|
+
if is_test_path(path):
|
|
294
|
+
continue
|
|
295
|
+
raw_keys = shard.get("dynamic_import_keys")
|
|
296
|
+
keys = raw_keys if isinstance(raw_keys, list) else []
|
|
297
|
+
for key in keys:
|
|
298
|
+
if isinstance(key, str) and key:
|
|
299
|
+
index[key].add(path)
|
|
300
|
+
return dict(index)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def extraction_from_liveness_shard(shard: dict[str, object]) -> FileExtraction:
|
|
304
|
+
extraction = shard.get("extraction")
|
|
305
|
+
raw = dict(extraction) if isinstance(extraction, dict) else {}
|
|
306
|
+
return FileExtraction(
|
|
307
|
+
path=str(raw.get("path") or ""),
|
|
308
|
+
language=str(raw.get("language") or ""),
|
|
309
|
+
imports=list(raw.get("imports") or []),
|
|
310
|
+
import_details=[ExtractedImport(**row) for row in raw.get("import_details") or []],
|
|
311
|
+
symbols=[ExtractedSymbol(**row) for row in raw.get("symbols") or []],
|
|
312
|
+
calls=[ExtractedCall(**row) for row in raw.get("calls") or []],
|
|
313
|
+
all_exports=list(raw.get("all_exports") or []),
|
|
314
|
+
reexports=list(raw.get("reexports") or []),
|
|
315
|
+
references=list(raw.get("references") or []),
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def token_dead_from_shards(
|
|
320
|
+
nodes: List[GraphNode],
|
|
321
|
+
shards: dict[str, dict[str, object]],
|
|
322
|
+
) -> Tuple[List[str], List[str], Set[str]]:
|
|
323
|
+
"""Compute token agreement from persisted per-file token occurrence shards."""
|
|
324
|
+
from devcouncil.indexing.wiring import (
|
|
325
|
+
is_private_symbol,
|
|
326
|
+
is_test_path,
|
|
327
|
+
is_vendored_path,
|
|
328
|
+
is_wiring_decorated,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
token_files: Dict[str, Set[str]] = defaultdict(set)
|
|
332
|
+
token_lines: Dict[str, Dict[str, Set[int]]] = defaultdict(lambda: defaultdict(set))
|
|
333
|
+
for path, shard in shards.items():
|
|
334
|
+
raw_token_lines = shard.get("token_lines")
|
|
335
|
+
rows = raw_token_lines if isinstance(raw_token_lines, dict) else {}
|
|
336
|
+
for name, lines in rows.items():
|
|
337
|
+
token_files[str(name)].add(path)
|
|
338
|
+
if isinstance(lines, list):
|
|
339
|
+
token_lines[str(name)][path].update(int(line) for line in lines)
|
|
340
|
+
# Read all_exports from the raw shard payload: rebuilding a FileExtraction
|
|
341
|
+
# here would instantiate every symbol/call dataclass in the repo per sync.
|
|
342
|
+
protected_by_path: Dict[str, Set[str]] = {}
|
|
343
|
+
for path, shard in shards.items():
|
|
344
|
+
raw_extraction = shard.get("extraction")
|
|
345
|
+
exports = (
|
|
346
|
+
raw_extraction.get("all_exports") if isinstance(raw_extraction, dict) else None
|
|
347
|
+
)
|
|
348
|
+
protected_by_path[path] = set(exports or [])
|
|
349
|
+
definitions: List[GraphNode] = []
|
|
350
|
+
for node in nodes:
|
|
351
|
+
suffix = Path(node.path).suffix.lower()
|
|
352
|
+
if node.kind not in {NodeKind.FUNCTION, NodeKind.CLASS}:
|
|
353
|
+
continue
|
|
354
|
+
if suffix not in {".py", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"}:
|
|
355
|
+
continue
|
|
356
|
+
if is_test_path(node.path) or is_vendored_path(node.path):
|
|
357
|
+
continue
|
|
358
|
+
if "." in str(node.extras.get("qualname") or node.name):
|
|
359
|
+
continue
|
|
360
|
+
if is_private_symbol(node.name):
|
|
361
|
+
continue
|
|
362
|
+
if node.name in protected_by_path.get(node.path, set()):
|
|
363
|
+
continue
|
|
364
|
+
if is_wiring_decorated(list(node.extras.get("decorators") or [])):
|
|
365
|
+
continue
|
|
366
|
+
if suffix != ".py" and not node.exported:
|
|
367
|
+
continue
|
|
368
|
+
definitions.append(node)
|
|
369
|
+
dead: List[str] = []
|
|
370
|
+
keys: Set[str] = set()
|
|
371
|
+
for node in definitions:
|
|
372
|
+
refs = token_files.get(node.name, set())
|
|
373
|
+
same_lines = token_lines.get(node.name, {}).get(node.path, set())
|
|
374
|
+
if refs - {node.path} or any(
|
|
375
|
+
line < node.line or line > node.end_line for line in same_lines
|
|
376
|
+
):
|
|
377
|
+
continue
|
|
378
|
+
dead.append(f"{node.path}:{node.line} {node.name}")
|
|
379
|
+
keys.add(f"{node.path}::{node.name}")
|
|
380
|
+
return dead, sorted(f"{node.path}::{node.name}" for node in definitions), keys
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def project_call_edges_to_files(edges: Iterable[Any]) -> Set[Tuple[str, str]]:
|
|
384
|
+
"""Confidently-resolved cross-file call edges collapsed to file pairs.
|
|
385
|
+
|
|
386
|
+
Import edges alone miss languages where files call across the same package
|
|
387
|
+
without importing (Go); union these pairs into file-level liveness edges so
|
|
388
|
+
a called file counts as wired. Ambiguous resolutions are excluded — an
|
|
389
|
+
uncertain call must not mark a file live. Shared by the full build and the
|
|
390
|
+
incremental sync so the two paths cannot diverge.
|
|
391
|
+
"""
|
|
392
|
+
out: Set[Tuple[str, str]] = set()
|
|
393
|
+
for e in edges:
|
|
394
|
+
if getattr(e, "kind", None) != "calls":
|
|
395
|
+
continue
|
|
396
|
+
if getattr(e.confidence, "value", e.confidence) == "ambiguous":
|
|
397
|
+
continue
|
|
398
|
+
src_file = e.source.split("::", 1)[0]
|
|
399
|
+
dst_file = e.target.split("::", 1)[0]
|
|
400
|
+
if src_file != dst_file:
|
|
401
|
+
out.add((src_file, dst_file))
|
|
402
|
+
return out
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def file_liveness_from_shards(
|
|
406
|
+
files: List[str],
|
|
407
|
+
file_edges: List[Tuple[str, str]],
|
|
408
|
+
shards: dict[str, dict[str, object]],
|
|
409
|
+
*,
|
|
410
|
+
root: Path,
|
|
411
|
+
entry_roots: List[str],
|
|
412
|
+
production_entry_roots: List[str],
|
|
413
|
+
dynamic_index: Optional[dict[str, Set[str]]] = None,
|
|
414
|
+
) -> Tuple[List[str], List[str], List[str], bool]:
|
|
415
|
+
"""Recompute file reachability from persisted adjacency without source scans."""
|
|
416
|
+
from devcouncil.indexing.wiring import (
|
|
417
|
+
is_liveness_code_file,
|
|
418
|
+
is_test_path,
|
|
419
|
+
reference_cleared,
|
|
420
|
+
structural_exemptions,
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
file_set = set(files)
|
|
424
|
+
roots = [path for path in entry_roots if path in file_set]
|
|
425
|
+
production_roots = [path for path in production_entry_roots if path in file_set]
|
|
426
|
+
unreliable = not bool(production_roots)
|
|
427
|
+
root_set = set(roots)
|
|
428
|
+
production_root_set = set(production_roots)
|
|
429
|
+
effective_dynamic_index = (
|
|
430
|
+
dynamic_import_index_from_shards(shards)
|
|
431
|
+
if dynamic_index is None
|
|
432
|
+
else dynamic_index
|
|
433
|
+
)
|
|
434
|
+
inbound: Dict[str, Set[str]] = defaultdict(set)
|
|
435
|
+
outbound: Dict[str, Set[str]] = defaultdict(set)
|
|
436
|
+
for importer, imported in file_edges:
|
|
437
|
+
inbound[imported].add(importer)
|
|
438
|
+
outbound[importer].add(imported)
|
|
439
|
+
unwired = [
|
|
440
|
+
path for path in sorted(files)
|
|
441
|
+
if is_liveness_code_file(path)
|
|
442
|
+
and path not in root_set
|
|
443
|
+
and not structural_exemptions(path)
|
|
444
|
+
and not bool(shards.get(path, {}).get("allow_unwired"))
|
|
445
|
+
and not {item for item in inbound.get(path, ()) if not is_test_path(item)}
|
|
446
|
+
and not reference_cleared(root, path, dynamic_index=effective_dynamic_index)
|
|
447
|
+
]
|
|
448
|
+
reachable: Set[str] = set()
|
|
449
|
+
queue = list(production_roots)
|
|
450
|
+
while queue:
|
|
451
|
+
path = queue.pop()
|
|
452
|
+
if path in reachable:
|
|
453
|
+
continue
|
|
454
|
+
reachable.add(path)
|
|
455
|
+
queue.extend(outbound.get(path, ()))
|
|
456
|
+
unreachable = [] if unreliable else [
|
|
457
|
+
path for path in sorted(files)
|
|
458
|
+
if is_liveness_code_file(path)
|
|
459
|
+
and path not in root_set
|
|
460
|
+
and path not in production_root_set
|
|
461
|
+
and not structural_exemptions(path)
|
|
462
|
+
and path not in reachable
|
|
463
|
+
and not bool(shards.get(path, {}).get("allow_unwired"))
|
|
464
|
+
and not reference_cleared(root, path, dynamic_index=effective_dynamic_index)
|
|
465
|
+
]
|
|
466
|
+
unreachable, unreliable, _ratio_meta = _apply_unreachable_ratio_gate(
|
|
467
|
+
root, files, unreachable, unreliable
|
|
468
|
+
)
|
|
469
|
+
return production_roots, unwired, unreachable, unreliable
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _resolve_reexport_targets(
|
|
473
|
+
extractions: Dict[str, FileExtraction],
|
|
474
|
+
file_edges: List[Tuple[str, str]],
|
|
475
|
+
) -> Set[Tuple[str, str]]:
|
|
476
|
+
"""Return ``(defining_path, name)`` pairs protected by barrel / ``__all__`` re-exports."""
|
|
477
|
+
imports_of: Dict[str, Set[str]] = defaultdict(set)
|
|
478
|
+
for a, b in file_edges:
|
|
479
|
+
imports_of[a].add(b)
|
|
480
|
+
|
|
481
|
+
# Top-level symbol names per file
|
|
482
|
+
names_in: Dict[str, Set[str]] = defaultdict(set)
|
|
483
|
+
for path, ext in extractions.items():
|
|
484
|
+
for sym in ext.symbols:
|
|
485
|
+
if "." not in sym.qualname and sym.kind != "rationale":
|
|
486
|
+
names_in[path].add(sym.name)
|
|
487
|
+
|
|
488
|
+
protected: Set[Tuple[str, str]] = set()
|
|
489
|
+
for path, ext in extractions.items():
|
|
490
|
+
for name in ext.all_exports:
|
|
491
|
+
protected.add((path, name))
|
|
492
|
+
|
|
493
|
+
reexport_names = set(ext.reexports)
|
|
494
|
+
if not reexport_names:
|
|
495
|
+
continue
|
|
496
|
+
imported_files = imports_of.get(path, set())
|
|
497
|
+
for detail in ext.import_details:
|
|
498
|
+
for name in detail.names:
|
|
499
|
+
bare = name.split(".")[-1]
|
|
500
|
+
if not bare or bare == "*":
|
|
501
|
+
continue
|
|
502
|
+
# Match either the imported name or local alias if present in reexports
|
|
503
|
+
locals_for = [
|
|
504
|
+
loc for loc, remote in detail.alias_map.items() if remote.endswith(bare) or remote == name
|
|
505
|
+
]
|
|
506
|
+
if bare not in reexport_names and not (set(locals_for) & reexport_names):
|
|
507
|
+
# Also accept when reexport list uses the imported bare name
|
|
508
|
+
if name not in reexport_names:
|
|
509
|
+
continue
|
|
510
|
+
for fpath in imported_files:
|
|
511
|
+
if bare in names_in.get(fpath, ()):
|
|
512
|
+
protected.add((fpath, bare))
|
|
513
|
+
break
|
|
514
|
+
else:
|
|
515
|
+
# Fallback: unique defining file among imports
|
|
516
|
+
hits = [f for f in imported_files if bare in names_in.get(f, ())]
|
|
517
|
+
if len(hits) == 1:
|
|
518
|
+
protected.add((hits[0], bare))
|
|
519
|
+
return protected
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
# Framework targets are live only through a reachable registration owner.
|
|
523
|
+
_FRAMEWORK_LIVENESS_EDGE_KINDS = frozenset({
|
|
524
|
+
"registers",
|
|
525
|
+
"routes_to",
|
|
526
|
+
"listens",
|
|
527
|
+
"provides",
|
|
528
|
+
"reflects_to",
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _live_seeds(
|
|
533
|
+
root: Path,
|
|
534
|
+
files: List[str],
|
|
535
|
+
nodes: List[GraphNode],
|
|
536
|
+
edges: List[GraphEdge],
|
|
537
|
+
extractions: Dict[str, FileExtraction],
|
|
538
|
+
protected: Set[Tuple[str, str]],
|
|
539
|
+
file_edges: Optional[List[Tuple[str, str]]] = None,
|
|
540
|
+
) -> Set[str]:
|
|
541
|
+
"""Entry / export / wiring / test-referenced symbols seed the live fixed-point."""
|
|
542
|
+
from devcouncil.indexing.wiring import (
|
|
543
|
+
entry_point_symbols,
|
|
544
|
+
is_test_path,
|
|
545
|
+
is_wiring_decorated,
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
live: Set[str] = set()
|
|
549
|
+
entry_syms = entry_point_symbols(root, files)
|
|
550
|
+
node_by_id = {n.id: n for n in nodes}
|
|
551
|
+
|
|
552
|
+
imports_of: Dict[str, Set[str]] = defaultdict(set)
|
|
553
|
+
if file_edges:
|
|
554
|
+
for a, b in file_edges:
|
|
555
|
+
imports_of[a].add(b)
|
|
556
|
+
|
|
557
|
+
for node in nodes:
|
|
558
|
+
if node.kind not in {
|
|
559
|
+
NodeKind.FUNCTION,
|
|
560
|
+
NodeKind.CLASS,
|
|
561
|
+
NodeKind.METHOD,
|
|
562
|
+
NodeKind.INTERFACE,
|
|
563
|
+
NodeKind.TYPE,
|
|
564
|
+
NodeKind.STRUCT,
|
|
565
|
+
NodeKind.ENUM,
|
|
566
|
+
NodeKind.TRAIT,
|
|
567
|
+
}:
|
|
568
|
+
continue
|
|
569
|
+
key = f"{node.path}::{node.name}"
|
|
570
|
+
qual = (node.extras or {}).get("qualname") or node.name
|
|
571
|
+
if key in entry_syms or f"{node.path}::{qual}" in entry_syms:
|
|
572
|
+
live.add(node.id)
|
|
573
|
+
continue
|
|
574
|
+
if (node.path, node.name) in protected:
|
|
575
|
+
live.add(node.id)
|
|
576
|
+
continue
|
|
577
|
+
# JS/TS explicit export surface (not Python's default-public exported flag)
|
|
578
|
+
lang = (node.language or "").lower()
|
|
579
|
+
if node.exported and lang in {"javascript", "typescript", "js", "ts", "tsx", "jsx"}:
|
|
580
|
+
live.add(node.id)
|
|
581
|
+
continue
|
|
582
|
+
# Python: only __all__/reexport protection above — do not seed all public names
|
|
583
|
+
if path_is_init_all_member(extractions, node):
|
|
584
|
+
live.add(node.id)
|
|
585
|
+
continue
|
|
586
|
+
decs = list((node.extras or {}).get("decorators") or [])
|
|
587
|
+
if is_wiring_decorated(decs):
|
|
588
|
+
live.add(node.id)
|
|
589
|
+
continue
|
|
590
|
+
# Test-referenced: inbound call/named-import from a test file seeds live
|
|
591
|
+
for e in edges:
|
|
592
|
+
if e.kind not in {"calls", "imports"}:
|
|
593
|
+
continue
|
|
594
|
+
src_path = e.source.split("::", 1)[0]
|
|
595
|
+
if not is_test_path(src_path):
|
|
596
|
+
continue
|
|
597
|
+
if e.target in node_by_id:
|
|
598
|
+
live.add(e.target)
|
|
599
|
+
|
|
600
|
+
# Same-file + imported-module name references (``pool.submit(self._run_one)``,
|
|
601
|
+
# ``card.blocks_completion`` property access, registry dispatch). Includes
|
|
602
|
+
# private methods so callback callees stay in the live fixed-point.
|
|
603
|
+
from devcouncil.indexing.graph.schema import symbol_node_id
|
|
604
|
+
|
|
605
|
+
by_name: Dict[str, List[GraphNode]] = defaultdict(list)
|
|
606
|
+
for node in nodes:
|
|
607
|
+
if node.kind in {
|
|
608
|
+
NodeKind.FUNCTION,
|
|
609
|
+
NodeKind.CLASS,
|
|
610
|
+
NodeKind.METHOD,
|
|
611
|
+
NodeKind.INTERFACE,
|
|
612
|
+
NodeKind.TYPE,
|
|
613
|
+
NodeKind.STRUCT,
|
|
614
|
+
NodeKind.ENUM,
|
|
615
|
+
NodeKind.TRAIT,
|
|
616
|
+
}:
|
|
617
|
+
by_name[node.name].append(node)
|
|
618
|
+
|
|
619
|
+
for path, ext in extractions.items():
|
|
620
|
+
ref_names = set(getattr(ext, "references", None) or ())
|
|
621
|
+
if not ref_names:
|
|
622
|
+
continue
|
|
623
|
+
imported = set(imports_of.get(path, ())) | {path}
|
|
624
|
+
for name in ref_names:
|
|
625
|
+
hits = [n for n in by_name.get(name, ()) if n.path in imported]
|
|
626
|
+
if len(hits) == 1:
|
|
627
|
+
live.add(hits[0].id)
|
|
628
|
+
continue
|
|
629
|
+
if len(hits) > 1:
|
|
630
|
+
same = [n for n in hits if n.path == path]
|
|
631
|
+
if len(same) == 1:
|
|
632
|
+
live.add(same[0].id)
|
|
633
|
+
continue
|
|
634
|
+
# Same-file symbol by qualname even when name is globally ambiguous
|
|
635
|
+
for sym in ext.symbols:
|
|
636
|
+
if sym.name == name:
|
|
637
|
+
live.add(symbol_node_id(path, sym.qualname))
|
|
638
|
+
|
|
639
|
+
return live
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def path_is_init_all_member(
|
|
643
|
+
extractions: Dict[str, FileExtraction],
|
|
644
|
+
node: GraphNode,
|
|
645
|
+
) -> bool:
|
|
646
|
+
"""True when ``node.name`` is listed in the defining file's ``__all__``."""
|
|
647
|
+
ext = extractions.get(node.path)
|
|
648
|
+
if ext is None:
|
|
649
|
+
return False
|
|
650
|
+
return node.name in set(ext.all_exports)
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def symbol_reachability_dead(
|
|
654
|
+
root: Path,
|
|
655
|
+
files: List[str],
|
|
656
|
+
nodes: List[GraphNode],
|
|
657
|
+
edges: List[GraphEdge],
|
|
658
|
+
extractions: Dict[str, FileExtraction],
|
|
659
|
+
entry_roots: List[str],
|
|
660
|
+
*,
|
|
661
|
+
token_dead_keys: Optional[Set[str]] = None,
|
|
662
|
+
file_edges: Optional[List[Tuple[str, str]]] = None,
|
|
663
|
+
unreachable: Optional[List[str]] = None,
|
|
664
|
+
dynamic_index: Optional[dict[str, Set[str]]] = None,
|
|
665
|
+
) -> List[DeadCodeEntry]:
|
|
666
|
+
"""Symbol-level dead code with fixed-point live propagation and confidence tiers.
|
|
667
|
+
|
|
668
|
+
A symbol is live only if it is a seed (entry / exported surface / wiring /
|
|
669
|
+
test-referenced / getattr) or has an inbound call/named-import from a live
|
|
670
|
+
source. Cascade members whose only callers are dead get
|
|
671
|
+
``reason="only callers are dead"`` at ``inferred`` confidence.
|
|
672
|
+
"""
|
|
673
|
+
from devcouncil.indexing.wiring import (
|
|
674
|
+
GETATTR_INDEX_PREFIX,
|
|
675
|
+
build_dynamic_import_index,
|
|
676
|
+
has_allow_unwired,
|
|
677
|
+
is_dunder_symbol,
|
|
678
|
+
is_private_symbol,
|
|
679
|
+
is_test_path,
|
|
680
|
+
is_wiring_decorated,
|
|
681
|
+
structural_exemptions,
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
# Reconstruct file edges from graph imports when not provided
|
|
685
|
+
if file_edges is None:
|
|
686
|
+
file_edges = [
|
|
687
|
+
(e.source, e.target)
|
|
688
|
+
for e in edges
|
|
689
|
+
if e.kind == "imports" and "::" not in e.source and "::" not in e.target
|
|
690
|
+
]
|
|
691
|
+
|
|
692
|
+
protected = _resolve_reexport_targets(extractions, file_edges)
|
|
693
|
+
ref_index = _reference_index(extractions)
|
|
694
|
+
dyn_index = (
|
|
695
|
+
build_dynamic_import_index(root, files)
|
|
696
|
+
if dynamic_index is None
|
|
697
|
+
else dynamic_index
|
|
698
|
+
)
|
|
699
|
+
getattr_names = {
|
|
700
|
+
k[len(GETATTR_INDEX_PREFIX) :]
|
|
701
|
+
for k in dyn_index
|
|
702
|
+
if k.startswith(GETATTR_INDEX_PREFIX)
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
imported_by: Dict[str, Set[str]] = defaultdict(set)
|
|
706
|
+
for a, b in file_edges:
|
|
707
|
+
imported_by[b].add(a)
|
|
708
|
+
|
|
709
|
+
# Inbound call / named-import sources per symbol (liveness-relevant edges)
|
|
710
|
+
inbound_live_edges: Dict[str, List[GraphEdge]] = defaultdict(list)
|
|
711
|
+
overrides_of: Dict[str, List[str]] = defaultdict(list) # child -> parent method ids
|
|
712
|
+
for e in edges:
|
|
713
|
+
if e.kind in {"calls", "imports"} | _FRAMEWORK_LIVENESS_EDGE_KINDS:
|
|
714
|
+
if e.kind in _FRAMEWORK_LIVENESS_EDGE_KINDS:
|
|
715
|
+
confidence = (
|
|
716
|
+
e.confidence.value
|
|
717
|
+
if hasattr(e.confidence, "value")
|
|
718
|
+
else str(e.confidence)
|
|
719
|
+
)
|
|
720
|
+
if confidence == Confidence.AMBIGUOUS.value:
|
|
721
|
+
continue
|
|
722
|
+
# Ignore file→file imports for symbol liveness; named imports target symbols
|
|
723
|
+
if e.kind == "imports" and "::" not in e.target:
|
|
724
|
+
continue
|
|
725
|
+
# Ignore contains/defines-style noise: already filtered by kind
|
|
726
|
+
inbound_live_edges[e.target].append(e)
|
|
727
|
+
elif e.kind == "overrides":
|
|
728
|
+
overrides_of[e.source].append(e.target)
|
|
729
|
+
|
|
730
|
+
live = _live_seeds(
|
|
731
|
+
root, files, nodes, edges, extractions, protected, file_edges=file_edges
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
# getattr(x, "name") in non-test files seeds the named symbol live
|
|
735
|
+
_seed_kinds = {
|
|
736
|
+
NodeKind.FUNCTION,
|
|
737
|
+
NodeKind.CLASS,
|
|
738
|
+
NodeKind.METHOD,
|
|
739
|
+
NodeKind.INTERFACE,
|
|
740
|
+
NodeKind.TYPE,
|
|
741
|
+
NodeKind.STRUCT,
|
|
742
|
+
NodeKind.ENUM,
|
|
743
|
+
NodeKind.TRAIT,
|
|
744
|
+
}
|
|
745
|
+
for node in nodes:
|
|
746
|
+
if node.kind in _seed_kinds and node.name in getattr_names:
|
|
747
|
+
live.add(node.id)
|
|
748
|
+
|
|
749
|
+
# Fixed-point: live if inbound from live source, or overrides a live base method.
|
|
750
|
+
unreachable_set = {p.replace("\\", "/") for p in (unreachable or [])}
|
|
751
|
+
entry_root_set = {p.replace("\\", "/") for p in entry_roots}
|
|
752
|
+
|
|
753
|
+
def _file_source_is_live(path: str) -> bool:
|
|
754
|
+
"""Module-level calls execute on import/run when the file is reachable."""
|
|
755
|
+
norm = path.replace("\\", "/")
|
|
756
|
+
if norm in entry_root_set or structural_exemptions(norm):
|
|
757
|
+
return True
|
|
758
|
+
return norm not in unreachable_set
|
|
759
|
+
|
|
760
|
+
def _source_is_live(src: str) -> bool:
|
|
761
|
+
if src in live:
|
|
762
|
+
return True
|
|
763
|
+
if "::" not in src:
|
|
764
|
+
if _file_source_is_live(src):
|
|
765
|
+
return True
|
|
766
|
+
return any(n.startswith(f"{src}::") for n in live)
|
|
767
|
+
return False
|
|
768
|
+
|
|
769
|
+
changed = True
|
|
770
|
+
while changed:
|
|
771
|
+
changed = False
|
|
772
|
+
for node in nodes:
|
|
773
|
+
alias = str(node.extras.get("identity_alias") or "")
|
|
774
|
+
if not alias:
|
|
775
|
+
continue
|
|
776
|
+
if node.id in live and alias not in live:
|
|
777
|
+
live.add(alias)
|
|
778
|
+
changed = True
|
|
779
|
+
elif alias in live and node.id not in live:
|
|
780
|
+
live.add(node.id)
|
|
781
|
+
changed = True
|
|
782
|
+
for target, srcs in inbound_live_edges.items():
|
|
783
|
+
if target in live:
|
|
784
|
+
continue
|
|
785
|
+
if any(_source_is_live(e.source) for e in srcs):
|
|
786
|
+
live.add(target)
|
|
787
|
+
changed = True
|
|
788
|
+
for child, parents in overrides_of.items():
|
|
789
|
+
if child in live:
|
|
790
|
+
continue
|
|
791
|
+
if any(p in live for p in parents):
|
|
792
|
+
live.add(child)
|
|
793
|
+
changed = True
|
|
794
|
+
|
|
795
|
+
token_dead_keys = token_dead_keys or set()
|
|
796
|
+
dead: List[DeadCodeEntry] = []
|
|
797
|
+
|
|
798
|
+
for node in nodes:
|
|
799
|
+
if node.kind not in {NodeKind.FUNCTION, NodeKind.CLASS, NodeKind.METHOD}:
|
|
800
|
+
continue
|
|
801
|
+
if is_test_path(node.path):
|
|
802
|
+
continue
|
|
803
|
+
if structural_exemptions(node.path):
|
|
804
|
+
continue
|
|
805
|
+
if is_private_symbol(node.name) or is_dunder_symbol(node.name):
|
|
806
|
+
continue
|
|
807
|
+
if has_allow_unwired(root, node.path):
|
|
808
|
+
continue
|
|
809
|
+
if node.id in live:
|
|
810
|
+
continue
|
|
811
|
+
decs = list((node.extras or {}).get("decorators") or [])
|
|
812
|
+
if is_wiring_decorated(decs):
|
|
813
|
+
continue
|
|
814
|
+
if (node.path, node.name) in protected:
|
|
815
|
+
continue
|
|
816
|
+
|
|
817
|
+
key = f"{node.path}::{node.name}"
|
|
818
|
+
qual_key = f"{node.path}::{(node.extras or {}).get('qualname') or node.name}"
|
|
819
|
+
in_token = key in token_dead_keys or qual_key in token_dead_keys
|
|
820
|
+
# Non-call references scoped to defining file + its importers (not global
|
|
821
|
+
# bare-name collisions in unrelated modules).
|
|
822
|
+
ref_files = ref_index.get(node.name, set())
|
|
823
|
+
scoped_refs = {node.path} | imported_by.get(node.path, set())
|
|
824
|
+
referenced_by_name = bool(ref_files & scoped_refs)
|
|
825
|
+
|
|
826
|
+
# Relevant inbound call/named-import edges (exclude self-recursive).
|
|
827
|
+
# Module-level file→symbol calls count when the source file is live.
|
|
828
|
+
relevant = [
|
|
829
|
+
e
|
|
830
|
+
for e in inbound_live_edges.get(node.id, [])
|
|
831
|
+
if e.source != node.id
|
|
832
|
+
]
|
|
833
|
+
has_any_caller = bool(relevant)
|
|
834
|
+
only_dead_callers = has_any_caller and all(
|
|
835
|
+
not _source_is_live(e.source) for e in relevant
|
|
836
|
+
)
|
|
837
|
+
|
|
838
|
+
if node.kind == NodeKind.METHOD:
|
|
839
|
+
if is_dunder_symbol(node.name):
|
|
840
|
+
continue
|
|
841
|
+
if node.name in _REACT_LIFECYCLE_METHODS:
|
|
842
|
+
continue
|
|
843
|
+
if referenced_by_name:
|
|
844
|
+
dead.append(
|
|
845
|
+
DeadCodeEntry(
|
|
846
|
+
id=node.id,
|
|
847
|
+
path=node.path,
|
|
848
|
+
line=node.line,
|
|
849
|
+
kind="method",
|
|
850
|
+
confidence=Confidence.AMBIGUOUS,
|
|
851
|
+
reason="referenced by name only (possible callback/property)",
|
|
852
|
+
)
|
|
853
|
+
)
|
|
854
|
+
continue
|
|
855
|
+
if only_dead_callers:
|
|
856
|
+
# Reachable file + "dead" callers usually means incomplete call
|
|
857
|
+
# edges (JSX/React), not a real cascade from a dead root.
|
|
858
|
+
file_reachable = node.path.replace("\\", "/") not in unreachable_set
|
|
859
|
+
dead.append(
|
|
860
|
+
DeadCodeEntry(
|
|
861
|
+
id=node.id,
|
|
862
|
+
path=node.path,
|
|
863
|
+
line=node.line,
|
|
864
|
+
kind="method",
|
|
865
|
+
confidence=(
|
|
866
|
+
Confidence.AMBIGUOUS if file_reachable else Confidence.INFERRED
|
|
867
|
+
),
|
|
868
|
+
reason=(
|
|
869
|
+
"only callers look dead (reachable file; possible incomplete call graph)"
|
|
870
|
+
if file_reachable
|
|
871
|
+
else "only callers are dead"
|
|
872
|
+
),
|
|
873
|
+
)
|
|
874
|
+
)
|
|
875
|
+
continue
|
|
876
|
+
dead.append(
|
|
877
|
+
DeadCodeEntry(
|
|
878
|
+
id=node.id,
|
|
879
|
+
path=node.path,
|
|
880
|
+
line=node.line,
|
|
881
|
+
kind="method",
|
|
882
|
+
confidence=Confidence.INFERRED,
|
|
883
|
+
reason="no inbound call edges (method)",
|
|
884
|
+
)
|
|
885
|
+
)
|
|
886
|
+
continue
|
|
887
|
+
|
|
888
|
+
# Top-level function/class
|
|
889
|
+
if only_dead_callers:
|
|
890
|
+
file_reachable = node.path.replace("\\", "/") not in unreachable_set
|
|
891
|
+
dead.append(
|
|
892
|
+
DeadCodeEntry(
|
|
893
|
+
id=node.id,
|
|
894
|
+
path=node.path,
|
|
895
|
+
line=node.line,
|
|
896
|
+
kind=node.kind.value,
|
|
897
|
+
confidence=(
|
|
898
|
+
Confidence.AMBIGUOUS if file_reachable else Confidence.INFERRED
|
|
899
|
+
),
|
|
900
|
+
reason=(
|
|
901
|
+
"only callers look dead (reachable file; possible incomplete call graph)"
|
|
902
|
+
if file_reachable
|
|
903
|
+
else "only callers are dead"
|
|
904
|
+
),
|
|
905
|
+
)
|
|
906
|
+
)
|
|
907
|
+
continue
|
|
908
|
+
|
|
909
|
+
graph_dead = not has_any_caller
|
|
910
|
+
if not graph_dead:
|
|
911
|
+
# Has live-or-entry callers — should have been marked live; skip
|
|
912
|
+
continue
|
|
913
|
+
|
|
914
|
+
if graph_dead and in_token:
|
|
915
|
+
dead.append(
|
|
916
|
+
DeadCodeEntry(
|
|
917
|
+
id=node.id,
|
|
918
|
+
path=node.path,
|
|
919
|
+
line=node.line,
|
|
920
|
+
kind=node.kind.value,
|
|
921
|
+
confidence=(
|
|
922
|
+
Confidence.AMBIGUOUS if referenced_by_name else Confidence.EXTRACTED
|
|
923
|
+
),
|
|
924
|
+
reason=(
|
|
925
|
+
"referenced by name only (possible callback)"
|
|
926
|
+
if referenced_by_name
|
|
927
|
+
else "no inbound calls and token-scan agrees"
|
|
928
|
+
),
|
|
929
|
+
)
|
|
930
|
+
)
|
|
931
|
+
elif graph_dead and not in_token:
|
|
932
|
+
dead.append(
|
|
933
|
+
DeadCodeEntry(
|
|
934
|
+
id=node.id,
|
|
935
|
+
path=node.path,
|
|
936
|
+
line=node.line,
|
|
937
|
+
kind=node.kind.value,
|
|
938
|
+
confidence=Confidence.AMBIGUOUS,
|
|
939
|
+
reason="graph-dead but token-scan cleared (possible name collision)",
|
|
940
|
+
)
|
|
941
|
+
)
|
|
942
|
+
|
|
943
|
+
return dead
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def legacy_dead_strings(
|
|
947
|
+
dead_code: List[DeadCodeEntry],
|
|
948
|
+
token_dead: List[str],
|
|
949
|
+
*,
|
|
950
|
+
cap: Optional[int] = None,
|
|
951
|
+
) -> List[str]:
|
|
952
|
+
"""repo_map.json format: intersection of extracted-confidence dead + token list.
|
|
953
|
+
|
|
954
|
+
Preserves ``path:line name`` strings for verify-gate / ratchet compatibility.
|
|
955
|
+
Methods (inferred) are excluded from the legacy list.
|
|
956
|
+
``cap`` defaults to uncapped; apply caps only when serializing ``repo_map.json``.
|
|
957
|
+
|
|
958
|
+
When the graph has no extracted-tier dead symbols, return an empty list rather
|
|
959
|
+
than flooding agents with token-only false positives.
|
|
960
|
+
"""
|
|
961
|
+
extracted_paths_names = {
|
|
962
|
+
(d.path, d.id.split("::")[-1].split(".")[-1])
|
|
963
|
+
for d in dead_code
|
|
964
|
+
if d.confidence == Confidence.EXTRACTED
|
|
965
|
+
}
|
|
966
|
+
if not extracted_paths_names:
|
|
967
|
+
return []
|
|
968
|
+
out: List[str] = []
|
|
969
|
+
for entry in token_dead:
|
|
970
|
+
loc, _, name = entry.partition(" ")
|
|
971
|
+
path, _, _line = loc.rpartition(":")
|
|
972
|
+
if (path, name) in extracted_paths_names:
|
|
973
|
+
out.append(entry)
|
|
974
|
+
if cap is not None and cap > 0:
|
|
975
|
+
return out[:cap]
|
|
976
|
+
return out
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def confidence_at_least(conf: object, minimum: str) -> bool:
|
|
980
|
+
"""True when ``conf`` ranks at or above ``minimum`` (extracted > inferred > ambiguous)."""
|
|
981
|
+
want = _CONFIDENCE_RANK.get(minimum, 0)
|
|
982
|
+
if hasattr(conf, "value"):
|
|
983
|
+
conf = conf.value
|
|
984
|
+
have = _CONFIDENCE_RANK.get(str(conf), 0)
|
|
985
|
+
return have >= want
|
|
986
|
+
|
|
987
|
+
def apply_liveness_cap(items: list, cap: int | None) -> tuple[list, dict]:
|
|
988
|
+
total = len(items)
|
|
989
|
+
if cap is None or cap <= 0 or total <= cap:
|
|
990
|
+
return list(items), {"total": total, "shown": total, "truncated": 0}
|
|
991
|
+
shown = list(items)[:cap]
|
|
992
|
+
return shown, {"total": total, "shown": len(shown), "truncated": total - len(shown)}
|